SPB Git

spb/fabri-ka Public

Agrégateur de produits québécois — www.fabri-ka.com

HTML 57.9% Python 18.6% TypeScript 15.6% CSS 7.8%
9.1 KB · 161 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois (marketplace de découverte)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèles standardisés Product / Store + normalisation5# -----------------------------------------------------------------------------6"""Chaque connecteur, peu importe la plateforme ecommerce de la boutique,7produit des objets `Product` conformes à ce schéma. Le pipeline (ingest.py)8fait le diff avec la base et le frontend consomme un format unique."""9from __future__ import annotations1011import hashlib12import json13import re14import unicodedata15from dataclasses import dataclass, field, asdict1617REGIONS = [18    "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale",19    "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue",20    "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",21    "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides",22    "Montérégie", "Centre-du-Québec",23]2425# Taxonomie produit Fabri-Ka (clé canonique -> libellé FR + mots-clés de match)26CATEGORIES: dict[str, tuple[str, list[str]]] = {27    "erable": ("Érable & miel", ["erable", "maple", "sirop", "miel", "honey", "hydromel"]),28    "epicerie": ("Épicerie fine", ["confiture", "jam", "sauce", "epice", "spice", "condiment",29                                    "vinaigre", "huile", "moutarde", "ketchup", "marinade",30                                    "tartinade", "sel ", "sucre", "farine", "grain", "cereale",31                                    "granola", "collation", "snack", "chip", "craquelin",32                                    "sans gluten", "vrac", "conserve", "pesto", "bouillon"]),33    "cafe_the": ("Café & thé", ["cafe", "coffee", "the ", "tisane", "tea", "infusion", "espresso", "matcha"]),34    "chocolat": ("Chocolat & confiseries", ["chocolat", "chocolate", "bonbon", "candy", "caramel",35                                             "confiserie", "sucrerie", "guimauve", "fudge"]),36    "boulangerie": ("Boulangerie & pâtisserie", ["pain", "bread", "patisserie", "biscuit", "cookie",37                                                  "gateau", "brioche", "viennoiserie", "tarte"]),38    "viande_poisson": ("Viandes & poissons", ["viande", "meat", "boucherie", "charcuterie", "saucisse",39                                               "jerky", "poisson", "fish", "fruits de mer", "seafood",40                                               "fume", "smoked", "canard", "porc", "boeuf", "bison", "cerf"]),41    "fromage_laitier": ("Fromages & produits laitiers", ["fromage", "cheese", "laitier", "dairy",42                                                          "yogourt", "beurre", "creme glacee"]),43    "boissons": ("Boissons", ["kombucha", "jus", "juice", "limonade", "boisson", "drink", "cocktail",44                               "sirop simple", "tonic", "soda", "eau petillante"]),45    "alcool": ("Bières, vins & spiritueux", ["biere", "beer", "vin ", "wine", "cidre", "cider", "gin",46                                              "vodka", "whisky", "spiritueux", "hydromel", "brasserie"]),47    "sante_beaute": ("Beauté & soins", ["savon", "soap", "cosmetique", "creme", "skincare", "shampoing",48                                         "barbe", "beard", "baume", "lotion", "deodorant", "parfum",49                                         "bain", "bath", "chandelle de massage", "serum", "soin"]),50    "maison": ("Maison & déco", ["chandelle", "bougie", "candle", "deco", "coussin", "vaisselle",51                                  "ceramique", "poterie", "pottery", "verre", "planche", "ustensile",52                                  "tablier", "linge", "literie", "couverture", "lampe", "meuble",53                                  "furniture", "bois", "woodwork", "menuiserie"]),54    "mode": ("Mode & accessoires", ["vetement", "clothing", "t-shirt", "tshirt", "chandail", "hoodie",55                                     "tuque", "casquette", "chapeau", "foulard", "mitaine", "bas ",56                                     "chaussette", "manteau", "robe", "jupe", "pantalon", "legging",57                                     "sac ", "handbag", "cuir", "leather", "portefeuille", "ceinture"]),58    "bijoux": ("Bijoux", ["bijou", "jewel", "collier", "necklace", "bracelet", "bague", "ring",59                           "boucle", "earring", "pendentif"]),60    "art": ("Art & artisanat", ["oeuvre", "art ", "print", "affiche", "poster", "illustration",61                                 "peinture", "sculpture", "photographie", "carte de souhait",62                                 "papeterie", "stationery", "carnet", "sticker", "autocollant", "macrame"]),63    "enfants": ("Enfants & bébés", ["bebe", "baby", "enfant", "kid", "jouet", "toy", "doudou",64                                     "hochet", "couche", "puericulture", "peluche", "figurine",65                                     "casse-tete", "puzzle", "bricolage", "jeu de societe",66                                     "jeux de societe", "lego", "poupee"]),67    "animaux": ("Animaux", ["chien", "dog", "chat", "cat", "animaux", "pet ", "gaterie", "laisse",68                             "collier pour", "litiere"]),69    "plein_air": ("Plein air & sport", ["plein air", "outdoor", "camping", "randonnee", "velo", "bike",70                                         "peche", "chasse", "kayak", "ski", "raquette", "sport"]),71    "jardin": ("Jardin & agriculture", ["jardin", "garden", "semence", "seed", "plante", "compost",72                                         "serre", "potager", "engrais"]),73    "industriel": ("Manufacturier & spécialisé", ["outil", "tool", "equipement", "industriel",74                                                   "machine", "piece", "quincaillerie", "electronique",75                                                   "remorque", "trailer", "essieu", "attache", "pneu",76                                                   "frein", "garde-boue", "treuil", "moteur", "hitch",77                                                   "auto", "camion", "vehicule", "soudure", "boulon"]),78    "autre": ("Autre", []),79}808182def strip_accents(s: str) -> str:83    return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")848586def infer_category(*texts: str) -> str:87    """Devine la catégorie canonique à partir de textes bruts (type, tags, titre)."""88    blob = " " + strip_accents(" ".join(t for t in texts if t).lower()) + " "89    best, best_hits = "autre", 090    for key, (_, kws) in CATEGORIES.items():91        hits = sum(1 for kw in kws if kw in blob)92        if hits > best_hits:93            best, best_hits = key, hits94    return best959697def parse_price(raw) -> float | None:98    """'24,95 $' | '$24.95' | 24.95 -> 24.95 (CAD)."""99    if raw is None:100        return None101    if isinstance(raw, (int, float)):102        return float(raw) if raw > 0 else None103    s = re.sub(r"[^\d,.]", "", str(raw))104    if not s:105        return None106    if "," in s and "." not in s:107        s = s.replace(",", ".")108    else:109        s = s.replace(",", "")110    try:111        v = float(s)112        return v if v > 0 else None113    except ValueError:114        return None115116117@dataclass118class Product:119    """Produit standardisé Fabri-Ka."""120121    store_id: str                    # domaine canonique de la boutique122    external_id: str                 # identifiant chez la source123    url: str                         # page produit chez la boutique124    title: str = ""125    description: str = ""            # texte court, sans HTML126    price: float | None = None       # CAD, le plus bas des variantes127    price_max: float | None = None   # CAD, le plus haut des variantes128    compare_at_price: float | None = None129    currency: str = "CAD"130    images: list[str] = field(default_factory=list)131    category: str = ""               # clé canonique (CATEGORIES)132    product_type: str = ""           # valeur brute de la source133    tags: list[str] = field(default_factory=list)134    vendor: str = ""                 # marque affichée par la boutique135    available: bool | None = None136137    @property138    def uid(self) -> str:139        return hashlib.sha1(f"{self.store_id}::{self.external_id}".encode()).hexdigest()[:20]140141    def finalize(self) -> "Product":142        import html as _html143        self.title = _html.unescape(re.sub(r"\s+", " ", self.title or "")).strip()[:300]144        self.description = re.sub(r"<[^>]+>", " ", self.description or "")145        self.description = _html.unescape(re.sub(r"\s+", " ", self.description)).strip()[:600]146        if not self.category:147            self.category = infer_category(self.product_type, " ".join(self.tags),148                                           self.title, self.description[:200])149        self.images = [i for i in self.images if isinstance(i, str) and i.startswith("http")][:8]150        return self151152    def content_hash(self) -> str:153        basis = json.dumps([self.title, self.price, self.price_max, self.available,154                            self.images[:1], self.description[:200]], ensure_ascii=False)155        return hashlib.sha1(basis.encode()).hexdigest()[:16]156157    def to_row(self) -> dict:158        d = asdict(self)159        d["uid"] = self.uid160        return d161