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%
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