diff --git a/docs/analytics.md b/docs/analytics.md index 6118957..91b51cc 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -54,6 +54,10 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with tasks after the visit is stored, so the `/ _a` response is never delayed. The decompressed `dbip-*.mmdb` file is kept in the repository root and ignored by git. +- **Crawler hits**: every document GET is queued in RAM as a pending crawler + hit. If a ping from the same (IP, User-Agent) pair arrives within 10 + seconds the hit is discarded; otherwise it is written to `crawlers`. + Crawlers do not count as visits or views. ## Visits and sessions @@ -81,8 +85,22 @@ Each `Visit` record: `Accept-Language` region subtag, but overwritten by the DB-IP MMDB result when a database is available, - `ua` — raw `User-Agent` string from the initial ping, +- `ua_pretty` — compact display form of the UA (browser/OS/device) when + parsable, otherwise the raw string, - `utm` — `utm_*` query parameters from the landing URL, as a dict. +Each `CrawlerHit` record: + +- `start` — timestamp of the document GET, +- `entry` — page path requested, +- `ip` — IP address, +- `ua` — raw `User-Agent` header, +- `ua_pretty` — compact display form of the UA when parsable, +- `referer` — external https origin of the request, `""` for direct/none, +- `query` — raw query string of the request. + +Crawler hits are grouped by User-Agent in the analytics viewer. + ## Aggregates - `transitions`: sparse nested dict `from -> to -> count`. `from` is the diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 966c89d..7a67dc1 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -8,7 +8,14 @@ // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { RANGES } from './analytics/time.js' -import { calcTotalViews, formatVisitRows } from './analytics/format.js' +import { + calcTotalViews, + copyIp, + countCrawlerUas, + formatCounts, + formatCrawlerRows, + formatVisitRows, +} from './analytics/format.js' import * as flagSvgs from 'country-flag-icons/string/3x2' import TransitionGraph from './TransitionGraph.vue' import VisitorCharts from './VisitorCharts.vue' @@ -57,6 +64,9 @@ watch(range, (r) => { }) const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value)) +const crawlers = computed(() => data.value?.crawlers || []) +const crawlerRows = computed(() => formatCrawlerRows(crawlers.value)) +const topCrawlerUas = computed(() => countCrawlerUas(crawlers.value).slice(0, 10)) function flagSvg(code) { return flagSvgs[code?.toUpperCase()] || '' @@ -106,7 +116,6 @@ function countryName(code) { trail referer ip - host lang country ua @@ -123,14 +132,17 @@ function countryName(code) { {{ v.referer }} - {{ v.ip }} - {{ v.host }} + + {{ v.ipDisplay }} + {{ v.lang }} - {{ v.ua }} + {{ v.ua }} {{ v.utm }} @@ -138,6 +150,42 @@ function countryName(code) {

no visits recorded yet

+ +
+

Crawlers

+
+

top UAs: {{ formatCounts(topCrawlerUas) }}

+
+
+ + + + + + + + + + + + + + + + + + + + + +
whenentryipuarefererquery
{{ c.when }}{{ c.entry }} + {{ c.ipDisplay }} + {{ c.ua }}{{ c.referer }}{{ c.query }}
+
+

no crawler hits recorded yet

+
@@ -268,6 +316,16 @@ function countryName(code) { margin-left: 0.5rem; } +.visit-table .clickable-ip { + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; +} + +.visit-table .clickable-ip:hover { + color: var(--accent); +} + .visit-table .ua { max-width: 18rem; overflow: hidden; @@ -291,6 +349,15 @@ function countryName(code) { display: block; } +.crawler-top-uas { + font-size: 0.9rem; + margin-bottom: 0.6rem; +} + +.crawler-top-uas strong { + color: var(--muted); +} + .empty, .loading, .error { color: var(--muted); } .error { color: var(--error, #c00); } diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index f322a92..0adcf76 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -3,6 +3,38 @@ * visit trail. */ +/** + * IPv4 unchanged, IPv6 returns the /64 network prefix in compact form. + * Falls back to the original value when parsing fails. + */ +export const hostIP = (ip) => { + try { + if (!ip || !ip.includes(':')) return ip + const strip = (s) => s.replace(/^\[|\]$/g, '') + const norm = strip(new URL(`http://[${ip}]/`).hostname) + const [l, r] = norm.split('::').map((s) => (s ? s.split(':') : [])) + const full = r + ? [...l, ...Array(8 - l.length - r.length).fill('0'), ...r] + : l + return strip( + new URL(`http://[${full.slice(0, 4).join(':')}::]/`).hostname, + ).replace(/::$/, '') + } catch (e) { + console.error('hostIP processing failed for:', ip, e) + return ip + } +} + +/** Copy the full IP to the clipboard, ignoring failures. */ +export async function copyIp(ip) { + if (!ip) return + try { + await navigator.clipboard.writeText(ip) + } catch { + /* ignore */ + } +} + /** Total page views across every page and every bucket. */ export function calcTotalViews(views) { let n = 0 @@ -87,6 +119,37 @@ export function formatCounts(entries) { return entries.map(([value, count]) => `${value} (${count})`).join(', ') } +/** + * Count distinct User-Agent strings among crawler hits, most common first. + * Returns an array of [ua, count] pairs. + */ +export function countCrawlerUas(crawlers) { + const counts = {} + for (const c of crawlers || []) { + const value = c.ua_pretty || c.ua || '(no UA)' + counts[value] = (counts[value] || 0) + 1 + } + return Object.entries(counts).sort((a, b) => b[1] - a[1]) +} + +/** + * Format raw crawler hit records as rows for a technical table. Missing + * values become "—". + */ +export function formatCrawlerRows(crawlers) { + const dash = (s) => (s || '—') + return [...(crawlers || [])].reverse().map((c) => ({ + when: new Date(c.start).toLocaleString(), + entry: dash(c.entry), + ip: c.ip || '', + ipDisplay: c.host || hostIP(c.ip) || c.ip || '—', + ua: c.ua_pretty || c.ua || '—', + uaRaw: c.ua || '', + referer: dash(c.referer), + query: dash(c.query), + })) +} + /** * Format raw visit records as rows for a technical table. Returns objects * with display strings; missing values become "—". ``trail`` joins page @@ -110,11 +173,13 @@ export function formatVisitRows(visits, pageTree) { when: new Date(v.start).toLocaleString(), trail, referer: dash(v.referer), - ip: dash(v.ip), + ip: v.ip || '', + ipDisplay: v.host || hostIP(v.ip) || v.ip || '—', host: dash(v.host), lang: dash(v.lang), country: dash(v.country), - ua: dash(v.ua), + ua: v.ua_pretty || v.ua || '—', + uaRaw: v.ua || '', utm: utm || '—', } }) diff --git a/pagerite/analytics.py b/pagerite/analytics.py index d481df6..614bf20 100644 --- a/pagerite/analytics.py +++ b/pagerite/analytics.py @@ -17,11 +17,34 @@ rewritten atomically on every recorded event. import os import re import tempfile -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.parse import parse_qs, urlparse import msgspec +from ua_parser import parse + + +def _compact_user_agent(ua: str) -> str: + """Format a User-Agent string into a compact display form. + + Returns the original UA when the parser cannot identify the browser/OS. + """ + if not ua or not ua.strip() or ua == "-": + return "" + r = parse(ua) + browser = r.user_agent.family if r.user_agent else None + ver = r.user_agent.major if r.user_agent else "" + os_name = r.os.family if r.os else None + dev = r.device.family if r.device else None + if browser in (None, "Other") and os_name in (None, "Other"): + return ua + browser = browser if browser and browser != "Other" else "" + os_name = os_name if os_name and os_name != "Other" else "" + if dev in (None, "Other") or dev == browser: + dev = "" + parts = [f"{browser}/{ver}" if browser else "", os_name, dev] + return " ".join(p for p in parts if p).strip() class Visit(msgspec.Struct, omit_defaults=True): @@ -47,15 +70,34 @@ class Visit(msgspec.Struct, omit_defaults=True): country: str = "" #: Raw User-Agent header from the initial ping. ua: str = "" + #: Compact display form of ``ua`` (browser/OS/device) when parsable. + ua_pretty: str = "" #: UTM query parameters from the landing URL, keyed by parameter name. utm: dict[str, str] = {} +class CrawlerHit(msgspec.Struct, omit_defaults=True): + """A document GET that was never followed by an analytics ping.""" + + start: datetime + entry: str + ip: str = "" + ua: str = "" + #: Compact display form of ``ua`` when parsable. + ua_pretty: str = "" + #: External https origin of the initial load, "" for direct/none. + referer: str = "" + #: Raw query string of the landing URL (UTM tags can be parsed from it). + query: str = "" + + class Analytics(msgspec.Struct, omit_defaults=True): """Root of the analytics JSON file. Append-only by design: old data is dropped by deleting list entries / bucket keys.""" visits: list[Visit] = [] + #: Document GETs that never produced a ping, treated as crawler/bot hits. + crawlers: list[CrawlerHit] = [] #: Page transition matrix: from -> to -> count. ``from`` is the referer #: origin or "(direct)" for initial loads, a page path for pings. transitions: dict[str, dict[str, int]] = {} @@ -125,6 +167,9 @@ def _utm_tags(query: str) -> dict[str, str]: return {k: v[0] for k, v in parsed.items() if k.startswith("utm_")} +_CRAWLER_TIMEOUT = timedelta(seconds=10) + + class Store: """In-memory analytics data plus the (IP, UA) -> visit session map.""" @@ -147,6 +192,9 @@ class Store: #: Only non-empty sets are stored, so a later parameter-less page #: does not overwrite an earlier tagged landing URL. self.pending_utms: dict[str, dict[str, str]] = {} + #: Document GETs that have not yet been matched by a ping. Kept + #: in RAM only; expired entries are written to ``data.crawlers``. + self.pending_crawlers: list[CrawlerHit] = [] def _save(self) -> None: """Rewrite the JSON file atomically (temp file + rename).""" @@ -160,6 +208,21 @@ class Store: except OSError: pass # analytics must never break page serving + def _flush_crawlers(self, now: datetime | None = None) -> None: + """Move expired pending crawler hits into persistent ``data.crawlers``.""" + if not self.pending_crawlers: + return + now = now or datetime.now(UTC) + cutoff = now - _CRAWLER_TIMEOUT + expired: list[CrawlerHit] = [] + remaining: list[CrawlerHit] = [] + for hit in self.pending_crawlers: + (expired if hit.start <= cutoff else remaining).append(hit) + if expired: + self.pending_crawlers = remaining + self.data.crawlers.extend(expired) + self._save() + def _count(self, table: dict[str, int], key: str) -> None: table[key] = table.get(key, 0) + 1 @@ -183,6 +246,7 @@ class Store: lang=lang, country=country, ua=ua, + ua_pretty=_compact_user_agent(ua), utm=utm or {}, ) self.data.visits.append(visit) @@ -215,10 +279,16 @@ class Store: if changed: self._save() - def entry_referer( - self, referer: str, own_origin: str, ip: str, query: str = "" + def track_entry( + self, + referer: str, + own_origin: str, + ip: str, + ua: str, + entry: str, + query: str = "", ) -> None: - """Stash the entry referer and UTM tags of a document GET for ping attribution. + """Stash the entry referer/UTM tags and queue a pending crawler hit. Nothing is counted here — the client's initial /_a ping starts the visit (only non-admin clients ping). Only a cross-origin https @@ -226,7 +296,13 @@ class Store: stashed origin untouched. UTM parameters are kept only when the landing URL actually carries them, so a subsequent parameter-less page does not erase an earlier tagged landing. + + Every document GET is also queued as a pending crawler hit. If a ping + from the same (IP, UA) pair arrives within ``_CRAWLER_TIMEOUT``, the + hit is discarded; otherwise it is flushed to ``data.crawlers``. """ + now = datetime.now(UTC) + self._flush_crawlers(now) if referer: origin = _origin(referer) if origin is not None and origin != own_origin: @@ -234,6 +310,17 @@ class Store: utms = _utm_tags(query) if utms: self.pending_utms[ip] = utms + self.pending_crawlers.append( + CrawlerHit( + start=now, + entry=entry, + ip=ip, + ua=ua, + ua_pretty=_compact_user_agent(ua), + referer=self.pending_referers.get(ip, ""), + query=query, + ) + ) def ping( self, @@ -254,6 +341,12 @@ class Store: Returns the index of the new visit when one is created, so callers can enrich it later with non-blocking lookups (host, geoip country). """ + self._flush_crawlers() + # A real visitor ping cancels any pending crawler hits from this + # (IP, UA) pair. + self.pending_crawlers = [ + hit for hit in self.pending_crawlers if not (hit.ip == ip and hit.ua == ua) + ] if to.startswith("/") and not to.startswith("//"): target = _internal_path(to) or "" else: diff --git a/pagerite/app.py b/pagerite/app.py index 01dfdac..9f3487b 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -651,16 +651,18 @@ async def analytics_ping(ping: AnalyticsPing, request: Request) -> None: def _track_entry(path: str, request: Request) -> None: - """Stash the referer and UTM tags of the document GET for the initial ping. + """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 - visit, so bots and admin browsing never register. + visit, so bots and admin browsing never register as visits. """ own_origin = f"https://{urlparse(str(request.base_url)).netloc}" - analytics_store.entry_referer( + analytics_store.track_entry( request.headers.get("referer", ""), own_origin, _client_ip(request), + request.headers.get("user-agent", ""), + "/" if path == "" else f"/{path}", str(request.url.query), ) @@ -680,6 +682,15 @@ def _is_reserved(path: str) -> bool: return any(not _SLUG_RE.match(seg) for seg in path.split("/")) +def _is_trackable_path(path: str) -> bool: + """Content URLs only: skip auth endpoints and reserved/machinery paths.""" + if not path: + return True + if path == "auth" or path.startswith("auth/"): + return False + return not _is_reserved(path) + + def _check_reserved(path: str) -> None: """Reject paths that do not follow the slug charset.""" if _is_reserved(path): @@ -881,7 +892,8 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: etag = f'"{path}@{node.modified.timestamp()}v{data.version}"' if request.headers.get("if-none-match") == etag: return Response(status_code=304) - _track_entry(path, request) + if _is_trackable_path(path): + _track_entry(path, request) return HTMLResponse( views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")), headers={ @@ -893,7 +905,8 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: if node is not None and node.published and node.content is None: # Category label without a landing page: placeholder with the pen # to create it (404 — no page here, but the node is real). - _track_entry(path, request) + if _is_trackable_path(path): + _track_entry(path, request) return HTMLResponse( views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404, @@ -908,5 +921,6 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: for slug, item in sorted_nodes(data.menu): if item.published: return RedirectResponse(f"/{slug}") - _track_entry(path, request) + if _is_trackable_path(path): + _track_entry(path, request) return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404) diff --git a/pyproject.toml b/pyproject.toml index c69e12d..02168d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "maxminddb>=3.1.1", "mdit-py-plugins>=0.6.1", "pygments>=2.20.0", + "ua-parser>=1.0.2", ] [project.scripts]