# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/osm.py # Desc: Connecteur de DÉCOUVERTE OpenStreetMap (Palier 4) — TOUS les # établissements où l'on mange au Québec : restaurants, fast-foods, # cafés, bars/pubs, crèmeries, foires alimentaires, boulangeries- # pâtisseries. C'est la couche « exhaustivité » de Resto·Ka : des # milliers de fiches (nom, adresse, GPS, cuisine, horaires, contact), # SANS menu — les connecteurs de menus (UEAT…) viennent s'y greffer # par déduplication. # # Mode d'extraction : API Overpass (miroirs publics, requêtes par type # d'établissement, aire administrative « Québec » 3600061549). # Base légale : données © contributeurs OpenStreetMap, licence ODbL — # attribution affichée sur le site (pages Sources et pied de page). # Politesse : 1 requête par type, timeout large, miroirs en rotation. # # Contexte de prix produit : AUCUN (pas de menu — fiche de découverte). # ============================================================================== from __future__ import annotations import sys import time from ..hours import parse_opening_hours from ..normalize import classify_cuisines from ..schema import Restaurant from .base import BaseConnector # Aire Overpass de la province de Québec (relation OSM 61549 + 3600000000) QC_AREA = 3600061549 MIRRORS = [ "https://overpass.kumi.systems/api/interpreter", "https://overpass-api.de/api/interpreter", "https://maps.mail.ru/osm/tools/overpass/api/interpreter", "https://overpass.private.coffee/api/interpreter", ] # NB : overpass.osm.jp RETIRÉ (2026-08-23) — certificat TLS invalide pour ce # nom d'hôte (hostname mismatch permanent). maps.mail.ru AJOUTÉ : instance # planète complète (vérifiée sur l'aire Québec), seul miroir à répondre le # 2026-08-22 quand kumi/de/coffee sont tombés ensemble et que le run OSM a # fini à 0 POI. # Réessais : les pannes Overpass (500/502, surcharge) sont le plus souvent # transitoires — on refait des passes complètes sur les miroirs avec une # attente croissante avant d'abandonner le type d'établissement. RETRY_ROUNDS = 3 RETRY_BACKOFF_SECONDS = (0, 90, 300) # User-Agent DÉDIÉ aux miroirs Overpass. Vérifié le 2026-08-18 : # overpass-api.de répond 406 Not Acceptable à tout UA « déguisé en # navigateur » (Mozilla/… Chrome/… — y compris notre UA global qui préfixe # RestoKaBot d'une chaîne Chrome) et à python-requests, mais accepte un UA # de bot honnête et identifiable. On envoie donc le nom nu du bot ici. OVERPASS_UA = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)" # NB : overpass.osm.ch RETIRÉ des miroirs — c'est un extrait SUISSE seulement : # il répond 200 avec 0 élément pour l'aire Québec, ce qui empoisonnait les # runs (incident des runs #2/#3 : des types entiers « réussis » à 0 POI). # (clé, valeur, type d'établissement Resto·Ka) TARGETS = [ ("amenity", "restaurant", "restaurant"), ("amenity", "fast_food", "fast-food"), ("amenity", "cafe", "cafe"), ("amenity", "bar", "bar"), ("amenity", "pub", "bar"), ("amenity", "ice_cream", "cafe"), ("amenity", "food_court", "fast-food"), ("shop", "bakery", "boulangerie-patisserie"), ("shop", "pastry", "boulangerie-patisserie"), ] # cuisine=* OSM -> taxonomie Resto·Ka (§6.1) ; multi-valué « a;b;c » _CUISINE_MAP = { "pizza": "pizza", "italian": "italien", "burger": "burgers", "chicken": "poulet", "sushi": "sushi-japonais", "japanese": "sushi-japonais", "chinese": "chinois", "thai": "thai", "vietnamese": "vietnamien", "korean": "coreen", "indian": "indien", "lebanese": "libanais-moyen-orient", "kebab": "libanais-moyen-orient", "mexican": "mexicain", "greek": "grec", "mediterranean": "mediterraneen", "seafood": "fruits-de-mer", "fish_and_chips": "fruits-de-mer", "french": "francais", "breakfast": "dejeuner-brunch", "brunch": "dejeuner-brunch", "crepe": "dejeuner-brunch", "coffee_shop": "cafe-dessert", "ice_cream": "cafe-dessert", "dessert": "cafe-dessert", "donut": "cafe-dessert", "bubble_tea": "cafe-dessert", "vegan": "vegetarien-vegan", "vegetarian": "vegetarien-vegan", "barbecue": "bbq-grillades", "steak_house": "bbq-grillades", "grill": "bbq-grillades", "poutine": "quebecois", "regional": "quebecois", "canadian": "quebecois", "sandwich": "fast-food", "friture": "quebecois", "asian": "chinois", "ramen": "sushi-japonais", "poke": "sushi-japonais", "shawarma": "libanais-moyen-orient", "falafel": "libanais-moyen-orient", "tapas": "mediterraneen", "portuguese": "mediterraneen", "spanish": "mediterraneen", "american": "burgers", "diner": "dejeuner-brunch", # valeurs OSM courantes au QC, non couvertes par la v1 "belgian": "francais", "bistro": "francais", "brasserie": "francais", "turkish": "libanais-moyen-orient", "persian": "libanais-moyen-orient", "iranian": "libanais-moyen-orient", "syrian": "libanais-moyen-orient", "afghan": "libanais-moyen-orient", "pita": "libanais-moyen-orient", "moroccan": "mediterraneen", "tunisian": "mediterraneen", "gyro": "grec", "pakistani": "indien", "bangladeshi": "indien", "sri_lankan": "indien", "nepalese": "indien", "cantonese": "chinois", "szechuan": "chinois", "sichuan": "chinois", "hong_kong": "chinois", "taiwanese": "chinois", "dim_sum": "chinois", "dumpling": "chinois", "noodle": "chinois", "noodles": "chinois", "malaysian": "thai", "indonesian": "thai", "filipino": "thai", "teriyaki": "sushi-japonais", "izakaya": "sushi-japonais", "peruvian": "mexicain", "brazilian": "mexicain", "colombian": "mexicain", "salvadoran": "mexicain", "latin_american": "mexicain", "argentinian": "bbq-grillades", "steak": "bbq-grillades", "wings": "poulet", "fried_chicken": "poulet", "hotdog": "fast-food", "hot_dog": "fast-food", "deli": "fast-food", "bagel": "dejeuner-brunch", "pancake": "dejeuner-brunch", "waffle": "cafe-dessert", "cake": "cafe-dessert", "chocolate": "cafe-dessert", "tea": "cafe-dessert", "juice": "cafe-dessert", "coffee": "cafe-dessert", "pasta": "italien", "oysters": "fruits-de-mer", "fish": "fruits-de-mer", "smoked_meat": "quebecois", "sugar_shack": "quebecois", } # diet:*=yes|only|limited -> options alimentaires (§6) ; « limited » = offre # partielle, on l'affiche quand même (mieux vaut signaler l'option qui existe) _DIET_TAGS = { "diet:vegan": "vegan", "diet:vegetarian": "vegetarien", "diet:gluten_free": "sans-gluten", "diet:halal": "halal", "diet:kosher": "casher", } # payment:* -> étiquettes de service (modes de paiement acceptés) _PAYMENT_TAGS = { "payment:cash": "paiement-comptant", "payment:credit_cards": "paiement-credit", "payment:visa": "paiement-credit", "payment:mastercard": "paiement-credit", "payment:american_express": "paiement-credit", "payment:debit_cards": "paiement-debit", "payment:interac": "paiement-debit", } class OsmConnector(BaseConnector): source_id = "osm" request_delay = 3.0 # politesse Overpass : requêtes lourdes timeout = 240 use_detail_cache = False # une seule passe, pas de pages détail def _overpass(self, key: str, value: str) -> list[dict]: """Une requête Overpass (nœuds + centres de chemins), miroirs en rotation.""" query = ( f'[out:json][timeout:180];' f'(node["{key}"="{value}"](area:{QC_AREA});' f'way["{key}"="{value}"](area:{QC_AREA}););' f'out tags center;' ) last_exc: Exception | None = None for round_no in range(RETRY_ROUNDS): wait = RETRY_BACKOFF_SECONDS[min(round_no, len(RETRY_BACKOFF_SECONDS) - 1)] if wait: print(f"[resto-ka] osm: nouvelle passe sur les miroirs dans {wait}s " f"(ronde {round_no + 1}/{RETRY_ROUNDS})", file=sys.stderr) time.sleep(wait) for mirror in MIRRORS: try: resp = self.post(mirror, data={"data": query}, headers={"User-Agent": OVERPASS_UA}) data = resp.json() # Overpass peut répondre 200 avec des résultats TRONQUÉS et un # « remark » (timeout/mémoire) — traiter comme un échec de # miroir plutôt que d'avaler une liste partielle (incident du # run #2 : 6 213 POI au lieu de 13 531). remark = (data.get("remark") or "").lower() if "timed out" in remark or "error" in remark: raise RuntimeError(f"réponse partielle : {remark[:200]}") elements = data.get("elements") or [] # 0 élément pour une requête à l'échelle de la PROVINCE est # toujours implausible (chaque type compte des centaines de # POI au QC) : miroir sans les données (extrait régional…) if not elements: raise RuntimeError("0 élément — miroir sans données " "pour l'aire Québec") return elements except Exception as exc: # miroir surchargé -> suivant last_exc = exc print(f"[resto-ka] osm: {mirror} a échoué ({exc}), miroir suivant", file=sys.stderr) raise RuntimeError(f"tous les miroirs Overpass ont échoué : {last_exc}") # cuisine par défaut quand ni le tag cuisine ni le nom ne classent le POI _DEFAULT_CUISINE = { "ice_cream": "cafe-dessert", "cafe": "cafe-dessert", "bakery": "cafe-dessert", "pastry": "cafe-dessert", "fast_food": "fast-food", "food_court": "fast-food", } @classmethod def _cuisines(cls, tags: dict, name: str, value: str) -> list[str]: vals = [v.strip().lower() for v in (tags.get("cuisine") or "").split(";") if v.strip()] out: list[str] = [] for v in vals: mapped = _CUISINE_MAP.get(v) if mapped and mapped not in out: out.append(mapped) if not out: out = classify_cuisines(name) if out == ["autre"] and value in cls._DEFAULT_CUISINE: out = [cls._DEFAULT_CUISINE[value]] return out[:4] def _to_restaurant(self, el: dict, etype: str, value: str = "") -> Restaurant | None: tags = el.get("tags") or {} name = (tags.get("name") or "").strip() if not name: return None # un POI sans nom est inutilisable # marque/chaîne (brand=*) -> colonne chain ; branch=* = nom de la # succursale, suffixé au nom (même convention que UEAT « Marque — Succ. ») chain = (tags.get("brand") or "").strip() or None branch = (tags.get("branch") or "").strip() if branch and branch.lower() not in name.lower(): name = f"{name} — {branch}" lat = el.get("lat") or (el.get("center") or {}).get("lat") lng = el.get("lon") or (el.get("center") or {}).get("lon") civic = (tags.get("addr:housenumber") or "").strip() street = (tags.get("addr:street") or "").strip() services = [] if tags.get("takeaway") in ("yes", "only"): services.append("takeout") if tags.get("delivery") == "yes": services.append("delivery") if tags.get("takeaway") != "only": services.append("salle") if tags.get("wheelchair") in ("yes", "designated", "limited"): services.append("accessible") for t, svc in _PAYMENT_TAGS.items(): if tags.get(t) == "yes" and svc not in services: services.append(svc) capacity = (tags.get("capacity") or "").strip() if capacity.isdigit(): services.append(f"capacite-{capacity}") hours = {} if tags.get("opening_hours"): hours["osm"] = tags["opening_hours"] # horaires STRUCTURÉS par jour quand la syntaxe est courante # (Mo-Fr 11:00-22:00…) ; les cas exotiques restent en brut parsed = parse_opening_hours(tags["opening_hours"]) if parsed: hours.update(parsed) osm_id = f"{el.get('type')}/{el.get('id')}" return Restaurant( source=self.source_id, external_id=osm_id, name=name, url=f"https://www.openstreetmap.org/{osm_id}", chain=chain, cuisines=self._cuisines(tags, name, value), establishment_type=etype, address=f"{civic} {street}".strip(), city=(tags.get("addr:city") or "").strip(), postal_code=(tags.get("addr:postcode") or "").strip(), lat=lat, lng=lng, phone=(tags.get("phone") or tags.get("contact:phone") or "").strip(), website=(tags.get("website") or tags.get("contact:website") or "").strip(), hours=hours, services=services, dietary_options=[d for t, d in _DIET_TAGS.items() if tags.get(t) in ("yes", "only", "limited")], languages=["fr"], menu=None, # découverte : pas de menu ici ) def fetch(self) -> list[Restaurant]: out: dict[str, Restaurant] = {} # run partiel : si UN type d'établissement échoue sur tous les miroirs, # on continue avec les autres mais on signale le run comme incomplet — # le pipeline suspendra alors les retraits (pas de fausses fermetures). self.partial_run = False for key, value, etype in TARGETS: try: elements = self._overpass(key, value) except Exception as exc: self.partial_run = True print(f"[resto-ka] osm: {key}={value} ÉCHEC sur tous les " f"miroirs ({exc}) — run marqué partiel, retraits " "suspendus", file=sys.stderr) continue kept = 0 for el in elements: resto = self._to_restaurant(el, etype, value) if resto is not None and resto.uid not in out: out[resto.uid] = resto kept += 1 print(f"[resto-ka] osm: {key}={value} -> {len(elements)} POI, " f"{kept} fiches nommées") return list(out.values())