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%

Vague 2 : circulaires grandes bannières + comparateur inter-bannières + nutrition OFF

1. Connecteurs Flipp des grandes bannières (API backflipp publique, 0 crédit
   Scrapfly) : iga_flyer (merchant 4592), metro_flyer (2269, filtre Quebec),
   superc_flyer (2585), maxi_flyer (2349), provigo_flyer (2338),
   walmart_flyer (234, filtre Circulaire), costco_flyer (2596, Grocery).
   Flyers provinciaux verifies sur H2X 1Y4 et G1R 4P5 (memes flyer_id).
   _flipp : tri des flyers par valid_from (la semaine courante gagne sur les
   slugs dupliques semaine courante/a venir).

2. foodka/matching.py : rapprochement inter-bannieres — table product_links
   (group_id, uid) reconstruite apres chaque cycle. Cle marque+nom nettoye+
   format, matching conservateur (marque obligatoire, similarite nom >= 0,86
   avec format identique / 0,95 sans format, jetons numeriques strictement
   egaux — lait 2 % jamais fusionne avec 3,25 %). Une source *_flyer = meme
   banniere que son catalogue. Endpoint GET /api/products/{uid}/compare +
   compteurs matching/nutrition dans /api/stats.

3. foodka/nutrition.py : enrichissement Open Food Facts (voila.ca PDP derriere
   Incapsula et Shopify sans code-barres public -> croisement OFF par
   marque+nom+format, conservateur). search.openfoodfacts.org (le vieux
   cgi/search.pl repond 503) + /api/v2/product/{code} pour les ingredients.
   Nutriscore/NOVA/ecoscore/allergenes/nutriments dans details.off, budget
   100 recherches/cycle, throttle 6 s, cache off_cache (jamais re-cherche).

db.py : tables product_links + off_cache. ingest.py : hooks post-cycle
matching + nutrition (jamais bloquants). sources.json : 54 sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent afdb2e6

14 changed files +667 −0

modified data/sources.json +77 −0
@@ -495,6 +495,83 @@
495 495 "status": "actif",
496 496 "region": "Tout le Québec (spécialiste du surgelé)",
497 497 "notes": "Merchant Flipp 2024 — circulaire 100 % alimentaire."
498 + },
499 + {
500 + "id": "iga_flyer",
501 + "name": "IGA — Circulaire",
502 + "url": "https://www.iga.net",
503 + "catalog_url": "https://www.iga.net/fr/circulaire",
504 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
505 + "connector": "iga_flyer",
506 + "status": "actif",
507 + "region": "Tout le Québec",
508 + "notes": "Merchant Flipp 4592, flyer « Québec – Circulaire Hebdomadaire » (même flyer_id vérifié H2X/G1R). Rabais hebdo de la bannière — complète la source « iga » (catalogue voila.ca)."
509 + },
510 + {
511 + "id": "metro_flyer",
512 + "name": "Metro — Circulaire",
513 + "url": "https://www.metro.ca",
514 + "catalog_url": "https://www.metro.ca/circulaire",
515 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
516 + "connector": "metro_flyer",
517 + "status": "actif",
518 + "region": "Tout le Québec",
519 + "notes": "Merchant Flipp 2269, flyer « Quebec » (filtre : écarte « Metrogo! »). Rabais hebdo sans crédit Scrapfly — complète la source « metro »."
520 + },
521 + {
522 + "id": "superc_flyer",
523 + "name": "Super C — Circulaire",
524 + "url": "https://www.superc.ca",
525 + "catalog_url": "https://www.superc.ca/circulaire",
526 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
527 + "connector": "superc_flyer",
528 + "status": "actif",
529 + "region": "Tout le Québec",
530 + "notes": "Merchant Flipp 2585, flyer « Circulaire ». Rabais hebdo sans crédit Scrapfly — complète la source « superc »."
531 + },
532 + {
533 + "id": "maxi_flyer",
534 + "name": "Maxi — Circulaire",
535 + "url": "https://www.maxi.ca",
536 + "catalog_url": "https://www.maxi.ca/fr/deals/flyer",
537 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
538 + "connector": "maxi_flyer",
539 + "status": "actif",
540 + "region": "Tout le Québec",
541 + "notes": "Merchant Flipp 2349, flyers « Weekly Flyer - Valid … » (semaine courante + à venir). Rabais hebdo sans crédit Scrapfly — complète la source « maxi »."
542 + },
543 + {
544 + "id": "provigo_flyer",
545 + "name": "Provigo — Circulaire",
546 + "url": "https://www.provigo.ca",
547 + "catalog_url": "https://www.provigo.ca/fr/deals/flyer",
548 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
549 + "connector": "provigo_flyer",
550 + "status": "actif",
551 + "region": "Tout le Québec",
552 + "notes": "Merchant Flipp 2338, flyers « Weekly Flyer - Valid … » (semaine courante + à venir). Rabais hebdo sans crédit Scrapfly — complète la source « provigo »."
553 + },
554 + {
555 + "id": "walmart_flyer",
556 + "name": "Walmart — Circulaire",
557 + "url": "https://www.walmart.ca",
558 + "catalog_url": "https://www.walmart.ca/fr/flyer",
559 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
560 + "connector": "walmart_flyer",
561 + "status": "actif",
562 + "region": "Tout le Québec",
563 + "notes": "Merchant Flipp 234, flyer « Circulaire » (filtre : écarte les livrets saisonniers). Rabais hebdo sans crédit Scrapfly — complète la source « walmart »."
564 + },
565 + {
566 + "id": "costco_flyer",
567 + "name": "Costco — Circulaire épicerie",
568 + "url": "https://www.costco.ca",
569 + "catalog_url": "https://www.costco.ca/coupons.html",
570 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
571 + "connector": "costco_flyer",
572 + "status": "actif",
573 + "region": "Tout le Québec",
574 + "notes": "Merchant Flipp 2596, flyer « Costco Grocery » (filtre : cahier épicerie seulement). Complète la source « costco »."
498 575 }
499 576 ]
500 577 }
modified foodka/connectors/_flipp.py +5 −0
@@ -116,6 +116,11 @@ class FlippConnector(BaseConnector):
116 116 needle = self.flyer_name_filter.lower()
117 117 flyers = [f for f in flyers
118 118 if needle in str(f.get("name") or "").lower()]
119 + # Grandes bannières : Flipp publie souvent la semaine COURANTE et la
120 + # semaine À VENIR. On trie par valid_from croissant : en cas de slug
121 + # dupliqué entre les deux semaines, le prix de la semaine courante
122 + # gagne (le pipeline garde la première occurrence d'un uid).
123 + flyers.sort(key=lambda f: str(f.get("valid_from") or ""))
119 124 return flyers
120 125
121 126 def fetch(self) -> list[Product]:
added foodka/connectors/costco_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Costco (épicerie)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire « Costco Grocery » sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « costco » (catalogue costco.ca) : ici, les RABAIS
6 +# épicerie de la semaine — merchant 2596, flyer « Costco Grocery »
7 +# (le filtre écarte les cahiers non alimentaires du même merchant).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class CostcoFlyerConnector(FlippConnector):
13 + source_id = "costco_flyer"
14 + merchant_id = 2596 # Costco
15 + flyer_name_filter = "grocery" # cahier épicerie seulement
16 + flyer_page = "https://www.costco.ca/coupons.html"
added foodka/connectors/iga_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire IGA (Sobeys Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire provinciale sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « iga » (catalogue voila.ca) : ici, les RABAIS papier de la
6 +# semaine — merchant 4592, flyer « Québec – Circulaire Hebdomadaire »
7 +# (même flyer_id vérifié sur H2X 1Y4 et G1R 4P5 : une circulaire provinciale).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class IgaFlyerConnector(FlippConnector):
13 + source_id = "iga_flyer"
14 + merchant_id = 4592 # IGA (Québec)
15 + flyer_name_filter = "circulaire" # « Québec – Circulaire Hebdomadaire »
16 + flyer_page = "https://www.iga.net/fr/circulaire"
added foodka/connectors/maxi_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Maxi (Loblaw)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire provinciale sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « maxi » (catalogue maxi.ca via Scrapfly) : ici, les
6 +# RABAIS de la semaine, sans crédit Scrapfly — merchant 2349, flyers
7 +# « Weekly Flyer - Valid … » (semaine courante + semaine à venir).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class MaxiFlyerConnector(FlippConnector):
13 + source_id = "maxi_flyer"
14 + merchant_id = 2349 # Maxi
15 + flyer_name_filter = "weekly"
16 + flyer_page = "https://www.maxi.ca/fr/deals/flyer"
added foodka/connectors/metro_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Metro (Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire provinciale sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « metro » (catalogue metro.ca via Scrapfly) : ici, les
6 +# RABAIS de la semaine, sans crédit Scrapfly — merchant 2269, flyer « Quebec »
7 +# (le filtre écarte le flyer « Metrogo! » du service de livraison).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class MetroFlyerConnector(FlippConnector):
13 + source_id = "metro_flyer"
14 + merchant_id = 2269 # Metro
15 + flyer_name_filter = "quebec" # écarte « Metrogo! »
16 + flyer_page = "https://www.metro.ca/circulaire"
added foodka/connectors/provigo_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Provigo (Loblaw)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire provinciale sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « provigo » (catalogue provigo.ca via Scrapfly) : ici, les
6 +# RABAIS de la semaine, sans crédit Scrapfly — merchant 2338, flyers
7 +# « Weekly Flyer - Valid … » (semaine courante + semaine à venir).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class ProvigoFlyerConnector(FlippConnector):
13 + source_id = "provigo_flyer"
14 + merchant_id = 2338 # Provigo
15 + flyer_name_filter = "weekly"
16 + flyer_page = "https://www.provigo.ca/fr/deals/flyer"
added foodka/connectors/superc_flyer.py +15 −0
@@ -0,0 +1,15 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Super C (Metro inc.)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire provinciale sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « superc » (catalogue superc.ca via Scrapfly) : ici, les
6 +# RABAIS de la semaine, sans crédit Scrapfly — merchant 2585, flyer « Circulaire ».
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class SuperCFlyerConnector(FlippConnector):
12 + source_id = "superc_flyer"
13 + merchant_id = 2585 # Super C
14 + flyer_name_filter = "circulaire"
15 + flyer_page = "https://www.superc.ca/circulaire"
added foodka/connectors/walmart_flyer.py +16 −0
@@ -0,0 +1,16 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur circulaire Walmart (Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Complète la source « walmart » (catalogue walmart.ca via Scrapfly) : ici, les
6 +# RABAIS de la semaine, sans crédit Scrapfly — merchant 234, flyer « Circulaire »
7 +# (le filtre écarte les livrets saisonniers « Livret … » et promos ciblées).
8 +# -----------------------------------------------------------------------------
9 +from ._flipp import FlippConnector
10 +
11 +
12 +class WalmartFlyerConnector(FlippConnector):
13 + source_id = "walmart_flyer"
14 + merchant_id = 234 # Walmart Canada
15 + flyer_name_filter = "circulaire" # écarte livrets et promos thématiques
16 + flyer_page = "https://www.walmart.ca/fr/flyer"
modified foodka/db.py +18 −0
@@ -89,6 +89,24 @@ CREATE TABLE IF NOT EXISTS price_log (
89 89 price REAL -- prix observé (NULL = retiré de l'affichage)
90 90 );
91 91 CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
92 +
93 +-- Rapprochement inter-bannières (foodka/matching.py) : groupes de produits
94 +-- identiques vendus par des bannières différentes. Reconstruite à chaque cycle.
95 +CREATE TABLE IF NOT EXISTS product_links (
96 + group_id TEXT NOT NULL,
97 + uid TEXT NOT NULL PRIMARY KEY -- un produit = au plus un groupe
98 +);
99 +CREATE INDEX IF NOT EXISTS idx_product_links_group ON product_links(group_id);
100 +
101 +-- Cache Open Food Facts (foodka/nutrition.py) : une clé marque+nom+format
102 +-- n'est cherchée qu'une fois (found=0 = aucune correspondance sûre).
103 +CREATE TABLE IF NOT EXISTS off_cache (
104 + match_key TEXT PRIMARY KEY,
105 + code TEXT, -- code-barres OFF du produit apparié
106 + found INTEGER, -- 1 = correspondance conservatrice trouvée
107 + payload TEXT, -- JSON (nutriscore, ingrédients, nutriments…)
108 + fetched_at REAL
109 +);
92 110 """
93 111
94 112
modified foodka/ingest.py +14 −0
@@ -46,6 +46,20 @@ def run(sources: list[str] | None = None) -> list[dict]:
46 46 db.log_failure(con, sid, f"{exc}")
47 47 traceback.print_exc()
48 48 results.append({"source": sid, "error": str(exc)})
49 +
50 + # post-cycle 1 : rapprochement inter-bannières (table product_links)
51 + try:
52 + from . import matching
53 + print(f"[food-ka] matching : {matching.rebuild(con)}")
54 + except Exception: # l'enrichissement n'est jamais bloquant
55 + traceback.print_exc()
56 + # post-cycle 2 : nutrition Open Food Facts (details.off, budget 100/cycle)
57 + try:
58 + from . import nutrition
59 + print(f"[food-ka] nutrition : {nutrition.enrich(con, budget=100)}")
60 + except Exception:
61 + traceback.print_exc()
62 +
49 63 con.close()
50 64 return results
51 65
added foodka/matching.py +164 −0
@@ -0,0 +1,164 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# matching.py : rapprochement inter-bannières — « le même produit ailleurs »
5 +# Après chaque cycle d'ingestion, reconstruit la table product_links
6 +# (group_id, uid) : groupes de produits ACTIFS identiques vendus par des
7 +# bannières DIFFÉRENTES, rapprochés par clé normalisée
8 +# 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 format
14 +# n'est connu de part et d'autre.
15 +# Une source « *_flyer » est la même bannière que sa source catalogue
16 +# (metro / metro_flyer) : un groupe doit couvrir >= 2 bannières distinctes.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import hashlib
21 +import re
22 +import sqlite3
23 +from difflib import SequenceMatcher
24 +
25 +from .normalize import parse_size, strip_accents
26 +
27 +# seuils de similarité (SequenceMatcher sur jetons triés)
28 +SIM_WITH_SIZE = 0.86 # marque + format identiques -> le nom confirme
29 +SIM_NO_SIZE = 0.95 # aucun format connu -> quasi-identité exigée
30 +MAX_BLOCK = 400 # garde-fou : bloc (marque, format) anormalement gros
31 +
32 +_ALNUM_RE = re.compile(r"[^a-z0-9]+")
33 +# jetons de format dans le nom (« 2 x 450 g », « 1,5 l »…) — retirés du nom
34 +_SIZE_TOKEN_RE = re.compile(
35 + r"\b\d+(?:[.,]\d+)?\s*(?:x|×)?\s*\d*(?:[.,]\d+)?\s*"
36 + r"(?:kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk)\b")
37 +_STOPWORDS = {
38 + "de", "du", "des", "la", "le", "les", "l", "d", "et", "a", "au", "aux",
39 + "en", "pour", "the", "of", "and", "with", "un", "une",
40 +}
41 +
42 +
43 +def banner(source: str) -> str:
44 + """Bannière d'une source — « metro_flyer » et « metro » = même bannière."""
45 + return source[:-6] if source.endswith("_flyer") else source
46 +
47 +
48 +def brand_key(brand: str) -> str:
49 + """Marque normalisée (accents/casse/ponctuation) — '' si inutilisable."""
50 + key = _ALNUM_RE.sub("", strip_accents(brand or "").lower())
51 + return key if len(key) >= 2 else ""
52 +
53 +
54 +def name_key(name: str, brand: str = "") -> str:
55 + """Nom nettoyé : accents/casse, marque, format et mots vides retirés,
56 + jetons triés (robuste à l'ordre FR/EN des mots). Les jetons NUMÉRIQUES
57 + restants sont conservés : ils sont discriminants (« lait 2 % » vs
58 + « lait 3,25 % ») — name_similarity exige leur égalité stricte."""
59 + s = strip_accents(name or "").lower()
60 + s = _SIZE_TOKEN_RE.sub(" ", s)
61 + s = _ALNUM_RE.sub(" ", s)
62 + drop = set(_STOPWORDS)
63 + if brand:
64 + drop |= set(_ALNUM_RE.sub(" ", strip_accents(brand).lower()).split())
65 + tokens = [t for t in s.split() if t and t not in drop]
66 + return " ".join(sorted(tokens))
67 +
68 +
69 +def size_key(size_label: str) -> str:
70 + """Format canonique « 450|g », « 2000|ml »… — '' si non extractible."""
71 + parsed = parse_size(size_label)
72 + if not parsed:
73 + return ""
74 + qty, base = parsed
75 + return f"{qty:g}|{base}"
76 +
77 +
78 +_NUM_RE = re.compile(r"\d+")
79 +
80 +
81 +def name_similarity(a: str, b: str) -> float:
82 + """Similarité de deux noms nettoyés (name_key). Garde-fou : les jetons
83 + numériques doivent être IDENTIQUES (« lait 2 » ≠ « lait 3 25 ») — un
84 + chiffre différent = produit différent (teneur en gras, compte, calibre)."""
85 + if not a or not b:
86 + return 0.0
87 + if a == b:
88 + return 1.0
89 + if set(_NUM_RE.findall(a)) != set(_NUM_RE.findall(b)):
90 + return 0.0
91 + return SequenceMatcher(None, a, b).ratio()
92 +
93 +
94 +# ---------------------------------------------------------------------------
95 +# Reconstruction de la table product_links
96 +# ---------------------------------------------------------------------------
97 +
98 +def rebuild(con: sqlite3.Connection) -> dict:
99 + """Reconstruit product_links à partir des produits actifs. Retourne des
100 + compteurs : groupes multi-bannières, produits reliés, bannières couvertes."""
101 + rows = con.execute(
102 + """SELECT uid, source, brand, name, size_label FROM products
103 + WHERE active=1 AND brand<>'' AND name<>''""").fetchall()
104 +
105 + # blocs de candidats : même marque + même format (clé exacte)
106 + blocks: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
107 + for r in rows:
108 + bk = brand_key(r["brand"])
109 + if not bk:
110 + continue
111 + nk = name_key(r["name"], r["brand"])
112 + if not nk:
113 + continue
114 + blocks.setdefault((bk, size_key(r["size_label"])),
115 + []).append((r["uid"], r["source"], nk))
116 +
117 + groups: list[list[str]] = []
118 + for (bk, sk), members in blocks.items():
119 + if len(members) < 2 or len(members) > MAX_BLOCK:
120 + continue
121 + threshold = SIM_WITH_SIZE if sk else SIM_NO_SIZE
122 + # union-find sur la similarité des noms nettoyés
123 + parent = list(range(len(members)))
124 +
125 + def find(i: int) -> int:
126 + while parent[i] != i:
127 + parent[i] = parent[parent[i]]
128 + i = parent[i]
129 + return i
130 +
131 + # tri par nom nettoyé : les paires proches sont voisines — on ne
132 + # compare chaque membre qu'à une fenêtre bornée (blocs petits en
133 + # pratique, la fenêtre évite le pire cas quadratique)
134 + order = sorted(range(len(members)), key=lambda i: members[i][2])
135 + for a in range(len(order)):
136 + for b in range(a + 1, min(a + 30, len(order))):
137 + i, j = order[a], order[b]
138 + if name_similarity(members[i][2], members[j][2]) >= threshold:
139 + parent[find(i)] = find(j)
140 + clusters: dict[int, list[int]] = {}
141 + for i in range(len(members)):
142 + clusters.setdefault(find(i), []).append(i)
143 + for idx in clusters.values():
144 + uids = [members[i][0] for i in idx]
145 + banners = {banner(members[i][1]) for i in idx}
146 + if len(banners) >= 2: # inter-bannières seulement
147 + groups.append(sorted(set(uids)))
148 +
149 + con.execute("DELETE FROM product_links")
150 + linked = 0
151 + for uids in groups:
152 + gid = hashlib.md5("|".join(uids).encode("utf-8")).hexdigest()[:16]
153 + for uid in uids:
154 + # un produit ne peut appartenir qu'à un groupe (uid = clé primaire)
155 + con.execute(
156 + "INSERT OR IGNORE INTO product_links (group_id, uid) VALUES (?,?)",
157 + (gid, uid))
158 + linked += 1
159 + con.commit()
160 + real_groups = con.execute(
161 + "SELECT COUNT(DISTINCT group_id) c FROM product_links").fetchone()["c"]
162 + real_linked = con.execute(
163 + "SELECT COUNT(*) c FROM product_links").fetchone()["c"]
164 + return {"groups": real_groups, "linked_products": real_linked}
added foodka/nutrition.py +229 −0
@@ -0,0 +1,229 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# nutrition.py : enrichissement nutritionnel via Open Food Facts (OFF)
5 +# Les bannières n'exposent pas leurs fiches nutritionnelles sans anti-bot
6 +# (voila.ca : pages PDP derrière Incapsula ; Shopify : pas de code-barres
7 +# dans /products.json public). On croise donc avec la base ouverte OFF :
8 +# https://search.openfoodfacts.org/search (« search-a-licious » : plein
9 +# texte + filtre brands_tags, JSON) — nutriscore, groupe NOVA, nutriments
10 +# /100 g ; les INGRÉDIENTS viennent d'un second appel /api/v2/product/
11 +# {code} (l'index de recherche ne les porte pas). NB : l'ancien
12 +# cgi/search.pl répond 503 (déprécié) — ne pas y revenir.
13 +# Croisement CONSERVATEUR (mieux vaut rater que fusionner faux) :
14 +# - marque non vide obligatoire, et retrouvée dans le champ brands d'OFF ;
15 +# - similarité élevée des noms nettoyés (0,80 si le format concorde
16 +# aussi, 0,90 sinon) ;
17 +# - si les deux formats sont connus, ils doivent concorder (±2 %).
18 +# Budget par cycle (défaut 100 requêtes) + throttle 6 s (limite OFF :
19 +# 10 recherches/min) + cache BD off_cache (une marque+nom+format n'est
20 +# cherchée qu'une seule fois, trouvée ou non — vider off_cache pour
21 +# re-tenter). Résultat stocké dans details.off, ré-appliqué à chaque cycle
22 +# (les mises à jour de produit écrasent details ; le cache est la vérité).
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import hashlib
27 +import json
28 +import re
29 +import sqlite3
30 +import time
31 +
32 +import requests
33 +
34 +from .matching import brand_key, name_key, name_similarity, size_key
35 +from .normalize import parse_size, strip_accents
36 +
37 +SEARCH_URL = "https://search.openfoodfacts.org/search"
38 +PRODUCT_URL = ("https://world.openfoodfacts.org/api/v2/product/{code}"
39 + "?fields=ingredients_text_fr,ingredients_text")
40 +USER_AGENT = "FoodKaBot/1.0 (+https://www.food-ka.com/bot; contact@spboucher.ai)"
41 +
42 +_SLUG_RE = re.compile(r"[^a-z0-9]+")
43 +
44 +REQUEST_DELAY = 6.0 # limite OFF : 10 recherches/minute
45 +SIM_WITH_SIZE = 0.80 # marque + format concordants -> le nom confirme
46 +SIM_NO_SIZE = 0.90 # format inconnu -> quasi-identité exigée
47 +
48 +# nutriments retenus (valeurs /100 g ou /100 ml, + énergie)
49 +_NUTRIMENT_KEYS = (
50 + "energy-kcal_100g", "fat_100g", "saturated-fat_100g", "trans-fat_100g",
51 + "carbohydrates_100g", "sugars_100g", "fiber_100g", "proteins_100g",
52 + "salt_100g", "sodium_100g", "calcium_100g", "iron_100g",
53 +)
54 +
55 +
56 +def match_key(brand: str, name: str, size_label: str) -> str:
57 + raw = f"{brand_key(brand)}|{name_key(name, brand)}|{size_key(size_label)}"
58 + return hashlib.md5(raw.encode("utf-8")).hexdigest()
59 +
60 +
61 +def _sizes_agree(ours: str, theirs: str | None) -> bool | None:
62 + """True/False si les deux formats sont connus, None si l'un manque."""
63 + a, b = parse_size(ours), parse_size(theirs)
64 + if not a or not b:
65 + return None
66 + (qa, ua), (qb, ub) = a, b
67 + return ua == ub and abs(qa - qb) <= 0.02 * max(qa, qb)
68 +
69 +
70 +def _pick(hits: list[dict], brand: str, name: str, size_label: str) -> tuple[dict | None, float]:
71 + """Meilleur candidat OFF respectant marque + nom + format — ou None."""
72 + ours_nk = name_key(name, brand)
73 + ours_bk = brand_key(brand)
74 + best, best_sim = None, 0.0
75 + for hit in hits:
76 + # marque : notre marque doit se retrouver dans le champ brands d'OFF
77 + # (liste chez search-a-licious, chaîne « A, B » sur l'API produit)
78 + raw = hit.get("brands")
79 + brand_list = raw if isinstance(raw, list) else str(raw or "").split(",")
80 + their_brands = [brand_key(str(b)) for b in brand_list]
81 + if ours_bk not in [b for b in their_brands if b]:
82 + continue
83 + hit_name = hit.get("product_name_fr") or hit.get("product_name") or ""
84 + sim = name_similarity(ours_nk, name_key(hit_name, brand))
85 + agree = _sizes_agree(size_label, hit.get("quantity"))
86 + if agree is False:
87 + continue # formats connus et différents -> rejet
88 + threshold = SIM_WITH_SIZE if agree else SIM_NO_SIZE
89 + if sim >= threshold and sim > best_sim:
90 + best, best_sim = hit, sim
91 + return best, best_sim
92 +
93 +
94 +def _grade(value) -> str | None:
95 + """Grade OFF nettoyé — « unknown »/« not-applicable » -> None."""
96 + v = str(value or "").strip().lower()
97 + return v if v and v not in ("unknown", "not-applicable") else None
98 +
99 +
100 +def _payload(hit: dict, sim: float, ingredients: str = "") -> dict:
101 + nutr = hit.get("nutriments") or {}
102 + brands = hit.get("brands")
103 + if isinstance(brands, list):
104 + brands = ", ".join(str(b) for b in brands)
105 + out = {k: v for k, v in {
106 + "code": hit.get("code"),
107 + "product_name": hit.get("product_name_fr") or hit.get("product_name"),
108 + "brands": brands,
109 + "quantity": hit.get("quantity"),
110 + "nutriscore": _grade(hit.get("nutriscore_grade")),
111 + "nova": hit.get("nova_group"),
112 + "ecoscore": _grade(hit.get("ecoscore_grade")),
113 + "ingredients": ingredients[:800] or None,
114 + "allergens": [str(a).split(":")[-1] for a in hit.get("allergens_tags") or []] or None,
115 + "nutriments": {k: nutr[k] for k in _NUTRIMENT_KEYS if nutr.get(k) is not None} or None,
116 + "url": (f"https://world.openfoodfacts.org/product/{hit.get('code')}"
117 + if hit.get("code") else None),
118 + "match_similarity": round(sim, 3),
119 + }.items() if v not in (None, "", {}, [])}
120 + return out
121 +
122 +
123 +def _search(session: requests.Session, brand: str, name: str) -> list[dict]:
124 + slug = _SLUG_RE.sub("-", strip_accents(brand).lower()).strip("-")
125 + params = {
126 + "q": f'{name[:120]} brands_tags:"{slug}"',
127 + "page_size": "10", "langs": "fr",
128 + }
129 + resp = session.get(SEARCH_URL, params=params, timeout=25)
130 + resp.raise_for_status()
131 + return (resp.json().get("hits") or [])
132 +
133 +
134 +def _ingredients(session: requests.Session, code) -> str:
135 + """Texte des ingrédients (FR de préférence) — jamais bloquant."""
136 + if not code:
137 + return ""
138 + try:
139 + resp = session.get(PRODUCT_URL.format(code=code), timeout=25)
140 + resp.raise_for_status()
141 + prod = resp.json().get("product") or {}
142 + return str(prod.get("ingredients_text_fr")
143 + or prod.get("ingredients_text") or "")
144 + except Exception:
145 + return ""
146 +
147 +
148 +# ---------------------------------------------------------------------------
149 +# Cycle d'enrichissement (appelé après chaque ingestion)
150 +# ---------------------------------------------------------------------------
151 +
152 +def enrich(con: sqlite3.Connection, budget: int = 100) -> dict:
153 + """Recherche OFF pour au plus `budget` nouveaux produits (jamais cherchés),
154 + puis (ré)applique details.off à tous les produits actifs appariés."""
155 + rows = con.execute(
156 + """SELECT p.uid, p.brand, p.name, p.size_label,
157 + (l.uid IS NOT NULL) AS linked
158 + FROM products p LEFT JOIN product_links l ON l.uid = p.uid
159 + WHERE p.active=1 AND p.brand<>'' AND p.name<>''
160 + ORDER BY linked DESC, p.last_seen DESC""").fetchall()
161 +
162 + cached: dict[str, sqlite3.Row] = {
163 + r["match_key"]: r for r in
164 + con.execute("SELECT match_key, found, payload FROM off_cache")}
165 +
166 + session = requests.Session()
167 + session.headers["User-Agent"] = USER_AGENT
168 + searched = matched = errors = 0
169 + now = time.time()
170 + for r in rows:
171 + if searched >= budget or errors >= 3: # OFF injoignable -> on n'insiste pas
172 + break
173 + key = match_key(r["brand"], r["name"], r["size_label"])
174 + if key in cached:
175 + continue
176 + searched += 1
177 + try:
178 + hits = _search(session, r["brand"], r["name"])
179 + best, sim = _pick(hits, r["brand"], r["name"], r["size_label"])
180 + errors = 0
181 + except Exception:
182 + errors += 1
183 + searched -= 1
184 + time.sleep(REQUEST_DELAY)
185 + continue
186 + if best:
187 + time.sleep(1.0) # politesse entre recherche et fiche produit
188 + payload = _payload(best, sim, _ingredients(session, best.get("code")))
189 + else:
190 + payload = {}
191 + found = 1 if best else 0
192 + matched += found
193 + con.execute(
194 + "INSERT INTO off_cache (match_key, code, found, payload, fetched_at)"
195 + " VALUES (?,?,?,?,?) ON CONFLICT(match_key) DO UPDATE SET"
196 + " code=excluded.code, found=excluded.found,"
197 + " payload=excluded.payload, fetched_at=excluded.fetched_at",
198 + (key, payload.get("code"), found,
199 + json.dumps(payload, ensure_ascii=False), now))
200 + con.commit()
201 + cached[key] = {"match_key": key, "found": found, # type: ignore[assignment]
202 + "payload": json.dumps(payload, ensure_ascii=False)}
203 + time.sleep(REQUEST_DELAY)
204 +
205 + # (ré)application : le cache est la source de vérité de details.off
206 + applied = 0
207 + for r in con.execute(
208 + """SELECT uid, brand, name, size_label, details FROM products
209 + WHERE active=1 AND brand<>'' AND name<>''""").fetchall():
210 + hit = cached.get(match_key(r["brand"], r["name"], r["size_label"]))
211 + if hit is None or not hit["found"]:
212 + continue
213 + try:
214 + payload = json.loads(hit["payload"] or "{}")
215 + details = json.loads(r["details"] or "{}")
216 + except ValueError:
217 + continue
218 + if not payload or details.get("off") == payload:
219 + continue
220 + details["off"] = payload
221 + con.execute("UPDATE products SET details=? WHERE uid=?",
222 + (json.dumps(details, ensure_ascii=False), r["uid"]))
223 + applied += 1
224 + con.commit()
225 + with_off = con.execute(
226 + "SELECT COUNT(*) c FROM products WHERE active=1"
227 + " AND details LIKE '%\"off\"%'").fetchone()["c"]
228 + return {"searched": searched, "matched": matched, "applied": applied,
229 + "products_with_off": with_off}
modified foodka/web.py +49 −0
@@ -120,6 +120,38 @@ def get_product(uid: str):
120 120 return d
121 121
122 122
123 +@app.get("/api/products/{uid}/compare")
124 +def compare_product(uid: str):
125 + """Le même produit dans les autres bannières (rapprochement exact
126 + inter-bannières — table product_links, voir foodka/matching.py)."""
127 + con = db.connect()
128 + me = con.execute("SELECT * FROM products WHERE uid=?", (uid,)).fetchone()
129 + if me is None:
130 + con.close()
131 + raise HTTPException(404, "Produit introuvable")
132 + link = con.execute(
133 + "SELECT group_id FROM product_links WHERE uid=?", (uid,)).fetchone()
134 + matches = []
135 + if link is not None:
136 + matches = [_row_to_dict(r) for r in con.execute(
137 + """SELECT p.* FROM product_links l JOIN products p ON p.uid=l.uid
138 + WHERE l.group_id=? AND l.uid<>? AND p.active=1
139 + ORDER BY p.price IS NULL, p.price ASC""",
140 + (link["group_id"], uid)).fetchall()]
141 + con.close()
142 + prices = [m["price"] for m in matches if m.get("price") is not None]
143 + me_d = _row_to_dict(me)
144 + return {
145 + "uid": uid,
146 + "group_id": link["group_id"] if link else None,
147 + "product": me_d,
148 + "matches": matches,
149 + "best_price": min(prices) if prices else None,
150 + "is_best_price": (me_d.get("price") is not None
151 + and (not prices or me_d["price"] <= min(prices))),
152 + }
153 +
154 +
123 155 @app.get("/api/facets")
124 156 def facets(category: str | None = None):
125 157 """Valeurs distinctes pour construire les filtres du frontend."""
@@ -186,8 +218,25 @@ def stats():
186 218 ORDER BY (regular_price - price) / regular_price DESC LIMIT 24""")]
187 219 log = [dict(r) for r in con.execute(
188 220 "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
221 + # rapprochement inter-bannières + enrichissement nutritionnel (vague 2)
222 + matching_stats = {
223 + "groups": con.execute(
224 + "SELECT COUNT(DISTINCT group_id) c FROM product_links").fetchone()["c"],
225 + "linked_products": con.execute(
226 + "SELECT COUNT(*) c FROM product_links").fetchone()["c"],
227 + }
228 + nutrition_stats = {
229 + "off_matched": con.execute(
230 + "SELECT COUNT(*) c FROM off_cache WHERE found=1").fetchone()["c"],
231 + "off_searched": con.execute(
232 + "SELECT COUNT(*) c FROM off_cache").fetchone()["c"],
233 + "products_with_off": con.execute(
234 + "SELECT COUNT(*) c FROM products WHERE active=1"
235 + " AND details LIKE '%\"off\"%'").fetchone()["c"],
236 + }
189 237 con.close()
190 238 return {**dict(row), "by_source": by_source, "by_category": by_category,
239 + "matching": matching_stats, "nutrition": nutrition_stats,
191 240 "deals": deals, "recent_syncs": log}
192 241
193 242
194 243