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%

Enrichissement connecteurs + robustesse DB (audit 2026-08-18)

- pa_nature : merchant Flipp 3343 disparu -> merchant 3287 (Supermarche PA)
  + filtre flyer_name_filter sur le « Nature Flyer » (60 produits retrouvés)
- Flipp : fiche /flipp/items/{id} via le cache détail (1 appel/item/semaine) ->
  details.fine_print (description, +tx, disclaimer), discount_pct généralisé
  (percent_off), dollars_off, regular_price depuis original_price
- Shopify : details.variants (<=15, multi-formats), created_at/updated_at,
  body_html tronqué à 1500 car., unit_price via grams de variante,
  max_pages 8 -> 24 (pa/nuvo/epipresto/boite_a_grains étaient tronqués à 2000)
- WooCommerce Store API : brand depuis la taxonomie brands, details
  (average_rating, review_count, weight, dimensions, attributes,
  low_stock_remaining, variation_count), unit_price via formatted_weight,
  max_pages 8 -> 32 (aliments_merci tronqué à 800 ; catalogue réel 2765) ;
  akhavan : mêmes details + plafond 4 -> 12 (688 produits)
- db.connect() : WAL + busy_timeout 15 s ; base.get() : retry x3 sur
  ConnectionError/ChunkedEncodingError (parité auto-ka)

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

9 changed files +235 −17

modified .gitignore +2 −0
@@ -9,3 +9,5 @@ frontend/dist/
9 9 frontend/tsconfig.tsbuildinfo
10 10 .DS_Store
11 11 .pytest_cache/
12 +data/*.db-wal
13 +data/*.db-shm
modified data/sources.json +1 −1
@@ -483,7 +483,7 @@
483 483 "connector": "pa_nature",
484 484 "status": "actif",
485 485 "region": "Montréal (volet bio/santé du Supermarché PA)",
486 − "notes": "Merchant Flipp 3343 — bannière sœur de la source « pa »."
486 + "notes": "Merchant Flipp 3287 (« Supermarche PA »), flyer « Nature Flyer » — l'ancien merchant dédié 3343 a disparu de Flipp le 2026-08-16 ; bannière sœur de la source « pa »."
487 487 },
488 488 {
489 489 "id": "aliments_mm",
modified foodka/connectors/_flipp.py +71 −4
@@ -26,6 +26,7 @@ from .base import BaseConnector
26 26 LIST_URL = ("https://backflipp.wishabi.com/flipp/flyers"
27 27 "?locale=fr-ca&postal_code={postal}")
28 28 FLYER_URL = "https://backflipp.wishabi.com/flipp/flyers/{flyer_id}?locale=fr-ca"
29 +ITEM_URL = "https://backflipp.wishabi.com/flipp/items/{item_id}?locale=fr-ca"
29 30
30 31 # format dans le nom : « (550 à 675 g) », « 2 L », « 9,1 ou 12,7 kg »…
31 32 _SIZE_IN_NAME = re.compile(
@@ -98,14 +99,24 @@ class FlippConnector(BaseConnector):
98 99 postal_code: str = "H2X1Y4" # Montréal — capte les flyers provinciaux
99 100 flyer_page: str = ""
100 101 default_categories: dict[str, str] = {}
102 + # Certaines bannières publient PLUSIEURS circulaires sous le même
103 + # merchant_id (ex. Supermarché PA : « Weekly Flyer » + « Nature Flyer »).
104 + # Si non vide, seuls les flyers dont le nom contient ce texte (insensible
105 + # à la casse) sont retenus.
106 + flyer_name_filter: str = ""
101 107
102 108 # -- récupération -----------------------------------------------------------
103 109 def _flyers(self) -> list[dict]:
104 110 """Circulaires courantes de la bannière (résolution hebdo du flyer_id)."""
105 111 data = self.get(LIST_URL.format(postal=self.postal_code),
106 112 headers={"Accept": "application/json"}).json()
107 − return [f for f in (data.get("flyers") or [])
108 − if f.get("merchant_id") == self.merchant_id]
113 + flyers = [f for f in (data.get("flyers") or [])
114 + if f.get("merchant_id") == self.merchant_id]
115 + if self.flyer_name_filter:
116 + needle = self.flyer_name_filter.lower()
117 + flyers = [f for f in flyers
118 + if needle in str(f.get("name") or "").lower()]
119 + return flyers
109 120
110 121 def fetch(self) -> list[Product]:
111 122 products: list[Product] = []
@@ -138,10 +149,25 @@ class FlippConnector(BaseConnector):
138 149 size_m = _SIZE_IN_NAME.search(name_fr)
139 150 flyer_name = clean_text(flyer.get("name") or "")
140 151 default_cat = self.default_categories.get(flyer_name, "")
152 + external_id = self._slug(brand, name_fr, size_m.group(1) if size_m else "")
153 +
154 + # fiche item Flipp (petit texte de la vignette, prix régulier, % de
155 + # rabais) — mise en cache par id d'item Flipp : l'id change quand la
156 + # circulaire change, donc un seul appel par item et par semaine.
157 + detail: dict = {}
158 + try:
159 + detail = self.detail(external_id, str(item.get("id") or ""),
160 + lambda: self._item_detail(item.get("id")))
161 + except Exception: # la fiche est un bonus, jamais bloquante
162 + detail = {}
163 +
164 + regular = self._price(detail.get("original_price"))
165 + if regular is not None and regular <= price:
166 + regular = None
141 167
142 168 return Product(
143 169 source=self.source_id,
144 − external_id=self._slug(brand, name_fr, size_m.group(1) if size_m else ""),
170 + external_id=external_id,
145 171 url=self.flyer_page,
146 172 name=name_fr,
147 173 brand=brand,
@@ -149,6 +175,7 @@ class FlippConnector(BaseConnector):
149 175 category_raw=flyer_name,
150 176 size_label=size_m.group(1) if size_m else "",
151 177 price=price,
178 + regular_price=regular,
152 179 price_label=f"{item.get('price')} $",
153 180 on_sale=True, # une circulaire = les spéciaux de la semaine
154 181 keywords=keywords,
@@ -157,12 +184,52 @@ class FlippConnector(BaseConnector):
157 184 "flyer_name": flyer_name,
158 185 "valid_from": item.get("valid_from") or flyer.get("valid_from"),
159 186 "valid_to": item.get("valid_to") or flyer.get("valid_to"),
160 − "discount_pct": item.get("discount"),
187 + "discount_pct": item.get("discount") or detail.get("percent_off"),
188 + "dollars_off": detail.get("dollars_off"),
189 + "fine_print": self._fine_print(item, detail) or None,
161 190 }.items() if v is not None},
162 191 images=[u.replace("http://", "https://")
163 192 for u in [item.get("cutout_image_url")] if u],
164 193 )
165 194
195 + def _item_detail(self, item_id) -> dict:
196 + """Fiche item Flipp (/flipp/items/{id}) réduite aux champs utiles.
197 +
198 + C'est là que vivent le petit texte de la vignette (description,
199 + « +tx », limites/disclaimer) et le prix régulier (original_price).
200 + """
201 + if not item_id:
202 + return {}
203 + data = self.get(ITEM_URL.format(item_id=item_id),
204 + headers={"Accept": "application/json"}).json()
205 + it = data.get("item") or data or {}
206 + keep = ("description", "price_text", "disclaimer_text", "sale_story",
207 + "original_price", "percent_off", "dollars_off")
208 + return {k: it.get(k) for k in keep if it.get(k) not in (None, "", [])}
209 +
210 + @staticmethod
211 + def _fine_print(item: dict, detail: dict | None = None) -> str:
212 + """Petit texte de la vignette (limites, « +tx », « Sans carte… ») :
213 + text_areas de l'item + champs texte de la fiche /flipp/items/{id},
214 + dédupliqués, en une seule chaîne lisible."""
215 + bits: list[str] = []
216 + for ta in item.get("text_areas") or []:
217 + if isinstance(ta, str):
218 + bits.append(ta)
219 + elif isinstance(ta, dict):
220 + txt = ta.get("text") or ta.get("value") or ""
221 + if txt:
222 + bits.append(str(txt))
223 + for src in (item, detail or {}):
224 + for key in ("description", "price_text", "disclaimer_text",
225 + "sale_story", "pre_price_text", "post_price_text"):
226 + val = src.get(key)
227 + if val:
228 + bits.append(str(val))
229 + cleaned = [clean_text(b) for b in bits]
230 + uniq = list(dict.fromkeys(b for b in cleaned if b))
231 + return " · ".join(uniq)[:300]
232 +
166 233 @staticmethod
167 234 def _price(raw) -> float | None:
168 235 """« 5.99 », « 0.3 », 5.99 -> float ; vide/0 -> None (jamais inventé)."""
modified foodka/connectors/_shopify.py +36 −3
@@ -11,14 +11,15 @@ from __future__ import annotations
11 11 import html
12 12 import re
13 13
14 +from ..normalize import format_unit_price, unit_price
14 15 from ..schema import Product, normalize_category
15 16 from .base import BaseConnector
16 17
17 18 _TAG_RE = re.compile(r"<[^>]+>")
18 19
19 20
20 −def _strip_html(text: str | None, max_len: int = 500) -> str:
21 − """Retire les balises HTML d'un body_html Shopify et tronque (~500 car.)."""
21 +def _strip_html(text: str | None, max_len: int = 1500) -> str:
22 + """Retire les balises HTML d'un body_html Shopify et tronque (~1500 car.)."""
22 23 if not text:
23 24 return ""
24 25 clean = html.unescape(_TAG_RE.sub(" ", text))
@@ -39,7 +40,9 @@ class ShopifyConnector(BaseConnector):
39 40 """
40 41
41 42 domain: str = "" # ex. "www.supermarchepa.com"
42 − max_pages: int = 8 # 250 produits/page
43 + # /products.json est gratuit et direct (aucun crédit Scrapfly) : plafond
44 + # large — 24 × 250 = 6 000 produits par catalogue/collection.
45 + max_pages: int = 24 # 250 produits/page
43 46 collections: list[tuple[str, str]] = [] # [(handle, catégorie canonique)]
44 47
45 48 # -- récupération -----------------------------------------------------------
@@ -90,6 +93,33 @@ class ShopifyConnector(BaseConnector):
90 93 tags = [str(t) for t in product.get("tags") or [] if t]
91 94 category_raw = product.get("product_type") or " ".join(tags)
92 95
96 + # prix unitaire : size_label d'abord ; sinon le poids en grammes de la
97 + # variante (fiable même quand le titre de variante n'est pas un format)
98 + unit_p, unit_label = None, ""
99 + if price is not None:
100 + up = unit_price(price, size_label)
101 + if not up and variant.get("grams"):
102 + up = unit_price(price, f"{variant['grams']} g")
103 + if up:
104 + unit_p, unit_label = up[0], format_unit_price(up)
105 +
106 + # champs structurés : variantes multiples (multi-formats à prix
107 + # distincts), dates produit Shopify
108 + details: dict = {}
109 + variants = product.get("variants") or []
110 + if len(variants) > 1:
111 + details["variant_count"] = len(variants)
112 + details["variants"] = [{k: v for k, v in {
113 + "title": var.get("title"),
114 + "price": var.get("price"),
115 + "grams": var.get("grams"),
116 + "sku": var.get("sku"),
117 + "available": var.get("available"),
118 + }.items() if v not in (None, "")} for var in variants[:15]]
119 + for key in ("created_at", "updated_at"):
120 + if product.get(key):
121 + details[key] = product[key]
122 +
93 123 return Product(
94 124 source=self.source_id,
95 125 external_id=str(pid),
@@ -102,10 +132,13 @@ class ShopifyConnector(BaseConnector):
102 132 price=price,
103 133 regular_price=regular,
104 134 on_sale=bool(regular is not None),
135 + unit_price=unit_p,
136 + unit_price_label=unit_label,
105 137 in_stock=(bool(variant["available"])
106 138 if variant.get("available") is not None else None),
107 139 description=_strip_html(product.get("body_html")),
108 140 keywords=tags,
141 + details=details,
109 142 images=[img["src"] for img in product.get("images") or []
110 143 if isinstance(img, dict) and img.get("src")],
111 144 )
modified foodka/connectors/_woocommerce.py +78 −2
@@ -11,6 +11,7 @@ from __future__ import annotations
11 11 import html
12 12 import re
13 13
14 +from ..normalize import format_unit_price, unit_price
14 15 from ..schema import Product, normalize_category
15 16 from .base import BaseConnector
16 17
@@ -33,6 +34,59 @@ def _strip_html(text: str | None, max_len: int = 500) -> str:
33 34 return clean
34 35
35 36
37 +def store_api_details(raw: dict) -> dict:
38 + """Champs structurés riches de la Store API -> details (JSON).
39 +
40 + Note (avis clients, poids/dimensions catalogue, attributs bio/origine,
41 + stock restant, nombre de variantes) — seulement les champs renseignés.
42 + Partagé avec le connecteur akhavan (Store API maison).
43 + """
44 + details: dict = {}
45 + try:
46 + rating = float(raw.get("average_rating") or 0)
47 + except (TypeError, ValueError):
48 + rating = 0.0
49 + if rating > 0:
50 + details["average_rating"] = rating
51 + if raw.get("review_count"):
52 + details["review_count"] = raw["review_count"]
53 +
54 + fw = raw.get("formatted_weight") or ""
55 + weight = raw.get("weight") or ""
56 + if fw and fw not in ("N/A", "n/a"):
57 + details["weight"] = fw
58 + elif weight not in ("", "0", 0, None):
59 + details["weight"] = str(weight)
60 +
61 + dims = raw.get("dimensions") or {}
62 + if isinstance(dims, dict):
63 + dims = {k: v for k, v in dims.items()
64 + if k in ("length", "width", "height") and v not in ("", "0", None)}
65 + if dims:
66 + details["dimensions"] = dims
67 +
68 + # attributs produit (bio, origine, format…) : {nom: "terme1, terme2"}
69 + attrs: dict[str, str] = {}
70 + for att in raw.get("attributes") or []:
71 + if not isinstance(att, dict):
72 + continue
73 + aname = html.unescape(str(att.get("name") or "")).strip()
74 + terms = [html.unescape(str(t.get("name") or "")).strip()
75 + for t in att.get("terms") or [] if isinstance(t, dict)]
76 + terms = [t for t in terms if t]
77 + if aname and terms:
78 + attrs[aname] = ", ".join(terms)
79 + if attrs:
80 + details["attributes"] = attrs
81 +
82 + if raw.get("low_stock_remaining") is not None:
83 + details["low_stock_remaining"] = raw["low_stock_remaining"]
84 + variations = raw.get("variations") or []
85 + if variations:
86 + details["variation_count"] = len(variations)
87 + return details
88 +
89 +
36 90 def _minor(value: str | int | None, minor_unit: int) -> float | None:
37 91 """Convertit un prix Store API (unités mineures) en dollars : "1299" -> 12.99."""
38 92 if value in (None, ""):
@@ -47,7 +101,10 @@ class WooStoreConnector(BaseConnector):
47 101 """Base des épiceries WooCommerce — sous-classes : source_id et domain."""
48 102
49 103 domain: str = "" # ex. "bocoboco.ca"
50 − max_pages: int = 8 # 100 produits/page
104 + # Store API gratuite et directe : plafond large (aliments_merci était
105 + # tronqué à exactement 800 produits avec l'ancien plafond de 8 ;
106 + # son catalogue complet fait ~2 765 produits = 28 pages).
107 + max_pages: int = 32 # 100 produits/page
51 108 per_page: int = 100
52 109
53 110 def _page(self, page: int) -> list[dict]:
@@ -82,23 +139,42 @@ class WooStoreConnector(BaseConnector):
82 139 m = _SIZE_IN_NAME_RE.search(name)
83 140 size_label = m.group(0).strip() if m else ""
84 141
142 + # marque : taxonomie `brands` de la Store API (quand la boutique l'a)
143 + brands = raw.get("brands") or []
144 + brand = (html.unescape(brands[0].get("name") or "").strip()
145 + if brands and isinstance(brands[0], dict) else "")
146 +
147 + # prix unitaire : si le nom ne porte pas de format, le poids catalogue
148 + # (`formatted_weight` inclut l'unité, ex. "885 g") fiabilise le $/100 g
149 + fw = raw.get("formatted_weight") or ""
150 + if fw in ("N/A", "n/a"):
151 + fw = ""
152 + unit_p, unit_label = None, ""
153 + if price is not None and not size_label and fw:
154 + up = unit_price(price, fw)
155 + if up:
156 + unit_p, unit_label = up[0], format_unit_price(up)
157 +
85 158 return Product(
86 159 source=self.source_id,
87 160 external_id=str(pid),
88 161 url=raw.get("permalink") or f"https://{self.domain}/?p={pid}",
89 162 name=name,
90 − brand="",
163 + brand=brand,
91 164 category=normalize_category(category_raw),
92 165 category_raw=category_raw,
93 166 size_label=size_label,
94 167 price=price,
95 168 regular_price=regular,
96 169 on_sale=on_sale and regular is not None,
170 + unit_price=unit_p,
171 + unit_price_label=unit_label,
97 172 in_stock=(bool(raw["is_in_stock"])
98 173 if raw.get("is_in_stock") is not None else None),
99 174 description=_strip_html(raw.get("short_description")
100 175 or raw.get("description")),
101 176 keywords=keywords,
177 + details=store_api_details(raw),
102 178 images=[img["src"] for img in raw.get("images") or []
103 179 if isinstance(img, dict) and img.get("src")],
104 180 )
modified foodka/connectors/akhavan.py +19 −1
@@ -14,11 +14,13 @@ import re
14 14
15 15 from bs4 import BeautifulSoup
16 16
17 +from ..normalize import format_unit_price, unit_price
17 18 from ..schema import Product, normalize_category
18 19
19 20 BASE = "https://akhavanfood.com"
20 21 API = f"{BASE}/wp-json/wc/store/v1/products"
21 22
23 +from ._woocommerce import store_api_details
22 24 from .base import BaseConnector
23 25
24 26 # format dans le nom : « Mix Shoor – Kambiz – 670 g (شور مخلوط) » -> « 670 g »
@@ -41,7 +43,9 @@ class AkhavanConnector(BaseConnector):
41 43 source_id = "akhavan"
42 44
43 45 per_page: int = 100
44 − max_pages: int = 4 # 4 x 100 = ~400 produits par synchro
46 + # Store API gratuite et directe : plafond large (le catalogue ~700-900
47 + # produits était tronqué à exactement 400 avec l'ancien plafond de 4).
48 + max_pages: int = 12 # 12 x 100 = jusqu'à 1200 produits
45 49
46 50 def _parse_item(self, item: dict) -> Product | None:
47 51 name = _html.unescape(item.get("name") or "").strip()
@@ -75,6 +79,17 @@ class AkhavanConnector(BaseConnector):
75 79 images = [img.get("src") for img in (item.get("images") or [])
76 80 if img.get("src")]
77 81
82 + # prix unitaire : si le nom ne porte pas de format, le poids catalogue
83 + # (`formatted_weight` inclut l'unité, ex. « 885 g ») fiabilise le $/100 g
84 + fw = item.get("formatted_weight") or ""
85 + if fw in ("N/A", "n/a"):
86 + fw = ""
87 + unit_p, unit_label = None, ""
88 + if not size_label and fw:
89 + up = unit_price(price, fw)
90 + if up:
91 + unit_p, unit_label = up[0], format_unit_price(up)
92 +
78 93 return Product(
79 94 source=self.source_id,
80 95 external_id=str(item["id"]),
@@ -87,8 +102,11 @@ class AkhavanConnector(BaseConnector):
87 102 price=price,
88 103 regular_price=regular if regular and price and regular > price else None,
89 104 on_sale=bool(item.get("on_sale")),
105 + unit_price=unit_p,
106 + unit_price_label=unit_label,
90 107 in_stock=item.get("is_in_stock"),
91 108 description=description,
109 + details=store_api_details(item),
92 110 images=images[:3],
93 111 )
94 112
modified foodka/connectors/base.py +18 −4
@@ -45,14 +45,28 @@ class BaseConnector:
45 45
46 46 # -- backend 1 : requests direct ------------------------------------------
47 47 def get(self, url: str, **kw) -> requests.Response:
48 − """GET direct avec throttling poli."""
48 + """GET direct avec throttling poli.
49 +
50 + Retry (3 tentatives) sur les connexions keep-alive fermées par le
51 + serveur — fréquent après une période d'inactivité (parité auto-ka).
52 + """
49 53 wait = self.request_delay - (time.time() - self._last_request)
50 54 if wait > 0:
51 55 time.sleep(wait)
52 − resp = self.session.get(url, timeout=self.timeout, **kw)
56 + last_exc: Exception | None = None
57 + for attempt in range(3):
58 + try:
59 + resp = self.session.get(url, timeout=self.timeout, **kw)
60 + self._last_request = time.time()
61 + resp.raise_for_status()
62 + return resp
63 + except (requests.exceptions.ConnectionError,
64 + requests.exceptions.ChunkedEncodingError) as exc:
65 + last_exc = exc
66 + self.session.close() # repartir sur une connexion neuve
67 + time.sleep(1.5 * (attempt + 1))
53 68 self._last_request = time.time()
54 − resp.raise_for_status()
55 − return resp
69 + raise last_exc # type: ignore[misc]
56 70
57 71 def post(self, url: str, **kw) -> requests.Response:
58 72 """POST direct avec throttling poli (API JSON internes)."""
modified foodka/connectors/pa_nature.py +6 −1
@@ -3,11 +3,16 @@
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 5 # Bannière sœur de PA (source « pa ») — circulaire distincte.
6 +# 2026-08-18 : le merchant_id dédié 3343 (« PA Nature ») a disparu de Flipp ;
7 +# la circulaire est maintenant publiée sous le merchant 3287 (« Supermarche
8 +# PA ») avec le nom « Nature Flyer » — d'où le filtre sur le nom du flyer.
6 9 # -----------------------------------------------------------------------------
7 10 from ._flipp import FlippConnector
8 11
9 12
10 13 class PANatureConnector(FlippConnector):
11 14 source_id = "pa_nature"
12 − merchant_id = 3343
15 + merchant_id = 3287 # Supermarche PA (ex-3343 « PA Nature »)
16 + flyer_name_filter = "nature" # ne prend que le « Nature Flyer »
13 17 flyer_page = "https://www.supermarchepa.com/"
18 + default_categories = {"Nature Flyer": "Bio et santé"}
modified foodka/db.py +4 −1
@@ -94,8 +94,11 @@ CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
94 94
95 95 def connect() -> sqlite3.Connection:
96 96 DB_PATH.parent.mkdir(parents=True, exist_ok=True)
97 − con = sqlite3.connect(DB_PATH)
97 + con = sqlite3.connect(DB_PATH, timeout=15)
98 98 con.row_factory = sqlite3.Row
99 + # WAL : plusieurs processus (web + sync) peuvent lire/écrire sans se bloquer
100 + con.execute("PRAGMA journal_mode=WAL")
101 + con.execute("PRAGMA busy_timeout=15000")
99 102 con.executescript(_SCHEMA)
100 103 con.commit()
101 104 return con
102 105