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%
5.3 KB · 145 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/dedup.py4# Desc:   Déduplication inter-sources des RESTAURANTS (CLAUDE.md §12.1) :5#         un même resto publié sur plusieurs plateformes est fusionné vers une6#         fiche canonique (dup_of) ; les succursales d'une chaîne ne sont7#         JAMAIS fusionnées entre elles (une adresse = une fiche). Les menus8#         restent conservés PAR CONTEXTE de prix (db.menus) — la préférence9#         dine-in > takeout > delivery est appliquée au service (web.py).10#         Inspiré de louka/dedup.py et de l'identité forte d'Auto·Ka.11# ==============================================================================12from __future__ import annotations1314import json15import re16import time1718from . import db19from .regions import strip_accents2021# Ordre de préférence des sources pour le choix de la fiche canonique :22# sources de première partie (prix réels) avant les plateformes de livraison,23# la découverte (osm) toujours en dernier — sa fiche s'efface derrière celle24# qui porte un menu.25SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow",26                   "site-resto", "ubereats", "doordash", "skip", "osm",27                   "mtl-alim", "tastet"]282930def _name_key(name: str) -> str:31    """Nom normalisé : accents, ponctuation, mots vides d'enseigne.3233    Le suffixe de succursale (« Poulet Rouge — Beauport », « Ashton - 1e34    avenue ») est retranché : l'identité géographique (coordonnées/postal)35    distingue déjà les succursales, et les sources de découverte (OSM) nomment36    l'enseigne sans suffixe."""37    s = (name or "")38    for sep in (" — ", " – ", " - "):39        if sep in s:40            s = s.split(sep, 1)[0]41            break42    s = strip_accents(s.lower())43    s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b",44               " ", s)45    s = re.sub(r"[^a-z0-9]+", " ", s)46    return re.sub(r"\s+", " ", s).strip()474849def _phone_key(phone: str) -> str:50    return re.sub(r"\D", "", phone or "")[-10:]515253def fingerprints(row: dict) -> list[str]:54    """Empreintes d'identité d'un resto. Deux fiches partageant une empreinte55    sont candidates à la fusion (même établissement, même adresse)."""56    name = _name_key(row.get("name") or "")57    if not name:58        return []59    out = []60    # nom + géolocalisation arrondie (~110 m) — la plus fiable61    lat, lng = row.get("lat"), row.get("lng")62    if lat is not None and lng is not None:63        out.append(f"{name}|{round(lat, 3)},{round(lng, 3)}")64    # nom + code postal (repli quand pas encore géocodé)65    pc = (row.get("postal_code") or "").replace(" ", "").upper()66    if pc:67        out.append(f"{name}|{pc}")68    # nom + téléphone69    ph = _phone_key(row.get("phone") or "")70    if ph:71        out.append(f"{name}|{ph}")72    return out737475def _priority(source: str) -> int:76    try:77        return SOURCE_PRIORITY.index(source)78    except ValueError:79        return len(SOURCE_PRIORITY)808182def run() -> dict:83    """Déduplique les restos actifs. Retourne des statistiques."""84    con = db.connect()85    rows = [dict(r) for r in con.execute(86        "SELECT uid, source, name, phone, postal_code, lat, lng"87        " FROM restaurants WHERE active=1")]88    groups: dict[str, set[str]] = {}89    by_uid = {r["uid"]: r for r in rows}90    for r in rows:91        for fp in fingerprints(r):92            groups.setdefault(fp, set()).add(r["uid"])9394    # union des groupes qui partagent une fiche (empreintes multiples)95    parent: dict[str, str] = {}9697    def find(u: str) -> str:98        while parent.get(u, u) != u:99            parent[u] = parent.get(parent[u], parent[u])100            u = parent[u]101        return u102103    def union(a: str, b: str) -> None:104        ra, rb = find(a), find(b)105        if ra != rb:106            parent[rb] = ra107108    for uids in groups.values():109        uids = sorted(uids)110        for other in uids[1:]:111            union(uids[0], other)112113    clusters: dict[str, list[str]] = {}114    for r in rows:115        clusters.setdefault(find(r["uid"]), []).append(r["uid"])116117    now = time.time()118    merged = 0119    for members in clusters.values():120        if len(members) < 2:121            continue122        # une fiche par SOURCE au maximum peut être fusionnée : deux fiches de123        # la même source = deux succursales (jamais fusionnées, §12.1)124        by_source: dict[str, str] = {}125        for uid in sorted(members, key=lambda u: _priority(by_uid[u]["source"])):126            src = by_uid[uid]["source"]127            if src in by_source:128                continue129            by_source[src] = uid130        canon, *dups = list(by_source.values())131        if not dups:132            continue133        dup_sources = sorted({by_uid[u]["source"] for u in dups})134        con.execute("UPDATE restaurants SET dup_sources=?, updated_at=? WHERE uid=?",135                    (json.dumps(dup_sources, ensure_ascii=False), now, canon))136        for uid in dups:137            con.execute("UPDATE restaurants SET dup_of=?, updated_at=? WHERE uid=?",138                        (canon, now, uid))139            merged += 1140        print(f"[resto-ka] dedup: {canon} <- {dups}")   # journaliser (§12.1)141    con.commit()142    con.close()143    return {"clusters": sum(1 for m in clusters.values() if len(m) > 1),144            "merged": merged}145