# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : normalisation des champs bruts des connecteurs # prix, formats (450 g, 2 L, 12 un), prix unitaire ($/100 g), # catégories canoniques, marques. # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata __all__ = [ "strip_accents", "parse_price", "parse_size", "unit_price", "format_unit_price", "normalize_category", "clean_text", ] def strip_accents(text: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", text or "") if unicodedata.category(c) != "Mn") def clean_text(text: str | None) -> str: """Espaces normalisés, entités résiduelles retirées.""" if not text: return "" return re.sub(r"\s+", " ", text).strip() # --------------------------------------------------------------------------- # Prix — "3,49 $", "$3.49", "2 / 5,00 $", "349¢" # --------------------------------------------------------------------------- _PRICE_RE = re.compile(r"(\d{1,4}(?:[  ]\d{3})*(?:[.,]\d{1,2})?)\s*\$|\$\s*(\d{1,4}(?:[.,]\d{1,2})?)") _MULTI_RE = re.compile(r"(\d+)\s*(?:/|pour|for)\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*\$") _CENTS_RE = re.compile(r"(\d{1,3})\s*¢") def parse_price(label: str | float | int | None) -> float | None: """Extrait un prix unitaire ($ CAD) d'un libellé source. Gère "2 / 5,00 $" (prix multi-achat → prix à l'unité), "3,49 $", "$3.49" et "99 ¢". Retourne None si aucun prix affiché — jamais de prix inventé. """ if label is None: return None if isinstance(label, (int, float)): return float(label) if label > 0 else None text = str(label) m = _MULTI_RE.search(text) if m: n, total = int(m.group(1)), float(m.group(2).replace(",", ".")) if n > 0: return round(total / n, 2) m = _PRICE_RE.search(text) if m: raw = (m.group(1) or m.group(2)).replace(" ", "").replace(" ", "") return float(raw.replace(",", ".")) m = _CENTS_RE.search(text) if m: return round(int(m.group(1)) / 100, 2) return None # --------------------------------------------------------------------------- # Formats — "450 g", "1,5 L", "12 x 355 ml", "6 un", "environ 200 g" # --------------------------------------------------------------------------- # unité canonique -> (facteur vers unité de base, unité de base) _UNITS = { "kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"), "lb": (453.592, "g"), "lbs": (453.592, "g"), "oz": (28.3495, "g"), "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"), "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"), "ea": (1.0, "un"), "each": (1.0, "un"), "pk": (1.0, "un"), } _SIZE_RE = re.compile( r"(?:(\d+)\s*[x×]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b", re.IGNORECASE) def parse_size(label: str | None) -> tuple[float, str] | None: """Extrait (quantité, unité de base) d'un format. "450 g" -> (450, "g") ; "2 L" -> (2000, "ml") ; "12 x 355 ml" -> (4260, "ml") ; "6 un" -> (6, "un"). """ if not label: return None m = _SIZE_RE.search(strip_accents(label)) if not m: return None mult = int(m.group(1)) if m.group(1) else 1 qty = float(m.group(2).replace(",", ".")) factor, base = _UNITS[m.group(3).lower()] total = mult * qty * factor return (total, base) if total > 0 else None def unit_price(price: float | None, size_label: str | None) -> tuple[float, str] | None: """Prix par unité comparable : $/100 g, $/100 ml ou $/un.""" if price is None: return None size = parse_size(size_label) if not size: return None qty, base = size if base == "un": return (round(price / qty, 2), "un") return (round(price / qty * 100, 3), f"100 {base}") def format_unit_price(up: tuple[float, str] | None) -> str: if not up: return "" value, base = up return f"{value:.2f} $ / {base}".replace(".", ",") # --------------------------------------------------------------------------- # Catégories canoniques — chaque bannière a sa propre taxonomie ; on la # rabat sur un ensemble commun pour permettre les filtres inter-épiceries. # --------------------------------------------------------------------------- CATEGORIES = [ "Fruits et légumes", "Viandes et volailles", "Poissons et fruits de mer", "Charcuteries et fromages", "Produits laitiers et œufs", "Boulangerie", "Surgelés", "Garde-manger", "Boissons", "Collations et confiseries", "Prêt-à-manger", "Bio et santé", "Bébé", "Animaux", "Maison et entretien", "Santé et beauté", "Autres", ] # mots-clés (sans accents, minuscules) -> catégorie canonique ; l'ordre compte, # le premier motif trouvé gagne. _CATEGORY_RULES: list[tuple[str, str]] = [ ("fruit", "Fruits et légumes"), ("legume", "Fruits et légumes"), ("produce", "Fruits et légumes"), ("vegetable", "Fruits et légumes"), ("charcuterie", "Charcuteries et fromages"), ("deli", "Charcuteries et fromages"), ("fromage", "Charcuteries et fromages"), ("cheese", "Charcuteries et fromages"), ("viande", "Viandes et volailles"), ("volaille", "Viandes et volailles"), ("boeuf", "Viandes et volailles"), ("porc", "Viandes et volailles"), ("poulet", "Viandes et volailles"), ("meat", "Viandes et volailles"), ("poisson", "Poissons et fruits de mer"), ("fruits de mer", "Poissons et fruits de mer"), ("seafood", "Poissons et fruits de mer"), ("poissonnerie", "Poissons et fruits de mer"), ("laitier", "Produits laitiers et œufs"), ("lait", "Produits laitiers et œufs"), ("oeuf", "Produits laitiers et œufs"), ("dairy", "Produits laitiers et œufs"), ("yogourt", "Produits laitiers et œufs"), ("egg", "Produits laitiers et œufs"), ("boulangerie", "Boulangerie"), ("pain", "Boulangerie"), ("patisserie", "Boulangerie"), ("bakery", "Boulangerie"), ("surgele", "Surgelés"), ("congele", "Surgelés"), ("frozen", "Surgelés"), ("boisson", "Boissons"), ("jus", "Boissons"), ("eau", "Boissons"), ("cafe", "Boissons"), ("the ", "Boissons"), ("biere", "Boissons"), ("vin", "Boissons"), ("beverage", "Boissons"), ("drink", "Boissons"), ("collation", "Collations et confiseries"), ("croustille", "Collations et confiseries"), ("bonbon", "Collations et confiseries"), ("chocolat", "Collations et confiseries"), ("confiserie", "Collations et confiseries"), ("snack", "Collations et confiseries"), ("candy", "Collations et confiseries"), ("biscuit", "Collations et confiseries"), ("pret-a-manger", "Prêt-à-manger"), ("prets-a-manger", "Prêt-à-manger"), ("repas prepare", "Prêt-à-manger"), ("mets prepare", "Prêt-à-manger"), ("prepared", "Prêt-à-manger"), ("ready", "Prêt-à-manger"), ("biologique", "Bio et santé"), ("organic", "Bio et santé"), ("naturel", "Bio et santé"), ("sante et mieux", "Bio et santé"), ("vrac", "Bio et santé"), ("supplement", "Bio et santé"), ("bebe", "Bébé"), ("baby", "Bébé"), ("couche", "Bébé"), ("animaux", "Animaux"), ("animal", "Animaux"), ("pet", "Animaux"), ("nettoyage", "Maison et entretien"), ("entretien", "Maison et entretien"), ("menager", "Maison et entretien"), ("maison", "Maison et entretien"), ("cleaning", "Maison et entretien"), ("household", "Maison et entretien"), ("papier", "Maison et entretien"), ("lessive", "Maison et entretien"), ("beaute", "Santé et beauté"), ("pharmacie", "Santé et beauté"), ("hygiene", "Santé et beauté"), ("soin", "Santé et beauté"), ("health", "Santé et beauté"), ("beauty", "Santé et beauté"), ("cosmetique", "Santé et beauté"), ("epicerie", "Garde-manger"), ("garde-manger", "Garde-manger"), ("pantry", "Garde-manger"), ("grocery", "Garde-manger"), ("conserve", "Garde-manger"), ("pates", "Garde-manger"), ("cereale", "Garde-manger"), ("condiment", "Garde-manger"), ("sauce", "Garde-manger"), ("dejeuner", "Garde-manger"), ("cuisine du monde", "Garde-manger"), ("international", "Garde-manger"), ] def normalize_category(raw: str | None) -> str: """Rabat une catégorie source (fr/en, n'importe quelle bannière) sur la taxonomie canonique Food-Ka. Retourne "Autres" si rien ne correspond.""" if not raw: return "Autres" if raw in CATEGORIES: return raw key = strip_accents(raw).lower() for pattern, cat in _CATEGORY_RULES: if pattern in key: return cat return "Autres"