# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/dedup.py # Desc: Déduplication inter-sources des RESTAURANTS (CLAUDE.md §12.1) : # un même resto publié sur plusieurs plateformes est fusionné vers une # fiche canonique (dup_of) ; les succursales d'une chaîne ne sont # JAMAIS fusionnées entre elles (une adresse = une fiche). Les menus # restent conservés PAR CONTEXTE de prix (db.menus) — la préférence # dine-in > takeout > delivery est appliquée au service (web.py). # Inspiré de louka/dedup.py et de l'identité forte d'Auto·Ka. # ============================================================================== from __future__ import annotations import json import re import time from . import db from .regions import strip_accents # Ordre de préférence des sources pour le choix de la fiche canonique : # sources de première partie (prix réels) avant les plateformes de livraison, # la découverte (osm) toujours en dernier — sa fiche s'efface derrière celle # qui porte un menu. SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow", "site-resto", "ubereats", "doordash", "skip", "osm", "mtl-alim", "tastet"] def _name_key(name: str) -> str: """Nom normalisé : accents, ponctuation, mots vides d'enseigne. Le suffixe de succursale (« Poulet Rouge — Beauport », « Ashton - 1e avenue ») est retranché : l'identité géographique (coordonnées/postal) distingue déjà les succursales, et les sources de découverte (OSM) nomment l'enseigne sans suffixe.""" s = (name or "") for sep in (" — ", " – ", " - "): if sep in s: s = s.split(sep, 1)[0] break s = strip_accents(s.lower()) s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b", " ", s) s = re.sub(r"[^a-z0-9]+", " ", s) return re.sub(r"\s+", " ", s).strip() def _phone_key(phone: str) -> str: return re.sub(r"\D", "", phone or "")[-10:] def fingerprints(row: dict) -> list[str]: """Empreintes d'identité d'un resto. Deux fiches partageant une empreinte sont candidates à la fusion (même établissement, même adresse).""" name = _name_key(row.get("name") or "") if not name: return [] out = [] # nom + géolocalisation arrondie (~110 m) — la plus fiable lat, lng = row.get("lat"), row.get("lng") if lat is not None and lng is not None: out.append(f"{name}|{round(lat, 3)},{round(lng, 3)}") # nom + code postal (repli quand pas encore géocodé) pc = (row.get("postal_code") or "").replace(" ", "").upper() if pc: out.append(f"{name}|{pc}") # nom + téléphone ph = _phone_key(row.get("phone") or "") if ph: out.append(f"{name}|{ph}") return out def _priority(source: str) -> int: try: return SOURCE_PRIORITY.index(source) except ValueError: return len(SOURCE_PRIORITY) def run() -> dict: """Déduplique les restos actifs. Retourne des statistiques.""" con = db.connect() rows = [dict(r) for r in con.execute( "SELECT uid, source, name, phone, postal_code, lat, lng" " FROM restaurants WHERE active=1")] groups: dict[str, set[str]] = {} by_uid = {r["uid"]: r for r in rows} for r in rows: for fp in fingerprints(r): groups.setdefault(fp, set()).add(r["uid"]) # union des groupes qui partagent une fiche (empreintes multiples) parent: dict[str, str] = {} def find(u: str) -> str: while parent.get(u, u) != u: parent[u] = parent.get(parent[u], parent[u]) u = parent[u] return u def union(a: str, b: str) -> None: ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for uids in groups.values(): uids = sorted(uids) for other in uids[1:]: union(uids[0], other) clusters: dict[str, list[str]] = {} for r in rows: clusters.setdefault(find(r["uid"]), []).append(r["uid"]) now = time.time() merged = 0 for members in clusters.values(): if len(members) < 2: continue # une fiche par SOURCE au maximum peut être fusionnée : deux fiches de # la même source = deux succursales (jamais fusionnées, §12.1) by_source: dict[str, str] = {} for uid in sorted(members, key=lambda u: _priority(by_uid[u]["source"])): src = by_uid[uid]["source"] if src in by_source: continue by_source[src] = uid canon, *dups = list(by_source.values()) if not dups: continue dup_sources = sorted({by_uid[u]["source"] for u in dups}) con.execute("UPDATE restaurants SET dup_sources=?, updated_at=? WHERE uid=?", (json.dumps(dup_sources, ensure_ascii=False), now, canon)) for uid in dups: con.execute("UPDATE restaurants SET dup_of=?, updated_at=? WHERE uid=?", (canon, now, uid)) merged += 1 print(f"[resto-ka] dedup: {canon} <- {dups}") # journaliser (§12.1) con.commit() con.close() return {"clusters": sum(1 for m in clusters.values() if len(m) > 1), "merged": merged}