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>
This commit is contained in:
2026-09-13 01:58:45 +08:00
co-authored by Claude Opus 5
parent 22e63ebf35
commit 15f3388e5e
7 changed files with 883 additions and 103 deletions
+429
View File
@@ -0,0 +1,429 @@
# 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?
+48 -51
View File
@@ -330,25 +330,6 @@
.rail { display: flex; flex-direction: column; gap: 26px; }
.tweet {
padding: 11px 0;
border-bottom: 1px solid var(--line-soft);
}
.tweet:last-child { border-bottom: 0; }
.tweet .who {
font-size: 12.5px;
font-weight: 500;
color: var(--text);
overflow-wrap: anywhere;
}
.tweet .text {
font-size: 12.5px;
color: var(--text-dim);
margin-top: 3px;
overflow-wrap: anywhere;
}
.tweet .engagement { margin-top: 6px; }
/* ── empty / notice ────────────────────────────────────────────────── */
.empty {
@@ -397,17 +378,6 @@
background: var(--surface-2);
border-color: var(--line);
}
a.cardlink:hover .tweet,
a.cardlink:focus-visible .tweet {
background: var(--surface-2);
}
a.cardlink .tweet {
padding-left: 8px;
padding-right: 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: var(--r-sm);
}
footer.foot {
margin-top: 34px;
@@ -460,21 +430,21 @@
<main>
<section>
<div class="shead">
<h2>Timeline</h2>
<span class="tool">create_post · get_notifications</span>
<span class="count" id="tl-count"></span>
<h2>Feed</h2>
<span class="tool">capture</span>
<span class="count" id="feed-count"></span>
</div>
<div class="timeline" id="timeline"></div>
<div id="feed"></div>
</section>
<aside class="rail">
<section>
<div class="shead">
<h2>Twitter intel</h2>
<span class="tool">get_feed</span>
<span class="count" id="feed-count"></span>
<h2>Timeline</h2>
<span class="tool">create_post · get_notifications</span>
<span class="count" id="tl-count"></span>
</div>
<div id="feed"></div>
<div class="timeline" id="timeline"></div>
</section>
</aside>
</main>
@@ -600,6 +570,14 @@
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Some rows carry no platform clock — a Threads payload that arrived without
// taken_at, a notification whose only marker was "1d". The store fills `ts`
// with first_seen and flags it. Saying "just now" for a post of unknown age
// is a small lie that sorts it to the top, so say what the stamp actually is.
const stamp = row => row._tsIsFirstSeen
? "seen " + ago(parseTime(row))
: ago(parseTime(row));
// Engagement counts arrive as scraped strings ("1.2K") or numbers.
const countNum = v => {
if (typeof v === "number") return v;
@@ -669,6 +647,7 @@
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
text: d.text || "",
t: parseTime(d),
tsIsFirstSeen: !!d._tsIsFirstSeen,
url: d.url || d.link || null,
stats: {}
});
@@ -714,7 +693,7 @@
<div class="meta">
<span class="who">${esc(r.who)}</span>
<span class="kind ${r.kind === "activity" ? "" : r.kind}">${KIND_LABEL[r.kind]}</span>
<span class="ts">${esc(ago(r.t))}</span>
<span class="ts">${esc(r.tsIsFirstSeen ? "seen " + ago(r.t) : ago(r.t))}</span>
</div>
<div class="text${r.kind === "activity" ? " quiet" : ""}">${esc(r.text) || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
@@ -724,7 +703,7 @@
}).join("");
}
// ── twitter intel ───────────────────────────────────────────────────
// ── feed — every platform capture writes here ───────────────────────
function renderFeed() {
const rows = state.feed.slice().sort((a, b) => (parseTime(b) ?? -Infinity) - (parseTime(a) ?? -Infinity));
@@ -732,22 +711,40 @@
if (!rows.length) {
$("feed").innerHTML = `<div class="empty">
No tweets pulled yet. <code>get_feed</code> writes scraped tweets
(author, text, time, likes, retweets, replies) to <code>feed</code>.
Nothing captured yet. <code>capture</code> reads the platform's own JSON
— the app's GraphQL responses on X and Threads, the prefetched payload
inside the Instagram document — and writes it to <code>feed</code>.
</div>`;
return;
}
$("feed").innerHTML = rows.map(t => linkWrap(t.url, `<div class="tweet">
<div class="who">${esc(t.author || "unknown")}</div>
<div class="text">${esc(t.text || "")}</div>
<div class="meta engagement">
<span><b>${esc(showCount(t.likes))}</b> likes</span>
<span><b>${esc(showCount(t.retweets ?? t.reposts))}</b> RT</span>
<span><b>${esc(showCount(t.replies))}</b> replies</span>
<span class="ts">${esc(ago(parseTime(t)))}</span>
// Rows carry their own platform now, so a mixed feed still says which is
// which. A row from a platform the page does not know renders with a
// neutral glyph rather than being dropped.
$("feed").innerHTML = rows.map(t => {
const pid = norm(t.platform) || "twitter";
const p = BY_ID[pid];
const stripe = p ? p.color : "var(--line)";
const glyph = p
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
return linkWrap(t.url, `<article class="card" style="--stripe:${stripe}">
${glyph}
<div class="body">
<div class="meta">
<span class="who">${esc(t.author || "unknown")}</span>
<span class="ts">${esc(stamp(t))}</span>
</div>
</div>`, "twitter")).join("");
<div class="text">${esc(t.text || "") || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${engagementHTML({
views: t.views,
likes: t.likes,
replies: t.replies,
reposts: t.reposts ?? t.retweets
})}
</div>
</article>`, pid);
}).join("");
}
// ── stats ───────────────────────────────────────────────────────────
+335 -51
View File
@@ -1,18 +1,35 @@
{
"_note": "Local-mode data for dashboard.html. Same shape as the artifact db collections. Written 2026-09-11 ~10:05 from live socialmag probes: check_login, get_status, get_feed, get_notifications.",
"_yt_caveat": "yt is really LOGGED IN (ytcfg.get('LOGGED_IN') === true, verified live). socialmag check_login reports false because it tests for '#avatar-btn', which never renders in a backgrounded tab — YouTube leaves the masthead in skeleton state. Recorded here as the measured truth, not as check_login's answer.",
"platforms": {
"threads": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
"fb": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
"ig": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
"yt": { "loggedIn": true, "checkedAt": "2026-09-11T10:05:00+08:00" },
"tiktok": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
"twitter": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" }
"fb": {
"checkedAt": "2026-09-13T01:53:10+0800",
"loggedIn": true
},
"ig": {
"checkedAt": "2026-09-13T01:53:10+0800",
"loggedIn": true
},
"threads": {
"checkedAt": "2026-09-13T01:53:08+0800",
"loggedIn": true
},
"tiktok": {
"checkedAt": "2026-09-12T04:18:44+0800",
"loggedIn": true
},
"twitter": {
"checkedAt": "2026-09-13T01:53:09+0800",
"loggedIn": true
},
"yt": {
"checkedAt": "2026-09-13T01:53:09+0800",
"loggedIn": true
}
},
"posts": [
{
"platform": "twitter",
"text": "midnight oil burns brightest ✨ first automated post from the house 🏠",
"text": "midnight oil burns brightest \u2728 first automated post from the house \ud83c\udfe0",
"status": "ok",
"detail": "twitter: posted",
"ts": "2026-09-11T00:00:58+08:00",
@@ -22,69 +39,336 @@
"notifications": [
{
"platform": "threads",
"text": "Your thread got over 50 views. 11h late night coding sessions with good music is peak vibes ngl 🎧✨",
"author": null,
"text": "Your thread got over 50 views. 2d late night coding sessions with good music is peak vibes ngl \ud83c\udfa7\u2728",
"url": "https://www.threads.com/@beethovenkassim9917/post/DdG2Sz1FOZp",
"ts": "2026-09-10T23:00:00+08:00",
"_ts_note": "stamped here from the row's relative marker (11h) — get_notifications returns no timestamp"
"firstSeen": "2026-09-12T04:17:47+0800",
"timesSeen": 19,
"ts": "2026-09-11T04:17:25+0800"
},
{
"platform": "threads",
"text": "You created a new Threads profile in your Meta Account. Review how to log in. 14h",
"author": null,
"text": "You created a new Threads profile in your Meta Account. Review how to log in. 2d",
"url": "https://accountscenter.threads.com/settings-updates?settings_notif_id=2060336524603315&num_profiles=1",
"ts": "2026-09-10T20:00:00+08:00"
"firstSeen": "2026-09-12T04:17:47+0800",
"timesSeen": 19,
"ts": "2026-09-11T04:17:25+0800"
},
{
"platform": "threads",
"text": "threads 14h Your profile is public. You can manage your privacy in settings anytime.",
"author": null,
"text": "threads 2d Your profile is public. You can manage your privacy in settings anytime.",
"url": "https://www.threads.com/settings/privacy",
"ts": "2026-09-10T20:00:00+08:00"
"firstSeen": "2026-09-12T04:17:47+0800",
"timesSeen": 19,
"ts": "2026-09-11T04:17:25+0800"
}
],
"feed": [
{
"author": "Boris Cherny @bcherny",
"text": "Every day, I get a lot of of emails and messages like this one. I try to respond to as many as I can.\n\nSharing my response below, for anyone else in a similar situation. What do you think?",
"time": "2026-09-11T01:10:26.000Z",
"url": "https://x.com/bcherny/status/2098217571153838124",
"likes": "384",
"retweets": "14",
"replies": "40"
"platform": "threads",
"author": "shessoprecious_",
"text": "Embrace your natural gifts and talents.",
"url": "https://www.threads.com/@shessoprecious_/post/DdMbHtyIAAR",
"firstSeen": "2026-09-13T01:23:43+0800",
"timesSeen": 1,
"ts": "2026-09-13T00:33:00+0800",
"likes": 8,
"replies": 0
},
{
"author": "Boris Cherny @bcherny",
"text": "Hey ████,\n\nI think there is room for both.\n\n1. Prototypes and other throw-away code can be treated as totally black box. If you're going to throw it away anyway, and if the blast radius of it breaking is low, it doesn't need to be perfect.\n2. Production code written by Claude",
"time": "2026-09-11T01:10:27.000Z",
"url": "https://x.com/bcherny/status/2098217573276131577",
"likes": "260",
"retweets": "13",
"replies": "22"
"platform": "ig",
"author": "faizanarran",
"text": "",
"url": "https://www.instagram.com/p/DdMYdDxt5X4/",
"firstSeen": "2026-09-13T01:23:43+0800",
"timesSeen": 1,
"ts": "2026-09-13T00:11:03+0800",
"likes": 182,
"replies": 2
},
{
"author": "Elon Musk @elonmusk",
"text": "Grok @Bot summary of SpaceX CFO talk",
"time": "2026-09-11T01:09:06.000Z",
"url": "https://x.com/elonmusk/status/2098217236771295567",
"likes": "1.3K",
"retweets": "269",
"replies": "352"
"platform": "threads",
"author": "moneygatha",
"text": "Being \"relatable\" is a marketing tactic; being \"authentic\" is a lifestyle.",
"url": "https://www.threads.com/@moneygatha/post/DdMXWBOClua",
"firstSeen": "2026-09-13T00:23:39+0800",
"timesSeen": 1,
"ts": "2026-09-13T00:00:00+0800",
"likes": 3,
"replies": 0
},
{
"author": "Boris Cherny @bcherny",
"text": "wait what",
"time": "2026-09-11T00:39:52.000Z",
"url": "https://x.com/bcherny/status/2098209880310264048",
"likes": "282",
"retweets": "15",
"replies": "19"
"platform": "threads",
"author": "lessbusyrifqi",
"text": "A creator doesn't need a perfect system. \r\nThey need one they actually use.",
"url": "https://www.threads.com/@lessbusyrifqi/post/DdMQem-nUIK",
"firstSeen": "2026-09-12T23:23:41+0800",
"timesSeen": 2,
"ts": "2026-09-12T23:00:08+0800",
"likes": 2,
"replies": 0
},
{
"author": "Kevin O'Connor @KevinOConnor",
"text": "New pod with @SawyerMerritt diving into the Tesla Cybercab: What we loved, what can improve, our FSD experiences, and what's next for @robotaxi.\n\nWatch the full conversation here on X or listen to The Kevin O'Connor Show on your favorite podcast app.",
"time": "2026-09-10T20:37:00.000Z",
"url": "https://x.com/KevinOConnor/status/2098148756902355336",
"likes": "735",
"retweets": "101",
"replies": "74"
"platform": "threads",
"author": "heyellemills",
"text": "Your new life requires a new standard.",
"url": "https://www.threads.com/@heyellemills/post/DdMPyv0FfN7",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 1,
"ts": "2026-09-12T22:54:04+0800",
"likes": 0,
"replies": 0
},
{
"platform": "threads",
"author": "hoshibond",
"text": "Distribution can make or break a product",
"url": "https://www.threads.com/@hoshibond/post/DdMOmzbk2dJ",
"firstSeen": "2026-09-12T23:23:41+0800",
"timesSeen": 1,
"ts": "2026-09-12T22:43:39+0800",
"likes": 1,
"replies": 0
},
{
"platform": "ig",
"author": "morph.studies",
"text": "Motivation gets you started, discipline makes you unstoppable",
"url": "https://www.instagram.com/p/DdMLsbTSf7U/",
"firstSeen": "2026-09-13T00:23:39+0800",
"timesSeen": 2,
"ts": "2026-09-12T22:18:32+0800",
"likes": 3,
"replies": 0
},
{
"platform": "threads",
"author": "jomhackmy",
"text": "Building community is about creating opportunities, not just making useless noise \ud83e\udd1e\ud83c\udffc",
"url": "https://www.threads.com/@jomhackmy/post/DdMJ09iGqrp",
"firstSeen": "2026-09-13T01:53:34+0800",
"timesSeen": 1,
"ts": "2026-09-12T22:01:53+0800",
"likes": 5,
"replies": 2
},
{
"platform": "ig",
"author": "hillshots_",
"text": "The life i wanna live.\ud83d\udd4a\ud83c\udf43",
"url": "https://www.instagram.com/p/DdMI0ZaiIPs/",
"firstSeen": "2026-09-13T01:23:43+0800",
"timesSeen": 1,
"ts": "2026-09-12T21:54:09+0800",
"likes": 845,
"replies": 23
},
{
"platform": "threads",
"author": "aqmlqq",
"text": "Hi. Blh tlg suggestkan AI tools to self learning Python from scratch? Thankyou",
"url": "https://www.threads.com/@aqmlqq/post/DdMIjOjlPzG",
"firstSeen": "2026-09-13T00:23:39+0800",
"timesSeen": 3,
"ts": "2026-09-12T21:50:44+0800",
"likes": 9,
"replies": 7
},
{
"platform": "ig",
"author": "hesahera",
"text": "Winter and tangerine \ud83c\udf4a",
"url": "https://www.instagram.com/p/DdMHJhSodGg/",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T21:39:16+0800",
"likes": 2255,
"replies": 7
},
{
"platform": "threads",
"author": "getaipaycheck",
"text": "I've used AI to build apps I could never have shipped alone. An idea plus persistence gets you a working product now.",
"url": "https://www.threads.com/@getaipaycheck/post/DdMFynTDGWt",
"firstSeen": "2026-09-12T22:53:36+0800",
"timesSeen": 1,
"ts": "2026-09-12T21:27:40+0800",
"likes": 0,
"replies": 1
},
{
"platform": "threads",
"author": "buildwithmahinn",
"text": "Hey app founders, how do you stay consistent updating about your app?\n\nI swear I just blinked and 10 days went by lol.\n\nWould love to hear what works for you - and drop what you're building below \u2764\ufe0f \nLet\u2019s connect \ud83e\udd1d",
"url": "https://www.threads.com/@buildwithmahinn/post/DdMEhA2k2jD",
"firstSeen": "2026-09-13T01:53:34+0800",
"timesSeen": 1,
"ts": "2026-09-12T21:15:28+0800",
"likes": 6,
"replies": 10
},
{
"platform": "threads",
"author": "chrisldyson",
"text": "Some creators are so obsessed with being useful\u2026\n\nthey accidentally make themselves unnecessary.",
"url": "https://www.threads.com/@chrisldyson/post/DdMCvsylZlf",
"firstSeen": "2026-09-12T22:23:33+0800",
"timesSeen": 1,
"ts": "2026-09-12T21:00:02+0800",
"likes": 1,
"replies": 0
},
{
"platform": "ig",
"author": "meitime__",
"text": "A trip through the Kiso Valley \ud83c\udf3f\ud83d\ude83\nAvailable now on the Mei Time YouTube channel.\n\n\ud83d\udccdNarai-juku, Tsumago-juku\u00a0\n\n#nakasendo #naraijuku #tsumagojuku",
"url": "https://www.instagram.com/p/DdL2xPNphw3/",
"firstSeen": "2026-09-12T21:23:32+0800",
"timesSeen": 1,
"ts": "2026-09-12T19:20:35+0800",
"likes": 580,
"replies": 9
},
{
"platform": "ig",
"author": "faizanarran",
"text": "",
"url": "https://www.instagram.com/p/DdLvhIwRk43/",
"firstSeen": "2026-09-12T20:23:30+0800",
"timesSeen": 1,
"ts": "2026-09-12T18:13:09+0800",
"likes": 293,
"replies": 2
},
{
"platform": "twitter",
"author": "elonmusk",
"text": "RT @poteto: Next week, @mattyp @roshan_s and myself will be speedrunning building a new company using only Grok @Bot on the Grok Bot Galaxy\u2026",
"url": "https://x.com/elonmusk/status/2098821446009081945",
"firstSeen": "2026-09-13T01:23:43+0800",
"timesSeen": 2,
"ts": "2026-09-12T17:10:01+00:00",
"likes": 0,
"replies": 0,
"reposts": 125,
"views": 441455
},
{
"platform": "ig",
"author": "faizanarran",
"text": "",
"url": "https://www.instagram.com/p/DdLmdouRPHk/",
"firstSeen": "2026-09-12T18:23:46+0800",
"timesSeen": 1,
"ts": "2026-09-12T16:53:28+0800",
"likes": 419,
"replies": 2
},
{
"platform": "ig",
"author": "arcewane",
"text": "Selling with Arcewane!\n\n#arabic #villa #luxurylifestyle #mansion #luxuryhomes",
"url": "https://www.instagram.com/p/DdLmI87xAIG/",
"firstSeen": "2026-09-12T22:23:33+0800",
"timesSeen": 1,
"ts": "2026-09-12T16:50:04+0800",
"likes": 402,
"replies": 2
},
{
"platform": "twitter",
"author": "poteto",
"text": "Next week, @mattyp @roshan_s and myself will be speedrunning building a new company using only Grok @Bot on the Grok Bot Galaxy livestream! We\u2019ve been given just 3 days to do it, or else we might be turned into pizza \ud83e\udd2a it will be so fun and I hope to see you on the livestream!",
"url": "https://x.com/poteto/status/2098815981787414978",
"firstSeen": "2026-09-13T01:23:43+0800",
"timesSeen": 2,
"ts": "2026-09-12T16:48:18+00:00",
"likes": 1273,
"replies": 141,
"reposts": 125,
"views": 149016
},
{
"platform": "twitter",
"author": "ylecun",
"text": "RT @stevesi: A person with mid single digit years of industry experience and months at one company and negligible external footprint quits\u2026",
"url": "https://x.com/ylecun/status/2098815744590922105",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T16:47:22+00:00",
"likes": 0,
"replies": 0,
"reposts": 179,
"views": 3010
},
{
"platform": "twitter",
"author": "ylecun",
"text": "RT @Dan_Jeffries1: If we're doomed to get the Oversight Committee for Pacing Economic Innovation and Development, we need to make sure it's\u2026",
"url": "https://x.com/ylecun/status/2098815587946319892",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T16:46:44+00:00",
"likes": 0,
"replies": 0,
"reposts": 12,
"views": 2507
},
{
"platform": "twitter",
"author": "ylecun",
"text": "RT @kevinnbass: The Coxon psyop was a decade and more than a billion dollars in the making.\n\nHere's the story.\n\nCoefficient Giving (formerl\u2026",
"url": "https://x.com/ylecun/status/2098815511127560248",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T16:46:26+00:00",
"likes": 0,
"replies": 0,
"reposts": 840,
"views": 2417
},
{
"platform": "twitter",
"author": "ylecun",
"text": "RT @PessimistsArc: It\u2019s never been more important and urgent to study and understand the history of technology panics, the power dynamics a\u2026",
"url": "https://x.com/ylecun/status/2098814655745359894",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T16:43:02+00:00",
"likes": 0,
"replies": 0,
"reposts": 15,
"views": 2777
},
{
"platform": "twitter",
"author": "ylecun",
"text": "RT @RichardSSutton: There are a lot of things wrong with this world\u2026\n\nbut too much intelligence is not one of them.",
"url": "https://x.com/ylecun/status/2098814617673761230",
"firstSeen": "2026-09-13T00:53:35+0800",
"timesSeen": 3,
"ts": "2026-09-12T16:42:53+00:00",
"likes": 0,
"replies": 0,
"reposts": 232,
"views": 2454
}
],
"_refreshed": {
"at": "2026-09-13T01:53:34+0800",
"by": "socialmag.refresher",
"cdp": "http://127.0.0.1:9336",
"browserReachable": true,
"store": {
"checks": 248,
"items": 431,
"metrics": 8386,
"kinds": {
"feed": 428,
"notification": 3
}
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=socialmag dashboard file server (:8321)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/mnt/playground/Playground_V2
# No --bind, matching how this was started by hand: the page is reachable from
# abang's LAN, not only from this box. openbridge stays loopback-only, so a LAN
# viewer just falls back to opening a new tab — progressive enhancement, and the
# page is never worse than it was.
ExecStart=/usr/bin/python3 -m http.server 8321
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=socialmag openbridge (:8322) — open_link over localhost HTTP
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/mnt/playground/Playground_V2
Environment=PYTHONPATH=/mnt/playground/Playground_V2
Environment=SOCIALMAG_CDP_ENDPOINT=http://127.0.0.1:9336
ExecStart=/usr/bin/python3 -m socialmag.openbridge
# openbridge refuses to move to another port if :8322 is taken — no silent
# fallback — so a clash fails loudly here instead of listening somewhere nobody
# is looking.
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=socialmag refresh — login states, Threads activity, X timeline capture
# Brave is not a service on this box. There is nothing to order against, so a
# pass that finds no CDP records the third state and exits 0.
[Service]
Type=oneshot
User=root
WorkingDirectory=/mnt/playground/Playground_V2
Environment=PYTHONPATH=/mnt/playground/Playground_V2
Environment=SOCIALMAG_CDP_ENDPOINT=http://127.0.0.1:9336
# --skip-foreground is the whole reason this can run on a desktop abang is using.
# Every other platform reads a JS-state signal in a hidden tab; tiktok still
# activates, so it is left out and keeps its last stored result with its own
# older checkedAt. Stale is honest. Drop the flag for a full pass by hand.
ExecStart=/usr/bin/python3 -m socialmag.refresher --skip-foreground
# No Restart=: a down browser is a waiting state the refresher records and exits
# 0 for. The timer brings the next attempt, not a crash loop.
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=socialmag refresh every 30 minutes
[Timer]
# 2 min after boot so Brave has a chance to be up; a pass that finds no browser
# still records the unknown state rather than skipping.
OnBootSec=2min
OnUnitActiveSec=30min
# A pass takes ~28s with --skip-foreground. 30min is the desktop number, not
# kdmirah's 10min: this box has a human at it, and the metrics table only needs
# enough resolution to watch engagement move, not to watch it live.
Persistent=true
[Install]
WantedBy=timers.target