SPB Git

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%
7.9 KB · 256 lines typescript
Raw Blame History
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