Files
socialmag/README.md
T
juxtapo 1ac06c1bbe v0.3: dashboard local mode, openbridge tab reuse, tab activation fix
- 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
2026-09-11 10:18:38 +08:00

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").