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>
21 KiB
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_loginandyoutube.check_loginwere lying OUT on a signed-in account. Both were DOM-signal checks. Swapped to JS state (c_usercookie,ytcfg.get('LOGGED_IN')). 4-in/2-out → 6-in/0-out.refresher.fetch_content()added —notificationsandfeednow 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, all20260912-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 |
|---|---|---|---|
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: fivecheck_logins,get_notifications,get_feed. TikTok staysTrue. - Fixed sleeps →
wait_forin 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
11haged to1d. Nothing knows they are the same. - 🔴 The derived
tsdecays. A row sinks an hour every hour.first_seenis 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
sleepis not free. Removing X'ssleep(4)madecheck_loginreport OUT on a signed-in account once five tabs navigated at once — the DOM signal had not painted. Fixed with thetwidcookie, not by restoring the sleep. - 🔴
%zgoes intostruct_timeand cannot come back out._x_time_to_isoemitted2026-09-11T19:41:10with no offset; JSDate.parsereads a naked datetime as local, shifting every X row by the machine's offset, silently. Usedatetime.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. - 🔴
tsmust 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_atfalls back tofirst_seen; those rows read "seen X ago" now. - ⚠️
bravemagcapture.screenshotfails with an empty error string — the old stdout/stderr trap. DOM probes (page.evaluatefor bounding boxes and counts) proved the layout instead, and are better evidence anyway.
Open, abang's call
postshas no writer. Only a realcreate_postcan produce it, so the "posted" rows in the timeline stay hand-written until something records post results. Doescreate_postwrite to the DB in step 2?- Five platforms have no
get_notificationsat all —server.py:397is{"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 leaveactivate()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
:8321and: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 whyfetch_contentkeeps old rows and records_refreshed.contentErrorsinstead of writing[]. - ⚠️
SOCIALMAG_CDP_PORTis read by nothing./root/.claude.jsonsets it. OnlySOCIALMAG_CDP_ENDPOINTis consulted (server.py:65). - ⚠️ Run as
python3 -m socialmag, neversocialmag/server.py—selectors.pyandqueue.pyshadow 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
- Capture must be live.
getResponseBodyreads 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. - 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.
- 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_seenstill 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
aiograpiafter 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?