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