Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-15 11:38:26 -04:00
1 parent 6a5d525d6e
commit e9d9dd0d50
13 files changed
+143 -9

No files matched your search

+1
View File
@@ -172,6 +172,7 @@ export const EMPTY_HIT_STATS = {
byPath: {},
byPathReal: {},
byPathBot: {},
byPathUnknown: {},
byDay: {},
byDayReal: {},
byDayBot: {},
+2
View File
@@ -167,6 +167,7 @@ export function loadHitStatsFromDisk() {
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
@@ -1250,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
+7
View File
@@ -393,6 +393,13 @@ export function register(app) {
firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons,
+17
View File
@@ -484,6 +484,8 @@ const KNOWN_SITE_SEGMENTS = new Set([
'privacy', 'terms', 'salvation', 'qr',
])
const MAX_UNKNOWN_PATHS = 300
export function isKnownSitePath(pathname) {
const first = String(pathname ?? '/').split('/')[1] ?? ''
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
@@ -593,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot && botReason === 'unknown-path') {
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
const unknown = (state.hitStats.byPathUnknown ??= {})
const key = safePath.slice(0, 120)
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
state.hitStats.botHits += 1
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
return
}
if (isBot) {
state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1