Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/connectors/osm.py4# Desc: Connecteur de DÉCOUVERTE OpenStreetMap (Palier 4) — TOUS les5# établissements où l'on mange au Québec : restaurants, fast-foods,6# cafés, bars/pubs, crèmeries, foires alimentaires, boulangeries-7# pâtisseries. C'est la couche « exhaustivité » de Resto·Ka : des8# milliers de fiches (nom, adresse, GPS, cuisine, horaires, contact),9# SANS menu — les connecteurs de menus (UEAT…) viennent s'y greffer10# par déduplication.11#12# Mode d'extraction : API Overpass (miroirs publics, requêtes par type13# d'établissement, aire administrative « Québec » 3600061549).14# Base légale : données © contributeurs OpenStreetMap, licence ODbL —15# attribution affichée sur le site (pages Sources et pied de page).16# Politesse : 1 requête par type, timeout large, miroirs en rotation.17#18# Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).19# ==============================================================================20from __future__ import annotations2122import sys23import time2425from ..hours import parse_opening_hours26from ..normalize import classify_cuisines27from ..schema import Restaurant28from .base import BaseConnector2930# Aire Overpass de la province de Québec (relation OSM 61549 + 3600000000)31QC_AREA = 36000615493233MIRRORS = [34 "https://overpass.kumi.systems/api/interpreter",35 "https://overpass-api.de/api/interpreter",36 "https://maps.mail.ru/osm/tools/overpass/api/interpreter",37 "https://overpass.private.coffee/api/interpreter",38]39# NB : overpass.osm.jp RETIRÉ (2026-08-23) — certificat TLS invalide pour ce40# nom d'hôte (hostname mismatch permanent). maps.mail.ru AJOUTÉ : instance41# planète complète (vérifiée sur l'aire Québec), seul miroir à répondre le42# 2026-08-22 quand kumi/de/coffee sont tombés ensemble et que le run OSM a43# fini à 0 POI.4445# Réessais : les pannes Overpass (500/502, surcharge) sont le plus souvent46# transitoires — on refait des passes complètes sur les miroirs avec une47# attente croissante avant d'abandonner le type d'établissement.48RETRY_ROUNDS = 349RETRY_BACKOFF_SECONDS = (0, 90, 300)5051# User-Agent DÉDIÉ aux miroirs Overpass. Vérifié le 2026-08-18 :52# overpass-api.de répond 406 Not Acceptable à tout UA « déguisé en53# navigateur » (Mozilla/… Chrome/… — y compris notre UA global qui préfixe54# RestoKaBot d'une chaîne Chrome) et à python-requests, mais accepte un UA55# de bot honnête et identifiable. On envoie donc le nom nu du bot ici.56OVERPASS_UA = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)"57# NB : overpass.osm.ch RETIRÉ des miroirs — c'est un extrait SUISSE seulement :58# il répond 200 avec 0 élément pour l'aire Québec, ce qui empoisonnait les59# runs (incident des runs #2/#3 : des types entiers « réussis » à 0 POI).6061# (clé, valeur, type d'établissement Resto·Ka)62TARGETS = [63 ("amenity", "restaurant", "restaurant"),64 ("amenity", "fast_food", "fast-food"),65 ("amenity", "cafe", "cafe"),66 ("amenity", "bar", "bar"),67 ("amenity", "pub", "bar"),68 ("amenity", "ice_cream", "cafe"),69 ("amenity", "food_court", "fast-food"),70 ("shop", "bakery", "boulangerie-patisserie"),71 ("shop", "pastry", "boulangerie-patisserie"),72]7374# cuisine=* OSM -> taxonomie Resto·Ka (§6.1) ; multi-valué « a;b;c »75_CUISINE_MAP = {76 "pizza": "pizza", "italian": "italien", "burger": "burgers",77 "chicken": "poulet", "sushi": "sushi-japonais", "japanese": "sushi-japonais",78 "chinese": "chinois", "thai": "thai", "vietnamese": "vietnamien",79 "korean": "coreen", "indian": "indien", "lebanese": "libanais-moyen-orient",80 "kebab": "libanais-moyen-orient", "mexican": "mexicain", "greek": "grec",81 "mediterranean": "mediterraneen", "seafood": "fruits-de-mer",82 "fish_and_chips": "fruits-de-mer", "french": "francais",83 "breakfast": "dejeuner-brunch", "brunch": "dejeuner-brunch",84 "crepe": "dejeuner-brunch", "coffee_shop": "cafe-dessert",85 "ice_cream": "cafe-dessert", "dessert": "cafe-dessert",86 "donut": "cafe-dessert", "bubble_tea": "cafe-dessert",87 "vegan": "vegetarien-vegan", "vegetarian": "vegetarien-vegan",88 "barbecue": "bbq-grillades", "steak_house": "bbq-grillades",89 "grill": "bbq-grillades", "poutine": "quebecois", "regional": "quebecois",90 "canadian": "quebecois", "sandwich": "fast-food", "friture": "quebecois",91 "asian": "chinois", "ramen": "sushi-japonais", "poke": "sushi-japonais",92 "shawarma": "libanais-moyen-orient", "falafel": "libanais-moyen-orient",93 "tapas": "mediterraneen", "portuguese": "mediterraneen",94 "spanish": "mediterraneen", "american": "burgers", "diner": "dejeuner-brunch",95 # valeurs OSM courantes au QC, non couvertes par la v196 "belgian": "francais", "bistro": "francais", "brasserie": "francais",97 "turkish": "libanais-moyen-orient", "persian": "libanais-moyen-orient",98 "iranian": "libanais-moyen-orient", "syrian": "libanais-moyen-orient",99 "afghan": "libanais-moyen-orient", "pita": "libanais-moyen-orient",100 "moroccan": "mediterraneen", "tunisian": "mediterraneen",101 "gyro": "grec", "pakistani": "indien", "bangladeshi": "indien",102 "sri_lankan": "indien", "nepalese": "indien",103 "cantonese": "chinois", "szechuan": "chinois", "sichuan": "chinois",104 "hong_kong": "chinois", "taiwanese": "chinois", "dim_sum": "chinois",105 "dumpling": "chinois", "noodle": "chinois", "noodles": "chinois",106 "malaysian": "thai", "indonesian": "thai", "filipino": "thai",107 "teriyaki": "sushi-japonais", "izakaya": "sushi-japonais",108 "peruvian": "mexicain", "brazilian": "mexicain", "colombian": "mexicain",109 "salvadoran": "mexicain", "latin_american": "mexicain",110 "argentinian": "bbq-grillades", "steak": "bbq-grillades",111 "wings": "poulet", "fried_chicken": "poulet",112 "hotdog": "fast-food", "hot_dog": "fast-food", "deli": "fast-food",113 "bagel": "dejeuner-brunch", "pancake": "dejeuner-brunch",114 "waffle": "cafe-dessert", "cake": "cafe-dessert",115 "chocolate": "cafe-dessert", "tea": "cafe-dessert",116 "juice": "cafe-dessert", "coffee": "cafe-dessert",117 "pasta": "italien", "oysters": "fruits-de-mer", "fish": "fruits-de-mer",118 "smoked_meat": "quebecois", "sugar_shack": "quebecois",119}120121# diet:*=yes|only|limited -> options alimentaires (§6) ; « limited » = offre122# partielle, on l'affiche quand même (mieux vaut signaler l'option qui existe)123_DIET_TAGS = {124 "diet:vegan": "vegan", "diet:vegetarian": "vegetarien",125 "diet:gluten_free": "sans-gluten", "diet:halal": "halal",126 "diet:kosher": "casher",127}128129# payment:* -> étiquettes de service (modes de paiement acceptés)130_PAYMENT_TAGS = {131 "payment:cash": "paiement-comptant",132 "payment:credit_cards": "paiement-credit",133 "payment:visa": "paiement-credit",134 "payment:mastercard": "paiement-credit",135 "payment:american_express": "paiement-credit",136 "payment:debit_cards": "paiement-debit",137 "payment:interac": "paiement-debit",138}139140141class OsmConnector(BaseConnector):142 source_id = "osm"143 request_delay = 3.0 # politesse Overpass : requêtes lourdes144 timeout = 240145 use_detail_cache = False # une seule passe, pas de pages détail146147 def _overpass(self, key: str, value: str) -> list[dict]:148 """Une requête Overpass (nœuds + centres de chemins), miroirs en rotation."""149 query = (150 f'[out:json][timeout:180];'151 f'(node["{key}"="{value}"](area:{QC_AREA});'152 f'way["{key}"="{value}"](area:{QC_AREA}););'153 f'out tags center;'154 )155 last_exc: Exception | None = None156 for round_no in range(RETRY_ROUNDS):157 wait = RETRY_BACKOFF_SECONDS[min(round_no, len(RETRY_BACKOFF_SECONDS) - 1)]158 if wait:159 print(f"[resto-ka] osm: nouvelle passe sur les miroirs dans {wait}s "160 f"(ronde {round_no + 1}/{RETRY_ROUNDS})", file=sys.stderr)161 time.sleep(wait)162 for mirror in MIRRORS:163 try:164 resp = self.post(mirror, data={"data": query},165 headers={"User-Agent": OVERPASS_UA})166 data = resp.json()167 # Overpass peut répondre 200 avec des résultats TRONQUÉS et un168 # « remark » (timeout/mémoire) — traiter comme un échec de169 # miroir plutôt que d'avaler une liste partielle (incident du170 # run #2 : 6 213 POI au lieu de 13 531).171 remark = (data.get("remark") or "").lower()172 if "timed out" in remark or "error" in remark:173 raise RuntimeError(f"réponse partielle : {remark[:200]}")174 elements = data.get("elements") or []175 # 0 élément pour une requête à l'échelle de la PROVINCE est176 # toujours implausible (chaque type compte des centaines de177 # POI au QC) : miroir sans les données (extrait régional…)178 if not elements:179 raise RuntimeError("0 élément — miroir sans données "180 "pour l'aire Québec")181 return elements182 except Exception as exc: # miroir surchargé -> suivant183 last_exc = exc184 print(f"[resto-ka] osm: {mirror} a échoué ({exc}), miroir suivant",185 file=sys.stderr)186 raise RuntimeError(f"tous les miroirs Overpass ont échoué : {last_exc}")187188 # cuisine par défaut quand ni le tag cuisine ni le nom ne classent le POI189 _DEFAULT_CUISINE = {190 "ice_cream": "cafe-dessert", "cafe": "cafe-dessert",191 "bakery": "cafe-dessert", "pastry": "cafe-dessert",192 "fast_food": "fast-food", "food_court": "fast-food",193 }194195 @classmethod196 def _cuisines(cls, tags: dict, name: str, value: str) -> list[str]:197 vals = [v.strip().lower() for v in (tags.get("cuisine") or "").split(";") if v.strip()]198 out: list[str] = []199 for v in vals:200 mapped = _CUISINE_MAP.get(v)201 if mapped and mapped not in out:202 out.append(mapped)203 if not out:204 out = classify_cuisines(name)205 if out == ["autre"] and value in cls._DEFAULT_CUISINE:206 out = [cls._DEFAULT_CUISINE[value]]207 return out[:4]208209 def _to_restaurant(self, el: dict, etype: str,210 value: str = "") -> Restaurant | None:211 tags = el.get("tags") or {}212 name = (tags.get("name") or "").strip()213 if not name:214 return None # un POI sans nom est inutilisable215 # marque/chaîne (brand=*) -> colonne chain ; branch=* = nom de la216 # succursale, suffixé au nom (même convention que UEAT « Marque — Succ. »)217 chain = (tags.get("brand") or "").strip() or None218 branch = (tags.get("branch") or "").strip()219 if branch and branch.lower() not in name.lower():220 name = f"{name} — {branch}"221 lat = el.get("lat") or (el.get("center") or {}).get("lat")222 lng = el.get("lon") or (el.get("center") or {}).get("lon")223 civic = (tags.get("addr:housenumber") or "").strip()224 street = (tags.get("addr:street") or "").strip()225 services = []226 if tags.get("takeaway") in ("yes", "only"):227 services.append("takeout")228 if tags.get("delivery") == "yes":229 services.append("delivery")230 if tags.get("takeaway") != "only":231 services.append("salle")232 if tags.get("wheelchair") in ("yes", "designated", "limited"):233 services.append("accessible")234 for t, svc in _PAYMENT_TAGS.items():235 if tags.get(t) == "yes" and svc not in services:236 services.append(svc)237 capacity = (tags.get("capacity") or "").strip()238 if capacity.isdigit():239 services.append(f"capacite-{capacity}")240 hours = {}241 if tags.get("opening_hours"):242 hours["osm"] = tags["opening_hours"]243 # horaires STRUCTURÉS par jour quand la syntaxe est courante244 # (Mo-Fr 11:00-22:00…) ; les cas exotiques restent en brut245 parsed = parse_opening_hours(tags["opening_hours"])246 if parsed:247 hours.update(parsed)248 osm_id = f"{el.get('type')}/{el.get('id')}"249 return Restaurant(250 source=self.source_id,251 external_id=osm_id,252 name=name,253 url=f"https://www.openstreetmap.org/{osm_id}",254 chain=chain,255 cuisines=self._cuisines(tags, name, value),256 establishment_type=etype,257 address=f"{civic} {street}".strip(),258 city=(tags.get("addr:city") or "").strip(),259 postal_code=(tags.get("addr:postcode") or "").strip(),260 lat=lat, lng=lng,261 phone=(tags.get("phone") or tags.get("contact:phone") or "").strip(),262 website=(tags.get("website") or tags.get("contact:website") or "").strip(),263 hours=hours,264 services=services,265 dietary_options=[d for t, d in _DIET_TAGS.items()266 if tags.get(t) in ("yes", "only", "limited")],267 languages=["fr"],268 menu=None, # découverte : pas de menu ici269 )270271 def fetch(self) -> list[Restaurant]:272 out: dict[str, Restaurant] = {}273 # run partiel : si UN type d'établissement échoue sur tous les miroirs,274 # on continue avec les autres mais on signale le run comme incomplet —275 # le pipeline suspendra alors les retraits (pas de fausses fermetures).276 self.partial_run = False277 for key, value, etype in TARGETS:278 try:279 elements = self._overpass(key, value)280 except Exception as exc:281 self.partial_run = True282 print(f"[resto-ka] osm: {key}={value} ÉCHEC sur tous les "283 f"miroirs ({exc}) — run marqué partiel, retraits "284 "suspendus", file=sys.stderr)285 continue286 kept = 0287 for el in elements:288 resto = self._to_restaurant(el, etype, value)289 if resto is not None and resto.uid not in out:290 out[resto.uid] = resto291 kept += 1292 print(f"[resto-ka] osm: {key}={value} -> {len(elements)} POI, "293 f"{kept} fiches nommées")294 return list(out.values())295