socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok)
This commit is contained in:
+114
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
@@ -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
|
||||
@@ -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.
@@ -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')))()"
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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?'))"
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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')"
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user