SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%
7.4 KB · 173 lines python
Raw Blame History
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