SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

8.4 KB · 204 lines python
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
Files · laptop-wip-20261002
Menu

Theme