diff --git a/docs/analytics.md b/docs/analytics.md
index 7b1ea1f..90f3d05 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -47,6 +47,9 @@ WebSocket reports actual navigations and active time spent on a page.
(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.
+ Real-browser bots whose UA does not match still register a visit, but
+ their reported reading time stays under 5 seconds, so they are
+ reclassified as crawler hits at display time (see **Crawler hits** below).
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
@@ -90,7 +93,7 @@ WebSocket reports actual navigations and active time spent on a page.
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
https origin and accepted only when the client sent exactly that.
- **External-site favicons**: for every external https origin seen as a visit
- referer or an exit link, the server fetches `{origin}/favicon.ico` in a
+ referer, a crawler-hit referer or an exit link, the server fetches `{origin}/favicon.ico` in a
background task (httpx, 8 s timeout, ≤ 64 KB, image content-types only —
SVG is sniffed from the body when served without an image type) and stores
the icon content-hashed on disk in the FileStore (served at `/_f/{name}`,
@@ -134,11 +137,19 @@ WebSocket reports actual navigations and active time spent on a page.
from the same client arrives within 10 seconds the hit is discarded;
otherwise it is written to `crawlers` — unless the client is hidden
(admin), in which case the hit is discarded on expiry too. Crawlers do not count as
- visits or views. The `Accept-Language` header is stored on the shared
+ visits or views. Bots running real browsers can still slip past the UA
+ check: a visit whose total reported reading time stays under 5 seconds
+ (`_MIN_VISIT_READ`; durations are client-provided and trusted — such bots
+ report 0–2 s) is reclassified as crawler hits at display time, one hit
+ per internal trail page, and counts in no visit aggregate. The
+ `Accept-Language` header is stored on the shared
`Client` immediately; reverse-DNS host names and DB-IP geoip
country/city are filled in asynchronously, just like for real visits. In
the analytics viewer, crawler hits are grouped by client hash and shown as
- a trail of internal pages that crawler visited; the crawler table lists
+ a trail of internal pages that crawler visited, preceded by its referer
+ when there is one — spiders often advertise their own site as the
+ referer, and it is rendered with its favicon like visit referers (crawler
+ referers are included in the favicon fetch origins). The crawler table lists
the most recent crawler first, with the most active as a tie-breaker.
- **Abuse (scanner) hits**: a 404 for a telltale path — any URL segment
starting with a dot (`/.env`, `/.git/config`) or ending in `.php` —
@@ -152,9 +163,12 @@ WebSocket reports actual navigations and active time spent on a page.
is persisted in the JSON file; the plain-404 counters are RAM-only. In the
viewer, abuse hits are grouped by IP (never by client/UA — scanners
randomize theirs) in a separate "Abuse" table. Identical paths are
- collapsed into one entry with their hit count; flagged paths that
- triggered classification are lifted to the top, followed by other 404s and
- then document GETs from the abuser. Raw User-Agent strings are shown one
+ collapsed into one entry with their hit count. The 404 probes ("paths
+ abused": flagged paths that triggered classification first, then other
+ 404s) are kept in a separate column from the real articles the abuser
+ actually read ("articles read": document GETs that returned 200, not the
+ 404 fallback rendering — rendered as trail links like the visitor and
+ crawler tables, with the query string stripped). Raw User-Agent strings are shown one
per line with their occurrence counts, and the full lists are click-to-copy.
## Visits and sessions
@@ -219,14 +233,16 @@ Each `AbuseHit` record:
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
- `flag` — true for the path that triggered abuse classification (telltale
path or the 404 that crossed the threshold),
-- `is_404` — true for 404 responses, false for document GETs from the
- abuser.
+- `is_404` — true for 404 responses (probed paths and 404-fallback document
+ GETs), false for real (200) document GETs — articles the abuser read.
Crawler hits are grouped by client hash in the analytics viewer; abuse hits
are grouped by IP alone (resolved from the referenced `Client`). In the
-Abuse table identical paths are collapsed with their counts; flagged paths
-that triggered classification are lifted to the top, followed by other 404s
-and then document GETs from the abuser. Within each category paths are
+Abuse table identical paths are collapsed with their counts, split into the
+404 probes (flagged paths that triggered classification first, then other
+404s, shown verbatim) and the 200 document GETs shown as trail links in the
+separate articles column.
+Within each list paths are
sorted by count descending, then by their earliest hit.
In the visitor and crawler tables, internal paths that returned a 404 status
@@ -237,7 +253,9 @@ to tell misses from real pages at a glance.
Aggregates are **not stored**; they are computed at display time by
`Store.display()` from the visit records (entry + `navs` log), skipping
-hidden clients' visits. This is what allows a client to become hidden after
+hidden clients' visits and short visits reclassified as crawler hits
+(under `_MIN_VISIT_READ` seconds of total reported reading time). This is
+what allows a client to become hidden after
navigations were already logged: no counts need reversing. The computed
shapes, part of the WebSocket payload (`Display` struct alongside `visits`,
`crawlers`, `abuse` and `clients`):
diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue
index 6cb69e3..144225c 100644
--- a/frontend/src/AnalyticsView.vue
+++ b/frontend/src/AnalyticsView.vue
@@ -163,7 +163,7 @@ const favicons = computed(() => data.value?.favicons || {})
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
const crawlers = computed(() => rangeData.value?.crawlers || [])
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
-const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, now.value))
+const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, pageTree.value, now.value))
@@ -244,6 +244,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
+
|
formatAbuseRows(rangeData.value?.abuse || [], c
| paths abused |
+ articles read |
visitor |
last seen |
@@ -286,6 +288,11 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
+{{ a.paths.length - ABUSE_MAX_LINES }} more
+
+
+ —
+ |
g.lastStart) g.lastStart = start
+ if (c.referer) g.referer = c.referer
if (c.entry?.startsWith('/')) {
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
existing.count += 1
@@ -410,6 +414,7 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
lastSeen: formatWhen(g.lastStart, now),
lastSeenIso: formatWhenIso(g.lastStart),
lastSeenLocal: formatWhenLocal(g.lastStart),
+ refererStep: stepOf(g.referer, titles),
pages: [...g.pages.entries()]
.sort((a, b) => b[1].count - a[1].count)
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
@@ -430,16 +435,19 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
/**
* Group abuse hits by IP and format each group as a row with the full paths
* probed. Identical paths are collapsed into one entry with their hit count.
- * Flagged paths (the ones that triggered abuse classification) are lifted to
- * the top, followed by other 404s, then document GETs from the abuser. Within
- * each category paths are sorted by count descending, then earliest first.
+ * The paths split into two lists: ``paths`` holds the 404 probes (flagged
+ * paths — the ones that triggered abuse classification — first, then other
+ * 404s) shown verbatim, query string included, and ``articles`` holds the
+ * real (200) document GETs as trail steps resolved against the page tree
+ * (query string stripped), rendered like the visitor/crawler trails. Within
+ * each list paths are sorted by count descending, then earliest first.
* Rows are sorted by most recent hit first. Visitor metadata comes from the
* latest client hash seen for the IP; ``clientCount`` tells the visitor cell
- * how many distinct client variations the IP produced. Paths are shown
- * verbatim (query string included), not resolved against the page tree.
+ * how many distinct client variations the IP produced.
* ``clients`` maps client hashes to client records.
*/
-export function formatAbuseRows(abuse, clients, now = Date.now()) {
+export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
+ const titles = buildTitleMap(pageTree)
const groups = new Map()
for (const a of abuse || []) {
const client = (clients || {})[a.client] || {}
@@ -481,13 +489,14 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
.sort((a, b) => b.lastStart - a.lastStart)
.slice(0, 10)
.map((g) => {
- const pathCategory = (p) => (p.flag ? 0 : p.is_404 ? 1 : 2)
- const paths = [...g.pathCounts.values()].sort(
- (a, b) =>
- pathCategory(a) - pathCategory(b) ||
- b.count - a.count ||
- a.firstStart - b.firstStart,
- )
+ const all = [...g.pathCounts.values()]
+ const byCount = (a, b) => b.count - a.count || a.firstStart - b.firstStart
+ const paths = all
+ .filter((p) => p.flag || p.is_404)
+ .sort((a, b) => (a.flag ? 0 : 1) - (b.flag ? 0 : 1) || byCount(a, b))
+ const articles = all.filter((p) => !p.flag && !p.is_404).sort(byCount)
+ const pathList = (list) =>
+ list.map((p) => (p.count > 1 ? `${p.count}× ${p.path}` : p.path)).join('\n')
const client = (clients || {})[g.lastClient] || {}
const host = client.host || ''
const isHost = !!host
@@ -501,9 +510,14 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
flag: p.flag,
is_404: p.is_404,
})),
- allPaths: paths
- .map((p) => (p.count > 1 ? `${p.count}× ${p.path}` : p.path))
- .join('\n'),
+ allPaths: pathList(paths),
+ articles: articles
+ .map((p) => {
+ const step = stepOf(p.path.split('?')[0], titles)
+ return step ? { ...step, count: p.count } : null
+ })
+ .filter(Boolean),
+ allArticles: pathList(articles),
clientCount: g.clientHashes.size,
ip: client.ip || g.ip,
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
diff --git a/pagerite/analytics.py b/pagerite/analytics.py
index cf4887e..5ccf7d3 100644
--- a/pagerite/analytics.py
+++ b/pagerite/analytics.py
@@ -14,7 +14,11 @@ that only fetch documents end up in the crawler list). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do connect and send messages, but
their UA gives them
away (``_is_bot_ua``) and their messages are ignored, so they land in the
-crawler list too. Idle-time link preloads from pagerite.js carry an
+crawler list too. Bots whose UA does not match are caught by engagement:
+a visit whose total reported reading time is under ``_MIN_VISIT_READ``
+seconds is reclassified as crawler hits at display time (durations are
+client-provided and trusted — real-browser bots report 0–2 s), so it never
+counts in the visit aggregates either. Idle-time link preloads from pagerite.js carry an
``x-pagerite-preload`` header and are not tracked at all — the navigation
message sent when the user actually navigates does the counting.
Admin clients send ``hide``: the client record is flagged ``hide``,
@@ -43,7 +47,7 @@ from collections.abc import Callable
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from pathlib import Path
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import parse_qs, urlencode, urlparse
import blake3
import msgspec
@@ -200,8 +204,10 @@ class AbuseHit(msgspec.Struct, omit_defaults=True):
Unlike crawler hits the full request path (query string included) is
kept: the interesting part is exactly which paths were probed.
``flag`` marks the path that triggered classification; ``is_404``
- distinguishes 404 responses from document GETs made by the abuser.
- Client metadata is held in ``Analytics.clients`` keyed by ``client``.
+ distinguishes 404 responses (probed paths and 404-fallback document
+ GETs) from real 200 document GETs — the abuser actually reading
+ articles. Client metadata is held in ``Analytics.clients`` keyed by
+ ``client``.
"""
start: datetime
@@ -212,7 +218,7 @@ class AbuseHit(msgspec.Struct, omit_defaults=True):
#: True when this path triggered abuse classification (telltale path
#: or the 404 that crossed the threshold).
flag: bool = False
- #: True for 404 responses; false for document GETs from the abuser.
+ #: True for 404 responses; false for real (200) document GETs.
is_404: bool = False
@@ -347,6 +353,11 @@ def _utm_tags(query: str) -> dict[str, str]:
_CRAWLER_TIMEOUT = timedelta(seconds=10)
+#: Minimum total reported reading time (seconds, summed over the trail) for
+#: a session to count as a visit; shorter sessions are JS-running bots and
+#: are shown as crawler hits instead.
+_MIN_VISIT_READ = 5
+
#: How long a failed favicon fetch suppresses retries for the same origin.
_FAVICON_RETRY = timedelta(days=7)
@@ -505,16 +516,43 @@ class Store:
def display(self) -> Display:
"""Build the viewer payload, excluding hidden clients.
+ Visits whose total reported reading time is under
+ ``_MIN_VISIT_READ`` seconds are JS-running bots, not readers: they
+ are converted to crawler hits (one per internal trail page) and left
+ out of the visit list and every aggregate.
The aggregates (site visits, page views, transitions) are computed
here from the visit records rather than stored, so a client that
becomes hidden after navigations were already logged disappears
from every statistic. Internal-path navigations count as page
views; external https targets are transitions only.
"""
- visits = [v for v in self.data.visits if not self._hidden(v.client)]
+ visits: list[Visit] = []
+ crawlers = [h for h in self.data.crawlers if not self._hidden(h.client)]
+ for visit in self.data.visits:
+ if self._hidden(visit.client):
+ continue
+ if sum(item.read for item in visit.trail.values()) >= _MIN_VISIT_READ:
+ visits.append(visit)
+ continue
+ query = urlencode(visit.utm)
+ first = True
+ for t, item in visit.trail.items():
+ if not item.to.startswith("/"):
+ continue
+ crawlers.append(
+ CrawlerHit(
+ start=t,
+ entry=item.to,
+ client=visit.client,
+ referer=visit.referer if first else "",
+ query=query if first else "",
+ status=item.status,
+ )
+ )
+ first = False
display = Display(
visits=visits,
- crawlers=[h for h in self.data.crawlers if not self._hidden(h.client)],
+ crawlers=crawlers,
abuse=[h for h in self.data.abuse if not self._hidden(h.client)],
clients={h: c for h, c in self.data.clients.items() if not c.hide},
favicons={
@@ -601,7 +639,9 @@ class Store:
def favicon_origins_needed(self) -> list[str]:
"""External https origins seen in visits whose favicon needs fetching.
- Covers visit referers and external exit targets (trail and navs).
+ Covers visit referers and external exit targets (trail and navs),
+ plus crawler-hit referers — spiders often advertise their own site
+ as the referer, so the icon identifies them in the crawler table.
Origins with a stored icon, or a miss younger than
``_FAVICON_RETRY``, are skipped.
"""
@@ -613,6 +653,9 @@ class Store:
origin = _origin(target.to)
if origin is not None:
origins.add(origin)
+ for hit in self.data.crawlers:
+ if hit.referer:
+ origins.add(hit.referer)
now = datetime.now(UTC)
return [
origin
@@ -673,6 +716,7 @@ class Store:
h.client,
h.entry + (f"?{h.query}" if h.query else ""),
start=h.start,
+ is_404=h.status != 200,
)
pending = [
h for h in self.pending_crawlers if self._client_ip(h.client) == ip
@@ -686,6 +730,7 @@ class Store:
h.client,
h.entry + (f"?{h.query}" if h.query else ""),
start=h.start,
+ is_404=h.status != 200,
)
self._abuse_hit(client_hash, path, flag=flag, is_404=is_404)
self._save()
@@ -779,7 +824,7 @@ class Store:
client_hash = self._ensure_client(ip, ua, lang, country=country)
if ip in self.data.abuse_ips:
flushed = self._flush_crawlers()
- self._abuse_hit(client_hash, full_path, is_404=False, flag=False)
+ self._abuse_hit(client_hash, full_path, is_404=status != 200, flag=False)
self._save()
return flushed
now = datetime.now(UTC)