Files
socialmag/NAPKIN.md
T
juxtapoandClaude Opus 5 15f3388e5e v0.6: cross-platform feed on the dashboard, home units, napkin
dashboard.html — Feed and Timeline swap places; Feed is every platform's
capture now, not Twitter intel, so rows carry their own platform glyph and an
unknown platform renders neutral rather than being dropped. A row whose ts is
really first_seen reads "seen X ago": saying "just now" for a post of unknown
age is a lie that sorts it to the top.

dashboard.json — regenerated by the refresher; _yt_caveat dropped, the
activate fix made it stale.

deploy/home — dash, openbridge, refresh service and timer for this desktop.
Refresh every 30min with --skip-foreground, not kdmirah's 10min full pass:
there is a human at this box and the metrics table only needs enough
resolution to watch engagement move. No Restart= on the oneshot — a down
browser is a waiting state the refresher records and exits 0 for.

NAPKIN.md — the whiteboard: measured delay budget, the background-render
table, the three landmines earned (a DOM signal is a rendering question, %z
cannot come back out of struct_time, a pure time sort is not a feed), and the
open calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:58:45 +08:00

430 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NAPKIN — socialmag, going programmatic
Whiteboard for the couch talk, 2026-09-12 ~03:50. Numbers here were measured
tonight on this box against abang's live Brave (`:9336`), not estimated.
Nothing in step 1–3 below is built yet.
---
## Where we are tonight
Running at home right now, all by hand:
| Piece | State |
|---|---|
| dashboard `:8321` | up — `http://127.0.0.1:8321/socialmag/dashboard.html` |
| openbridge `:8322` | up (started 03:24; it was **down**, which is why notification clicks opened new tabs instead of reusing the platform tab) |
| brave-abang CDP `:9336` | up — not a service, abang launches it |
| `refresher.py` | works, one-shot, **pressed by hand** |
Shipped tonight, unplanned:
- `facebook.check_login` and `youtube.check_login` were **lying OUT on a signed-in
account**. Both were DOM-signal checks. Swapped to JS state (`c_user` cookie,
`ytcfg.get('LOGGED_IN')`). 4-in/2-out → **6-in/0-out**.
- `refresher.fetch_content()` added — `notifications` and `feed` now come from a
real scrape instead of being hand-pasted. Boris Cherny's 18h-old tweet gone;
feed reads 22m/54m/1h.
- Backups in `Archived/` (`facebook.py`, `youtube.py`, `refresher.py`,
`dashboard.json`, all `20260912-03*`).
---
## The delay budget — where 97 seconds goes
One full refresher pass, 8 jobs, measured 03:35:01 → 03:36:38.
| Source | Cost | Kind |
|---|---|---|
| `PostQueue._space_out()` | 3.0–6.0s **between every job** → 7 gaps ≈ 21–42s | deliberate anti-bot pacing — **keep** |
| `check_login` × 6 | navigate + `asyncio.sleep(4)` (fb/threads/twitter) or `sleep(5)` (ig/tiktok/yt) | blind sleep |
| `get_feed` | `sleep(4)` + click Following + `sleep(3)` + scroll + `sleep(2)` + `sleep(1)` = **10s fixed** | blind sleep |
| `get_notifications` | navigate + `sleep(4)` | blind sleep |
| CDP attach + activate + real navigate | ~3–8s per platform | real work |
**~55 of 97 seconds is unconditional `sleep()`.** Not a slow network — the code
asks for it.
X measured alone, warm tab, 3 runs: `get_feed` **15.0 / 11.2 / 11.1s**,
`check_login` **4.8 / 5.2 / 5.1s**. Of the 11.1s, **10.0s is fixed sleep** — the
actual DOM scrape of 10 articles takes ~1.1s.
The post flows already poll properly (`threads._wait_for_composer`,
`tiktok._wait_for_js`, `youtube._wait_for_editor_empty`). Only the **read** flows
sleep blind. History, not a technical reason.
---
## Background rendering — measured, not assumed
Question: can it work without stealing Brave's focus? Parked focus elsewhere,
attached without `activate()`:
| Platform | `visibilityState` | DOM signal | JS-state signal |
|---|---|---|---|
| twitter | `hidden` | ✅ True | ✅ True — **full scrape works**, 10 tweets, 11 articles at t=2s, flat to t=12s |
| ig | `hidden` | ✅ True | ✅ cookie `ds_user_id` |
| threads | `hidden` | 🔴 **False** | ✅ cookie `ds_user_id` |
| fb | — | 🔴 False | ✅ cookie `c_user` |
| yt | — | 🔴 False | ✅ `ytcfg.LOGGED_IN` |
| tiktok | **unproven** | True | ❌ `sessionid` is httpOnly, invisible to `document.cookie` |
🔴 **Threads is the third liar**, same shape as fb and yt: the "What's new?"
composer never mounts in a hidden tab, so a signed-in account reads as signed out.
⚠️ **TikTok could not be backgrounded.** Activated a sibling tab in the *same*
window (`Browser.getWindowForTarget` → both `windowId` 915141824) and TikTok still
reported `visible`. Either it lives in its own window, or it overrides
`visibilityState` to keep video playing. Its DOM signal has never lied — but it has
never been tested in a genuine background either. **Three states, not two: keep
`activate()` on tiktok until there is evidence.**
The headline: X renders in **2 seconds** in a hidden tab while the code sleeps
**10**. Dropping focus is not the only prize — the sleeps go with it.
---
## Plan — three steps, each stands alone
### 1 · Background pass *(smallest, highest value; nothing else is tolerable without it)*
- `threads.check_login` → cookie. **Required** — it lies today.
- `get_or_open_tab(url, contains, activate=True)` — add the parameter, don't
remove the behaviour. **Post flows still need foreground** for file dialogs.
- Read paths pass `activate=False`: five `check_login`s, `get_notifications`,
`get_feed`. TikTok stays `True`.
- Fixed sleeps → `wait_for` in the three read flows.
Estimate: **97s → ~25-30s, zero tab jumps.**
### 2 · SQLite behind the refresher
Not for size — 4.2KB today, JSON holds that forever. JSON breaks because **every
refresh replaces `feed` and `notifications` wholesale**:
- No history. *"How often does a session drop?"* — the exact question README says
we need answered before judging the VPS version — is unanswerable.
- No concept of new. Tonight's three Threads rows are yesterday's three rows with
`11h` aged to `1d`. Nothing knows they are the same.
- 🔴 The derived `ts` decays. A row sinks an hour every hour. **`first_seen` is a
timestamp we own** — exact, fixed, never guessed. Threads' missing clock stops
being a problem instead of being patched.
Shape: stdlib `sqlite3`, one file, `id = sha1(platform|text|url)`,
`first_seen` / `last_seen` / `times_seen`. Refresher writes the DB, then emits
`dashboard.json` as a derived snapshot — **`dashboard.html` does not change**.
Not proposed: artifact `db` mode. The page supports it (`onSnapshot` over four
collections) but it moves the data out of the house and fixes none of the three.
### 3 · The timer
`deploy/kdmirah/socialmag-refresh.timer` already exists —
`OnUnitActiveSec=10min`, `Persistent=true`, `OnBootSec=2min`. Written for
`User=ijat` on kdmirah, **never installed here**. Adapt paths and user.
Blocked on step 1 only. Installing it today means tabs jumping every 10 minutes
all day, on the desktop abang is working at.
---
# ✅ ALL THREE STEPS BUILT — 2026-09-12 04:10-06:00
The plan above is history now. What actually shipped, and where it differs:
| Step | State |
|---|---|
| 1 · background pass | ✅ `activate=False` param on `get_or_open_tab`; threads/ig/twitter moved to JS-state signals; tiktok deliberately left foreground |
| 2 · SQLite | ✅ `store.py` — `platform_checks` / `items` / `metrics`. `dashboard.json` is a derived view; `dashboard.html` unchanged for it |
| 3 · timer | ✅ three units in `deploy/home/`, installed and verified. Refresh every **30min** (not kdmirah's 10), `--skip-foreground` |
| — | ➕ `capture.py` — the third lane, which was not in the plan at all |
**The `wait_for` rewrite of `get_feed` was never done, on purpose.** `capture`
obsoleted the DOM read path before that work would have paid for itself.
## What changed, by file
cdp.py:179 get_or_open_tab(..., activate=True) — added, not replaced
platforms/threads.py DOM composer → ds_user_id cookie (it was LYING)
platforms/instagram.py → ds_user_id
platforms/twitter.py → twid
platforms/facebook.py → c_user, fixed sleep dropped
platforms/youtube.py → ytcfg.LOGGED_IN, fixed sleep dropped
platforms/tiktok.py untouched; a comment says why it stays foreground
refresher.py parallel check_all(skip_foreground=), capture wired in,
store wired in
capture.py NEW — network capture + in-document read
store.py NEW — SQLite
dashboard.html columns swapped, feed is cross-platform, "seen X ago"
deploy/home/* three units
Measured: **97s → 34s** full pass, **9-10s** login-only, 3/3 stable.
## Landmines earned tonight
- 🔴 **Dropping a `sleep` is not free.** Removing X's `sleep(4)` made `check_login`
report OUT on a signed-in account once five tabs navigated at once — the DOM
signal had not painted. Fixed with the `twid` cookie, not by restoring the sleep.
- 🔴 **`%z` goes into `struct_time` and cannot come back out.** `_x_time_to_iso`
emitted `2026-09-11T19:41:10` with no offset; JS `Date.parse` reads a naked
datetime as *local*, shifting every X row by the machine's offset, silently.
Use `datetime.strptime(...).isoformat()`.
- 🔴 **A pure time sort is not a cross-platform feed.** X returns ~185 fresh rows a
pass against Instagram's 3 older ones, so IG existed in the database and never
on the page. `FEED_PER_PLATFORM = 10` — representation before the merge.
- 🔴 **`ts` must be written once and never rewritten** (`COALESCE(ts, ?)`).
Re-deriving a Threads notification's time each pass reintroduces exactly the
decay the table exists to stop.
- 🔴 **"just now" for a post of unknown age is a lie that sorts to the top.** A
Threads payload without `taken_at` falls back to `first_seen`; those rows read
**"seen X ago"** now.
- ⚠️ `bravemag` `capture.screenshot` fails with an **empty** error string — the old
stdout/stderr trap. DOM probes (`page.evaluate` for bounding boxes and counts)
proved the layout instead, and are better evidence anyway.
---
## Open, abang's call
- **`posts` has no writer.** Only a real `create_post` can produce it, so the
"posted" rows in the timeline stay hand-written until something records post
results. Does `create_post` write to the DB in step 2?
- **Five platforms have no `get_notifications` at all** — `server.py:397` is
`{"threads": threads}`. fb / ig / yt / tiktok / twitter raise. Worth building,
or is Threads + X feed enough for the story?
- **TikTok's background behaviour** — worth one proper probe (own window? spoofed
`visibilityState`?), or just leave `activate()` on it forever?
- **Cadence.** 10min is kdmirah's number, chosen for a box nobody watches. On this
desktop, with step 1 done, what is it worth?
- **Units for `:8321` and `:8322`** — both hand-started today, both die on reboot.
`deploy/kdmirah/` has both service files ready to adapt.
---
## Landmines carried forward
- 🔴 **A DOM signal on a social platform is a rendering question, not a login
question.** Three platforms proved it tonight. Reach for JS state first.
- 🔴 **A short `sleep()` returns a short list with no error** — indistinguishable
from "nothing happened". This is why `fetch_content` keeps old rows and records
`_refreshed.contentErrors` instead of writing `[]`.
- ⚠️ `SOCIALMAG_CDP_PORT` is read by **nothing**. `/root/.claude.json` sets it.
Only `SOCIALMAG_CDP_ENDPOINT` is consulted (`server.py:65`).
- ⚠️ Run as `python3 -m socialmag`, never `socialmag/server.py` — `selectors.py`
and `queue.py` shadow stdlib modules asyncio imports.
- ⚠️ openbridge being down is **silent** by design (progressive enhancement). The
feature looks deleted rather than broken. That is how tonight started.
- ⚠️ **Escaping a JS regex through a Python heredoc bit me tonight.**
`"/ds_user_id=\\d+/"` reaches JS as a literal backslash and never matches, so
three platforms polled the full 20s and reported signed-out. The first
parallel benchmark measured three fake timeouts. A test that fails the way
the real thing would fail is the dangerous kind.
---
# The destination — Blotato, without the API
`https://www.blotato.com` — unified social API + MCP, $29/mo, 9 platforms.
Abang's target shape. **Their moat is the OAuth app approvals**, which is their
heaviest operational cost and the thing "without an API" skips entirely.
| # | Blotato block | socialmag today | Cost without an API |
|---|---|---|---|
| 01 | PUBLISH — 9 platforms, one call | **6 platforms, shipped** | LinkedIn, Bluesky, Pinterest — one module each, pattern exists |
| 02 | SCHEDULE — queue, next open slot, weekly slots | none | **Cheap.** Local queue + timer. Never touches the browser problem |
| 03 | COMMENTS & DMs — read inbox, reply, DM | Threads notifications, `twitter.reply` | **Expensive via DOM** — every inbox is a different page. Cheap via private API (see below) |
| 04 | DM AUTOMATIONS — keyword triggers | none | Needs 03. Highest account risk of anything here |
| 05 | ANALYTICS — top posts, snapshot history | `SCRAPE_JS` already pulls likes/RT/replies | **Cheap — this is step 2.** "Snapshot history" *is* `first_seen`/`last_seen` |
| 06 | VISUALS — infographics, carousels, video | none | Separate lane. House already has geminigen, piskelmag, blendmag |
| 07 | SOURCES — YouTube/PDF/article → post | none | Separate lane. An LLM + fetch problem, not a browser one |
| 08 | API & MCP | **socialmag is already an MCP server** | Done |
Steps 1–3 aren't changed by this — they got a destination. Block 02 *is* the
timer with a queue on it. Block 05 *is* the SQLite.
🔴 **Blotato has platform permission; we do not.** They call official APIs. We
drive a browser logged in as abang, from one home IP. So throughput is not their
1,800 req/hr — it is a sequential queue with 3–6s gaps, one tab at a time, and
that gap *is* the disguise. The risk of a login challenge lands on abang's six
real accounts, not on a service account. This is why the design is **single-seat,
not SaaS.** Do not chase their numbers.
---
# Parallel — CDP supports it, and our client already does
Measured tonight, 6 platforms, all six returning True:
| | Wall |
|---|---|
| Sequential (with polling) | **19.0s** |
| Parallel (`asyncio.gather`) | **6.1s** |
| | **3.1×** |
Against the 97s the code takes today, parallel + poll + background is **~16×**.
CDP is built for this: one WebSocket, `flatten: True`, a `sessionId` per tab, an
`id` on every command. `cdp.py:127-143` already keeps futures in a dict keyed by
id with a single `_reader` dispatching replies. **Nothing needs rewriting.**
🔴 **The bigger finding is in the poll column: every signal read `0.0s`.** It was
present the moment `navigate()` returned. `asyncio.sleep(4)` has been waiting for
something that had already happened.
**Where parallel is allowed — abang's call, not a technical one:**
- `check_login` — **parallel, agreed.** Read-only, no interaction, no writes. Six
tabs loading at once looks like a session restore. 19s → 6s.
- Post flows — **stay sequential with the gap.** That is where the pacing actually
works, and where one mistake is outward-facing.
- `get_feed` / `get_notifications` — parallel is fine, but it is two jobs; the win
is small.
---
# The other lane — private APIs (abang's two links)
⚠️ **`aegents/twitter-no-api` is a fork of `d60/twikit`**, created Dec 2024 and
untouched since; its README is twikit's, badges and all. Take the upstream:
**`d60/twikit` — 4,667★, pushed 2026-03-10, MIT, active.**
**`subzeroid/aiograpi-rest` — 640★, pushed 2026-05-21, MIT.** REST boundary over
`aiograpi` (the maintained async fork of `instagrapi`) = Instagram's **private
mobile API**. Full write surface: post, DM, story. First-class
`challenge_required` and 2FA handling.
These are a **different architecture**, not a socialmag upgrade:
| | socialmag | twikit / aiograpi |
|---|---|---|
| How | drives abang's already-logged-in Brave over CDP | logs in with username+password, speaks the app's own internal API |
| Stores a password? | **No** — the session belongs to Brave | **Yes**, required |
| Needs a browser / display? | Yes | **No** — headless, server, VPS |
| Data | scraped DOM | structured JSON, pydantic-typed |
| Breaks when | the platform redesigns its DOM | the platform moves an internal endpoint |
| Ban vector | traffic pattern | **unofficial client + login from a new IP** |
**Why this matters to the plan:** these libraries solve exactly what README's
"Running on a VPS" section says blocks us — no browser, no display, no tab, no
focus. Block 03 (Comments & DMs), which is expensive through the DOM, gets cheap
through `aiograpi`, because IG DMs have a real endpoint.
🔴 **The price, stated once: both require storing the real account passwords and
logging in as an unofficial client.** That is the classic path to a challenge or
a lock. socialmag never holds a password — that is *why* it is slow.
**The hybrid worth discussing on the couch:** private API for read-heavy and
scheduled work (analytics polling, inbox reads) where volume is the point, browser
for outward-facing writes where being genuinely abang's session is the point. Or
the reverse. Either way it is two lanes with one queue in front, not a migration.
## 🔴 DECIDED 2026-09-12 04:06 — both repos are OUT
Abang's call: **skip `twikit` and `aiograpi` entirely.** They log in via app
requests, and the ban risk lands on his six real accounts. The house lane does
not hold a password and does not need to. Kept in this napkin as the road not
taken, not as a backlog item.
The section below is what replaced them — and it is better than either.
---
# The third lane — structured JSON, no DOM, no password
**This is the finding of the night.** Measured 2026-09-12 04:05-04:09 against
abang's live Brave. The question was "do we still need DOM" — the answer is no,
on all three platforms tested.
The principle: the page's own app already fetches structured JSON to render
itself. **Read that, instead of reading the pixels it painted.** No credentials,
no unofficial client, no new IP — the traffic is genuinely abang's, we only
listen.
## One principle, two mechanisms
| Platform | Mechanism | Result |
|---|---|---|
| X | network capture — `/i/api/graphql/…/HomeLatestTimeline` | **190 tweets in 0.9s** |
| Threads | network capture — `/graphql/query` → `feedData` | 4 posts, 22.9KB body |
| IG | **in-document** — `RelayPrefetchedStreamCache` → `xdt_api__v1__feed__timeline` | 3 posts from a 150KB inline `<script>` |
X and Threads must be *heard*. IG must be *read* — Meta prefetches the feed into
the document, so nothing crosses the wire to capture.
## X — the same source, both ways
| | DOM scrape | Network capture |
|---|---|---|
| Tweets | 10 | **190** |
| Wall | 11.1s | **0.9s** |
| Author | `"Thariq @trq212 · 31m"` (display string) | `trq212` (real `screen_name`) |
| Counts | strings parsed out of the DOM | ints — `L=427 RT=19 RE=45` |
| Views | usually absent | **`V=17912`** |
| Posted at | `<time datetime>` attribute | full `created_at` |
IG returns the same quality: `@raybanmeta ts=1788538432 L=76919 C=945`. Exact
integers, not `"76.9K"` recovered from a rendered string.
## How it works
```
Network.enable on the tab session
→ listen for Network.responseReceived, filter the URL
→ wait for Network.loadingFinished for that requestId
→ Network.getResponseBody(requestId)
```
🔴 **`responseReceived` fires before the body has arrived.** The first probe read
`encodedDataLength` at that moment and reported 149-byte GraphQL responses for
Instagram. Waiting for `loadingFinished` turned the same Threads request into
**22,697 characters**. Sizing a body at `responseReceived` is reading a letter
while it is still in the post.
## The three real limits
1. **Capture must be live.** `getResponseBody` reads the renderer's buffer. Once
the tab navigates away the body is gone — there is no asking later. The
listener has to be armed *before* the request fires.
2. **We only get what the page asks for.** Want DMs? The page has to go to DMs.
Navigation is still our job; this replaces scraping, not driving.
3. **Writes are still unanswered.** Reading is solved. Posting either stays on
the DOM, or we capture the auth headers off one intercepted request and replay
`fetch()` from inside the page — no password, no DOM. **Untested. It does not
work until it has been fired.**
## Not proven — do not repeat these as facts
- ⚠️ **IG's 3 posts were all Meta-owned accounts** (`raybanmeta`, `whatsapp`,
`metaglasses`). That is the prefetched/suggested set, **not abang's following
feed**. His real feed most likely arrives in a later fetch — not captured yet.
- ⚠️ **Threads returned 4 posts against X's 190.** That is page one only; the rest
comes from scroll-triggered fetches. The 190 is X being generous in one
response, not a fair per-platform comparison.
- TikTok and YouTube have not been tried on this lane at all.
## What this does to the plan
- **Step 1 (background pass)** — unchanged and still first. Nothing here works on
a timer while it steals focus.
- **Step 2 (SQLite)** — gets *better*. Exact integers and real epochs arrive for
free, so analytics stops being a parsing problem. `first_seen` still solves
Threads' missing clock for notifications.
- **Block 05 (analytics)** — close to free now. Views and counts ride along.
- **Block 03 (comments & DMs)** — possibly cheap without `aiograpi` after all,
since the inbox pages fetch their own JSON. Worth one probe before assuming.
## Open, added tonight
- Which lane owns **writes**? DOM (proven, slow) or replayed `fetch()` with
captured headers (fast, unproven, and the one that could post something wrong
outward-facing)?
- Can IG's **real** following feed be captured, or is the prefetch all a logged-in
web session gets without scrolling?
- Does the capture lane reach **DMs and comments**? One probe answers whether
block 03 is cheap or expensive.
- TikTok and YouTube on this lane — untested.
- Where does capture live? A listener armed per call, or one long-lived recorder
that watches abang's normal browsing and fills the DB as a side effect?