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# normalize.py : normalisation des champs bruts des connecteurs5# prix, formats (450 g, 2 L, 12 un), prix unitaire ($/100 g),6# catégories canoniques, marques.7# -----------------------------------------------------------------------------8from __future__ import annotations910import re11import unicodedata1213__all__ = [14 "strip_accents", "parse_price", "parse_size", "unit_price",15 "format_unit_price", "normalize_category", "clean_text",16]171819def strip_accents(text: str) -> str:20 return "".join(c for c in unicodedata.normalize("NFD", text or "")21 if unicodedata.category(c) != "Mn")222324def clean_text(text: str | None) -> str:25 """Espaces normalisés, entités résiduelles retirées."""26 if not text:27 return ""28 return re.sub(r"\s+", " ", text).strip()293031# ---------------------------------------------------------------------------32# Prix — "3,49 $", "$3.49", "2 / 5,00 $", "349¢"33# ---------------------------------------------------------------------------3435_PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$|\$\s*(\d{1,4}(?:[.,]\d{1,2})?)")36_MULTI_RE = re.compile(r"(\d+)\s*(?:/|pour|for)\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*\$")37_CENTS_RE = re.compile(r"(\d{1,3})\s*¢")383940def parse_price(label: str | float | int | None) -> float | None:41 """Extrait un prix unitaire ($ CAD) d'un libellé source.4243 Gère "2 / 5,00 $" (prix multi-achat → prix à l'unité), "3,49 $",44 "$3.49" et "99 ¢". Retourne None si aucun prix affiché — jamais45 de prix inventé.46 """47 if label is None:48 return None49 if isinstance(label, (int, float)):50 return float(label) if label > 0 else None51 text = str(label)52 m = _MULTI_RE.search(text)53 if m:54 n, total = int(m.group(1)), float(m.group(2).replace(",", "."))55 if n > 0:56 return round(total / n, 2)57 m = _PRICE_RE.search(text)58 if m:59 raw = (m.group(1) or m.group(2)).replace(" ", "").replace(" ", "")60 return float(raw.replace(",", "."))61 m = _CENTS_RE.search(text)62 if m:63 return round(int(m.group(1)) / 100, 2)64 return None656667# ---------------------------------------------------------------------------68# Formats — "450 g", "1,5 L", "12 x 355 ml", "6 un", "environ 200 g"69# ---------------------------------------------------------------------------7071# unité canonique -> (facteur vers unité de base, unité de base)72_UNITS = {73 "kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"),74 "lb": (453.592, "g"), "lbs": (453.592, "g"), "oz": (28.3495, "g"),75 "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"),76 "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"),77 "ea": (1.0, "un"), "each": (1.0, "un"), "pk": (1.0, "un"),78}7980_SIZE_RE = re.compile(81 r"(?:(\d+)\s*[x×]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b",82 re.IGNORECASE)838485def parse_size(label: str | None) -> tuple[float, str] | None:86 """Extrait (quantité, unité de base) d'un format.8788 "450 g" -> (450, "g") ; "2 L" -> (2000, "ml") ;89 "12 x 355 ml" -> (4260, "ml") ; "6 un" -> (6, "un").90 """91 if not label:92 return None93 m = _SIZE_RE.search(strip_accents(label))94 if not m:95 return None96 mult = int(m.group(1)) if m.group(1) else 197 qty = float(m.group(2).replace(",", "."))98 factor, base = _UNITS[m.group(3).lower()]99 total = mult * qty * factor100 return (total, base) if total > 0 else None101102103def unit_price(price: float | None, size_label: str | None) -> tuple[float, str] | None:104 """Prix par unité comparable : $/100 g, $/100 ml ou $/un."""105 if price is None:106 return None107 size = parse_size(size_label)108 if not size:109 return None110 qty, base = size111 if base == "un":112 return (round(price / qty, 2), "un")113 return (round(price / qty * 100, 3), f"100 {base}")114115116def format_unit_price(up: tuple[float, str] | None) -> str:117 if not up:118 return ""119 value, base = up120 return f"{value:.2f} $ / {base}".replace(".", ",")121122123# ---------------------------------------------------------------------------124# Catégories canoniques — chaque bannière a sa propre taxonomie ; on la125# rabat sur un ensemble commun pour permettre les filtres inter-épiceries.126# ---------------------------------------------------------------------------127128CATEGORIES = [129 "Fruits et légumes",130 "Viandes et volailles",131 "Poissons et fruits de mer",132 "Charcuteries et fromages",133 "Produits laitiers et œufs",134 "Boulangerie",135 "Surgelés",136 "Garde-manger",137 "Boissons",138 "Collations et confiseries",139 "Prêt-à-manger",140 "Bio et santé",141 "Bébé",142 "Animaux",143 "Maison et entretien",144 "Santé et beauté",145 "Autres",146]147148# mots-clés (sans accents, minuscules) -> catégorie canonique ; l'ordre compte,149# le premier motif trouvé gagne.150_CATEGORY_RULES: list[tuple[str, str]] = [151 ("fruit", "Fruits et légumes"), ("legume", "Fruits et légumes"),152 ("produce", "Fruits et légumes"), ("vegetable", "Fruits et légumes"),153 ("charcuterie", "Charcuteries et fromages"), ("deli", "Charcuteries et fromages"),154 ("fromage", "Charcuteries et fromages"), ("cheese", "Charcuteries et fromages"),155 ("viande", "Viandes et volailles"), ("volaille", "Viandes et volailles"),156 ("boeuf", "Viandes et volailles"), ("porc", "Viandes et volailles"),157 ("poulet", "Viandes et volailles"), ("meat", "Viandes et volailles"),158 ("poisson", "Poissons et fruits de mer"), ("fruits de mer", "Poissons et fruits de mer"),159 ("seafood", "Poissons et fruits de mer"), ("poissonnerie", "Poissons et fruits de mer"),160 ("laitier", "Produits laitiers et œufs"), ("lait", "Produits laitiers et œufs"),161 ("oeuf", "Produits laitiers et œufs"), ("dairy", "Produits laitiers et œufs"),162 ("yogourt", "Produits laitiers et œufs"), ("egg", "Produits laitiers et œufs"),163 ("boulangerie", "Boulangerie"), ("pain", "Boulangerie"),164 ("patisserie", "Boulangerie"), ("bakery", "Boulangerie"),165 ("surgele", "Surgelés"), ("congele", "Surgelés"), ("frozen", "Surgelés"),166 ("boisson", "Boissons"), ("jus", "Boissons"), ("eau", "Boissons"),167 ("cafe", "Boissons"), ("the ", "Boissons"), ("biere", "Boissons"),168 ("vin", "Boissons"), ("beverage", "Boissons"), ("drink", "Boissons"),169 ("collation", "Collations et confiseries"), ("croustille", "Collations et confiseries"),170 ("bonbon", "Collations et confiseries"), ("chocolat", "Collations et confiseries"),171 ("confiserie", "Collations et confiseries"), ("snack", "Collations et confiseries"),172 ("candy", "Collations et confiseries"), ("biscuit", "Collations et confiseries"),173 ("pret-a-manger", "Prêt-à-manger"), ("prets-a-manger", "Prêt-à-manger"),174 ("repas prepare", "Prêt-à-manger"), ("mets prepare", "Prêt-à-manger"),175 ("prepared", "Prêt-à-manger"), ("ready", "Prêt-à-manger"),176 ("biologique", "Bio et santé"), ("organic", "Bio et santé"),177 ("naturel", "Bio et santé"), ("sante et mieux", "Bio et santé"),178 ("vrac", "Bio et santé"), ("supplement", "Bio et santé"),179 ("bebe", "Bébé"), ("baby", "Bébé"), ("couche", "Bébé"),180 ("animaux", "Animaux"), ("animal", "Animaux"), ("pet", "Animaux"),181 ("nettoyage", "Maison et entretien"), ("entretien", "Maison et entretien"),182 ("menager", "Maison et entretien"), ("maison", "Maison et entretien"),183 ("cleaning", "Maison et entretien"), ("household", "Maison et entretien"),184 ("papier", "Maison et entretien"), ("lessive", "Maison et entretien"),185 ("beaute", "Santé et beauté"), ("pharmacie", "Santé et beauté"),186 ("hygiene", "Santé et beauté"), ("soin", "Santé et beauté"),187 ("health", "Santé et beauté"), ("beauty", "Santé et beauté"),188 ("cosmetique", "Santé et beauté"),189 ("epicerie", "Garde-manger"), ("garde-manger", "Garde-manger"),190 ("pantry", "Garde-manger"), ("grocery", "Garde-manger"),191 ("conserve", "Garde-manger"), ("pates", "Garde-manger"),192 ("cereale", "Garde-manger"), ("condiment", "Garde-manger"),193 ("sauce", "Garde-manger"), ("dejeuner", "Garde-manger"),194 ("cuisine du monde", "Garde-manger"), ("international", "Garde-manger"),195]196197198def normalize_category(raw: str | None) -> str:199 """Rabat une catégorie source (fr/en, n'importe quelle bannière) sur la200 taxonomie canonique Food-Ka. Retourne "Autres" si rien ne correspond."""201 if not raw:202 return "Autres"203 if raw in CATEGORIES:204 return raw205 key = strip_accents(raw).lower()206 for pattern, cat in _CATEGORY_RULES:207 if pattern in key:208 return cat209 return "Autres"210