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