From eeff641a1dbdc9bc6ddf36031f2d5f18829c0dca Mon Sep 17 00:00:00 2001 From: Jeffrey Hales Date: Fri, 6 Nov 2026 14:05:37 -0700 Subject: [PATCH] feat(blog): limit rapid repeat views --- www/src/layouts/BlogIndexLayout.astro | 5 +-- www/src/scripts/likes-views.ts | 45 +++++++++++++++++++++++++++ www/tools/counters.test.mjs | 37 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/www/src/layouts/BlogIndexLayout.astro b/www/src/layouts/BlogIndexLayout.astro index 4771c46..a0776ef 100644 --- a/www/src/layouts/BlogIndexLayout.astro +++ b/www/src/layouts/BlogIndexLayout.astro @@ -30,10 +30,11 @@ const { entry } = await getPublication(Astro.url.pathname); :global(.library-intro h2) { color: white; } :global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: minmax(0, 1fr) 240px; } :global(.library-sidebar) { position: sticky; top: 1rem; } + :global(.library-sidebar :is(h1, h2, h3, h4, h5, h6)) { font-family: var(--font-body); } :global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; } :global(.library-sidebar ul) { list-style: none; padding: 0; } - :global(.library-sidebar li) { border-bottom: 1px solid var(--color-border); padding-block: .65rem; } - :global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; } + :global(.library-sidebar li) { border-bottom: .15em solid var(--color-accent-strong); padding-block: .65rem; } + :global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; line-height: 1.25; } :global(.blog-list) { display: block; } :global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; } :global(.library-search-results h2) { margin-top: 0; } diff --git a/www/src/scripts/likes-views.ts b/www/src/scripts/likes-views.ts index 17b05c8..16bdb7f 100644 --- a/www/src/scripts/likes-views.ts +++ b/www/src/scripts/likes-views.ts @@ -1,4 +1,6 @@ const likeStorageKey = 'aia-liked-articles-v1'; +const viewCooldownStorageKey = 'aia-view-cooldowns-v1'; +export const articleViewCooldownMs = 30 * 60 * 1_000; const requestTimeoutMs = 8_000; const pendingLikes = new Set(); @@ -17,6 +19,45 @@ function browserStorage(): Pick { } } +function browserSessionStorage(): Pick { + try { + return sessionStorage; + } catch { + return { getItem: () => null, setItem: () => undefined }; + } +} + +export function reserveArticleView( + slug: string, + storage: Pick = browserSessionStorage(), + now = Date.now(), +) { + let active: Record = {}; + try { + const parsed = JSON.parse(storage.getItem(viewCooldownStorageKey) ?? '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + active = Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, number] => { + const timestamp = entry[1]; + return typeof timestamp === 'number' + && Number.isFinite(timestamp) + && now >= timestamp + && now - timestamp < articleViewCooldownMs; + })); + } + } catch { + // Corrupt or blocked session storage must not prevent article rendering. + } + + if (active[slug] !== undefined) return false; + active[slug] = now; + try { + storage.setItem(viewCooldownStorageKey, JSON.stringify(active)); + } catch { + // The request may proceed when storage is unavailable. + } + return true; +} + export function readLikedSlugs(storage: Pick = browserStorage()) { try { const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]'); @@ -119,6 +160,7 @@ export async function bindLiveCounters( root: ParentNode = document, fetcher: Fetcher = fetch, storage: Pick = browserStorage(), + viewStorage: Pick = browserSessionStorage(), ) { const counters = [...root.querySelectorAll('[data-counter][data-counter-mode="live"]')]; const pending = counters.filter((counter) => counter.dataset.counterBound !== 'true'); @@ -139,6 +181,9 @@ export async function bindLiveCounters( // Mark before the non-retried request. Repeated initialization and // bfcache restoration reuse this DOM marker and cannot double-count. counter.dataset.viewReported = 'true'; + // A short per-tab reservation also suppresses a newly rendered copy of + // the same article after back navigation, without a cookie or visitor ID. + if (!reserveArticleView(slug, viewStorage)) return undefined; return reportArticleView(apiBase, slug, fetcher) .then((views) => ({ slug, views, failed: false })) .catch(() => ({ slug, views: undefined, failed: true })); diff --git a/www/tools/counters.test.mjs b/www/tools/counters.test.mjs index e5fd6ff..60bbbaf 100644 --- a/www/tools/counters.test.mjs +++ b/www/tools/counters.test.mjs @@ -5,10 +5,12 @@ import test from 'node:test'; import fg from 'fast-glob'; import matter from 'gray-matter'; import { + articleViewCooldownMs, bindLiveCounters, fetchCounterStats, readLikedSlugs, reportArticleView, + reserveArticleView, updateLike, } from '../src/scripts/likes-views.ts'; @@ -139,6 +141,41 @@ test('one article navigation increments once while cards only retrieve statistic assert.deepEqual([...readLikedSlugs(storage)], []); }); +test('a short per-tab cooldown suppresses back-navigation duplicates and then expires', async () => { + const likeStorage = new FakeStorage(); + const viewStorage = new FakeStorage(); + const calls = []; + const fetcher = async (url) => { + calls.push(url); + return response(url.endsWith('/views') + ? { views: 21 } + : { 'shared-slug': { likes: 4, views: 20 } }); + }; + + await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage); + await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage); + assert.equal(calls.filter((url) => url.endsWith('/views')).length, 1); + + const clockStorage = new FakeStorage(); + assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000), true); + assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs - 1), false); + assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs), true); + assert.equal(reserveArticleView('different-slug', clockStorage, 10_000 + articleViewCooldownMs), true); +}); + +test('invalid or unavailable session storage fails open without persisting an identifier', () => { + const corrupt = new FakeStorage(); + corrupt.setItem('aia-view-cooldowns-v1', '{broken'); + assert.equal(reserveArticleView('article', corrupt, 10_000), true); + assert.deepEqual(JSON.parse(corrupt.getItem('aia-view-cooldowns-v1')), { article: 10_000 }); + + const blocked = { + getItem() { throw new Error('blocked'); }, + setItem() { throw new Error('blocked'); }, + }; + assert.equal(reserveArticleView('article', blocked, 10_000), true); +}); + test('API failures preserve rendering and expose localized inline status', async () => { const counter = new FakeCounter('article', { trackView: true, lang: 'ar' }); const root = new FakeRoot([counter]);