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%
5.5 KB · 130 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/mtlalim.py4# Desc:   Connecteur de DÉCOUVERTE massif « mtl-alim » (Palier 4) — registre5#         officiel des établissements alimentaires inspectés par la Ville de6#         Montréal (données ouvertes, ~17 000 établissements OUVERTS dont7#         ~7 500 de restauration). Complète OSM sur l'île de Montréal : chaque8#         resto détenteur d'un permis y figure, y compris ceux absents des9#         cartes communautaires.10#11#         Mode d'extraction : téléchargement direct du CSV12#         (donnees.montreal.ca, licence CC-BY 4.0 — attribution « Ville de13#         Montréal » sur la page Sources). Une seule requête par run.14#         Base légale : données ouvertes, réutilisation permise avec15#         attribution. Filtres : statut == « Ouvert » + whitelist des types16#         food-service (restaurants, casse-croûtes, bars, boulangeries,17#         traiteurs, camions-cuisine…) — épiceries, garderies, cafétérias18#         institutionnelles et distributrices EXCLUES (hors périmètre §2).19#20#         Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).21# ==============================================================================22from __future__ import annotations2324import csv25import io26import sys2728from ..schema import Restaurant29from .base import BaseConnector3031CSV_URL = ("https://donnees.montreal.ca/dataset/"32           "c1d65779-d3cb-44e8-af0a-b9f2c5f7766d/resource/"33           "28a4957d-732e-48f9-8adb-0624867d9bb0/download/businesses.csv")3435# type d'établissement (registre Ville) -> establishment_type Resto·Ka.36# Whitelist STRICTE : tout type absent d'ici est ignoré (épiceries,37# distributrices, écoles, hôpitaux, entrepôts… — on ne mange pas « au resto »).38TYPE_MAP = {39    "Restaurant": "restaurant",40    "Restaurant service rapide": "fast-food",41    "Restaurant mets pour emporter": "fast-food",42    "Casse-croûte": "casse-croute",43    "Bar salon, taverne": "bar",44    "Brasserie": "bar",45    "Pâtisserie/Boulangerie": "boulangerie-patisserie",46    "Boulangerie": "boulangerie-patisserie",47    "Traiteur": "traiteur",48    "Camion-cuisine": "food-truck",49    "Bar laitier": "casse-croute",50    "Bar laitier saisonnier": "casse-croute",51}5253# ces types n'offrent pas de salle : takeout seulement54_TAKEOUT_ONLY = {"fast-food", "traiteur", "food-truck", "casse-croute",55                 "boulangerie-patisserie"}5657# borne île de Montréal (lat/lng du CSV parfois 0/0 ou aberrants)58_LAT = (45.35, 45.75)59_LNG = (-74.05, -73.35)606162class MtlAlimConnector(BaseConnector):63    source_id = "mtl-alim"64    request_delay = 1.065    timeout = 12066    use_detail_cache = False       # un seul CSV, pas de pages détail6768    def _rows(self) -> list[dict]:69        resp = self.get(CSV_URL)70        resp.encoding = "utf-8"71        return list(csv.DictReader(io.StringIO(resp.text)))7273    def _to_restaurant(self, row: dict) -> Restaurant | None:74        etype = TYPE_MAP.get((row.get("type") or "").strip())75        if not etype:76            return None77        name = (row.get("name") or "").strip()78        ext_id = (row.get("business_id") or "").strip()79        if not name or not ext_id:80            return None81        lat = lng = None82        try:83            lat, lng = float(row.get("latitude") or ""), float(row.get("longitude") or "")84        except ValueError:85            pass86        if lat is not None and not (_LAT[0] <= lat <= _LAT[1]87                                    and _LNG[0] <= lng <= _LNG[1]):88            lat = lng = None89        services = (["takeout"] if etype in _TAKEOUT_ONLY90                    else ["salle", "takeout"])91        return Restaurant(92            source=self.source_id,93            external_id=ext_id,94            name=name,95            url=("https://donnees.montreal.ca/dataset/"96                 "inspection-aliments-contrevenants"),97            establishment_type=etype,98            address=(row.get("address") or "").strip(),99            city=(row.get("city") or "").strip() or "Montréal",100            region="Montréal",             # registre = île de Montréal101            lat=lat, lng=lng,102            services=services,103            languages=["fr"],104            menu=None,                     # découverte : pas de menu ici105        )106107    def fetch(self) -> list[Restaurant]:108        rows = self._rows()109        out: dict[str, Restaurant] = {}110        skipped_types: dict[str, int] = {}111        for row in rows:112            if (row.get("statut") or "").strip() != "Ouvert":113                continue114            resto = self._to_restaurant(row)115            if resto is None:116                t = (row.get("type") or "?").strip()117                skipped_types[t] = skipped_types.get(t, 0) + 1118                continue119            out[resto.uid] = resto          # business_id unique -> dédup interne120        # garde-fou : un CSV tronqué/vidé ne doit pas déclencher des retraits121        # de masse — on signale le run comme partiel sous un plancher plausible122        if len(out) < 3000:123            self.partial_run = True124            print(f"[resto-ka] mtl-alim: seulement {len(out)} fiches "125                  "(plancher 3000) — run marqué partiel, retraits suspendus",126                  file=sys.stderr)127        print(f"[resto-ka] mtl-alim: {len(rows)} lignes CSV -> "128              f"{len(out)} établissements food-service ouverts")129        return list(out.values())130