"""Threads posting flow (2026-09 inline composer). 1. navigate threads.com 2. click the inline "What's new?" composer 3. type text 4. click Post button 5. text clearing / post appearing = success """ import asyncio from ..cdp import human_delay 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") if reused: await tab.eval("window.onbeforeunload = null") await tab.navigate(S["url"]) await asyncio.sleep(4) if await tab.exists(S["login_marker"]): return False return await tab.eval( "!!(document.querySelector('[placeholder=\"What\\'s new?\"]')" " || [...document.querySelectorAll('span,div')]" ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" )