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