# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/normalize.py # Desc: Couche de normalisation commune : nettoyage des noms/adresses, # parsing des prix, classification des cuisines et du type # d'établissement, diètes, fourchette de prix ($ à $$$$). # Calquée sur louka/normalize.py (famille ·Ka). # ============================================================================== from __future__ import annotations import html import re import statistics from .regions import strip_accents __all__ = [ "strip_accents", "clean_name", "clean_address", "parse_price", "classify_cuisines", "classify_establishment", "infer_dietary", "price_range_from_menu", "normalize_phone", "CUISINES", "PRICE_CONTEXTS", ] # Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué. CUISINES = [ "quebecois", "francais", "italien", "pizza", "burgers", "poulet", "bbq-grillades", "fruits-de-mer", "sushi-japonais", "chinois", "thai", "vietnamien", "coreen", "indien", "libanais-moyen-orient", "mexicain", "grec", "mediterraneen", "dejeuner-brunch", "cafe-dessert", "vegetarien-vegan", "fast-food", "autre", ] # Contextes de prix admissibles (CLAUDE.md §6.2) — un prix sans contexte est # inutilisable ; `delivery` est majoré de 25-30 % vs la salle. PRICE_CONTEXTS = ("dine-in", "takeout", "delivery") ESTABLISHMENT_TYPES = ( "restaurant", "fast-food", "cafe", "bar", "food-truck", "traiteur", "boulangerie-patisserie", "casse-croute", "microbrasserie", "hotel", "ghost-kitchen", ) def clean_name(name: str) -> str: """Nettoie un nom de resto/section/plat : HTML résiduel, espaces, casse SPAM.""" s = html.unescape(name or "") s = re.sub(r"<[^>]+>", " ", s) s = re.sub(r"\s+", " ", s).strip() # « CRÉEZ VOTRE BOL » -> « Créez Votre Bol » seulement si TOUT est en capitales if len(s) > 3 and s == s.upper() and any(c.isalpha() for c in s): s = s.title() return s def clean_address(address: str) -> str: s = html.unescape(address or "") s = re.sub(r"\s+", " ", s).strip().strip(",") # « 8245 Local A15 » -> garder le civique s = re.sub(r"^(\d+)\s+(?:local|suite|bureau|unite|#)\s*[\w-]+\s", r"\1 ", s, flags=re.I) return s _PHONE_RE = re.compile( r"(?:\+?1[\s.\-]?)?\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})(?!\d)") def normalize_phone(text: str | None) -> str: """Extrait et normalise un numéro nord-américain en E.164 (« +14185551234 »). Retourne "" si aucun numéro à 10 chiffres plausible n'est trouvé. Accepte les formats courants : (418) 555-1234, 418.555.1234, tel:+1418… """ if not text: return "" m = _PHONE_RE.search(str(text)) if not m: return "" return "+1" + "".join(m.groups()) _PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?") def parse_price(text: str | float | None) -> float | None: """Extrait un prix en dollars d'un texte (« 16,50 $ », « à partir de 12$ »).""" if text is None: return None if isinstance(text, (int, float)): return round(float(text), 2) if float(text) > 0 else None m = _PRICE_RE.search(text.replace("\xa0", " ")) if not m: return None raw = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".") try: val = round(float(raw), 2) except ValueError: return None return val if val > 0 else None # --- classification des cuisines --------------------------------------------- # mots-clés (sans accents, minuscules) -> cuisine. L'ordre n'importe pas : # multi-valué, toutes les cuisines détectées sont retournées. _CUISINE_KEYWORDS: dict[str, tuple[str, ...]] = { "pizza": ("pizza", "pizzeria", "pizzas"), "sushi-japonais": ("sushi", "maki", "sashimi", "izakaya", "ramen", "japonais", "poke", "bento", "tempura"), "burgers": ("burger", "burgers", "smash"), "poulet": ("poulet", "chicken", "rotisserie", "shish taouk"), "italien": ("italien", "trattoria", "osteria", "pasta", "pates fraiches", "risotto", "gnocchi"), "francais": ("bistro francais", "brasserie francaise", "cuisine francaise"), "quebecois": ("poutine", "casse-croute", "tourtiere", "cabane a sucre", "smoked meat", "quebecois"), "chinois": ("chinois", "dim sum", "szechuan", "cantonais", "wok", "dumpling", "boba", "bubble tea"), "thai": ("thai", "pad thai", "thailandais"), "vietnamien": ("pho", "vietnamien", "banh mi", "tonkinoise"), "coreen": ("coreen", "kimchi", "bibimbap", "bbq coreen"), "indien": ("indien", "curry", "tandoori", "biryani", "naan"), "libanais-moyen-orient": ("libanais", "shawarma", "falafel", "kebab", "moyen-orient", "hummus", "syrien"), "mexicain": ("taco", "tacos", "mexicain", "burrito", "quesadilla", "taqueria"), "grec": ("grec", "gyro", "souvlaki"), "mediterraneen": ("mediterraneen", "meze"), "fruits-de-mer": ("fruits de mer", "poissonnerie", "homard", "crabe", "huitres", "fish and chips"), "bbq-grillades": ("bbq", "grillades", "steakhouse", "cotes levees", "smokehouse", "grill"), "dejeuner-brunch": ("dejeuner", "brunch", "matin", "creperie", "oeufs", "pancake", "waffle", "gaufres"), "cafe-dessert": ("cafe", "espresso", "patisserie", "dessert", "creme glacee", "chocolat", "gelato", "beigne", "the ", "salon de the"), "vegetarien-vegan": ("vegan", "vegetalien", "vegetarien", "vege"), "fast-food": ("fast food", "restauration rapide", "frites"), } def classify_cuisines(name: str, menu_text: str = "") -> list[str]: """Classe un resto dans la taxonomie cuisine à partir de son nom et du texte de son menu. Retourne au moins ["autre"].""" hay = strip_accents(f"{name} {menu_text}".lower()) found = [c for c, kws in _CUISINE_KEYWORDS.items() if any(k in hay for k in kws)] # le nom du resto prime : ne garder que les 4 meilleures correspondances return found[:4] if found else ["autre"] def classify_establishment(name: str, cuisines: list[str]) -> str: hay = strip_accents((name or "").lower()) if any(k in hay for k in ("cafe", "salon de the", "boba", "bubble tea", "espresso", "torrefacteur")): return "cafe" if any(k in hay for k in ("boulangerie", "patisserie")): return "boulangerie-patisserie" if any(k in hay for k in ("casse-croute", "cantine", "roulotte")): return "casse-croute" if any(k in hay for k in ("food truck", "camion")): return "food-truck" if any(k in hay for k in ("pub ", "taverne", "bar ", "brasserie artisanale")): return "bar" if any(k in hay for k in ("microbrasserie", "brouepub")): return "microbrasserie" if any(k in hay for k in ("traiteur",)): return "traiteur" if "fast-food" in cuisines: return "fast-food" return "restaurant" _DIET_KEYWORDS = { "vegan": ("vegan", "vegetalien", "vegetalienne"), "vegetarien": ("vegetarien", "vegetarienne", "vege", "veggie"), "sans-gluten": ("sans gluten", "gluten free", "sans-gluten"), "halal": ("halal",), "casher": ("casher", "kascher", "kosher"), "sans-noix": ("sans noix", "sans arachide"), "epice": ("epice", "piquant", "spicy"), } def infer_dietary(menu: dict | None) -> list[str]: """Diètes offertes, déduites des tags et descriptions du menu.""" if not menu: return [] parts: list[str] = [] for sec in menu.get("sections") or []: for it in sec.get("items") or []: parts.append(it.get("name") or "") parts.append(it.get("description") or "") parts.extend(it.get("tags") or []) hay = strip_accents(" ".join(parts).lower()) return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)] def infer_item_tags(name: str, description: str = "", labels: list[str] | None = None) -> list[str]: """Tags diète d'un item de menu (vegan, sans-gluten, épicé…).""" hay = strip_accents(" ".join([name or "", description or ""] + list(labels or [])).lower()) return [d for d, kws in _DIET_KEYWORDS.items() if any(k in hay for k in kws)] def price_range_from_menu(menu: dict | None) -> str: """Fourchette $ à $$$$ estimée sur le prix médian des items non nuls.""" if not menu: return "" prices = [it.get("price") for sec in menu.get("sections") or [] for it in sec.get("items") or [] if isinstance(it.get("price"), (int, float)) and it["price"] > 3] if not prices: return "" med = statistics.median(prices) if med < 15: return "$" if med < 30: return "$$" if med < 60: return "$$$" return "$$$$"