commit a4f33f456ef11760a493526f12ef3edfe00679f0 Author: Muhammad Aizat Date: Thu Sep 10 21:10:55 2026 +0800 socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok) diff --git a/MANIFEST.md b/MANIFEST.md new file mode 100644 index 0000000..53d2bec --- /dev/null +++ b/MANIFEST.md @@ -0,0 +1,114 @@ +# socialmag — Social Media MCP Server + +**One tool, all platforms.** Post to Threads, Facebook, Instagram, TikTok (and YouTube when eligible) through a single MCP call. Built on CDP — no API keys, no rate limits, no approval process. Just a browser and a logged-in account. + +## What It Does + +- **`create_post`** — Post text/media to one or more platforms in one call +- **`check_login`** — Verify which platforms are logged in +- **`get_status`** — Last post results + open browser tabs + +## Architecture + +``` +Claude/MCP Client + ↓ stdio (JSON-RPC) +socialmag server.py + ↓ CDP WebSocket +Brave Browser (dedicated instance, port 9336) + ↓ real browser sessions +Threads · Facebook · Instagram · TikTok · YouTube +``` + +- **Tab reuse** — attaches to existing tabs, no cold loads +- **Per-platform modules** — each platform has its own flow + selectors +- **Sequential queue** — one platform at a time, no race conditions +- **Error screenshots** — failed flows capture the page state to `/tmp/socialmag/` + +## Platform Status + +| Platform | Text | Media | Tab Reuse | Status | +|----------|:----:|:-----:|:---------:|--------| +| Threads | ✅ | 🔲 | ✅ | **Live** — inline composer | +| Facebook | ✅ | 🔲 | ✅ | **Live** — dialog flow | +| Instagram | — | ✅ | ✅ | **Live** — media required | +| TikTok | — | ✅ | ✅ | **Live** — video/photo upload | +| YouTube | 🔲 | 🔲 | ✅ | **Parked** — needs 500 subs | + +✅ = tested live · 🔲 = coded but untested · — = not applicable + +## Stack + +- Python 3 (stdlib + websockets) +- Chrome DevTools Protocol (CDP) +- MCP stdio transport +- Brave Browser with `--remote-debugging-port` + +## Browser Setup + +```bash +# Dedicated Brave instance (separate profile, no interference) +brave --remote-debugging-port=9336 \ + --user-data-dir=$HOME/.local/share/brave-socialmag \ + --disable-features=BeforeUnloadDialog \ + --mute-audio --no-first-run +``` + +Log into each platform manually once. socialmag reuses those sessions. + +## Future Roadmap + +### v0.2 — Read Side +- [ ] `get_notifications(platform)` — pull notifications via CDP scraping +- [ ] `get_comments(platform, post_id?)` — read comments on recent posts +- [ ] `get_engagement(platform)` — likes, shares, reach per post + +### v0.3 — Monitoring +- [ ] Cron/loop polling — periodic checks, dump to JSON +- [ ] Unified notification feed across all platforms +- [ ] Alert on new comments/DMs + +### v0.4 — Dashboard +- [ ] Web dashboard — one view for all platform activity +- [ ] Post scheduler — queue posts for specific times +- [ ] Analytics — engagement trends, best posting times + +### v0.5 — Media Pipeline +- [ ] Threads media posting (image + video) +- [ ] Facebook media posting +- [ ] YouTube video upload (separate from community posts) +- [ ] Auto-resize/format media per platform requirements + +### Later +- [ ] Multi-account support +- [ ] Platform API integration (where available + worthwhile) +- [ ] Content calendar +- [ ] AI-assisted caption generation + +## File Structure + +``` +socialmag/ +├── __init__.py # package marker +├── __main__.py # python -m socialmag entry +├── server.py # MCP stdio server + tool handlers +├── cdp.py # CDP WebSocket client +├── selectors.py # all platform selectors in one file +├── queue.py # sequential post queue +├── platforms/ +│ ├── __init__.py # shared helpers (guard, require_media, etc.) +│ ├── threads.py +│ ├── facebook.py +│ ├── instagram.py +│ ├── tiktok.py +│ └── youtube.py +└── MANIFEST.md # this file +``` + +## License + +Private. Shared among collaborators by invitation. + +--- + +*Born 2026-09-10. Built by the household — Celeste architected, Umbra built the skeleton, abang drove the FAFO.* diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..c062658 --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,193 @@ +# socialmag — Social Media MCP Server +# Proposal Draft · 2026-09-10 + +## What + +An MCP server that lets any household agent post to social media platforms via a single +tool call. Uses bravemag's CDP connection (port 9222) to abang's logged-in Brave browser. +No API keys, no OAuth dance, no monthly fees. + +## Architecture + +``` +┌─ Any Agent ─────────────────────┐ +│ create_post( │ +│ platforms: ["threads","fb"], │ +│ text: "Hello world 🚀", │ +│ media: "/path/to/img.jpg", │ ← optional +│ schedule: null │ ← future: cron/delay +│ ) │ +└─────────────┬───────────────────┘ + ↓ +┌─ socialmag (MCP server) ────────┐ +│ Python · stdio MCP interface │ +│ CDP client → ws://127.0.0.1:9222 +│ │ +│ Queue: sequential per-platform │ +│ (one browser, one tab at a time) +│ │ +│ ┌─ post_threads(text, media?) │ +│ ├─ post_facebook(text, media?) │ +│ ├─ post_instagram(media, caption) +│ ├─ post_youtube(text) │ ← community post +│ ├─ post_youtube_video(file, title, desc) +│ └─ post_tiktok(file, caption) │ +│ │ +│ Returns: per-platform status │ +│ {threads: "ok", fb: "ok", ...} │ +└─────────────────────────────────┘ +``` + +## MCP Tools (public interface) + +### 1. `create_post` — the main tool +```json +{ + "platforms": ["threads", "fb", "ig", "yt", "tiktok"], + "text": "Post content here", + "media": "/absolute/path/to/file.jpg", + "visibility": "public" +} +``` +- `platforms`: array, pick any combo +- `text`: required for threads/fb/yt. Used as caption for ig/tiktok +- `media`: optional for threads/fb/yt. REQUIRED for ig/tiktok +- `visibility`: "public" | "friends" (fb only) + +### 2. `check_login` — verify which platforms are logged in +```json +{} +→ {"threads": true, "fb": true, "ig": true, "yt": true, "tiktok": true} +``` + +### 3. `get_status` — last post results +```json +{} +→ {"last_post": "2026-09-10T19:30:00", "results": {...}} +``` + +## Per-Platform Posting Logic (from FAFO recon) + +### Threads ⭐ +``` +1. navigate("https://www.threads.net/") +2. click([aria-label="Empty text field. Type to compose a new post."]) +3. wait for [role=dialog] +4. type into [role=dialog] [role=textbox] +5. if media: inject via CDP DOM.setFileInputFiles on "Attach media" flow +6. click "Post" button in dialog +7. wait for dialog close = success +``` + +### Facebook ⭐⭐ +``` +1. navigate("https://www.facebook.com/") +2. JS: find span with "on your mind" → walk parent to [role=button] → click() + ⚠️ Direct element click triggers Messenger! +3. wait for [role=dialog] containing "Create post" +4. type into dialog [role=textbox][contenteditable=true] +5. if media: click "Photo/video" → CDP setFileInputFiles +6. if visibility != "friends": click privacy button → change +7. click [aria-label="Post"] +8. wait for dialog close = success +``` + +### Instagram ⭐⭐⭐⭐ +``` +1. navigate("https://www.instagram.com/") +2. click(svg[aria-label="New post"]) +3. wait for "Create new post" dialog +4. CDP: DOM.setFileInputFiles on input[type=file] → inject media + (bypasses native file picker) +5. wait for crop screen → click "Next" +6. wait for filter screen → click "Next" +7. wait for caption screen → type caption in textarea +8. click "Share" +9. wait for dialog close = success +``` + +### YouTube (community post) ⭐⭐ +``` +1. navigate("https://www.youtube.com/") +2. click(button[aria-label="Create"]) +3. click "Create post" in dropdown menu +4. wait for redirect to channel/posts page +5. type into #contenteditable-root +6. click button[aria-label="Post"] +7. wait for post to appear = success +``` + +### TikTok ⭐⭐⭐⭐ +``` +1. navigate("https://www.tiktok.com/tiktokstudio/upload?from=upload") +2. wait for upload page +3. for video: CDP setFileInputFiles on input[type=file][accept="video/*"] +4. for photos: click "Photos" tab first, then upload +5. wait for processing +6. type caption/description +7. set hashtags, settings +8. click "Post" / "Publish" +9. wait for success confirmation +``` + +## Tech Stack + +- **Python 3** (stdlib-only server, same pattern as tiledmag/pixel-room-mcp) +- **CDP client**: `websockets` library for WebSocket to Chrome DevTools +- **MCP interface**: stdio (standard MCP pattern) +- **No framework**: plain Python, no LangChain/FastAPI/etc +- **Systemd managed**: `socialmag.service`, auto-restart + +## File Structure + +``` +/mnt/playground/Playground_V2/socialmag/ +├── server.py # MCP server + tool definitions +├── cdp.py # CDP WebSocket client (navigate, click, type, setFiles) +├── platforms/ +│ ├── threads.py # Threads posting logic +│ ├── facebook.py # Facebook posting logic +│ ├── instagram.py # Instagram posting logic +│ ├── youtube.py # YouTube posting logic +│ └── tiktok.py # TikTok posting logic +├── selectors.py # All platform selectors in one place +├── queue.py # Sequential execution queue +└── README.md +``` + +## Dependencies + +- `websockets` (for CDP WebSocket) +- That's it. Everything else is stdlib. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Platform UI changes break selectors | selectors.py is one file — update and go | +| Bot detection (especially TikTok) | Human-like delays between actions (random 0.5-2s) | +| Browser not logged in | `check_login` tool verifies before posting | +| Brave restart kills CDP | Health check + clear error message | +| Rate limiting | Queue with configurable delay between platforms | +| Media file not found | Validate path exists before starting | +| Multi-step IG/TikTok flow fails mid-way | Per-step error handling, screenshot on failure | + +## Future (not now) + +- Schedule posts (cron integration) +- Analytics reading (pull post performance) +- Reply/DM automation +- Multi-account support +- Template system ("post this format every Monday") + +## Build Order + +1. CDP client (cdp.py) — navigate, click, type, setFiles +2. Threads posting (simplest, validates the pattern) +3. Facebook posting +4. YouTube community post +5. Instagram posting (media flow) +6. TikTok posting (media flow) +7. MCP server wrapper (server.py) +8. Queue + error handling +9. systemd service diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dc4a85 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# 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`. + +## Run + +```bash +cd /mnt/playground/Playground_V2 +python3 -m socialmag # stdio MCP server, logs to stderr +``` + +⚠️ **Must run as a package.** `python3 socialmag/server.py` puts the package +dir first on `sys.path`, where `selectors.py` and `queue.py` shadow the stdlib +modules `asyncio` imports — asyncio then dies at import with +`module 'selectors' has no attribute 'SelectSelector'`. Run `-m`. + +Brave must be listening on `http://127.0.0.1:9222`. Override with +`SOCIALMAG_CDP_ENDPOINT`. + +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 | + +`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. + +## Layout + +``` +server.py MCP stdio JSON-RPC loop + the 3 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 +platforms/ one module per platform: post() + check_login() +``` + +Each platform module exposes: + +```python +async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail": ...} +async def check_login(cdp_client) -> bool +``` + +`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. + +## House rules honoured + +- **No silent fallbacks.** Missing media, missing text, an unknown platform, a + selector that never appears, a CDP call that errors — all raise with the + reason. Nothing picks a default and carries on. +- Unmapped flows **refuse** rather than guess: Facebook `visibility` switching + and YouTube media both raise, because clicking through an unmapped dialog + outward-facing is worse than a stop. +- Human-like delays (`random.uniform`) between every discrete action, longer on + TikTok. + +## Not built (out of the brief's scope) + +- `post_youtube_video` (video upload) — the brief scoped yt to community posts. +- `socialmag.service` — explicitly deferred until after live validation. +- Scheduling, analytics, multi-account (proposal "Future"). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cb72106 --- /dev/null +++ b/__init__.py @@ -0,0 +1,11 @@ +"""socialmag — post to social platforms through abang's logged-in Brave via CDP. + +This is a package, not a pile of scripts, for one concrete reason: two of the +module names the design calls for — `selectors.py` and `queue.py` — collide +with stdlib modules that asyncio itself imports. Run flat and Python resolves +`selectors` to ours, and asyncio dies at import. As a package with relative +imports, the names are ours inside and the stdlib's outside. + +Run it as: python3 -m socialmag (from the parent directory) +Not as: python3 socialmag/server.py (re-triggers the shadowing) +""" diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..fd436e2 --- /dev/null +++ b/__main__.py @@ -0,0 +1,11 @@ +"""Entry point: python3 -m socialmag""" + +import asyncio + +from .server import main + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..c490ded Binary files /dev/null and b/__pycache__/__init__.cpython-314.pyc differ diff --git a/__pycache__/__main__.cpython-314.pyc b/__pycache__/__main__.cpython-314.pyc new file mode 100644 index 0000000..c3c7f30 Binary files /dev/null and b/__pycache__/__main__.cpython-314.pyc differ diff --git a/__pycache__/cdp.cpython-314.pyc b/__pycache__/cdp.cpython-314.pyc new file mode 100644 index 0000000..948f546 Binary files /dev/null and b/__pycache__/cdp.cpython-314.pyc differ diff --git a/__pycache__/queue.cpython-314.pyc b/__pycache__/queue.cpython-314.pyc new file mode 100644 index 0000000..1c94d90 Binary files /dev/null and b/__pycache__/queue.cpython-314.pyc differ diff --git a/__pycache__/selectors.cpython-314.pyc b/__pycache__/selectors.cpython-314.pyc new file mode 100644 index 0000000..f96377f Binary files /dev/null and b/__pycache__/selectors.cpython-314.pyc differ diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000..2572bd4 Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ diff --git a/cdp.py b/cdp.py new file mode 100644 index 0000000..49e622c --- /dev/null +++ b/cdp.py @@ -0,0 +1,401 @@ +"""CDP WebSocket client for socialmag. + +Talks to abang's already-running Brave over the Chrome DevTools Protocol +(default endpoint: http://127.0.0.1:9222). No browser is launched here — if +nothing is listening on 9222 we raise, loudly. + +Shape: + + cdp = CDPClient() # browser-level connection + await cdp.connect() + tab = await cdp.open_tab("https://www.threads.net/") + await tab.wait_for(SEL) + await tab.click(SEL) + await tab.close() + await cdp.close() + +Every failure raises CDPError. Nothing here returns a plausible default. +""" + +import asyncio +import base64 +import json +import random +import urllib.error +import urllib.request + +import websockets + +DEFAULT_HTTP_ENDPOINT = "http://127.0.0.1:9336" + +# Human-like pause between discrete actions (proposal: "random 0.5-2s"). +HUMAN_DELAY_MIN = 0.5 +HUMAN_DELAY_MAX = 1.5 + +# How long a wait_for polls, and how often. +DEFAULT_TIMEOUT = 20.0 +POLL_INTERVAL = 0.25 + + +class CDPError(RuntimeError): + """Anything the browser side got wrong. Never swallowed, never defaulted.""" + + +async def human_delay(lo=HUMAN_DELAY_MIN, hi=HUMAN_DELAY_MAX): + await asyncio.sleep(random.uniform(lo, hi)) + + +def _http_get_json(url, timeout=5.0): + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + raise CDPError( + f"CDP endpoint unreachable at {url}: {exc}. " + "Is Brave running with --remote-debugging-port=9222?" + ) from exc + except json.JSONDecodeError as exc: + raise CDPError(f"CDP endpoint {url} returned non-JSON: {exc}") from exc + + +class CDPClient: + """Browser-level CDP connection. Owns the socket, hands out Tabs.""" + + def __init__(self, http_endpoint=DEFAULT_HTTP_ENDPOINT): + self.http_endpoint = http_endpoint.rstrip("/") + self._ws = None + self._reader_task = None + self._next_id = 0 + self._pending = {} # message id -> Future + self._events = {} # session_id -> asyncio.Queue of (method, params) + self.browser_version = None + + # ---- lifecycle ----------------------------------------------------- + + async def connect(self): + # NOTE: deliberately a plain blocking urlopen rather than + # asyncio.to_thread — `queue.py` in this package shadows the stdlib + # `queue` module, which concurrent.futures' thread pool imports. One + # 5s call at startup, before anything else is running. + version = _http_get_json(f"{self.http_endpoint}/json/version") + ws_url = version.get("webSocketDebuggerUrl") + if not ws_url: + raise CDPError( + f"/json/version has no webSocketDebuggerUrl: {version!r}" + ) + self.browser_version = version.get("Browser") + self._ws = await websockets.connect(ws_url, max_size=None) + self._reader_task = asyncio.create_task(self._reader()) + return self + + async def close(self): + if self._reader_task: + self._reader_task.cancel() + try: + await self._reader_task + except asyncio.CancelledError: + pass + self._reader_task = None + if self._ws: + await self._ws.close() + self._ws = None + + async def __aenter__(self): + return await self.connect() + + async def __aexit__(self, *exc): + await self.close() + + # ---- plumbing ------------------------------------------------------ + + async def _reader(self): + async for raw in self._ws: + msg = json.loads(raw) + mid = msg.get("id") + if mid is not None: + fut = self._pending.pop(mid, None) + if fut and not fut.done(): + fut.set_result(msg) + continue + session_id = msg.get("sessionId") + queue = self._events.get(session_id) + if queue is not None: + queue.put_nowait((msg.get("method"), msg.get("params", {}))) + + async def send(self, method, params=None, session_id=None, timeout=30.0): + if self._ws is None: + raise CDPError("CDPClient.send() before connect()") + self._next_id += 1 + mid = self._next_id + payload = {"id": mid, "method": method, "params": params or {}} + if session_id: + payload["sessionId"] = session_id + fut = asyncio.get_running_loop().create_future() + self._pending[mid] = fut + await self._ws.send(json.dumps(payload)) + try: + msg = await asyncio.wait_for(fut, timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(mid, None) + raise CDPError(f"CDP call timed out after {timeout}s: {method}") from exc + if "error" in msg: + raise CDPError(f"CDP {method} failed: {msg['error']}") + return msg.get("result", {}) + + # ---- tabs ---------------------------------------------------------- + + async def list_targets(self): + result = await self.send("Target.getTargets") + return [t for t in result["targetInfos"] if t["type"] == "page"] + + async def _attach(self, target_id): + result = await self.send( + "Target.attachToTarget", {"targetId": target_id, "flatten": True} + ) + session_id = result["sessionId"] + self._events[session_id] = asyncio.Queue() + tab = Tab(self, target_id, session_id) + await tab.send("Page.enable") + await tab.send("Runtime.enable") + await tab.send("DOM.enable") + return tab + + async def open_tab(self, url): + """Open a fresh tab and attach to it.""" + result = await self.send("Target.createTarget", {"url": url}) + tab = await self._attach(result["targetId"]) + await tab.wait_for_load() + return tab + + async def attach_existing(self, url_contains): + """Attach to an already-open tab whose URL contains `url_contains`.""" + for info in await self.list_targets(): + if url_contains in info.get("url", ""): + return await self._attach(info["targetId"]) + raise CDPError(f"no open tab whose URL contains {url_contains!r}") + + async def get_or_open_tab(self, url, url_contains): + """Reuse a warm existing tab if one matches, otherwise open a fresh one. + Returns (tab, reused: bool). Caller should skip tab.close() when reused.""" + try: + tab = await self.attach_existing(url_contains) + return tab, True + except CDPError: + tab = await self.open_tab(url) + return tab, False + + def _drop_session(self, session_id): + self._events.pop(session_id, None) + + +class Tab: + """One page target. All the verbs the platform modules use.""" + + def __init__(self, client, target_id, session_id): + self.client = client + self.target_id = target_id + self.session_id = session_id + + async def send(self, method, params=None, timeout=30.0): + return await self.client.send( + method, params, session_id=self.session_id, timeout=timeout + ) + + async def close(self): + await self.client.send("Target.closeTarget", {"targetId": self.target_id}) + self.client._drop_session(self.session_id) + + # ---- navigation ---------------------------------------------------- + + async def navigate(self, url, timeout=DEFAULT_TIMEOUT): + result = await self.send("Page.navigate", {"url": url}) + if result.get("errorText"): + raise CDPError(f"navigate to {url} failed: {result['errorText']}") + await self.wait_for_load(timeout) + + async def wait_for_load(self, timeout=DEFAULT_TIMEOUT): + """Wait for document.readyState == 'complete' (poll; simpler than + racing Page.loadEventFired against an already-loaded target).""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + state = await self.eval("document.readyState") + if state == "complete": + return + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"page did not finish loading within {timeout}s: {await self.url()}") + + async def url(self): + return await self.eval("location.href") + + # ---- evaluation ---------------------------------------------------- + + async def eval(self, expression, await_promise=False): + """Evaluate JS, return the value. Throws loudly on JS exceptions.""" + result = await self.send( + "Runtime.evaluate", + { + "expression": expression, + "returnByValue": True, + "awaitPromise": await_promise, + "userGesture": True, + }, + ) + if "exceptionDetails" in result: + detail = result["exceptionDetails"] + text = detail.get("exception", {}).get("description") or detail.get("text") + raise CDPError(f"JS threw: {text}\n expression: {expression[:200]}") + return result["result"].get("value") + + # ---- querying / waiting ------------------------------------------- + + async def exists(self, selector): + return bool(await self.eval(f"!!document.querySelector({json.dumps(selector)})")) + + async def wait_for(self, selector, timeout=DEFAULT_TIMEOUT, visible=True): + """Poll until selector is present (and, by default, has a box).""" + check = ( + "(() => { const e = document.querySelector(%s); if (!e) return false;" + " if (!%s) return true; const r = e.getBoundingClientRect();" + " return r.width > 0 && r.height > 0; })()" + % (json.dumps(selector), "true" if visible else "false") + ) + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await self.eval(check): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for selector: {selector}") + + async def wait_for_gone(self, selector, timeout=DEFAULT_TIMEOUT): + """Poll until selector disappears — how every dialog flow confirms success.""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if not await self.exists(selector): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for {selector} to disappear") + + async def wait_for_text(self, text, timeout=DEFAULT_TIMEOUT): + """Poll until the text appears anywhere in document.body.""" + expr = f"document.body.innerText.includes({json.dumps(text)})" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await self.eval(expr): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for text: {text!r}") + + # ---- interaction --------------------------------------------------- + + async def _box(self, selector): + box = await self.eval( + "(() => { const e = document.querySelector(%s); if (!e) return null;" + " e.scrollIntoView({block:'center'});" + " const r = e.getBoundingClientRect();" + " return {x: r.left + r.width/2, y: r.top + r.height/2," + " w: r.width, h: r.height}; })()" % json.dumps(selector) + ) + if not box: + raise CDPError(f"element not found: {selector}") + if box["w"] <= 0 or box["h"] <= 0: + raise CDPError(f"element has zero size (not visible): {selector}") + return box + + async def click(self, selector, delay=True): + """Real mouse click at the element's centre — the human-shaped path.""" + box = await self._box(selector) + await asyncio.sleep(0.1) + for kind in ("mousePressed", "mouseReleased"): + await self.send( + "Input.dispatchMouseEvent", + { + "type": kind, + "x": box["x"], + "y": box["y"], + "button": "left", + "clickCount": 1, + }, + ) + await asyncio.sleep(random.uniform(0.04, 0.12)) + if delay: + await human_delay() + + async def click_js(self, expression, delay=True): + """Click via JS on an element the expression returns. + + Facebook needs this: the composer's real click target is an ancestor + [role=button], and mouse-clicking the visible span opens Messenger + instead of the composer. + """ + ok = await self.eval( + "(() => { const el = (%s); if (!el) return false; el.click(); return true; })()" + % expression + ) + if not ok: + raise CDPError(f"click_js found no element for expression: {expression[:200]}") + if delay: + await human_delay() + + async def focus(self, selector): + await self.eval( + "(() => { const e = document.querySelector(%s);" + " if (!e) throw new Error('focus: not found'); e.focus(); })()" + % json.dumps(selector) + ) + + async def type_text(self, selector, text, per_char=False, delay=True): + """Focus `selector` then type. Default is Input.insertText (fast, and + React contenteditables accept it); per_char sends real key events for + editors that only listen to keydown.""" + await self.click(selector, delay=False) + await asyncio.sleep(0.2) + if per_char: + for ch in text: + await self.send("Input.dispatchKeyEvent", {"type": "keyDown", "text": ch}) + await self.send("Input.dispatchKeyEvent", {"type": "keyUp", "text": ch}) + await asyncio.sleep(random.uniform(0.02, 0.09)) + else: + await self.send("Input.insertText", {"text": text}) + if delay: + await human_delay() + + async def press_key(self, key, code=None, key_code=None): + params = {"type": "keyDown", "key": key} + if code: + params["code"] = code + if key_code: + params["windowsVirtualKeyCode"] = key_code + params["nativeVirtualKeyCode"] = key_code + await self.send("Input.dispatchKeyEvent", params) + await self.send("Input.dispatchKeyEvent", dict(params, type="keyUp")) + + # ---- file inputs --------------------------------------------------- + + async def set_file_input(self, selector, paths): + """DOM.setFileInputFiles — injects a file without the native picker. + + `selector` must resolve to an . Paths must be + absolute and must exist (caller validates; we re-check the shape). + """ + if isinstance(paths, str): + paths = [paths] + doc = await self.send("DOM.getDocument", {"depth": 0}) + node = await self.send( + "DOM.querySelector", + {"nodeId": doc["root"]["nodeId"], "selector": selector}, + ) + node_id = node.get("nodeId") + if not node_id: + raise CDPError(f"no file input matched selector: {selector}") + await self.send("DOM.setFileInputFiles", {"files": paths, "nodeId": node_id}) + await human_delay() + + # ---- diagnostics --------------------------------------------------- + + async def screenshot(self, path): + """Capture the page. Used on failure so a broken flow leaves a trace.""" + result = await self.send("Page.captureScreenshot", {"format": "png"}) + data = base64.b64decode(result["data"]) + with open(path, "wb") as fh: + fh.write(data) + return path diff --git a/platforms/__init__.py b/platforms/__init__.py new file mode 100644 index 0000000..2edb0ea --- /dev/null +++ b/platforms/__init__.py @@ -0,0 +1,73 @@ +"""Platform modules. + +Each module exposes two coroutines with a fixed shape: + + async def post(cdp_client, text, media_path, options) -> dict + -> {"status": "ok" | "error", "detail": "..."} (+ "screenshot" on error) + + async def check_login(cdp_client) -> bool + +`post` owns its own tab: it opens one, drives the flow, closes it. The queue +guarantees only one of these runs at a time, so there is one browser and one +tab in play (proposal: "sequential per-platform"). +""" + +import os +import time +import traceback + +from ..cdp import CDPError + +SCREENSHOT_DIR = "/tmp/socialmag" + + +def screenshot_path(platform): + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + return os.path.join( + SCREENSHOT_DIR, f"{platform}-{time.strftime('%Y%m%d-%H%M%S')}.png" + ) + + +async def guard(platform, tab, coro): + """Run one platform flow, turning any failure into a labelled result plus a + screenshot of the page as it actually looked. Never masks the failure.""" + try: + await coro + return {"status": "ok", "detail": f"{platform}: posted"} + except CDPError as exc: + shot = None + try: + shot = await tab.screenshot(screenshot_path(platform)) + except Exception: # the page may be gone; the original error still wins + pass + result = {"status": "error", "detail": f"{platform}: {exc}"} + if shot: + result["screenshot"] = shot + return result + except Exception as exc: # noqa: BLE001 — surface it, never swallow it + return { + "status": "error", + "detail": f"{platform}: unexpected {type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + + +def require_media(platform, media_path): + """ig/tiktok cannot post without media; nobody gets a silent no-op.""" + if not media_path: + raise CDPError(f"{platform} requires a media file; none was given") + validate_media(media_path) + + +def validate_media(media_path): + if not media_path: + return + if not os.path.isabs(media_path): + raise CDPError(f"media path must be absolute, got {media_path!r}") + if not os.path.exists(media_path): + raise CDPError(f"media file does not exist: {media_path}") + + +def require_text(platform, text): + if not text or not text.strip(): + raise CDPError(f"{platform} requires non-empty text") diff --git a/platforms/__pycache__/__init__.cpython-314.pyc b/platforms/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0be8c72 Binary files /dev/null and b/platforms/__pycache__/__init__.cpython-314.pyc differ diff --git a/platforms/__pycache__/facebook.cpython-314.pyc b/platforms/__pycache__/facebook.cpython-314.pyc new file mode 100644 index 0000000..393ee3b Binary files /dev/null and b/platforms/__pycache__/facebook.cpython-314.pyc differ diff --git a/platforms/__pycache__/instagram.cpython-314.pyc b/platforms/__pycache__/instagram.cpython-314.pyc new file mode 100644 index 0000000..b92ef3c Binary files /dev/null and b/platforms/__pycache__/instagram.cpython-314.pyc differ diff --git a/platforms/__pycache__/threads.cpython-314.pyc b/platforms/__pycache__/threads.cpython-314.pyc new file mode 100644 index 0000000..df77026 Binary files /dev/null and b/platforms/__pycache__/threads.cpython-314.pyc differ diff --git a/platforms/__pycache__/tiktok.cpython-314.pyc b/platforms/__pycache__/tiktok.cpython-314.pyc new file mode 100644 index 0000000..92ca076 Binary files /dev/null and b/platforms/__pycache__/tiktok.cpython-314.pyc differ diff --git a/platforms/__pycache__/youtube.cpython-314.pyc b/platforms/__pycache__/youtube.cpython-314.pyc new file mode 100644 index 0000000..66c7f22 Binary files /dev/null and b/platforms/__pycache__/youtube.cpython-314.pyc differ diff --git a/platforms/facebook.py b/platforms/facebook.py new file mode 100644 index 0000000..9ca0765 --- /dev/null +++ b/platforms/facebook.py @@ -0,0 +1,94 @@ +"""Facebook posting flow. + + 1. navigate facebook.com + 2. JS-click the composer: find the "on your mind" span, walk up to the + enclosing [role=button], .click() it + ⚠️ A real mouse click on the span opens Messenger instead. + 3. wait for the "Create post" dialog + 4. type into the dialog textbox + 5. optional: Photo/video -> setFileInputFiles + 6. optional: change audience when visibility != the current one + 7. click [aria-label="Post"] + 8. dialog closes = success +""" + +import asyncio + +from ..cdp import CDPError, human_delay +from . import guard, require_text, validate_media +from ..selectors import FACEBOOK as S + +PLATFORM = "fb" +VALID_VISIBILITY = ("public", "friends") + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_text(PLATFORM, text) + validate_media(media_path) + + visibility = options.get("visibility") + if visibility is not None and visibility not in VALID_VISIBILITY: + raise CDPError( + f"fb visibility must be one of {VALID_VISIBILITY}, got {visibility!r}" + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com") + if reused: + # Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run) + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text, media_path, visibility)) + + +async def _flow(tab, text, media_path, visibility): + await asyncio.sleep(5) + await tab.click_js(S["composer_trigger_js"]) + + await tab.wait_for(S["dialog_textbox"], timeout=30.0) + await tab.type_text(S["dialog_textbox"], text) + + if media_path: + # TODO: VALIDATE LIVE — whether the hidden file input is already in the + # DOM before the Photo/video button is clicked. + if not await tab.exists(S["file_input"]): + await tab.click_js(S["photo_video_js"]) + await tab.wait_for(S["file_input"], visible=False) + await tab.set_file_input(S["file_input"], media_path) + # TODO: VALIDATE LIVE — upload/preview settle time. + await human_delay(2.0, 3.5) + + if visibility: + # TODO: VALIDATE LIVE — the whole audience sub-dialog. We only open the + # picker here; the option list itself is unmapped, so we refuse rather + # than click something we haven't seen. + raise CDPError( + "fb visibility switching is not implemented: the audience sub-dialog " + "has not been mapped live yet. Post without `visibility` to use the " + "account's current default." + ) + + # Try aria-label first, fall back to text-match JS click + if await tab.exists(S["post_button"]): + await tab.click(S["post_button"]) + else: + await tab.click_js(S["post_button_js"]) + # FB keeps hidden [role="dialog"] elements in the DOM after the composer + # closes. Instead of waiting for all dialogs to vanish, check that the + # composer textbox is gone — that's the real "posted" signal. + await tab.wait_for_gone(S["dialog_textbox"], timeout=60.0) + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com") + if reused: + # Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run) + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(4) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "(() => !![...document.querySelectorAll('span')].find(s => s.innerText" + " && s.innerText.toLowerCase().includes('on your mind')))()" + ) diff --git a/platforms/instagram.py b/platforms/instagram.py new file mode 100644 index 0000000..2baf93a --- /dev/null +++ b/platforms/instagram.py @@ -0,0 +1,109 @@ +"""Instagram posting flow (media required). + + 1. navigate instagram.com + 2. click svg[aria-label="New post"] + 3. wait for the "Create new post" dialog + 4. setFileInputFiles on the dialog's file input (bypasses the picker) + 5. crop screen -> Next + 6. filter screen -> Next + 7. caption screen -> type caption + 8. Share + 9. dialog closes = success +""" + +import asyncio +import json + +from ..cdp import CDPError, human_delay +from . import guard, require_media +from ..selectors import INSTAGRAM as S + +PLATFORM = "ig" + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_media(PLATFORM, media_path) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text or "", media_path)) + + +async def _flow(tab, caption, media_path): + await tab.wait_for(S["new_post_svg"]) + await human_delay() + # JS click — walks up to the clickable parent of the SVG icon + await tab.eval(S["new_post_js"]) + await human_delay() + + await tab.wait_for(S["dialog"]) + await tab.wait_for_text(S["create_dialog_text"]) + + # The file input is present but hidden — visible=False on purpose. + await tab.wait_for(S["file_input"], visible=False) + await tab.set_file_input(S["file_input"], media_path) + + # TODO: VALIDATE LIVE — how long IG takes to move from picker to crop, and + # whether the crop step is skipped for already-square media. + await human_delay(2.0, 4.0) + + # Crop -> Next, filter -> Next. Same button text on both screens, so we + # click it twice with a settle between; if a screen is skipped the second + # Next simply won't be there and we fail loudly rather than guessing. + for step in ("crop", "filter"): + await _click_next(tab, step) + await human_delay(1.0, 2.0) + + await tab.wait_for(S["caption_box"]) + if caption: + await tab.type_text(S["caption_box"], caption) + + await tab.click_js(S["share_button_js"]) + + # TODO: VALIDATE LIVE — IG shows "Your post has been shared" before the + # dialog closes; either signal is success, whichever lands first. + await _wait_for_share(tab) + + +async def _click_next(tab, step): + deadline = asyncio.get_running_loop().time() + 20.0 + while asyncio.get_running_loop().time() < deadline: + found = await tab.eval("!!(%s)" % S["next_button_js"]) + if found: + await tab.click_js(S["next_button_js"]) + return + await asyncio.sleep(0.25) + raise CDPError(f"ig: no 'Next' button on the {step} screen after 20s") + + +async def _wait_for_share(tab): + deadline = asyncio.get_running_loop().time() + 90.0 + while asyncio.get_running_loop().time() < deadline: + if await tab.eval( + "document.body.innerText.includes(%s)" % json.dumps(S["shared_text"]) + ): + # Click "Done" to dismiss the confirmation dialog + await human_delay(0.5, 1.0) + try: + await tab.click_js(S["done_button_js"]) + except Exception: + pass + return + if not await tab.exists(S["dialog"]): + return + await asyncio.sleep(0.5) + raise CDPError("ig: no share confirmation and the dialog stayed open for 90s") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.exists(S["new_post_svg"]) diff --git a/platforms/threads.py b/platforms/threads.py new file mode 100644 index 0000000..da24503 --- /dev/null +++ b/platforms/threads.py @@ -0,0 +1,119 @@ +"""Threads posting 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 +""" + +import asyncio + +from ..cdp import human_delay +from . import guard, require_text, validate_media +from ..selectors import THREADS as S + +PLATFORM = "threads" + + +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"], "threads.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text, media_path)) + + +async def _flow(tab, text, media_path): + # Wait for the inline composer to appear + await _wait_for_composer(tab) + await human_delay() + + # Click the composer input area + await tab.click_js(S["composer_trigger_js"]) + await human_delay(0.5, 1.0) + + # Type into whichever input is now focused — could be a contenteditable + # or a textbox that appeared after clicking + textbox = await _find_active_input(tab) + await tab.type_text(textbox, text) + + if media_path: + if not await tab.exists(S["file_input"]): + # Look for an attach media button near the composer + await human_delay() + if await tab.exists(S["file_input"]): + await tab.set_file_input(S["file_input"], media_path) + await human_delay(1.5, 3.0) + + await human_delay(0.5, 1.0) + await tab.click_js(S["post_button_js"]) + + # Success = the composer returns to its empty placeholder state + await _wait_for_post_complete(tab) + + +async def _wait_for_composer(tab, timeout=20.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + has_input = await tab.eval( + "!!(document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" + ) + if has_input: + return + await asyncio.sleep(0.5) + from ..cdp import CDPError + raise CDPError("threads: composer ('What's new?') not found after 20s") + + +async def _find_active_input(tab): + """After clicking the composer, find the actual text input element.""" + # Check for contenteditable first (Threads often uses these) + for sel in ('[contenteditable="true"]', '[role="textbox"]', 'textarea', + '[placeholder="What\'s new?"]'): + if await tab.exists(sel): + return sel + from ..cdp import CDPError + raise CDPError("threads: no text input found after clicking composer") + + +async def _wait_for_post_complete(tab, timeout=30.0): + """Wait for the post to go through — composer empties or a toast appears.""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + # Check if the composer is back to empty placeholder state + empty = await tab.eval( + "(() => {" + " const ce = document.querySelector('[contenteditable=\"true\"]');" + " if (ce && ce.innerText.trim() === '') return true;" + " const tb = document.querySelector('[role=\"textbox\"]');" + " if (tb && tb.innerText.trim() === '') return true;" + " return false;" + "})()" + ) + if empty: + return + await asyncio.sleep(0.5) + from ..cdp import CDPError + raise CDPError("threads: composer still has text 30s after clicking Post") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(4) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "!!(document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" + ) diff --git a/platforms/tiktok.py b/platforms/tiktok.py new file mode 100644 index 0000000..eb0a4b0 --- /dev/null +++ b/platforms/tiktok.py @@ -0,0 +1,130 @@ +"""TikTok upload flow (media required). + + 1. navigate tiktokstudio/upload + 2. wait for the upload page + 3. photos: switch to the Photos tab first + 4. setFileInputFiles on the matching input[type=file] + 5. wait for processing + 6. type the caption + 7. click Post/Publish + 8. wait for the success confirmation + +TikTok is the most bot-sensitive of the five (proposal: ⭐⭐⭐⭐). Delays here +are deliberately longer than elsewhere. +""" + +import asyncio +import json +import os + +from ..cdp import CDPError, human_delay +from . import guard, require_media +from ..selectors import TIKTOK as S + +PLATFORM = "tiktok" + +VIDEO_EXTS = {".mp4", ".mov", ".webm", ".avi", ".mkv"} +PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".heic"} + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_media(PLATFORM, media_path) + + ext = os.path.splitext(media_path)[1].lower() + if ext in VIDEO_EXTS: + kind = "video" + elif ext in PHOTO_EXTS: + kind = "photo" + else: + raise CDPError( + f"tiktok: unrecognised media extension {ext!r} — " + f"videos {sorted(VIDEO_EXTS)}, photos {sorted(PHOTO_EXTS)}" + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text or "", media_path, kind)) + + +async def _flow(tab, caption, media_path, kind): + await tab.wait_for_load(timeout=45.0) + await human_delay(1.5, 3.0) + + if kind == "photo": + # TODO: VALIDATE LIVE — the Photos tab switch and the input it reveals. + await _wait_for_js(tab, S["photos_tab_js"], "the Photos tab", timeout=30.0) + await tab.click_js(S["photos_tab_js"]) + await human_delay(1.0, 2.0) + file_input = S["photo_file_input"] + else: + file_input = S["video_file_input"] + + await tab.wait_for(file_input, visible=False, timeout=45.0) + await tab.set_file_input(file_input, media_path) + + # Step 5: TikTok transcodes before it will let you post. + # TODO: VALIDATE LIVE — the real "upload finished" marker. Right now we + # wait for the Post button to become enabled, which is the behaviour the + # flow actually depends on. + await _wait_for_js( + tab, S["post_button_js"], "an enabled Post button", timeout=300.0 + ) + + if caption: + await tab.wait_for(S["caption_box"]) + # DraftJS: type per-char so its keypress handlers fire. + # TODO: VALIDATE LIVE — whether insertText is accepted here. + await tab.type_text(S["caption_box"], caption, per_char=True) + await human_delay(1.0, 2.0) + + await tab.click_js(S["post_button_js"]) + + # TikTok may show a copyright check dialog — dismiss it + await asyncio.sleep(3) + try: + has_continue = await tab.eval("!!(%s)" % S["continue_post_js"]) + if has_continue: + await tab.click_js(S["continue_post_js"]) + except Exception: + pass + + await _wait_for_success(tab) + + +async def _wait_for_js(tab, expression, what, timeout=30.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await tab.eval("!!(%s)" % expression): + return + await asyncio.sleep(0.5) + raise CDPError(f"tiktok: {what} did not appear within {timeout}s") + + +async def _wait_for_success(tab, timeout=120.0): + # TODO: VALIDATE LIVE — the confirmation copy. TikTok also redirects to + # /tiktokstudio/content on success, which we accept as the same signal. + deadline = asyncio.get_running_loop().time() + timeout + marker = json.dumps(S["success_text"]) + while asyncio.get_running_loop().time() < deadline: + if await tab.eval(f"document.body.innerText.includes({marker})"): + return + if "tiktokstudio/content" in (await tab.url() or ""): + return + await asyncio.sleep(1.0) + raise CDPError( + f"tiktok: no success confirmation within {timeout}s of clicking Post" + ) + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.exists(S["video_file_input"]) diff --git a/platforms/youtube.py b/platforms/youtube.py new file mode 100644 index 0000000..a548fe5 --- /dev/null +++ b/platforms/youtube.py @@ -0,0 +1,106 @@ +"""YouTube community post flow. + + 1. navigate youtube.com + 2. click button[aria-label="Create"] + 3. click "Create post" in the dropdown + 4. wait for the redirect to the channel posts page + 5. type into #contenteditable-root + 6. click Post + 7. the editor emptying / the post appearing = success + +Community posts require a channel that has them enabled. Video upload +(post_youtube_video in the proposal) is NOT built here — the brief scoped this +module to the community post. +""" + +import asyncio + +from ..cdp import CDPError, human_delay +from . import guard, require_text, validate_media +from ..selectors import YOUTUBE as S + +PLATFORM = "yt" + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_text(PLATFORM, text) + validate_media(media_path) + if media_path: + # Community posts do take images, but that flow is unmapped. Refuse + # rather than drop the media on the floor. + raise CDPError( + "yt: media on community posts is not implemented (the image-attach " + "flow has not been mapped live). Post text-only, or use a platform " + "whose media flow is built." + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text)) + + +async def _flow(tab, text): + await tab.wait_for(S["create_button"]) + await human_delay() + await tab.click(S["create_button"]) + + # TODO: VALIDATE LIVE — the dropdown item text and element type. + await _wait_for_js(tab, S["create_post_item_js"], "the 'Create post' menu item") + await tab.click_js(S["create_post_item_js"]) + + # Step 4: YouTube navigates to the channel's posts page. + await tab.wait_for_load(timeout=30.0) + await tab.wait_for(S["editor"], timeout=30.0) + + # #contenteditable-root is a YouTube custom editor: it listens to real key + # events, so type per-char rather than insertText. + # TODO: VALIDATE LIVE — whether insertText alone enables the Post button. + await tab.type_text(S["editor"], text, per_char=True) + + if await tab.exists(S["post_button"]): + await tab.click(S["post_button"]) + else: + await tab.click_js(S["post_button_js"]) + + # TODO: VALIDATE LIVE — success marker. The editor going empty is the + # cheapest observable signal; the proposal says "post appears". + await _wait_for_editor_empty(tab) + + +async def _wait_for_js(tab, expression, what, timeout=20.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await tab.eval("!!(%s)" % expression): + return + await asyncio.sleep(0.25) + raise CDPError(f"yt: {what} did not appear within {timeout}s") + + +async def _wait_for_editor_empty(tab, timeout=30.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + text = await tab.eval( + "(() => { const e = document.querySelector('%s');" + " return e ? e.innerText.trim() : null; })()" % S["editor"] + ) + if text == "": + return + await asyncio.sleep(0.5) + raise CDPError("yt: editor still holds text 30s after clicking Post") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "!!document.querySelector('#avatar-btn, button#avatar-btn," + " ytd-topbar-menu-button-renderer #avatar-btn')" + ) diff --git a/queue.py b/queue.py new file mode 100644 index 0000000..a4d4803 --- /dev/null +++ b/queue.py @@ -0,0 +1,90 @@ +"""Sequential execution queue — one browser, one tab at a time. + +Every platform flow drives abang's real browser window. Two flows at once +would fight over focus, file inputs and dialogs, so everything funnels through +one worker task here. + + q = PostQueue(inter_platform_delay=(3.0, 6.0)) + await q.start() + result = await q.submit("threads", coro_factory) + +`submit` returns that job's result (or raises what the job raised). Jobs run +strictly in submission order. +""" + +import asyncio +import random +import time + + +class PostQueue: + def __init__(self, inter_platform_delay=(3.0, 6.0)): + lo, hi = inter_platform_delay + if lo < 0 or hi < lo: + raise ValueError( + f"inter_platform_delay must be (lo, hi) with 0 <= lo <= hi, got {lo, hi}" + ) + self.delay_lo, self.delay_hi = lo, hi + self._queue = asyncio.Queue() + self._worker = None + self._last_finished = None + self.history = [] # (iso_time, label, status) + + async def start(self): + if self._worker is None: + self._worker = asyncio.create_task(self._run()) + return self + + async def stop(self): + if self._worker: + self._worker.cancel() + try: + await self._worker + except asyncio.CancelledError: + pass + self._worker = None + + @property + def depth(self): + return self._queue.qsize() + + async def submit(self, label, factory): + """Queue `factory()` (a zero-arg coroutine function) and await its result.""" + if self._worker is None: + raise RuntimeError("PostQueue.submit() before start()") + future = asyncio.get_running_loop().create_future() + await self._queue.put((label, factory, future)) + return await future + + async def _run(self): + while True: + label, factory, future = await self._queue.get() + await self._space_out() + try: + result = await factory() + if not future.done(): + future.set_result(result) + status = result.get("status") if isinstance(result, dict) else "ok" + except asyncio.CancelledError: + if not future.done(): + future.cancel() + raise + except Exception as exc: # noqa: BLE001 — hand it to the caller intact + if not future.done(): + future.set_exception(exc) + status = f"raised {type(exc).__name__}" + finally: + self._last_finished = time.time() + self._queue.task_done() + self.history.append( + (time.strftime("%Y-%m-%dT%H:%M:%S"), label, status) + ) + + async def _space_out(self): + """Rate-limit gap between consecutive platform jobs.""" + if self._last_finished is None: + return + gap = random.uniform(self.delay_lo, self.delay_hi) + elapsed = time.time() - self._last_finished + if elapsed < gap: + await asyncio.sleep(gap - elapsed) diff --git a/selectors.py b/selectors.py new file mode 100644 index 0000000..31ace2c --- /dev/null +++ b/selectors.py @@ -0,0 +1,165 @@ +"""Every platform selector, in one file. + +When a platform ships a UI change, this is the only file that should need +touching (proposal, Risks table). Nothing here is guessed silently — anything +that came from a doc rather than a live FAFO session is marked, and every +selector a flow depends on is validated live before we trust it. + +Convention: + *_SEL CSS selector string + *_JS a JS expression returning an element (for click_js) +""" + +# -------------------------------------------------------------------------- +# Threads +# -------------------------------------------------------------------------- + +THREADS = { + "url": "https://www.threads.com/", + # New inline composer (2026-09 redesign) — no dialog. + "composer_input": '[placeholder="What\'s new?"]', + "composer_trigger_js": ( + "document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?')" + ), + "post_button_js": ( + "[...document.querySelectorAll('button, [role=\"button\"]')]" + ".find(b => b.innerText.trim() === 'Post')" + ), + # Legacy dialog selectors (kept for fallback) + "dialog": '[role="dialog"]', + "dialog_textbox": '[role="dialog"] [role="textbox"]', + "attach_media": '[role="dialog"] [aria-label="Attach media"]', + "file_input": 'input[type="file"]', + "login_marker": 'input[name="username"], a[href*="/login"]', +} + +# -------------------------------------------------------------------------- +# Facebook +# -------------------------------------------------------------------------- + +FACEBOOK = { + "url": "https://www.facebook.com/", + # ⚠️ Mouse-clicking the visible "What's on your mind" span opens Messenger. + # Walk up to the enclosing [role=button] and .click() it instead. + "composer_trigger_js": ( + "(() => { const span = [...document.querySelectorAll('span')]" + ".find(s => s.innerText && s.innerText.toLowerCase().includes('on your mind'));" + " return span ? span.closest('[role=\"button\"]') : null; })()" + ), + "dialog": '[role="dialog"]', + "dialog_textbox": '[role="dialog"] [role="textbox"][contenteditable="true"]', + # TODO: VALIDATE LIVE — the Photo/video button inside the create-post dialog. + "photo_video_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]" + ".find(b => /photo\\/video/i.test(b.innerText || ''))" + ), + "file_input": '[role="dialog"] input[type="file"][accept*="image"]', + # TODO: VALIDATE LIVE — audience/privacy selector inside the dialog. + "privacy_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]" + ".find(b => /public|friends|audience/i.test(b.innerText || ''))" + ), + "post_button": '[role="dialog"] [aria-label="Post"]', + "post_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Post')" + ), + "create_post_text": "Create post", + "login_marker": 'input[name="email"][type="text"], form[action*="login"]', +} + +# -------------------------------------------------------------------------- +# Instagram +# -------------------------------------------------------------------------- + +INSTAGRAM = { + "url": "https://www.instagram.com/", + "new_post_svg": 'svg[aria-label="New post"]', + # JS click — more reliable than mouse on sidebar SVG icons + "new_post_js": ( + "(() => { const svg = document.querySelector('svg[aria-label=\"New post\"]');" + " if (svg) { const btn = svg.closest('a, button, [role=\"button\"], div[role=\"button\"]')" + " || svg.parentElement; btn.click(); return true; } return false; })()" + ), + "dialog": '[role="dialog"]', + "create_dialog_text": "Create new post", + "file_input": '[role="dialog"] input[type="file"]', + "next_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Next')" + ), + "caption_box": '[role="dialog"] textarea, [role="dialog"] [contenteditable="true"]', + "share_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Share')" + ), + "shared_text": "Your post has been shared", + "done_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Done')" + ), + "login_marker": 'input[name="username"]', +} + +# -------------------------------------------------------------------------- +# YouTube — community post +# -------------------------------------------------------------------------- + +YOUTUBE = { + "url": "https://www.youtube.com/", + "create_button": 'button[aria-label="Create"]', + # TODO: VALIDATE LIVE — the dropdown item is a tp-yt-paper-item with text. + "create_post_item_js": ( + "[...document.querySelectorAll('tp-yt-paper-item, ytd-compact-link-renderer, " + "yt-formatted-string')].find(e => e.innerText && " + "e.innerText.trim().toLowerCase() === 'create post')" + ), + "editor": "#contenteditable-root", + "post_button": 'button[aria-label="Post"]', + # TODO: VALIDATE LIVE — YouTube also renders Post as ytd-button-renderer text. + "post_button_js": ( + "[...document.querySelectorAll('button, tp-yt-paper-button')]" + ".find(b => b.innerText.trim().toLowerCase() === 'post')" + ), + "login_marker": 'a[href*="accounts.google.com/ServiceLogin"]', +} + +# -------------------------------------------------------------------------- +# TikTok +# -------------------------------------------------------------------------- + +TIKTOK = { + "url": "https://www.tiktok.com/tiktokstudio/upload?from=upload", + "video_file_input": 'input[type="file"][accept*="video"]', + "photo_file_input": 'input[type="file"][accept*="image"]', + # TODO: VALIDATE LIVE — the Photos/Video tab switch on the upload page. + "photos_tab_js": ( + "[...document.querySelectorAll('div, span, button')]" + ".find(e => e.innerText && e.innerText.trim() === 'Photos')" + ), + # TikTok's caption box is a DraftJS contenteditable. + "caption_box": 'div[contenteditable="true"]', + # TODO: VALIDATE LIVE — upload progress marker; used to know processing is done. + "uploaded_text": "Uploaded", + "post_button_js": ( + "[...document.querySelectorAll('button')]" + ".find(b => ['post','publish'].includes(b.innerText.trim().toLowerCase()) " + "&& !b.disabled)" + ), + "success_text": "Your video is being uploaded", + "continue_post_js": ( + "[...document.querySelectorAll('button, [role=\"button\"]')]" + ".find(b => /continue to post|post now/i.test(b.innerText.trim()))" + ), + "login_marker": 'a[href*="/login"]', +} + +PLATFORMS = { + "threads": THREADS, + "fb": FACEBOOK, + "ig": INSTAGRAM, + "yt": YOUTUBE, + "tiktok": TIKTOK, +} diff --git a/server.py b/server.py new file mode 100644 index 0000000..08025ed --- /dev/null +++ b/server.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""socialmag — MCP stdio server. + +Three tools: + create_post post to one or more platforms through abang's logged-in Brave + check_login which platforms are currently signed in + get_status the last create_post run's results + +Transport is the standard MCP stdio pattern: one JSON-RPC message per line on +stdin, one per line on stdout. Nothing but protocol goes to stdout — logs go +to stderr. + +Run: python3 -m socialmag (from the parent dir; expects Brave on :9222) + NOT `python3 server.py` — selectors.py/queue.py shadow stdlib modules + unless this runs as a package. See __init__.py. +""" + +import asyncio +import json +import os +import sys +import time + +from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT +from .platforms import facebook, instagram, threads, tiktok, youtube +from .queue import PostQueue + +PROTOCOL_VERSION = "2025-06-18" +SERVER_NAME = "socialmag" +SERVER_VERSION = "0.1.0" + +STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json") + +MODULES = { + "threads": threads, + "fb": facebook, + "ig": instagram, + "yt": youtube, + "tiktok": tiktok, +} + +# 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) + + +def log(*args): + print(f"[socialmag {time.strftime('%H:%M:%S')}]", *args, file=sys.stderr, flush=True) + + +# --------------------------------------------------------------------------- +# Tool schemas +# --------------------------------------------------------------------------- + +TOOLS = [ + { + "name": "create_post", + "description": ( + "Post to one or more social platforms through the logged-in Brave " + "browser (CDP). Platforms run sequentially, one tab at a time. " + "Returns a per-platform status map." + ), + "inputSchema": { + "type": "object", + "properties": { + "platforms": { + "type": "array", + "items": {"enum": sorted(MODULES)}, + "description": "Any combination of threads, fb, ig, yt, tiktok.", + }, + "text": { + "type": "string", + "description": ( + "Post body. Required for threads/fb/yt; used as the " + "caption for ig/tiktok." + ), + }, + "media": { + "type": "string", + "description": ( + "Absolute path to an image/video. Optional for " + "threads/fb; REQUIRED for ig/tiktok; not supported " + "for yt community posts." + ), + }, + "visibility": { + "enum": ["public", "friends"], + "description": "Facebook audience. Not implemented yet — see detail.", + }, + }, + "required": ["platforms"], + }, + }, + { + "name": "check_login", + "description": ( + "Check which platforms the browser is signed in to. Opens and " + "closes one tab per platform; no posting." + ), + "inputSchema": { + "type": "object", + "properties": { + "platforms": { + "type": "array", + "items": {"enum": sorted(MODULES)}, + "description": "Defaults to all five when omitted.", + } + }, + }, + }, + { + "name": "get_status", + "description": "Results of the last create_post run, plus queue history.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + + +def save_state(state): + tmp = STATE_PATH + ".tmp" + with open(tmp, "w") as fh: + json.dump(state, fh, indent=2) + os.replace(tmp, STATE_PATH) + + +def load_state(): + if not os.path.exists(STATE_PATH): + return {"last_post": None, "results": {}} + with open(STATE_PATH) as fh: + return json.load(fh) + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + + +async def tool_create_post(args, queue): + platforms = args.get("platforms") + if not platforms: + raise ValueError("create_post: 'platforms' is required and must be non-empty") + unknown = [p for p in platforms if p not in MODULES] + if unknown: + raise ValueError( + f"create_post: unknown platform(s) {unknown}; known: {sorted(MODULES)}" + ) + + text = args.get("text") + media = args.get("media") + options = {} + if "visibility" in args: + options["visibility"] = args["visibility"] + + results = {} + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + log(f"connected to {cdp.browser_version} at {CDP_ENDPOINT}") + try: + for name in platforms: + module = MODULES[name] + + def factory(module=module): + return module.post(cdp, text, media, options) + + try: + results[name] = await queue.submit(name, factory) + except CDPError as exc: + # Raised before the flow reached its guard (bad args, bad media). + results[name] = {"status": "error", "detail": f"{name}: {exc}"} + except Exception as exc: # noqa: BLE001 + results[name] = { + "status": "error", + "detail": f"{name}: unexpected {type(exc).__name__}: {exc}", + } + log(f"{name} -> {results[name]['status']}") + finally: + await cdp.close() + + state = { + "last_post": time.strftime("%Y-%m-%dT%H:%M:%S"), + "results": results, + "text": text, + "media": media, + } + save_state(state) + return results + + +async def tool_check_login(args, queue): + platforms = args.get("platforms") or sorted(MODULES) + unknown = [p for p in platforms if p not in MODULES] + if unknown: + raise ValueError( + f"check_login: unknown platform(s) {unknown}; known: {sorted(MODULES)}" + ) + + out = {} + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + for name in platforms: + module = MODULES[name] + + def factory(module=module): + return module.check_login(cdp) + + try: + out[name] = bool(await queue.submit(f"check:{name}", factory)) + except Exception as exc: # noqa: BLE001 — a failed check is not a False + out[name] = f"error: {type(exc).__name__}: {exc}" + finally: + await cdp.close() + return out + + +async def tool_get_status(args, queue): + state = load_state() + state["queue_history"] = queue.history[-20:] + state["queue_depth"] = queue.depth + # Show open browser tabs so the caller can see what's reusable + try: + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + targets = await cdp.list_targets() + state["open_tabs"] = [ + {"id": t["targetId"], "url": t.get("url", ""), "title": t.get("title", "")} + for t in targets + ] + await cdp.close() + except Exception: + state["open_tabs"] = "could not connect to browser" + return state + + +HANDLERS = { + "create_post": tool_create_post, + "check_login": tool_check_login, + "get_status": tool_get_status, +} + + +# --------------------------------------------------------------------------- +# JSON-RPC / MCP plumbing +# --------------------------------------------------------------------------- + + +class Server: + def __init__(self): + self.queue = PostQueue() + + async def handle(self, msg): + """Return a response dict, or None for notifications.""" + method = msg.get("method") + mid = msg.get("id") + params = msg.get("params") or {} + + if method == "initialize": + return self._ok( + mid, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + }, + ) + if method in ("notifications/initialized", "initialized"): + return None + if method == "ping": + return self._ok(mid, {}) + if method == "tools/list": + return self._ok(mid, {"tools": TOOLS}) + if method == "tools/call": + return await self._call_tool(mid, params) + if mid is None: + return None # unknown notification — ignore, don't answer + return self._err(mid, -32601, f"method not found: {method}") + + async def _call_tool(self, mid, params): + name = params.get("name") + args = params.get("arguments") or {} + handler = HANDLERS.get(name) + if handler is None: + return self._err(mid, -32602, f"unknown tool: {name}") + try: + result = await handler(args, self.queue) + except Exception as exc: # noqa: BLE001 — report as a tool error, loudly + log(f"tool {name} failed: {type(exc).__name__}: {exc}") + return self._ok( + mid, + { + "content": [ + {"type": "text", "text": f"{type(exc).__name__}: {exc}"} + ], + "isError": True, + }, + ) + return self._ok( + mid, + {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]}, + ) + + @staticmethod + def _ok(mid, result): + return {"jsonrpc": "2.0", "id": mid, "result": result} + + @staticmethod + def _err(mid, code, message): + return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} + + +async def main(): + server = Server() + await server.queue.start() + + reader = asyncio.StreamReader() + await asyncio.get_running_loop().connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), sys.stdin + ) + log(f"listening on stdio · CDP endpoint {CDP_ENDPOINT}") + + while True: + line = await reader.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError as exc: + log(f"dropping unparseable line: {exc}") + continue + response = await server.handle(msg) + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + await server.queue.stop() + log("stdin closed, exiting") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/state.json b/state.json new file mode 100644 index 0000000..beb807f --- /dev/null +++ b/state.json @@ -0,0 +1,11 @@ +{ + "last_post": "2026-09-10T21:06:30", + "results": { + "tiktok": { + "status": "ok", + "detail": "tiktok: posted" + } + }, + "text": "vibes check, late night energy \ud83c\udf19\u2728", + "media": "/mnt/playground/Server_files/Personal_pic/O.E.P/bahaya_tema_ni_haha.mp4" +} \ No newline at end of file