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%
14.5 KB · 295 lines python
Raw Blame History
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