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>
271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
"""Twitter/X posting, reading, and replying via CDP.
|
|
|
|
post — tweet from the inline home composer
|
|
get_feed — scrape timeline (Following tab) or search results
|
|
reply — reply to a tweet found by search
|
|
check_login — verify the account is signed in
|
|
"""
|
|
|
|
import asyncio
|
|
import urllib.parse
|
|
|
|
from ..cdp import human_delay, CDPError
|
|
from . import guard, require_text, validate_media
|
|
from ..selectors import TWITTER as S
|
|
|
|
PLATFORM = "twitter"
|
|
|
|
|
|
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"], "x.com")
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(S["url"])
|
|
else:
|
|
await asyncio.sleep(2)
|
|
return await guard(PLATFORM, tab, _flow(tab, text, media_path))
|
|
|
|
|
|
async def _flow(tab, text, media_path):
|
|
await _wait_for_composer(tab)
|
|
await human_delay()
|
|
|
|
# Click the contenteditable to focus it
|
|
textbox = S["composer_textbox"]
|
|
if not await tab.exists(textbox):
|
|
# Fallback: try the broader testid
|
|
textbox = '[data-testid="tweetTextarea_0"]'
|
|
await tab.wait_for(textbox, timeout=10)
|
|
|
|
await tab.type_text(textbox, text)
|
|
await human_delay(0.5, 1.0)
|
|
|
|
if media_path:
|
|
file_input = 'input[type="file"]'
|
|
if await tab.exists(file_input):
|
|
await tab.set_file_input(file_input, media_path)
|
|
await human_delay(2.0, 4.0)
|
|
|
|
await human_delay(0.5, 1.0)
|
|
|
|
# Click the Post button
|
|
await tab.click_js(S["post_button_js"])
|
|
|
|
await _wait_for_post_complete(tab)
|
|
|
|
|
|
async def _wait_for_composer(tab, timeout=20.0):
|
|
"""Wait for the tweet compose box to appear."""
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
has_box = await tab.eval(
|
|
"!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]'))"
|
|
)
|
|
if has_box:
|
|
return
|
|
await asyncio.sleep(0.5)
|
|
from ..cdp import CDPError
|
|
raise CDPError("twitter: compose box not found after 20s")
|
|
|
|
|
|
async def _wait_for_post_complete(tab, timeout=30.0):
|
|
"""Wait for the tweet to go through — composer empties or toast appears."""
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
# Check for the success toast
|
|
toast = await tab.eval(
|
|
"!!([...document.querySelectorAll('[role=\"alert\"], [data-testid=\"toast\"]')]"
|
|
".find(e => e.innerText && e.innerText.includes('post')))"
|
|
)
|
|
if toast:
|
|
return
|
|
|
|
# Inline composer: text cleared back to empty
|
|
empty = await tab.eval(
|
|
"(() => {"
|
|
" const box = document.querySelector('[data-testid=\"tweetTextarea_0\"] [contenteditable=\"true\"]');"
|
|
" if (box && box.innerText.trim() === '') return true;"
|
|
" const box2 = document.querySelector('[data-testid=\"tweetTextarea_0\"]');"
|
|
" if (box2 && box2.innerText.trim() === '') return true;"
|
|
" return false;"
|
|
"})()"
|
|
)
|
|
if empty:
|
|
return
|
|
|
|
await asyncio.sleep(0.5)
|
|
from ..cdp import CDPError
|
|
raise CDPError("twitter: post didn't complete within 30s")
|
|
|
|
|
|
async def check_login(cdp_client):
|
|
tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com", activate=False)
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(S["url"])
|
|
# If we see login markers, not logged in
|
|
if await tab.exists(S["login_marker"]):
|
|
return False
|
|
# JS state, not the composer. The DOM signal does mount in a background tab,
|
|
# but only once React has painted — dropping the old sleep(4) made it read
|
|
# OUT on a signed-in account the moment five tabs navigated at once
|
|
# (measured 2026-09-12). `twid` carries the user id and is set at login,
|
|
# same shape as fb's c_user and ig's ds_user_id.
|
|
state = await tab.eval(
|
|
"(() => { const m = document.cookie.match(/(?:^|;\\s*)twid=([^;]+)/);"
|
|
" return m ? 'in:' + m[1] : (document.cookie ? 'out' : 'unavailable'); })()"
|
|
)
|
|
if state.startswith("in:"):
|
|
return True
|
|
if state == "out":
|
|
return False
|
|
raise CDPError("twitter: no cookies readable on x.com — cannot tell signed-in from signed-out")
|
|
|
|
|
|
# ── Scrape tweets from a page ────────────────────────────────────────────────
|
|
|
|
SCRAPE_JS = r"""
|
|
(() => {
|
|
const articles = document.querySelectorAll('article[data-testid="tweet"]');
|
|
const results = [];
|
|
for (const a of [...articles].slice(0, COUNT)) {
|
|
const nameEl = a.querySelector('[data-testid="User-Name"]');
|
|
const textEl = a.querySelector('[data-testid="tweetText"]');
|
|
const timeEl = a.querySelector('time');
|
|
const likes = a.querySelector('[data-testid="like"]');
|
|
const retweets = a.querySelector('[data-testid="retweet"]');
|
|
const reply = a.querySelector('[data-testid="reply"]');
|
|
const getCount = (el) => {
|
|
if (!el) return 0;
|
|
const span = el.querySelector('[data-testid$="Count"]')
|
|
|| el.querySelector('span[data-testid]')
|
|
|| el.querySelector('span');
|
|
return span ? span.innerText.trim() : '0';
|
|
};
|
|
// Permalink: the timestamp sits inside the canonical
|
|
// /<user>/status/<id> anchor. Verified live 2026-09-11 — the other
|
|
// status hrefs in an article are /analytics, /photo/N, /media_tags.
|
|
const permalink = (() => {
|
|
const link = timeEl ? timeEl.closest('a[href]') : null;
|
|
let href = link ? link.getAttribute('href') : null;
|
|
if (!href) {
|
|
href = [...a.querySelectorAll('a[href]')]
|
|
.map(x => x.getAttribute('href'))
|
|
.find(h => /^\/[^\/]+\/status\/\d+$/.test(h)) || null;
|
|
}
|
|
return href ? new URL(href, location.origin).href : null;
|
|
})();
|
|
results.push({
|
|
author: nameEl ? nameEl.innerText.replace(/\n/g, ' ') : '?',
|
|
text: textEl ? textEl.innerText.slice(0, 500) : '',
|
|
time: timeEl ? timeEl.getAttribute('datetime') : '?',
|
|
url: permalink,
|
|
likes: getCount(likes),
|
|
retweets: getCount(retweets),
|
|
replies: getCount(reply)
|
|
});
|
|
}
|
|
return results;
|
|
})()
|
|
"""
|
|
|
|
|
|
async def get_feed(cdp_client, mode="following", query=None, count=10):
|
|
"""Scrape tweets. mode='following' for timeline, 'search' for query."""
|
|
if mode == "search" and not query:
|
|
raise CDPError("twitter get_feed: 'query' required for search mode")
|
|
|
|
if mode == "search":
|
|
url = S["search_url"].format(query=urllib.parse.quote(query))
|
|
url_match = "x.com/search"
|
|
else:
|
|
url = S["url"]
|
|
url_match = "x.com"
|
|
|
|
tab, reused = await cdp_client.get_or_open_tab(url, url_match)
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(url)
|
|
|
|
await asyncio.sleep(4)
|
|
|
|
# For following mode, click the Following tab
|
|
if mode == "following":
|
|
await tab.eval(
|
|
"(() => { const t = %s; if (t) t.click(); })()" % S["following_tab_js"]
|
|
)
|
|
await asyncio.sleep(3)
|
|
|
|
# Scroll once to load more tweets
|
|
await tab.eval("window.scrollBy(0, 800)")
|
|
await asyncio.sleep(2)
|
|
await tab.eval("window.scrollTo(0, 0)")
|
|
await asyncio.sleep(1)
|
|
|
|
count = min(count, 20)
|
|
js = SCRAPE_JS.replace("COUNT", str(count))
|
|
tweets = await tab.eval(js)
|
|
return tweets or []
|
|
|
|
|
|
async def reply(cdp_client, search_query, reply_text):
|
|
"""Find a tweet by search and reply to it."""
|
|
require_text(PLATFORM, reply_text)
|
|
if not search_query:
|
|
raise CDPError("twitter reply: 'search_query' required to find the tweet")
|
|
|
|
url = S["search_url"].format(query=urllib.parse.quote(search_query))
|
|
tab, reused = await cdp_client.get_or_open_tab(url, "x.com/search")
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(url)
|
|
await asyncio.sleep(5)
|
|
|
|
# Click reply on the first matching tweet
|
|
clicked = await tab.eval(r"""
|
|
(() => {
|
|
const tweet = document.querySelector('article[data-testid="tweet"]');
|
|
if (!tweet) return false;
|
|
const btn = tweet.querySelector('[data-testid="reply"]');
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
})()
|
|
""")
|
|
if not clicked:
|
|
raise CDPError("twitter reply: no tweet found for that search query")
|
|
|
|
await asyncio.sleep(3)
|
|
|
|
# Type into the reply box
|
|
for sel in ('[data-testid="tweetTextarea_0"] [contenteditable="true"]',
|
|
'[data-testid="tweetTextarea_0"]'):
|
|
if await tab.exists(sel):
|
|
await tab.type_text(sel, reply_text)
|
|
break
|
|
else:
|
|
raise CDPError("twitter reply: reply box not found")
|
|
|
|
await human_delay(0.5, 1.0)
|
|
|
|
# Post the reply
|
|
posted = await tab.eval(r"""
|
|
(() => {
|
|
const btn = document.querySelector('[data-testid="tweetButtonInline"]')
|
|
|| document.querySelector('[data-testid="tweetButton"]');
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
})()
|
|
""")
|
|
if not posted:
|
|
raise CDPError("twitter reply: could not find the reply post button")
|
|
|
|
await asyncio.sleep(3)
|
|
return {"status": "ok", "detail": "twitter: replied"}
|
|
|