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%
12.0 KB · 285 lines python
Raw Blame History
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