socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok)

This commit is contained in:
2026-09-10 21:10:55 +08:00
commit a4f33f456e
28 changed files with 2053 additions and 0 deletions
+114
View File
@@ -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.*
+193
View File
@@ -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
+75
View File
@@ -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").
+11
View File
@@ -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)
"""
+11
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+401
View File
@@ -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 <input type=file>. 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
+73
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -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')))()"
)
+109
View File
@@ -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"])
+119
View File
@@ -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?'))"
)
+130
View File
@@ -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"])
+106
View File
@@ -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')"
)
+90
View File
@@ -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)
+165
View File
@@ -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,
}
+351
View File
@@ -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
+11
View File
@@ -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"
}