Files
socialmag/platforms/threads.py
T

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?'))"
)