spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
1// -----------------------------------------------------------------------------2// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + client API robuste (timeout, erreurs typées)5// -----------------------------------------------------------------------------67/** Préfixe de déploiement (ex. « /food ») — dérivé du `base` de Vite. */8export const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, "");910export interface Product {11 uid: string;12 source: string; // id de la bannière (metro, iga, maxi…)13 external_id: string;14 url: string; // fiche produit chez la source15 name: string;16 brand: string;17 category: string; // catégorie canonique Food-Ka18 category_raw: string; // taxonomie originale de la bannière19 size_label: string; // format affiché, ex. "500 g", "2 L"20 price: number | null; // prix courant ($ CAD), rabais inclus21 regular_price: number | null;// prix régulier si le produit est en solde22 price_label: string; // texte original (ex. "2 / 5,00 $")23 on_sale: boolean;24 unit_price: number | null; // prix par unité comparable25 unit_price_label: string; // ex. "0,70 $ / 100 g"26 in_stock: boolean | null; // null = inconnu27 description: string;28 keywords: string[]; // tags source (bio, sans gluten…)29 details: Record<string, unknown>;30 images: string[];31 first_seen?: number;32 last_seen: number;33 updated_at: number;34 active: number;35}3637/** Fiche produit : produit + historique de prix + comparaison inter-bannières */38export interface ProductDetail extends Product {39 price_history: { ts: number; price: number | null }[];40 compare: Product[];41}4243export interface Facets {44 categories: { category: string; n: number }[];45 brands: { brand: string; n: number }[];46 sources: { source: string; n: number }[];47 on_sale: number;48}4950export interface Source {51 id: string;52 name: string;53 url: string;54 catalog_url: string;55 connector: string | null;56 status: string;57 region: string;58 tech: string;59 active_products: number;60 last_sync: number | null;61 notes?: string;62}6364export interface SyncEntry {65 id: number;66 source: string;67 ts: number;68 found: number;69 added: number;70 updated: number;71 removed: number;72 ok: number;73 message: string | null;74}7576export interface Stats {77 total: number;78 on_sale: number;79 sources: number;80 categories: number;81 avg_price: number | null;82 by_source: { source: string; n: number; sales: number; avg_price: number | null }[];83 by_category: { category: string; n: number; avg_price: number | null }[];84 deals: Product[];85 recent_syncs: SyncEntry[];86}8788// --- Agrégats détaillés du marché (GET /api/stats/detailed) -------------------89export interface SourceMarketStats {90 source: string;91 n: number;92 sales: number;93 sale_share: number; // 0..194 avg_price: number | null;95 median_price: number | null;96 avg_discount_pct: number | null; // % moyen des rabais en cours97 max_discount_pct: number | null;98 with_unit_price: number;99 no_price: number;100}101102export interface MatrixCell { median_price: number | null; n: number; }103104export interface BasketItem {105 item: string; // ex. « Lait », « Œufs »106 by_source: Record<string, MatrixCell>; // prix médian par bannière107}108109export interface BasketTotal { source: string; items: number; total: number; }110111export interface PriceDrop {112 uid: string;113 name: string;114 source: string;115 old_price: number;116 new_price: number;117 drop_pct: number;118}119120export interface DetailedStats {121 global: {122 total: number;123 on_sale: number;124 sale_share: number; // 0..1125 sources: number;126 categories: number;127 brands: number;128 avg_price: number | null;129 median_price: number | null;130 price_changes_7d: number;131 };132 by_source: SourceMarketStats[];133 category_matrix: Record<string, Record<string, MatrixCell>>;134 basket: BasketItem[];135 basket_totals: BasketTotal[]; // triés du panier le moins cher au plus cher136 price_drops: PriceDrop[];137 price_distribution: { range: string; n: number }[];138}139140// --- Noms d'affichage des bannières ------------------------------------------141const SOURCE_NAMES: Record<string, string> = {142 metro: "Metro",143 superc: "Super C",144 iga: "IGA",145 provigo: "Provigo",146 maxi: "Maxi",147 walmart: "Walmart",148 adonis: "Marché Adonis",149 avril: "Avril",150 rachelle_bery: "Rachelle-Béry",151 pa: "PA Supermarché",152 mayrand: "Mayrand",153 tau: "Marché Tau",154 giant_tiger: "Giant Tiger",155 club_entrepot: "Club Entrepôt",156 loco: "LOCO",157 frenco: "Frenco",158 akhavan: "Akhavan",159 tt: "T&T Supermarket",160};161162export function registerSourceNames(sources: Source[]) {163 for (const s of sources) SOURCE_NAMES[s.id] = s.name;164}165166/** Nom d'affichage d'une bannière — id inconnu : joliment reformaté. */167export function sourceName(id: string): string {168 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];169 return id170 .split(/[_-]/)171 .filter(Boolean)172 .map((w) => w.charAt(0).toUpperCase() + w.slice(1))173 .join(" ");174}175176// Noms courts — à côté des logos (cartes, tableaux serrés)177const SOURCE_SHORT: Record<string, string> = {178 adonis: "Adonis",179 pa: "PA",180 giant_tiger: "G. Tiger",181 boite_a_grains: "B. à grains",182 aliments_merci: "Merci",183 bocoboco: "BocoBoco",184 epipresto: "ÉpiPresto",185 rachelle_bery: "Rachelle-Béry",186 tau: "Tau",187 maturin: "Maturin",188 club_entrepot: "Club Ent.",189 tt: "T&T",190};191192/** Nom court d'une bannière (logo + libellé compact). */193export const sourceShort = (id: string): string =>194 SOURCE_SHORT[id] ?? sourceName(id);195196async function get<T>(path: string): Promise<T> {197 const ctrl = new AbortController();198 const timer = setTimeout(() => ctrl.abort(), 20000);199 try {200 const res = await fetch(`${API_BASE}${path}`, { signal: ctrl.signal });201 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);202 return (await res.json()) as T;203 } finally {204 clearTimeout(timer);205 }206}207208export type SortKey =209 | "price_asc" | "price_desc" | "unit_price" | "discount" | "name" | "recent";210211export interface ProductFilters {212 category?: string;213 source?: string;214 brand?: string;215 price_min?: string;216 price_max?: string;217 on_sale?: string; // "1" = en solde seulement218 q?: string;219 sort?: string; // SortKey220 limit?: string;221 offset?: string;222}223224export function fetchProducts(f: ProductFilters) {225 const params = new URLSearchParams();226 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);227 return get<{ total: number; count: number; products: Product[] }>(228 `/api/products?${params}`229 );230}231232export const fetchProduct = (uid: string) =>233 get<ProductDetail>(`/api/products/${encodeURIComponent(uid)}`);234export const fetchFacets = (category?: string) =>235 get<Facets>(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`);236export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");237export const fetchStats = () => get<Stats>("/api/stats");238export const statsDetailed = () => get<DetailedStats>("/api/stats/detailed");239240// --- Formats ------------------------------------------------------------------241/** 4.99 -> « 4,99 $ » (les prix d'épicerie gardent les cents) */242export const fmtPrice = (p: number | null | undefined, label?: string) =>243 p != null244 ? p.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"245 : label || "Prix non affiché";246247/** Rabais relatif en % (positif), ou null si non applicable. */248export function discountPct(p: Product): number | null {249 if (p.price == null || p.regular_price == null || p.regular_price <= p.price) return null;250 return Math.round(((p.regular_price - p.price) / p.regular_price) * 100);251}252253/** Timestamp Unix -> « 12 août 2026, 13 h 05 » */254export const fmtTs = (ts: number | null | undefined) =>255 ts ? new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) : "—";256