Files
socialmag/platforms/twitter.py
T

263 lines
9.2 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")
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
// /<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"}