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%
8.8 KB · 219 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/permits.py4# Desc:   Enrichissement RACJ — permis d'alcool en vigueur (Régie des alcools,5#         des courses et des jeux, Données Québec, licence CC-BY 4.0).6#         Télécharge le registre CSV des permis de détaillant d'alcool7#         (bar, restaurant pour vendre/servir…), regroupe par établissement et8#         croise de façon CONSERVATRICE avec les restaurants (mêmes règles que9#         les inspections MAPAQ : nom exact + lieu, ou postal + civique + nom10#         similaire — jamais fusionner deux établissements). Résultat dans11#         details.permis_alcool (catégories de permis, capacité, titulaire).12#         Gratuit, sans clé API. Cadence hebdomadaire (guard 6 jours).13# ==============================================================================14from __future__ import annotations1516import csv17import io18import json19import re20import sys21import time22from datetime import date2324import requests2526from .connectors.base import USER_AGENT27from .inspections import (_CIVIC_RE, _NUMBERED_CO_RE, _name_similar,28                          core_name, norm_name)29from .regions import strip_accents3031SOURCE_ID = "racj"32REFRESH_DAYS = 6                     # au plus une fois par cycle hebdo33CSV_URL = ("https://www.donneesquebec.ca/recherche/dataset/"34           "d817c9f7-76c7-44af-882d-0d673056ef86/resource/"35           "6b69360c-af8d-4c57-b5bf-1c38d5461de3/download/"36           "racj-alcool-detaillant.csv")373839def _download() -> list[dict]:40    resp = requests.get(CSV_URL, headers={"User-Agent": USER_AGENT}, timeout=120)41    resp.raise_for_status()42    text = None43    for enc in ("utf-8-sig", "latin-1"):44        try:45            text = resp.content.decode(enc)46            break47        except UnicodeDecodeError:48            continue49    if text is None:50        raise RuntimeError("encodage CSV RACJ inconnu")51    return list(csv.DictReader(io.StringIO(text)))525354def group_establishments(records: list[dict]) -> list[dict]:55    """Regroupe les lignes CSV (une par local/terrasse/permis) par56    établissement (NoEtablissement) : catégories de permis, capacité totale,57    nom, adresse, ville, code postal, titulaire."""58    by_no: dict[str, dict] = {}59    for rec in records:60        no = (rec.get("NoEtablissement") or "").strip()61        if not no:62            continue63        e = by_no.setdefault(no, {64            "no": no,65            "nom": (rec.get("RaisonSociale") or "").strip(),66            "titulaire": (rec.get("Titulaire") or "").strip(),67            "adresse": (rec.get("Adresse") or "").strip(),68            "ville": (rec.get("Ville") or "").strip(),69            "postal": (rec.get("CodePostal") or "").replace(" ", "").upper(),70            "categories": set(),71            "capacite": 0,72            "permis": set(),73        })74        cat = (rec.get("Categorie") or "").strip()75        if cat:76            e["categories"].add(cat)77        e["permis"].add((rec.get("NoPermis") or "").strip())78        try:79            e["capacite"] += int(rec.get("Capacite") or 0)80        except ValueError:81            pass82    return list(by_no.values())838485def match(con, establishments: list[dict]) -> dict:86    """Croisement CONSERVATEUR permis <-> restaurants (mêmes règles que87    inspections.match) :8889    Règle A « nom+lieu » : nom commercial normalisé IDENTIQUE ET (code postal90      identique OU même ville + même numéro civique).91    Règle B « adresse+nom » : code postal identique ET numéro civique92      identique ET similarité de nom (hors tokens d'adresse/ville).93    """94    restos = con.execute(95        "SELECT uid, name, chain, city, postal_code, address FROM restaurants"96        " WHERE active=1 AND dup_of IS NULL").fetchall()97    by_name: dict[str, list] = {}98    by_postal: dict[str, list] = {}99    for r in restos:100        info = {101            "uid": r["uid"],102            "nname": norm_name(r["name"]),103            "nchain": norm_name(r["chain"] or ""),104            "ncity": strip_accents((r["city"] or "").lower()).strip(),105            "postal": (r["postal_code"] or "").replace(" ", "").upper(),106        }107        mm = _CIVIC_RE.match(r["address"] or "")108        info["civic"] = mm.group(1) if mm else ""109        if info["nname"]:110            by_name.setdefault(info["nname"], []).append(info)111            core = core_name(info["nname"])112            if core and core != info["nname"]:113                by_name.setdefault(core, []).append(info)114        if info["postal"]:115            by_postal.setdefault(info["postal"], []).append(info)116117    today = date.today().isoformat()118    matched = 0119    seen_uids: set[str] = set()120    for est in establishments:121        names = []122        if est["nom"]:123            names.append(norm_name(est["nom"]))124        tit = est["titulaire"]125        if tit and not _NUMBERED_CO_RE.match(strip_accents(tit.lower())):126            names.append(norm_name(tit))127        names = [n for n in names if n]128        names += [c for c in (core_name(n) for n in names)129                  if c and c not in names]130        if not names:131            continue132        ncity = strip_accents(est["ville"].lower()).strip()133        civic_m = _CIVIC_RE.match(est["adresse"])134        civic = civic_m.group(1) if civic_m else ""135        postal = est["postal"]136137        hit, how = None, ""138        # Règle A : nom exact + code postal ou ville+civique139        for n in names:140            for info in by_name.get(n, []):141                same_place = ((postal and info["postal"] == postal)142                              or (ncity and info["ncity"]143                                  and (ncity == info["ncity"]144                                       or ncity in info["ncity"]145                                       or info["ncity"] in ncity)146                                  and civic and info["civic"] == civic))147                if same_place:148                    hit, how = info["uid"], "nom+lieu"149                    break150            if hit:151                break152        # Règle B : code postal + civique + similarité de nom153        if hit is None and postal and civic:154            addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents(155                est["adresse"].lower())).split()) | {ncity}156            for info in by_postal.get(postal, []):157                if info["civic"] != civic:158                    continue159                if any(_name_similar(n, info["nname"], addr_tokens)160                       or (info["nchain"]161                           and _name_similar(n, info["nchain"], addr_tokens))162                       for n in names):163                    hit, how = info["uid"], "adresse+nom"164                    break165        if hit is None or hit in seen_uids:166            continue                  # jamais deux établissements sur 1 fiche167        seen_uids.add(hit)168        from . import db as _db169        _db.merge_details(con, hit, {"permis_alcool": {170            "categories": sorted(est["categories"]),171            "nb_permis": len(est["permis"]),172            "capacite": est["capacite"] or None,173            "titulaire": est["titulaire"],174            "matched_by": how,175            "source": "RACJ / Données Québec (CC-BY 4.0)",176            "maj": today,177        }})178        matched += 1179    con.commit()180    total = con.execute(181        "SELECT COUNT(*) c FROM restaurants WHERE active=1 AND dup_of IS NULL"182        " AND details LIKE '%\"permis_alcool\"%'").fetchone()["c"]183    return {"matched": matched, "with_permit": total}184185186def sync(con=None, force: bool = False) -> dict | None:187    """Télécharge le registre RACJ, regroupe et croise. Cadence hebdo."""188    from . import db189    own = con is None190    if own:191        con = db.connect()192    try:193        last = con.execute(194            "SELECT MAX(ts) ts FROM sync_log WHERE source=? AND ok=1",195            (SOURCE_ID,)).fetchone()["ts"]196        if not force and last and time.time() - last < REFRESH_DAYS * 86400:197            return None                         # déjà à jour cette semaine198        records = _download()199        establishments = group_establishments(records)200        m = match(con, establishments)201        stats = {"rows": len(records), "etablissements": len(establishments),202                 **m}203        con.execute(204            "INSERT INTO sync_log (source, ts, found, added, updated, removed,"205            " ok, message, stats) VALUES (?,?,?,?,0,0,1,?,?)",206            (SOURCE_ID, time.time(), len(establishments), m["matched"],207             f"permis d'alcool croisés : {m['with_permit']} resto(s)",208             json.dumps(stats, ensure_ascii=False)))209        con.commit()210        print(f"[resto-ka] racj: {stats}")211        return stats212    except Exception as exc:213        db.log_failure(con, SOURCE_ID, str(exc))214        print(f"[resto-ka] racj: erreur non bloquante: {exc}", file=sys.stderr)215        return {"error": str(exc)}216    finally:217        if own:218            con.close()219