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# schema.py : modèle de données standardisé (Product) + enrichissement central5# -----------------------------------------------------------------------------6"""Schéma standard d'un produit d'épicerie (Product) et normalisation.78Chaque connecteur, peu importe la bannière (Metro, IGA, Maxi…), doit9produire des objets `Product` conformes à ce schéma. La méthode10`finalize()` applique ensuite la couche de normalisation commune11(foodka/normalize.py) : prix, prix unitaire $/100 g, catégorie12canonique… — les connecteurs restent simples et remplissent les13champs bruts.14"""15from __future__ import annotations1617import hashlib18import json19from dataclasses import dataclass, field, asdict2021from .normalize import ( # ré-exportés pour les connecteurs22 clean_text,23 format_unit_price,24 normalize_category,25 parse_price,26 parse_size,27 strip_accents,28 unit_price,29)3031__all__ = [32 "Product", "normalize_category", "parse_price", "parse_size",33 "clean_text", "strip_accents",34]353637@dataclass38class Product:39 """Produit d'épicerie standardisé Food-Ka."""4041 source: str # id de la bannière (voir data/sources.json)42 external_id: str # identifiant/SKU chez la source43 url: str # fiche produit chez la source44 name: str = "" # ex. "Beurre d'arachide croquant"45 brand: str = "" # ex. "Kraft"46 category: str = "" # catégorie canonique Food-Ka47 category_raw: str = "" # taxonomie originale de la bannière48 size_label: str = "" # format affiché, ex. "500 g", "2 L"49 price: float | None = None # prix courant ($ CAD), rabais inclus50 regular_price: float | None = None # prix régulier si le produit est en solde51 price_label: str = "" # texte original (ex. "2 / 5,00 $")52 on_sale: bool = False # produit en promotion53 unit_price: float | None = None # prix par unité comparable54 unit_price_label: str = "" # ex. "0,70 $ / 100 g"55 in_stock: bool | None = None # None = inconnu56 description: str = ""57 keywords: list[str] = field(default_factory=list) # tags source (bio, sans gluten…)58 details: dict = field(default_factory=dict) # champs structurés (JSON)59 images: list[str] = field(default_factory=list) # URLs absolues6061 @property62 def uid(self) -> str:63 return f"{self.source}:{self.external_id}"6465 def content_hash(self) -> str:66 """Hash du contenu pour la détection de changements (pseudo-webhook)."""67 payload = asdict(self)68 blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)69 return hashlib.sha256(blob.encode("utf-8")).hexdigest()7071 def finalize(self) -> "Product":72 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.7374 Idempotent ; ne remplace jamais une valeur explicite du connecteur.75 """76 self.name = clean_text(self.name)77 self.brand = clean_text(self.brand)78 self.size_label = clean_text(self.size_label)79 self.description = clean_text(self.description)8081 if not self.category:82 self.category = normalize_category(self.category_raw)8384 if self.price is None:85 self.price = parse_price(self.price_label)8687 # cohérence solde : un "prix régulier" égal ou inférieur au prix88 # courant n'apporte rien — on le laisse tomber.89 if self.regular_price is not None and self.price is not None:90 if self.regular_price <= self.price:91 self.regular_price = None92 else:93 self.on_sale = True94 if self.regular_price is None and not self.on_sale:95 self.on_sale = False9697 if self.unit_price is None:98 up = unit_price(self.price, self.size_label)99 if up:100 self.unit_price = up[0]101 if not self.unit_price_label:102 self.unit_price_label = format_unit_price(up)103104 # garde-fou : prix absurdes (0 $, > 2000 $) = donnée cassée, pas un prix105 if self.price is not None and not (0 < self.price <= 2000):106 self.price = None107 self.regular_price = None108 self.on_sale = False109110 return self111