"""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 []