SPB Git

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%
8.7 KB · 210 lines python
Raw Blame History
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