feat: add anonymous view counter with most-read sidebar

- node:sqlite storage in $DATA_DIR/views.db (no native deps)
- POST /api/views/<slug>/ counts once per visitor per 24h via
  daily-salted hash; IPs are never stored, bots are skipped
- GET /api/views/top/ feeds the sidebar's featured list, falling back
  to frontmatter-featured and latest posts when unavailable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 01:15:05 +03:00
parent 809f89af08
commit c1c57c4f85
7 changed files with 178 additions and 30 deletions
+36 -2
View File
@@ -71,9 +71,9 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
{blog.sidebar.featured}
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
</h2>
<ol data-featured class="flex flex-col gap-4">
<ol data-featured data-limit="3" class="flex flex-col gap-4">
{featured.map((post, i) => (
<li class="flex gap-3">
<li class="flex gap-3" data-slug={post.id}>
<span
aria-hidden="true"
class="font-display text-accent-text text-2xl leading-none font-black"
@@ -115,3 +115,37 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
</section>
)}
</aside>
<script>
// Replace the build-time fallback with the most viewed posts when the API is reachable.
const list = document.querySelector<HTMLOListElement>('ol[data-featured]');
if (list) {
const limit = Number(list.dataset.limit) || 3;
fetch(`/api/views/top/?limit=${limit}`)
.then((r) => (r.ok ? r.json() : []))
.then((top: { slug: string; title: string; url: string }[]) => {
if (!top.length) return;
const template = list.querySelector('li');
if (!template) return;
const fallback = [...list.querySelectorAll<HTMLLIElement>('li')];
const items = top.map((t) => {
const li = template.cloneNode(true) as HTMLLIElement;
li.dataset.slug = t.slug;
const a = li.querySelector('a')!;
a.href = t.url;
a.textContent = t.title;
return li;
});
// Top up with fallback posts not already listed.
for (const li of fallback) {
if (items.length >= limit) break;
if (!items.some((x) => x.dataset.slug === li.dataset.slug)) items.push(li);
}
items.forEach((li, i) => {
li.querySelector('span')!.textContent = String(i + 1).padStart(2, '0');
});
list.replaceChildren(...items);
})
.catch(() => {});
}
</script>
+2 -2
View File
@@ -59,10 +59,10 @@ export function tagCounts(posts: Post[]): TagCount[] {
return [...map.values()].sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag, 'tr'));
}
/** Fallback "featured" list: frontmatter `featured`, else the latest posts. */
/** Fallback "featured" list: frontmatter `featured` first, topped up with the latest posts. */
export function featuredPosts(posts: Post[], limit = 3): Post[] {
const featured = posts.filter((p) => p.data.featured);
return (featured.length ? featured : posts).slice(0, limit);
return [...featured, ...posts.filter((p) => !p.data.featured)].slice(0, limit);
}
/** prev = older post, next = newer post. */
+66
View File
@@ -0,0 +1,66 @@
/**
* Anonymous view counter on node:sqlite (server-only; used by /api/views/*).
* Privacy: the IP is never stored. Each hit stores sha256(daySalt + ip + slug) for 24 h only,
* the salt is random per UTC day and old salts are deleted, so hashes cannot be linked later.
*/
import { createHash, randomBytes } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const DAY_MS = 24 * 60 * 60 * 1000;
let db: DatabaseSync | undefined;
function open(): DatabaseSync {
if (db) return db;
const dir = process.env.DATA_DIR ?? './data';
mkdirSync(dir, { recursive: true });
db = new DatabaseSync(join(dir, 'views.db'));
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS views (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS hits (hash TEXT PRIMARY KEY, at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL);
`);
return db;
}
function daySalt(d: DatabaseSync, now: number): string {
const day = new Date(now).toISOString().slice(0, 10);
const row = d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as
{ salt: string } | undefined;
if (row) return row.salt;
const salt = randomBytes(32).toString('hex');
d.prepare('INSERT OR IGNORE INTO salts (day, salt) VALUES (?, ?)').run(day, salt);
d.prepare('DELETE FROM salts WHERE day <> ?').run(day);
return (d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as { salt: string }).salt;
}
/** Counts a view unless the same visitor viewed this slug in the last 24 h. Returns true if counted. */
export function recordView(slug: string, ip: string, now = Date.now()): boolean {
const d = open();
const hash = createHash('sha256')
.update(`${daySalt(d, now)}|${ip}|${slug}`)
.digest('hex');
d.prepare('DELETE FROM hits WHERE at < ?').run(now - DAY_MS);
const fresh = d.prepare('INSERT OR IGNORE INTO hits (hash, at) VALUES (?, ?)').run(hash, now);
if (fresh.changes === 0) return false;
d.prepare(
'INSERT INTO views (slug, count) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET count = count + 1',
).run(slug);
return true;
}
export function topViews(limit: number): { slug: string; count: number }[] {
return open()
.prepare('SELECT slug, count FROM views WHERE count > 0 ORDER BY count DESC, slug LIMIT ?')
.all(limit) as { slug: string; count: number }[];
}
/** First X-Forwarded-For hop (Traefik/Dokploy), else the socket address. */
export function clientIp(request: Request, fallback: string): string {
return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || fallback;
}
export const isBot = (ua: string | null) =>
!ua || /bot|crawl|spider|slurp|preview|facebookexternalhit|headless|lighthouse/i.test(ua);
+16
View File
@@ -0,0 +1,16 @@
import type { APIRoute } from 'astro';
import { getPosts } from '../../../lib/posts';
import { clientIp, isBot, recordView } from '../../../lib/views';
export const prerender = false;
// POST /api/views/<slug>/ — counts one view (sent with navigator.sendBeacon).
export const POST: APIRoute = async ({ params, request, clientAddress }) => {
const slug = params.slug ?? '';
const posts = await getPosts();
if (!posts.some((p) => p.id === slug)) return new Response(null, { status: 404 });
if (!isBot(request.headers.get('user-agent'))) {
recordView(slug, clientIp(request, clientAddress));
}
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
};
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from 'astro';
import { getPosts, postUrl } from '../../../lib/posts';
import { topViews } from '../../../lib/views';
export const prerender = false;
// GET /api/views/top/?limit=3 — most viewed published posts.
export const GET: APIRoute = async ({ url }) => {
const limit = Math.min(10, Math.max(1, Number(url.searchParams.get('limit')) || 3));
const posts = new Map((await getPosts()).map((p) => [p.id, p]));
const items = topViews(limit * 2) // headroom for deleted/draft slugs
.filter((v) => posts.has(v.slug))
.slice(0, limit)
.map((v) => {
const post = posts.get(v.slug)!;
return { slug: v.slug, title: post.data.title, url: postUrl(post), count: v.count };
});
return Response.json(items, { headers: { 'Cache-Control': 'public, max-age=300' } });
};
+7
View File
@@ -121,6 +121,7 @@ const jsonLd = [
<div
class="prose article-prose md:prose-lg max-w-[72ch]"
data-view-slug={post.id}
data-code-copy={blog.post.copyCode}
data-code-copied={blog.post.codeCopied}
>
@@ -212,6 +213,12 @@ const jsonLd = [
</BaseLayout>
<script>
// Count one view (anonymous, server dedupes per visitor for 24 h).
const viewed = document.querySelector<HTMLElement>('[data-view-slug]')?.dataset.viewSlug;
if (viewed && 'sendBeacon' in navigator) {
navigator.sendBeacon(`/api/views/${encodeURIComponent(viewed)}/`);
}
// Copy button on every code block.
const prose = document.querySelector<HTMLElement>('[data-code-copy]');
if (prose) {