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>
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 | |