# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # matching.py : rapprochement inter-bannières — « le même produit ailleurs » # Après chaque cycle d'ingestion, reconstruit la table product_links # (group_id, uid) : groupes de produits ACTIFS identiques vendus par des # bannières DIFFÉRENTES, rapprochés par clé normalisée # marque + nom nettoyé + format. # Matching CONSERVATEUR (mieux vaut rater un rapprochement qu'en inventer) : # - marque non vide obligatoire (égalité stricte après normalisation) ; # - format identique (quantité + unité de base via parse_size) ; # - similarité élevée entre noms nettoyés (accents/casse/mots vides/ # format retirés, jetons triés) — seuil 0,86, ou 0,95 si aucun format # n'est connu de part et d'autre. # Une source « *_flyer » est la même bannière que sa source catalogue # (metro / metro_flyer) : un groupe doit couvrir >= 2 bannières distinctes. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re import sqlite3 from difflib import SequenceMatcher from .normalize import parse_size, strip_accents # seuils de similarité (SequenceMatcher sur jetons triés) SIM_WITH_SIZE = 0.86 # marque + format identiques -> le nom confirme SIM_NO_SIZE = 0.95 # aucun format connu -> quasi-identité exigée # Sources hors matching épicerie : la SAQ est une bannière unique à prix # provinciaux uniformes — aucun « même produit ailleurs » pertinent, et ses # vins/bières ne doivent pas se rapprocher des rayons boissons des épiceries. EXCLUDED_SOURCES = frozenset({"saq"}) MAX_BLOCK = 3000 # garde-fou : bloc (marque, format) anormalement gros # (comparaisons en fenêtre triée de 30 -> coût linéaire ; # les marques maison dépassent 400 depuis les circulaires) _ALNUM_RE = re.compile(r"[^a-z0-9]+") # jetons de format dans le nom (« 2 x 450 g », « 1,5 l »…) — retirés du nom _SIZE_TOKEN_RE = re.compile( r"\b\d+(?:[.,]\d+)?\s*(?:x|×)?\s*\d*(?:[.,]\d+)?\s*" r"(?:kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b") _STOPWORDS = { "de", "du", "des", "la", "le", "les", "l", "d", "et", "a", "au", "aux", "en", "pour", "the", "of", "and", "with", "un", "une", } def banner(source: str) -> str: """Bannière d'une source — « metro_flyer » et « metro » = même bannière.""" return source[:-6] if source.endswith("_flyer") else source def brand_key(brand: str) -> str: """Marque normalisée (accents/casse/ponctuation) — '' si inutilisable.""" key = _ALNUM_RE.sub("", strip_accents(brand or "").lower()) return key if len(key) >= 2 else "" def name_key(name: str, brand: str = "") -> str: """Nom nettoyé : accents/casse, marque, format et mots vides retirés, jetons triés (robuste à l'ordre FR/EN des mots). Les jetons NUMÉRIQUES restants sont conservés : ils sont discriminants (« lait 2 % » vs « lait 3,25 % ») — name_similarity exige leur égalité stricte.""" s = strip_accents(name or "").lower() s = _SIZE_TOKEN_RE.sub(" ", s) s = _ALNUM_RE.sub(" ", s) drop = set(_STOPWORDS) if brand: drop |= set(_ALNUM_RE.sub(" ", strip_accents(brand).lower()).split()) tokens = [t for t in s.split() if t and t not in drop] return " ".join(sorted(tokens)) def size_key(size_label: str) -> str: """Format canonique « 450|g », « 2000|ml »… — '' si non extractible.""" parsed = parse_size(size_label) if not parsed: return "" qty, base = parsed return f"{qty:g}|{base}" _NUM_RE = re.compile(r"\d+") def name_similarity(a: str, b: str) -> float: """Similarité de deux noms nettoyés (name_key). Garde-fou : les jetons numériques doivent être IDENTIQUES (« lait 2 » ≠ « lait 3 25 ») — un chiffre différent = produit différent (teneur en gras, compte, calibre).""" if not a or not b: return 0.0 if a == b: return 1.0 if set(_NUM_RE.findall(a)) != set(_NUM_RE.findall(b)): return 0.0 return SequenceMatcher(None, a, b).ratio() # --------------------------------------------------------------------------- # Reconstruction de la table product_links # --------------------------------------------------------------------------- def rebuild(con: sqlite3.Connection) -> dict: """Reconstruit product_links à partir des produits actifs. Retourne des compteurs : groupes multi-bannières, produits reliés, bannières couvertes.""" rows = con.execute( """SELECT uid, source, brand, name, size_label FROM products WHERE active=1 AND brand<>'' AND name<>''""").fetchall() # blocs de candidats : même marque + même format (clé exacte) blocks: dict[tuple[str, str], list[tuple[str, str, str]]] = {} for r in rows: if r["source"] in EXCLUDED_SOURCES: continue bk = brand_key(r["brand"]) if not bk: continue nk = name_key(r["name"], r["brand"]) if not nk: continue blocks.setdefault((bk, size_key(r["size_label"])), []).append((r["uid"], r["source"], nk)) groups: list[list[str]] = [] for (bk, sk), members in blocks.items(): if len(members) < 2 or len(members) > MAX_BLOCK: continue threshold = SIM_WITH_SIZE if sk else SIM_NO_SIZE # union-find sur la similarité des noms nettoyés parent = list(range(len(members))) def find(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i # tri par nom nettoyé : les paires proches sont voisines — on ne # compare chaque membre qu'à une fenêtre bornée (blocs petits en # pratique, la fenêtre évite le pire cas quadratique) order = sorted(range(len(members)), key=lambda i: members[i][2]) for a in range(len(order)): for b in range(a + 1, min(a + 30, len(order))): i, j = order[a], order[b] if name_similarity(members[i][2], members[j][2]) >= threshold: parent[find(i)] = find(j) clusters: dict[int, list[int]] = {} for i in range(len(members)): clusters.setdefault(find(i), []).append(i) for idx in clusters.values(): uids = [members[i][0] for i in idx] banners = {banner(members[i][1]) for i in idx} if len(banners) >= 2: # inter-bannières seulement groups.append(sorted(set(uids))) con.execute("DELETE FROM product_links") linked = 0 for uids in groups: gid = hashlib.md5("|".join(uids).encode("utf-8")).hexdigest()[:16] for uid in uids: # un produit ne peut appartenir qu'à un groupe (uid = clé primaire) con.execute( "INSERT OR IGNORE INTO product_links (group_id, uid) VALUES (?,?)", (gid, uid)) linked += 1 con.commit() real_groups = con.execute( "SELECT COUNT(DISTINCT group_id) c FROM product_links").fetchone()["c"] real_linked = con.execute( "SELECT COUNT(*) c FROM product_links").fetchone()["c"] return {"groups": real_groups, "linked_products": real_linked}