# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois (marketplace de découverte) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # schema.py : modèles standardisés Product / Store + normalisation # ----------------------------------------------------------------------------- """Chaque connecteur, peu importe la plateforme ecommerce de la boutique, produit des objets `Product` conformes à ce schéma. Le pipeline (ingest.py) fait le diff avec la base et le frontend consomme un format unique.""" from __future__ import annotations import hashlib import json import re import unicodedata from dataclasses import dataclass, field, asdict REGIONS = [ "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine", "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec", ] # Taxonomie produit Fabri-Ka (clé canonique -> libellé FR + mots-clés de match) CATEGORIES: dict[str, tuple[str, list[str]]] = { "erable": ("Érable & miel", ["erable", "maple", "sirop", "miel", "honey", "hydromel"]), "epicerie": ("Épicerie fine", ["confiture", "jam", "sauce", "epice", "spice", "condiment", "vinaigre", "huile", "moutarde", "ketchup", "marinade", "tartinade", "sel ", "sucre", "farine", "grain", "cereale", "granola", "collation", "snack", "chip", "craquelin", "sans gluten", "vrac", "conserve", "pesto", "bouillon"]), "cafe_the": ("Café & thé", ["cafe", "coffee", "the ", "tisane", "tea", "infusion", "espresso", "matcha"]), "chocolat": ("Chocolat & confiseries", ["chocolat", "chocolate", "bonbon", "candy", "caramel", "confiserie", "sucrerie", "guimauve", "fudge"]), "boulangerie": ("Boulangerie & pâtisserie", ["pain", "bread", "patisserie", "biscuit", "cookie", "gateau", "brioche", "viennoiserie", "tarte"]), "viande_poisson": ("Viandes & poissons", ["viande", "meat", "boucherie", "charcuterie", "saucisse", "jerky", "poisson", "fish", "fruits de mer", "seafood", "fume", "smoked", "canard", "porc", "boeuf", "bison", "cerf"]), "fromage_laitier": ("Fromages & produits laitiers", ["fromage", "cheese", "laitier", "dairy", "yogourt", "beurre", "creme glacee"]), "boissons": ("Boissons", ["kombucha", "jus", "juice", "limonade", "boisson", "drink", "cocktail", "sirop simple", "tonic", "soda", "eau petillante"]), "alcool": ("Bières, vins & spiritueux", ["biere", "beer", "vin ", "wine", "cidre", "cider", "gin", "vodka", "whisky", "spiritueux", "hydromel", "brasserie"]), "sante_beaute": ("Beauté & soins", ["savon", "soap", "cosmetique", "creme", "skincare", "shampoing", "barbe", "beard", "baume", "lotion", "deodorant", "parfum", "bain", "bath", "chandelle de massage", "serum", "soin"]), "maison": ("Maison & déco", ["chandelle", "bougie", "candle", "deco", "coussin", "vaisselle", "ceramique", "poterie", "pottery", "verre", "planche", "ustensile", "tablier", "linge", "literie", "couverture", "lampe", "meuble", "furniture", "bois", "woodwork", "menuiserie"]), "mode": ("Mode & accessoires", ["vetement", "clothing", "t-shirt", "tshirt", "chandail", "hoodie", "tuque", "casquette", "chapeau", "foulard", "mitaine", "bas ", "chaussette", "manteau", "robe", "jupe", "pantalon", "legging", "sac ", "handbag", "cuir", "leather", "portefeuille", "ceinture"]), "bijoux": ("Bijoux", ["bijou", "jewel", "collier", "necklace", "bracelet", "bague", "ring", "boucle", "earring", "pendentif"]), "art": ("Art & artisanat", ["oeuvre", "art ", "print", "affiche", "poster", "illustration", "peinture", "sculpture", "photographie", "carte de souhait", "papeterie", "stationery", "carnet", "sticker", "autocollant", "macrame"]), "enfants": ("Enfants & bébés", ["bebe", "baby", "enfant", "kid", "jouet", "toy", "doudou", "hochet", "couche", "puericulture", "peluche", "figurine", "casse-tete", "puzzle", "bricolage", "jeu de societe", "jeux de societe", "lego", "poupee"]), "animaux": ("Animaux", ["chien", "dog", "chat", "cat", "animaux", "pet ", "gaterie", "laisse", "collier pour", "litiere"]), "plein_air": ("Plein air & sport", ["plein air", "outdoor", "camping", "randonnee", "velo", "bike", "peche", "chasse", "kayak", "ski", "raquette", "sport"]), "jardin": ("Jardin & agriculture", ["jardin", "garden", "semence", "seed", "plante", "compost", "serre", "potager", "engrais"]), "industriel": ("Manufacturier & spécialisé", ["outil", "tool", "equipement", "industriel", "machine", "piece", "quincaillerie", "electronique", "remorque", "trailer", "essieu", "attache", "pneu", "frein", "garde-boue", "treuil", "moteur", "hitch", "auto", "camion", "vehicule", "soudure", "boulon"]), "autre": ("Autre", []), } def strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def infer_category(*texts: str) -> str: """Devine la catégorie canonique à partir de textes bruts (type, tags, titre).""" blob = " " + strip_accents(" ".join(t for t in texts if t).lower()) + " " best, best_hits = "autre", 0 for key, (_, kws) in CATEGORIES.items(): hits = sum(1 for kw in kws if kw in blob) if hits > best_hits: best, best_hits = key, hits return best def parse_price(raw) -> float | None: """'24,95 $' | '$24.95' | 24.95 -> 24.95 (CAD).""" if raw is None: return None if isinstance(raw, (int, float)): return float(raw) if raw > 0 else None s = re.sub(r"[^\d,.]", "", str(raw)) if not s: return None if "," in s and "." not in s: s = s.replace(",", ".") else: s = s.replace(",", "") try: v = float(s) return v if v > 0 else None except ValueError: return None @dataclass class Product: """Produit standardisé Fabri-Ka.""" store_id: str # domaine canonique de la boutique external_id: str # identifiant chez la source url: str # page produit chez la boutique title: str = "" description: str = "" # texte court, sans HTML price: float | None = None # CAD, le plus bas des variantes price_max: float | None = None # CAD, le plus haut des variantes compare_at_price: float | None = None currency: str = "CAD" images: list[str] = field(default_factory=list) category: str = "" # clé canonique (CATEGORIES) product_type: str = "" # valeur brute de la source tags: list[str] = field(default_factory=list) vendor: str = "" # marque affichée par la boutique available: bool | None = None @property def uid(self) -> str: return hashlib.sha1(f"{self.store_id}::{self.external_id}".encode()).hexdigest()[:20] def finalize(self) -> "Product": import html as _html self.title = _html.unescape(re.sub(r"\s+", " ", self.title or "")).strip()[:300] self.description = re.sub(r"<[^>]+>", " ", self.description or "") self.description = _html.unescape(re.sub(r"\s+", " ", self.description)).strip()[:600] if not self.category: self.category = infer_category(self.product_type, " ".join(self.tags), self.title, self.description[:200]) self.images = [i for i in self.images if isinstance(i, str) and i.startswith("http")][:8] return self def content_hash(self) -> str: basis = json.dumps([self.title, self.price, self.price_max, self.available, self.images[:1], self.description[:200]], ensure_ascii=False) return hashlib.sha1(basis.encode()).hexdigest()[:16] def to_row(self) -> dict: d = asdict(self) d["uid"] = self.uid return d