feat(blog): limit rapid repeat views
This commit is contained in:
3 files changed
+85
-2
No files matched your search
@@ -30,10 +30,11 @@ const { entry } = await getPublication(Astro.url.pathname);
|
|||||||
:global(.library-intro h2) { color: white; }
|
: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-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) { 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 h2) { font-size: 1.1rem; margin-top: 2rem; }
|
||||||
:global(.library-sidebar ul) { list-style: none; padding: 0; }
|
: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 li) { border-bottom: .15em solid var(--color-accent-strong); padding-block: .65rem; }
|
||||||
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; }
|
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; line-height: 1.25; }
|
||||||
:global(.blog-list) { display: block; }
|
:global(.blog-list) { display: block; }
|
||||||
:global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; }
|
:global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; }
|
||||||
:global(.library-search-results h2) { margin-top: 0; }
|
:global(.library-search-results h2) { margin-top: 0; }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const likeStorageKey = 'aia-liked-articles-v1';
|
const likeStorageKey = 'aia-liked-articles-v1';
|
||||||
|
const viewCooldownStorageKey = 'aia-view-cooldowns-v1';
|
||||||
|
export const articleViewCooldownMs = 30 * 60 * 1_000;
|
||||||
const requestTimeoutMs = 8_000;
|
const requestTimeoutMs = 8_000;
|
||||||
const pendingLikes = new Set<string>();
|
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()) {
|
export function readLikedSlugs(storage: Pick<Storage, 'getItem'> = browserStorage()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]');
|
const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]');
|
||||||
@@ -119,6 +160,7 @@ export async function bindLiveCounters(
|
|||||||
root: ParentNode = document,
|
root: ParentNode = document,
|
||||||
fetcher: Fetcher = fetch,
|
fetcher: Fetcher = fetch,
|
||||||
storage: Pick<Storage, 'getItem' | 'setItem'> = browserStorage(),
|
storage: Pick<Storage, 'getItem' | 'setItem'> = browserStorage(),
|
||||||
|
viewStorage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
|
||||||
) {
|
) {
|
||||||
const counters = [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')];
|
const counters = [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')];
|
||||||
const pending = counters.filter((counter) => counter.dataset.counterBound !== 'true');
|
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
|
// Mark before the non-retried request. Repeated initialization and
|
||||||
// bfcache restoration reuse this DOM marker and cannot double-count.
|
// bfcache restoration reuse this DOM marker and cannot double-count.
|
||||||
counter.dataset.viewReported = 'true';
|
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)
|
return reportArticleView(apiBase, slug, fetcher)
|
||||||
.then((views) => ({ slug, views, failed: false }))
|
.then((views) => ({ slug, views, failed: false }))
|
||||||
.catch(() => ({ slug, views: undefined, failed: true }));
|
.catch(() => ({ slug, views: undefined, failed: true }));
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import test from 'node:test';
|
|||||||
import fg from 'fast-glob';
|
import fg from 'fast-glob';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import {
|
import {
|
||||||
|
articleViewCooldownMs,
|
||||||
bindLiveCounters,
|
bindLiveCounters,
|
||||||
fetchCounterStats,
|
fetchCounterStats,
|
||||||
readLikedSlugs,
|
readLikedSlugs,
|
||||||
reportArticleView,
|
reportArticleView,
|
||||||
|
reserveArticleView,
|
||||||
updateLike,
|
updateLike,
|
||||||
} from '../src/scripts/likes-views.ts';
|
} 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)], []);
|
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 () => {
|
test('API failures preserve rendering and expose localized inline status', async () => {
|
||||||
const counter = new FakeCounter('article', { trackView: true, lang: 'ar' });
|
const counter = new FakeCounter('article', { trackView: true, lang: 'ar' });
|
||||||
const root = new FakeRoot([counter]);
|
const root = new FakeRoot([counter]);
|
||||||
|
|||||||
Reference in new issue
Block a user