v0.2: twitter module, feed/notifications/reply/open_link tools, dashboard
This commit is contained in:
+70
-7
@@ -1,15 +1,13 @@
|
||||
"""Threads posting flow (2026-09 inline composer).
|
||||
"""Threads posting and notifications 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
|
||||
post — post via inline composer
|
||||
check_login — verify signed in
|
||||
get_notifications — scrape the Activity page
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from ..cdp import human_delay
|
||||
from ..cdp import human_delay, CDPError
|
||||
from . import guard, require_text, validate_media
|
||||
from ..selectors import THREADS as S
|
||||
|
||||
@@ -117,3 +115,68 @@ async def check_login(cdp_client):
|
||||
" || [...document.querySelectorAll('span,div')]"
|
||||
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
|
||||
)
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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"}
|
||||
|
||||
Reference in New Issue
Block a user