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%

[ka4] fix connecteur euro_marche: Euro Marché a quitté Flipp (plus de circulaire backflipp, scans PNG auto-hébergés sur flyer_fr.php) — stratégie hybride Flipp d'abord + repli extraction vision des scans avec cache hebdo par page (71 produits retrouvés, médiane 71)

simon-pierre boucher committed 9 days ago (Sep 18, 2026) parent c33416a

2 changed files +132 −6

modified data/sources.json +3 −3
@@ -434,12 +434,12 @@
434 434 "id": "euro_marche",
435 435 "name": "Euro Marché",
436 436 "url": "https://euromarche.ca",
437 − "catalog_url": "https://euromarche.ca/",
438 − "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
437 + "catalog_url": "https://euromarche.ca/flyer_fr.php",
438 + "tech": "Hybride : circulaire Flipp (backflipp) si présente, sinon scans PNG auto-hébergés (flyer_fr.php) extraits par vision (API Anthropic, cache hebdo par page)",
439 439 "connector": "euro_marche",
440 440 "status": "actif",
441 441 "region": "Montréal (épiceries européennes)",
442 − "notes": "Merchant Flipp 3300."
442 + "notes": "Merchant Flipp 3300 — plus de circulaire sur backflipp depuis le 2026-09-17 (la bannière auto-héberge ses scans) ; le merchant existe encore au registre Flipp, d'où le repli hybride."
443 443 },
444 444 {
445 445 "id": "val_mont",
modified foodka/connectors/euro_marche.py +129 −3
@@ -1,12 +1,138 @@
1 1 # -----------------------------------------------------------------------------
2 2 # Food-Ka — connecteur Euro Marché (épiceries européennes/méditerranéennes, Montréal)
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
4 +# Historique : circulaire hebdo sur Flipp (merchant 3300) jusqu'au 2026-09-16.
5 +# Depuis, la bannière auto-héberge sa circulaire en scans PNG sur euromarche.ca
6 +# (flyer_fr.php, images/1..N.png) — plus aucune circulaire sur backflipp (vérifié
7 +# aux 4 codes postaux des magasins ; le merchant 3300 existe encore au registre
8 +# Flipp, d'où la stratégie hybride : Flipp d'abord, scans en repli).
9 +# Repli scans : extraction vision (API Anthropic, clé résolue comme _resilient),
10 +# 1 appel par page et par semaine grâce au cache détail (clé = hash de l'image).
5 11 # -----------------------------------------------------------------------------
6 −from ._flipp import FlippConnector
12 +from __future__ import annotations
13 +
14 +import base64
15 +import hashlib
16 +import json
17 +import re
18 +
19 +from ..schema import Product, clean_text
20 +from ._flipp import FlippConnector, _categorize
21 +from ._resilient import _secret
22 +
23 +_FLYER_FR = "https://euromarche.ca/flyer_fr.php"
24 +_PAGE_RE = re.compile(r'src="(images/\d+\.png)"')
25 +_ANTHROPIC_API = "https://api.anthropic.com/v1/messages"
26 +_VISION_MODEL = "claude-sonnet-4-6"
27 +
28 +_VISION_PROMPT = (
29 + "Voici une page de la circulaire hebdomadaire d'une épicerie québécoise. "
30 + "Extrais CHAQUE produit qui a un prix visible. Réponds UNIQUEMENT avec un "
31 + "tableau JSON, sans texte autour. Un objet par produit :\n"
32 + '{"name_fr": "nom en français", "name_en": "nom anglais ou \'\'", '
33 + '"brand": "marque si imprimée sinon \'\'", '
34 + '"size": "format imprimé (ex. 1 KG, 3 L, 800 GR) ou \'\'", '
35 + '"price": prix unitaire effectif en dollars (nombre ; 79¢ -> 0.79 ; '
36 + '"2/5.00$" -> 2.5), '
37 + "\"price_text\": \"texte du prix tel qu'imprimé (ex. 12.99 CH/EA, 99¢/lb)\", "
38 + '"unit": "ch|lb|kg|\'\'"}\n'
39 + "N'invente rien : si un prix est illisible, omets le produit."
40 +)
7 41
8 42
9 43 class EuroMarcheConnector(FlippConnector):
10 44 source_id = "euro_marche"
11 45 merchant_id = 3300
12 − flyer_page = "https://euromarche.ca/"
46 + flyer_page = _FLYER_FR
47 +
48 + def fetch(self) -> list[Product]:
49 + # 1. Flipp (source structurée historique) — si la bannière y revient,
50 + # le connecteur reprend seul le chemin préféré.
51 + products = super().fetch()
52 + if products:
53 + return products
54 + # 2. Repli : scans PNG auto-hébergés de la circulaire courante.
55 + return self._fetch_site_scans()
56 +
57 + # -- repli : scans de la circulaire sur euromarche.ca -------------------------
58 + def _fetch_site_scans(self) -> list[Product]:
59 + html = self.get(_FLYER_FR).text
60 + pages = sorted(set(_PAGE_RE.findall(html)),
61 + key=lambda p: int(re.sub(r"\D", "", p)))
62 + products: list[Product] = []
63 + for idx, page in enumerate(pages, start=1):
64 + img = self.get(f"https://euromarche.ca/{page}").content
65 + page_key = hashlib.sha256(img).hexdigest()[:16]
66 + # cache hebdo : l'appel vision ne part que si le scan a changé
67 + payload = self.detail(f"__scan_p{idx}", page_key,
68 + lambda img=img: {"items": self._vision_extract(img)})
69 + for item in payload.get("items") or []:
70 + prod = self._scan_item_to_product(item, idx)
71 + if prod is not None:
72 + products.append(prod)
73 + return products
74 +
75 + def _scan_item_to_product(self, item: dict, page: int) -> Product | None:
76 + price = self._price(item.get("price"))
77 + name_fr = clean_text(str(item.get("name_fr") or ""))
78 + if price is None or not name_fr:
79 + return None
80 + brand = clean_text(str(item.get("brand") or ""))
81 + if brand and brand == brand.lower():
82 + brand = brand.title()
83 + size = clean_text(str(item.get("size") or ""))
84 + name_en = clean_text(str(item.get("name_en") or ""))
85 + unit = clean_text(str(item.get("unit") or ""))
86 + price_label = clean_text(str(item.get("price_text") or "")) or f"{price} $"
87 + return Product(
88 + source=self.source_id,
89 + external_id=self._slug(brand, name_fr, size),
90 + url=self.flyer_page,
91 + name=name_fr,
92 + brand=brand,
93 + category=_categorize(name_fr, ""),
94 + category_raw="Circulaire (scan)",
95 + size_label=size,
96 + price=price,
97 + price_label=price_label,
98 + on_sale=True, # une circulaire = les spéciaux de la semaine
99 + keywords=[name_en] if name_en else [],
100 + details={k: v for k, v in {
101 + "scan_page": page,
102 + "price_unit": unit or None,
103 + }.items() if v is not None},
104 + )
105 +
106 + # -- extraction vision d'une page de scan -------------------------------------
107 + def _vision_extract(self, img: bytes) -> list[dict]:
108 + key = _secret("ANTHROPIC_API_KEY")
109 + if not key:
110 + raise RuntimeError("ANTHROPIC_API_KEY manquant (os.environ, .env, ~/.claude/.env)")
111 + resp = self.session.post(
112 + _ANTHROPIC_API,
113 + headers={"x-api-key": key, "anthropic-version": "2023-06-01",
114 + "content-type": "application/json"},
115 + json={
116 + "model": _VISION_MODEL,
117 + "max_tokens": 8000,
118 + "temperature": 0,
119 + "messages": [{"role": "user", "content": [
120 + {"type": "image",
121 + "source": {"type": "base64", "media_type": "image/png",
122 + "data": base64.b64encode(img).decode()}},
123 + {"type": "text", "text": _VISION_PROMPT},
124 + ]}],
125 + },
126 + timeout=300,
127 + )
128 + resp.raise_for_status()
129 + text = "".join(b.get("text") or "" for b in resp.json().get("content") or [])
130 + text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
131 + try:
132 + items = json.loads(text)
133 + except json.JSONDecodeError:
134 + start, end = text.find("["), text.rfind("]")
135 + if start < 0 or end <= start:
136 + raise
137 + items = json.loads(text[start:end + 1])
138 + return [it for it in items if isinstance(it, dict)]
13 139