Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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