import { createHash, randomUUID } from "node:crypto"; export function nowIso(): string { return new Date().toISOString(); } export function newId(prefix: string): string { return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 20)}`; } export function sha1(input: string): string { return createHash("sha1").update(input).digest("hex"); } export function shortHash(input: string, len = 12): string { return sha1(input).slice(0, len); } export function clamp01(n: number): number { if (Number.isNaN(n)) return 0; return Math.max(0, Math.min(1, n)); } export function truncate(s: string | undefined, max: number): string { if (!s) return ""; const t = s.replace(/\s+/g, " ").trim(); return t.length > max ? t.slice(0, max - 1) + "…" : t; } export function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** Parse "1.2M", "483", "12 k", "3,4 M de vues" → number. Returns undefined when nothing numeric. */ export function parseCount(text: string | undefined | null): number | undefined { if (!text) return undefined; const m = text.replace(/ /g, " ").replace(/\s+/g, " ").match(/(\d{1,3}(?:[ ,.]\d{3})+|\d+(?:[.,]\d+)?)\s*([kKmMbBG])?(?![a-z])/i); // matches "3 624", "12,345", "1,2 M", "48 k", "483" (all unicode spaces normalised first) if (!m) return undefined; let raw = m[1]!; const suffix = (m[2] ?? "").toLowerCase(); if (/^\d{1,3}(?:[ ,.]\d{3})+$/.test(raw) && !suffix) raw = raw.replace(/[ ,.]/g, ""); // thousands groups else raw = raw.replace(/ /g, "").replace(",", "."); // decimal comma (fr) let n = Number(raw); if (Number.isNaN(n)) return undefined; if (suffix === "k") n *= 1e3; else if (suffix === "m") n *= 1e6; else if (suffix === "b" || suffix === "g") n *= 1e9; return Math.round(n); } /** Parse "12:34" / "1:02:03" → seconds. */ export function parseDuration(text: string | undefined | null): number | undefined { if (!text) return undefined; const m = text.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); if (!m) return undefined; const h = m[1] ? Number(m[1]) : 0; return h * 3600 + Number(m[2]) * 60 + Number(m[3]); } const STOPWORDS = new Set(["the", "and", "for", "with", "that", "this", "from", "les", "des", "une", "sur", "pour", "dans", "est", "qui", "que", "de", "la", "le", "du", "en", "un", "et", "au", "of", "to", "in", "on", "is", "it", "or", "by", "an", "as", "at", "be", "we", "vs", "ce", "se", "sa", "son", "ses", "ne", "pas", "plus", "how", "what", "why", "who", "public", "discover", "discussing", "about"]); /** Tokenize for cheap lexical similarity (novelty fallback when no embedding model is present). */ export function tokenize(text: string): Set { return new Set( text .toLowerCase() .normalize("NFD") .replace(/[̀-ͯ]/g, "") .split(/[^a-z0-9#@]+/) // keep 2-letter tokens: "ai", "ia", "qc" carry meaning in this domain .filter((t) => t.length >= 2 && !STOPWORDS.has(t)), ); } export function jaccard(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0; let inter = 0; for (const t of a) if (b.has(t)) inter++; return inter / (a.size + b.size - inter); } export function canonicalUrl(url: string): string { try { const u = new URL(url); u.hash = ""; // strip common tracking params for (const k of [...u.searchParams.keys()]) { if (/^(utm_|fbclid|gclid|igshid|si|feature|pp|ref_src|ref_url|t|ref|refid|rdid|mibextid|locale|__cft__|__tn__|__xts__|eid|hc_ref|notif_id|notif_t|comment_tracking|source|sfnsn|extid)/i.test(k)) u.searchParams.delete(k); } u.hostname = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, ""); let s = u.toString(); if (s.endsWith("/")) s = s.slice(0, -1); return s; } catch { return url; } } export function safeJsonParse(text: string): unknown | undefined { try { return JSON.parse(text); } catch { return undefined; } } /** Walk any JSON value depth-first. Visitor receives (value, path). Return false to stop descending. */ export function walkJson( value: unknown, visitor: (v: unknown, path: string[]) => boolean | void, path: string[] = [], depth = 0, ): void { if (depth > 40) return; const cont = visitor(value, path); if (cont === false) return; if (Array.isArray(value)) { value.forEach((v, i) => walkJson(v, visitor, [...path, `[${i}]`], depth + 1)); } else if (value && typeof value === "object") { for (const [k, v] of Object.entries(value as Record)) { walkJson(v, visitor, [...path, k], depth + 1); } } }