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/inspections.py4# Desc: Connecteur MAPAQ — « Condamnations des établissements alimentaires »5# (Données Québec, jeu 515374ee…, CSV listecondamnation.csv, licence6# CC-BY 4.0). Inspections alimentaires condamnées : établissement,7# adresse, dates, amende, motif (INSALUBRITE…). Alimente la table8# LIÉE `inspections` + croisement CONSERVATEUR avec les restaurants9# (jamais de fusion hasardeuse : nom normalisé + ville/code postal,10# ou code postal + civique + similarité de nom).11#12# Ce n'est PAS une source de fiches restaurant : c'est un13# enrichissement conformité/qualité appelé par ingest.enrich()14# (cadence hebdomadaire, guard via sync_log source='mapaq').15# ==============================================================================16from __future__ import annotations1718import csv19import hashlib20import io21import json22import re23import sys24import time2526import requests2728from .normalize import parse_price, strip_accents2930SOURCE_ID = "mapaq"31CSV_URL = ("https://www.donneesquebec.ca/recherche/dataset/"32 "515374ee-ce34-464f-9875-7d1af3fa9b2a/resource/"33 "40105615-3abf-414b-bcba-182e8f2c5eb2/download/listecondamnation.csv")34USER_AGENT = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)"35REFRESH_DAYS = 6 # au plus une fois par cycle hebdo du watcher3637_POSTAL_RE = re.compile(r"\b([A-Z]\d[A-Z])\s?(\d[A-Z]\d)\b")38_CIVIC_RE = re.compile(r"^\s*(\d+)")39# entité légale « à numéro » (9277-4876 QUEBEC INC.) : inutilisable pour le nom40_NUMBERED_CO_RE = re.compile(r"^\d{4}-\d{4}\s+quebec", re.I)41_LEGAL_RE = re.compile(42 r"\b(inc|ltee|ltd|enr|senc|sec|s\.e\.n\.c|s\.a|cie|co|corp|corporation)\b\.?",43 re.I)44_STOPWORDS = {"le", "la", "les", "l", "du", "de", "des", "d", "au", "aux",45 "et", "un", "une", "chez", "restaurant", "resto", "cafe", "bar",46 "groupe", "gestion", "quebec", "canada"}474849def norm_name(name: str) -> str:50 """Nom commercial normalisé pour le croisement : accents, ponctuation,51 suffixes légaux et espaces réduits."""52 s = strip_accents((name or "").lower())53 s = _LEGAL_RE.sub(" ", s)54 s = re.sub(r"[^a-z0-9]+", " ", s)55 return re.sub(r"\s+", " ", s).strip()565758def core_name(norm: str) -> str:59 """Cœur du nom : tokens significatifs sans mots génériques (« Restaurant60 Chez Mamy » et « Chez Mamy » -> « mamy »)."""61 return " ".join(t for t in norm.split() if t not in _STOPWORDS)626364def _tokens(norm: str) -> set[str]:65 return {t for t in norm.split() if len(t) >= 3 and t not in _STOPWORDS}666768def _name_similar(a: str, b: str, exclude: frozenset | set = frozenset()) -> bool:69 """Similarité conservatrice : contenance mutuelle ou ≥1 token significatif70 partagé. `exclude` = tokens de l'adresse/ville : un nom qui ne partage que71 le nom de sa VILLE (« Les Moulins La Fayette St-Hyacinthe » vs « Sushi72 Taxi — St-Hyacinthe », voisins de la même adresse) n'est PAS similaire."""73 if not a or not b:74 return False75 ta = _tokens(a) - set(exclude)76 if not ta: # rien de distinctif hors adresse/ville77 return False78 if a == b or a in b or b in a:79 return True80 return bool(ta & _tokens(b))818283def _iso_date(raw: str) -> str:84 """« 05/21/2025 00:00:00 » (MM/DD/YYYY) -> « 2025-05-21 »."""85 m = re.match(r"^(\d{2})/(\d{2})/(\d{4})", (raw or "").strip())86 if not m:87 return ""88 mm, dd, yyyy = m.groups()89 return f"{yyyy}-{mm}-{dd}"909192def _download() -> list[dict]:93 resp = requests.get(CSV_URL, headers={"User-Agent": USER_AGENT}, timeout=120)94 resp.raise_for_status()95 text = None96 for enc in ("utf-8-sig", "latin-1"):97 try:98 text = resp.content.decode(enc)99 break100 except UnicodeDecodeError:101 continue102 if text is None:103 raise RuntimeError("encodage CSV MAPAQ inconnu")104 return list(csv.DictReader(io.StringIO(text)))105106107def _row_hash(rec: dict) -> str:108 blob = "|".join((rec.get("Nom_exploitant") or "",109 rec.get("Description_infraction") or "",110 rec.get("Adresse_lieu_infraction") or "",111 rec.get("Date_infraction") or "",112 rec.get("Date_jugement") or "",113 rec.get("Amende") or ""))114 return hashlib.sha256(blob.encode("utf-8")).hexdigest()115116117def import_rows(con, records: list[dict]) -> int:118 """Insère les condamnations (anti-doublon par row_hash). Retourne le nb119 de nouvelles lignes."""120 added = 0121 for rec in records:122 h = _row_hash(rec)123 adresse = (rec.get("Adresse_lieu_infraction") or "").strip()124 m = _POSTAL_RE.search(adresse.upper())125 postal = f"{m.group(1)}{m.group(2)}" if m else ""126 cur = con.execute(127 """INSERT OR IGNORE INTO inspections128 (row_hash, exploitant, etablissement, description, adresse,129 postal_code, type_etablissement, categorie, date_infraction,130 date_jugement, date_publication, montant_amende, loi, motif)131 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",132 (h,133 (rec.get("Nom_exploitant") or "").strip(),134 (rec.get("Raison_sociale") or "").strip(),135 (rec.get("Description_infraction") or "").strip(),136 adresse, postal,137 (rec.get("Type_etablissement") or "").strip(),138 (rec.get("SOC_DESC_REGRP_TYP_ENTT") or "").strip(),139 _iso_date(rec.get("Date_infraction")),140 _iso_date(rec.get("Date_jugement")),141 _iso_date(rec.get("Date_publication")),142 parse_price(rec.get("Amende")),143 (rec.get("SOC_NOM_LOI") or "").strip(),144 (rec.get("SOC_NOM_ARTCL_INFRC") or "").strip()))145 added += cur.rowcount146 con.commit()147 return added148149150def match(con) -> dict:151 """Croisement CONSERVATEUR inspections <-> restaurants (uid canonique).152153 Règle A « nom+lieu » : nom commercial normalisé IDENTIQUE au nom du resto154 ET (code postal identique OU ville du resto contenue dans l'adresse155 d'infraction ET numéro civique identique). Le civique est exigé quand on156 n'a que la ville : les CHAÎNES ont plusieurs succursales par ville et une157 condamnation ne doit JAMAIS être épinglée sur la mauvaise succursale.158 Règle B « adresse+nom » : code postal identique ET numéro civique159 identique ET similarité de nom (contenance ou token partagé).160 """161 restos = con.execute(162 "SELECT uid, name, chain, city, postal_code, address FROM restaurants"163 " WHERE active=1 AND dup_of IS NULL").fetchall()164 by_name: dict[str, list] = {}165 by_postal: dict[str, list] = {}166 for r in restos:167 info = {168 "uid": r["uid"],169 "nname": norm_name(r["name"]),170 "nchain": norm_name(r["chain"] or ""),171 "ncity": strip_accents((r["city"] or "").lower()).strip(),172 "postal": (r["postal_code"] or "").replace(" ", "").upper(),173 }174 mm = _CIVIC_RE.match(r["address"] or "")175 info["civic"] = mm.group(1) if mm else ""176 if info["nname"]:177 by_name.setdefault(info["nname"], []).append(info)178 core = core_name(info["nname"])179 if core and core != info["nname"]:180 by_name.setdefault(core, []).append(info)181 if info["postal"]:182 by_postal.setdefault(info["postal"], []).append(info)183184 matched = 0185 for row in con.execute(186 "SELECT id, exploitant, etablissement, adresse, postal_code"187 " FROM inspections WHERE uid IS NULL").fetchall():188 names = []189 if row["etablissement"]:190 names.append(norm_name(row["etablissement"]))191 expl = row["exploitant"] or ""192 if expl and not _NUMBERED_CO_RE.match(strip_accents(expl.lower())):193 names.append(norm_name(expl))194 # variantes du nom : normalisé complet + cœur sans mots génériques195 names = [n for n in names if n]196 names += [c for c in (core_name(n) for n in names)197 if c and c not in names]198 nadresse = strip_accents((row["adresse"] or "").lower())199 civic_m = _CIVIC_RE.match(row["adresse"] or "")200 civic = civic_m.group(1) if civic_m else ""201 postal = (row["postal_code"] or "").upper()202203 hit, how = None, ""204 # Règle A : nom exact + ville/code postal205 for n in names:206 for info in by_name.get(n, []):207 same_place = ((postal and info["postal"] == postal)208 or (info["ncity"] and len(info["ncity"]) >= 4209 and info["ncity"] in nadresse210 and civic and info["civic"] == civic))211 if same_place:212 hit, how = info["uid"], "nom+lieu"213 break214 if hit:215 break216 # Règle B : code postal + civique + similarité de nom (hors tokens217 # d'adresse/ville — les voisins d'un même centre commercial partagent218 # CP + civique, seul un nom distinctif partagé fait foi)219 if hit is None and postal and civic:220 addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", nadresse).split())221 for info in by_postal.get(postal, []):222 if info["civic"] != civic:223 continue224 if any(_name_similar(n, info["nname"], addr_tokens)225 or (info["nchain"]226 and _name_similar(n, info["nchain"], addr_tokens))227 for n in names):228 hit, how = info["uid"], "adresse+nom"229 break230 if hit:231 con.execute("UPDATE inspections SET uid=?, matched_by=? WHERE id=?",232 (hit, how, row["id"]))233 matched += 1234 con.commit()235236 # résumé conformité par resto -> details.mapaq (affichage fiche)237 from . import db as _db238 for agg in con.execute(239 "SELECT uid, COUNT(*) n, SUM(COALESCE(montant_amende,0)) total,"240 " MAX(date_jugement) derniere FROM inspections"241 " WHERE uid IS NOT NULL GROUP BY uid"):242 _db.merge_details(con, agg["uid"], {"mapaq": {243 "condamnations": agg["n"],244 "total_amendes": round(agg["total"] or 0, 2),245 "derniere_condamnation": agg["derniere"],246 }})247 con.commit()248 total_linked = con.execute(249 "SELECT COUNT(*) c FROM inspections WHERE uid IS NOT NULL").fetchone()["c"]250 return {"matched_new": matched, "matched_total": total_linked}251252253def sync(con=None, force: bool = False) -> dict | None:254 """Télécharge le CSV MAPAQ, importe et croise. Cadence hebdo (guard)."""255 from . import db256 own = con is None257 if own:258 con = db.connect()259 try:260 last = con.execute(261 "SELECT MAX(ts) ts FROM sync_log WHERE source=? AND ok=1",262 (SOURCE_ID,)).fetchone()["ts"]263 if not force and last and time.time() - last < REFRESH_DAYS * 86400:264 return None # déjà à jour cette semaine265 records = _download()266 added = import_rows(con, records)267 m = match(con)268 stats = {"rows": len(records), "new_rows": added, **m}269 con.execute(270 "INSERT INTO sync_log (source, ts, found, added, updated, removed,"271 " ok, message, stats) VALUES (?,?,?,?,0,0,1,?,?)",272 (SOURCE_ID, time.time(), len(records), added,273 f"condamnations importées, {m['matched_total']} croisées",274 json.dumps(stats, ensure_ascii=False)))275 con.commit()276 print(f"[resto-ka] mapaq: {stats}")277 return stats278 except Exception as exc:279 db.log_failure(con, SOURCE_ID, str(exc))280 print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr)281 return {"error": str(exc)}282 finally:283 if own:284 con.close()285