SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

5.3 KB · 144 lines python
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"]272829def _name_key(name: str) -> str:30    """Nom normalisé : accents, ponctuation, mots vides d'enseigne.3132    Le suffixe de succursale (« Poulet Rouge — Beauport », « Ashton - 1e33    avenue ») est retranché : l'identité géographique (coordonnées/postal)34    distingue déjà les succursales, et les sources de découverte (OSM) nomment35    l'enseigne sans suffixe."""36    s = (name or "")37    for sep in (" — ", " – ", " - "):38        if sep in s:39            s = s.split(sep, 1)[0]40            break41    s = strip_accents(s.lower())42    s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b",43               " ", s)44    s = re.sub(r"[^a-z0-9]+", " ", s)45    return re.sub(r"\s+", " ", s).strip()464748def _phone_key(phone: str) -> str:49    return re.sub(r"\D", "", phone or "")[-10:]505152def fingerprints(row: dict) -> list[str]:53    """Empreintes d'identité d'un resto. Deux fiches partageant une empreinte54    sont candidates à la fusion (même établissement, même adresse)."""55    name = _name_key(row.get("name") or "")56    if not name:57        return []58    out = []59    # nom + géolocalisation arrondie (~110 m) — la plus fiable60    lat, lng = row.get("lat"), row.get("lng")61    if lat is not None and lng is not None:62        out.append(f"{name}|{round(lat, 3)},{round(lng, 3)}")63    # nom + code postal (repli quand pas encore géocodé)64    pc = (row.get("postal_code") or "").replace(" ", "").upper()65    if pc:66        out.append(f"{name}|{pc}")67    # nom + téléphone68    ph = _phone_key(row.get("phone") or "")69    if ph:70        out.append(f"{name}|{ph}")71    return out727374def _priority(source: str) -> int:75    try:76        return SOURCE_PRIORITY.index(source)77    except ValueError:78        return len(SOURCE_PRIORITY)798081def run() -> dict:82    """Déduplique les restos actifs. Retourne des statistiques."""83    con = db.connect()84    rows = [dict(r) for r in con.execute(85        "SELECT uid, source, name, phone, postal_code, lat, lng"86        " FROM restaurants WHERE active=1")]87    groups: dict[str, set[str]] = {}88    by_uid = {r["uid"]: r for r in rows}89    for r in rows:90        for fp in fingerprints(r):91            groups.setdefault(fp, set()).add(r["uid"])9293    # union des groupes qui partagent une fiche (empreintes multiples)94    parent: dict[str, str] = {}9596    def find(u: str) -> str:97        while parent.get(u, u) != u:98            parent[u] = parent.get(parent[u], parent[u])99            u = parent[u]100        return u101102    def union(a: str, b: str) -> None:103        ra, rb = find(a), find(b)104        if ra != rb:105            parent[rb] = ra106107    for uids in groups.values():108        uids = sorted(uids)109        for other in uids[1:]:110            union(uids[0], other)111112    clusters: dict[str, list[str]] = {}113    for r in rows:114        clusters.setdefault(find(r["uid"]), []).append(r["uid"])115116    now = time.time()117    merged = 0118    for members in clusters.values():119        if len(members) < 2:120            continue121        # une fiche par SOURCE au maximum peut être fusionnée : deux fiches de122        # la même source = deux succursales (jamais fusionnées, §12.1)123        by_source: dict[str, str] = {}124        for uid in sorted(members, key=lambda u: _priority(by_uid[u]["source"])):125            src = by_uid[uid]["source"]126            if src in by_source:127                continue128            by_source[src] = uid129        canon, *dups = list(by_source.values())130        if not dups:131            continue132        dup_sources = sorted({by_uid[u]["source"] for u in dups})133        con.execute("UPDATE restaurants SET dup_sources=?, updated_at=? WHERE uid=?",134                    (json.dumps(dup_sources, ensure_ascii=False), now, canon))135        for uid in dups:136            con.execute("UPDATE restaurants SET dup_of=?, updated_at=? WHERE uid=?",137                        (canon, now, uid))138            merged += 1139        print(f"[resto-ka] dedup: {canon} <- {dups}")   # journaliser (§12.1)140    con.commit()141    con.close()142    return {"clusters": sum(1 for m in clusters.values() if len(m) > 1),143            "merged": merged}144
Files · f1cd42b954e0128f07acb17cb489f8ed8da9f818
Menu

Theme