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>
190 lines
7.5 KiB
Python
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 []
|