"""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") if reused: await tab.eval("window.onbeforeunload = null") await tab.navigate(S["url"]) await asyncio.sleep(4) # If we see login markers, not logged in if await tab.exists(S["login_marker"]): return False # If we see the home timeline composer, we're in return await tab.eval( "!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]')" " || document.querySelector('[data-testid=\"SideNav_NewTweet_Button\"]'))" ) # ── 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 // //status/ 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"}