GETs store the resolved page language, activity pings carry <html lang> (including a new ping on in-place language switches, which also keeps the switch's GET out of crawler classification), and trail items/crawler hits surface it. The viewer shows flags discreetly: nothing on single-language sites or primary-language-only rows, one leading flag for uniform visits, transition flags on mixed trails, a flag row per crawler.
1055 lines
42 KiB
Python
1055 lines
42 KiB
Python
"""Server-side visit analytics (collection only; see docs/analytics.md).
|
||
|
||
Raw recording, display-time classification. Every document GET is appended
|
||
to ``Analytics.gets`` as a raw access-log line (path with query string, true
|
||
HTTP status, external referer origin, preload flag, rendered content
|
||
language) and every pagerite.js activity message from the /_ws WebSocket is
|
||
appended to ``Analytics.msgs``
|
||
(navigations ``fr`` -> ``to`` and active reading-time updates). Nothing is
|
||
classified when it is recorded: whether a client turns out to be a reader,
|
||
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
||
events, so the stored data survives any future change to the classification
|
||
rules.
|
||
|
||
At display time:
|
||
|
||
- An IP with a 404 on a telltale scanner path (empty segment like
|
||
``//foo``, dot segment, *.php) or ten plain 404s within an hour on paths
|
||
that don't resolve to a menu node is classified as abuse; every document
|
||
GET from that IP is shown in the abuse list, split by status into the 404
|
||
probes and the real articles read. Its activity messages are ignored.
|
||
Hidden (admin) clients never trigger classification — editing means
|
||
visiting not-found pages. RFC 8615 well-known URIs (``/.well-known/…``)
|
||
are mostly legitimate browser/service probes and never count as abuse
|
||
evidence.
|
||
- A document GET never followed by an activity message (within
|
||
``_CRAWLER_TIMEOUT``) is a crawler hit. Messages whose UA claims a
|
||
crawler identity (``_is_bot_ua``) are ignored, so JS-running crawlers
|
||
(Googlebot, GoogleOther, Applebot, ...) land in the crawler list too.
|
||
- The remaining messages are grouped into visits per client: a new visit
|
||
starts after ``_SESSION_GAP`` of inactivity. A visit whose total
|
||
reported reading time is under ``_MIN_VISIT_READ`` seconds is a
|
||
real-browser bot and is reclassified as crawler hits (durations are
|
||
client-provided and trusted — such bots report 0–2 s).
|
||
- Admin clients (``hide`` message field, set at collection time on the
|
||
client record) are excluded from every list and aggregate.
|
||
|
||
Idle-time link preloads from pagerite.js (``x-pagerite-preload`` header)
|
||
are recorded raw but flag ``pre``: they never count as views, crawler hits
|
||
or abuse — they exist so a navigation served from the in-memory page cache
|
||
can still be attributed the HTTP status of its preload GET.
|
||
|
||
Aggregates (site visits, page views, transitions) are not stored; they are
|
||
computed at display time from the derived visits. Client metadata (IP, UA,
|
||
language, country/city, host) is stored once per unique client hash and
|
||
referenced from every event.
|
||
|
||
Data is a msgspec Struct JSON-dumped to its own file (not the kanta db),
|
||
rewritten atomically on every recorded event.
|
||
"""
|
||
|
||
import ipaddress
|
||
import os
|
||
import re
|
||
import tempfile
|
||
from bisect import bisect_left, bisect_right
|
||
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, urlencode, urlparse
|
||
|
||
import blake3
|
||
import msgspec
|
||
from uarite import UA, uaparse
|
||
|
||
|
||
def _display_client(client: Client) -> Client:
|
||
"""Client copy with ``uarite`` filled in by the current uarite.
|
||
|
||
The parsed UA is a display-time field: stored records always carry the
|
||
default (None), so it never lands on disk, and old records always
|
||
follow current uarite rules.
|
||
"""
|
||
return msgspec.structs.replace(client, uarite=uaparse(client.ua))
|
||
|
||
|
||
class Ping(msgspec.Struct, omit_defaults=True):
|
||
"""One client message on the /_ws activity WebSocket (wire format).
|
||
|
||
Sent as a JSON text frame (msgspec-encoded, decoded to str for the
|
||
wire). ``to`` set: a navigation — internal page path or external https
|
||
exit URL. ``read`` alone (with ``fr``): an active reading-time update
|
||
for the page ``fr``; these arrive frequently while the user is active.
|
||
``hide`` flags the client as an admin: everything it ever did is
|
||
excluded from the statistics.
|
||
"""
|
||
|
||
#: Path of the page the activity happened on ("" for the initial load).
|
||
fr: str = ""
|
||
#: Navigation target: internal path or external https exit URL.
|
||
to: str = ""
|
||
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
||
read: int = 0
|
||
#: Admin client: record but hide everything from the statistics.
|
||
hide: bool = False
|
||
#: Rendered language of the page the activity happened on (the page's
|
||
#: ``<html lang>``, sent by pagerite.js).
|
||
lang: str = ""
|
||
|
||
|
||
class Get(msgspec.Struct, omit_defaults=True):
|
||
"""One document GET — the raw access-log line.
|
||
|
||
Recorded once per served document (200, or 404 for a category
|
||
placeholder or a missing page); never classified at this point.
|
||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||
"""
|
||
|
||
t: datetime
|
||
#: Full request path including the query string (e.g. "/.env?x=1").
|
||
path: str
|
||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||
client: bytes = b""
|
||
#: True HTTP status of the response.
|
||
status: int = 200
|
||
#: External https origin of the Referer, "" for direct/internal.
|
||
ref: str = ""
|
||
#: Idle-time cache warm-up by pagerite.js (x-pagerite-preload header):
|
||
#: never counted as a view/crawler/abuse hit; recorded only so a later
|
||
#: cache-served navigation can be attributed this GET's status.
|
||
pre: bool = False
|
||
#: Rendered content language of the served document; "" for
|
||
#: non-localized responses (404 probes, reserved paths).
|
||
lang: str = ""
|
||
|
||
|
||
class Msg(msgspec.Struct, omit_defaults=True):
|
||
"""One raw activity message from pagerite.js over /_ws.
|
||
|
||
``to`` set: a navigation — validated internal page path or external
|
||
https exit URL. ``read`` alone (with ``fr``): an active reading-time
|
||
update for the page ``fr`` (seconds since the previous report).
|
||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||
"""
|
||
|
||
t: datetime
|
||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||
client: bytes = b""
|
||
#: Path of the page the activity happened on ("" for the initial load).
|
||
fr: str = ""
|
||
#: Navigation target: internal path or external https exit URL.
|
||
to: str = ""
|
||
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
||
read: int = 0
|
||
#: Rendered language reported by the client for the page the activity
|
||
#: happened on.
|
||
lang: str = ""
|
||
|
||
|
||
class Client(msgspec.Struct, omit_defaults=True):
|
||
"""Client metadata shared by the raw events and every derived row.
|
||
|
||
Identified by a 6-byte blake3 hash of the IPv4 address or IPv6 /64
|
||
network, the full User-Agent string and the extracted language tag.
|
||
Country/city/host are filled in asynchronously after the first event.
|
||
"""
|
||
|
||
#: Visitor IP address (first X-Forwarded-For hop or direct peer).
|
||
ip: str = ""
|
||
#: Reverse-DNS host name for ``ip`` when resolvable, else "".
|
||
host: str = ""
|
||
#: First Accept-Language tag, lowercased (e.g. "en-us").
|
||
lang: str = ""
|
||
#: Two-letter country code from the DB-IP geoip lookup, or "".
|
||
country: str = ""
|
||
#: City name from the DB-IP geoip lookup, or "".
|
||
city: str = ""
|
||
#: Raw User-Agent header.
|
||
ua: str = ""
|
||
#: True for admin clients (hide=1 ping): everything this client ever did
|
||
#: is excluded from all statistics and from the viewer payload.
|
||
hide: bool = False
|
||
#: Display-time parsed UA (uarite.UA dataclass: pretty/engine/os/
|
||
#: provider/kind/url). Set only on the display-payload copies by
|
||
#: ``_display_client`` — stored records keep the default, so it is never
|
||
#: persisted and old data always follows the current uarite version.
|
||
uarite: UA | None = None
|
||
|
||
|
||
# --- Display DTOs -------------------------------------------------------
|
||
# The structs below are never persisted; Store.display() builds them from
|
||
# the raw events. They define the viewer payload shape consumed by
|
||
# frontend/src/analytics/*.
|
||
|
||
|
||
class Nav(msgspec.Struct, omit_defaults=True):
|
||
"""One navigation inside a visit: from ``fr`` to ``to``.
|
||
|
||
``to`` is an internal page path or an external https exit URL. Every
|
||
navigation is logged (repeats included), keyed by its timestamp in
|
||
``Visit.navs``, so display-time aggregates can count views and
|
||
transitions; ``Visit.trail`` keeps the first-seen order.
|
||
"""
|
||
|
||
fr: str
|
||
to: str
|
||
|
||
|
||
class TrailItem(msgspec.Struct, omit_defaults=True):
|
||
"""One first-seen target in a visit trail: a page or external exit URL.
|
||
|
||
``read`` accumulates active reading time (seconds) across the whole
|
||
visit; ``status`` is the most recent HTTP status seen for the target.
|
||
A page seen in two rendered languages within one visit (a mid-article
|
||
language switch) gets one item per language.
|
||
"""
|
||
|
||
to: str
|
||
#: Accumulated active reading time in seconds.
|
||
read: int = 0
|
||
#: Most recent HTTP status of the response (200 or 404).
|
||
status: int = 200
|
||
#: Rendered language of the target: the client's report, for the entry
|
||
#: page falling back to its GET's rendered language; "" when unknown
|
||
#: (old clients or data from before language recording).
|
||
lang: str = ""
|
||
|
||
|
||
class Visit(msgspec.Struct, omit_defaults=True):
|
||
"""One visit: a client's activity since ``_SESSION_GAP`` of inactivity.
|
||
|
||
``trail`` holds the entry page and everything seen afterwards, keyed by
|
||
the timestamp of first sight (insertion order = first-seen order);
|
||
re-visiting an already seen target updates its item instead of
|
||
appending — unless the client reports a different rendered language for
|
||
it, which appends a distinct item (a mid-article language switch).
|
||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||
"""
|
||
|
||
start: datetime
|
||
entry: str
|
||
#: External https origin of the initial load, "" for direct visits.
|
||
referer: str = ""
|
||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||
client: bytes = b""
|
||
#: First-seen targets keyed by their timestamp (entry included).
|
||
trail: dict[datetime, TrailItem] = {}
|
||
#: Every navigation (repeats included) keyed by its timestamp; the
|
||
#: aggregates are computed from this log at display time.
|
||
navs: dict[datetime, Nav] = {}
|
||
#: 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 activity message.
|
||
|
||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||
"""
|
||
|
||
start: datetime
|
||
entry: str
|
||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||
client: bytes = b""
|
||
#: 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 = ""
|
||
#: HTTP status of the served response (200 or 404 for content pages).
|
||
status: int = 200
|
||
#: Rendered content language of the served document (from the GET).
|
||
lang: str = ""
|
||
|
||
|
||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||
"""A document GET from an IP classified as a scanner/abuser.
|
||
|
||
Unlike crawler hits the full request path (query string included) is
|
||
kept: the interesting part is exactly which paths were probed.
|
||
``flag`` marks the paths that triggered classification; ``is_404``
|
||
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
|
||
#: Full request path including the query string (e.g. "/.env?x=1").
|
||
path: str
|
||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||
client: bytes = b""
|
||
#: 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 real (200) document GETs.
|
||
is_404: bool = False
|
||
|
||
|
||
class Favicon(msgspec.Struct, omit_defaults=True):
|
||
"""Favicon fetch record for one external https origin.
|
||
|
||
The icon itself is stored on disk under a content-hashed name (like
|
||
uploads, but outside the kanta db), referenced here by ``file``; an
|
||
empty ``file`` is a known miss, retried after ``_FAVICON_RETRY``.
|
||
"""
|
||
|
||
#: Content-hashed file name of the stored icon, "" when the fetch failed.
|
||
file: str = ""
|
||
#: When the fetch was last attempted.
|
||
fetched: datetime | None = None
|
||
|
||
|
||
class Analytics(msgspec.Struct, omit_defaults=True):
|
||
"""Root of the analytics JSON file: the raw event log, append-only by
|
||
design. Old data is dropped by deleting list entries."""
|
||
|
||
#: Every document GET, in arrival order (see Get).
|
||
gets: list[Get] = []
|
||
#: Every activity message from pagerite.js, in arrival order (see Msg).
|
||
msgs: list[Msg] = []
|
||
#: Favicon fetch records keyed by external https origin.
|
||
favicons: dict[str, Favicon] = {}
|
||
#: Client metadata keyed by 6-byte blake3 hash.
|
||
clients: dict[bytes, Client] = {}
|
||
|
||
|
||
class Display(msgspec.Struct, omit_defaults=True):
|
||
"""The viewer payload: visible derived data plus display-time aggregates.
|
||
|
||
Hidden clients are excluded everywhere: their events are dropped, and
|
||
the aggregates are computed from the visible visits only.
|
||
The aggregate shapes match what the viewer consumes: sparse 5-minute
|
||
buckets keyed by their floored ISO timestamp.
|
||
"""
|
||
|
||
visits: list[Visit] = []
|
||
crawlers: list[CrawlerHit] = []
|
||
abuse: list[AbuseHit] = []
|
||
clients: dict[bytes, Client] = {}
|
||
#: origin -> URL path of the stored favicon ("/_f/<file>"),
|
||
#: only for origins whose icon was fetched successfully.
|
||
favicons: dict[str, str] = {}
|
||
#: Page transitions per 5-minute bucket (sparse):
|
||
#: from -> to -> bucket ISO -> count. ``from`` is the referer origin or
|
||
#: "(direct)" for initial loads, a page path for pings.
|
||
transitions: dict[str, dict[str, dict[str, int]]] = {}
|
||
#: Page views per 5-minute bucket: path -> bucket ISO -> count (sparse).
|
||
views: dict[str, dict[str, int]] = {}
|
||
#: New visits per 5-minute bucket: bucket ISO -> count (sparse).
|
||
site_visits: dict[str, int] = {}
|
||
#: Site context: true when translation languages are configured, so the
|
||
#: viewer can suppress language UI on single-language sites.
|
||
multilingual: bool = False
|
||
#: The site's primary language (the front page's), so the viewer can
|
||
#: skip the primary-language default case.
|
||
primary_lang: str = ""
|
||
|
||
|
||
def _bucket(now: datetime) -> str:
|
||
"""Start of the 5-minute interval containing ``now``, as ISO string."""
|
||
return now.replace(minute=now.minute // 5 * 5, second=0, microsecond=0).isoformat()
|
||
|
||
|
||
def _origin(url: str) -> str | None:
|
||
"""The origin part of an https URL (scheme://host[:port]), else None."""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except ValueError:
|
||
return None
|
||
if parsed.scheme != "https" or not parsed.netloc:
|
||
return None
|
||
return f"https://{parsed.netloc}"
|
||
|
||
|
||
def _external_target(url: str) -> str | None:
|
||
"""A valid https URL (origin or full page), else None."""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except ValueError:
|
||
return None
|
||
if parsed.scheme != "https" or not parsed.netloc:
|
||
return None
|
||
return url
|
||
|
||
|
||
_SEGMENT = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
||
|
||
|
||
def _internal_path(to: str) -> str | None:
|
||
"""A valid internal page path ("/" or slug segments), else None."""
|
||
path = to.split("?")[0].split("#")[0].strip("/")
|
||
if not path:
|
||
return "/"
|
||
if all(_SEGMENT.fullmatch(seg) for seg in path.split("/")):
|
||
return f"/{path}"
|
||
return None
|
||
|
||
|
||
def _parse_accept_language(value: str) -> tuple[str, str]:
|
||
"""First Accept-Language tag and the region/country subtag if present.
|
||
|
||
``en-US, fr;q=0.9`` -> ("en-us", "US"). Wildcards and missing regions
|
||
produce an empty country. The region is intentionally approximate:
|
||
it reflects the browser's language preference, not geo-location.
|
||
"""
|
||
if not value:
|
||
return "", ""
|
||
tag = value.split(",")[0].split(";")[0].strip()
|
||
if not tag or tag == "*":
|
||
return "", ""
|
||
lang = tag.lower()
|
||
country = ""
|
||
# Region subtags follow the initial language tag (en-US, zh-Hans-CN).
|
||
# A bare two-letter tag such as "fr" is a language code, not a region.
|
||
for part in reversed(tag.split("-")[1:]):
|
||
if len(part) == 2 and part.isalpha():
|
||
country = part.upper()
|
||
break
|
||
return lang, country
|
||
|
||
|
||
def _utm_tags(query: str) -> dict[str, str]:
|
||
"""UTM parameters from a query string, keeping only the first value."""
|
||
if not query:
|
||
return {}
|
||
parsed = parse_qs(query, keep_blank_values=True)
|
||
return {k: v[0] for k, v in parsed.items() if k.startswith("utm_")}
|
||
|
||
|
||
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
||
|
||
#: Inactivity after which a client's next navigation starts a new visit.
|
||
_SESSION_GAP = timedelta(minutes=30)
|
||
|
||
#: 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)
|
||
|
||
#: UAs of JS-running crawlers, which would register as visitors on their
|
||
#: activity messages. ``uarite`` knows the common crawlers
|
||
#: and link-preview fetchers (including disguised ones such as
|
||
#: facebookexternalhit and Google-Extended) plus any UA with a
|
||
#: bot/spider/crawler/scanner token. 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.
|
||
|
||
|
||
def _is_bot_ua(ua: str) -> bool:
|
||
"""True when the UA is not a regular browser.
|
||
|
||
Every real browser registers as ``kind == "browser"``; anything else
|
||
(recognized crawler/previewer, generic bot token, or an unclassified
|
||
HTTP client such as httpx) is not a visitor.
|
||
"""
|
||
return uaparse(ua).kind != "browser"
|
||
|
||
|
||
#: Plain-404 count per IP within ``_ABUSE_404_WINDOW`` that classifies it as
|
||
#: abuse even without a telltale path hit. Windowed so a long-time reader
|
||
#: slowly accumulating misses (deleted articles over months) never crosses
|
||
#: it — scanners spray their probes in bursts.
|
||
_ABUSE_404_THRESHOLD = 10
|
||
|
||
#: Sliding window the plain-404 threshold is counted over.
|
||
_ABUSE_404_WINDOW = timedelta(hours=1)
|
||
|
||
#: Paths that instantly classify an IP as abuse when they 404: an empty
|
||
#: segment ("//foo" — no real client requests those), any segment starting
|
||
#: with a dot ("/.env", "/.git/config") or ending in ".php".
|
||
_ABUSE_PATH = re.compile(r"/{2,}|(^|/)\.|\.php$", re.IGNORECASE)
|
||
|
||
|
||
def _is_abuse_path(path: str) -> bool:
|
||
"""Telltale scanner path: empty segment, dot segment or *.php."""
|
||
return bool(_ABUSE_PATH.search(path.split("?")[0]))
|
||
|
||
|
||
def _is_well_known(path: str) -> bool:
|
||
"""RFC 8615 well-known URI ("/.well-known/...").
|
||
|
||
Mostly legitimate (browsers and services probe them, e.g. Chrome's
|
||
devtools fetch of appspecific/com.chrome.devtools.json): never telltale
|
||
and never counted toward the plain-404 threshold.
|
||
"""
|
||
return path.split("?")[0].lower().startswith("/.well-known/")
|
||
|
||
|
||
def _network_ip(ip: str) -> str:
|
||
"""IPv4 address unchanged, IPv6 collapsed to its /64 network address.
|
||
|
||
We hash the network rather than the full address so that clients in the
|
||
same /64 (a typical end-user allocation) are treated as one visitor.
|
||
"""
|
||
if not ip:
|
||
return ip
|
||
try:
|
||
addr = ipaddress.ip_address(ip)
|
||
except ValueError:
|
||
return ip
|
||
if isinstance(addr, ipaddress.IPv6Address):
|
||
return str(ipaddress.IPv6Network(f"{ip}/64", strict=False).network_address)
|
||
return ip
|
||
|
||
|
||
def _client_hash(ip: str, ua: str, lang: str) -> bytes:
|
||
"""6-byte blake3 digest identifying a visitor/client tuple.
|
||
|
||
The key is the prettified IP (IPv6 /64), the raw UA string and the
|
||
extracted language tag, separated by null bytes.
|
||
"""
|
||
return blake3.blake3(f"{_network_ip(ip)}\0{ua}\0{lang}".encode()).digest()[:6]
|
||
|
||
|
||
class Store:
|
||
"""In-memory analytics data: the raw event log plus its JSON persistence."""
|
||
|
||
def __init__(self, path: Path) -> None:
|
||
self.path = path
|
||
self.data = Analytics()
|
||
if path.exists():
|
||
try:
|
||
raw = path.read_bytes()
|
||
except OSError:
|
||
raw = b""
|
||
# The pre-redesign schema (stored visits/crawlers/abuse lists) is
|
||
# not convertible: set it aside and start fresh.
|
||
legacy = b'"visits"' in raw
|
||
if raw and not legacy:
|
||
try:
|
||
self.data = msgspec.json.decode(raw, type=Analytics)
|
||
except msgspec.DecodeError:
|
||
legacy = True # corrupt file: start fresh
|
||
if legacy:
|
||
with suppress(OSError):
|
||
path.rename(path.with_name(path.name + ".bak-legacy"))
|
||
#: Callables to notify when persisted data changes. Registered by the
|
||
#: analytics WebSocket broadcaster.
|
||
self._on_change: list[Callable[[], None]] = []
|
||
|
||
def subscribe(self, callback: Callable[[], None]) -> None:
|
||
"""Register a callback to be called after every persisted change."""
|
||
if callback not in self._on_change:
|
||
self._on_change.append(callback)
|
||
|
||
def unsubscribe(self, callback: Callable[[], None]) -> None:
|
||
"""Remove a previously registered change callback."""
|
||
with suppress(ValueError):
|
||
self._on_change.remove(callback)
|
||
|
||
def _notify(self) -> None:
|
||
for callback in self._on_change:
|
||
callback()
|
||
|
||
def _save(self) -> None:
|
||
"""Rewrite the JSON file atomically (temp file + rename)."""
|
||
try:
|
||
fd, tmp = tempfile.mkstemp(
|
||
dir=self.path.parent, prefix=self.path.name, suffix=".tmp"
|
||
)
|
||
with os.fdopen(fd, "wb") as f:
|
||
f.write(msgspec.json.encode(self.data))
|
||
os.replace(tmp, self.path)
|
||
except OSError:
|
||
pass # analytics must never break page serving
|
||
else:
|
||
self._notify()
|
||
|
||
def _hidden(self, client_hash: bytes) -> bool:
|
||
"""True when the client record is flagged hidden (admin)."""
|
||
client = self.data.clients.get(client_hash)
|
||
return client is not None and client.hide
|
||
|
||
def _ensure_client(
|
||
self,
|
||
ip: str,
|
||
ua: str,
|
||
lang: str,
|
||
*,
|
||
country: str = "",
|
||
) -> bytes:
|
||
"""Get or create a ``Client`` record; return its 6-byte hash."""
|
||
h = _client_hash(ip, ua, lang)
|
||
if h not in self.data.clients:
|
||
self.data.clients[h] = Client(
|
||
ip=ip,
|
||
ua=ua,
|
||
lang=lang,
|
||
country=country,
|
||
)
|
||
self._save()
|
||
return h
|
||
|
||
def enrich_client(
|
||
self,
|
||
client_hash: bytes,
|
||
*,
|
||
host: str = "",
|
||
country: str = "",
|
||
city: str = "",
|
||
) -> None:
|
||
"""Fill in host/geoip fields on a client record after async lookups."""
|
||
client = self.data.clients.get(client_hash)
|
||
if client is None:
|
||
return
|
||
changed = False
|
||
if host and not client.host:
|
||
client.host = host
|
||
changed = True
|
||
if country:
|
||
client.country = country
|
||
changed = True
|
||
if city:
|
||
client.city = city
|
||
changed = True
|
||
if changed:
|
||
self._save()
|
||
|
||
def record_get(
|
||
self,
|
||
ip: str,
|
||
ua: str,
|
||
path: str,
|
||
*,
|
||
status: int = 200,
|
||
referer: str = "",
|
||
accept_language: str = "",
|
||
pre: bool = False,
|
||
lang: str = "",
|
||
) -> bytes | None:
|
||
"""Append one document GET to the raw log.
|
||
|
||
``path`` is the full request path, query string included; ``status``
|
||
the true HTTP status of the response; ``referer`` the raw Referer
|
||
header (reduced here to an external https origin, "" when internal
|
||
or absent); ``pre`` marks idle-time preloads from pagerite.js;
|
||
``lang`` the rendered content language of the served document (""
|
||
for non-localized responses such as 404 probes and reserved paths).
|
||
|
||
Returns the client hash when the client record was just created (so
|
||
the caller can schedule async enrichment), else None.
|
||
"""
|
||
client_lang, country = _parse_accept_language(accept_language)
|
||
client_hash = _client_hash(ip, ua, client_lang)
|
||
new = client_hash not in self.data.clients
|
||
if new:
|
||
self._ensure_client(ip, ua, client_lang, country=country)
|
||
self.data.gets.append(
|
||
Get(
|
||
t=datetime.now(UTC),
|
||
path=path,
|
||
client=client_hash,
|
||
status=status,
|
||
ref=_origin(referer) or "",
|
||
pre=pre,
|
||
lang=lang,
|
||
)
|
||
)
|
||
self._save()
|
||
return client_hash if new else None
|
||
|
||
def record_msg(
|
||
self,
|
||
fr: str,
|
||
to: str | None,
|
||
ip: str,
|
||
ua: str,
|
||
accept_language: str = "",
|
||
hide: bool = False,
|
||
read: int = 0,
|
||
lang: str = "",
|
||
) -> bytes | None:
|
||
"""Append one client activity message (``Ping`` from pagerite.js) to
|
||
the raw log.
|
||
|
||
``to`` is validated here — internal slug path or external https URL,
|
||
anything else is dropped; that is sanitation, not classification.
|
||
No abuse/bot filtering happens at record time: those messages are
|
||
stored raw and filtered at display time, so future rule changes lose
|
||
nothing. ``hide`` flags the client record as an admin; the message
|
||
itself is recorded normally and hidden at display time like
|
||
everything else the client ever did. ``lang`` is the rendered
|
||
language reported by the client for the page the activity happened
|
||
on.
|
||
|
||
Returns the client hash when the client record was just created (so
|
||
the caller can schedule async enrichment), else None.
|
||
"""
|
||
client_lang, country = _parse_accept_language(accept_language)
|
||
client_hash = _client_hash(ip, ua, client_lang)
|
||
new = client_hash not in self.data.clients
|
||
if new:
|
||
self._ensure_client(ip, ua, client_lang, country=country)
|
||
if hide:
|
||
self.data.clients[client_hash].hide = True
|
||
fr = (_internal_path(fr) or "") if fr else ""
|
||
target = ""
|
||
if to:
|
||
if to.startswith("/") and not to.startswith("//"):
|
||
target = _internal_path(to) or ""
|
||
else:
|
||
target = _external_target(to) or ""
|
||
if target or read > 0:
|
||
self.data.msgs.append(
|
||
Msg(
|
||
t=datetime.now(UTC),
|
||
client=client_hash,
|
||
fr=fr,
|
||
to=target,
|
||
read=read,
|
||
lang=lang,
|
||
)
|
||
)
|
||
if target or read > 0 or hide:
|
||
self._save()
|
||
return client_hash if new else None
|
||
|
||
def favicon_origins_needed(self) -> list[str]:
|
||
"""External https origins seen in the raw events whose favicon needs
|
||
fetching.
|
||
|
||
Covers referer origins of document GETs and external exit targets of
|
||
activity messages — spiders often advertise their own site as the
|
||
referer, so the icon identifies them in the crawler table. Hidden
|
||
(admin) clients' events never trigger fetches. Origins with a
|
||
stored icon, or a miss younger than ``_FAVICON_RETRY``, are skipped.
|
||
"""
|
||
origins: set[str] = set()
|
||
for g in self.data.gets:
|
||
if g.ref and not self._hidden(g.client):
|
||
origins.add(g.ref)
|
||
for m in self.data.msgs:
|
||
origin = _origin(m.to)
|
||
if origin is not None and not self._hidden(m.client):
|
||
origins.add(origin)
|
||
now = datetime.now(UTC)
|
||
return [
|
||
origin
|
||
for origin in origins
|
||
if (f := self.data.favicons.get(origin)) is None
|
||
or (not f.file and (f.fetched is None or now - f.fetched > _FAVICON_RETRY))
|
||
]
|
||
|
||
def record_favicon(self, origin: str, file: str = "") -> None:
|
||
"""Store the favicon fetch result for ``origin`` ("" = miss)."""
|
||
self.data.favicons[origin] = Favicon(file=file, fetched=datetime.now(UTC))
|
||
self._save()
|
||
|
||
def display(
|
||
self,
|
||
in_menu: Callable[[str], bool] | None = None,
|
||
*,
|
||
multilingual: bool = False,
|
||
primary_lang: str = "",
|
||
) -> Display:
|
||
"""Build the viewer payload from the raw events.
|
||
|
||
All classification happens here, so the stored data is independent
|
||
of the rules:
|
||
|
||
- abuse IPs: a 404 on a telltale path (empty segment ``//foo``, dot
|
||
segment or ``*.php`` — but never ``/.well-known/…``, which is
|
||
mostly legitimate browser probing and counts neither as telltale
|
||
nor toward the threshold), or ``_ABUSE_404_THRESHOLD`` plain 404s
|
||
within ``_ABUSE_404_WINDOW`` on paths that don't resolve to a
|
||
menu node (``in_menu``; category placeholders return 404 but are
|
||
real nodes and never count). Hidden clients never classify.
|
||
Every non-preload GET from such an IP becomes an abuse row; its
|
||
messages are ignored.
|
||
- crawler hits: non-preload GETs no activity message matched within
|
||
``_CRAWLER_TIMEOUT``, plus visits reclassified as real-browser
|
||
bots (under ``_MIN_VISIT_READ`` seconds of total reading time).
|
||
Messages from bot-UAs are ignored, so their GETs never match.
|
||
- visits: the remaining messages, grouped per client with a new
|
||
visit after ``_SESSION_GAP`` of inactivity. Trail statuses come
|
||
from the client's GETs (preloads included — a cache-served
|
||
navigation's only GET is its preload); trail languages come from
|
||
the client's messages (the entry item falling back to its GET's
|
||
rendered language), and a page re-visited in a different rendered
|
||
language becomes a distinct trail step. The entry referer and
|
||
UTM tags come from the GET that loaded the entry page.
|
||
|
||
Hidden (admin) clients are excluded from every list and aggregate.
|
||
``multilingual`` and ``primary_lang`` are site context (translation
|
||
languages configured, the front page's primary language) copied
|
||
onto the payload so the viewer can suppress language UI on
|
||
single-language sites and skip the primary-language default case.
|
||
"""
|
||
in_menu = in_menu or (lambda path: False)
|
||
data = self.data
|
||
ip_of = {h: c.ip for h, c in data.clients.items()}
|
||
|
||
# --- abuse classification: each IP's document GETs, chronologically.
|
||
# Hidden (admin) clients never classify: editing means visiting
|
||
# not-found pages (that is where the create pen lives).
|
||
gets_by_ip: dict[str, list[Get]] = {}
|
||
for g in data.gets:
|
||
if not g.pre and not self._hidden(g.client):
|
||
gets_by_ip.setdefault(ip_of.get(g.client, ""), []).append(g)
|
||
menu_cache: dict[str, bool] = {}
|
||
|
||
def real_node(path: str) -> bool:
|
||
if path not in menu_cache:
|
||
menu_cache[path] = in_menu(path)
|
||
return menu_cache[path]
|
||
|
||
abuse_ips: set[str] = set()
|
||
flag_ids: set[int] = set()
|
||
for ip, gets in gets_by_ip.items():
|
||
gets.sort(key=lambda g: g.t)
|
||
telltale = False
|
||
recent: list[Get] = [] # plain 404s inside the sliding window
|
||
for g in gets:
|
||
if g.status != 404:
|
||
continue
|
||
path = g.path.split("?")[0]
|
||
if _is_well_known(path):
|
||
continue # legitimate probes, never abuse evidence
|
||
if _is_abuse_path(path):
|
||
telltale = True
|
||
flag_ids.add(id(g))
|
||
elif not real_node(path):
|
||
recent.append(g)
|
||
while g.t - recent[0].t > _ABUSE_404_WINDOW:
|
||
recent.pop(0)
|
||
if len(recent) == _ABUSE_404_THRESHOLD:
|
||
flag_ids.add(id(g))
|
||
if telltale or len(recent) >= _ABUSE_404_THRESHOLD:
|
||
abuse_ips.add(ip)
|
||
|
||
# --- per-client event lists, chronological
|
||
msgs_by_client: dict[bytes, list[Msg]] = {}
|
||
for m in data.msgs:
|
||
msgs_by_client.setdefault(m.client, []).append(m)
|
||
for msgs in msgs_by_client.values():
|
||
msgs.sort(key=lambda m: m.t)
|
||
gets_by_client: dict[bytes, list[Get]] = {}
|
||
for g in data.gets:
|
||
gets_by_client.setdefault(g.client, []).append(g)
|
||
for gets in gets_by_client.values():
|
||
gets.sort(key=lambda g: g.t)
|
||
|
||
def status_at(client_hash: bytes, path: str, t: datetime) -> int:
|
||
"""Latest status served to the client for ``path`` at or before ``t``."""
|
||
gets = gets_by_client.get(client_hash)
|
||
if not gets:
|
||
return 200
|
||
i = bisect_right(gets, t, key=lambda g: g.t)
|
||
for g in reversed(gets[:i]):
|
||
if g.path.split("?")[0] == path:
|
||
return g.status
|
||
return 200
|
||
|
||
def entry_get(client_hash: bytes, path: str, t: datetime) -> Get | None:
|
||
"""The GET that loaded ``path`` just before the message at ``t``."""
|
||
gets = gets_by_client.get(client_hash)
|
||
if not gets:
|
||
return None
|
||
i = bisect_right(gets, t, key=lambda g: g.t)
|
||
for g in reversed(gets[:i]):
|
||
if g.t < t - _CRAWLER_TIMEOUT:
|
||
break
|
||
if not g.pre and g.path.split("?")[0] == path:
|
||
return g
|
||
return None
|
||
|
||
# --- visits: group each visible, non-abuse, non-bot client's messages
|
||
visits: list[Visit] = []
|
||
for h, msgs in msgs_by_client.items():
|
||
if self._hidden(h) or ip_of.get(h, "") in abuse_ips:
|
||
continue
|
||
client = data.clients.get(h)
|
||
if client is not None and _is_bot_ua(client.ua):
|
||
continue
|
||
visit: Visit | None = None
|
||
last_t: datetime | None = None
|
||
for m in msgs:
|
||
if m.to:
|
||
if visit is None or (
|
||
last_t is not None and m.t - last_t > _SESSION_GAP
|
||
):
|
||
# First navigation ever, or after a long silence:
|
||
# start a visit. The entry referer/UTM tags come
|
||
# from the GET that loaded the entry page. Only an
|
||
# internal page load can open a visit — an external
|
||
# exit without an open visit is dropped.
|
||
if not m.to.startswith("/"):
|
||
last_t = m.t
|
||
continue
|
||
visit = Visit(start=m.t, entry=m.to, client=h)
|
||
g = entry_get(h, m.to, m.t)
|
||
if g is not None:
|
||
visit.referer = g.ref
|
||
visit.utm = _utm_tags(
|
||
g.path.split("?", 1)[1] if "?" in g.path else ""
|
||
)
|
||
visit.trail[m.t] = TrailItem(
|
||
to=m.to,
|
||
status=status_at(h, m.to, m.t),
|
||
# The client's report wins; the entry GET fills
|
||
# in for old clients that don't send lang.
|
||
lang=m.lang or (g.lang if g is not None else ""),
|
||
)
|
||
visits.append(visit)
|
||
else:
|
||
fr = m.fr or "(direct)"
|
||
visit.navs[m.t] = Nav(fr=fr, to=m.to)
|
||
status = status_at(h, m.to, m.t)
|
||
# First-seen only: repeat pages and repeated exits
|
||
# update the existing trail item instead of
|
||
# appending — but a repeat in a different rendered
|
||
# language (a mid-article language switch) becomes
|
||
# a distinct step.
|
||
for item in visit.trail.values():
|
||
if item.to == m.to:
|
||
if m.lang and item.lang and m.lang != item.lang:
|
||
visit.trail[m.t] = TrailItem(
|
||
to=m.to, status=status, lang=m.lang
|
||
)
|
||
else:
|
||
item.status = status
|
||
if not item.lang:
|
||
item.lang = m.lang
|
||
break
|
||
else:
|
||
visit.trail[m.t] = TrailItem(
|
||
to=m.to, status=status, lang=m.lang
|
||
)
|
||
if m.read > 0 and m.fr and visit is not None:
|
||
# A page appears in the trail once per language seen:
|
||
# land the seconds on the matching-language step when
|
||
# the client reports one, else on the first-seen item.
|
||
read_item: TrailItem | None = None
|
||
for item in visit.trail.values():
|
||
if item.to != m.fr:
|
||
continue
|
||
if read_item is None:
|
||
read_item = item
|
||
if m.lang and item.lang == m.lang:
|
||
read_item = item
|
||
break
|
||
if read_item is not None:
|
||
read_item.read += m.read
|
||
last_t = m.t
|
||
|
||
# --- crawler hits: document GETs no message matched
|
||
crawlers: list[CrawlerHit] = []
|
||
msg_times = {h: [m.t for m in msgs] for h, msgs in msgs_by_client.items()}
|
||
for g in data.gets:
|
||
if g.pre or self._hidden(g.client):
|
||
continue
|
||
if ip_of.get(g.client, "") in abuse_ips:
|
||
continue
|
||
client = data.clients.get(g.client)
|
||
if client is None or not _is_bot_ua(client.ua):
|
||
msgs = msgs_by_client.get(g.client, [])
|
||
times = msg_times.get(g.client, [])
|
||
stripped = g.path.split("?")[0]
|
||
matched = False
|
||
for m in msgs[bisect_left(times, g.t) :]:
|
||
if m.t - g.t > _CRAWLER_TIMEOUT:
|
||
break
|
||
if m.to == stripped:
|
||
matched = True
|
||
break
|
||
if matched:
|
||
continue
|
||
entry, _, query = g.path.partition("?")
|
||
crawlers.append(
|
||
CrawlerHit(
|
||
start=g.t,
|
||
entry=entry,
|
||
client=g.client,
|
||
referer=g.ref,
|
||
query=query,
|
||
status=g.status,
|
||
lang=g.lang,
|
||
)
|
||
)
|
||
|
||
# --- real-browser bots: visits with too little reading time become
|
||
# crawler hits (one per internal trail page) and count nowhere
|
||
kept: list[Visit] = []
|
||
for visit in visits:
|
||
if sum(item.read for item in visit.trail.values()) >= _MIN_VISIT_READ:
|
||
kept.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,
|
||
lang=item.lang,
|
||
)
|
||
)
|
||
first = False
|
||
|
||
display = Display(
|
||
visits=kept,
|
||
crawlers=crawlers,
|
||
abuse=[
|
||
AbuseHit(
|
||
start=g.t,
|
||
path=g.path,
|
||
client=g.client,
|
||
flag=id(g) in flag_ids,
|
||
is_404=g.status != 200,
|
||
)
|
||
for g in data.gets
|
||
if not g.pre
|
||
and not self._hidden(g.client)
|
||
and ip_of.get(g.client, "") in abuse_ips
|
||
],
|
||
clients={h: _display_client(c) for h, c in data.clients.items() if not c.hide},
|
||
favicons={
|
||
origin: f"/_f/{f.file}"
|
||
for origin, f in data.favicons.items()
|
||
if f.file
|
||
},
|
||
multilingual=multilingual,
|
||
primary_lang=primary_lang,
|
||
)
|
||
for visit in kept:
|
||
bucket = _bucket(visit.start)
|
||
site = display.site_visits
|
||
site[bucket] = site.get(bucket, 0) + 1
|
||
entry_views = display.views.setdefault(visit.entry, {})
|
||
entry_views[bucket] = entry_views.get(bucket, 0) + 1
|
||
fr = visit.referer or "(direct)"
|
||
buckets = display.transitions.setdefault(fr, {}).setdefault(visit.entry, {})
|
||
buckets[bucket] = buckets.get(bucket, 0) + 1
|
||
for t, nav in visit.navs.items():
|
||
nb = _bucket(t)
|
||
if nav.to.startswith("/"):
|
||
nav_views = display.views.setdefault(nav.to, {})
|
||
nav_views[nb] = nav_views.get(nb, 0) + 1
|
||
nbuckets = display.transitions.setdefault(nav.fr, {}).setdefault(
|
||
nav.to, {}
|
||
)
|
||
nbuckets[nb] = nbuckets.get(nb, 0) + 1
|
||
return display
|
||
|
||
def display_json(
|
||
self,
|
||
in_menu: Callable[[str], bool] | None = None,
|
||
*,
|
||
multilingual: bool = False,
|
||
primary_lang: str = "",
|
||
) -> str:
|
||
"""The ``display()`` payload as a JSON string for the WebSocket."""
|
||
return msgspec.json.encode(
|
||
self.display(in_menu, multilingual=multilingual, primary_lang=primary_lang)
|
||
).decode()
|