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