From 462e995adc33628972b8ea6bcda567cc6a92f08e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 21 Aug 2026 02:44:54 +0000 Subject: [PATCH] Add external link (referer/outgoing) display on connection graph. --- docs/analytics.md | 8 ++-- frontend/src/AnalyticsView.vue | 5 ++- frontend/src/TransitionGraph.vue | 20 ++++----- frontend/src/analytics/format.js | 55 ++++++++++++++++-------- frontend/src/analytics/transitions.js | 60 +++++++++++++++++---------- frontend/src/pagerite.js | 6 +-- pagerite/analytics.py | 23 +++++++--- 7 files changed, 114 insertions(+), 63 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index efd9358..40783de 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -32,9 +32,11 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with - **Internal fetch-navigations**: `to` is the target path, sent only after the swap actually happened (a failed swap falls back to a full load, whose initial ping counts the view instead — no gap, no double count). -- **External links** (`https` only): `to` is the link's origin. This is the +- **External links** (`https` only): `to` is the link's full URL. This is the exit-link record; the user may continue navigating afterwards (new tab, - back), so the exit origin is not necessarily the last trail entry. + back), so the exit URL is not necessarily the last trail entry. Outbound + links are stored by full URL so several links to the same domain remain + distinct. - **Excluded**: back/forward (popstate) navigations, navigation involving the analytics page itself (`/_a`), and everything while the user is known to be an admin *and SSO is actually in use* — with no auth proxy (dev/test) @@ -83,7 +85,7 @@ Each `Visit` record: - `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer), - `host` — reverse-DNS host name for `ip` when resolvable, else `""`, - `trail` — everything seen afterwards in first-seen order: page paths and - external exit origins. Re-visiting an already seen page (incl. the entry) + external exit URLs. Re-visiting an already seen page (incl. the entry) does not append. - `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`), - `country` — two-letter country code. Initially derived from the diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 607382d..d4de75f 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -146,7 +146,10 @@ function countryName(code) { {{ v.when }} + :href="s.path" :title="s.title" + :target="s.external ? '_blank' : undefined" + :rel="s.external ? 'noopener' : undefined" + @click="(e) => { if (!s.external) $emit('close') }"> {{ s.slug }} diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index 6cb253d..bb58669 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -109,10 +109,12 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) - - {{ x.path }} - - {{ x.label }} + + + {{ x.label }} + {{ x.count }} + @@ -144,14 +146,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) } .tmap .txnode { fill: var(--bg, Canvas); - stroke: var(--muted); - stroke-width: 1; -} -.tmap .txlabel { - fill: var(--muted); - font-size: 9px; - text-anchor: middle; + stroke-width: 1.5; } +.tmap .txnode-source { stroke: var(--text); } +.tmap .txnode-exit { stroke: var(--muted); } .tmap .tarc { fill: none; stroke: var(--line); diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 281add5..faabba9 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -62,6 +62,31 @@ function slugOf(path) { return path === '/' ? '🏠' : path.split('/').pop() } +/** Host name of an external https origin, with scheme stripped. */ +function externalSlug(origin) { + try { + return new URL(origin).host + } catch { + return origin.replace(/^https?:\/\//, '') + } +} + +/** Format one trail step: an internal page or an external https origin. */ +function stepOf(path, titles) { + if (path?.startsWith('/')) { + return { path, slug: slugOf(path), title: titles.get(path) || '', external: false } + } + if (path?.startsWith('https://')) { + return { + path, + slug: externalSlug(path), + title: 'External site', + external: true, + } + } + return null +} + /** * Human-readable relative timestamp. Adapted from cista-storage: uses * ``Intl.RelativeTimeFormat`` for short intervals and a compact date for @@ -117,8 +142,9 @@ export function formatWhenTooltip(ts) { /** * Format recent visits for display, newest first. Each step is a linked slug - * pointing to its article; external referers/origins and direct entries are - * omitted. The link title shows the article heading when known. + * pointing to its article; external referers/origins are shown as their + * domain name with the full origin as the link href. The link title shows the + * article heading when known, or "External site" for origins. */ export function formatRecentVisits(visits, pageTree, limit = 50) { const titles = buildTitleMap(pageTree) @@ -126,13 +152,9 @@ export function formatRecentVisits(visits, pageTree, limit = 50) { .reverse() .map((v) => ({ when: new Date(v.start).toLocaleString(), - steps: [v.entry, ...(v.trail || [])] - .filter((p) => p?.startsWith('/')) - .map((p) => ({ - path: p, - slug: slugOf(p), - title: titles.get(p) || '', - })), + steps: [v.referer, v.entry, ...(v.trail || [])] + .map((p) => stepOf(p, titles)) + .filter(Boolean), })) .filter((v) => v.steps.length) .slice(0, limit) @@ -239,19 +261,16 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) { /** * Format raw visit records as rows for a technical table. Returns objects - * with display strings; missing values become "—". ``trail`` joins page - * titles (when known) with " -> ". Only the 20 most recent visits are shown. + * with display strings; missing values become "—". ``trail`` starts with the + * external referer (when present), then the entry page and any further internal + * pages or external exit origins. Only the 20 most recent visits are shown. */ export function formatVisitRows(visits, pageTree, now = Date.now()) { const titles = buildTitleMap(pageTree) return [...(visits || [])].reverse().slice(0, 20).map((v) => { - const trail = [v.entry, ...(v.trail || [])] - .filter((p) => p?.startsWith('/')) - .map((p) => ({ - path: p, - slug: slugOf(p), - title: titles.get(p) || '', - })) + const trail = [v.referer, v.entry, ...(v.trail || [])] + .map((p) => stepOf(p, titles)) + .filter(Boolean) const utm = Object.entries(v.utm || {}) .map(([k, value]) => `${k}=${value}`) .join(', ') diff --git a/frontend/src/analytics/transitions.js b/frontend/src/analytics/transitions.js index fb77c59..36397c8 100644 --- a/frontend/src/analytics/transitions.js +++ b/frontend/src/analytics/transitions.js @@ -14,12 +14,13 @@ * emitted at time intervals inversely proportional (linear) to the * directional count. * External referers appear as nodes in a row above the map, external exits - * as small nodes just outside their source page, angled away from the - * center. Self-loops (reload pings) are skipped. + * as full-size nodes just outside their source page, angled away from the + * center. Each distinct full exit URL is its own node. Self-loops (reload + * pings) are skipped. */ export const TNODE_R = 34 // node circles hold the slug and the view count -export const EXT_R = 16 // external referer/exit nodes +export const EXT_R = 34 // external referer/exit nodes use the same full size // Edge width (half-width of the thin middle) grows logarithmically with // the count, anchored so a single recorded transition renders as a ~1 px @@ -86,7 +87,7 @@ function collectInternalTransitions(transitions) { /** Short display label for an external origin (protocol stripped). */ function extLabel(ext) { const s = ext.replace(/^https?:\/\//, '') - return s.length > 18 ? `${s.slice(0, 17)}…` : s + return s.length > 11 ? `${s.slice(0, 10)}…` : s } /** @@ -541,7 +542,8 @@ function buildExternal(external, byPath, radius, innerBounds) { const spacing = 2 * EXT_R + 44 const x0 = cx - ((origins.length - 1) * spacing) / 2 origins.forEach(({ ext, ps }, i) => { - const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R } + const total = ps.reduce((s, p) => s + p.in, 0) + const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: 'source' } extNodes.push(xn) for (const p of ps) { const page = byPath.get(p.page) @@ -551,30 +553,46 @@ function buildExternal(external, byPath, radius, innerBounds) { }) } - // Outgoing: small exit nodes fanned outwards from the source page. + // Outgoing: group by full URL so several links to the same domain stay + // distinct. Each exit node is placed one ring-gap outside its source page + // (same radial spacing internal rings use), fanned around the source angle, + // and shows the total count across all pages that link to that URL. + const GAP = radius(1) - radius(0) const outgoing = live.filter((p) => p.out >= minCount) - .sort((a, b) => b.out - a.out).slice(0, MAX_EXT_OUT) + .sort((a, b) => b.out - a.out) const perPage = new Map() + const selected = [] for (const p of outgoing) { const used = perPage.get(p.page) || 0 if (used >= MAX_EXT_OUT_PER_PAGE) continue perPage.set(p.page, used + 1) + selected.push(p) + if (selected.length >= MAX_EXT_OUT) break + } + + const exitNodes = new Map() // full URL -> node + const placedPerPage = new Map() // for angle fanning of the placement anchor + for (const p of selected) { const page = byPath.get(p.page) - // Fan multiple exits of one page symmetrically around the outward - // direction; the center page has no angle, so its exits point down - // (the top row above the map belongs to referers). - const base = page.depth ? page.angle : Math.PI / 2 - const ang = base + [0, 0.4, -0.4][used] - let dist = TNODE_R + 40 - let x = page.x + Math.cos(ang) * dist - let y = page.y + Math.sin(ang) * dist - for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) { - dist += 24 - x = page.x + Math.cos(ang) * dist - y = page.y + Math.sin(ang) * dist + let xn = exitNodes.get(p.ext) + if (!xn) { + const used = placedPerPage.get(p.page) || 0 + placedPerPage.set(p.page, used + 1) + const base = page.depth ? page.angle : Math.PI / 2 + const ang = base + [0, 0.4, -0.4][used] + let dist = GAP + let x = page.x + Math.cos(ang) * dist + let y = page.y + Math.sin(ang) * dist + for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) { + dist += GAP * 0.3 + x = page.x + Math.cos(ang) * dist + y = page.y + Math.sin(ang) * dist + } + xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R, count: 0, kind: 'exit' } + exitNodes.set(p.ext, xn) + extNodes.push(xn) } - const xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R } - extNodes.push(xn) + xn.count += p.out edges.push(buildRibbon(page, xn, p.out, 0, width(p.out), TNODE_R, EXT_R)) flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0)) } diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index fb383f1..c2645bc 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -536,9 +536,9 @@ import "overlayscrollbars/overlayscrollbars.css"; if (!a || a.target || a.hasAttribute("download")) return; const url = new URL(a.href, location.href); if (url.origin !== location.origin) { - // External link: the browser navigates; just record the exit (https - // origins only, stripped to the origin part server-side anyway). - if (url.protocol === "https:") ping(url.origin); + // External link: the browser navigates; record the full https URL so + // different links to the same domain stay distinct in analytics. + if (url.protocol === "https:") ping(url.href); return; } // Same-page anchor links (footnotes etc.): let the browser handle them diff --git a/pagerite/analytics.py b/pagerite/analytics.py index 710f928..2ce8bd0 100644 --- a/pagerite/analytics.py +++ b/pagerite/analytics.py @@ -52,7 +52,7 @@ def _compact_user_agent(ua: str) -> str: class Visit(msgspec.Struct, omit_defaults=True): """One visit: the initial-load data plus everything seen afterwards. - ``trail`` holds page paths and external exit origins in first-seen + ``trail`` holds page paths and external exit URLs in first-seen order; re-visiting an already seen page does not append. The entry page itself is in ``entry``, not in the trail. """ @@ -129,6 +129,17 @@ def _origin(url: str) -> str | 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_-]*") @@ -364,9 +375,9 @@ class Store: ) -> int | None: """Record a client navigation ping ({from, to} from pagerite.js). - ``to`` is an internal path ("/...") or an https origin for exit - links; anything else is ignored. The transition is always counted; - the trail only grows on first sight of a page within the visit. + ``to`` is an internal path ("/...") or an https URL for exit links; + anything else is ignored. The transition is always counted; the trail + only grows on first sight of a page within the visit. A ping with no known session starts a fresh visit, consuming the referer and UTM tags stashed by the document GET if there are any. @@ -382,8 +393,8 @@ class Store: if to.startswith("/") and not to.startswith("//"): target = _internal_path(to) or "" else: - target = _origin(to) or "" - if not target or (not to.startswith("/") and target != to): + target = _external_target(to) or "" + if not target: return None key = (ip, ua) index = self.sessions.get(key)