Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/normalize.py4# Desc: Couche de normalisation commune : nettoyage des noms/adresses,5# parsing des prix, classification des cuisines et du type6# d'établissement, diètes, fourchette de prix ($ à $$$$).7# Calquée sur louka/normalize.py (famille ·Ka).8# ==============================================================================9from __future__ import annotations1011import html12import re13import statistics1415from .regions import strip_accents1617__all__ = [18 "strip_accents", "clean_name", "clean_address", "parse_price",19 "classify_cuisines", "classify_establishment", "infer_dietary",20 "price_range_from_menu", "CUISINES", "PRICE_CONTEXTS",21]2223# Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué.24CUISINES = [25 "quebecois", "francais", "italien", "pizza", "burgers", "poulet",26 "bbq-grillades", "fruits-de-mer", "sushi-japonais", "chinois", "thai",27 "vietnamien", "coreen", "indien", "libanais-moyen-orient", "mexicain",28 "grec", "mediterraneen", "dejeuner-brunch", "cafe-dessert",29 "vegetarien-vegan", "fast-food", "autre",30]3132# Contextes de prix admissibles (CLAUDE.md §6.2) — un prix sans contexte est33# inutilisable ; `delivery` est majoré de 25-30 % vs la salle.34PRICE_CONTEXTS = ("dine-in", "takeout", "delivery")3536ESTABLISHMENT_TYPES = (37 "restaurant", "fast-food", "cafe", "bar", "food-truck", "traiteur",38 "boulangerie-patisserie", "casse-croute", "microbrasserie", "hotel",39 "ghost-kitchen",40)414243def clean_name(name: str) -> str:44 """Nettoie un nom de resto/section/plat : HTML résiduel, espaces, casse SPAM."""45 s = html.unescape(name or "")46 s = re.sub(r"<[^>]+>", " ", s)47 s = re.sub(r"\s+", " ", s).strip()48 # « CRÉEZ VOTRE BOL » -> « Créez Votre Bol » seulement si TOUT est en capitales49 if len(s) > 3 and s == s.upper() and any(c.isalpha() for c in s):50 s = s.title()51 return s525354def clean_address(address: str) -> str:55 s = html.unescape(address or "")56 s = re.sub(r"\s+", " ", s).strip().strip(",")57 # « 8245 Local A15 » -> garder le civique58 s = re.sub(r"^(\d+)\s+(?:local|suite|bureau|unite|#)\s*[\w-]+\s", r"\1 ", s,59 flags=re.I)60 return s616263_PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?")646566def parse_price(text: str | float | None) -> float | None:67 """Extrait un prix en dollars d'un texte (« 16,50 $ », « à partir de 12$ »)."""68 if text is None:69 return None70 if isinstance(text, (int, float)):71 return round(float(text), 2) if float(text) > 0 else None72 m = _PRICE_RE.search(text.replace("\xa0", " "))73 if not m:74 return None75 raw = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".")76 try:77 val = round(float(raw), 2)78 except ValueError:79 return None80 return val if val > 0 else None818283# --- classification des cuisines ---------------------------------------------84# mots-clés (sans accents, minuscules) -> cuisine. L'ordre n'importe pas :85# multi-valué, toutes les cuisines détectées sont retournées.86_CUISINE_KEYWORDS: dict[str, tuple[str, ...]] = {87 "pizza": ("pizza", "pizzeria", "pizzas"),88 "sushi-japonais": ("sushi", "maki", "sashimi", "izakaya", "ramen",89 "japonais", "poke", "bento", "tempura"),90 "burgers": ("burger", "burgers", "smash"),91 "poulet": ("poulet", "chicken", "rotisserie", "shish taouk"),92 "italien": ("italien", "trattoria", "osteria", "pasta", "pates fraiches",93 "risotto", "gnocchi"),94 "francais": ("bistro francais", "brasserie francaise", "cuisine francaise"),95 "quebecois": ("poutine", "casse-croute", "tourtiere", "cabane a sucre",96 "smoked meat", "quebecois"),97 "chinois": ("chinois", "dim sum", "szechuan", "cantonais", "wok",98 "dumpling", "boba", "bubble tea"),99 "thai": ("thai", "pad thai", "thailandais"),100 "vietnamien": ("pho", "vietnamien", "banh mi", "tonkinoise"),101 "coreen": ("coreen", "kimchi", "bibimbap", "bbq coreen"),102 "indien": ("indien", "curry", "tandoori", "biryani", "naan"),103 "libanais-moyen-orient": ("libanais", "shawarma", "falafel", "kebab",104 "moyen-orient", "hummus", "syrien"),105 "mexicain": ("taco", "tacos", "mexicain", "burrito", "quesadilla",106 "taqueria"),107 "grec": ("grec", "gyro", "souvlaki"),108 "mediterraneen": ("mediterraneen", "meze"),109 "fruits-de-mer": ("fruits de mer", "poissonnerie", "homard", "crabe",110 "huitres", "fish and chips"),111 "bbq-grillades": ("bbq", "grillades", "steakhouse", "cotes levees",112 "smokehouse", "grill"),113 "dejeuner-brunch": ("dejeuner", "brunch", "matin", "creperie", "oeufs",114 "pancake", "waffle", "gaufres"),115 "cafe-dessert": ("cafe", "espresso", "patisserie", "dessert", "creme glacee",116 "chocolat", "gelato", "beigne", "the ", "salon de the"),117 "vegetarien-vegan": ("vegan", "vegetalien", "vegetarien", "vege"),118 "fast-food": ("fast food", "restauration rapide", "frites"),119}120121122def classify_cuisines(name: str, menu_text: str = "") -> list[str]:123 """Classe un resto dans la taxonomie cuisine à partir de son nom et du texte124 de son menu. Retourne au moins ["autre"]."""125 hay = strip_accents(f"{name} {menu_text}".lower())126 found = [c for c, kws in _CUISINE_KEYWORDS.items()127 if any(k in hay for k in kws)]128 # le nom du resto prime : ne garder que les 4 meilleures correspondances129 return found[:4] if found else ["autre"]130131132def classify_establishment(name: str, cuisines: list[str]) -> str:133 hay = strip_accents((name or "").lower())134 if any(k in hay for k in ("cafe", "salon de the", "boba", "bubble tea",135 "espresso", "torrefacteur")):136 return "cafe"137 if any(k in hay for k in ("boulangerie", "patisserie")):138 return "boulangerie-patisserie"139 if any(k in hay for k in ("casse-croute", "cantine", "roulotte")):140 return "casse-croute"141 if any(k in hay for k in ("food truck", "camion")):142 return "food-truck"143 if any(k in hay for k in ("pub ", "taverne", "bar ", "brasserie artisanale")):144 return "bar"145 if any(k in hay for k in ("microbrasserie", "brouepub")):146 return "microbrasserie"147 if any(k in hay for k in ("traiteur",)):148 return "traiteur"149 if "fast-food" in cuisines:150 return "fast-food"151 return "restaurant"152153154_DIET_KEYWORDS = {155 "vegan": ("vegan", "vegetalien", "vegetalienne"),156 "vegetarien": ("vegetarien", "vegetarienne", "vege", "veggie"),157 "sans-gluten": ("sans gluten", "gluten free", "sans-gluten"),158 "halal": ("halal",),159 "casher": ("casher", "kascher", "kosher"),160 "sans-noix": ("sans noix", "sans arachide"),161 "epice": ("epice", "piquant", "spicy"),162}163164165def infer_dietary(menu: dict | None) -> list[str]:166 """Diètes offertes, déduites des tags et descriptions du menu."""167 if not menu:168 return []169 parts: list[str] = []170 for sec in menu.get("sections") or []:171 for it in sec.get("items") or []:172 parts.append(it.get("name") or "")173 parts.append(it.get("description") or "")174 parts.extend(it.get("tags") or [])175 hay = strip_accents(" ".join(parts).lower())176 return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]177178179def infer_item_tags(name: str, description: str = "",180 labels: list[str] | None = None) -> list[str]:181 """Tags diète d'un item de menu (vegan, sans-gluten, épicé…)."""182 hay = strip_accents(" ".join([name or "", description or ""]183 + list(labels or [])).lower())184 return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]185186187def price_range_from_menu(menu: dict | None) -> str:188 """Fourchette $ à $$$$ estimée sur le prix médian des items non nuls."""189 if not menu:190 return ""191 prices = [it.get("price") for sec in menu.get("sections") or []192 for it in sec.get("items") or []193 if isinstance(it.get("price"), (int, float)) and it["price"] > 3]194 if not prices:195 return ""196 med = statistics.median(prices)197 if med < 15:198 return "$"199 if med < 30:200 return "$$"201 if med < 60:202 return "$$$"203 return "$$$$"204