diff --git a/README.md b/README.md index 6dc4a85..117ef00 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # socialmag -MCP server that posts to Threads / Facebook / Instagram / YouTube / TikTok by -driving abang's already-logged-in Brave over CDP. No API keys, no OAuth. - -Built to `PROPOSAL.md` (2026-09-10). **Nothing here has been run against a live -platform yet** — every step whose selector or timing needs a real page is -marked `# TODO: VALIDATE LIVE`. +MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook, +Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in +Brave over CDP. No API keys, no OAuth. ## Run @@ -26,24 +23,35 @@ Requires `websockets` (17.1 present on this box). Everything else is stdlib. ## Tools -| Tool | Args | Returns | -|---|---|---| -| `create_post` | `platforms[]`, `text`, `media`, `visibility` | `{platform: {status, detail}}` | -| `check_login` | `platforms[]` (default all) | `{platform: true/false/"error: ..."}` | -| `get_status` | — | last run's results + queue history | +| Tool | Description | +|------|-------------| +| `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) | +| `check_login` | Check which platforms are signed in | +| `get_status` | Last post results + open browser tabs | +| `get_feed` | Read Twitter timeline or search with X operators | +| `reply_tweet` | Reply to a tweet found by search | +| `get_notifications` | Check platform activity/notifications (Threads) | +| `open_link` | Navigate platform's browser tab to a URL (tab reuse) | -`text` is required for threads/fb/yt and used as the caption for ig/tiktok. -`media` must be an **absolute path to an existing file**; it is required for -ig/tiktok, optional for threads/fb, and rejected for yt. +`create_post` — `text` is required for threads/fb/yt and used as the caption for +ig/tiktok. `media` must be an **absolute path to an existing file**; required for +ig/tiktok, optional for threads/fb, rejected for yt. + +## Dashboard + +`dashboard.html` is a local viewer — open it in a browser tab to see platform +status, post timeline, and Twitter intel. Reads from `dashboard.json` (written +by the server at runtime), auto-refreshes every 10 seconds. ## Layout ``` -server.py MCP stdio JSON-RPC loop + the 3 tools +server.py MCP stdio JSON-RPC loop + 7 tools cdp.py CDP client: CDPClient (browser) + Tab (page verbs) queue.py PostQueue — strictly sequential, one tab at a time -selectors.py every platform selector, one file +selectors.py every platform selector + PLATFORMS dict, one file platforms/ one module per platform: post() + check_login() +dashboard.html local status/timeline viewer (reads dashboard.json) ``` Each platform module exposes: @@ -53,6 +61,9 @@ async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail async def check_login(cdp_client) -> bool ``` +Twitter additionally exposes `get_feed`, `reply`, and `check_login` (reusing +the existing selector set). Threads exposes `get_notifications`. + `post` opens its own tab, drives the flow, closes the tab. A failure inside the flow is caught by `platforms.guard`, which screenshots the page as it actually looked into `/tmp/socialmag/` and puts the path in the result. diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..f83c0f8 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,748 @@ +socialmag console + + + + + + +
+
+
+ socialmag + 6 platforms + + + connecting to store… + +
+
+
+ +
+
+
Posts sent
+
0
+
+
+
Total views
+
—
+
+
+
Platforms active
+
0/6
+
+
+ +
+ +
+
+
+

Timeline

+ create_post · get_notifications + +
+
+
+ + +
+ + +
+ + diff --git a/platforms/threads.py b/platforms/threads.py index da24503..76f7b22 100644 --- a/platforms/threads.py +++ b/platforms/threads.py @@ -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 [] diff --git a/platforms/twitter.py b/platforms/twitter.py new file mode 100644 index 0000000..6dc5c1a --- /dev/null +++ b/platforms/twitter.py @@ -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 + // //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"} + diff --git a/selectors.py b/selectors.py index 31ace2c..b0cac6e 100644 --- a/selectors.py +++ b/selectors.py @@ -156,10 +156,43 @@ TIKTOK = { "login_marker": 'a[href*="/login"]', } +# -------------------------------------------------------------------------- +# Twitter / X +# -------------------------------------------------------------------------- + +TWITTER = { + "url": "https://x.com/home", + "compose_url": "https://x.com/compose/post", + # The main tweet box is a DraftJS contenteditable inside the compose dialog + "composer_textbox": '[data-testid="tweetTextarea_0"] [contenteditable="true"]', + # Fallback: the inline composer on the home timeline + "inline_composer": '[data-testid="tweetTextarea_0"]', + "post_button_js": ( + "document.querySelector('[data-testid=\"tweetButtonInline\"]')" + " || document.querySelector('[data-testid=\"tweetButton\"]')" + ), + "file_input": 'input[type="file"][accept*="image"]', + "media_button": '[data-testid="fileInput"]', + # After posting, the compose dialog closes or the timeline updates + "toast_text": "Your post was sent", + "login_marker": 'a[href="/login"], a[href*="flow/login"], input[name="text"][autocomplete="username"]', + # For reading feeds + "tweet_article": 'article[data-testid="tweet"]', + "tweet_text": '[data-testid="tweetText"]', + "tweet_user": '[data-testid="User-Name"]', + "reply_button": '[data-testid="reply"]', + "following_tab_js": ( + "[...document.querySelectorAll('[role=\"tab\"], a[role=\"tab\"]')]" + ".find(t => t.innerText.trim() === 'Following')" + ), + "search_url": "https://x.com/search?q={query}&src=typed_query&f=live", +} + PLATFORMS = { "threads": THREADS, "fb": FACEBOOK, "ig": INSTAGRAM, "yt": YOUTUBE, "tiktok": TIKTOK, + "twitter": TWITTER, } diff --git a/server.py b/server.py index 08025ed..49d1ddc 100644 --- a/server.py +++ b/server.py @@ -20,10 +20,12 @@ import json import os import sys import time +import urllib.parse from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT -from .platforms import facebook, instagram, threads, tiktok, youtube +from .platforms import facebook, instagram, threads, tiktok, twitter, youtube from .queue import PostQueue +from .selectors import PLATFORMS as PLATFORM_SELECTORS PROTOCOL_VERSION = "2025-06-18" SERVER_NAME = "socialmag" @@ -37,8 +39,27 @@ MODULES = { "ig": instagram, "yt": youtube, "tiktok": tiktok, + "twitter": twitter, } + +def _domain_of(platform): + """The tab-match string for a platform, derived from its selectors URL — + 'https://www.threads.com/' -> 'threads.com'. One source of truth: change a + platform's URL in selectors.py and tab matching follows it.""" + host = urllib.parse.urlsplit(PLATFORM_SELECTORS[platform]["url"]).hostname + if not host: + raise RuntimeError( + f"selectors.PLATFORMS[{platform!r}]['url'] has no hostname: " + f"{PLATFORM_SELECTORS[platform]['url']!r}" + ) + return host[4:] if host.startswith("www.") else host + + +# threads.com · facebook.com · instagram.com · youtube.com · tiktok.com · x.com +PLATFORM_DOMAINS = {p: _domain_of(p) for p in MODULES} + + # The endpoint is a spec constant, not a fallback for missing config. Override # with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere. CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT) @@ -113,6 +134,111 @@ TOOLS = [ "description": "Results of the last create_post run, plus queue history.", "inputSchema": {"type": "object", "properties": {}}, }, + { + "name": "get_feed", + "description": ( + "Read tweets from Twitter/X. Two modes: 'following' reads the " + "home timeline (Following tab), 'search' scrapes live results " + "for a query. Supports X search operators (from:, since:, " + "min_faves:, lang:). Returns up to 20 tweets with author, " + "text, time, and engagement counts." + ), + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "enum": ["following", "search"], + "description": "following = home timeline, search = keyword search.", + "default": "following", + }, + "query": { + "type": "string", + "description": ( + "Search query (required for search mode). Supports " + "X operators: from:user, since:2026-01-01, " + "min_faves:100, lang:en, etc." + ), + }, + "count": { + "type": "integer", + "description": "Max tweets to return (1-20, default 10).", + "default": 10, + }, + }, + }, + }, + { + "name": "reply_tweet", + "description": ( + "Reply to a tweet found by search query. Finds the first " + "matching tweet and posts a reply to it." + ), + "inputSchema": { + "type": "object", + "properties": { + "search_query": { + "type": "string", + "description": ( + "Search query to find the tweet to reply to. Use " + "from:user + keywords for precision." + ), + }, + "text": { + "type": "string", + "description": "Reply text.", + }, + }, + "required": ["search_query", "text"], + }, + }, + { + "name": "get_notifications", + "description": ( + "Check notifications/activity from social platforms. Currently " + "supports Threads (Activity page). Returns a list of recent " + "notifications with text, time, and post links where available." + ), + "inputSchema": { + "type": "object", + "properties": { + "platform": { + "enum": ["threads"], + "description": "Platform to check. Currently only threads.", + }, + "count": { + "type": "integer", + "description": "Max notifications to return (1-20, default 10).", + "default": 10, + }, + }, + "required": ["platform"], + }, + }, + { + "name": "open_link", + "description": ( + "Open a URL in the platform's existing browser tab (reuses warm " + "tabs). Falls back to opening a new tab if none exists. The tab is " + "left open — that's what makes it reusable next time." + ), + "inputSchema": { + "type": "object", + "properties": { + "platform": { + "enum": sorted(MODULES), + "description": ( + "Which platform's tab to reuse. Matched by domain: " + + ", ".join(f"{k} → {v}" for k, v in sorted(PLATFORM_DOMAINS.items())) + ), + }, + "url": { + "type": "string", + "description": "Full http(s) URL to navigate to.", + }, + }, + "required": ["platform", "url"], + }, + }, ] @@ -237,10 +363,111 @@ async def tool_get_status(args, queue): return state +async def tool_get_feed(args, queue): + mode = args.get("mode", "following") + query = args.get("query") + count = min(args.get("count", 10), 20) + + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + result = await twitter.get_feed(cdp, mode=mode, query=query, count=count) + finally: + await cdp.close() + return result + + +async def tool_reply_tweet(args, queue): + search_query = args.get("search_query") + text = args.get("text") + if not search_query or not text: + raise ValueError("reply_tweet: both 'search_query' and 'text' are required") + + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + result = await twitter.reply(cdp, search_query, text) + finally: + await cdp.close() + return result + + +async def tool_get_notifications(args, queue): + platform = args.get("platform") + count = min(args.get("count", 10), 20) + + notif_modules = { + "threads": threads, + } + if platform not in notif_modules: + raise ValueError( + f"get_notifications: unsupported platform {platform!r}; " + f"supported: {sorted(notif_modules)}" + ) + + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + result = await notif_modules[platform].get_notifications(cdp, count=count) + finally: + await cdp.close() + return result + + +async def tool_open_link(args, queue): + platform = args.get("platform") + url = args.get("url") + + # Validate before touching the browser: a bad call should cost nothing. + if platform not in PLATFORM_DOMAINS: + raise ValueError( + f"open_link: unknown platform {platform!r}; " + f"known: {sorted(PLATFORM_DOMAINS)}" + ) + if not url or not isinstance(url, str): + raise ValueError("open_link: 'url' is required and must be a string") + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in ("http", "https"): + raise ValueError( + f"open_link: url must be http(s), got {scheme or 'no'} scheme: {url!r}" + ) + + domain = PLATFORM_DOMAINS[platform] + + async def go(): + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + tab, reused = await cdp.get_or_open_tab(url, domain) + if reused: + # A warm tab may hold an unload prompt (a half-written composer); + # clear it, then send the tab to the requested URL. + await tab.eval("window.onbeforeunload = null") + await tab.navigate(url) + # Deliberately NOT closed — leaving it open is what makes the next + # open_link on this platform a reuse. + return { + "status": "ok", + "tab_reused": reused, + "platform": platform, + "matched_domain": domain, + "url": url, + } + finally: + await cdp.close() + + # Through the queue like every other browser job: one tab at a time. + return await queue.submit(f"open:{platform}", go) + + HANDLERS = { "create_post": tool_create_post, "check_login": tool_check_login, "get_status": tool_get_status, + "get_feed": tool_get_feed, + "reply_tweet": tool_reply_tweet, + "get_notifications": tool_get_notifications, + "open_link": tool_open_link, }