feat(blog): limit rapid repeat views

This commit is contained in:
DeCentN2Madness committed 2026-11-06 14:05:37 -07:00
1 parent 96288b1eb8
commit eeff641a1d
3 files changed
+85 -2

No files matched your search

+3 -2
View File
@@ -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; }
+45
View File
@@ -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<string>();
@@ -17,6 +19,45 @@ function browserStorage(): Pick<Storage, 'getItem' | 'setItem'> {
}
}
function browserSessionStorage(): Pick<Storage, 'getItem' | 'setItem'> {
try {
return sessionStorage;
} catch {
return { getItem: () => null, setItem: () => undefined };
}
}
export function reserveArticleView(
slug: string,
storage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
now = Date.now(),
) {
let active: Record<string, number> = {};
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<Storage, 'getItem'> = 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<Storage, 'getItem' | 'setItem'> = browserStorage(),
viewStorage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
) {
const counters = [...root.querySelectorAll<HTMLElement>('[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 }));
+37
View File
@@ -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]);