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

21 KiB
Raw Blame History

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_logins, 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?