# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/inspections.py # Desc: Connecteur MAPAQ — « Condamnations des établissements alimentaires » # (Données Québec, jeu 515374ee…, CSV listecondamnation.csv, licence # CC-BY 4.0). Inspections alimentaires condamnées : établissement, # adresse, dates, amende, motif (INSALUBRITE…). Alimente la table # LIÉE `inspections` + croisement CONSERVATEUR avec les restaurants # (jamais de fusion hasardeuse : nom normalisé + ville/code postal, # ou code postal + civique + similarité de nom). # # Ce n'est PAS une source de fiches restaurant : c'est un # enrichissement conformité/qualité appelé par ingest.enrich() # (cadence hebdomadaire, guard via sync_log source='mapaq'). # ============================================================================== from __future__ import annotations import csv import hashlib import io import json import re import sys import time import requests from .normalize import parse_price, strip_accents SOURCE_ID = "mapaq" CSV_URL = ("https://www.donneesquebec.ca/recherche/dataset/" "515374ee-ce34-464f-9875-7d1af3fa9b2a/resource/" "40105615-3abf-414b-bcba-182e8f2c5eb2/download/listecondamnation.csv") USER_AGENT = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)" REFRESH_DAYS = 6 # au plus une fois par cycle hebdo du watcher _POSTAL_RE = re.compile(r"\b([A-Z]\d[A-Z])\s?(\d[A-Z]\d)\b") _CIVIC_RE = re.compile(r"^\s*(\d+)") # entité légale « à numéro » (9277-4876 QUEBEC INC.) : inutilisable pour le nom _NUMBERED_CO_RE = re.compile(r"^\d{4}-\d{4}\s+quebec", re.I) _LEGAL_RE = re.compile( r"\b(inc|ltee|ltd|enr|senc|sec|s\.e\.n\.c|s\.a|cie|co|corp|corporation)\b\.?", re.I) _STOPWORDS = {"le", "la", "les", "l", "du", "de", "des", "d", "au", "aux", "et", "un", "une", "chez", "restaurant", "resto", "cafe", "bar", "groupe", "gestion", "quebec", "canada"} def norm_name(name: str) -> str: """Nom commercial normalisé pour le croisement : accents, ponctuation, suffixes légaux et espaces réduits.""" s = strip_accents((name or "").lower()) s = _LEGAL_RE.sub(" ", s) s = re.sub(r"[^a-z0-9]+", " ", s) return re.sub(r"\s+", " ", s).strip() def core_name(norm: str) -> str: """Cœur du nom : tokens significatifs sans mots génériques (« Restaurant Chez Mamy » et « Chez Mamy » -> « mamy »).""" return " ".join(t for t in norm.split() if t not in _STOPWORDS) def _tokens(norm: str) -> set[str]: return {t for t in norm.split() if len(t) >= 3 and t not in _STOPWORDS} def _name_similar(a: str, b: str, exclude: frozenset | set = frozenset()) -> bool: """Similarité conservatrice : contenance mutuelle ou ≥1 token significatif partagé. `exclude` = tokens de l'adresse/ville : un nom qui ne partage que le nom de sa VILLE (« Les Moulins La Fayette St-Hyacinthe » vs « Sushi Taxi — St-Hyacinthe », voisins de la même adresse) n'est PAS similaire.""" if not a or not b: return False ta = _tokens(a) - set(exclude) if not ta: # rien de distinctif hors adresse/ville return False if a == b or a in b or b in a: return True return bool(ta & _tokens(b)) def _iso_date(raw: str) -> str: """« 05/21/2025 00:00:00 » (MM/DD/YYYY) -> « 2025-05-21 ».""" m = re.match(r"^(\d{2})/(\d{2})/(\d{4})", (raw or "").strip()) if not m: return "" mm, dd, yyyy = m.groups() return f"{yyyy}-{mm}-{dd}" def _download() -> list[dict]: resp = requests.get(CSV_URL, headers={"User-Agent": USER_AGENT}, timeout=120) resp.raise_for_status() text = None for enc in ("utf-8-sig", "latin-1"): try: text = resp.content.decode(enc) break except UnicodeDecodeError: continue if text is None: raise RuntimeError("encodage CSV MAPAQ inconnu") return list(csv.DictReader(io.StringIO(text))) def _row_hash(rec: dict) -> str: blob = "|".join((rec.get("Nom_exploitant") or "", rec.get("Description_infraction") or "", rec.get("Adresse_lieu_infraction") or "", rec.get("Date_infraction") or "", rec.get("Date_jugement") or "", rec.get("Amende") or "")) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def import_rows(con, records: list[dict]) -> int: """Insère les condamnations (anti-doublon par row_hash). Retourne le nb de nouvelles lignes.""" added = 0 for rec in records: h = _row_hash(rec) adresse = (rec.get("Adresse_lieu_infraction") or "").strip() m = _POSTAL_RE.search(adresse.upper()) postal = f"{m.group(1)}{m.group(2)}" if m else "" cur = con.execute( """INSERT OR IGNORE INTO inspections (row_hash, exploitant, etablissement, description, adresse, postal_code, type_etablissement, categorie, date_infraction, date_jugement, date_publication, montant_amende, loi, motif) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (h, (rec.get("Nom_exploitant") or "").strip(), (rec.get("Raison_sociale") or "").strip(), (rec.get("Description_infraction") or "").strip(), adresse, postal, (rec.get("Type_etablissement") or "").strip(), (rec.get("SOC_DESC_REGRP_TYP_ENTT") or "").strip(), _iso_date(rec.get("Date_infraction")), _iso_date(rec.get("Date_jugement")), _iso_date(rec.get("Date_publication")), parse_price(rec.get("Amende")), (rec.get("SOC_NOM_LOI") or "").strip(), (rec.get("SOC_NOM_ARTCL_INFRC") or "").strip())) added += cur.rowcount con.commit() return added def match(con) -> dict: """Croisement CONSERVATEUR inspections <-> restaurants (uid canonique). Règle A « nom+lieu » : nom commercial normalisé IDENTIQUE au nom du resto ET (code postal identique OU ville du resto contenue dans l'adresse d'infraction ET numéro civique identique). Le civique est exigé quand on n'a que la ville : les CHAÎNES ont plusieurs succursales par ville et une condamnation ne doit JAMAIS être épinglée sur la mauvaise succursale. Règle B « adresse+nom » : code postal identique ET numéro civique identique ET similarité de nom (contenance ou token partagé). """ restos = con.execute( "SELECT uid, name, chain, city, postal_code, address FROM restaurants" " WHERE active=1 AND dup_of IS NULL").fetchall() by_name: dict[str, list] = {} by_postal: dict[str, list] = {} for r in restos: info = { "uid": r["uid"], "nname": norm_name(r["name"]), "nchain": norm_name(r["chain"] or ""), "ncity": strip_accents((r["city"] or "").lower()).strip(), "postal": (r["postal_code"] or "").replace(" ", "").upper(), } mm = _CIVIC_RE.match(r["address"] or "") info["civic"] = mm.group(1) if mm else "" if info["nname"]: by_name.setdefault(info["nname"], []).append(info) core = core_name(info["nname"]) if core and core != info["nname"]: by_name.setdefault(core, []).append(info) if info["postal"]: by_postal.setdefault(info["postal"], []).append(info) matched = 0 for row in con.execute( "SELECT id, exploitant, etablissement, adresse, postal_code" " FROM inspections WHERE uid IS NULL").fetchall(): names = [] if row["etablissement"]: names.append(norm_name(row["etablissement"])) expl = row["exploitant"] or "" if expl and not _NUMBERED_CO_RE.match(strip_accents(expl.lower())): names.append(norm_name(expl)) # variantes du nom : normalisé complet + cœur sans mots génériques names = [n for n in names if n] names += [c for c in (core_name(n) for n in names) if c and c not in names] nadresse = strip_accents((row["adresse"] or "").lower()) civic_m = _CIVIC_RE.match(row["adresse"] or "") civic = civic_m.group(1) if civic_m else "" postal = (row["postal_code"] or "").upper() hit, how = None, "" # Règle A : nom exact + ville/code postal for n in names: for info in by_name.get(n, []): same_place = ((postal and info["postal"] == postal) or (info["ncity"] and len(info["ncity"]) >= 4 and info["ncity"] in nadresse and civic and info["civic"] == civic)) if same_place: hit, how = info["uid"], "nom+lieu" break if hit: break # Règle B : code postal + civique + similarité de nom (hors tokens # d'adresse/ville — les voisins d'un même centre commercial partagent # CP + civique, seul un nom distinctif partagé fait foi) if hit is None and postal and civic: addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", nadresse).split()) for info in by_postal.get(postal, []): if info["civic"] != civic: continue if any(_name_similar(n, info["nname"], addr_tokens) or (info["nchain"] and _name_similar(n, info["nchain"], addr_tokens)) for n in names): hit, how = info["uid"], "adresse+nom" break if hit: con.execute("UPDATE inspections SET uid=?, matched_by=? WHERE id=?", (hit, how, row["id"])) matched += 1 con.commit() # résumé conformité par resto -> details.mapaq (affichage fiche) from . import db as _db for agg in con.execute( "SELECT uid, COUNT(*) n, SUM(COALESCE(montant_amende,0)) total," " MAX(date_jugement) derniere FROM inspections" " WHERE uid IS NOT NULL GROUP BY uid"): _db.merge_details(con, agg["uid"], {"mapaq": { "condamnations": agg["n"], "total_amendes": round(agg["total"] or 0, 2), "derniere_condamnation": agg["derniere"], }}) con.commit() total_linked = con.execute( "SELECT COUNT(*) c FROM inspections WHERE uid IS NOT NULL").fetchone()["c"] return {"matched_new": matched, "matched_total": total_linked} def sync(con=None, force: bool = False) -> dict | None: """Télécharge le CSV MAPAQ, importe et croise. Cadence hebdo (guard).""" from . import db own = con is None if own: con = db.connect() try: last = con.execute( "SELECT MAX(ts) ts FROM sync_log WHERE source=? AND ok=1", (SOURCE_ID,)).fetchone()["ts"] if not force and last and time.time() - last < REFRESH_DAYS * 86400: return None # déjà à jour cette semaine records = _download() added = import_rows(con, records) m = match(con) stats = {"rows": len(records), "new_rows": added, **m} con.execute( "INSERT INTO sync_log (source, ts, found, added, updated, removed," " ok, message, stats) VALUES (?,?,?,?,0,0,1,?,?)", (SOURCE_ID, time.time(), len(records), added, f"condamnations importées, {m['matched_total']} croisées", json.dumps(stats, ensure_ascii=False))) con.commit() print(f"[resto-ka] mapaq: {stats}") return stats except Exception as exc: db.log_failure(con, SOURCE_ID, str(exc)) print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr) return {"error": str(exc)} finally: if own: con.close()