Add external link (referer/outgoing) display on connection graph.
This commit is contained in:
+5
-3
@@ -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
|
- **Internal fetch-navigations**: `to` is the target path, sent only after
|
||||||
the swap actually happened (a failed swap falls back to a full load,
|
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).
|
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,
|
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
|
- **Excluded**: back/forward (popstate) navigations, navigation involving
|
||||||
the analytics page itself (`/_a`), and everything while the user is known to
|
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)
|
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),
|
- `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer),
|
||||||
- `host` — reverse-DNS host name for `ip` when resolvable, else `""`,
|
- `host` — reverse-DNS host name for `ip` when resolvable, else `""`,
|
||||||
- `trail` — everything seen afterwards in first-seen order: page paths and
|
- `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.
|
does not append.
|
||||||
- `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`),
|
- `lang` — first `Accept-Language` tag, lowercased (e.g. `en-us`),
|
||||||
- `country` — two-letter country code. Initially derived from the
|
- `country` — two-letter country code. Initially derived from the
|
||||||
|
|||||||
@@ -146,7 +146,10 @@ function countryName(code) {
|
|||||||
<td class="when" :title="v.whenTooltip">{{ v.when }}</td>
|
<td class="when" :title="v.whenTooltip">{{ v.when }}</td>
|
||||||
<td class="trail">
|
<td class="trail">
|
||||||
<a v-for="(s, si) in v.trail" :key="si"
|
<a v-for="(s, si) in v.trail" :key="si"
|
||||||
:href="s.path" :title="s.title" @click="$emit('close')">
|
: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 }}
|
{{ s.slug }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -109,10 +109,12 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||||
<circle :cx="x.x" :cy="x.y" :r="x.r" class="txnode">
|
<a :href="x.path" target="_blank" rel="noopener" :title="x.path">
|
||||||
<title>{{ x.path }}</title>
|
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||||
</circle>
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text>
|
<text :x="x.x" :y="x.y - 2" class="tnodeslug">{{ x.label }}</text>
|
||||||
|
<text :x="x.x" :y="x.y + 12" class="tnodecount">{{ x.count }}</text>
|
||||||
|
</a>
|
||||||
</g>
|
</g>
|
||||||
<g v-for="n in graph.nodes" :key="n.path">
|
<g v-for="n in graph.nodes" :key="n.path">
|
||||||
<a :href="n.path" :title="n.title">
|
<a :href="n.path" :title="n.title">
|
||||||
@@ -144,14 +146,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
}
|
}
|
||||||
.tmap .txnode {
|
.tmap .txnode {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--bg, Canvas);
|
||||||
stroke: var(--muted);
|
stroke-width: 1.5;
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
.tmap .txlabel {
|
|
||||||
fill: var(--muted);
|
|
||||||
font-size: 9px;
|
|
||||||
text-anchor: middle;
|
|
||||||
}
|
}
|
||||||
|
.tmap .txnode-source { stroke: var(--text); }
|
||||||
|
.tmap .txnode-exit { stroke: var(--muted); }
|
||||||
.tmap .tarc {
|
.tmap .tarc {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--line);
|
stroke: var(--line);
|
||||||
|
|||||||
@@ -62,6 +62,31 @@ function slugOf(path) {
|
|||||||
return path === '/' ? '🏠' : path.split('/').pop()
|
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
|
* Human-readable relative timestamp. Adapted from cista-storage: uses
|
||||||
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
|
* ``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
|
* Format recent visits for display, newest first. Each step is a linked slug
|
||||||
* pointing to its article; external referers/origins and direct entries are
|
* pointing to its article; external referers/origins are shown as their
|
||||||
* omitted. The link title shows the article heading when known.
|
* 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) {
|
export function formatRecentVisits(visits, pageTree, limit = 50) {
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
@@ -126,13 +152,9 @@ export function formatRecentVisits(visits, pageTree, limit = 50) {
|
|||||||
.reverse()
|
.reverse()
|
||||||
.map((v) => ({
|
.map((v) => ({
|
||||||
when: new Date(v.start).toLocaleString(),
|
when: new Date(v.start).toLocaleString(),
|
||||||
steps: [v.entry, ...(v.trail || [])]
|
steps: [v.referer, v.entry, ...(v.trail || [])]
|
||||||
.filter((p) => p?.startsWith('/'))
|
.map((p) => stepOf(p, titles))
|
||||||
.map((p) => ({
|
.filter(Boolean),
|
||||||
path: p,
|
|
||||||
slug: slugOf(p),
|
|
||||||
title: titles.get(p) || '',
|
|
||||||
})),
|
|
||||||
}))
|
}))
|
||||||
.filter((v) => v.steps.length)
|
.filter((v) => v.steps.length)
|
||||||
.slice(0, limit)
|
.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
|
* Format raw visit records as rows for a technical table. Returns objects
|
||||||
* with display strings; missing values become "—". ``trail`` joins page
|
* with display strings; missing values become "—". ``trail`` starts with the
|
||||||
* titles (when known) with " -> ". Only the 20 most recent visits are shown.
|
* 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()) {
|
export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||||
const trail = [v.entry, ...(v.trail || [])]
|
const trail = [v.referer, v.entry, ...(v.trail || [])]
|
||||||
.filter((p) => p?.startsWith('/'))
|
.map((p) => stepOf(p, titles))
|
||||||
.map((p) => ({
|
.filter(Boolean)
|
||||||
path: p,
|
|
||||||
slug: slugOf(p),
|
|
||||||
title: titles.get(p) || '',
|
|
||||||
}))
|
|
||||||
const utm = Object.entries(v.utm || {})
|
const utm = Object.entries(v.utm || {})
|
||||||
.map(([k, value]) => `${k}=${value}`)
|
.map(([k, value]) => `${k}=${value}`)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|||||||
@@ -14,12 +14,13 @@
|
|||||||
* emitted at time intervals inversely proportional (linear) to the
|
* emitted at time intervals inversely proportional (linear) to the
|
||||||
* directional count.
|
* directional count.
|
||||||
* External referers appear as nodes in a row above the map, external exits
|
* 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
|
* as full-size nodes just outside their source page, angled away from the
|
||||||
* center. Self-loops (reload pings) are skipped.
|
* 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 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
|
// Edge width (half-width of the thin middle) grows logarithmically with
|
||||||
// the count, anchored so a single recorded transition renders as a ~1 px
|
// 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). */
|
/** Short display label for an external origin (protocol stripped). */
|
||||||
function extLabel(ext) {
|
function extLabel(ext) {
|
||||||
const s = ext.replace(/^https?:\/\//, '')
|
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 spacing = 2 * EXT_R + 44
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||||
origins.forEach(({ ext, ps }, i) => {
|
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)
|
extNodes.push(xn)
|
||||||
for (const p of ps) {
|
for (const p of ps) {
|
||||||
const page = byPath.get(p.page)
|
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)
|
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 perPage = new Map()
|
||||||
|
const selected = []
|
||||||
for (const p of outgoing) {
|
for (const p of outgoing) {
|
||||||
const used = perPage.get(p.page) || 0
|
const used = perPage.get(p.page) || 0
|
||||||
if (used >= MAX_EXT_OUT_PER_PAGE) continue
|
if (used >= MAX_EXT_OUT_PER_PAGE) continue
|
||||||
perPage.set(p.page, used + 1)
|
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)
|
const page = byPath.get(p.page)
|
||||||
// Fan multiple exits of one page symmetrically around the outward
|
let xn = exitNodes.get(p.ext)
|
||||||
// direction; the center page has no angle, so its exits point down
|
if (!xn) {
|
||||||
// (the top row above the map belongs to referers).
|
const used = placedPerPage.get(p.page) || 0
|
||||||
const base = page.depth ? page.angle : Math.PI / 2
|
placedPerPage.set(p.page, used + 1)
|
||||||
const ang = base + [0, 0.4, -0.4][used]
|
const base = page.depth ? page.angle : Math.PI / 2
|
||||||
let dist = TNODE_R + 40
|
const ang = base + [0, 0.4, -0.4][used]
|
||||||
let x = page.x + Math.cos(ang) * dist
|
let dist = GAP
|
||||||
let y = page.y + Math.sin(ang) * dist
|
let x = page.x + Math.cos(ang) * dist
|
||||||
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
|
let y = page.y + Math.sin(ang) * dist
|
||||||
dist += 24
|
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
|
||||||
x = page.x + Math.cos(ang) * dist
|
dist += GAP * 0.3
|
||||||
y = page.y + Math.sin(ang) * dist
|
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 }
|
xn.count += p.out
|
||||||
extNodes.push(xn)
|
|
||||||
edges.push(buildRibbon(page, xn, p.out, 0, width(p.out), TNODE_R, EXT_R))
|
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))
|
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,9 +536,9 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
if (!a || a.target || a.hasAttribute("download")) return;
|
if (!a || a.target || a.hasAttribute("download")) return;
|
||||||
const url = new URL(a.href, location.href);
|
const url = new URL(a.href, location.href);
|
||||||
if (url.origin !== location.origin) {
|
if (url.origin !== location.origin) {
|
||||||
// External link: the browser navigates; just record the exit (https
|
// External link: the browser navigates; record the full https URL so
|
||||||
// origins only, stripped to the origin part server-side anyway).
|
// different links to the same domain stay distinct in analytics.
|
||||||
if (url.protocol === "https:") ping(url.origin);
|
if (url.protocol === "https:") ping(url.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||||
|
|||||||
+17
-6
@@ -52,7 +52,7 @@ def _compact_user_agent(ua: str) -> str:
|
|||||||
class Visit(msgspec.Struct, omit_defaults=True):
|
class Visit(msgspec.Struct, omit_defaults=True):
|
||||||
"""One visit: the initial-load data plus everything seen afterwards.
|
"""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
|
order; re-visiting an already seen page does not append. The entry
|
||||||
page itself is in ``entry``, not in the trail.
|
page itself is in ``entry``, not in the trail.
|
||||||
"""
|
"""
|
||||||
@@ -129,6 +129,17 @@ def _origin(url: str) -> str | None:
|
|||||||
return f"https://{parsed.netloc}"
|
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_-]*")
|
_SEGMENT = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
||||||
|
|
||||||
|
|
||||||
@@ -364,9 +375,9 @@ class Store:
|
|||||||
) -> int | None:
|
) -> int | None:
|
||||||
"""Record a client navigation ping ({from, to} from pagerite.js).
|
"""Record a client navigation ping ({from, to} from pagerite.js).
|
||||||
|
|
||||||
``to`` is an internal path ("/...") or an https origin for exit
|
``to`` is an internal path ("/...") or an https URL for exit links;
|
||||||
links; anything else is ignored. The transition is always counted;
|
anything else is ignored. The transition is always counted; the trail
|
||||||
the trail only grows on first sight of a page within the visit.
|
only grows on first sight of a page within the visit.
|
||||||
A ping with no known session starts a fresh visit, consuming the
|
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.
|
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("//"):
|
if to.startswith("/") and not to.startswith("//"):
|
||||||
target = _internal_path(to) or ""
|
target = _internal_path(to) or ""
|
||||||
else:
|
else:
|
||||||
target = _origin(to) or ""
|
target = _external_target(to) or ""
|
||||||
if not target or (not to.startswith("/") and target != to):
|
if not target:
|
||||||
return None
|
return None
|
||||||
key = (ip, ua)
|
key = (ip, ua)
|
||||||
index = self.sessions.get(key)
|
index = self.sessions.get(key)
|
||||||
|
|||||||
Reference in New Issue
Block a user