Admin step A: privacy-respecting first-party event logging
- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups to one row per visitor-day — caps volume and makes counts mean distinct visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query time, never stored. - POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/ native_share/source_click); visitor token hashed server-side (never raw). - Frontend analytics.js: random localStorage visitor token; track() via sendBeacon; visit once/day; open on article click; share_ub/copy_source/native_share from the share menu; /a landing pages fire source_click. 127 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -336,6 +336,23 @@ class PrefsBody(BaseModel):
|
||||
prefs: dict = {}
|
||||
|
||||
|
||||
class EventBody(BaseModel):
|
||||
kind: str
|
||||
article_id: int | None = None
|
||||
visitor: str | None = None
|
||||
|
||||
|
||||
# The only event kinds we record (per the agreed vocabulary).
|
||||
_EVENT_KINDS = {"visit", "open", "share_ub", "copy_source", "native_share", "source_click"}
|
||||
|
||||
|
||||
def _visitor_hash(token: str | None) -> str:
|
||||
token = (token or "").strip()[:200]
|
||||
if not token:
|
||||
return ""
|
||||
return hashlib.sha256(f"{SESSION_SECRET}:{token}".encode()).hexdigest()
|
||||
|
||||
|
||||
# --- App --------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -666,6 +683,20 @@ def create_app() -> FastAPI:
|
||||
_kick_summary(aid, background_tasks) # generate for next time; page polls
|
||||
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
|
||||
|
||||
# --- Privacy-respecting first-party analytics -------------------------
|
||||
|
||||
@app.post("/api/events")
|
||||
def record_event(body: EventBody) -> dict:
|
||||
if body.kind in _EVENT_KINDS:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
|
||||
"VALUES (?, ?, ?, date('now'))",
|
||||
(body.kind, body.article_id or 0, _visitor_hash(body.visitor)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True} # always identical; dedup'd by the unique key
|
||||
|
||||
@app.get("/api/summary/{article_id}")
|
||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -205,6 +205,24 @@ CREATE TABLE IF NOT EXISTS article_summaries (
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
||||
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
||||
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
||||
-- caps volume and makes counts mean "distinct visitor-days". Groupings are derived
|
||||
-- from article_id at query time, never stored here.
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
article_id INTEGER NOT NULL DEFAULT 0,
|
||||
visitor_hash TEXT NOT NULL DEFAULT '',
|
||||
day TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (kind, article_id, visitor_hash, day)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_day ON events(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_article ON events(article_id);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,21 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
}}
|
||||
go();
|
||||
}})();
|
||||
</script>"""
|
||||
|
||||
# Fire a privacy-respecting 'source_click' when the reader heads to the source
|
||||
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
|
||||
src_click = f"""<script>
|
||||
(function(){{
|
||||
var b=document.querySelector('[data-src-click]'); if(!b) return;
|
||||
b.addEventListener('click',function(){{
|
||||
try{{
|
||||
var v=localStorage.getItem('goodnews:visitor')||'';
|
||||
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
|
||||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
|
||||
}}catch(e){{}}
|
||||
}});
|
||||
}})();
|
||||
</script>"""
|
||||
|
||||
return f"""<!doctype html>
|
||||
@@ -134,6 +149,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
{src_click}
|
||||
{poll}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
Reference in New Issue
Block a user