diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 75f7c86..8f44bf9 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -3,6 +3,7 @@ // admin dashboard tell "new vs returning" in aggregate. Server hashes it. const VISITOR_KEY = 'goodnews:visitor'; const VISITDAY_KEY = 'goodnews:visitday'; +const ENGAGEDDAY_KEY = 'goodnews:engagedday'; export function visitorId() { try { @@ -63,3 +64,38 @@ export function trackVisit() { /* ignore */ } } + +// "Engaged reader" signal — what separates real readers from a JS-capable bot that +// trips a raw visit. Fire 'engaged' at most once/day, and ONLY once the page has been +// VISIBLE for ~8s AND we've seen a genuine gesture (scroll/pointer/key/touch). Captures +// nothing beyond the day-deduped visitor token. Returns a cleanup fn. (A deliberate +// action — source-click, share, game start — also counts as engagement, server-side.) +export function armEngaged() { + let fired = false, gesture = false, visibleSecs = 0; + function fire() { + if (fired || !gesture || visibleSecs < 8) return; + fired = true; + try { + const today = new Date().toISOString().slice(0, 10); + if (localStorage.getItem(ENGAGEDDAY_KEY) !== today) { + localStorage.setItem(ENGAGEDDAY_KEY, today); + track('engaged'); + } + } catch { /* ignore */ } + cleanup(); + } + const onGesture = () => { gesture = true; fire(); }; + const tick = setInterval(() => { + if (typeof document === 'undefined' || document.visibilityState === 'visible') { + visibleSecs += 1; + fire(); + } + }, 1000); + const evs = ['scroll', 'pointerdown', 'keydown', 'touchstart']; + function cleanup() { + clearInterval(tick); + if (typeof window !== 'undefined') evs.forEach((e) => window.removeEventListener(e, onGesture)); + } + if (typeof window !== 'undefined') evs.forEach((e) => window.addEventListener(e, onGesture, { passive: true })); + return cleanup; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 25382bb..ce5a5b0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import FeedbackModal from '$lib/components/FeedbackModal.svelte'; import { fb, closeFeedback } from '$lib/feedback.svelte.js'; - import { trackVisit } from '$lib/analytics.js'; + import { trackVisit, armEngaged } from '$lib/analytics.js'; let { children } = $props(); // Tell the boot-failure seatbelt (app.html) the app mounted — clears the // recovery card + timeout as soon as the shell hydrates. @@ -12,6 +12,8 @@ // Count the daily visit at the LAYOUT level so every landing page counts — // direct /play and /a/ arrivals included, not just the news homepage. trackVisit(); + // Arm the once-daily "engaged reader" signal (≈8s visible + a real gesture). + return armEngaged(); // cleanup on layout teardown }); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b429694..4007681 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -744,9 +744,10 @@

Pulse

-
{stats.visitors.today}Visitors today
-
{stats.visitors.d7}Visitors (7d)
-
{stats.visitors.d30}Visitors ({range}d)
+
{stats.visitors.engaged_today}Engaged today
+
{stats.visitors.today}Visits today (raw)
+
{stats.visitors.d7}Visits (7d)
+
{stats.visitors.d30}Visits ({range}d)
{stats.content.served}Articles live
{stats.content.accepted_7d}Fresh (7d)
{stats.content.latest_brief_size}In today's brief
@@ -1089,7 +1090,15 @@ {:else if section === 'audience'}

export audience CSV ({range}d) ↓

-

Visitors

+

Engaged readers (distinct visitor-days with a real gesture or deliberate action)

+
+
{stats.visitors.engaged_today}Today
+
{stats.visitors.engaged_d7}Last 7 days
+
{stats.visitors.engaged_d30}Last {range} days
+
+
+
+

Recorded visits (raw — includes UA-spoofing bots)

{stats.visitors.today}Today
{stats.visitors.d7}Last 7 days
diff --git a/goodnews/api.py b/goodnews/api.py index cbed5ec..b281beb 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -644,6 +644,7 @@ _EVENT_KINDS = { "share_ub", "copy_source", "native_share", "not_today", "less_like_this", "hide_topic", "replace_used", "replace_none", "paywall_replace", "paywalled_source_open", + "engaged", # genuine engagement: ~8s visible + a real gesture (vs. a raw visit) "client_error", # boot-failure seatbelt beacon (blank-screen risk signal) } | _GAME_EVENT_KINDS diff --git a/goodnews/queries.py b/goodnews/queries.py index 65e4e18..5f70269 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -20,6 +20,19 @@ from .paywall import is_paywalled, is_paywalled_for_source BOT_UA_MARKS = ("headlesschrome", "bot", "spider", "crawl", "python", "curl", "wget", "phantomjs") _NOT_BOT_SQL = " AND ".join(f"instr(lower(user_agent), '{m}')=0" for m in BOT_UA_MARKS) +# "Engaged reader" = a distinct visitor-day with DELIBERATE activity, as opposed to a raw +# visit (which a JS-capable bot can trip). Counts the gesture-gated 'engaged' beacon OR a +# genuine deliberate action. Deliberately EXCLUDES auto-fired/passive kinds (visit, +# summary_viewed, open), replace_none, and game *_arrival (a share-loop landing, not engagement). +_ENGAGED_GAMES = ("word", "wordsearch", "bloom", "match") +ENGAGED_EVENT_KINDS = ( + "engaged", "full_story", "source_click", + "share_ub", "copy_source", "native_share", + "replace_used", "paywall_replace", "paywalled_source_open", + "not_today", "less_like_this", "hide_topic", + *(f"{g}_{e}" for g in _ENGAGED_GAMES for e in ("started", "completed", "shared")), +) + def is_bot_ua(ua: str | None) -> bool: low = (ua or "").lower() @@ -746,13 +759,25 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: def scalar(sql, params=()): return conn.execute(sql, params).fetchone()[0] or 0 + eng_ph = ",".join("?" * len(ENGAGED_EVENT_KINDS)) visitors = { + # Recorded visits — the raw/noisy count (one daily 'visit' beacon per device). "today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events " "WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"), "d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events " "WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"), "d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events " "WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)), + # Engaged readers — distinct visitor-day with deliberate activity (the honest number). + "engaged_today": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events " + f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day=date('now')", + ENGAGED_EVENT_KINDS), + "engaged_d7": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events " + f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now','-7 days')", + ENGAGED_EVENT_KINDS), + "engaged_d30": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events " + f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now',?)", + (*ENGAGED_EVENT_KINDS, since)), } # Returning (seen on ≥2 distinct days) vs one-and-done, over the window. diff --git a/goodnews/share.py b/goodnews/share.py index 8bad5eb..17102ee 100644 --- a/goodnews/share.py +++ b/goodnews/share.py @@ -344,6 +344,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None, // visit isn't recorded for a /a/ landing — count it here, once per day per device. var t=new Date().toISOString().slice(0,10); if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}} + // Engaged-reader signal (mirrors the SPA's armEngaged): ~8s visible + a real gesture, once/day. + var eng=false,gest=false,secs=0; + function fireEng(){{ + if(eng||!gest||secs<8) return; eng=true; + try{{ if(localStorage.getItem('goodnews:engagedday')!==t){{localStorage.setItem('goodnews:engagedday',t);beacon({{kind:'engaged',article_id:0,visitor:v}});}} }}catch(e){{}} + }} + var iv=setInterval(function(){{ if(document.visibilityState==='visible'){{secs++;fireEng();}} if(eng) clearInterval(iv); }},1000); + ['scroll','pointerdown','keydown','touchstart'].forEach(function(e){{window.addEventListener(e,function(){{gest=true;fireEng();}},{{passive:true}});}}); }}catch(e){{}} }})(); diff --git a/tests/test_events.py b/tests/test_events.py index d422527..61fd97b 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -103,3 +103,20 @@ def test_admin_stats_games_funnel_aggregates(app_db): assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1} assert games["by_game"]["match"]["completed"] == 1 assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1 + + +def test_engaged_readers_metric(app_db): + """Engaged readers counts the gesture-gated 'engaged' beacon + deliberate actions, + NOT auto-fired visit/summary_viewed or a game-share arrival.""" + app, db = app_db + tc = TestClient(app, headers=_BROWSER) + tc.post("/api/events", json={"kind": "engaged", "visitor": "a"}) # gesture beacon + tc.post("/api/events", json={"kind": "source_click", "article_id": 5, "visitor": "b"}) # deliberate + tc.post("/api/events", json={"kind": "visit", "visitor": "c"}) # raw visit only + tc.post("/api/events", json={"kind": "summary_viewed", "article_id": 5, "visitor": "c"}) # auto-fired + tc.post("/api/events", json={"kind": "word_arrival", "visitor": "d"}) # share-loop landing + from goodnews.db import connect + from goodnews import queries + cn = connect(str(db)); v = queries.admin_stats(cn, days=30)["visitors"]; cn.close() + assert v["engaged_today"] == 2 # a (engaged) + b (source_click) + assert v["today"] == 1 # only c fired a raw visit