- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode; clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware freshness line - openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the platform's warm tab instead of spawning one per click - cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded tab never finishes rendering, which made YouTube's login check read a signed-in account as signed out - README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the false claim that the server writes dashboard.json, documented the reboot/VPS requirements - .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked pycache and state.json from index
250 lines
12 KiB
Markdown
250 lines
12 KiB
Markdown
# socialmag
|
|
|
|
MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook,
|
|
Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in
|
|
Brave over CDP. No API keys, no OAuth.
|
|
|
|
## 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:9336` (`cdp.py:29`,
|
|
`DEFAULT_HTTP_ENDPOINT`). Override with `SOCIALMAG_CDP_ENDPOINT`.
|
|
|
|
⚠️ **`SOCIALMAG_CDP_PORT` is not read by anything.** `/root/.claude.json` sets it
|
|
for this server, but no code looks at it — grep says the only env var consulted is
|
|
`SOCIALMAG_CDP_ENDPOINT` (`server.py:65`). It appears to work today purely because
|
|
the default endpoint is already `:9336`. Change the port there and nothing happens,
|
|
silently. Fix the name in the config, or teach `server.py` to read the port.
|
|
|
|
Requires `websockets` (17.1 present on this box). Everything else is stdlib.
|
|
|
|
## Tools
|
|
|
|
| Tool | Description |
|
|
|------|-------------|
|
|
| `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) |
|
|
| `check_login` | Check which platforms are signed in |
|
|
| `get_status` | Last post results + open browser tabs |
|
|
| `get_feed` | Read Twitter timeline or search with X operators |
|
|
| `reply_tweet` | Reply to a tweet found by search |
|
|
| `get_notifications` | Check platform activity/notifications (Threads) |
|
|
| `open_link` | Navigate platform's browser tab to a URL (tab reuse) |
|
|
|
|
`create_post` — `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**; required for
|
|
ig/tiktok, optional for threads/fb, rejected for yt.
|
|
|
|
## Tab reuse and focus
|
|
|
|
`cdp.get_or_open_tab(url, domain)` attaches to the first open tab whose URL
|
|
contains `domain`, and only opens a new tab when none matches. Every tool goes
|
|
through it, so socialmag does not spawn a tab per action.
|
|
|
|
A reused tab is then brought to the front via `Tab.activate()`
|
|
(`Target.activateTarget`). **This is not cosmetic.** A backgrounded tab is not
|
|
guaranteed to finish rendering: YouTube leaves its masthead as
|
|
`#masthead-skeleton-icons` with an empty `#buttons` and never mounts
|
|
`#avatar-btn`, so `youtube.check_login` — which tests for that element — reported
|
|
a signed-in account as signed out. Measured 2026-09-11: `#avatar-btn` still absent
|
|
after 23s in a background tab, while `ytcfg.get('LOGGED_IN')` was `true` the whole
|
|
time. With activation, `yt` returns `True` 3/3; without it, `False` 3/3.
|
|
|
|
**The cost:** any socialmag call that touches a platform now pulls Brave's focus to
|
|
that tab. Fine for a deliberate call, disruptive on a timer — see *Surviving reboot*
|
|
below.
|
|
|
|
A DOM-render signal (`#avatar-btn`) needs the foreground. A JS-state signal
|
|
(`ytcfg.get('LOGGED_IN')`) does not. That distinction is the lever if focus-stealing
|
|
ever has to go away.
|
|
|
|
## Dashboard
|
|
|
|
`dashboard.html` is a local viewer — platform status strip, post/notification
|
|
timeline, and a Twitter intel rail. It runs in two modes:
|
|
|
|
- **Artifact mode** — published as a Claude Artifact with `capabilities: {db: {}}`,
|
|
reading four live collections (`platforms`, `posts`, `notifications`, `feed`)
|
|
over `onSnapshot`.
|
|
- **Local mode** — served over plain HTTP, it falls back to fetching
|
|
`dashboard.json` from beside itself and re-polls every 10s. The freshness line
|
|
reads `local · updated Xs ago`, and the banner is informational, not a warning.
|
|
|
|
```bash
|
|
cd /mnt/playground/Playground_V2 && python3 -m http.server 8321
|
|
# http://127.0.0.1:8321/socialmag/dashboard.html
|
|
```
|
|
|
|
⚠️ **Nothing writes `dashboard.json`.** It is hand-maintained. There is no
|
|
refresher, no writer loop — an earlier version of this README claimed the server
|
|
wrote it at runtime; it does not. See *Surviving reboot* for what building one
|
|
actually costs.
|
|
|
|
Cards and feed rows with a `url` are clickable, behind a `safeUrl()` filter that
|
|
admits `http(s)` only — scraped URLs are untrusted input.
|
|
|
|
### openbridge — clicking without tab explosion
|
|
|
|
A static page cannot speak MCP, so a plain `target="_blank"` card opens a new tab
|
|
on every click. `openbridge.py` exposes exactly one capability — `open_link` — over
|
|
localhost so a click navigates the platform's existing tab instead.
|
|
|
|
```bash
|
|
cd /mnt/playground/Playground_V2 && python3 -m socialmag.openbridge # 127.0.0.1:8322
|
|
```
|
|
|
|
| Endpoint | Returns |
|
|
|---|---|
|
|
| `GET /health` | `{"ok": true, "platforms": [...]}` |
|
|
| `GET /open?platform=X&url=Y` | the `open_link` result, or `{"error": ...}` |
|
|
|
|
It imports `tool_open_link` rather than reimplementing it, so validation and domain
|
|
matching stay in one place. Verified 2026-09-11: 8 tabs before two clicks, 8 after,
|
|
both reused.
|
|
|
|
The dashboard treats an unreachable bridge as "just open a new tab" — progressive
|
|
enhancement, so the page is never worse than without it. Ctrl/Cmd/middle-click is
|
|
left alone. A bridge that answers and *refuses* surfaces the error instead of
|
|
quietly opening a tab.
|
|
|
|
## Layout
|
|
|
|
```
|
|
server.py MCP stdio JSON-RPC loop + 7 tools
|
|
cdp.py CDP client: CDPClient (browser) + Tab (page verbs, incl. activate)
|
|
queue.py PostQueue — strictly sequential, one tab at a time
|
|
selectors.py every platform selector + PLATFORMS dict, one file
|
|
platforms/ one module per platform: post() + check_login()
|
|
openbridge.py localhost HTTP shim exposing open_link to the dashboard
|
|
dashboard.html status/timeline viewer (artifact db mode, or local dashboard.json)
|
|
dashboard.json local-mode data — hand-maintained, nothing writes it
|
|
```
|
|
|
|
Each platform module exposes:
|
|
|
|
```python
|
|
async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail": ...}
|
|
async def check_login(cdp_client) -> bool
|
|
```
|
|
|
|
Twitter additionally exposes `get_feed`, `reply`, and `check_login` (reusing
|
|
the existing selector set). Threads exposes `get_notifications`.
|
|
|
|
`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.
|
|
|
|
⚠️ **Don't leave anything you want to keep in `/tmp`.** The v2 dashboard work was
|
|
written to `/tmp/socialmag/` and was lost to a `/tmp` clear — git only held the
|
|
pre-v2 file, and it had to be rebuilt from the handback. Guard screenshots are
|
|
disposable by design; build output is not.
|
|
|
|
## 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. `openbridge` refuses to move to
|
|
a different port if `:8322` is taken, for the same reason.
|
|
- 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.
|
|
|
|
---
|
|
|
|
# Surviving reboot, interval updates, and the VPS question
|
|
|
|
**Status: not built.** This section is the requirements map, not a description of
|
|
something running. Everything below was checked on this box on 2026-09-11.
|
|
|
|
## What has to survive, and what each piece actually needs
|
|
|
|
| Piece | Survives reboot today? | What it needs |
|
|
|---|---|---|
|
|
| `dashboard.html` / `dashboard.json` | ✅ files on disk | nothing — edit and refresh; `:8321` reads from disk, JSON re-polls every 10s |
|
|
| file server `:8321` | ❌ | a systemd unit. Started by hand today — parent process is `/bin/fish` |
|
|
| `openbridge` `:8322` | ❌ | a systemd unit |
|
|
| socialmag MCP server | ❌ and **systemd cannot help** | Claude Code spawns it as `"type": "stdio"` (`/root/.claude.json`). No unit can own that process. Editing `server.py`/`cdp.py` requires Claude Code to respawn it |
|
|
| Brave + CDP `:9336` | ❌ | **not a service** — pid 267116, launched by abang. After boot there is no CDP until he opens Brave |
|
|
| the data refresher | ❌ **does not exist** | has to be written — see below |
|
|
|
|
The house already runs this exact shape as system units (`cc-sidepanel-bridge.service`
|
|
— *"localhost HTTP shim to injectd"*), so the pattern to copy is
|
|
`/etc/systemd/system/*.service`, `User=juxtapo`, `Restart=on-failure`,
|
|
`WantedBy=multi-user.target`.
|
|
|
|
## The refresher, and the two things that make it hard
|
|
|
|
Making the dashboard update on an interval means writing something that calls
|
|
`check_login` / `get_feed` / `get_notifications` on a timer and merges the results
|
|
into `dashboard.json` (atomically — write a temp file and rename, so a poll never
|
|
reads a half-written file).
|
|
|
|
That much is small. These two are the real constraints:
|
|
|
|
**1 · A timer steals focus.** Because `get_or_open_tab` now activates the tab it
|
|
reuses (and it has to, or YouTube reads as signed-out), every scheduled refresh
|
|
yanks Brave to whatever tab it is scraping. Three ways out, none free:
|
|
|
|
- accept it, and pick a cadence sparse enough to tolerate (30-60 min)
|
|
- refresh **on demand only** — dashboard always served, data as fresh as the last
|
|
call. No timer, no focus theft
|
|
- rewrite the six `check_login` implementations to use JS-state signals
|
|
(`ytcfg.get('LOGGED_IN')` and equivalents) which work in a background tab, and
|
|
keep the genuinely DOM-dependent work (`get_feed`, `get_notifications`) on demand
|
|
|
|
**2 · Boot order cannot be satisfied.** Brave is not a service, so at boot there is
|
|
no CDP endpoint. A refresher unit must treat "no Brave" as a normal waiting state —
|
|
log it and retry on the next tick — not crash-loop. `Restart=on-failure` with a
|
|
timer is the right shape; a `Type=simple` daemon that exits on a failed connect is
|
|
not.
|
|
|
|
A systemd `.timer` is a better fit than cron here: it survives a missed window with
|
|
`Persistent=true`, logs to the journal, and can be ordered after the network. Cron
|
|
would work, but gives up the journal and the ordering.
|
|
|
|
## Running on a VPS — read this before planning it
|
|
|
|
socialmag's whole premise is *drive the browser abang is already logged into*. A VPS
|
|
has no such browser. Moving it there is not a port, it is a different product. What
|
|
it would actually require:
|
|
|
|
- **A headless Chrome/Brave on the VPS with a persisted profile**, and every one of
|
|
the six accounts logged in *there*. Cookies would have to be established on the
|
|
VPS and kept alive.
|
|
- **A display.** Several of these flows depend on rendered layout — and we now have
|
|
it in writing that background/unrendered pages give wrong answers (the YouTube
|
|
masthead skeleton above). Headless-with-Xvfb is the usual answer; "headless is
|
|
fine" is the usual mistake.
|
|
- **Surviving anti-bot.** A datacentre IP logging into Meta/TikTok/X accounts that
|
|
normally appear from a home connection is the single most likely thing to get the
|
|
accounts challenged or locked. This is a real risk to abang's actual accounts, not
|
|
a theoretical one.
|
|
- **Re-auth with no human present.** Every one of these platforms will eventually
|
|
demand a re-login or a 2FA prompt. On the desktop abang just does it. On a VPS,
|
|
something has to notice and tell him — which means the failure path needs to be
|
|
loud and reach him, not sit in a log.
|
|
|
|
None of this is refused — it is a build, and a larger one than the units above.
|
|
The honest order is: units for the two servers and a refresher on this box first,
|
|
because they are cheap and the answers they produce (how often does a session drop?
|
|
how often does a platform challenge a login?) are exactly the data needed to know
|
|
whether the VPS version is worth it.
|
|
|
|
## Not built (out of the brief's scope)
|
|
|
|
- `post_youtube_video` (video upload) — the brief scoped yt to community posts.
|
|
- `socialmag.service`, `socialmag-bridge.service`, `socialmag-refresh.timer` — the
|
|
units above. Deferred pending abang's call on the focus/cadence trade-off.
|
|
- The refresher itself. Nothing writes `dashboard.json`.
|
|
- Scheduling, analytics, multi-account (proposal "Future").
|