Files
socialmag/platforms/threads.py
T
juxtapoandClaude Opus 5 5419b08264 v0.4: background reads, JS-state login signals, blind sleeps dropped
get_or_open_tab(activate=True) — added, not replaced. Read paths pass
activate=False; post flows keep the foreground for dialogs and file inputs.

check_login moved off DOM signals onto session state: fb c_user, ig/threads
ds_user_id, twitter twid, yt ytcfg.LOGGED_IN. Three platforms were reporting
OUT on a signed-in account because their composer never mounts in a hidden
tab. The fixed sleep(4)/sleep(5) before each check goes with them — every
signal reads present the moment navigate() returns.

Unreadable cookies raise instead of returning False: "cannot tell" is not
"signed out". tiktok untouched and still activated — it reported
visibilityState "visible" from a sibling-activated tab, so it has never been
read from a genuine background.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:58:25 +08:00

190 lines
7.5 KiB
Python

"""Threads posting and notifications flow (2026-09 inline composer).
post — post via inline composer
check_login — verify signed in
get_notifications — scrape the Activity page
"""
import asyncio
from ..cdp import human_delay, CDPError
from . import guard, require_text, validate_media
from ..selectors import THREADS as S
PLATFORM = "threads"
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_text(PLATFORM, text)
validate_media(media_path)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
return await guard(PLATFORM, tab, _flow(tab, text, media_path))
async def _flow(tab, text, media_path):
# Wait for the inline composer to appear
await _wait_for_composer(tab)
await human_delay()
# Click the composer input area
await tab.click_js(S["composer_trigger_js"])
await human_delay(0.5, 1.0)
# Type into whichever input is now focused — could be a contenteditable
# or a textbox that appeared after clicking
textbox = await _find_active_input(tab)
await tab.type_text(textbox, text)
if media_path:
if not await tab.exists(S["file_input"]):
# Look for an attach media button near the composer
await human_delay()
if await tab.exists(S["file_input"]):
await tab.set_file_input(S["file_input"], media_path)
await human_delay(1.5, 3.0)
await human_delay(0.5, 1.0)
await tab.click_js(S["post_button_js"])
# Success = the composer returns to its empty placeholder state
await _wait_for_post_complete(tab)
async def _wait_for_composer(tab, timeout=20.0):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
has_input = await tab.eval(
"!!(document.querySelector('[placeholder=\"What\\'s new?\"]')"
" || [...document.querySelectorAll('span,div')]"
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
)
if has_input:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("threads: composer ('What's new?') not found after 20s")
async def _find_active_input(tab):
"""After clicking the composer, find the actual text input element."""
# Check for contenteditable first (Threads often uses these)
for sel in ('[contenteditable="true"]', '[role="textbox"]', 'textarea',
'[placeholder="What\'s new?"]'):
if await tab.exists(sel):
return sel
from ..cdp import CDPError
raise CDPError("threads: no text input found after clicking composer")
async def _wait_for_post_complete(tab, timeout=30.0):
"""Wait for the post to go through — composer empties or a toast appears."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
# Check if the composer is back to empty placeholder state
empty = await tab.eval(
"(() => {"
" const ce = document.querySelector('[contenteditable=\"true\"]');"
" if (ce && ce.innerText.trim() === '') return true;"
" const tb = document.querySelector('[role=\"textbox\"]');"
" if (tb && tb.innerText.trim() === '') return true;"
" return false;"
"})()"
)
if empty:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("threads: composer still has text 30s after clicking Post")
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com", activate=False)
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
if await tab.exists(S["login_marker"]):
return False
# JS state, not the "What's new?" composer: that composer never mounts in a
# backgrounded tab, so a signed-in account read as signed out (measured
# 2026-09-12 — DOM signal False while the session cookie was right there).
# Threads runs on the Instagram session, hence ds_user_id.
state = await tab.eval(
"(() => { const m = document.cookie.match(/(?:^|;\\s*)ds_user_id=(\\d+)/);"
" return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()"
)
if state.startswith("in:"):
return True
if state == "out":
return False
raise CDPError("threads: no cookies readable on threads.com — cannot tell signed-in from signed-out")
async def get_notifications(cdp_client, count=10):
"""Scrape the Threads Activity page."""
tab, reused = await cdp_client.get_or_open_tab(
"https://www.threads.com/activity", "threads.com"
)
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate("https://www.threads.com/activity")
await asyncio.sleep(4)
count = min(count, 20)
js = r"""
(() => {
const region = document.querySelector('[role="region"]');
if (!region) return [];
const ORIGIN = 'https://www.threads.com';
const abs = (h) => !h ? null
: (/^https?:/.test(h) ? h : ORIGIN + (h.startsWith('/') ? h : '/' + h));
// Row detection, from the live DOM (probed 2026-09-11): a
// notification row is an element that links somewhere, carries a
// relative-time marker ("3h", "6h", "2d"), and holds no smaller
// element that does the same. The length floor rejects the bare
// "6h" time chip, which is itself a link on some rows.
const isRow = (el) => {
const t = el.innerText ? el.innerText.trim() : '';
return t.length >= 12
&& /\b\d+[smhdw]\b/.test(t)
&& !!el.querySelector('a[href]');
};
const cands = [...region.querySelectorAll('*')].filter(isRow);
const rows = cands.filter(el => !cands.some(o => o !== el && el.contains(o)));
const results = [];
for (const row of rows) {
const text = row.innerText.trim().replace(/\n+/g, ' ');
if (text.length < 5) continue;
const hrefs = [...row.querySelectorAll('a[href]')]
.map(x => x.getAttribute('href'));
// Prefer the post permalink; otherwise whatever the row links to
// (settings notices link to /settings/... — a real destination).
const post = hrefs.find(h => /\/@[^\/]+\/post\//.test(h || ''));
results.push({ text: text, url: abs(post || hrefs[0]) });
}
// Fallback: no anchors at all (layout change, or a notification kind
// that doesn't link anywhere). Keep the old text-split rather than
// returning an empty list that would read as "no activity".
if (!results.length) {
const items = region.innerText.trim()
.split(/(?<=\b\d+[hdm]\b)\s*\n\s*/);
for (const p of items) {
const t = p.trim().replace(/\n+/g, ' ');
if (t.length < 5) continue;
results.push({ text: t, url: null });
}
}
return results.slice(0, COUNT);
})()
""".replace("COUNT", str(count))
return await tab.eval(js) or []