SPB Git forge

spb/resto-ka

Public

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

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
8.9 KB · 222 lines python
Raw Blame History
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", "normalize_phone", "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_PHONE_RE = re.compile(64    r"(?:\+?1[\s.\-]?)?\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})(?!\d)")656667def normalize_phone(text: str | None) -> str:68    """Extrait et normalise un numéro nord-américain en E.164 (« +14185551234 »).6970    Retourne "" si aucun numéro à 10 chiffres plausible n'est trouvé.71    Accepte les formats courants : (418) 555-1234, 418.555.1234, tel:+1418…72    """73    if not text:74        return ""75    m = _PHONE_RE.search(str(text))76    if not m:77        return ""78    return "+1" + "".join(m.groups())798081_PRICE_RE = re.compile(r"(\d{1,4}(?:[  ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?")828384def parse_price(text: str | float | None) -> float | None:85    """Extrait un prix en dollars d'un texte (« 16,50 $ », « à partir de 12$ »)."""86    if text is None:87        return None88    if isinstance(text, (int, float)):89        return round(float(text), 2) if float(text) > 0 else None90    m = _PRICE_RE.search(text.replace("\xa0", " "))91    if not m:92        return None93    raw = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".")94    try:95        val = round(float(raw), 2)96    except ValueError:97        return None98    return val if val > 0 else None99100101# --- classification des cuisines ---------------------------------------------102# mots-clés (sans accents, minuscules) -> cuisine. L'ordre n'importe pas :103# multi-valué, toutes les cuisines détectées sont retournées.104_CUISINE_KEYWORDS: dict[str, tuple[str, ...]] = {105    "pizza": ("pizza", "pizzeria", "pizzas"),106    "sushi-japonais": ("sushi", "maki", "sashimi", "izakaya", "ramen",107                       "japonais", "poke", "bento", "tempura"),108    "burgers": ("burger", "burgers", "smash"),109    "poulet": ("poulet", "chicken", "rotisserie", "shish taouk"),110    "italien": ("italien", "trattoria", "osteria", "pasta", "pates fraiches",111                "risotto", "gnocchi"),112    "francais": ("bistro francais", "brasserie francaise", "cuisine francaise"),113    "quebecois": ("poutine", "casse-croute", "tourtiere", "cabane a sucre",114                  "smoked meat", "quebecois"),115    "chinois": ("chinois", "dim sum", "szechuan", "cantonais", "wok",116                "dumpling", "boba", "bubble tea"),117    "thai": ("thai", "pad thai", "thailandais"),118    "vietnamien": ("pho", "vietnamien", "banh mi", "tonkinoise"),119    "coreen": ("coreen", "kimchi", "bibimbap", "bbq coreen"),120    "indien": ("indien", "curry", "tandoori", "biryani", "naan"),121    "libanais-moyen-orient": ("libanais", "shawarma", "falafel", "kebab",122                              "moyen-orient", "hummus", "syrien"),123    "mexicain": ("taco", "tacos", "mexicain", "burrito", "quesadilla",124                 "taqueria"),125    "grec": ("grec", "gyro", "souvlaki"),126    "mediterraneen": ("mediterraneen", "meze"),127    "fruits-de-mer": ("fruits de mer", "poissonnerie", "homard", "crabe",128                      "huitres", "fish and chips"),129    "bbq-grillades": ("bbq", "grillades", "steakhouse", "cotes levees",130                      "smokehouse", "grill"),131    "dejeuner-brunch": ("dejeuner", "brunch", "matin", "creperie", "oeufs",132                        "pancake", "waffle", "gaufres"),133    "cafe-dessert": ("cafe", "espresso", "patisserie", "dessert", "creme glacee",134                     "chocolat", "gelato", "beigne", "the ", "salon de the"),135    "vegetarien-vegan": ("vegan", "vegetalien", "vegetarien", "vege"),136    "fast-food": ("fast food", "restauration rapide", "frites"),137}138139140def classify_cuisines(name: str, menu_text: str = "") -> list[str]:141    """Classe un resto dans la taxonomie cuisine à partir de son nom et du texte142    de son menu. Retourne au moins ["autre"]."""143    hay = strip_accents(f"{name} {menu_text}".lower())144    found = [c for c, kws in _CUISINE_KEYWORDS.items()145             if any(k in hay for k in kws)]146    # le nom du resto prime : ne garder que les 4 meilleures correspondances147    return found[:4] if found else ["autre"]148149150def classify_establishment(name: str, cuisines: list[str]) -> str:151    hay = strip_accents((name or "").lower())152    if any(k in hay for k in ("cafe", "salon de the", "boba", "bubble tea",153                              "espresso", "torrefacteur")):154        return "cafe"155    if any(k in hay for k in ("boulangerie", "patisserie")):156        return "boulangerie-patisserie"157    if any(k in hay for k in ("casse-croute", "cantine", "roulotte")):158        return "casse-croute"159    if any(k in hay for k in ("food truck", "camion")):160        return "food-truck"161    if any(k in hay for k in ("pub ", "taverne", "bar ", "brasserie artisanale")):162        return "bar"163    if any(k in hay for k in ("microbrasserie", "brouepub")):164        return "microbrasserie"165    if any(k in hay for k in ("traiteur",)):166        return "traiteur"167    if "fast-food" in cuisines:168        return "fast-food"169    return "restaurant"170171172_DIET_KEYWORDS = {173    "vegan": ("vegan", "vegetalien", "vegetalienne"),174    "vegetarien": ("vegetarien", "vegetarienne", "vege", "veggie"),175    "sans-gluten": ("sans gluten", "gluten free", "sans-gluten"),176    "halal": ("halal",),177    "casher": ("casher", "kascher", "kosher"),178    "sans-noix": ("sans noix", "sans arachide"),179    "epice": ("epice", "piquant", "spicy"),180}181182183def infer_dietary(menu: dict | None) -> list[str]:184    """Diètes offertes, déduites des tags et descriptions du menu."""185    if not menu:186        return []187    parts: list[str] = []188    for sec in menu.get("sections") or []:189        for it in sec.get("items") or []:190            parts.append(it.get("name") or "")191            parts.append(it.get("description") or "")192            parts.extend(it.get("tags") or [])193    hay = strip_accents(" ".join(parts).lower())194    return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]195196197def infer_item_tags(name: str, description: str = "",198                    labels: list[str] | None = None) -> list[str]:199    """Tags diète d'un item de menu (vegan, sans-gluten, épicé…)."""200    hay = strip_accents(" ".join([name or "", description or ""]201                                 + list(labels or [])).lower())202    return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)]203204205def price_range_from_menu(menu: dict | None) -> str:206    """Fourchette $ à $$$$ estimée sur le prix médian des items non nuls."""207    if not menu:208        return ""209    prices = [it.get("price") for sec in menu.get("sections") or []210              for it in sec.get("items") or []211              if isinstance(it.get("price"), (int, float)) and it["price"] > 3]212    if not prices:213        return ""214    med = statistics.median(prices)215    if med < 15:216        return "$"217    if med < 30:218        return "$$"219    if med < 60:220        return "$$$"221    return "$$$$"222