Files
socialmag/store.py
T
juxtapoandClaude Opus 5 22e63ebf35 v0.5: capture lane, sqlite store, refresher wired to both
capture.py — read the platform's own JSON instead of the pixels it painted.
Network capture on X (/i/api/graphql HomeLatestTimeline) and Threads
(/graphql/query feedData); in-document read on Instagram, whose feed is
prefetched into a RelayPrefetchedStreamCache script and never crosses the
wire. 190 X rows in 0.9s against the DOM lane's 10 in 11.1s, with exact
integer counts and view numbers the DOM never exposes. Waits for
Network.loadingFinished, not responseReceived — the body is not there yet at
responseReceived. Nothing captured raises; an empty list would say the
platform posted nothing when the truth is our listener missed it.

store.py — SQLite, stdlib only. platform_checks / items / metrics.
id = sha1(platform|kind|url or text), first_seen written once and ts under
COALESCE so a derived Threads marker cannot decay across passes.
FEED_PER_PLATFORM = 10 caps any one platform before the merge, or X's ~185
rows a pass sort Instagram's 3 off the page entirely. dashboard.json is now a
derived view of the database; dashboard.html did not have to change for it.

refresher.py — check_all runs the background-safe platforms in parallel
(19.0s sequential to 6.1s), tiktok still sequential and foreground.
--skip-foreground leaves it out of a timed pass and store.latest_checks keeps
returning its last real result with its own older checkedAt; stale is honest,
a fabricated row is not. Browser down stays the third state and exits 0.

.gitignore: socialmag.db (regenerable), deploy/kdmirah/ (carries the box's
VNC password and addresses — stays in the lab, out of git).

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

323 lines
12 KiB
Python

"""socialmag.store — the durable half. SQLite, stdlib only, one file.
Not here for size. `dashboard.json` is 4KB and would hold that forever. It is
here because **every refresh replaced `feed` and `notifications` wholesale**, so:
* there was no history — "how often does a session drop?", the exact question
README's VPS section says we need answered, was unanswerable;
* there was no concept of *new* — three Threads rows aged `11h` to `1d` and
nothing knew they were the same three rows;
* 🔴 the derived `ts` decayed. A row sank an hour every hour.
`first_seen` fixes the third one outright: it is a timestamp **we own**, exact
and fixed. Threads' missing clock stops being a problem instead of being patched.
from socialmag import store
db = store.open_db()
store.record_checks(db, rows)
store.record_items(db, "feed", captured_rows)
data = store.snapshot(db, previous) # -> the dashboard.json view
`dashboard.json` becomes a **derived view**, so `dashboard.html` does not change.
The database is the record; the JSON is a picture of it.
"""
import hashlib
import json
import os
import sqlite3
import time
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "socialmag.db")
#: How many rows of each kind the derived dashboard.json carries. The database
#: keeps everything; this is only what the page is asked to render.
VIEW_LIMIT = 25
#: Most rows one platform may contribute to the feed view. X returns ~185 fresh
#: rows a pass, Instagram 3 older ones — without a cap the "cross-platform feed"
#: is an X feed with the others sorted off the end.
FEED_PER_PLATFORM = 10
SCHEMA = """
CREATE TABLE IF NOT EXISTS platform_checks (
platform TEXT NOT NULL,
checked_at TEXT NOT NULL,
logged_in INTEGER, -- 1 / 0, NULL when the check itself failed
error TEXT, -- set only for the third state
PRIMARY KEY (platform, checked_at)
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY, -- sha1(platform|kind|url or text)
platform TEXT NOT NULL,
kind TEXT NOT NULL, -- 'feed' | 'notification' | 'post'
author TEXT,
text TEXT,
ts TEXT, -- the platform's own time, when it gives one
url TEXT,
likes INTEGER,
replies INTEGER,
reposts INTEGER,
views INTEGER,
first_seen TEXT NOT NULL, -- ours. exact, fixed, never guessed
last_seen TEXT NOT NULL,
times_seen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS items_by_kind ON items(kind, first_seen DESC);
-- One row per item per pass where a count actually moved. This is the
-- "snapshot history" a paid product sells; it costs a WHERE clause.
CREATE TABLE IF NOT EXISTS metrics (
item_id TEXT NOT NULL,
seen_at TEXT NOT NULL,
likes INTEGER,
replies INTEGER,
reposts INTEGER,
views INTEGER,
PRIMARY KEY (item_id, seen_at)
);
"""
COUNTS = ("likes", "replies", "reposts", "views")
def now():
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
def open_db(path=DB_PATH):
db = sqlite3.connect(path)
db.row_factory = sqlite3.Row
db.executescript(SCHEMA)
return db
def item_id(platform, kind, row):
"""Stable identity for a row that the platform may not have given one.
Prefer the platform's own id, then the permalink, then the text. Text is the
last resort and it is the reason Threads' three unchanging notification rows
collapse to three records instead of three per pass.
"""
seed = row.get("id") or row.get("url") or row.get("text") or ""
if not seed:
raise ValueError(f"store: {platform} {kind} row has no id, url or text: {row!r}")
digest = hashlib.sha1(f"{platform}|{kind}|{seed}".encode("utf-8"))
return digest.hexdigest()
# ---------------------------------------------------------------------------
# writes
# ---------------------------------------------------------------------------
def record_checks(db, rows):
"""One login-state row per platform per pass. Three states, all kept.
`rows` is refresher's {platform: {loggedIn|error, checkedAt}}.
"""
stamp = now()
with db:
for platform, row in rows.items():
logged_in = row.get("loggedIn")
db.execute(
"INSERT OR REPLACE INTO platform_checks"
" (platform, checked_at, logged_in, error) VALUES (?, ?, ?, ?)",
(platform,
row.get("checkedAt") or stamp,
None if logged_in is None else int(bool(logged_in)),
row.get("error")),
)
return len(rows)
def record_items(db, kind, rows):
"""Upsert rows, and append a metrics snapshot only when a count moved.
Returns (new, updated). A row already in the table keeps its `first_seen` —
that is the whole point of the table.
"""
stamp = now()
new = updated = 0
with db:
for row in rows:
platform = row.get("platform")
if not platform:
raise ValueError(f"store: {kind} row has no platform: {row!r}")
key = item_id(platform, kind, row)
counts = {c: row.get(c) for c in COUNTS}
existing = db.execute(
"SELECT likes, replies, reposts, views, times_seen FROM items WHERE id = ?",
(key,),
).fetchone()
if existing is None:
db.execute(
"INSERT INTO items (id, platform, kind, author, text, ts, url,"
" likes, replies, reposts, views, first_seen, last_seen, times_seen)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1)",
(key, platform, kind, row.get("author"), row.get("text"),
row.get("ts"), row.get("url"),
counts["likes"], counts["replies"], counts["reposts"], counts["views"],
stamp, stamp),
)
new += 1
moved = any(v is not None for v in counts.values())
else:
# 🔴 `ts` is written once and never rewritten. A Threads
# notification's time is derived from a relative marker ("1d"),
# so re-deriving it every pass moves the row forward an hour
# every hour — exactly the decay this table exists to stop. The
# first derivation was made closest to first sighting and is the
# most accurate one we will ever have.
db.execute(
"UPDATE items SET author=?, text=?, ts=COALESCE(ts, ?), url=?,"
" likes=?, replies=?, reposts=?, views=?,"
" last_seen=?, times_seen=times_seen+1 WHERE id=?",
(row.get("author"), row.get("text"), row.get("ts"), row.get("url"),
counts["likes"], counts["replies"], counts["reposts"], counts["views"],
stamp, key),
)
updated += 1
moved = any(counts[c] is not None and counts[c] != existing[c]
for c in COUNTS)
if moved:
db.execute(
"INSERT OR REPLACE INTO metrics"
" (item_id, seen_at, likes, replies, reposts, views)"
" VALUES (?,?,?,?,?,?)",
(key, stamp, counts["likes"], counts["replies"],
counts["reposts"], counts["views"]),
)
return new, updated
# ---------------------------------------------------------------------------
# reads
# ---------------------------------------------------------------------------
def latest_checks(db):
"""The newest check per platform, in refresher's own row shape."""
rows = {}
for r in db.execute(
"SELECT platform, checked_at, logged_in, error FROM platform_checks"
" WHERE (platform, checked_at) IN"
" (SELECT platform, MAX(checked_at) FROM platform_checks GROUP BY platform)"
):
row = {"checkedAt": r["checked_at"]}
if r["error"]:
row["error"] = r["error"]
else:
row["loggedIn"] = bool(r["logged_in"])
rows[r["platform"]] = row
return rows
def recent_items(db, kind, limit=VIEW_LIMIT, per_platform=None):
"""Newest first by the platform's own time, falling back to when we met it.
A row with no `ts` — every Threads notification — sorts by `first_seen`,
which is exact rather than derived from a decaying "1d" marker.
`per_platform` caps how many rows any one platform may contribute before
the merge. Without it 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 never
reaches the page at all. The cap is representation, not ranking — rows still
sort by time against each other afterwards.
"""
if per_platform:
platforms = [r["platform"] for r in db.execute(
"SELECT DISTINCT platform FROM items WHERE kind = ?", (kind,))]
rows = []
for platform in platforms:
rows.extend(db.execute(
"SELECT * FROM items WHERE kind = ? AND platform = ?"
" ORDER BY COALESCE(ts, first_seen) DESC LIMIT ?",
(kind, platform, per_platform),
).fetchall())
rows.sort(key=lambda r: r["ts"] or r["first_seen"], reverse=True)
rows = rows[:limit]
else:
rows = db.execute(
"SELECT * FROM items WHERE kind = ?"
" ORDER BY COALESCE(ts, first_seen) DESC LIMIT ?",
(kind, limit),
).fetchall()
out = []
for r in rows:
row = {
"platform": r["platform"], "author": r["author"], "text": r["text"],
"url": r["url"], "firstSeen": r["first_seen"], "timesSeen": r["times_seen"],
}
if r["ts"]:
row["ts"] = r["ts"]
else:
# No platform clock. Say which stamp this is instead of passing our
# own off as theirs.
row["ts"] = r["first_seen"]
row["_tsIsFirstSeen"] = True
for c in COUNTS:
if r[c] is not None:
row[c] = r[c]
out.append(row)
return out
def uptime(db, platform, days=7):
"""(checks, drops) for one platform — the number the VPS question needs.
A "drop" is a check that came back signed-out. Errors are not drops: a
browser that was not running says nothing about the account.
"""
since = time.strftime(
"%Y-%m-%dT%H:%M:%S%z", time.localtime(time.time() - days * 86400)
)
row = db.execute(
"SELECT COUNT(*) AS checks,"
" SUM(CASE WHEN logged_in = 0 THEN 1 ELSE 0 END) AS drops"
" FROM platform_checks WHERE platform = ? AND checked_at >= ?"
" AND error IS NULL",
(platform, since),
).fetchone()
return row["checks"], (row["drops"] or 0)
def snapshot(db, previous=None, limit=VIEW_LIMIT):
"""Build the dashboard.json view from the database.
`previous` carries forward keys the store does not own — `posts`, which only
a real create_post can produce, and `_note`.
"""
data = dict(previous or {})
data["platforms"] = latest_checks(db)
data["feed"] = recent_items(db, "feed", limit, per_platform=FEED_PER_PLATFORM)
data["notifications"] = recent_items(db, "notification", limit)
data.setdefault("posts", [])
return data
def stats(db):
"""One line per table — what is actually in there."""
return {
"checks": db.execute("SELECT COUNT(*) FROM platform_checks").fetchone()[0],
"items": db.execute("SELECT COUNT(*) FROM items").fetchone()[0],
"metrics": db.execute("SELECT COUNT(*) FROM metrics").fetchone()[0],
"kinds": {
r["kind"]: r["n"] for r in db.execute(
"SELECT kind, COUNT(*) AS n FROM items GROUP BY kind"
)
},
}
if __name__ == "__main__":
db = open_db()
print(json.dumps(stats(db), indent=2))
for platform in sorted({r["platform"] for r in db.execute(
"SELECT DISTINCT platform FROM platform_checks")}):
checks, drops = uptime(db, platform)
print(f" {platform:<8} {checks:>4} checks, {drops} signed-out")