Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 53.9%
TypeScript 24%
CSS 14.9%
JavaScript 5.8%
HTML 1.4%
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# matching.py : rapprochement inter-bannières — « le même produit ailleurs »5# Après chaque cycle d'ingestion, reconstruit la table product_links6# (group_id, uid) : groupes de produits ACTIFS identiques vendus par des7# bannières DIFFÉRENTES, rapprochés par clé normalisée8# marque + nom nettoyé + format.9# Matching CONSERVATEUR (mieux vaut rater un rapprochement qu'en inventer) :10# - marque non vide obligatoire (égalité stricte après normalisation) ;11# - format identique (quantité + unité de base via parse_size) ;12# - similarité élevée entre noms nettoyés (accents/casse/mots vides/13# format retirés, jetons triés) — seuil 0,86, ou 0,95 si aucun format14# n'est connu de part et d'autre.15# Une source « *_flyer » est la même bannière que sa source catalogue16# (metro / metro_flyer) : un groupe doit couvrir >= 2 bannières distinctes.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import re22import sqlite323from difflib import SequenceMatcher2425from .normalize import parse_size, strip_accents2627# seuils de similarité (SequenceMatcher sur jetons triés)28SIM_WITH_SIZE = 0.86 # marque + format identiques -> le nom confirme29SIM_NO_SIZE = 0.95 # aucun format connu -> quasi-identité exigée30# Sources hors matching épicerie : la SAQ est une bannière unique à prix31# provinciaux uniformes — aucun « même produit ailleurs » pertinent, et ses32# vins/bières ne doivent pas se rapprocher des rayons boissons des épiceries.33EXCLUDED_SOURCES = frozenset({"saq"})34MAX_BLOCK = 3000 # garde-fou : bloc (marque, format) anormalement gros35 # (comparaisons en fenêtre triée de 30 -> coût linéaire ;36 # les marques maison dépassent 400 depuis les circulaires)3738_ALNUM_RE = re.compile(r"[^a-z0-9]+")39# jetons de format dans le nom (« 2 x 450 g », « 1,5 l »…) — retirés du nom40_SIZE_TOKEN_RE = re.compile(41 r"\b\d+(?:[.,]\d+)?\s*(?:x|×)?\s*\d*(?:[.,]\d+)?\s*"42 r"(?:kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b")43_STOPWORDS = {44 "de", "du", "des", "la", "le", "les", "l", "d", "et", "a", "au", "aux",45 "en", "pour", "the", "of", "and", "with", "un", "une",46}474849def banner(source: str) -> str:50 """Bannière d'une source — « metro_flyer » et « metro » = même bannière."""51 return source[:-6] if source.endswith("_flyer") else source525354def brand_key(brand: str) -> str:55 """Marque normalisée (accents/casse/ponctuation) — '' si inutilisable."""56 key = _ALNUM_RE.sub("", strip_accents(brand or "").lower())57 return key if len(key) >= 2 else ""585960def name_key(name: str, brand: str = "") -> str:61 """Nom nettoyé : accents/casse, marque, format et mots vides retirés,62 jetons triés (robuste à l'ordre FR/EN des mots). Les jetons NUMÉRIQUES63 restants sont conservés : ils sont discriminants (« lait 2 % » vs64 « lait 3,25 % ») — name_similarity exige leur égalité stricte."""65 s = strip_accents(name or "").lower()66 s = _SIZE_TOKEN_RE.sub(" ", s)67 s = _ALNUM_RE.sub(" ", s)68 drop = set(_STOPWORDS)69 if brand:70 drop |= set(_ALNUM_RE.sub(" ", strip_accents(brand).lower()).split())71 tokens = [t for t in s.split() if t and t not in drop]72 return " ".join(sorted(tokens))737475def size_key(size_label: str) -> str:76 """Format canonique « 450|g », « 2000|ml »… — '' si non extractible."""77 parsed = parse_size(size_label)78 if not parsed:79 return ""80 qty, base = parsed81 return f"{qty:g}|{base}"828384_NUM_RE = re.compile(r"\d+")858687def name_similarity(a: str, b: str) -> float:88 """Similarité de deux noms nettoyés (name_key). Garde-fou : les jetons89 numériques doivent être IDENTIQUES (« lait 2 » ≠ « lait 3 25 ») — un90 chiffre différent = produit différent (teneur en gras, compte, calibre)."""91 if not a or not b:92 return 0.093 if a == b:94 return 1.095 if set(_NUM_RE.findall(a)) != set(_NUM_RE.findall(b)):96 return 0.097 return SequenceMatcher(None, a, b).ratio()9899100# ---------------------------------------------------------------------------101# Reconstruction de la table product_links102# ---------------------------------------------------------------------------103104def rebuild(con: sqlite3.Connection) -> dict:105 """Reconstruit product_links à partir des produits actifs. Retourne des106 compteurs : groupes multi-bannières, produits reliés, bannières couvertes."""107 rows = con.execute(108 """SELECT uid, source, brand, name, size_label FROM products109 WHERE active=1 AND brand<>'' AND name<>''""").fetchall()110111 # blocs de candidats : même marque + même format (clé exacte)112 blocks: dict[tuple[str, str], list[tuple[str, str, str]]] = {}113 for r in rows:114 if r["source"] in EXCLUDED_SOURCES:115 continue116 bk = brand_key(r["brand"])117 if not bk:118 continue119 nk = name_key(r["name"], r["brand"])120 if not nk:121 continue122 blocks.setdefault((bk, size_key(r["size_label"])),123 []).append((r["uid"], r["source"], nk))124125 groups: list[list[str]] = []126 for (bk, sk), members in blocks.items():127 if len(members) < 2 or len(members) > MAX_BLOCK:128 continue129 threshold = SIM_WITH_SIZE if sk else SIM_NO_SIZE130 # union-find sur la similarité des noms nettoyés131 parent = list(range(len(members)))132133 def find(i: int) -> int:134 while parent[i] != i:135 parent[i] = parent[parent[i]]136 i = parent[i]137 return i138139 # tri par nom nettoyé : les paires proches sont voisines — on ne140 # compare chaque membre qu'à une fenêtre bornée (blocs petits en141 # pratique, la fenêtre évite le pire cas quadratique)142 order = sorted(range(len(members)), key=lambda i: members[i][2])143 for a in range(len(order)):144 for b in range(a + 1, min(a + 30, len(order))):145 i, j = order[a], order[b]146 if name_similarity(members[i][2], members[j][2]) >= threshold:147 parent[find(i)] = find(j)148 clusters: dict[int, list[int]] = {}149 for i in range(len(members)):150 clusters.setdefault(find(i), []).append(i)151 for idx in clusters.values():152 uids = [members[i][0] for i in idx]153 banners = {banner(members[i][1]) for i in idx}154 if len(banners) >= 2: # inter-bannières seulement155 groups.append(sorted(set(uids)))156157 con.execute("DELETE FROM product_links")158 linked = 0159 for uids in groups:160 gid = hashlib.md5("|".join(uids).encode("utf-8")).hexdigest()[:16]161 for uid in uids:162 # un produit ne peut appartenir qu'à un groupe (uid = clé primaire)163 con.execute(164 "INSERT OR IGNORE INTO product_links (group_id, uid) VALUES (?,?)",165 (gid, uid))166 linked += 1167 con.commit()168 real_groups = con.execute(169 "SELECT COUNT(DISTINCT group_id) c FROM product_links").fetchone()["c"]170 real_linked = con.execute(171 "SELECT COUNT(*) c FROM product_links").fetchone()["c"]172 return {"groups": real_groups, "linked_products": real_linked}173