Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 51.9%
TypeScript 32.7%
JavaScript 4.8%
CSS 4.1%
Shell 2.9%
HTML 1.8%
SQL 1.5%
1/**2 * Trouve-KA — favoris « Mon univers Ka » : client serveur du hub Groupe KA3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 *6 * Le hub groupe-ka.com est le MAGASIN CENTRAL des favoris du groupe :7 * Trouve-KA ne stocke rien localement. Chaque ♥ est poussé au hub (POST8 * signé HMAC-SHA256, synchrone : un échec remonte à l'appelant) et la liste9 * est lue au hub (GET signé, cache mémoire 30 s, invalidé à chaque toggle).10 * Même secret que le SSO (KA_SSO_SECRET). Appels SERVEUR uniquement.11 * Modèle de référence : food-ka/foodka/hubfav.py.12 */13import { createHash, createHmac } from "node:crypto";14import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";1516const CACHE_TTL_MS = 30_000; // liste des favoris17const TIMEOUT_MS = 6_000;1819/** Un favori tel que stocké au hub. */20export type HubFavItem = {21 item_id: string;22 title: string;23 subtitle?: string;24 price_label?: string;25 image_url?: string;26 url?: string;27 meta?: Record<string, unknown>;28};2930/** Champs d'item acceptés -> longueur maximale (troncature défensive). */31const FIELD_MAX: Record<string, number> = {32 item_id: 120,33 title: 200,34 subtitle: 200,35 price_label: 60,36 image_url: 500,37 url: 500,38};3940const cache = new Map<string, { at: number; favs: HubFavItem[] }>();4142/** Signature HMAC-SHA256 du hub : hex("trouve-ka.<ka_id>.<ts>"). */43function sig(kaId: string, ts: number): string | null {44 const secret = process.env.KA_SSO_SECRET;45 if (!secret) return null;46 return createHmac("sha256", secret)47 .update(`${CLIENT_ID}.${kaId}.${ts}`)48 .digest("hex");49}5051/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */52export function linked(kaId: string | null | undefined): boolean {53 return Boolean(kaId) && String(kaId).startsWith("ka-");54}5556/** Identifiant STABLE d'un résultat : dérivé de son URL (les résultats de57 * Trouve-KA sont des pages — l'URL est leur identité). */58export function itemIdForUrl(url: string): string {59 return `pg-${createHash("sha256").update(url).digest("hex").slice(0, 40)}`;60}6162/** Ne garde que les champs d'item connus, en chaînes tronquées. */63export function cleanItem(item: Record<string, unknown>): Partial<HubFavItem> {64 const out: Record<string, string> = {};65 for (const [k, max] of Object.entries(FIELD_MAX)) {66 const v = item?.[k];67 if (v !== undefined && v !== null && v !== "") {68 out[k] = String(v).slice(0, max);69 }70 }71 return out as Partial<HubFavItem>;72}7374/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :75 * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */76export async function hubToggle(77 kaId: string,78 action: "add" | "remove",79 item: Partial<HubFavItem>,80): Promise<boolean> {81 const ts = Math.floor(Date.now() / 1000);82 const s = sig(kaId, ts);83 if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) {84 return false;85 }86 let ok = false;87 try {88 const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, {89 method: "POST",90 headers: { "Content-Type": "application/json" },91 body: JSON.stringify({92 client_id: CLIENT_ID,93 ka_id: kaId,94 ts: String(ts),95 sig: s,96 action,97 item,98 }),99 signal: AbortSignal.timeout(TIMEOUT_MS),100 cache: "no-store",101 });102 ok = r.status === 200;103 } catch {104 ok = false;105 }106 if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle107 return ok;108}109110/** Favoris Trouve-KA du membre, lus au hub (cache mémoire 30 s).111 * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */112export async function hubList(kaId: string): Promise<HubFavItem[] | null> {113 if (!linked(kaId)) return []; // compte legacy non relié au hub114 const now = Date.now();115 const hit = cache.get(kaId);116 if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;117 const ts = Math.floor(now / 1000);118 const s = sig(kaId, ts);119 if (!s) return null;120 try {121 const params = new URLSearchParams({122 client_id: CLIENT_ID,123 ka_id: kaId,124 ts: String(ts),125 sig: s,126 });127 const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${params}`, {128 signal: AbortSignal.timeout(TIMEOUT_MS),129 cache: "no-store",130 });131 if (r.status !== 200) return null;132 const data = (await r.json()) as { favorites?: unknown };133 if (!Array.isArray(data.favorites)) return null;134 const favs = data.favorites.filter(135 (f): f is HubFavItem => typeof f === "object" && f !== null,136 );137 cache.set(kaId, { at: now, favs });138 return favs;139 } catch {140 return null;141 }142}143