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/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