Fix analytics classification: ignore bot-UA pings, skip preload GETs.

JS-running crawlers (Googlebot, GoogleOther, Applebot) execute pagerite.js
and send navigation pings, registering as visitors. Pings whose User-Agent
matches _is_bot_ua (any "bot" token plus listed exceptions) are now
ignored, so their document GETs flush to the crawler list as intended. No
source verification: a spoofed bot UA merely lands in the crawler stats,
and path-based abuse classification catches scanners regardless.

Idle-time link preloads from pagerite.js were queued as pending crawler
hits and flushed to the crawler list whenever the user navigated more than
10s later, so real visitors' subpage loads showed up as crawler hits.
Preload fetches now carry an x-pagerite-preload header and the document
GET handler skips tracking for them; the ping sent on actual navigation
does the counting.
This commit is contained in:
2026-08-22 18:23:38 +00:00
parent b7fc543a83
commit 6d2ae104d7
4 changed files with 56 additions and 8 deletions
+15 -3
View File
@@ -24,7 +24,14 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
- **Initial page load**: `to` is the loaded path. This ping is what starts - **Initial page load**: `to` is the loaded path. This ping is what starts
the visit and counts the entry page view — the document GET alone records the visit and counts the entry page view — the document GET alone records
nothing, so bots and admin browsing never register. Reloads are not nothing, so bots and admin browsing never register. JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
gives them away: pings whose UA matches `_is_bot_ua` (anything calling
itself a "bot", plus known exceptions such as GoogleOther) are ignored
server-side, and their document GETs land in the crawler list instead.
No source-IP verification is done: a spoofed bot UA merely lands in the
crawler stats, and scanners that probe telltale paths are caught by the
abuse rules regardless. Reloads are not
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a
refresh neither counts a second view nor logs a self-transition. The GET refresh neither counts a second view nor logs a self-transition. The GET
handler stashes a cross-origin https `Referer` (origin part only) and any handler stashes a cross-origin https `Referer` (origin part only) and any
@@ -74,8 +81,13 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
removing older versions after an update; without the flag only an existing removing older versions after an update; without the flag only an existing
file is used. file is used.
- **Crawler hits**: every document GET is queued in RAM as a pending crawler - **Crawler hits**: every document GET is queued in RAM as a pending crawler
hit. If a ping from the same client arrives within 10 seconds the hit is hit — except idle-time link preloads from pagerite.js, which carry an
discarded; otherwise it is written to `crawlers`. Crawlers do not count as `x-pagerite-preload` header and are not tracked at all (the ping sent when
the user actually navigates to a preloaded page does the counting; forging
the header only hides a GET from the crawler stats, the path-based abuse
classification is unaffected). If a ping
from the same client arrives within 10 seconds the hit is discarded;
otherwise it is written to `crawlers`. Crawlers do not count as
visits or views. The `Accept-Language` header is stored on the shared visits or views. The `Accept-Language` header is stored on the shared
`Client` immediately; reverse-DNS host names and DB-IP geoip `Client` immediately; reverse-DNS host names and DB-IP geoip
country/city are filled in asynchronously, just like for real visits. In country/city are filled in asynchronously, just like for real visits. In
+4 -1
View File
@@ -337,7 +337,10 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
for (const url of urls) { for (const url of urls) {
if (pageCache.has(url)) continue; if (pageCache.has(url)) continue;
fetch(url) // x-pagerite-preload: idle cache warm-up, not a page view — the
// server excludes these GETs from analytics (the ping sent on actual
// navigation does the counting).
fetch(url, { headers: { "x-pagerite-preload": "1" } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html") .then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : "")) ? r.text() : ""))
.then((html) => { if (html) pageCache.set(url, html); }) .then((html) => { if (html) pageCache.set(url, html); })
+28 -2
View File
@@ -5,7 +5,13 @@ ping on page load starts a visit, later pings extend it, and pings with no
known session start a fresh one (missing data, not dropped). The document known session start a fresh one (missing data, not dropped). The document
GET handler stashes the entry referer (external https origin) and any GET handler stashes the entry referer (external https origin) and any
utm_* query parameters in in-memory IP tables, consumed when the ping utm_* query parameters in in-memory IP tables, consumed when the ping
starts the visit; nothing is counted without a ping (bots stay invisible). starts the visit; nothing is counted without a ping (plain bots that only
fetch documents end up in the crawler list). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their UA gives them
away (``_is_bot_ua``) and their pings are ignored, so they land in the
crawler list too. Idle-time link preloads from pagerite.js carry an
``x-pagerite-preload`` header and are not tracked at all — the ping sent
when the user actually navigates does the counting.
Admin clients ping with ``hide=1``, which records nothing and removes any Admin clients ping with ``hide=1``, which records nothing and removes any
visit the session accumulated before logging in. Scanner telltale 404s visit the session accumulated before logging in. Scanner telltale 404s
(dotpaths, *.php) classify the source IP as abuse; its hits — including (dotpaths, *.php) classify the source IP as abuse; its hits — including
@@ -239,6 +245,18 @@ def _utm_tags(query: str) -> dict[str, str]:
_CRAWLER_TIMEOUT = timedelta(seconds=10) _CRAWLER_TIMEOUT = timedelta(seconds=10)
#: UAs of JS-running crawlers, which would register as visitors on their
#: ping. Anything calling itself a "bot" matches; known crawlers without
#: that token (GoogleOther) are listed as extra alternates. No source
#: verification: a spoofed bot UA just lands in the crawler list, and
#: scanners that probe telltale paths are caught by the abuse rules anyway.
_BOT_UA = re.compile(r"bot|googleother", re.IGNORECASE)
def _is_bot_ua(ua: str) -> bool:
"""True when the UA claims a crawler identity (Googlebot, Applebot, ...)."""
return bool(_BOT_UA.search(ua))
#: Plain-404 count per IP that classifies it as abuse even without a #: Plain-404 count per IP that classifies it as abuse even without a
#: telltale path hit. #: telltale path hit.
_ABUSE_404_THRESHOLD = 10 _ABUSE_404_THRESHOLD = 10
@@ -664,7 +682,10 @@ class Store:
removed from the stats (the admin browsed anonymously before logging removed from the stats (the admin browsed anonymously before logging
in). Nothing new is recorded. in). Nothing new is recorded.
Pings from IPs classified as abuse are ignored entirely. Pings from IPs classified as abuse, and pings whose User-Agent
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
entirely — the crawler's pending hits stay queued and flush to
``data.crawlers`` normally.
Returns the index of the new visit when one is created (or None) and Returns the index of the new visit when one is created (or None) and
the client hashes of any crawler hits flushed by this call, so callers the client hashes of any crawler hits flushed by this call, so callers
@@ -685,6 +706,11 @@ class Store:
return None, flushed return None, flushed
if ip in self.data.abuse_ips: if ip in self.data.abuse_ips:
return None, flushed return None, flushed
if _is_bot_ua(ua):
# A JS-running crawler (Googlebot, GoogleOther, Applebot execute
# JS and ping): never a visit. Its pending crawler hits are
# kept and flush to ``data.crawlers`` normally.
return None, flushed
# A real visitor ping cancels any pending crawler hits from this client. # A real visitor ping cancels any pending crawler hits from this client.
self.pending_crawlers = [ self.pending_crawlers = [
hit for hit in self.pending_crawlers if hit.client != client_hash hit for hit in self.pending_crawlers if hit.client != client_hash
+9 -2
View File
@@ -857,8 +857,9 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET. """Stash the referer/UTM tags and queue a pending crawler hit for the GET.
Nothing is counted on the GET itself — the client's /_a ping starts the Nothing is counted on the GET itself — the client's /_a ping starts the
visit, so bots never register as visits. (Admin clients ping too, but visit, so bots never register as visits (JS-running crawlers ping too,
with hide=1, which scrubs their session instead of recording it.) but the ping handler ignores known bot UAs). (Admin clients ping too,
but with hide=1, which scrubs their session instead of recording it.)
The devserver's health probe (``GET /?from=devserver.py`` from The devserver's health probe (``GET /?from=devserver.py`` from
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be ``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
@@ -868,6 +869,12 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
Returns the client hashes of any pending crawler hits flushed to persistent Returns the client hashes of any pending crawler hits flushed to persistent
storage, so callers can schedule async geoip and reverse-DNS enrichment. storage, so callers can schedule async geoip and reverse-DNS enrichment.
""" """
if request.headers.get("x-pagerite-preload"):
# Idle-time page-cache warm-up by pagerite.js, not a page view: the
# ping sent when the user actually navigates does the counting.
# (Forging the header only hides a GET from the crawler stats; the
# path-based abuse classification is unaffected.)
return []
if ( if (
path == "" path == ""
and str(request.url.query) == "from=devserver.py" and str(request.url.query) == "from=devserver.py"