SPB Git

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%
4.5 KB · 111 lines python
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# 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