# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/mtlalim.py # Desc: Connecteur de DÉCOUVERTE massif « mtl-alim » (Palier 4) — registre # officiel des établissements alimentaires inspectés par la Ville de # Montréal (données ouvertes, ~17 000 établissements OUVERTS dont # ~7 500 de restauration). Complète OSM sur l'île de Montréal : chaque # resto détenteur d'un permis y figure, y compris ceux absents des # cartes communautaires. # # Mode d'extraction : téléchargement direct du CSV # (donnees.montreal.ca, licence CC-BY 4.0 — attribution « Ville de # Montréal » sur la page Sources). Une seule requête par run. # Base légale : données ouvertes, réutilisation permise avec # attribution. Filtres : statut == « Ouvert » + whitelist des types # food-service (restaurants, casse-croûtes, bars, boulangeries, # traiteurs, camions-cuisine…) — épiceries, garderies, cafétérias # institutionnelles et distributrices EXCLUES (hors périmètre §2). # # Contexte de prix produit : AUCUN (pas de menu — fiche de découverte). # ============================================================================== from __future__ import annotations import csv import io import sys from ..schema import Restaurant from .base import BaseConnector CSV_URL = ("https://donnees.montreal.ca/dataset/" "c1d65779-d3cb-44e8-af0a-b9f2c5f7766d/resource/" "28a4957d-732e-48f9-8adb-0624867d9bb0/download/businesses.csv") # type d'établissement (registre Ville) -> establishment_type Resto·Ka. # Whitelist STRICTE : tout type absent d'ici est ignoré (épiceries, # distributrices, écoles, hôpitaux, entrepôts… — on ne mange pas « au resto »). TYPE_MAP = { "Restaurant": "restaurant", "Restaurant service rapide": "fast-food", "Restaurant mets pour emporter": "fast-food", "Casse-croûte": "casse-croute", "Bar salon, taverne": "bar", "Brasserie": "bar", "Pâtisserie/Boulangerie": "boulangerie-patisserie", "Boulangerie": "boulangerie-patisserie", "Traiteur": "traiteur", "Camion-cuisine": "food-truck", "Bar laitier": "casse-croute", "Bar laitier saisonnier": "casse-croute", } # ces types n'offrent pas de salle : takeout seulement _TAKEOUT_ONLY = {"fast-food", "traiteur", "food-truck", "casse-croute", "boulangerie-patisserie"} # borne île de Montréal (lat/lng du CSV parfois 0/0 ou aberrants) _LAT = (45.35, 45.75) _LNG = (-74.05, -73.35) class MtlAlimConnector(BaseConnector): source_id = "mtl-alim" request_delay = 1.0 timeout = 120 use_detail_cache = False # un seul CSV, pas de pages détail def _rows(self) -> list[dict]: resp = self.get(CSV_URL) resp.encoding = "utf-8" return list(csv.DictReader(io.StringIO(resp.text))) def _to_restaurant(self, row: dict) -> Restaurant | None: etype = TYPE_MAP.get((row.get("type") or "").strip()) if not etype: return None name = (row.get("name") or "").strip() ext_id = (row.get("business_id") or "").strip() if not name or not ext_id: return None lat = lng = None try: lat, lng = float(row.get("latitude") or ""), float(row.get("longitude") or "") except ValueError: pass if lat is not None and not (_LAT[0] <= lat <= _LAT[1] and _LNG[0] <= lng <= _LNG[1]): lat = lng = None services = (["takeout"] if etype in _TAKEOUT_ONLY else ["salle", "takeout"]) return Restaurant( source=self.source_id, external_id=ext_id, name=name, url=("https://donnees.montreal.ca/dataset/" "inspection-aliments-contrevenants"), establishment_type=etype, address=(row.get("address") or "").strip(), city=(row.get("city") or "").strip() or "Montréal", region="Montréal", # registre = île de Montréal lat=lat, lng=lng, services=services, languages=["fr"], menu=None, # découverte : pas de menu ici ) def fetch(self) -> list[Restaurant]: rows = self._rows() out: dict[str, Restaurant] = {} skipped_types: dict[str, int] = {} for row in rows: if (row.get("statut") or "").strip() != "Ouvert": continue resto = self._to_restaurant(row) if resto is None: t = (row.get("type") or "?").strip() skipped_types[t] = skipped_types.get(t, 0) + 1 continue out[resto.uid] = resto # business_id unique -> dédup interne # garde-fou : un CSV tronqué/vidé ne doit pas déclencher des retraits # de masse — on signale le run comme partiel sous un plancher plausible if len(out) < 3000: self.partial_run = True print(f"[resto-ka] mtl-alim: seulement {len(out)} fiches " "(plancher 3000) — run marqué partiel, retraits suspendus", file=sys.stderr) print(f"[resto-ka] mtl-alim: {len(rows)} lignes CSV -> " f"{len(out)} établissements food-service ouverts") return list(out.values())