From a4f33f456ef11760a493526f12ef3edfe00679f0 Mon Sep 17 00:00:00 2001 From: Muhammad Aizat Date: Thu, 10 Sep 2026 21:10:55 +0800 Subject: [PATCH] =?UTF-8?q?socialmag=20v0.1=20=E2=80=94=204/5=20platforms?= =?UTF-8?q?=20live=20(Threads,=20FB,=20IG,=20TikTok)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MANIFEST.md | 114 +++++ PROPOSAL.md | 193 +++++++++ README.md | 75 ++++ __init__.py | 11 + __main__.py | 11 + __pycache__/__init__.cpython-314.pyc | Bin 0 -> 765 bytes __pycache__/__main__.cpython-314.pyc | Bin 0 -> 456 bytes __pycache__/cdp.cpython-314.pyc | Bin 0 -> 25344 bytes __pycache__/queue.cpython-314.pyc | Bin 0 -> 5689 bytes __pycache__/selectors.cpython-314.pyc | Bin 0 -> 5028 bytes __pycache__/server.cpython-314.pyc | Bin 0 -> 15112 bytes cdp.py | 401 ++++++++++++++++++ platforms/__init__.py | 73 ++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 3531 bytes .../__pycache__/facebook.cpython-314.pyc | Bin 0 -> 5320 bytes .../__pycache__/instagram.cpython-314.pyc | Bin 0 -> 6884 bytes platforms/__pycache__/threads.cpython-314.pyc | Bin 0 -> 6601 bytes platforms/__pycache__/tiktok.cpython-314.pyc | Bin 0 -> 7715 bytes platforms/__pycache__/youtube.cpython-314.pyc | Bin 0 -> 6405 bytes platforms/facebook.py | 94 ++++ platforms/instagram.py | 109 +++++ platforms/threads.py | 119 ++++++ platforms/tiktok.py | 130 ++++++ platforms/youtube.py | 106 +++++ queue.py | 90 ++++ selectors.py | 165 +++++++ server.py | 351 +++++++++++++++ state.json | 11 + 28 files changed, 2053 insertions(+) create mode 100644 MANIFEST.md create mode 100644 PROPOSAL.md create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __main__.py create mode 100644 __pycache__/__init__.cpython-314.pyc create mode 100644 __pycache__/__main__.cpython-314.pyc create mode 100644 __pycache__/cdp.cpython-314.pyc create mode 100644 __pycache__/queue.cpython-314.pyc create mode 100644 __pycache__/selectors.cpython-314.pyc create mode 100644 __pycache__/server.cpython-314.pyc create mode 100644 cdp.py create mode 100644 platforms/__init__.py create mode 100644 platforms/__pycache__/__init__.cpython-314.pyc create mode 100644 platforms/__pycache__/facebook.cpython-314.pyc create mode 100644 platforms/__pycache__/instagram.cpython-314.pyc create mode 100644 platforms/__pycache__/threads.cpython-314.pyc create mode 100644 platforms/__pycache__/tiktok.cpython-314.pyc create mode 100644 platforms/__pycache__/youtube.cpython-314.pyc create mode 100644 platforms/facebook.py create mode 100644 platforms/instagram.py create mode 100644 platforms/threads.py create mode 100644 platforms/tiktok.py create mode 100644 platforms/youtube.py create mode 100644 queue.py create mode 100644 selectors.py create mode 100644 server.py create mode 100644 state.json diff --git a/MANIFEST.md b/MANIFEST.md new file mode 100644 index 0000000..53d2bec --- /dev/null +++ b/MANIFEST.md @@ -0,0 +1,114 @@ +# socialmag — Social Media MCP Server + +**One tool, all platforms.** Post to Threads, Facebook, Instagram, TikTok (and YouTube when eligible) through a single MCP call. Built on CDP — no API keys, no rate limits, no approval process. Just a browser and a logged-in account. + +## What It Does + +- **`create_post`** — Post text/media to one or more platforms in one call +- **`check_login`** — Verify which platforms are logged in +- **`get_status`** — Last post results + open browser tabs + +## Architecture + +``` +Claude/MCP Client + ↓ stdio (JSON-RPC) +socialmag server.py + ↓ CDP WebSocket +Brave Browser (dedicated instance, port 9336) + ↓ real browser sessions +Threads · Facebook · Instagram · TikTok · YouTube +``` + +- **Tab reuse** — attaches to existing tabs, no cold loads +- **Per-platform modules** — each platform has its own flow + selectors +- **Sequential queue** — one platform at a time, no race conditions +- **Error screenshots** — failed flows capture the page state to `/tmp/socialmag/` + +## Platform Status + +| Platform | Text | Media | Tab Reuse | Status | +|----------|:----:|:-----:|:---------:|--------| +| Threads | ✅ | 🔲 | ✅ | **Live** — inline composer | +| Facebook | ✅ | 🔲 | ✅ | **Live** — dialog flow | +| Instagram | — | ✅ | ✅ | **Live** — media required | +| TikTok | — | ✅ | ✅ | **Live** — video/photo upload | +| YouTube | 🔲 | 🔲 | ✅ | **Parked** — needs 500 subs | + +✅ = tested live · 🔲 = coded but untested · — = not applicable + +## Stack + +- Python 3 (stdlib + websockets) +- Chrome DevTools Protocol (CDP) +- MCP stdio transport +- Brave Browser with `--remote-debugging-port` + +## Browser Setup + +```bash +# Dedicated Brave instance (separate profile, no interference) +brave --remote-debugging-port=9336 \ + --user-data-dir=$HOME/.local/share/brave-socialmag \ + --disable-features=BeforeUnloadDialog \ + --mute-audio --no-first-run +``` + +Log into each platform manually once. socialmag reuses those sessions. + +## Future Roadmap + +### v0.2 — Read Side +- [ ] `get_notifications(platform)` — pull notifications via CDP scraping +- [ ] `get_comments(platform, post_id?)` — read comments on recent posts +- [ ] `get_engagement(platform)` — likes, shares, reach per post + +### v0.3 — Monitoring +- [ ] Cron/loop polling — periodic checks, dump to JSON +- [ ] Unified notification feed across all platforms +- [ ] Alert on new comments/DMs + +### v0.4 — Dashboard +- [ ] Web dashboard — one view for all platform activity +- [ ] Post scheduler — queue posts for specific times +- [ ] Analytics — engagement trends, best posting times + +### v0.5 — Media Pipeline +- [ ] Threads media posting (image + video) +- [ ] Facebook media posting +- [ ] YouTube video upload (separate from community posts) +- [ ] Auto-resize/format media per platform requirements + +### Later +- [ ] Multi-account support +- [ ] Platform API integration (where available + worthwhile) +- [ ] Content calendar +- [ ] AI-assisted caption generation + +## File Structure + +``` +socialmag/ +├── __init__.py # package marker +├── __main__.py # python -m socialmag entry +├── server.py # MCP stdio server + tool handlers +├── cdp.py # CDP WebSocket client +├── selectors.py # all platform selectors in one file +├── queue.py # sequential post queue +├── platforms/ +│ ├── __init__.py # shared helpers (guard, require_media, etc.) +│ ├── threads.py +│ ├── facebook.py +│ ├── instagram.py +│ ├── tiktok.py +│ └── youtube.py +└── MANIFEST.md # this file +``` + +## License + +Private. Shared among collaborators by invitation. + +--- + +*Born 2026-09-10. Built by the household — Celeste architected, Umbra built the skeleton, abang drove the FAFO.* diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..c062658 --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,193 @@ +# socialmag — Social Media MCP Server +# Proposal Draft · 2026-09-10 + +## What + +An MCP server that lets any household agent post to social media platforms via a single +tool call. Uses bravemag's CDP connection (port 9222) to abang's logged-in Brave browser. +No API keys, no OAuth dance, no monthly fees. + +## Architecture + +``` +┌─ Any Agent ─────────────────────┐ +│ create_post( │ +│ platforms: ["threads","fb"], │ +│ text: "Hello world 🚀", │ +│ media: "/path/to/img.jpg", │ ← optional +│ schedule: null │ ← future: cron/delay +│ ) │ +└─────────────┬───────────────────┘ + ↓ +┌─ socialmag (MCP server) ────────┐ +│ Python · stdio MCP interface │ +│ CDP client → ws://127.0.0.1:9222 +│ │ +│ Queue: sequential per-platform │ +│ (one browser, one tab at a time) +│ │ +│ ┌─ post_threads(text, media?) │ +│ ├─ post_facebook(text, media?) │ +│ ├─ post_instagram(media, caption) +│ ├─ post_youtube(text) │ ← community post +│ ├─ post_youtube_video(file, title, desc) +│ └─ post_tiktok(file, caption) │ +│ │ +│ Returns: per-platform status │ +│ {threads: "ok", fb: "ok", ...} │ +└─────────────────────────────────┘ +``` + +## MCP Tools (public interface) + +### 1. `create_post` — the main tool +```json +{ + "platforms": ["threads", "fb", "ig", "yt", "tiktok"], + "text": "Post content here", + "media": "/absolute/path/to/file.jpg", + "visibility": "public" +} +``` +- `platforms`: array, pick any combo +- `text`: required for threads/fb/yt. Used as caption for ig/tiktok +- `media`: optional for threads/fb/yt. REQUIRED for ig/tiktok +- `visibility`: "public" | "friends" (fb only) + +### 2. `check_login` — verify which platforms are logged in +```json +{} +→ {"threads": true, "fb": true, "ig": true, "yt": true, "tiktok": true} +``` + +### 3. `get_status` — last post results +```json +{} +→ {"last_post": "2026-09-10T19:30:00", "results": {...}} +``` + +## Per-Platform Posting Logic (from FAFO recon) + +### Threads ⭐ +``` +1. navigate("https://www.threads.net/") +2. click([aria-label="Empty text field. Type to compose a new post."]) +3. wait for [role=dialog] +4. type into [role=dialog] [role=textbox] +5. if media: inject via CDP DOM.setFileInputFiles on "Attach media" flow +6. click "Post" button in dialog +7. wait for dialog close = success +``` + +### Facebook ⭐⭐ +``` +1. navigate("https://www.facebook.com/") +2. JS: find span with "on your mind" → walk parent to [role=button] → click() + ⚠️ Direct element click triggers Messenger! +3. wait for [role=dialog] containing "Create post" +4. type into dialog [role=textbox][contenteditable=true] +5. if media: click "Photo/video" → CDP setFileInputFiles +6. if visibility != "friends": click privacy button → change +7. click [aria-label="Post"] +8. wait for dialog close = success +``` + +### Instagram ⭐⭐⭐⭐ +``` +1. navigate("https://www.instagram.com/") +2. click(svg[aria-label="New post"]) +3. wait for "Create new post" dialog +4. CDP: DOM.setFileInputFiles on input[type=file] → inject media + (bypasses native file picker) +5. wait for crop screen → click "Next" +6. wait for filter screen → click "Next" +7. wait for caption screen → type caption in textarea +8. click "Share" +9. wait for dialog close = success +``` + +### YouTube (community post) ⭐⭐ +``` +1. navigate("https://www.youtube.com/") +2. click(button[aria-label="Create"]) +3. click "Create post" in dropdown menu +4. wait for redirect to channel/posts page +5. type into #contenteditable-root +6. click button[aria-label="Post"] +7. wait for post to appear = success +``` + +### TikTok ⭐⭐⭐⭐ +``` +1. navigate("https://www.tiktok.com/tiktokstudio/upload?from=upload") +2. wait for upload page +3. for video: CDP setFileInputFiles on input[type=file][accept="video/*"] +4. for photos: click "Photos" tab first, then upload +5. wait for processing +6. type caption/description +7. set hashtags, settings +8. click "Post" / "Publish" +9. wait for success confirmation +``` + +## Tech Stack + +- **Python 3** (stdlib-only server, same pattern as tiledmag/pixel-room-mcp) +- **CDP client**: `websockets` library for WebSocket to Chrome DevTools +- **MCP interface**: stdio (standard MCP pattern) +- **No framework**: plain Python, no LangChain/FastAPI/etc +- **Systemd managed**: `socialmag.service`, auto-restart + +## File Structure + +``` +/mnt/playground/Playground_V2/socialmag/ +├── server.py # MCP server + tool definitions +├── cdp.py # CDP WebSocket client (navigate, click, type, setFiles) +├── platforms/ +│ ├── threads.py # Threads posting logic +│ ├── facebook.py # Facebook posting logic +│ ├── instagram.py # Instagram posting logic +│ ├── youtube.py # YouTube posting logic +│ └── tiktok.py # TikTok posting logic +├── selectors.py # All platform selectors in one place +├── queue.py # Sequential execution queue +└── README.md +``` + +## Dependencies + +- `websockets` (for CDP WebSocket) +- That's it. Everything else is stdlib. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Platform UI changes break selectors | selectors.py is one file — update and go | +| Bot detection (especially TikTok) | Human-like delays between actions (random 0.5-2s) | +| Browser not logged in | `check_login` tool verifies before posting | +| Brave restart kills CDP | Health check + clear error message | +| Rate limiting | Queue with configurable delay between platforms | +| Media file not found | Validate path exists before starting | +| Multi-step IG/TikTok flow fails mid-way | Per-step error handling, screenshot on failure | + +## Future (not now) + +- Schedule posts (cron integration) +- Analytics reading (pull post performance) +- Reply/DM automation +- Multi-account support +- Template system ("post this format every Monday") + +## Build Order + +1. CDP client (cdp.py) — navigate, click, type, setFiles +2. Threads posting (simplest, validates the pattern) +3. Facebook posting +4. YouTube community post +5. Instagram posting (media flow) +6. TikTok posting (media flow) +7. MCP server wrapper (server.py) +8. Queue + error handling +9. systemd service diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dc4a85 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# socialmag + +MCP server that posts to Threads / Facebook / Instagram / YouTube / TikTok by +driving abang's already-logged-in Brave over CDP. No API keys, no OAuth. + +Built to `PROPOSAL.md` (2026-09-10). **Nothing here has been run against a live +platform yet** — every step whose selector or timing needs a real page is +marked `# TODO: VALIDATE LIVE`. + +## Run + +```bash +cd /mnt/playground/Playground_V2 +python3 -m socialmag # stdio MCP server, logs to stderr +``` + +⚠️ **Must run as a package.** `python3 socialmag/server.py` puts the package +dir first on `sys.path`, where `selectors.py` and `queue.py` shadow the stdlib +modules `asyncio` imports — asyncio then dies at import with +`module 'selectors' has no attribute 'SelectSelector'`. Run `-m`. + +Brave must be listening on `http://127.0.0.1:9222`. Override with +`SOCIALMAG_CDP_ENDPOINT`. + +Requires `websockets` (17.1 present on this box). Everything else is stdlib. + +## Tools + +| Tool | Args | Returns | +|---|---|---| +| `create_post` | `platforms[]`, `text`, `media`, `visibility` | `{platform: {status, detail}}` | +| `check_login` | `platforms[]` (default all) | `{platform: true/false/"error: ..."}` | +| `get_status` | — | last run's results + queue history | + +`text` is required for threads/fb/yt and used as the caption for ig/tiktok. +`media` must be an **absolute path to an existing file**; it is required for +ig/tiktok, optional for threads/fb, and rejected for yt. + +## Layout + +``` +server.py MCP stdio JSON-RPC loop + the 3 tools +cdp.py CDP client: CDPClient (browser) + Tab (page verbs) +queue.py PostQueue — strictly sequential, one tab at a time +selectors.py every platform selector, one file +platforms/ one module per platform: post() + check_login() +``` + +Each platform module exposes: + +```python +async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail": ...} +async def check_login(cdp_client) -> bool +``` + +`post` opens its own tab, drives the flow, closes the tab. A failure inside the +flow is caught by `platforms.guard`, which screenshots the page as it actually +looked into `/tmp/socialmag/` and puts the path in the result. + +## House rules honoured + +- **No silent fallbacks.** Missing media, missing text, an unknown platform, a + selector that never appears, a CDP call that errors — all raise with the + reason. Nothing picks a default and carries on. +- Unmapped flows **refuse** rather than guess: Facebook `visibility` switching + and YouTube media both raise, because clicking through an unmapped dialog + outward-facing is worse than a stop. +- Human-like delays (`random.uniform`) between every discrete action, longer on + TikTok. + +## Not built (out of the brief's scope) + +- `post_youtube_video` (video upload) — the brief scoped yt to community posts. +- `socialmag.service` — explicitly deferred until after live validation. +- Scheduling, analytics, multi-account (proposal "Future"). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cb72106 --- /dev/null +++ b/__init__.py @@ -0,0 +1,11 @@ +"""socialmag — post to social platforms through abang's logged-in Brave via CDP. + +This is a package, not a pile of scripts, for one concrete reason: two of the +module names the design calls for — `selectors.py` and `queue.py` — collide +with stdlib modules that asyncio itself imports. Run flat and Python resolves +`selectors` to ours, and asyncio dies at import. As a package with relative +imports, the names are ours inside and the stdlib's outside. + +Run it as: python3 -m socialmag (from the parent directory) +Not as: python3 socialmag/server.py (re-triggers the shadowing) +""" diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..fd436e2 --- /dev/null +++ b/__main__.py @@ -0,0 +1,11 @@ +"""Entry point: python3 -m socialmag""" + +import asyncio + +from .server import main + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c490ded24a63569beeb3eb954bf3e4f3932d51fc GIT binary patch literal 765 zcmYk4y^hmB5XbFs0%QrEVI&GJB8ergJ0T=KY7_-TLF4Rs$M(Q_$Ly{XrMriqqNJnZ zeNw^;Ams&^wUZnp7dzhB-~8v}+2co9BKGV1kI$bbN%AK;CrgiqLkzi1R!K-!>62tS z>EBDJ=aQ6ZRSmyyzd^@7KwudELT6N{*tH%)*@uQa$fuxF4a#a*f9Zvw!$v b9MMa6Ul?(Zeca){_tG>yOtSRCU5NV+AhZ1E literal 0 HcmV?d00001 diff --git a/__pycache__/__main__.cpython-314.pyc b/__pycache__/__main__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3c7f3070fd75e7b34532740b8cf82ce66a409cf GIT binary patch literal 456 zcmX|+ze~eF6vyB7k~Y{zaq>qkO>vMS8c_$a;vgy(L8ODYv`1`$Mw&}Vu0o1~qqvBO zi=&&H;O5|8A)+@4#l50~IC<%>2Y27^z4z{O@1_UU0TA(cdw2XSv>6?GA$867sW4|y z0tOW*p(0|af=aPsYzs4-g|bw_OZxz38ejs_P)56LW)IWR9F%bm7Q4ws)G=AXn`|!) z%JDdua>U3&rk%5)fHPpDywu2TQ09l4=h~DlYhK8XT)LpmI9lLVZKLKGhdX)1CC9KS zR|tuekbHtC2r=C%Av|FOA+6djj|Y~2V)=edOc1qF!?8pi`*q5Px2^ENHGH#3nP{nd z>>j?ON%?~AP^Nn|BRuroIyLp(z9oC}dVdPN_x7w8a%IJF&3esR^;N-$z8tKHt%VUn z?=aPb!A}_dz{&H*3m$95u&ll*Lzl^mb%7 literal 0 HcmV?d00001 diff --git a/__pycache__/cdp.cpython-314.pyc b/__pycache__/cdp.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..948f5461fe6875e957b0096a524c6b6583c5a572 GIT binary patch literal 25344 zcmb_^dvqJudFKo;0EZwRB*6DW9Ey}kNF*rhWr=#wd`OloiKZcnl4aR2M2<*EB)|+n zQG%0=tF#T}xRDfRE80nFI!TY2ZXZnJG;Fflh*Wx_p6%{}f^1NTx^Xty)8_2{p;wP1 zCuh(8zB@A@07Fuf(~)p_=gyrwckX=e$6af;SvVZudFSgVx9sG&pVNtcn6%2hKS$+N z&dWu(mpCuKmyhu5uOVV!e~l3%`zu5Q_SY0Kv%h)TUoj$j4Uv4WF=FuwkpizNV)dFM zwi2$6^X8RtUhzfyMx*+yh~q`eiv=4D-uyCdgpU+@9bOBo6|vq0=xyC-(E2)An+_79$IQO1M8KyXOvRQ#u9DRf#^q{y%=bqy<_QCaGiPxpmFBVnmm z2}ME!p%F{dpnSqVJ`$1S;NVy&5R9}*!;#2XTWjkR>!0+j!@nomwys~l-tCckLeg<1 zG!e#-fv_~<9}f-;%Y)LetjN;;diBdv;DjX@iVV}!>3Sd>k=ay2L5W6?CS*zR2f}i* zG!hyg965{EI5_MdliMs73CFWj6@gK*)%U45Va?ppyrj_)jVZ7?Z#Dpg@ObZzH1m#HUO7%wd z7WDMc$Uc07rh~ioXJ5k?8aS2vLMSY2b69@*z=+yv*@bU&RyyGijErLe*~*0J^>-;s zNTJnMmy#{7B1guRAilCRhIJ1Ij*rNa_TfC1o@mJ@KiP&2n}<|@`>Au zN`c-!py4#mInIpV=kU9C8pTSk7AtrRHTCFC@2R!bamQ$G?C7PtvyM9MFt1aHjoP<) z8uaWW=5>aD)wthe!Bf0kJ(tJrH*Ycq_;2w&_&9m~@Y&!%Ae1zPM`U>{X;%Ef!O&1kO<9oKK@rEivX==b;9vM%7% z%j+wy{~b4u8@%kFPW5ZC@)Ea}Yt(KZ!VIgqfT5PF!FQp5UPC>%%~-<)cq#p@?Vo&J zZ{a7~%-moFjW}jJ=F+cvjeVSz^9p@Nl&1A2eRh4uVa#pJ*bXR2Ta9A~jEn@1C-W5fQ{!?t0#H{*h|DL2li^S>X&MQEv?m3k@JaKa zJP;a`lO~x6ZBjhs-Oora!}O$s2JL1&SS#?!h{2RHtRM{#9!nbKGXr75+|{ckNyQzz zk0`KjNRIgEfyydeU58)z3W`^`+lG8oFMrGCy71EZmlC%6MO*!JSIkzQux-6z+d9{G z%T;!{?MrP_gKr1k41DEOV#T_}73*fp;w!esU2Pv3IeQy=;(y$>_1C{BD7vuy{PskF zv{)eB=LD1Mmc4YzzGh~_%&Hk>wtlwcHpf5N$)mJ&^7o8Jhvl}JD|Fq?Gv>Q~eP86P zg^7aN#e&-V98Z(7mrm78ho|M(+V(5Px$#)puDE^owXT@>?5}SdsmI6R-FS&}O� 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

1O3xDl^bUqiNQAB)LD5rU6y@`PP(o6`*L$ zplk*JJm^&RL6+8Z1WE(5%=@8u=CT(>!~ck?&x8*hqr55iAi=yT;AwftLrM}_c} zTGYPJcT@>KC_`=D$#>KU^R7IqSMgNVP?-S}M7BQ1an^hP6O~87MB2B>;BtlB?rj`5 zW;pE31rbDYb%IO6mB!s5kO6`S9<&Y0#$Fn!XG09f(jSHa-L9DS-I z1sZ<9jTRF`UyA|H#4zlH5*(f+&qn+Kc(PuGh1;i}z3k~>kdx#X=tOH_mw(jF0H7dV zP*vLrz?dCgH>6wBhfWFsV%A1<0xeT1nL`C+9}H392sxEVj#j;Jo~%oMhWo;UC>Wqs zu1Hj_U94OiuWU&aw_Go7ne1M2RFPsmW13Oowd+4{tY50Bd;8d%$7UL4qVKP1i`Q&R zlx@3SxDAHO63=}z=O~L+Y?-TG+_EQDwl{8nCMG^}Cl8l?R<`XynAqC6;*J(rxH{az zfj%Ksfp&Ne8&qjX zgu0Ys(NK+msbpJ*YLo-E`ckGbHYR4HMv3Hc7Q_(r8NJ30kj`oVR~c%L5dET89tT|b zr3t?>D(PQ?K}_I;1QU!j>W?7SE8Hw0C`nc(5X7azg-)5!$7AtoK}t=aQ_b+yV@#+y z#}RLa;IS@fM-&1y92y@Pl)|S1V>G@;i-wXWh5@4LEuNmf-2kEA!y2k?omWuHAXT5G zOKGGk1y7_hg%qDoozmec>(aL|M3}T4f~VVNQ~rvj^6Er+^J01P%&~ZRTf*4}OAgF6 z!o{sIYc(V}*S25%!cj)DJ#~HC_NHxmAYQ&E;an4Qu2K7%F1B5_){q>(;x}QkES=*! zoc!+>*K<>Ro$zK}JvU7a?;zIi8?_w{4Y+w?ILRz72BqoU?!(nUl4Jx5ApJql&mMvpv#)Tiy zeURfZFVgEEJ|x8QMX`LUa=PyWaSijw!cIq~J5|3W10VB&sERKUdFIM<8+&^G87DY) zKIJY^2*wp6;0g)C3fXBC5;l`1-Me;o9NORK+tb(A>)X}S-TUm`o<3y*bpWyElx`~a zP(l6&X5~}4AR||dQV~?CK=%7o`#cq-;UcI6N2_h`>KH;`4orPukKN5oSgh z{gi4!Y@{2hpbsAIL-DK6VBh~5R>ip*e&7ez0{ zw<4GVeeA41LMl*qusQ-OP)FP=3$Up47P51vx6)h0YA&x6SBkw=UKcwr@m70_S*_Gt zf-7a-Qk3Q1vPcDjGAdb=VT}^OOGURl3;IL49q0wq1EmhKvmgKie%j+|CF2A{0QC((2a>mMT`hZeOZw_@aY-;GT>*=b_){ z2>?0T6A9g|jNnZi&Ch~7QskLXb9wve^cbMA%qL4BR^?)3+7bV!sc>QHN zSQ##5y*~9$iuC$qr1~*+*o=bdCh(EHaOs6a$*RSYRZCXqbmZO7zVq2DdAE&R>FRr& zp_Fu#LJM5s`Ib9W)0|GtoA|%vHwb@a+Vl`>#(%~Ge%qz;vgn#zic6S=01;R^)u~4EzM=g z2w*aw)}C?Lf27OQhb=zrW2eraO4wH{+E>JCH_V>A((q%?yeGElxwze%5WO+cON6}O zd;tXWMX_$W;oa7ET0aoigAL{%L^J^zD9<%_RO4fGR0@AZwG2Th1-XwCy4liaDqY zGHNpR97e%Du&Rhn*UoO5tDkL-m$fH~+M$Oba+L zSuUAZzt+z;)9BiPh+w^%GrDWpu=U6>4O^T5tfxul&J8HbZmn!UA;9$-$=*3=r28on zIBiZj=mDa^N&H}k4&2$J!p#a^a4h8}gX2pDaxRx!Nne}nQ8G~gfP4hMR{ZQ5K!5r$ zB~pN)N(ihUA4TYchr$8R9z=A#EbZ7KH4cPE$42Cc+$c4Tg+@lUN#OuBP_BtTXi@wF zlmx=ig6s^)bO=Uv2NZZl{6nO5MqtKBX{Xc!S5)rqhCL(4Lwk~<{>W%O69xtY6a$P| zBBT(e5gLS^NioF%Xc8}B9o-_cxG8?rVI+oaE-A>Tp%fQVgRS@3{ri0o8+Up8JNBzs zNSLQ{c4BBraS-oKnFffZr}ZRJr19_nYom!`66ZTcWs)`G2P@ zHykT(7M9*}R$OlRQp-1Oai=F?^<1}l5XT#<*gki7Zts!rZ+mFS>V+hwN*#_*jwcgzO`~${|&vi6*iTu2q?=lGUYu2E?P|SA;!a~VPs>2a% zwl8ccK>ez~Q)#9$!;t{+R;84uP)V^~jXI+#bv)dWkS=#bZ6l!pByog-9wg+PK-i!1 zH4LF-GdTKG$vj$mSQ&nflW-*pM*SdLJb&r=sn5nsS`&`enAp1L&~p?@R=@yXElinK zWHPJ*+{>ua?i5u5#^-toW;yg4DdG~g9J5zw6?FR#@GLJ~Mi?`C#eO5v_(7mPXb8^J zt%uA+PRo~dc=MMXr#YFQMW+vGPH#{uvJAk4o@>Mc{f;ryv1Jx%?Kgo%+MpuZ)8~~* zA24f2`qiA{7wY4)Px&7h9Vv?L+y!hKCz|ohK{5zX4wEXmqgnPyeMm~1K&BYW27{de z-C%tfkjLorhdt54lu2D1Cuxz-jKLI2;Ur0$I$-D7ej2SWY4wwF6Nzo3Fi#{4paLjQ zBNqU!(w<~tYS?ZWcCV2z__O|?WWk^u9zY~CJr#6#n2Jt$9ValQXdrNY|j;-$hB)3&9;nyJX`e3Q+7&m@wKUt~6Z zCudzTU3#OSajB##R_%Geq;+!llB?o!+oiUtQ!}7M9+f1`?uffO60XBB*WoMuKR!Bt zG}hZ6-*tGho5X~pGs5)gSj&#Nlh=yh-`Nw}@ocQBH|~5cW_@nSQF3wkeMhb8`ka0` z?r4sQ&3CHs^aso+aBf9s9Y4)?)(Y=zLv6mM!-zi_#tls-5bvGCOKLJf3ip7Axgh#6 zahU0WVt{deeM(@==7t%NtBQ2SXhmIJiVGofNI82@9zmud5&)CDn{uoKGHa=nsS1-; z22p%V4Yo^)2!CgtGmI%_W>*U*k-B-@qt<_oo5IAU>BHV~xTYEy#b4e2BjLxE1{{~o_X{BC|b-Wirfwj`THaBe}wm7CeJ*04c_J0Kqow8gh@ugh$81rb$^T7oqwjDs}0k-;g1> znf_hIG%?9-H>0{-t`MI7RF)Iq!JJODQwoLp#xUBY&B(euf4v5|&92vVSjrv~a@#26 zHd6fA0lt8I059DAnY0!%SOJID<%k{+oq-&+rU~IIJ9bL1kg*>k7bLf3c`g&EqbuCokiIK5)yZz1PM`3Pgk`N2 zR8bj+48+=^$5MT$b(=Q4g5hOy(sxp7C1f$xknaU1G+}g&`YWD^z#!7Tc1r6c@&kB= z<-pKzgwD4~pK&+2d-^^u%nqW7mWN@Q4v>pLO@!9)t4;b=K8t-}=(jBFDI|NT$VsA} z@+VXf5z(pg_o#shczLG-ESnaw*2D6^DdlZk(aCWt=3hpms&47-v;RlkW>EZ+`#tWy zl`Aa15PdB=)%1a*{;ww9>7RT0`l>D@T-kQ4>ko@P227En`d#@s|}gBSU=6HnE((Im`p?JMpVjRj~1AFzsB}KNtx6$Hrv8 z5@sp)!=VX@GO0lVur)vk=z;udLP(@YkA|i2_`m?9C3sYn|AGOd_DnpA+Q=f28WJ{o zGVCyYNkySKI?X~M&1alKAr)l=^dt#Jb?hy>nTOyR%F>Xi$p%OY{{ikH&i4ySAo%U| zBShs^y;(JVBwhg##`A&8^BCNi^;{{xV!755D?1Xm6Bh+?6t^xTM@M7lI#Pu?o1qHj zQ+;DC$^{qS=@Ax+E2!?_QDz`ViqB4^kmI-Ev&3cB8xe>@3&@UKfi}MrC;j)~&O!!4 zk)D)wAmj&cP6rSwBML%CD9r*Q{EnP7W6V!*QfOgVL$l@?PjU4ugvyI8Runo`nMvVV*D@q~yNip~17B=&~Hh&I8zSf?4%Ef#AT%I5dYQn6wHNeMl!`3P3jg&;VKZLrtZGKVQL3OeH8<|K7&$wf%M5++ch| zS8RRvm5!KW_oToQbAEfg(s)kf4PV01@PVV@F=!QAzxzsLemGY4Y~0=(6MKo}tXjsZ zPQJ^+FBoA+5Eg`ds@rR+Y=I#`SnyP!epRGC85)6xRpPym@s5u+DlGfQb6N1yW!h#M zRXfsPHiKYi_`vw+Fvl^ zxL3{qs68X{iHNio6(-WPu5XskQgb9U##&X$j+*rY+O*agk}B2JE}zjPzo-jx9a$Sy z!ybu|h?3?flKgOVm41mN+^DRCU~!DfQWKc~H4)B@tpJ29l}wfT)WlAOSXR;q;W#r2 zMew0X)wZd&l2D7B!-QHCy0rJwUW5$WHY0U@>y95ZX3J+Sv8rux=k}O&`%-~@GWBaS!ED|+)^;#*B=#D+@Qj0~h?I9;~Qu^tHQ zpcH$^aes;5m+*TLzh?Xj89Lj>yD%fK>>q)rnnG^iRvtJ-n`976(m))WIT2cd8_}jp zbb)Qm(h{bmhTNB9m)eK4p#<-574;G;I5p)vy%LoB!4z( z#%4GXvThO1CN1o-T|ltN&@07%`Sj1C-)tY6Tk!|~=efDRYe)XRl})af3~lmY(#Fob zn4KS|5EB0z##JRnCK?XVDXF#!Y>*t=IY#Go4C74djxzsB0GlObaXX~BVik06 zC93NP1;3B}jJv5QI5iwEZcNx4Cyg+fUl@IDbgCPB`IQV9-ztvsz#J#}KKSemZ8U0^P9-ZSO9>>qLY$hD^8elh2&WHxp4yY{c! zXCkwGaj7lt+?KFzyKdcfr;IDv{82e)ExP5bd7~v^t-Wrog*((*bf<=MAgWcmBVd#} z#hh*X&#bi%!Y^R@=SsQ?VQ&WOo2o(WyTtPTW;1Gtb?vg4=UdjJzEI0|SRCuc_EA~#diA#WEkg8VT&ml`*Oe?gbW<&O%5b@)D_?tyjj>ij18{3oye*}E-55r zhhgQjyZ~+~?@*EE#b&e*stDV{ZX!Dbji@mm378jT@o-`30O3q3U*J=~TFrbaM$}b&)s(tFrG^BGq#<-F+wrVFM74zIXI+G=e%wO1dfUzcTen`? zx@0}JWH0|$B!X992byBANTb~{Y4}Lw3Z0DgJ$=m*J9zm0Jx5|sKOd_)8h5@Bv%c`t zg2GQ=mkl@Gv2!-*L72=1=c+qa@_)j2tPtKVKAYy8vV`xDg!$5Zs!Ke|+!AFJ zqdXEv9?NK!+37=YDT{GwEP?IN!hT#J2F>V{ z9jyFO-vAT_ezvq7O|o>r$B6>olpdI^lTxI8iar4p;FU8#h*2QKP@;7nW|(z&P4MzT zJn1)Oq}F83=n?nkKknYgz0L0^*ksLcUqlsb4pcv6u2!;2XGA<@jgXcM|8S8g>mG{a zqaUdHKjAlz-xPkI#%~{fBpMZ~qEYlOb`v~HFYA`>mx*q|Ux?%`4>qn;cc}!OR^=U# zS6PYlK^K;qPWZ#vhY3U)A;|s#YypYjc2aBx0})Ei0cnRBY%GFo382LowsFAbWC%q@ zP@2XHPRVDnh1J!!!b>iQkTU3q5b31 zOi65?d^SxE%fpS`xRL4qNfXWvjcLNym)nOdiEoVUuzasQTA2NIkhgB)F^`eAG;brx zTj~Qu{WUdmJ}{iJYU@V2d|Fu_Gcef5CzU#-U6R5-#ZZi-i=4wn+doW|&y-A_M07$z zB%k^xrLdnCxPDSvdC@?r?}1B!n6zbX^OddFPQ?0;#M+*ZmmW-eykTd&v^`PSe!Z~$ZXQ=wbsM|u zl#!?hcjj#AT=A?OIi(3JrIqGlATBO{9f@aVuIL5+X5o>Wh3!AH;y+}xKfpVlE9*A# zZz3sPc&i4r?;-VFm@7bSURXipO1^8GdA^>mEpU9-4)X%Pp6VO;u6FanMhW$+#e6p} zTrHuyuGZP8^zhwA;c9C>)wl9gZe!&RR<=|5Bc7E;Dl zYNhs>(h}MmyethbjjqAlhD7&`USTyxz+XKb9p5$kfGYOT_yrc@nDd_H1aYW$P~3D% zt*cj{x*3rWknrF~1Fo;dPxY}{n0li80uxFafelH4ZOT_N0R;g;0sR3bf;~ThLtV62 zUvUs2A*bccR^{ioxkvfmD1nTm?I37%fCdyOc3W3M{tsG||4Ypxo`YN$>}&QMPIWaT zg%2#Uz(;do_>ek1N%%m+Gk)kSpK?zhn(2xat-}UR1rFqyT-C^D^K58zdvq2kPr`C7Y|T?W!p{h zfEHd?*|CQIS`7*$J*^gg(16-}E#J{3%u9JxZ{ksAr~<^m7>oTPCanU`vD7E*H!QOn z{ZplwAq>#O3_UzdU^&xE2n1i^9?l@3G~~WdwE$%5`x$wt-aI7ZrRS|?FcB6elan#) zT0B^{Nq-O3_;xJia0XM$Hn8QMWxm$}lO0BS_)K7fD8Tx%BTbpfm@PnEWq0QR>OTCc z@l!KQ3&wwpTPZCR8=CA6jL3}EQ$=;=1cE2wEF}l-3AC|LHriE?Gz}H}r7`FLvH~S1 zjY*DNV4`T5m!(oZ_JuX|2R2mw=_PDBILj$mY z1gVMkFhCR^f=&X16z;Z-ZQ3liFy|y;#l%Cm>=j=aO^8+3 z#i~0l&e49yfT5vNA^hN`Sfy>z^+cD6{|?`|LwIj3YM?Tm?VvK%RNv8va-o6m;)R7( zMO5F+cNv9+C-bP@&ZE4_vl3>0Fte1M4vRy0%jl7qqDP<-DT;)v+CkI>+3V>kxf!|M z25patb9B3$sr7a%?0_IqNCPq0bDdoGBLW^P-(y{xJ$oV>m?ttsdzRtZQWC(02q61D9z-?NbnfnOheIxYH!e zN)ZIu%ARN`3$Ptf2RexKUpa{Mx=7MYsWAQswonQV^?(=pBmSgef)dRK@J_G=!j89j zV>18v=8f7`xB_L&B~24bAR=pQllD`9#`!YtP>lFqNyCZZ>`ZhEybAh5?F>Uw=U*V~ z7{bkj9jV0Nt#f;>$gyqv<7EdDMF%F$w;Ux2N9}b-?X-FN^q<<}j#hZBY{)vF5)!tW z8@8Hh=aSWaVbA$J7yDlCp^b1?e8_e+%;%gv{6E`jKCsoWhTnvV{+}!FG*CEtv+$Ko zsLdBTJ3OZOl?7Dy6m+!b&u`5~9Xjzvdaa{WjH7THdX)3@mxol}=Qixducyad_0R+~ zHPeh%Q|U&Lbm;kLeR~h=diGGCGDVlj(!jC-RBH=!rpwfJl?vhoswtJp_y0tvA5!rz zRNSG0qD+(mtRzcJQ@%i_ucJs$KZ91su|%mK(uE5swDhyiyMocQ>b}KjvfQ;8OdD=n zxwXhg6HIHgTE6K;{%$F6T6f>#FtyxW&6|$$*b-n}`!(S{XEPn0KVPEy)1Hgoo{1PY~`n(SJWw!O2`hDPNzy5iV#=s-u0 zuY1@2j^}*`_V#2obR1DA)PO|*C9PUGDKnp_0pG+UlvPv^{IGRo`^Br1CSyiY+^!~- z?NnSC3IDn985G#$kmv6hig@APYL0jOgtPsGD`2JTCtS(TxuOrtTVq@a7SvVxsskH@ zz3QNP8R~__uiEaLdHy+m^3&Ke5r0ADE8R0bI$3vLwD7(BN0p!D`L%aADnIHLyhgtG L9wZB1UC#dxSTWzG literal 0 HcmV?d00001 diff --git a/__pycache__/queue.cpython-314.pyc b/__pycache__/queue.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c94d90004f1207b9f0fc29b349209033962350f GIT binary patch literal 5689 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 L(Y=Cjt`G4a6<(Ki literal 0 HcmV?d00001 diff --git a/__pycache__/selectors.cpython-314.pyc b/__pycache__/selectors.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f96377f96ad87c38dcb4a8ffcbe3872ffd9fba83 GIT binary patch literal 5028 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 literal 0 HcmV?d00001 diff --git a/cdp.py b/cdp.py new file mode 100644 index 0000000..49e622c --- /dev/null +++ b/cdp.py @@ -0,0 +1,401 @@ +"""CDP WebSocket client for socialmag. + +Talks to abang's already-running Brave over the Chrome DevTools Protocol +(default endpoint: http://127.0.0.1:9222). No browser is launched here — if +nothing is listening on 9222 we raise, loudly. + +Shape: + + cdp = CDPClient() # browser-level connection + await cdp.connect() + tab = await cdp.open_tab("https://www.threads.net/") + await tab.wait_for(SEL) + await tab.click(SEL) + await tab.close() + await cdp.close() + +Every failure raises CDPError. Nothing here returns a plausible default. +""" + +import asyncio +import base64 +import json +import random +import urllib.error +import urllib.request + +import websockets + +DEFAULT_HTTP_ENDPOINT = "http://127.0.0.1:9336" + +# Human-like pause between discrete actions (proposal: "random 0.5-2s"). +HUMAN_DELAY_MIN = 0.5 +HUMAN_DELAY_MAX = 1.5 + +# How long a wait_for polls, and how often. +DEFAULT_TIMEOUT = 20.0 +POLL_INTERVAL = 0.25 + + +class CDPError(RuntimeError): + """Anything the browser side got wrong. Never swallowed, never defaulted.""" + + +async def human_delay(lo=HUMAN_DELAY_MIN, hi=HUMAN_DELAY_MAX): + await asyncio.sleep(random.uniform(lo, hi)) + + +def _http_get_json(url, timeout=5.0): + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + raise CDPError( + f"CDP endpoint unreachable at {url}: {exc}. " + "Is Brave running with --remote-debugging-port=9222?" + ) from exc + except json.JSONDecodeError as exc: + raise CDPError(f"CDP endpoint {url} returned non-JSON: {exc}") from exc + + +class CDPClient: + """Browser-level CDP connection. Owns the socket, hands out Tabs.""" + + def __init__(self, http_endpoint=DEFAULT_HTTP_ENDPOINT): + self.http_endpoint = http_endpoint.rstrip("/") + self._ws = None + self._reader_task = None + self._next_id = 0 + self._pending = {} # message id -> Future + self._events = {} # session_id -> asyncio.Queue of (method, params) + self.browser_version = None + + # ---- lifecycle ----------------------------------------------------- + + async def connect(self): + # NOTE: deliberately a plain blocking urlopen rather than + # asyncio.to_thread — `queue.py` in this package shadows the stdlib + # `queue` module, which concurrent.futures' thread pool imports. One + # 5s call at startup, before anything else is running. + version = _http_get_json(f"{self.http_endpoint}/json/version") + ws_url = version.get("webSocketDebuggerUrl") + if not ws_url: + raise CDPError( + f"/json/version has no webSocketDebuggerUrl: {version!r}" + ) + self.browser_version = version.get("Browser") + self._ws = await websockets.connect(ws_url, max_size=None) + self._reader_task = asyncio.create_task(self._reader()) + return self + + async def close(self): + if self._reader_task: + self._reader_task.cancel() + try: + await self._reader_task + except asyncio.CancelledError: + pass + self._reader_task = None + if self._ws: + await self._ws.close() + self._ws = None + + async def __aenter__(self): + return await self.connect() + + async def __aexit__(self, *exc): + await self.close() + + # ---- plumbing ------------------------------------------------------ + + async def _reader(self): + async for raw in self._ws: + msg = json.loads(raw) + mid = msg.get("id") + if mid is not None: + fut = self._pending.pop(mid, None) + if fut and not fut.done(): + fut.set_result(msg) + continue + session_id = msg.get("sessionId") + queue = self._events.get(session_id) + if queue is not None: + queue.put_nowait((msg.get("method"), msg.get("params", {}))) + + async def send(self, method, params=None, session_id=None, timeout=30.0): + if self._ws is None: + raise CDPError("CDPClient.send() before connect()") + self._next_id += 1 + mid = self._next_id + payload = {"id": mid, "method": method, "params": params or {}} + if session_id: + payload["sessionId"] = session_id + fut = asyncio.get_running_loop().create_future() + self._pending[mid] = fut + await self._ws.send(json.dumps(payload)) + try: + msg = await asyncio.wait_for(fut, timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(mid, None) + raise CDPError(f"CDP call timed out after {timeout}s: {method}") from exc + if "error" in msg: + raise CDPError(f"CDP {method} failed: {msg['error']}") + return msg.get("result", {}) + + # ---- tabs ---------------------------------------------------------- + + async def list_targets(self): + result = await self.send("Target.getTargets") + return [t for t in result["targetInfos"] if t["type"] == "page"] + + async def _attach(self, target_id): + result = await self.send( + "Target.attachToTarget", {"targetId": target_id, "flatten": True} + ) + session_id = result["sessionId"] + self._events[session_id] = asyncio.Queue() + tab = Tab(self, target_id, session_id) + await tab.send("Page.enable") + await tab.send("Runtime.enable") + await tab.send("DOM.enable") + return tab + + async def open_tab(self, url): + """Open a fresh tab and attach to it.""" + result = await self.send("Target.createTarget", {"url": url}) + tab = await self._attach(result["targetId"]) + await tab.wait_for_load() + return tab + + async def attach_existing(self, url_contains): + """Attach to an already-open tab whose URL contains `url_contains`.""" + for info in await self.list_targets(): + if url_contains in info.get("url", ""): + return await self._attach(info["targetId"]) + raise CDPError(f"no open tab whose URL contains {url_contains!r}") + + async def get_or_open_tab(self, url, url_contains): + """Reuse a warm existing tab if one matches, otherwise open a fresh one. + Returns (tab, reused: bool). Caller should skip tab.close() when reused.""" + try: + tab = await self.attach_existing(url_contains) + return tab, True + except CDPError: + tab = await self.open_tab(url) + return tab, False + + def _drop_session(self, session_id): + self._events.pop(session_id, None) + + +class Tab: + """One page target. All the verbs the platform modules use.""" + + def __init__(self, client, target_id, session_id): + self.client = client + self.target_id = target_id + self.session_id = session_id + + async def send(self, method, params=None, timeout=30.0): + return await self.client.send( + method, params, session_id=self.session_id, timeout=timeout + ) + + async def close(self): + await self.client.send("Target.closeTarget", {"targetId": self.target_id}) + self.client._drop_session(self.session_id) + + # ---- navigation ---------------------------------------------------- + + async def navigate(self, url, timeout=DEFAULT_TIMEOUT): + result = await self.send("Page.navigate", {"url": url}) + if result.get("errorText"): + raise CDPError(f"navigate to {url} failed: {result['errorText']}") + await self.wait_for_load(timeout) + + async def wait_for_load(self, timeout=DEFAULT_TIMEOUT): + """Wait for document.readyState == 'complete' (poll; simpler than + racing Page.loadEventFired against an already-loaded target).""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + state = await self.eval("document.readyState") + if state == "complete": + return + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"page did not finish loading within {timeout}s: {await self.url()}") + + async def url(self): + return await self.eval("location.href") + + # ---- evaluation ---------------------------------------------------- + + async def eval(self, expression, await_promise=False): + """Evaluate JS, return the value. Throws loudly on JS exceptions.""" + result = await self.send( + "Runtime.evaluate", + { + "expression": expression, + "returnByValue": True, + "awaitPromise": await_promise, + "userGesture": True, + }, + ) + if "exceptionDetails" in result: + detail = result["exceptionDetails"] + text = detail.get("exception", {}).get("description") or detail.get("text") + raise CDPError(f"JS threw: {text}\n expression: {expression[:200]}") + return result["result"].get("value") + + # ---- querying / waiting ------------------------------------------- + + async def exists(self, selector): + return bool(await self.eval(f"!!document.querySelector({json.dumps(selector)})")) + + async def wait_for(self, selector, timeout=DEFAULT_TIMEOUT, visible=True): + """Poll until selector is present (and, by default, has a box).""" + check = ( + "(() => { const e = document.querySelector(%s); if (!e) return false;" + " if (!%s) return true; const r = e.getBoundingClientRect();" + " return r.width > 0 && r.height > 0; })()" + % (json.dumps(selector), "true" if visible else "false") + ) + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await self.eval(check): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for selector: {selector}") + + async def wait_for_gone(self, selector, timeout=DEFAULT_TIMEOUT): + """Poll until selector disappears — how every dialog flow confirms success.""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if not await self.exists(selector): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for {selector} to disappear") + + async def wait_for_text(self, text, timeout=DEFAULT_TIMEOUT): + """Poll until the text appears anywhere in document.body.""" + expr = f"document.body.innerText.includes({json.dumps(text)})" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await self.eval(expr): + return True + await asyncio.sleep(POLL_INTERVAL) + raise CDPError(f"timed out after {timeout}s waiting for text: {text!r}") + + # ---- interaction --------------------------------------------------- + + async def _box(self, selector): + box = await self.eval( + "(() => { const e = document.querySelector(%s); if (!e) return null;" + " e.scrollIntoView({block:'center'});" + " const r = e.getBoundingClientRect();" + " return {x: r.left + r.width/2, y: r.top + r.height/2," + " w: r.width, h: r.height}; })()" % json.dumps(selector) + ) + if not box: + raise CDPError(f"element not found: {selector}") + if box["w"] <= 0 or box["h"] <= 0: + raise CDPError(f"element has zero size (not visible): {selector}") + return box + + async def click(self, selector, delay=True): + """Real mouse click at the element's centre — the human-shaped path.""" + box = await self._box(selector) + await asyncio.sleep(0.1) + for kind in ("mousePressed", "mouseReleased"): + await self.send( + "Input.dispatchMouseEvent", + { + "type": kind, + "x": box["x"], + "y": box["y"], + "button": "left", + "clickCount": 1, + }, + ) + await asyncio.sleep(random.uniform(0.04, 0.12)) + if delay: + await human_delay() + + async def click_js(self, expression, delay=True): + """Click via JS on an element the expression returns. + + Facebook needs this: the composer's real click target is an ancestor + [role=button], and mouse-clicking the visible span opens Messenger + instead of the composer. + """ + ok = await self.eval( + "(() => { const el = (%s); if (!el) return false; el.click(); return true; })()" + % expression + ) + if not ok: + raise CDPError(f"click_js found no element for expression: {expression[:200]}") + if delay: + await human_delay() + + async def focus(self, selector): + await self.eval( + "(() => { const e = document.querySelector(%s);" + " if (!e) throw new Error('focus: not found'); e.focus(); })()" + % json.dumps(selector) + ) + + async def type_text(self, selector, text, per_char=False, delay=True): + """Focus `selector` then type. Default is Input.insertText (fast, and + React contenteditables accept it); per_char sends real key events for + editors that only listen to keydown.""" + await self.click(selector, delay=False) + await asyncio.sleep(0.2) + if per_char: + for ch in text: + await self.send("Input.dispatchKeyEvent", {"type": "keyDown", "text": ch}) + await self.send("Input.dispatchKeyEvent", {"type": "keyUp", "text": ch}) + await asyncio.sleep(random.uniform(0.02, 0.09)) + else: + await self.send("Input.insertText", {"text": text}) + if delay: + await human_delay() + + async def press_key(self, key, code=None, key_code=None): + params = {"type": "keyDown", "key": key} + if code: + params["code"] = code + if key_code: + params["windowsVirtualKeyCode"] = key_code + params["nativeVirtualKeyCode"] = key_code + await self.send("Input.dispatchKeyEvent", params) + await self.send("Input.dispatchKeyEvent", dict(params, type="keyUp")) + + # ---- file inputs --------------------------------------------------- + + async def set_file_input(self, selector, paths): + """DOM.setFileInputFiles — injects a file without the native picker. + + `selector` must resolve to an . Paths must be + absolute and must exist (caller validates; we re-check the shape). + """ + if isinstance(paths, str): + paths = [paths] + doc = await self.send("DOM.getDocument", {"depth": 0}) + node = await self.send( + "DOM.querySelector", + {"nodeId": doc["root"]["nodeId"], "selector": selector}, + ) + node_id = node.get("nodeId") + if not node_id: + raise CDPError(f"no file input matched selector: {selector}") + await self.send("DOM.setFileInputFiles", {"files": paths, "nodeId": node_id}) + await human_delay() + + # ---- diagnostics --------------------------------------------------- + + async def screenshot(self, path): + """Capture the page. Used on failure so a broken flow leaves a trace.""" + result = await self.send("Page.captureScreenshot", {"format": "png"}) + data = base64.b64decode(result["data"]) + with open(path, "wb") as fh: + fh.write(data) + return path diff --git a/platforms/__init__.py b/platforms/__init__.py new file mode 100644 index 0000000..2edb0ea --- /dev/null +++ b/platforms/__init__.py @@ -0,0 +1,73 @@ +"""Platform modules. + +Each module exposes two coroutines with a fixed shape: + + async def post(cdp_client, text, media_path, options) -> dict + -> {"status": "ok" | "error", "detail": "..."} (+ "screenshot" on error) + + async def check_login(cdp_client) -> bool + +`post` owns its own tab: it opens one, drives the flow, closes it. The queue +guarantees only one of these runs at a time, so there is one browser and one +tab in play (proposal: "sequential per-platform"). +""" + +import os +import time +import traceback + +from ..cdp import CDPError + +SCREENSHOT_DIR = "/tmp/socialmag" + + +def screenshot_path(platform): + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + return os.path.join( + SCREENSHOT_DIR, f"{platform}-{time.strftime('%Y%m%d-%H%M%S')}.png" + ) + + +async def guard(platform, tab, coro): + """Run one platform flow, turning any failure into a labelled result plus a + screenshot of the page as it actually looked. Never masks the failure.""" + try: + await coro + return {"status": "ok", "detail": f"{platform}: posted"} + except CDPError as exc: + shot = None + try: + shot = await tab.screenshot(screenshot_path(platform)) + except Exception: # the page may be gone; the original error still wins + pass + result = {"status": "error", "detail": f"{platform}: {exc}"} + if shot: + result["screenshot"] = shot + return result + except Exception as exc: # noqa: BLE001 — surface it, never swallow it + return { + "status": "error", + "detail": f"{platform}: unexpected {type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + + +def require_media(platform, media_path): + """ig/tiktok cannot post without media; nobody gets a silent no-op.""" + if not media_path: + raise CDPError(f"{platform} requires a media file; none was given") + validate_media(media_path) + + +def validate_media(media_path): + if not media_path: + return + if not os.path.isabs(media_path): + raise CDPError(f"media path must be absolute, got {media_path!r}") + if not os.path.exists(media_path): + raise CDPError(f"media file does not exist: {media_path}") + + +def require_text(platform, text): + if not text or not text.strip(): + raise CDPError(f"{platform} requires non-empty text") diff --git a/platforms/__pycache__/__init__.cpython-314.pyc b/platforms/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0be8c72e395c894615a7348ebbfc8e9d0df692b8 GIT binary patch literal 3531 zcmZu!|8LvI6+eEHC`y*|)wU9+xyZ3=rM46p2{I_L8{6^n-4z(sXa*xHP0=!Aid4x{ zY-<@{bORO-7?KXd;uZrI6a|t2`@z75A^R;({{c0|7MJzV0Nb$bH#g3JV%-mWM~RXX zO1nZXL4*5|c6c4m~4mX;(8O>q`X z73Z~S4Esp0nlI&4L)T2whlw^1eNoHnO0J}knLcclh;Erq0{0K$ysi?D1LXqWKa4v> zA!R3?#&K&lj&I_)X4{q>@5AxDMikwkE6HRs{v+7X`xcHns;z0JGh>lBwoL3%CtlS` zozc|UoM9DobDMIW22+-0NYeXM@At7aXF6CX4jo{kOr_xi^acLFTkFGlTfafgGNa*X z!`Ni)ga3knw}CF@w+{G|Bs)G6dv_ zI?ciD`vN{i^PPbJf3-C^O`r&p*-0-QC{-r%DlL^PbI0m>12sRuAFIoTfEKd!xZ1q# zkeoH^GX0g7?yKF!?tFjux$eu|S1Nq6WEK)Zm$e*MEGn}QinilMuZ+rLW8+uOy_3zI zIWN0B4NjN8Zt14WQ`fknL+oifqWpdPI#i7wcQ92nNs2n90NG*YQ<=svH!;w(HYM@= z;-qpp-PB1gmn~e`N+ND&+_++pB7i%S?f7d?H+rSt#(}4;@C)F6R|>I3z#64I@WL01wTfJ z-}(gfkE4SiV?UZ;&w?AT1Km8c$Xya7n3q|ZlX=f6!T_6MyHF5aYQfuF96))9lTK9P zWTwRLM_DGbT~b}*q~MFrBHwlX+mi~RRFfJ+5-RbRcY=&Y@PG_h?*x4gpv%0DPI3p3 z&VX)!a9J8K!7LX+16+zN30eNlx~M$J>_k_`WU&WkvO<|X0Ou=lZ*yJsx6ZsI1B~sR zqscck*$CEbBjhrT;PlUgnmP!wG}#E*OteYij}D<@^vS2eFDK;kWwAn|fJQz>e> zoZU{>C0eKylGEnZM8IyLQyie8%hSr_axm@k6#v~o?fec6qn@4~C=fRFhbvG(&fAAz zd=MVz8Fc@K4A>0p5~TI$uG`WrX?;h>^5J{pgZCa>SedCFdAr(rx+-^0dAHVUt)`#L+@9h6)HGv98scDz7LXy=!a z9h)4y|NCs$zU8y`-dpKi+4peZq5AOMYHGB)^USTl*WuRnt~c+T`XYkYTlfCF<-3jW zo)<_Eq-T*mcM9u~J!s64TEAED}S%q7!$S82UIoDsrE( z!)fmRf#EQ_$}_{kz^Wk7vBbdiYB)R`6IRk=?pkv$^qW8~y`dO- zULjh}BZg@&My2MR$Ds%B>8V9yOPza-+U|y>6Yw|~x<&NQ@ZODR+j8&nzG~O`YWsz? z=*6mdaf9D=C;b=x4SNR=y;Q;LW<*>6^0rT)h8e*~op5>V+6LXPtLm8sPy>T{n`l6G zgBu72rv!ZpnVCRo79e=LTl_`zS~(41ZIGeY$f-HMDTk%*-!6roD8Y?a4%IwEt2Qo{ z!NXGkZBveAlnLCb3gGw3{-*PA-U4^hXz(ryX`B#j3KKTf$`y16w${8E@Ccj)V;_d4 zTDkWeXLnJ0+6~c#tI#c?ufu!46r+BD9$Xq+-uE?o`$=hgu6YeV=F&cxm0Z*UW<-Y#Sws(XBYw$jl|0SNkjtym%p-c25Y-WA|pq?bHv`LWK^ zTmDN?9`U1`uGoDb?<_nH)r4BD^$xjP`KWUD<}YqG!u@2m{nT1?uqqCM4LS~cHb_L= zU@n)pR44|Xzrw(vRu(w;%czwSdW-c+i3jmsM%A1}#r_s~jB2?jo~r%t4B1CviCWB| zxd%wiFyF8dhTrN$jPw->e}#7Z1MPdZ^Uz}Sd4OjE&!c-8>G@HfIruypU`Cl2yI5w( GWBh+8`Ya#- literal 0 HcmV?d00001 diff --git a/platforms/__pycache__/facebook.cpython-314.pyc b/platforms/__pycache__/facebook.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..393ee3b7aa044cf2659f3cec01a1989516e9d394 GIT binary patch literal 5320 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 XvlfryM;lx5?r(>%8#~n(VDA3_0daNy literal 0 HcmV?d00001 diff --git a/platforms/__pycache__/instagram.cpython-314.pyc b/platforms/__pycache__/instagram.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b92ef3c472e5345e21c165569dd9b683fde611f1 GIT binary patch literal 6884 zcmcIpTWlN06`di6# zq7*4Hl5~N&ygNH{=kDyCGiNThIqW=w>w#atdGtL8LVu?>`ei66cjBO&Mj}$tb4bKP zSjFtupcfjvKD#8(X3{Jpvq51I1bXZX&g|$e78BNJLT;e%NQw zxPg5m`(-&LYu1U$L^v6WO7ZYXcwicz49ii?UYOMon`0l=%7%uYI@li+`ybZ~vGEAe zV*`H6pu7m>&P(vE8j%}93k>@74IHzHIIwQVSE9D^45YfKL+G-~K7=x#KMMofB( z<=-*0u8YWDr|SqpQ12N;qTwiva{`|>K4#+4aj+=wA2sPdMEj@@qhy5oy6O^*T?MaJ zTLFK_h;>zSxLwbm_3ez=bt}v7eUOYf_1f?Ce|f${0y6{eL@)h!wTB*LL64n)@(h%2 zD15guhJ7Z@I4Q@!u&-DG!(Y@Nk0qn2@$j8Za@-=Sc zvHpVxo)90`oa2%jO39&AT1tl0@L>%Tdkv_A=N22KvEHo*p zYD9%#)_4{{p>$ZC&@3qyyozR|#?0V>B1IMYb+^JL2wGoTBB{2eAr!~u)MPT+Hd0hW zqaAHZDgp~I5gw;aRCtAi(zg7L>603v03p*5sez)L2k{RG#|6iR^NF*GDc9-XBB{98 zJoU&&#QSMYW3Hy-dQHdM?N^_EXZw4hY|Z02*W*8V^z^_d7W;yu;{3$9iA;5O*3pyW zdNN!Ot%{$EUuvIv`t|KuM^lb#%5Y7K6;&5cyis{sx-#%?-POAHT<;yqZXL{44CSmt z88Wm~hAM~gEw1M#*L2f5bWb?|KAqV$fbjcx?{+ec8bO_I#Csni(;g4#GiJPZCz-MM zsoshA_K}$`6V)HWRPJQ5kIG0PIKUu6^vk^n<<38$HopjAF%_2^ZIZv1190VHDhXa( zx|G~#thgk*3N|1NSG9q$Ecyt`za?WCN*C_89_y>$%XoidfzNBeL5a`gzU_RqKc-SU zqFJ=`765O}d0Y!{ieM?Ou(b@GU$(UT)kID#D*?(?Urw}_qXR97f^!k1o-wZ9zQiss za_hHy)K7PY%@oqVdUmX(v$K9=j21VP=vD^iXru_AYew7(yQ8IB#i|(q4=a`}=vqk} z*zV86u}Zz~C|dtLD6g@g{rIupG5K>Qf5c>p$zdj2ndAY|KZ7z4<$Wl>h4ND@0gNiN?2cxo+eqa3P?ByUWt8OP+j047crjtFrxRnu92mhZ`to($<>z3Q*?^_QNU`r+%L zD@U{ZjvU#MAv^AJ$XfMEWD4xKbHtq??mIlH@SG)!j@l{D^43J}rj)G1ljA%$IM0#^ z8r_Dz`iPt6tG!viIY*i^r1=wzgYIyNbBRm+Q;GR{|CRCL)<<8Qy;i2SWxje_miOm~ zKSTV~kvbTW-<%_xZ;;KO_hV#g(Ma{*NE4GDjdXo(MyB4IWXBSROu~{4m2J7nd2W$r z*c5LOKdp7&Q=IS-&IrM7{3Z^Tlea2Bdm9HU$vYmUiX#I=> z2kXgf^4z`gGD(NU6^x$AS`JmSY`iEsW*hxO*EmW_>R8}!r z%Va&1ZYCR<1k2FI%Os2r`Zgw8nQUXSoykrrBSqAQC@t>qe}ak1@9$$!oxes(TNdgX zkiJ{5MXB{?zyWk4@S8S#7dA%?+hKu4PZdyl4PGGPF-{*=T)_^x3F)(-exRi zBx&QQ(Oc*q1qFybT9c-)Gsb#$;bdy`ppqG|={_$1HXxC#-~Z8Hz~P{{D~YC2Y(t{C zqj8FpL7^TxaOoH92wG;dLSnu9{#GNuj}T4Z*gs*jC7BqURUmmU+n zLLPSYgXgIV{ZJS_LeGvJ4=O%RvxJqC$w(}vl~XP+PbQOaeh$S`fYC&aB_xK$d1$7Q zpqZ4oB&B5WVU+h>=Y3gz+m(rr_@0HT+Sev7OiZ2l zO+&`NbG~KQyn9!+sxMdGch>ZYwS2*~;WhsS|D_i)4ej&pj;yOQXY0(^I&a!siR?I%19wh!3=_(y+Fe+!VLc3 zmMJa?(r>)NK}TOk_ISaFH0T*mqmxu50iE!d30ho~=IK14K?(90DN z;6@wiqF;-)Eqi2MhqkSE2SqGVy!Rq}S0`?00JNj~GT3QVe28Hd}i0h7A`CX}wQ z3#fJ>&KQVX0swi5f{B2}D4SuI8AV~6pt+V!49a5YVdEwgqoDHylx+-f%EnpS)1!M% zHAPdA$pjqL1BX-5lYv+=Dai)`SwM@#C!>omat9cZJE@?%RGuu7 zqe?2NnWB@4v{FJO8Dy31KM|2wVnH)YCt`}K$lbIf!!QHmgmMp5D6~SSfUUFz6(W7| zUl88{uqqySt>!|_g0182gYO>t{h>Mh$JY5hBiZgJ=WS2kF&bR_zmdVl|BDJ>OqH9b zB5!nNUEZ9{o3VLs8;tf16!YR2;+NYqTe{~xJ=v<>TzPM%ym!fr?3D`}YF;~Z;m~FL zM)NPN*$w`j)Bmy4UxIf7S%;A0gd3bd@vid8EH7q=xU><*1A1j|zE$4)pT)}hy9mQb zaUR@)AtInZ5S?9G-FFeSR`iUPkpgCzU?DFFo@3kgEJ7FYVp1R?&KyvK@!rM`rL z(X2>ULXM@#Rn`XD=S=JO9mONXlRL~<;VL=5u9wGH;X`pI;DlBWIeGxaud}-zz^<^% zQj~uH_?d!o49X!W+o3q}{70ra7@6)a!!k{MXg0{O$C9B$ScWY50l6OPd=?r0lR)`} zPY;hWMgBZcu%!;^UF0*$^Z=e;?=1aC&zg``WM@7&w@yfKoMgu3et055Q7(gEXTR?# z8OT?hZ+*FS3TOGc9I4BYx&?dr`9tRpUBXj=`5Ir=-jcJld~9jC>3m=TP^0cb9UQij z^YyLSia^d9$dJI2vm{|i>TX(EZdn8OloU96reVN`-=ZAlawBNdW=c+`Egq`3P;xT8 zZ4>A-9MBRnQ%37&{CHpsnQ1MfdN1t{XQKfxoQ+IW2O0wsXbeaXlUtYs+T+7M!|<@r zrCCBD*e61v{B%DIerQI>@-leMALr?o%ksea{BfBg#ifXvk`xicZF*J~;?=bkkFIfw*nN7%v&DnB1vD*!dS^{S0|OL5+V! nTR*irPjh$89DW3UUe$ozpATXVKMHy|ehS~+%HcMdW?laWPVGM6 literal 0 HcmV?d00001 diff --git a/platforms/__pycache__/threads.cpython-314.pyc b/platforms/__pycache__/threads.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df77026df0a847fabca5196100ad560ec92d63f2 GIT binary patch literal 6601 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 EFP+gay#N3J literal 0 HcmV?d00001 diff --git a/platforms/__pycache__/youtube.cpython-314.pyc b/platforms/__pycache__/youtube.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66c7f22a9ab60800a9b140fe27bf307693900386 GIT binary patch literal 6405 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 Ig9}0b1?%@N2mk;8 literal 0 HcmV?d00001 diff --git a/platforms/facebook.py b/platforms/facebook.py new file mode 100644 index 0000000..9ca0765 --- /dev/null +++ b/platforms/facebook.py @@ -0,0 +1,94 @@ +"""Facebook posting flow. + + 1. navigate facebook.com + 2. JS-click the composer: find the "on your mind" span, walk up to the + enclosing [role=button], .click() it + ⚠️ A real mouse click on the span opens Messenger instead. + 3. wait for the "Create post" dialog + 4. type into the dialog textbox + 5. optional: Photo/video -> setFileInputFiles + 6. optional: change audience when visibility != the current one + 7. click [aria-label="Post"] + 8. dialog closes = success +""" + +import asyncio + +from ..cdp import CDPError, human_delay +from . import guard, require_text, validate_media +from ..selectors import FACEBOOK as S + +PLATFORM = "fb" +VALID_VISIBILITY = ("public", "friends") + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_text(PLATFORM, text) + validate_media(media_path) + + visibility = options.get("visibility") + if visibility is not None and visibility not in VALID_VISIBILITY: + raise CDPError( + f"fb visibility must be one of {VALID_VISIBILITY}, got {visibility!r}" + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com") + if reused: + # Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run) + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text, media_path, visibility)) + + +async def _flow(tab, text, media_path, visibility): + await asyncio.sleep(5) + await tab.click_js(S["composer_trigger_js"]) + + await tab.wait_for(S["dialog_textbox"], timeout=30.0) + await tab.type_text(S["dialog_textbox"], text) + + if media_path: + # TODO: VALIDATE LIVE — whether the hidden file input is already in the + # DOM before the Photo/video button is clicked. + if not await tab.exists(S["file_input"]): + await tab.click_js(S["photo_video_js"]) + await tab.wait_for(S["file_input"], visible=False) + await tab.set_file_input(S["file_input"], media_path) + # TODO: VALIDATE LIVE — upload/preview settle time. + await human_delay(2.0, 3.5) + + if visibility: + # TODO: VALIDATE LIVE — the whole audience sub-dialog. We only open the + # picker here; the option list itself is unmapped, so we refuse rather + # than click something we haven't seen. + raise CDPError( + "fb visibility switching is not implemented: the audience sub-dialog " + "has not been mapped live yet. Post without `visibility` to use the " + "account's current default." + ) + + # Try aria-label first, fall back to text-match JS click + if await tab.exists(S["post_button"]): + await tab.click(S["post_button"]) + else: + await tab.click_js(S["post_button_js"]) + # FB keeps hidden [role="dialog"] elements in the DOM after the composer + # closes. Instead of waiting for all dialogs to vanish, check that the + # composer textbox is gone — that's the real "posted" signal. + await tab.wait_for_gone(S["dialog_textbox"], timeout=60.0) + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "facebook.com") + if reused: + # Dismiss any leftover dialog (e.g. unsaved Create Post from a failed run) + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(4) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "(() => !![...document.querySelectorAll('span')].find(s => s.innerText" + " && s.innerText.toLowerCase().includes('on your mind')))()" + ) diff --git a/platforms/instagram.py b/platforms/instagram.py new file mode 100644 index 0000000..2baf93a --- /dev/null +++ b/platforms/instagram.py @@ -0,0 +1,109 @@ +"""Instagram posting flow (media required). + + 1. navigate instagram.com + 2. click svg[aria-label="New post"] + 3. wait for the "Create new post" dialog + 4. setFileInputFiles on the dialog's file input (bypasses the picker) + 5. crop screen -> Next + 6. filter screen -> Next + 7. caption screen -> type caption + 8. Share + 9. dialog closes = success +""" + +import asyncio +import json + +from ..cdp import CDPError, human_delay +from . import guard, require_media +from ..selectors import INSTAGRAM as S + +PLATFORM = "ig" + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_media(PLATFORM, media_path) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text or "", media_path)) + + +async def _flow(tab, caption, media_path): + await tab.wait_for(S["new_post_svg"]) + await human_delay() + # JS click — walks up to the clickable parent of the SVG icon + await tab.eval(S["new_post_js"]) + await human_delay() + + await tab.wait_for(S["dialog"]) + await tab.wait_for_text(S["create_dialog_text"]) + + # The file input is present but hidden — visible=False on purpose. + await tab.wait_for(S["file_input"], visible=False) + await tab.set_file_input(S["file_input"], media_path) + + # TODO: VALIDATE LIVE — how long IG takes to move from picker to crop, and + # whether the crop step is skipped for already-square media. + await human_delay(2.0, 4.0) + + # Crop -> Next, filter -> Next. Same button text on both screens, so we + # click it twice with a settle between; if a screen is skipped the second + # Next simply won't be there and we fail loudly rather than guessing. + for step in ("crop", "filter"): + await _click_next(tab, step) + await human_delay(1.0, 2.0) + + await tab.wait_for(S["caption_box"]) + if caption: + await tab.type_text(S["caption_box"], caption) + + await tab.click_js(S["share_button_js"]) + + # TODO: VALIDATE LIVE — IG shows "Your post has been shared" before the + # dialog closes; either signal is success, whichever lands first. + await _wait_for_share(tab) + + +async def _click_next(tab, step): + deadline = asyncio.get_running_loop().time() + 20.0 + while asyncio.get_running_loop().time() < deadline: + found = await tab.eval("!!(%s)" % S["next_button_js"]) + if found: + await tab.click_js(S["next_button_js"]) + return + await asyncio.sleep(0.25) + raise CDPError(f"ig: no 'Next' button on the {step} screen after 20s") + + +async def _wait_for_share(tab): + deadline = asyncio.get_running_loop().time() + 90.0 + while asyncio.get_running_loop().time() < deadline: + if await tab.eval( + "document.body.innerText.includes(%s)" % json.dumps(S["shared_text"]) + ): + # Click "Done" to dismiss the confirmation dialog + await human_delay(0.5, 1.0) + try: + await tab.click_js(S["done_button_js"]) + except Exception: + pass + return + if not await tab.exists(S["dialog"]): + return + await asyncio.sleep(0.5) + raise CDPError("ig: no share confirmation and the dialog stayed open for 90s") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "instagram.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.exists(S["new_post_svg"]) diff --git a/platforms/threads.py b/platforms/threads.py new file mode 100644 index 0000000..da24503 --- /dev/null +++ b/platforms/threads.py @@ -0,0 +1,119 @@ +"""Threads posting flow (2026-09 inline composer). + + 1. navigate threads.com + 2. click the inline "What's new?" composer + 3. type text + 4. click Post button + 5. text clearing / post appearing = success +""" + +import asyncio + +from ..cdp import human_delay +from . import guard, require_text, validate_media +from ..selectors import THREADS as S + +PLATFORM = "threads" + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_text(PLATFORM, text) + validate_media(media_path) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text, media_path)) + + +async def _flow(tab, text, media_path): + # Wait for the inline composer to appear + await _wait_for_composer(tab) + await human_delay() + + # Click the composer input area + await tab.click_js(S["composer_trigger_js"]) + await human_delay(0.5, 1.0) + + # Type into whichever input is now focused — could be a contenteditable + # or a textbox that appeared after clicking + textbox = await _find_active_input(tab) + await tab.type_text(textbox, text) + + if media_path: + if not await tab.exists(S["file_input"]): + # Look for an attach media button near the composer + await human_delay() + if await tab.exists(S["file_input"]): + await tab.set_file_input(S["file_input"], media_path) + await human_delay(1.5, 3.0) + + await human_delay(0.5, 1.0) + await tab.click_js(S["post_button_js"]) + + # Success = the composer returns to its empty placeholder state + await _wait_for_post_complete(tab) + + +async def _wait_for_composer(tab, timeout=20.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + has_input = await tab.eval( + "!!(document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" + ) + if has_input: + return + await asyncio.sleep(0.5) + from ..cdp import CDPError + raise CDPError("threads: composer ('What's new?') not found after 20s") + + +async def _find_active_input(tab): + """After clicking the composer, find the actual text input element.""" + # Check for contenteditable first (Threads often uses these) + for sel in ('[contenteditable="true"]', '[role="textbox"]', 'textarea', + '[placeholder="What\'s new?"]'): + if await tab.exists(sel): + return sel + from ..cdp import CDPError + raise CDPError("threads: no text input found after clicking composer") + + +async def _wait_for_post_complete(tab, timeout=30.0): + """Wait for the post to go through — composer empties or a toast appears.""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + # Check if the composer is back to empty placeholder state + empty = await tab.eval( + "(() => {" + " const ce = document.querySelector('[contenteditable=\"true\"]');" + " if (ce && ce.innerText.trim() === '') return true;" + " const tb = document.querySelector('[role=\"textbox\"]');" + " if (tb && tb.innerText.trim() === '') return true;" + " return false;" + "})()" + ) + if empty: + return + await asyncio.sleep(0.5) + from ..cdp import CDPError + raise CDPError("threads: composer still has text 30s after clicking Post") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "threads.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(4) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "!!(document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" + ) diff --git a/platforms/tiktok.py b/platforms/tiktok.py new file mode 100644 index 0000000..eb0a4b0 --- /dev/null +++ b/platforms/tiktok.py @@ -0,0 +1,130 @@ +"""TikTok upload flow (media required). + + 1. navigate tiktokstudio/upload + 2. wait for the upload page + 3. photos: switch to the Photos tab first + 4. setFileInputFiles on the matching input[type=file] + 5. wait for processing + 6. type the caption + 7. click Post/Publish + 8. wait for the success confirmation + +TikTok is the most bot-sensitive of the five (proposal: ⭐⭐⭐⭐). Delays here +are deliberately longer than elsewhere. +""" + +import asyncio +import json +import os + +from ..cdp import CDPError, human_delay +from . import guard, require_media +from ..selectors import TIKTOK as S + +PLATFORM = "tiktok" + +VIDEO_EXTS = {".mp4", ".mov", ".webm", ".avi", ".mkv"} +PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".heic"} + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_media(PLATFORM, media_path) + + ext = os.path.splitext(media_path)[1].lower() + if ext in VIDEO_EXTS: + kind = "video" + elif ext in PHOTO_EXTS: + kind = "photo" + else: + raise CDPError( + f"tiktok: unrecognised media extension {ext!r} — " + f"videos {sorted(VIDEO_EXTS)}, photos {sorted(PHOTO_EXTS)}" + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text or "", media_path, kind)) + + +async def _flow(tab, caption, media_path, kind): + await tab.wait_for_load(timeout=45.0) + await human_delay(1.5, 3.0) + + if kind == "photo": + # TODO: VALIDATE LIVE — the Photos tab switch and the input it reveals. + await _wait_for_js(tab, S["photos_tab_js"], "the Photos tab", timeout=30.0) + await tab.click_js(S["photos_tab_js"]) + await human_delay(1.0, 2.0) + file_input = S["photo_file_input"] + else: + file_input = S["video_file_input"] + + await tab.wait_for(file_input, visible=False, timeout=45.0) + await tab.set_file_input(file_input, media_path) + + # Step 5: TikTok transcodes before it will let you post. + # TODO: VALIDATE LIVE — the real "upload finished" marker. Right now we + # wait for the Post button to become enabled, which is the behaviour the + # flow actually depends on. + await _wait_for_js( + tab, S["post_button_js"], "an enabled Post button", timeout=300.0 + ) + + if caption: + await tab.wait_for(S["caption_box"]) + # DraftJS: type per-char so its keypress handlers fire. + # TODO: VALIDATE LIVE — whether insertText is accepted here. + await tab.type_text(S["caption_box"], caption, per_char=True) + await human_delay(1.0, 2.0) + + await tab.click_js(S["post_button_js"]) + + # TikTok may show a copyright check dialog — dismiss it + await asyncio.sleep(3) + try: + has_continue = await tab.eval("!!(%s)" % S["continue_post_js"]) + if has_continue: + await tab.click_js(S["continue_post_js"]) + except Exception: + pass + + await _wait_for_success(tab) + + +async def _wait_for_js(tab, expression, what, timeout=30.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await tab.eval("!!(%s)" % expression): + return + await asyncio.sleep(0.5) + raise CDPError(f"tiktok: {what} did not appear within {timeout}s") + + +async def _wait_for_success(tab, timeout=120.0): + # TODO: VALIDATE LIVE — the confirmation copy. TikTok also redirects to + # /tiktokstudio/content on success, which we accept as the same signal. + deadline = asyncio.get_running_loop().time() + timeout + marker = json.dumps(S["success_text"]) + while asyncio.get_running_loop().time() < deadline: + if await tab.eval(f"document.body.innerText.includes({marker})"): + return + if "tiktokstudio/content" in (await tab.url() or ""): + return + await asyncio.sleep(1.0) + raise CDPError( + f"tiktok: no success confirmation within {timeout}s of clicking Post" + ) + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "tiktok.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.exists(S["video_file_input"]) diff --git a/platforms/youtube.py b/platforms/youtube.py new file mode 100644 index 0000000..a548fe5 --- /dev/null +++ b/platforms/youtube.py @@ -0,0 +1,106 @@ +"""YouTube community post flow. + + 1. navigate youtube.com + 2. click button[aria-label="Create"] + 3. click "Create post" in the dropdown + 4. wait for the redirect to the channel posts page + 5. type into #contenteditable-root + 6. click Post + 7. the editor emptying / the post appearing = success + +Community posts require a channel that has them enabled. Video upload +(post_youtube_video in the proposal) is NOT built here — the brief scoped this +module to the community post. +""" + +import asyncio + +from ..cdp import CDPError, human_delay +from . import guard, require_text, validate_media +from ..selectors import YOUTUBE as S + +PLATFORM = "yt" + + +async def post(cdp_client, text, media_path=None, options=None): + options = options or {} + require_text(PLATFORM, text) + validate_media(media_path) + if media_path: + # Community posts do take images, but that flow is unmapped. Refuse + # rather than drop the media on the floor. + raise CDPError( + "yt: media on community posts is not implemented (the image-attach " + "flow has not been mapped live). Post text-only, or use a platform " + "whose media flow is built." + ) + + tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + return await guard(PLATFORM, tab, _flow(tab, text)) + + +async def _flow(tab, text): + await tab.wait_for(S["create_button"]) + await human_delay() + await tab.click(S["create_button"]) + + # TODO: VALIDATE LIVE — the dropdown item text and element type. + await _wait_for_js(tab, S["create_post_item_js"], "the 'Create post' menu item") + await tab.click_js(S["create_post_item_js"]) + + # Step 4: YouTube navigates to the channel's posts page. + await tab.wait_for_load(timeout=30.0) + await tab.wait_for(S["editor"], timeout=30.0) + + # #contenteditable-root is a YouTube custom editor: it listens to real key + # events, so type per-char rather than insertText. + # TODO: VALIDATE LIVE — whether insertText alone enables the Post button. + await tab.type_text(S["editor"], text, per_char=True) + + if await tab.exists(S["post_button"]): + await tab.click(S["post_button"]) + else: + await tab.click_js(S["post_button_js"]) + + # TODO: VALIDATE LIVE — success marker. The editor going empty is the + # cheapest observable signal; the proposal says "post appears". + await _wait_for_editor_empty(tab) + + +async def _wait_for_js(tab, expression, what, timeout=20.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await tab.eval("!!(%s)" % expression): + return + await asyncio.sleep(0.25) + raise CDPError(f"yt: {what} did not appear within {timeout}s") + + +async def _wait_for_editor_empty(tab, timeout=30.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + text = await tab.eval( + "(() => { const e = document.querySelector('%s');" + " return e ? e.innerText.trim() : null; })()" % S["editor"] + ) + if text == "": + return + await asyncio.sleep(0.5) + raise CDPError("yt: editor still holds text 30s after clicking Post") + + +async def check_login(cdp_client): + tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com") + if reused: + await tab.eval("window.onbeforeunload = null") + await tab.navigate(S["url"]) + await asyncio.sleep(5) + if await tab.exists(S["login_marker"]): + return False + return await tab.eval( + "!!document.querySelector('#avatar-btn, button#avatar-btn," + " ytd-topbar-menu-button-renderer #avatar-btn')" + ) diff --git a/queue.py b/queue.py new file mode 100644 index 0000000..a4d4803 --- /dev/null +++ b/queue.py @@ -0,0 +1,90 @@ +"""Sequential execution queue — one browser, one tab at a time. + +Every platform flow drives abang's real browser window. Two flows at once +would fight over focus, file inputs and dialogs, so everything funnels through +one worker task here. + + q = PostQueue(inter_platform_delay=(3.0, 6.0)) + await q.start() + result = await q.submit("threads", coro_factory) + +`submit` returns that job's result (or raises what the job raised). Jobs run +strictly in submission order. +""" + +import asyncio +import random +import time + + +class PostQueue: + def __init__(self, inter_platform_delay=(3.0, 6.0)): + lo, hi = inter_platform_delay + if lo < 0 or hi < lo: + raise ValueError( + f"inter_platform_delay must be (lo, hi) with 0 <= lo <= hi, got {lo, hi}" + ) + self.delay_lo, self.delay_hi = lo, hi + self._queue = asyncio.Queue() + self._worker = None + self._last_finished = None + self.history = [] # (iso_time, label, status) + + async def start(self): + if self._worker is None: + self._worker = asyncio.create_task(self._run()) + return self + + async def stop(self): + if self._worker: + self._worker.cancel() + try: + await self._worker + except asyncio.CancelledError: + pass + self._worker = None + + @property + def depth(self): + return self._queue.qsize() + + async def submit(self, label, factory): + """Queue `factory()` (a zero-arg coroutine function) and await its result.""" + if self._worker is None: + raise RuntimeError("PostQueue.submit() before start()") + future = asyncio.get_running_loop().create_future() + await self._queue.put((label, factory, future)) + return await future + + async def _run(self): + while True: + label, factory, future = await self._queue.get() + await self._space_out() + try: + result = await factory() + if not future.done(): + future.set_result(result) + status = result.get("status") if isinstance(result, dict) else "ok" + except asyncio.CancelledError: + if not future.done(): + future.cancel() + raise + except Exception as exc: # noqa: BLE001 — hand it to the caller intact + if not future.done(): + future.set_exception(exc) + status = f"raised {type(exc).__name__}" + finally: + self._last_finished = time.time() + self._queue.task_done() + self.history.append( + (time.strftime("%Y-%m-%dT%H:%M:%S"), label, status) + ) + + async def _space_out(self): + """Rate-limit gap between consecutive platform jobs.""" + if self._last_finished is None: + return + gap = random.uniform(self.delay_lo, self.delay_hi) + elapsed = time.time() - self._last_finished + if elapsed < gap: + await asyncio.sleep(gap - elapsed) diff --git a/selectors.py b/selectors.py new file mode 100644 index 0000000..31ace2c --- /dev/null +++ b/selectors.py @@ -0,0 +1,165 @@ +"""Every platform selector, in one file. + +When a platform ships a UI change, this is the only file that should need +touching (proposal, Risks table). Nothing here is guessed silently — anything +that came from a doc rather than a live FAFO session is marked, and every +selector a flow depends on is validated live before we trust it. + +Convention: + *_SEL CSS selector string + *_JS a JS expression returning an element (for click_js) +""" + +# -------------------------------------------------------------------------- +# Threads +# -------------------------------------------------------------------------- + +THREADS = { + "url": "https://www.threads.com/", + # New inline composer (2026-09 redesign) — no dialog. + "composer_input": '[placeholder="What\'s new?"]', + "composer_trigger_js": ( + "document.querySelector('[placeholder=\"What\\'s new?\"]')" + " || [...document.querySelectorAll('span,div')]" + ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?')" + ), + "post_button_js": ( + "[...document.querySelectorAll('button, [role=\"button\"]')]" + ".find(b => b.innerText.trim() === 'Post')" + ), + # Legacy dialog selectors (kept for fallback) + "dialog": '[role="dialog"]', + "dialog_textbox": '[role="dialog"] [role="textbox"]', + "attach_media": '[role="dialog"] [aria-label="Attach media"]', + "file_input": 'input[type="file"]', + "login_marker": 'input[name="username"], a[href*="/login"]', +} + +# -------------------------------------------------------------------------- +# Facebook +# -------------------------------------------------------------------------- + +FACEBOOK = { + "url": "https://www.facebook.com/", + # ⚠️ Mouse-clicking the visible "What's on your mind" span opens Messenger. + # Walk up to the enclosing [role=button] and .click() it instead. + "composer_trigger_js": ( + "(() => { const span = [...document.querySelectorAll('span')]" + ".find(s => s.innerText && s.innerText.toLowerCase().includes('on your mind'));" + " return span ? span.closest('[role=\"button\"]') : null; })()" + ), + "dialog": '[role="dialog"]', + "dialog_textbox": '[role="dialog"] [role="textbox"][contenteditable="true"]', + # TODO: VALIDATE LIVE — the Photo/video button inside the create-post dialog. + "photo_video_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]" + ".find(b => /photo\\/video/i.test(b.innerText || ''))" + ), + "file_input": '[role="dialog"] input[type="file"][accept*="image"]', + # TODO: VALIDATE LIVE — audience/privacy selector inside the dialog. + "privacy_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]" + ".find(b => /public|friends|audience/i.test(b.innerText || ''))" + ), + "post_button": '[role="dialog"] [aria-label="Post"]', + "post_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Post')" + ), + "create_post_text": "Create post", + "login_marker": 'input[name="email"][type="text"], form[action*="login"]', +} + +# -------------------------------------------------------------------------- +# Instagram +# -------------------------------------------------------------------------- + +INSTAGRAM = { + "url": "https://www.instagram.com/", + "new_post_svg": 'svg[aria-label="New post"]', + # JS click — more reliable than mouse on sidebar SVG icons + "new_post_js": ( + "(() => { const svg = document.querySelector('svg[aria-label=\"New post\"]');" + " if (svg) { const btn = svg.closest('a, button, [role=\"button\"], div[role=\"button\"]')" + " || svg.parentElement; btn.click(); return true; } return false; })()" + ), + "dialog": '[role="dialog"]', + "create_dialog_text": "Create new post", + "file_input": '[role="dialog"] input[type="file"]', + "next_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Next')" + ), + "caption_box": '[role="dialog"] textarea, [role="dialog"] [contenteditable="true"]', + "share_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Share')" + ), + "shared_text": "Your post has been shared", + "done_button_js": ( + "[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], " + "[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Done')" + ), + "login_marker": 'input[name="username"]', +} + +# -------------------------------------------------------------------------- +# YouTube — community post +# -------------------------------------------------------------------------- + +YOUTUBE = { + "url": "https://www.youtube.com/", + "create_button": 'button[aria-label="Create"]', + # TODO: VALIDATE LIVE — the dropdown item is a tp-yt-paper-item with text. + "create_post_item_js": ( + "[...document.querySelectorAll('tp-yt-paper-item, ytd-compact-link-renderer, " + "yt-formatted-string')].find(e => e.innerText && " + "e.innerText.trim().toLowerCase() === 'create post')" + ), + "editor": "#contenteditable-root", + "post_button": 'button[aria-label="Post"]', + # TODO: VALIDATE LIVE — YouTube also renders Post as ytd-button-renderer text. + "post_button_js": ( + "[...document.querySelectorAll('button, tp-yt-paper-button')]" + ".find(b => b.innerText.trim().toLowerCase() === 'post')" + ), + "login_marker": 'a[href*="accounts.google.com/ServiceLogin"]', +} + +# -------------------------------------------------------------------------- +# TikTok +# -------------------------------------------------------------------------- + +TIKTOK = { + "url": "https://www.tiktok.com/tiktokstudio/upload?from=upload", + "video_file_input": 'input[type="file"][accept*="video"]', + "photo_file_input": 'input[type="file"][accept*="image"]', + # TODO: VALIDATE LIVE — the Photos/Video tab switch on the upload page. + "photos_tab_js": ( + "[...document.querySelectorAll('div, span, button')]" + ".find(e => e.innerText && e.innerText.trim() === 'Photos')" + ), + # TikTok's caption box is a DraftJS contenteditable. + "caption_box": 'div[contenteditable="true"]', + # TODO: VALIDATE LIVE — upload progress marker; used to know processing is done. + "uploaded_text": "Uploaded", + "post_button_js": ( + "[...document.querySelectorAll('button')]" + ".find(b => ['post','publish'].includes(b.innerText.trim().toLowerCase()) " + "&& !b.disabled)" + ), + "success_text": "Your video is being uploaded", + "continue_post_js": ( + "[...document.querySelectorAll('button, [role=\"button\"]')]" + ".find(b => /continue to post|post now/i.test(b.innerText.trim()))" + ), + "login_marker": 'a[href*="/login"]', +} + +PLATFORMS = { + "threads": THREADS, + "fb": FACEBOOK, + "ig": INSTAGRAM, + "yt": YOUTUBE, + "tiktok": TIKTOK, +} diff --git a/server.py b/server.py new file mode 100644 index 0000000..08025ed --- /dev/null +++ b/server.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""socialmag — MCP stdio server. + +Three tools: + create_post post to one or more platforms through abang's logged-in Brave + check_login which platforms are currently signed in + get_status the last create_post run's results + +Transport is the standard MCP stdio pattern: one JSON-RPC message per line on +stdin, one per line on stdout. Nothing but protocol goes to stdout — logs go +to stderr. + +Run: python3 -m socialmag (from the parent dir; expects Brave on :9222) + NOT `python3 server.py` — selectors.py/queue.py shadow stdlib modules + unless this runs as a package. See __init__.py. +""" + +import asyncio +import json +import os +import sys +import time + +from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT +from .platforms import facebook, instagram, threads, tiktok, youtube +from .queue import PostQueue + +PROTOCOL_VERSION = "2025-06-18" +SERVER_NAME = "socialmag" +SERVER_VERSION = "0.1.0" + +STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json") + +MODULES = { + "threads": threads, + "fb": facebook, + "ig": instagram, + "yt": youtube, + "tiktok": tiktok, +} + +# The endpoint is a spec constant, not a fallback for missing config. Override +# with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere. +CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT) + + +def log(*args): + print(f"[socialmag {time.strftime('%H:%M:%S')}]", *args, file=sys.stderr, flush=True) + + +# --------------------------------------------------------------------------- +# Tool schemas +# --------------------------------------------------------------------------- + +TOOLS = [ + { + "name": "create_post", + "description": ( + "Post to one or more social platforms through the logged-in Brave " + "browser (CDP). Platforms run sequentially, one tab at a time. " + "Returns a per-platform status map." + ), + "inputSchema": { + "type": "object", + "properties": { + "platforms": { + "type": "array", + "items": {"enum": sorted(MODULES)}, + "description": "Any combination of threads, fb, ig, yt, tiktok.", + }, + "text": { + "type": "string", + "description": ( + "Post body. Required for threads/fb/yt; used as the " + "caption for ig/tiktok." + ), + }, + "media": { + "type": "string", + "description": ( + "Absolute path to an image/video. Optional for " + "threads/fb; REQUIRED for ig/tiktok; not supported " + "for yt community posts." + ), + }, + "visibility": { + "enum": ["public", "friends"], + "description": "Facebook audience. Not implemented yet — see detail.", + }, + }, + "required": ["platforms"], + }, + }, + { + "name": "check_login", + "description": ( + "Check which platforms the browser is signed in to. Opens and " + "closes one tab per platform; no posting." + ), + "inputSchema": { + "type": "object", + "properties": { + "platforms": { + "type": "array", + "items": {"enum": sorted(MODULES)}, + "description": "Defaults to all five when omitted.", + } + }, + }, + }, + { + "name": "get_status", + "description": "Results of the last create_post run, plus queue history.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + + +def save_state(state): + tmp = STATE_PATH + ".tmp" + with open(tmp, "w") as fh: + json.dump(state, fh, indent=2) + os.replace(tmp, STATE_PATH) + + +def load_state(): + if not os.path.exists(STATE_PATH): + return {"last_post": None, "results": {}} + with open(STATE_PATH) as fh: + return json.load(fh) + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + + +async def tool_create_post(args, queue): + platforms = args.get("platforms") + if not platforms: + raise ValueError("create_post: 'platforms' is required and must be non-empty") + unknown = [p for p in platforms if p not in MODULES] + if unknown: + raise ValueError( + f"create_post: unknown platform(s) {unknown}; known: {sorted(MODULES)}" + ) + + text = args.get("text") + media = args.get("media") + options = {} + if "visibility" in args: + options["visibility"] = args["visibility"] + + results = {} + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + log(f"connected to {cdp.browser_version} at {CDP_ENDPOINT}") + try: + for name in platforms: + module = MODULES[name] + + def factory(module=module): + return module.post(cdp, text, media, options) + + try: + results[name] = await queue.submit(name, factory) + except CDPError as exc: + # Raised before the flow reached its guard (bad args, bad media). + results[name] = {"status": "error", "detail": f"{name}: {exc}"} + except Exception as exc: # noqa: BLE001 + results[name] = { + "status": "error", + "detail": f"{name}: unexpected {type(exc).__name__}: {exc}", + } + log(f"{name} -> {results[name]['status']}") + finally: + await cdp.close() + + state = { + "last_post": time.strftime("%Y-%m-%dT%H:%M:%S"), + "results": results, + "text": text, + "media": media, + } + save_state(state) + return results + + +async def tool_check_login(args, queue): + platforms = args.get("platforms") or sorted(MODULES) + unknown = [p for p in platforms if p not in MODULES] + if unknown: + raise ValueError( + f"check_login: unknown platform(s) {unknown}; known: {sorted(MODULES)}" + ) + + out = {} + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + try: + for name in platforms: + module = MODULES[name] + + def factory(module=module): + return module.check_login(cdp) + + try: + out[name] = bool(await queue.submit(f"check:{name}", factory)) + except Exception as exc: # noqa: BLE001 — a failed check is not a False + out[name] = f"error: {type(exc).__name__}: {exc}" + finally: + await cdp.close() + return out + + +async def tool_get_status(args, queue): + state = load_state() + state["queue_history"] = queue.history[-20:] + state["queue_depth"] = queue.depth + # Show open browser tabs so the caller can see what's reusable + try: + cdp = CDPClient(CDP_ENDPOINT) + await cdp.connect() + targets = await cdp.list_targets() + state["open_tabs"] = [ + {"id": t["targetId"], "url": t.get("url", ""), "title": t.get("title", "")} + for t in targets + ] + await cdp.close() + except Exception: + state["open_tabs"] = "could not connect to browser" + return state + + +HANDLERS = { + "create_post": tool_create_post, + "check_login": tool_check_login, + "get_status": tool_get_status, +} + + +# --------------------------------------------------------------------------- +# JSON-RPC / MCP plumbing +# --------------------------------------------------------------------------- + + +class Server: + def __init__(self): + self.queue = PostQueue() + + async def handle(self, msg): + """Return a response dict, or None for notifications.""" + method = msg.get("method") + mid = msg.get("id") + params = msg.get("params") or {} + + if method == "initialize": + return self._ok( + mid, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + }, + ) + if method in ("notifications/initialized", "initialized"): + return None + if method == "ping": + return self._ok(mid, {}) + if method == "tools/list": + return self._ok(mid, {"tools": TOOLS}) + if method == "tools/call": + return await self._call_tool(mid, params) + if mid is None: + return None # unknown notification — ignore, don't answer + return self._err(mid, -32601, f"method not found: {method}") + + async def _call_tool(self, mid, params): + name = params.get("name") + args = params.get("arguments") or {} + handler = HANDLERS.get(name) + if handler is None: + return self._err(mid, -32602, f"unknown tool: {name}") + try: + result = await handler(args, self.queue) + except Exception as exc: # noqa: BLE001 — report as a tool error, loudly + log(f"tool {name} failed: {type(exc).__name__}: {exc}") + return self._ok( + mid, + { + "content": [ + {"type": "text", "text": f"{type(exc).__name__}: {exc}"} + ], + "isError": True, + }, + ) + return self._ok( + mid, + {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]}, + ) + + @staticmethod + def _ok(mid, result): + return {"jsonrpc": "2.0", "id": mid, "result": result} + + @staticmethod + def _err(mid, code, message): + return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} + + +async def main(): + server = Server() + await server.queue.start() + + reader = asyncio.StreamReader() + await asyncio.get_running_loop().connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), sys.stdin + ) + log(f"listening on stdio · CDP endpoint {CDP_ENDPOINT}") + + while True: + line = await reader.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError as exc: + log(f"dropping unparseable line: {exc}") + continue + response = await server.handle(msg) + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + await server.queue.stop() + log("stdin closed, exiting") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/state.json b/state.json new file mode 100644 index 0000000..beb807f --- /dev/null +++ b/state.json @@ -0,0 +1,11 @@ +{ + "last_post": "2026-09-10T21:06:30", + "results": { + "tiktok": { + "status": "ok", + "detail": "tiktok: posted" + } + }, + "text": "vibes check, late night energy \ud83c\udf19\u2728", + "media": "/mnt/playground/Server_files/Personal_pic/O.E.P/bahaya_tema_ni_haha.mp4" +} \ No newline at end of file