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>
323 lines
12 KiB
Python
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")
|