120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""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?'))"
|
|
)
|