2026-05-01 15:51:12 +00:00
|
|
|
import { getDb, hashIp } from "./db";
|
|
|
|
|
import { lookupCountry } from "./geo";
|
|
|
|
|
|
2026-05-01 17:22:04 +00:00
|
|
|
// Patterns we don't want polluting analytics
|
|
|
|
|
const BOT_UA = /bot|crawl|spider|slurp|curl|wget|httpclient|python-requests|axios|node-fetch|monitor|uptime|pingdom|datadog|prometheus|scanner|fetch|preview|whatsapp|telegrambot|facebookexternalhit|linkedinbot|twitterbot|discordbot|skypeuripreview|mastodon|matrix-bot|preconnect|dnsperf|sentry|newrelic|gtmetrix|lighthouse|headlesschrome|phantomjs|puppeteer|playwright|chrome-lighthouse/i;
|
|
|
|
|
const SKIP_PATHS = /^\/(favicon\.ico|robots\.txt|sitemap\.xml|apple-touch-icon[\w-]*\.png|browserconfig\.xml|\.well-known\/|ads\.txt)/i;
|
|
|
|
|
|
|
|
|
|
export function shouldRecord(method: string, path: string | null, userAgent: string | null): boolean {
|
|
|
|
|
const m = (method || "GET").toUpperCase();
|
|
|
|
|
if (m === "HEAD" || m === "OPTIONS") return false;
|
|
|
|
|
if (path && SKIP_PATHS.test(path)) return false;
|
|
|
|
|
if (userAgent && BOT_UA.test(userAgent)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 15:51:12 +00:00
|
|
|
type PendingHit = {
|
|
|
|
|
domain_id: number;
|
|
|
|
|
ts: number;
|
|
|
|
|
ip_hash: string;
|
|
|
|
|
country: string | null;
|
|
|
|
|
user_agent: string | null;
|
|
|
|
|
referer: string | null;
|
|
|
|
|
path: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buffer: PendingHit[] = [];
|
|
|
|
|
let flushTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
function flush() {
|
|
|
|
|
if (buffer.length === 0) return;
|
|
|
|
|
const batch = buffer.splice(0, buffer.length);
|
|
|
|
|
try {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const stmt = db.prepare(
|
|
|
|
|
"INSERT INTO hits (domain_id, ts, ip_hash, country, user_agent, referer, path) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
|
|
|
);
|
|
|
|
|
db.transaction(() => {
|
|
|
|
|
for (const h of batch) {
|
|
|
|
|
stmt.run(h.domain_id, h.ts, h.ip_hash, h.country, h.user_agent, h.referer, h.path);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[hits] flush failed", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleFlush() {
|
|
|
|
|
if (flushTimer) return;
|
|
|
|
|
flushTimer = setTimeout(() => {
|
|
|
|
|
flushTimer = null;
|
|
|
|
|
flush();
|
|
|
|
|
}, 5000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function recordHit(input: {
|
|
|
|
|
domain_id: number;
|
|
|
|
|
ip: string;
|
|
|
|
|
user_agent: string | null;
|
|
|
|
|
referer: string | null;
|
|
|
|
|
path: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
const country = await lookupCountry(input.ip).catch(() => null);
|
|
|
|
|
buffer.push({
|
|
|
|
|
domain_id: input.domain_id,
|
|
|
|
|
ts: Date.now(),
|
|
|
|
|
ip_hash: hashIp(input.ip),
|
|
|
|
|
country,
|
|
|
|
|
user_agent: input.user_agent ? input.user_agent.slice(0, 500) : null,
|
|
|
|
|
referer: input.referer ? input.referer.slice(0, 500) : null,
|
|
|
|
|
path: input.path ? input.path.slice(0, 500) : null,
|
|
|
|
|
});
|
|
|
|
|
if (buffer.length >= 100) flush();
|
|
|
|
|
else scheduleFlush();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function flushHitsSync() {
|
|
|
|
|
flush();
|
|
|
|
|
}
|