spb/food-ka Public
Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 57.7%
TypeScript 24.9%
CSS 16.7%
HTML 0.6%
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// -----------------------------------------------------------------------------67export interface Product {8 uid: string;9 source: string; // id de la bannière (metro, iga, maxi…)10 external_id: string;11 url: string; // fiche produit chez la source12 name: string;13 brand: string;14 category: string; // catégorie canonique Food-Ka15 category_raw: string; // taxonomie originale de la bannière16 size_label: string; // format affiché, ex. "500 g", "2 L"17 price: number | null; // prix courant ($ CAD), rabais inclus18 regular_price: number | null;// prix régulier si le produit est en solde19 price_label: string; // texte original (ex. "2 / 5,00 $")20 on_sale: boolean;21 unit_price: number | null; // prix par unité comparable22 unit_price_label: string; // ex. "0,70 $ / 100 g"23 in_stock: boolean | null; // null = inconnu24 description: string;25 keywords: string[]; // tags source (bio, sans gluten…)26 details: Record<string, unknown>;27 images: string[];28 first_seen?: number;29 last_seen: number;30 updated_at: number;31 active: number;32}3334/** Fiche produit : produit + historique de prix + comparaison inter-bannières */35export interface ProductDetail extends Product {36 price_history: { ts: number; price: number | null }[];37 compare: Product[];38}3940export interface Facets {41 categories: { category: string; n: number }[];42 brands: { brand: string; n: number }[];43 sources: { source: string; n: number }[];44 on_sale: number;45}4647export interface Source {48 id: string;49 name: string;50 url: string;51 catalog_url: string;52 connector: string | null;53 status: string;54 region: string;55 tech: string;56 active_products: number;57 last_sync: number | null;58 notes?: string;59}6061export interface SyncEntry {62 id: number;63 source: string;64 ts: number;65 found: number;66 added: number;67 updated: number;68 removed: number;69 ok: number;70 message: string | null;71}7273export interface Stats {74 total: number;75 on_sale: number;76 sources: number;77 categories: number;78 avg_price: number | null;79 by_source: { source: string; n: number; sales: number; avg_price: number | null }[];80 by_category: { category: string; n: number; avg_price: number | null }[];81 deals: Product[];82 recent_syncs: SyncEntry[];83}8485// --- Agrégats détaillés du marché (GET /api/stats/detailed) -------------------86export interface SourceMarketStats {87 source: string;88 n: number;89 sales: number;90 sale_share: number; // 0..191 avg_price: number | null;92 median_price: number | null;93 avg_discount_pct: number | null; // % moyen des rabais en cours94 max_discount_pct: number | null;95 with_unit_price: number;96 no_price: number;97}9899export interface MatrixCell { median_price: number | null; n: number; }100101export interface BasketItem {102 item: string; // ex. « Lait », « Œufs »103 by_source: Record<string, MatrixCell>; // prix médian par bannière104}105106export interface BasketTotal { source: string; items: number; total: number; }107108export interface PriceDrop {109 uid: string;110 name: string;111 source: string;112 old_price: number;113 new_price: number;114 drop_pct: number;115}116117export interface DetailedStats {118 global: {119 total: number;120 on_sale: number;121 sale_share: number; // 0..1122 sources: number;123 categories: number;124 brands: number;125 avg_price: number | null;126 median_price: number | null;127 price_changes_7d: number;128 };129 by_source: SourceMarketStats[];130 category_matrix: Record<string, Record<string, MatrixCell>>;131 basket: BasketItem[];132 basket_totals: BasketTotal[]; // triés du panier le moins cher au plus cher133 price_drops: PriceDrop[];134 price_distribution: { range: string; n: number }[];135}136137// --- Noms d'affichage des bannières ------------------------------------------138const SOURCE_NAMES: Record<string, string> = {139 metro: "Metro",140 superc: "Super C",141 iga: "IGA",142 provigo: "Provigo",143 maxi: "Maxi",144 walmart: "Walmart",145 adonis: "Marché Adonis",146 avril: "Avril",147 rachelle_bery: "Rachelle-Béry",148 pa: "PA Supermarché",149 mayrand: "Mayrand",150 tau: "Marché Tau",151 giant_tiger: "Giant Tiger",152 club_entrepot: "Club Entrepôt",153 loco: "LOCO",154 frenco: "Frenco",155 akhavan: "Akhavan",156 tt: "T&T Supermarket",157};158159export function registerSourceNames(sources: Source[]) {160 for (const s of sources) SOURCE_NAMES[s.id] = s.name;161}162163/** Nom d'affichage d'une bannière — id inconnu : joliment reformaté. */164export function sourceName(id: string): string {165 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];166 return id167 .split(/[_-]/)168 .filter(Boolean)169 .map((w) => w.charAt(0).toUpperCase() + w.slice(1))170 .join(" ");171}172173// Noms courts — à côté des logos (cartes, tableaux serrés)174const SOURCE_SHORT: Record<string, string> = {175 adonis: "Adonis",176 pa: "PA",177 giant_tiger: "G. Tiger",178 boite_a_grains: "B. à grains",179 aliments_merci: "Merci",180 bocoboco: "BocoBoco",181 epipresto: "ÉpiPresto",182 rachelle_bery: "Rachelle-Béry",183 tau: "Tau",184 maturin: "Maturin",185 club_entrepot: "Club Ent.",186 tt: "T&T",187};188189/** Nom court d'une bannière (logo + libellé compact). */190export const sourceShort = (id: string): string =>191 SOURCE_SHORT[id] ?? sourceName(id);192193async function get<T>(path: string): Promise<T> {194 const ctrl = new AbortController();195 const timer = setTimeout(() => ctrl.abort(), 20000);196 try {197 const res = await fetch(path, { signal: ctrl.signal });198 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);199 return (await res.json()) as T;200 } finally {201 clearTimeout(timer);202 }203}204205export type SortKey =206 | "price_asc" | "price_desc" | "unit_price" | "discount" | "name" | "recent";207208export interface ProductFilters {209 category?: string;210 source?: string;211 brand?: string;212 price_min?: string;213 price_max?: string;214 on_sale?: string; // "1" = en solde seulement215 q?: string;216 sort?: string; // SortKey217 limit?: string;218 offset?: string;219}220221export function fetchProducts(f: ProductFilters) {222 const params = new URLSearchParams();223 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);224 return get<{ total: number; count: number; products: Product[] }>(225 `/api/products?${params}`226 );227}228229export const fetchProduct = (uid: string) =>230 get<ProductDetail>(`/api/products/${encodeURIComponent(uid)}`);231export const fetchFacets = (category?: string) =>232 get<Facets>(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`);233export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");234export const fetchStats = () => get<Stats>("/api/stats");235export const statsDetailed = () => get<DetailedStats>("/api/stats/detailed");236237// --- Formats ------------------------------------------------------------------238/** 4.99 -> « 4,99 $ » (les prix d'épicerie gardent les cents) */239export const fmtPrice = (p: number | null | undefined, label?: string) =>240 p != null241 ? p.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"242 : label || "Prix non affiché";243244/** Rabais relatif en % (positif), ou null si non applicable. */245export function discountPct(p: Product): number | null {246 if (p.price == null || p.regular_price == null || p.regular_price <= p.price) return null;247 return Math.round(((p.regular_price - p.price) / p.regular_price) * 100);248}249250/** Timestamp Unix -> « 12 août 2026, 13 h 05 » */251export const fmtTs = (ts: number | null | undefined) =>252 ts ? new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) : "—";253