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%

Connecteurs Flipp : socle + 19 bannières de circulaires (~2 760 produits)

Socle _flipp.py : API publique backflipp.wishabi.com (résolution hebdo du
flyer_id, filtrage merchant_id côté client, catégorisation par mots du nom,
external_id stable marque+nom+format pour l'historique price_log).

Vague 1 — bannières sans e-commerce (Metro/Sobeys affiliés) : richelieu,
marche_ami, bonichoix, tradition, rachelle_bery (réactivée, 2 flyers).
Vague 2 — balayage de l'index Flipp (12 codes postaux, 156 marchands) :
inter_marche (+intl), axep, pasquier, kim_phat (réactivé), marche_ct,
fu_tai, aures, euro_marche, val_mont, bonanza, marche_vegetarien,
pa_nature, aliments_mm. postal_code par connecteur (visibilité régionale).

Tests : fixtures calquées des vraies réponses API (mapping, dédup, garde-fous
prix, frontières de mots du classifieur).

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

22 changed files +751 −11

modified data/sources.json +198 −11
@@ -217,23 +217,23 @@
217 217 "id": "rachelle_bery",
218 218 "name": "Rachelle-Béry",
219 219 "url": "https://www.rachellebery.ca",
220 − "catalog_url": null,
221 − "tech": "Site vitrine sans prix (bannière Sobeys)",
222 − "connector": null,
223 − "status": "non connectable",
220 + "catalog_url": "https://www.rachellebery.ca/circulaires/",
221 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot) — 2 flyers : Épicerie + Espace Santé",
222 + "connector": "rachelle_bery",
223 + "status": "actif",
224 224 "region": "Montréal et environs",
225 − "notes": "Aucun catalogue en ligne avec prix : l'épicerie en ligne passe par Voilà (couverte via IGA) et Instacart."
225 + "notes": "Site vitrine WordPress sans prix ; les prix viennent des circulaires Flipp (merchant 3295). Domaine sans trait d'union."
226 226 },
227 227 {
228 228 "id": "kim_phat",
229 229 "name": "Kim Phat",
230 230 "url": "https://www.kimphat.com",
231 − "catalog_url": null,
232 − "tech": "Site vitrine",
233 − "connector": null,
234 − "status": "non connectable",
231 + "catalog_url": "https://www.kimphat.com/",
232 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
233 + "connector": "kim_phat",
234 + "status": "actif",
235 235 "region": "Grand Montréal",
236 − "notes": "Commande en ligne déléguée à Voilà — aucun catalogue propre avec prix."
236 + "notes": "Site vitrine sans prix ; les prix viennent de la circulaire Flipp (merchant 3290)."
237 237 },
238 238 {
239 239 "id": "bulk_barn",
@@ -308,6 +308,193 @@
308 308 "status": "non connectable",
309 309 "region": "Montréal (vrac)",
310 310 "notes": "L'API GoDaddy de la boutique refuse toute requête publique (403) et la page rendue n'affiche aucun prix."
311 + },
312 + {
313 + "id": "richelieu",
314 + "name": "Marché Richelieu",
315 + "url": "https://www.marcherichelieu.ca",
316 + "catalog_url": "https://www.marcherichelieu.ca/circulaire",
317 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
318 + "connector": "richelieu",
319 + "status": "actif",
320 + "region": "Québec (épiceries de proximité affiliées Metro)",
321 + "notes": "Merchant Flipp 3372 — une circulaire provinciale par semaine."
322 + },
323 + {
324 + "id": "marche_ami",
325 + "name": "Marché Ami",
326 + "url": "https://www.marcheami.ca",
327 + "catalog_url": "https://www.marcheami.ca/circulaire",
328 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
329 + "connector": "marche_ami",
330 + "status": "actif",
331 + "region": "Québec (villages et quartiers, affiliés Metro)",
332 + "notes": "Merchant Flipp 3761 — une circulaire provinciale par semaine."
333 + },
334 + {
335 + "id": "bonichoix",
336 + "name": "Marché Bonichoix",
337 + "url": "https://www.bonichoix.com",
338 + "catalog_url": "https://www.bonichoix.com/",
339 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
340 + "connector": "bonichoix",
341 + "status": "actif",
342 + "region": "Québec (épiceries de village, bannière Sobeys)",
343 + "notes": "Merchant Flipp 3566. Site vitrine derrière Akamai — inutile de le scraper."
344 + },
345 + {
346 + "id": "tradition",
347 + "name": "Les Marchés Tradition",
348 + "url": "https://www.marchetradition.com",
349 + "catalog_url": "https://www.marchetradition.com/",
350 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
351 + "connector": "tradition",
352 + "status": "actif",
353 + "region": "Québec (épiceries de quartier, bannière Sobeys)",
354 + "notes": "Merchant Flipp 4780. Le site bloque les IP datacenter — Flipp est la seule voie."
355 + },
356 + {
357 + "id": "inter_marche",
358 + "name": "L'Inter-Marché",
359 + "url": "https://www.lintermarche.ca",
360 + "catalog_url": "https://www.lintermarche.ca/",
361 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
362 + "connector": "inter_marche",
363 + "status": "actif",
364 + "region": "Québec, Saguenay, Mauricie, Estrie, Outaouais (affilié Loblaw)",
365 + "notes": "Merchant Flipp 3293 — résolu depuis la zone de Québec (G1R2L3)."
366 + },
367 + {
368 + "id": "inter_marche_intl",
369 + "name": "L'Inter-Marché International",
370 + "url": "https://www.lintermarche.ca",
371 + "catalog_url": "https://www.lintermarche.ca/",
372 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
373 + "connector": "inter_marche_intl",
374 + "status": "actif",
375 + "region": "Grand Montréal (volet international)",
376 + "notes": "Merchant Flipp 4642 — circulaire distincte de L'Inter-Marché."
377 + },
378 + {
379 + "id": "axep",
380 + "name": "Axep",
381 + "url": "https://www.axep.ca",
382 + "catalog_url": "https://www.axep.ca/",
383 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
384 + "connector": "axep",
385 + "status": "actif",
386 + "region": "Régions du Québec (proximité, affilié Metro)",
387 + "notes": "Merchant Flipp 3488 — résolu depuis la zone de Sherbrooke (J1H1A1)."
388 + },
389 + {
390 + "id": "pasquier",
391 + "name": "Pasquier",
392 + "url": "https://pasquier.qc.ca",
393 + "catalog_url": "https://pasquier.qc.ca/",
394 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
395 + "connector": "pasquier",
396 + "status": "actif",
397 + "region": "Saint-Jean-sur-Richelieu et Montérégie",
398 + "notes": "Merchant Flipp 3791 — ~150 items/semaine."
399 + },
400 + {
401 + "id": "marche_ct",
402 + "name": "Marché C&T",
403 + "url": "https://www.marchect.ca",
404 + "catalog_url": "https://www.marchect.ca/",
405 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
406 + "connector": "marche_ct",
407 + "status": "actif",
408 + "region": "Grand Montréal (supermarchés asiatiques)",
409 + "notes": "Merchant Flipp 3288 — plusieurs circulaires (une par succursale)."
410 + },
411 + {
412 + "id": "fu_tai",
413 + "name": "Marché Fu Tai",
414 + "url": "https://flipp.com/fr-ca/search/march%C3%A9%20fu%20tai",
415 + "catalog_url": "https://flipp.com/fr-ca/search/march%C3%A9%20fu%20tai",
416 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
417 + "connector": "fu_tai",
418 + "status": "actif",
419 + "region": "Grand Montréal (supermarché asiatique)",
420 + "notes": "Merchant Flipp 5719 — pas de site web officiel trouvé."
421 + },
422 + {
423 + "id": "aures",
424 + "name": "Supermarché Aurès",
425 + "url": "https://www.supermarcheaures.com",
426 + "catalog_url": "https://www.supermarcheaures.com/",
427 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
428 + "connector": "aures",
429 + "status": "actif",
430 + "region": "Montréal (épiceries maghrébines/méditerranéennes)",
431 + "notes": "Merchant Flipp 6798."
432 + },
433 + {
434 + "id": "euro_marche",
435 + "name": "Euro Marché",
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)",
439 + "connector": "euro_marche",
440 + "status": "actif",
441 + "region": "Montréal (épiceries européennes)",
442 + "notes": "Merchant Flipp 3300."
443 + },
444 + {
445 + "id": "val_mont",
446 + "name": "Marché Val-Mont",
447 + "url": "https://flipp.com/fr-ca/search/val-mont",
448 + "catalog_url": "https://flipp.com/fr-ca/search/val-mont",
449 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
450 + "connector": "val_mont",
451 + "status": "actif",
452 + "region": "Rive-Nord de Montréal (fruiteries-épiceries)",
453 + "notes": "Merchant Flipp 3296 — site officiel non vérifiable."
454 + },
455 + {
456 + "id": "bonanza",
457 + "name": "Marché Bonanza",
458 + "url": "https://flipp.com/fr-ca/search/march%C3%A9%20bonanza",
459 + "catalog_url": "https://flipp.com/fr-ca/search/march%C3%A9%20bonanza",
460 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
461 + "connector": "bonanza",
462 + "status": "actif",
463 + "region": "Montréal (épiceries indépendantes)",
464 + "notes": "Merchant Flipp 3292 — site officiel non vérifiable."
465 + },
466 + {
467 + "id": "marche_vegetarien",
468 + "name": "Le Marché Végétarien",
469 + "url": "https://www.marchevegetarien.com",
470 + "catalog_url": "https://www.marchevegetarien.com/",
471 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
472 + "connector": "marche_vegetarien",
473 + "status": "actif",
474 + "region": "Mauricie et Centre-du-Québec (épiceries santé)",
475 + "notes": "Merchant Flipp 4795 — résolu depuis Trois-Rivières (G9A5H7)."
476 + },
477 + {
478 + "id": "pa_nature",
479 + "name": "PA Nature",
480 + "url": "https://www.supermarchepa.com",
481 + "catalog_url": "https://www.supermarchepa.com/",
482 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
483 + "connector": "pa_nature",
484 + "status": "actif",
485 + "region": "Montréal (volet bio/santé du Supermarché PA)",
486 + "notes": "Merchant Flipp 3343 — bannière sœur de la source « pa »."
487 + },
488 + {
489 + "id": "aliments_mm",
490 + "name": "Les Aliments M&M",
491 + "url": "https://www.mmfoodmarket.com/fr",
492 + "catalog_url": "https://www.mmfoodmarket.com/fr",
493 + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)",
494 + "connector": "aliments_mm",
495 + "status": "actif",
496 + "region": "Tout le Québec (spécialiste du surgelé)",
497 + "notes": "Merchant Flipp 2024 — circulaire 100 % alimentaire."
311 498 }
312 499 ]
313 −}
\ No newline at end of file
500 +}
added foodka/connectors/_flipp.py +180 −0
@@ -0,0 +1,180 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/_flipp.py : socle commun des bannières « circulaire seulement »
5 +# Beaucoup d'épiceries de proximité (affiliés Metro et Sobeys) n'ont aucun
6 +# catalogue en ligne : leur seule donnée de prix est la circulaire hebdo,
7 +# hébergée sur Flipp. Le backend Flipp (backflipp.wishabi.com) est une API
8 +# JSON publique sans clé ni anti-bot :
9 +# 1. /flipp/flyers?locale=fr-ca&postal_code=… -> circulaires de la zone
10 +# (filtrer côté client sur merchant_id — le paramètre d'URL ne filtre pas)
11 +# 2. /flipp/flyers/{flyer_id}?locale=fr-ca -> items avec prix
12 +# Une seule circulaire provinciale par bannière par semaine (vérifié sur
13 +# 4 codes postaux) : un poste montréalais suffit. Le flyer_id change chaque
14 +# semaine — toujours résolu dynamiquement via l'étape 1.
15 +# Les items n'ont ni SKU ni catégorie ni unité (« /lb ») : external_id =
16 +# slug stable marque+nom+format (continuité du price_log d'une semaine à
17 +# l'autre), catégorie déduite du nom du produit.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import re
22 +
23 +from ..schema import Product, clean_text, strip_accents
24 +from .base import BaseConnector
25 +
26 +LIST_URL = ("https://backflipp.wishabi.com/flipp/flyers"
27 + "?locale=fr-ca&postal_code={postal}")
28 +FLYER_URL = "https://backflipp.wishabi.com/flipp/flyers/{flyer_id}?locale=fr-ca"
29 +
30 +# format dans le nom : « (550 à 675 g) », « 2 L », « 9,1 ou 12,7 kg »…
31 +_SIZE_IN_NAME = re.compile(
32 + r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",
33 + re.IGNORECASE)
34 +_SLUG_RE = re.compile(r"[^a-z0-9]+")
35 +
36 +# Catégorisation par mots du nom (les items Flipp n'ont pas de taxonomie).
37 +# Motifs à frontière de mot sur le nom FRANÇAIS sans accents ; l'ordre compte
38 +# (les motifs composés/désambiguïsants d'abord). Faute de correspondance :
39 +# default_category du flyer, sinon « Autres ».
40 +_NAME_RULES: list[tuple[re.Pattern, str]] = [(re.compile(rf"\b(?:{pat})\b"), cat) for pat, cat in [
41 + # désambiguïsations prioritaires
42 + (r"beurres? d.arachides?|tartinades?|vinaigre\w*", "Garde-manger"),
43 + (r"cremes? glacees?|sorbets?|surgele\w*|congele\w*|pizzas?", "Surgelés"),
44 + (r"chocolat\w*|croustilles?|chips|biscuits?|bonbons?|craquelins?|"
45 + r"barres? tendres|popcorn|mais souffle|arachides?|noix|gommes?|"
46 + r"friandises?|collations?", "Collations et confiseries"),
47 + (r"jus|eaux?|boissons?|cafes?|thes?|tisanes?|sodas?|cola|bieres?|vins?|"
48 + r"cidres?|limonades?|smoothies?|kombucha", "Boissons"),
49 + (r"fromages?|cheddar|mozzarella|feta|brie|parmesan|jambons?|salami|"
50 + r"pepperoni|prosciutto|charcuteries?", "Charcuteries et fromages"),
51 + (r"lait|laits|yogourts?|oeufs?|margarines?|beurres?|cremes?",
52 + "Produits laitiers et œufs"),
53 + (r"saumons?|truites?|tilapia|morue|aiglefin|sole|thons?|crevettes?|"
54 + r"homards?|petoncles?|poissons?|fruits de mer|calmars?",
55 + "Poissons et fruits de mer"),
56 + (r"boeuf|poulets?|porc|veau|agneau|dindes?|poitrines?|cuisses?|"
57 + r"biftecks?|steaks?|cotelettes?|saucisses?|tournedos|rotis?|hache\w*|"
58 + r"bacon|viandes?", "Viandes et volailles"),
59 + (r"pains?|baguettes?|bagels?|muffins?|tortillas?|croissants?|brioches?|"
60 + r"gateau\w*|tarte\w*|patisseries?", "Boulangerie"),
61 + (r"peches?|pommes?|bananes?|fraises?|framboises?|bleuets?|raisins?|"
62 + r"melons?|ananas|celeris?|laitues?|salades?|carottes?|brocolis?|"
63 + r"chou\w*|poivrons?|tomates?|concombres?|oignons?|patates?|mais|"
64 + r"avocats?|citrons?|limes?|cerises?|prunes?|poires?|mangues?|kiwis?|"
65 + r"champignons?|epinards?|asperges?|courge\w*|legumes?|fruits?",
66 + "Fruits et légumes"),
67 + (r"cereales?|pates?|riz|sauces?|conserves?|soupes?|bouillons?|huiles?|"
68 + r"vinaigre\w*|farines?|sucres?|ketchup|mayonnaise|moutarde|confitures?|"
69 + r"miel|gruau|granola|tofu|legumineuses?|epices?", "Garde-manger"),
70 + (r"repas|sushis?|sandwich\w*|pret[- ]a[- ]manger", "Prêt-à-manger"),
71 + (r"vitamines?|supplements?|proteines?|probiotiques?|collagene|curcuma|"
72 + r"chlorophylle|omega", "Bio et santé"),
73 + (r"couches?|bebes?", "Bébé"),
74 + (r"chats?|chiens?|litieres?|animaux", "Animaux"),
75 + (r"papiers?|essuie[- ]tout|lessives?|detergents?|savons?|nettoyants?|"
76 + r"javel|assouplissants?", "Maison et entretien"),
77 + (r"shampoing\w*|shampooing\w*|dentifrices?|deodorants?|hygiene",
78 + "Santé et beauté"),
79 +]]
80 +
81 +
82 +def _categorize(name_fr: str, default: str) -> str:
83 + key = strip_accents(name_fr).lower()
84 + for rx, cat in _NAME_RULES:
85 + if rx.search(key):
86 + return cat
87 + return default or "Autres"
88 +
89 +
90 +class FlippConnector(BaseConnector):
91 + """Base des circulaires Flipp — sous-classes : source_id, merchant_id,
92 + flyer_page (URL humaine de la circulaire, pour le lien produit).
93 +
94 + `default_categories` associe un nom de flyer à une catégorie par défaut
95 + (ex. Rachelle-Béry « Espace Santé » -> « Bio et santé »)."""
96 +
97 + merchant_id: int = 0
98 + postal_code: str = "H2X1Y4" # Montréal — capte les flyers provinciaux
99 + flyer_page: str = ""
100 + default_categories: dict[str, str] = {}
101 +
102 + # -- récupération -----------------------------------------------------------
103 + def _flyers(self) -> list[dict]:
104 + """Circulaires courantes de la bannière (résolution hebdo du flyer_id)."""
105 + data = self.get(LIST_URL.format(postal=self.postal_code),
106 + headers={"Accept": "application/json"}).json()
107 + return [f for f in (data.get("flyers") or [])
108 + if f.get("merchant_id") == self.merchant_id]
109 +
110 + def fetch(self) -> list[Product]:
111 + products: list[Product] = []
112 + for flyer in self._flyers():
113 + data = self.get(FLYER_URL.format(flyer_id=flyer["id"]),
114 + headers={"Accept": "application/json"}).json()
115 + for item in data.get("items") or []:
116 + prod = self._item_to_product(item, flyer)
117 + if prod is not None:
118 + products.append(prod)
119 + return products
120 +
121 + # -- extraction d'un item ----------------------------------------------------
122 + def _item_to_product(self, item: dict, flyer: dict) -> Product | None:
123 + price = self._price(item.get("price"))
124 + if price is None: # vignette non-produit (« Scene offre ») ou multi-format
125 + return None
126 +
127 + # nom bilingue « PÊCHES ONTARIO | ONTARIO PEACHES » -> FR + EN en mot-clé
128 + parts = [clean_text(p) for p in str(item.get("name") or "").split("|")]
129 + parts = [p for p in parts if p]
130 + if not parts:
131 + return None
132 + name_fr, keywords = parts[0], parts[1:]
133 +
134 + brand = clean_text(item.get("brand") or "").replace(" | ", " / ")
135 + if brand and brand == brand.lower(): # Flipp met parfois tout en minuscules
136 + brand = brand.title()
137 +
138 + size_m = _SIZE_IN_NAME.search(name_fr)
139 + flyer_name = clean_text(flyer.get("name") or "")
140 + default_cat = self.default_categories.get(flyer_name, "")
141 +
142 + return Product(
143 + source=self.source_id,
144 + external_id=self._slug(brand, name_fr, size_m.group(1) if size_m else ""),
145 + url=self.flyer_page,
146 + name=name_fr,
147 + brand=brand,
148 + category=_categorize(name_fr, default_cat),
149 + category_raw=flyer_name,
150 + size_label=size_m.group(1) if size_m else "",
151 + price=price,
152 + price_label=f"{item.get('price')} $",
153 + on_sale=True, # une circulaire = les spéciaux de la semaine
154 + keywords=keywords,
155 + details={k: v for k, v in {
156 + "flyer_id": flyer.get("id"),
157 + "flyer_name": flyer_name,
158 + "valid_from": item.get("valid_from") or flyer.get("valid_from"),
159 + "valid_to": item.get("valid_to") or flyer.get("valid_to"),
160 + "discount_pct": item.get("discount"),
161 + }.items() if v is not None},
162 + images=[u.replace("http://", "https://")
163 + for u in [item.get("cutout_image_url")] if u],
164 + )
165 +
166 + @staticmethod
167 + def _price(raw) -> float | None:
168 + """« 5.99 », « 0.3 », 5.99 -> float ; vide/0 -> None (jamais inventé)."""
169 + try:
170 + value = float(str(raw).strip().replace(",", "."))
171 + except (TypeError, ValueError):
172 + return None
173 + return value if 0 < value <= 2000 else None
174 +
175 + @staticmethod
176 + def _slug(brand: str, name_fr: str, size: str) -> str:
177 + """Identifiant stable d'une semaine à l'autre (l'id d'item Flipp change) :
178 + le même produit garde le même uid et son historique price_log."""
179 + raw = strip_accents(f"{brand} {name_fr} {size}").lower()
180 + return _SLUG_RE.sub("-", raw).strip("-")[:80]
added foodka/connectors/aliments_mm.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Les Aliments M&M (surgelés, présents partout au Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Tous les items de la circulaire sont des produits M&M avec prix.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class AlimentsMMConnector(FlippConnector):
11 + source_id = "aliments_mm"
12 + merchant_id = 2024
13 + flyer_page = "https://www.mmfoodmarket.com/fr"
added foodka/connectors/aures.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Supermarché Aurès (épiceries maghrébines/méditerranéennes, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# -----------------------------------------------------------------------------
6 +from ._flipp import FlippConnector
7 +
8 +
9 +class AuresConnector(FlippConnector):
10 + source_id = "aures"
11 + merchant_id = 6798
12 + flyer_page = "https://www.supermarcheaures.com/"
added foodka/connectors/axep.py +15 −0
@@ -0,0 +1,15 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Axep (épiceries de proximité affiliées Metro)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Présent en région (Estrie, Saguenay, Outaouais, Centre-du-Québec) —
6 +# flyer résolu depuis la zone de Sherbrooke.
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class AxepConnector(FlippConnector):
12 + source_id = "axep"
13 + merchant_id = 3488
14 + postal_code = "J1H1A1"
15 + flyer_page = "https://www.axep.ca/"
added foodka/connectors/bonanza.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Bonanza (épiceries indépendantes, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Site officiel non vérifiable — lien produit vers la recherche Flipp.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class BonanzaConnector(FlippConnector):
11 + source_id = "bonanza"
12 + merchant_id = 3292
13 + flyer_page = "https://flipp.com/fr-ca/search/march%C3%A9%20bonanza"
added foodka/connectors/bonichoix.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Bonichoix (bannière indépendante Sobeys)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Le site bonichoix.com est derrière Akamai (403) — lien produit vers la racine.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class BonichoixConnector(FlippConnector):
11 + source_id = "bonichoix"
12 + merchant_id = 3566
13 + flyer_page = "https://www.bonichoix.com/"
added foodka/connectors/euro_marche.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Euro Marché (épiceries européennes/méditerranéennes, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# -----------------------------------------------------------------------------
6 +from ._flipp import FlippConnector
7 +
8 +
9 +class EuroMarcheConnector(FlippConnector):
10 + source_id = "euro_marche"
11 + merchant_id = 3300
12 + flyer_page = "https://euromarche.ca/"
added foodka/connectors/fu_tai.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Fu Tai (supermarché asiatique, Grand Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Pas de site web trouvé — lien produit vers la recherche publique Flipp.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class FuTaiConnector(FlippConnector):
11 + source_id = "fu_tai"
12 + merchant_id = 5719
13 + flyer_page = "https://flipp.com/fr-ca/search/march%C3%A9%20fu%20tai"
added foodka/connectors/inter_marche.py +15 −0
@@ -0,0 +1,15 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur L'Inter-Marché (épiceries affiliées Loblaw)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Présent surtout hors Montréal (Québec, Saguenay, Mauricie, Estrie,
6 +# Outaouais) — flyer résolu depuis la zone de Québec.
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class InterMarcheConnector(FlippConnector):
12 + source_id = "inter_marche"
13 + merchant_id = 3293
14 + postal_code = "G1R2L3"
15 + flyer_page = "https://www.lintermarche.ca/"
added foodka/connectors/inter_marche_intl.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur L'Inter-Marché International (volet ethnique/international)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Magasins du Grand Montréal — circulaire distincte de L'Inter-Marché.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class InterMarcheIntlConnector(FlippConnector):
11 + source_id = "inter_marche_intl"
12 + merchant_id = 4642
13 + flyer_page = "https://www.lintermarche.ca/"
added foodka/connectors/kim_phat.py +14 −0
@@ -0,0 +1,14 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Kim Phat (supermarchés asiatiques, Grand Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Le site kimphat.com est une vitrine sans prix — la donnée vit dans la
6 +# circulaire Flipp (~160 items/semaine).
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class KimPhatConnector(FlippConnector):
12 + source_id = "kim_phat"
13 + merchant_id = 3290
14 + flyer_page = "https://www.kimphat.com/"
added foodka/connectors/marche_ami.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Ami (épiceries de proximité affiliées Metro)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py)
5 +# -----------------------------------------------------------------------------
6 +from ._flipp import FlippConnector
7 +
8 +
9 +class MarcheAmiConnector(FlippConnector):
10 + source_id = "marche_ami"
11 + merchant_id = 3761
12 + flyer_page = "https://www.marcheami.ca/circulaire"
added foodka/connectors/marche_ct.py +14 −0
@@ -0,0 +1,14 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché C&T (supermarchés asiatiques, Grand Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Plusieurs circulaires simultanées (une par succursale) — les doublons
6 +# inter-flyers sont dédupliqués par uid dans le pipeline.
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class MarcheCTConnector(FlippConnector):
12 + source_id = "marche_ct"
13 + merchant_id = 3288
14 + flyer_page = "https://www.marchect.ca/"
added foodka/connectors/marche_vegetarien.py +14 −0
@@ -0,0 +1,14 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Le Marché Végétarien (épiceries santé, Mauricie/Centre-du-Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Visible depuis la zone de Trois-Rivières (2 flyers simultanés).
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class MarcheVegetarienConnector(FlippConnector):
11 + source_id = "marche_vegetarien"
12 + merchant_id = 4795
13 + postal_code = "G9A5H7"
14 + flyer_page = "https://www.marchevegetarien.com/"
added foodka/connectors/pa_nature.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur PA Nature (volet bio/santé du Supermarché PA, Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Bannière sœur de PA (source « pa ») — circulaire distincte.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class PANatureConnector(FlippConnector):
11 + source_id = "pa_nature"
12 + merchant_id = 3343
13 + flyer_page = "https://www.supermarchepa.com/"
added foodka/connectors/pasquier.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Pasquier (supermarché indépendant, Saint-Jean-sur-Richelieu)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Circulaire très fournie (~150 items/semaine).
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class PasquierConnector(FlippConnector):
11 + source_id = "pasquier"
12 + merchant_id = 3791
13 + flyer_page = "https://pasquier.qc.ca/"
added foodka/connectors/rachelle_bery.py +15 −0
@@ -0,0 +1,15 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Rachelle-Béry (épiceries santé, bannière Sobeys)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Le site rachellebery.ca (sans trait d'union) est une vitrine WordPress sans
5 +# prix ; la donnée vit dans DEUX circulaires Flipp hebdomadaires du même
6 +# marchand : « Épicerie » et « Espace Santé » (suppléments, produits naturels).
7 +# -----------------------------------------------------------------------------
8 +from ._flipp import FlippConnector
9 +
10 +
11 +class RachelleBeryConnector(FlippConnector):
12 + source_id = "rachelle_bery"
13 + merchant_id = 3295
14 + flyer_page = "https://www.rachellebery.ca/circulaires/"
15 + default_categories = {"Espace Santé": "Bio et santé"}
added foodka/connectors/richelieu.py +12 −0
@@ -0,0 +1,12 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Richelieu (épiceries de proximité affiliées Metro)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py)
5 +# -----------------------------------------------------------------------------
6 +from ._flipp import FlippConnector
7 +
8 +
9 +class RichelieuConnector(FlippConnector):
10 + source_id = "richelieu"
11 + merchant_id = 3372
12 + flyer_page = "https://www.marcherichelieu.ca/circulaire"
added foodka/connectors/tradition.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Les Marchés Tradition (bannière indépendante Sobeys)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# marchetradition.com bloque les IP datacenter — lien produit vers la racine.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class TraditionConnector(FlippConnector):
11 + source_id = "tradition"
12 + merchant_id = 4780
13 + flyer_page = "https://www.marchetradition.com/"
added foodka/connectors/val_mont.py +13 −0
@@ -0,0 +1,13 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — connecteur Marché Val-Mont (fruiteries-épiceries, Rive-Nord de Montréal)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py).
5 +# Site officiel non vérifiable — lien produit vers la recherche Flipp.
6 +# -----------------------------------------------------------------------------
7 +from ._flipp import FlippConnector
8 +
9 +
10 +class ValMontConnector(FlippConnector):
11 + source_id = "val_mont"
12 + merchant_id = 3296
13 + flyer_page = "https://flipp.com/fr-ca/search/val-mont"
added tests/test_flipp.py +121 −0
@@ -0,0 +1,121 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — tests du socle Flipp (circulaires backflipp.wishabi.com)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# Fixtures calquées sur de vraies réponses API (flyers 8072017/8076529,
5 +# semaine du 13 au 19 août 2026) — aucun appel réseau.
6 +# -----------------------------------------------------------------------------
7 +import pytest
8 +
9 +from foodka.connectors._flipp import FlippConnector, _categorize
10 +from foodka.connectors.rachelle_bery import RachelleBeryConnector
11 +from foodka.connectors.richelieu import RichelieuConnector
12 +
13 +FLYER = {
14 + "id": 8072017, "merchant_id": 3372, "merchant": "Marché Richelieu",
15 + "name": "Circulaire hebdomadaire",
16 + "valid_from": "2026-08-13T00:00:00-04:00",
17 + "valid_to": "2026-08-19T23:59:59-04:00",
18 +}
19 +
20 +ITEMS = [
21 + {"id": 1, "name": "PÊCHES ONTARIO | ONTARIO PEACHES", "brand": "ontario",
22 + "price": "5.99", "discount": 25,
23 + "cutout_image_url": "http://f.wishabi.net/page_items/1/extra_large.jpg",
24 + "valid_from": "2026-08-13T00:00:00-04:00",
25 + "valid_to": "2026-08-19T23:59:59-04:00"},
26 + {"id": 2, "name": "YOGOURT MÉDITERRANÉE LIBERTÉ (500 g)", "brand": "Liberté",
27 + "price": "3.49", "discount": None, "cutout_image_url": None},
28 + {"id": 3, "name": "Scene offre", "brand": None, "price": "",
29 + "discount": None, "cutout_image_url": None}, # vignette non-produit
30 + {"id": 4, "name": "maïs en épis 2 couleurs | fresh corn on the cob",
31 + "brand": None, "price": "0.3", "discount": 62, "cutout_image_url": None},
32 +]
33 +
34 +LISTING = {"flyers": [dict(FLYER), {"id": 999, "merchant_id": 1234,
35 + "name": "Autre marchand"}]}
36 +
37 +
38 +def _wire(connector, listing, items):
39 + """Court-circuite le réseau : rejoue les deux réponses de l'API."""
40 + class FakeResp:
41 + def __init__(self, payload):
42 + self._payload = payload
43 +
44 + def json(self):
45 + return self._payload
46 +
47 + def fake_get(url, **kw):
48 + return FakeResp(listing if "/flyers?" in url else {"items": items})
49 +
50 + connector.get = fake_get
51 + return connector
52 +
53 +
54 +def test_flipp_fetch_and_mapping():
55 + products = _wire(RichelieuConnector(), LISTING, ITEMS).fetch()
56 +
57 + # la vignette sans prix est filtrée ; l'autre marchand est ignoré
58 + assert len(products) == 3
59 +
60 + peches = products[0].finalize()
61 + assert peches.source == "richelieu"
62 + assert peches.name == "PÊCHES ONTARIO" # partie française seulement
63 + assert peches.keywords == ["ONTARIO PEACHES"] # partie anglaise en mot-clé
64 + assert peches.brand == "Ontario" # minuscules Flipp redressées
65 + assert peches.price == 5.99
66 + assert peches.on_sale is True
67 + assert peches.category == "Fruits et légumes"
68 + assert peches.images == ["https://f.wishabi.net/page_items/1/extra_large.jpg"]
69 + assert peches.details["valid_to"] == "2026-08-19T23:59:59-04:00"
70 + assert peches.details["discount_pct"] == 25
71 + assert peches.url == "https://www.marcherichelieu.ca/circulaire"
72 +
73 + yogourt = products[1].finalize()
74 + assert yogourt.category == "Produits laitiers et œufs"
75 + assert yogourt.size_label == "500 g" # format extrait du nom
76 + assert yogourt.unit_price == pytest.approx(0.698)
77 +
78 + mais = products[2].finalize()
79 + assert mais.price == 0.3
80 + assert mais.category == "Fruits et légumes"
81 +
82 +
83 +def test_flipp_external_id_stable():
84 + """Le même produit garde le même uid d'une semaine à l'autre (price_log)."""
85 + con = _wire(RichelieuConnector(), LISTING, ITEMS)
86 + first = {p.uid for p in con.fetch()}
87 + moved = [dict(i, id=i["id"] + 5000, discount=10) for i in ITEMS]
88 + second = {p.uid for p in _wire(RichelieuConnector(), LISTING, moved).fetch()}
89 + assert first == second
90 + assert "richelieu:liberte-yogourt-mediterranee-liberte-500-g-500-g" in first
91 +
92 +
93 +def test_flipp_default_category_per_flyer():
94 + """Rachelle-Béry « Espace Santé » : Bio et santé par défaut."""
95 + listing = {"flyers": [{"id": 8075771, "merchant_id": 3295,
96 + "name": "Espace Santé"}]}
97 + items = [{"id": 9, "name": "CHLOROPHYLLE LAND ART", "brand": "Land Art",
98 + "price": "37.99"},
99 + {"id": 10, "name": "PRODUIT MYSTÈRE", "brand": None,
100 + "price": "9.99"}]
101 + products = _wire(RachelleBeryConnector(), listing, items).fetch()
102 + assert [p.category for p in products] == ["Bio et santé", "Bio et santé"]
103 +
104 +
105 +def test_flipp_price_guardrails():
106 + assert FlippConnector._price("5.99") == 5.99
107 + assert FlippConnector._price("0.3") == 0.3
108 + assert FlippConnector._price(13.49) == 13.49
109 + assert FlippConnector._price("") is None
110 + assert FlippConnector._price(None) is None
111 + assert FlippConnector._price("0") is None
112 + assert FlippConnector._price("abc") is None
113 +
114 +
115 +def test_categorize_word_boundaries():
116 + # « gâteau » ne doit PAS devenir Boissons via le mot « eau »
117 + assert _categorize("GÂTEAUX HOSTESS", "") == "Boulangerie"
118 + # « vinaigre » ne doit PAS devenir Boissons via « vin »
119 + assert _categorize("VINAIGRE DE CIDRE", "") == "Garde-manger"
120 + assert _categorize("BIFTECKS FRANÇAIS ET TOURNEDOS", "") == "Viandes et volailles"
121 + assert _categorize("PRODUIT INCONNU", "") == "Autres"
122