Files
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

286 lines
11 KiB
Python

#!/usr/bin/env python3
"""socialmag.refresher — one pass of check_login for every platform, merged
into dashboard.json.
Run as a one-shot; cadence belongs to a systemd .timer, not to a sleep loop.
python3 -m socialmag.refresher
Three states per platform, never two:
{"loggedIn": true, "checkedAt": ...} signed in
{"loggedIn": false, "checkedAt": ...} signed out
{"error": "...", "checkedAt": ...} could not check
"Brave is not up" is the third state, not the second. Collapsing them says an
account died when the browser simply was not running. dashboard.html already
reads all three (`loginState()` — a row with no boolean `loggedIn` and an
`error` renders as unknown).
Owned here: `platforms`, and — unless `--no-content` is passed — `notifications`
(Threads Activity) and `feed` (the X timeline, via `capture`). `posts` is left
exactly as found, because nothing but a real `create_post` can produce it.
Flags:
--no-content login states only, no feed or activity read
--skip-foreground leave out platforms that still activate a tab, so a
timed pass never pulls focus (today: tiktok)
Threads returns no timestamp, only a relative marker in the row text ("11h").
Those rows get a `ts` derived from that marker against fetch time, flagged
`"_tsDerived": true`. A row with no marker gets no `ts` at all rather than a
guessed one — and `store` writes `ts` once and never rewrites it, so the
derivation cannot decay across passes.
This activates each tab it checks, which pulls the browser's focus — the same
cost every socialmag call pays (see README, "Tab reuse and focus"). On a
dedicated box nobody is watching that display; do not put this on a timer on a
desktop someone is using.
"""
import asyncio
import json
import os
import re
import sys
import time
from .cdp import CDPClient
from .queue import PostQueue
from . import capture, store
from .platforms import threads, twitter # noqa: F401 (twitter kept for the post lane)
from .server import CDP_ENDPOINT, MODULES, STATE_PATH # noqa: F401 (STATE_PATH unused, kept explicit)
DASHBOARD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dashboard.json")
# Threads writes age, not time: "11h", "2d", "45m". This is the only clock the
# Activity page gives us.
REL_MARKER = re.compile(r"\b(\d+)\s*([smhdw])\b")
REL_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
def derive_ts(text, fetched_epoch):
"""ISO stamp implied by a relative marker, or None if the row has none."""
match = REL_MARKER.search(text or "")
if not match:
return None
seconds = int(match.group(1)) * REL_SECONDS[match.group(2)]
return time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(fetched_epoch - seconds))
def log(*args):
print(f"[refresher {time.strftime('%H:%M:%S')}]", *args, file=sys.stderr, flush=True)
def now():
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
def load_dashboard():
"""Existing dashboard.json, or a fresh skeleton if there is none.
A file that exists but does not parse is an error, not an excuse to start
over — overwriting it would destroy posts/notifications/feed that nothing
else holds.
"""
if not os.path.exists(DASHBOARD_PATH):
log(f"no {DASHBOARD_PATH} yet — creating one")
return {"platforms": {}, "posts": [], "notifications": [], "feed": []}
with open(DASHBOARD_PATH) as fh:
return json.load(fh)
def write_dashboard(data):
"""Atomic: a poller reading every 10s must never see half a file."""
tmp = DASHBOARD_PATH + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
os.replace(tmp, DASHBOARD_PATH)
#: TikTok is the one platform whose check still activates its tab, so it cannot
#: run beside the others without yanking focus mid-pass. Everything else reads a
#: JS-state or background-safe signal and goes in one `gather`.
FOREGROUND_ONLY = {"tiktok"}
async def check_all(skip_foreground=False):
"""{platform: row} for every platform. Raises only if CDP itself is gone —
the caller turns that into the third state for all six at once.
Background-safe checks run **in parallel**: CDP multiplexes one WebSocket
with a sessionId per tab, and `cdp.py` already keys its pending futures by
message id, so concurrent calls need nothing new. Measured 2026-09-12:
19.0s sequential → 6.1s parallel across six platforms.
The queue's 3-6s inter-platform gap is deliberately **not** applied here.
That pacing exists for the post flows, which write; these only read, and six
tabs restoring at once is an ordinary browser thing to do. Posting stays
sequential and paced — see PostQueue.
`skip_foreground=True` leaves out the platforms that still activate a tab,
so a timed pass never pulls focus. Those platforms are simply not checked
this pass; `store.latest_checks` keeps returning their last real result with
its own older `checkedAt`. Stale is honest. A fabricated row is not.
"""
rows = {}
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
log(f"connected to {cdp.browser_version} at {CDP_ENDPOINT}")
async def check(name):
try:
ok = bool(await MODULES[name].check_login(cdp))
log(f"{name}: {'in' if ok else 'OUT'}")
return name, {"loggedIn": ok, "checkedAt": now()}
except Exception as exc: # noqa: BLE001 — a failed check is not a False
log(f"{name}: ERROR {type(exc).__name__}: {exc}")
return name, {"error": f"{type(exc).__name__}: {exc}", "checkedAt": now()}
try:
parallel = sorted(set(MODULES) - FOREGROUND_ONLY)
for name, row in await asyncio.gather(*(check(n) for n in parallel)):
rows[name] = row
foreground = sorted(set(MODULES) & FOREGROUND_ONLY)
if skip_foreground:
log(f"skipping (needs foreground): {', '.join(foreground)}")
else:
for name in foreground:
_, rows[name] = await check(name)
finally:
await cdp.close()
return rows
async def fetch_content(count=10):
"""Fresh Threads activity and X timeline.
The X timeline comes from `capture` — the platform's own GraphQL JSON, read
off the network. That is 184 rows in about a second against the DOM lane's
10 in eleven, with exact integer counts and view numbers the DOM never
exposes. Threads activity is still a DOM scrape: capture covers the Threads
*feed*, and the Activity page is a different surface nobody has probed.
Returns (rows_by_key, errors). A key missing from rows_by_key means that
read failed — the caller keeps whatever was in dashboard.json rather than
replacing real data with an empty list, which would read as "no activity".
"""
queue = await PostQueue().start()
rows, errors = {}, {}
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
try:
try:
fetched = time.time()
raw = await queue.submit(
"notif:threads", lambda: threads.get_notifications(cdp, count=count)
)
stamp = now()
out = []
for item in raw or []:
row = {
"platform": "threads",
"text": item.get("text", ""),
"url": item.get("url"),
"fetchedAt": stamp,
}
ts = derive_ts(row["text"], fetched)
if ts:
row["ts"] = ts
row["_tsDerived"] = True
out.append(row)
rows["notifications"] = out
log(f"threads activity: {len(out)} rows"
f" ({sum(1 for r in out if 'ts' not in r)} with no time marker)")
except Exception as exc: # noqa: BLE001 — a failed scrape must not erase good rows
errors["notifications"] = f"{type(exc).__name__}: {exc}"
log(f"threads activity: ERROR {errors['notifications']}")
# Every platform capture lands in one `feed`, tagged by platform. The
# feed is cross-platform, not "Twitter intel" — a row knows where it
# came from, so the page can show them together without merging away
# which is which. Everything captured goes to the store; trimming
# belongs to the view (store.VIEW_LIMIT), not to the record.
captured, capture_errors = await capture.capture_all(cdp)
if captured:
merged = []
for platform in sorted(captured):
merged.extend(captured[platform])
log(f"capture {platform}: {len(captured[platform])} rows")
rows["feed"] = merged
if capture_errors:
# Partial is not failure. Platforms that answered are kept; the ones
# that did not are named, so a missing rail is never a silent one.
errors["feed"] = "; ".join(f"{k}: {v}" for k, v in capture_errors.items())
if not captured:
log(f"capture: every platform failed — {errors['feed']}")
finally:
await queue.stop()
await cdp.close()
return rows, errors
async def main():
with_content = "--no-content" not in sys.argv
skip_foreground = "--skip-foreground" in sys.argv
data = load_dashboard()
try:
rows = await check_all(skip_foreground=skip_foreground)
reachable = True
except Exception as exc: # noqa: BLE001 — browser down is a state, not a crash
reason = f"{type(exc).__name__}: {exc}"
log(f"CDP unreachable at {CDP_ENDPOINT} — {reason}")
stamp = now()
rows = {
name: {"error": f"browser unreachable at {CDP_ENDPOINT}: {reason}",
"checkedAt": stamp}
for name in sorted(MODULES)
}
reachable = False
db = store.open_db()
store.record_checks(db, rows)
content_errors = {}
if with_content and reachable:
fresh, content_errors = await fetch_content()
for key, kind in (("feed", "feed"), ("notifications", "notification")):
if key in fresh:
added, seen_again = store.record_items(db, kind, fresh[key])
log(f"store {kind}: {added} new, {seen_again} seen again")
elif with_content:
content_errors = {"notifications": "browser unreachable", "feed": "browser unreachable"}
# The database is the record; dashboard.json is a picture of it. Anything the
# store does not own (posts, _note) is carried through from the old file.
data = store.snapshot(db, previous=data)
data["_refreshed"] = {
"at": now(),
"by": "socialmag.refresher",
"cdp": CDP_ENDPOINT,
"browserReachable": reachable,
"store": store.stats(db),
}
if content_errors:
data["_refreshed"]["contentErrors"] = content_errors
data.pop("_yt_caveat", None) # the activate fix made this stale; live data replaces it
write_dashboard(data)
db.close()
counts = {"in": 0, "out": 0, "unknown": 0}
for row in rows.values():
counts["in" if row.get("loggedIn") is True
else "out" if row.get("loggedIn") is False
else "unknown"] += 1
log(f"wrote {DASHBOARD_PATH} — in={counts['in']} out={counts['out']} unknown={counts['unknown']}")
# Exit 0 even when the browser is down: a waiting state is not a failure,
# and Restart=on-failure must not crash-loop before Brave comes up.
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))