Vague 3 : SAQ (API GraphQL directe) + 6 circulaires Flipp (4 pharmacies, Adonis, Avril) + Metro 4 pages allees prioritaires
- saq : connecteur Adobe Live Search (catalog-service.adobe.io, cle publique du storefront) — 3 282 produits prioritaires/sync (vin/spiritueux/mousseux plafonnes, petites categories entieres), prix final+regulier, format ml, pays, cepages, pastille de gout, code SAQ, dispo en ligne, images ; 22 requetes directes, zero Scrapfly. Categorie canonique « Alcool » (ajoutee a normalize.CATEGORIES), exclue du matching inter-bannieres (matching.EXCLUDED_SOURCES — banniere unique, prix provinciaux uniformes). - Flipp : pharmaprix (2170), jean_coutu (2521), brunet (3753), uniprix (3467) — keep_categories=GROCERY_CATEGORIES ne garde que les rayons alimentaires des cahiers de pharmacie (filtre AVANT la fiche item : zero requete pour les items ecartes) ; adonis_flyer (3384) et avril_flyer (3940) completent les catalogues adonis/avril. Testes : 127/44/65/25/389/87 produits. - _flipp : desambiguisation « eau de parfum/toilette/javel », « creme adhesive/corps » (les cahiers pharmacie exposaient ces faux Boissons / Produits laitiers). - metro : pages_override — 4 pages (au lieu de 2) sur fruits-et-legumes, viandes-et-volailles, produits-laitiers-et-oeufs ; 664 -> 1 005 produits. Cout mesure 40-80 credits Scrapfly/page (ASP sans render_js). - Sondes documentees : Costco (recherche montee cote client, API Lucidworks 403 sans cle JS — render_js reste la seule voie) ; Dollarama absent de Flipp. sources.json : 54 -> 61 entrees ; docs regeneres. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 changed files +574 −77
modified
data/sources.json
+79 −2
@@ -200,7 +200,7 @@ | ||
| 200 | 200 | "connector": "costco", |
| 201 | 201 | "status": "actif", |
| 202 | 202 | "region": "Tout le Québec (livraison)", |
| 203 | − "notes": "Épicerie non périssable livrée seulement — le frais est « prix en entrepôt » sans prix en ligne." | |
| 203 | + "notes": "Épicerie non périssable livrée seulement — le frais est « prix en entrepôt » sans prix en ligne. Sonde 2026-08-18 : la recherche costco.ca (CatalogSearch) répond en direct mais les tuiles/prix sont montés côté client ; l'API Lucidworks (search.costco.ca) exige une clé injectée par le JS (403 avec la clé typeahead publique) — pas d'accès JSON sans render_js, le connecteur Scrapfly render_js reste la seule voie." | |
| 204 | 204 | }, |
| 205 | 205 | { |
| 206 | 206 | "id": "adonis", |
@@ -255,7 +255,7 @@ | ||
| 255 | 255 | "connector": null, |
| 256 | 256 | "status": "non connectable", |
| 257 | 257 | "region": "Tout le Québec", |
| 258 | − "notes": "Le rendu public affiche des prix à 0,00 $ (placeholders DoorDash) — aucun prix réel sans session." | |
| 258 | + "notes": "Le rendu public affiche des prix à 0,00 $ (placeholders DoorDash) — aucun prix réel sans session. Vérifié 2026-08-18 : absent aussi de Flipp (aucun merchant Dollarama sur backflipp, H2X 1Y4 et G1R 4P5)." | |
| 259 | 259 | }, |
| 260 | 260 | { |
| 261 | 261 | "id": "lufa", |
@@ -572,6 +572,83 @@ | ||
| 572 | 572 | "status": "actif", |
| 573 | 573 | "region": "Tout le Québec", |
| 574 | 574 | "notes": "Merchant Flipp 2596, flyer « Costco Grocery » (filtre : cahier épicerie seulement). Complète la source « costco »." |
| 575 | + }, | |
| 576 | + { | |
| 577 | + "id": "saq", | |
| 578 | + "name": "SAQ (Société des alcools du Québec)", | |
| 579 | + "url": "https://www.saq.com", | |
| 580 | + "catalog_url": "https://www.saq.com/fr/produits", | |
| 581 | + "tech": "Magento 2 + Adobe Live Search — API GraphQL catalog-service.adobe.io en direct (clé publique du storefront, sans anti-bot)", | |
| 582 | + "connector": "saq", | |
| 583 | + "status": "actif", | |
| 584 | + "region": "Tout le Québec", | |
| 585 | + "notes": "Prix officiels uniformes dans toute la province (monopole d'État). ~3 300 produits prioritaires par sync (vins, spiritueux, mousseux plafonnés ; bière/cidre/porto… couverts en entier), 200/page, ~22 requêtes directes — zéro Scrapfly. Catégorie canonique « Alcool », exclue du matching inter-bannières (bannière unique)." | |
| 586 | + }, | |
| 587 | + { | |
| 588 | + "id": "pharmaprix", | |
| 589 | + "name": "Pharmaprix", | |
| 590 | + "url": "https://www.pharmaprix.ca", | |
| 591 | + "catalog_url": "https://www.pharmaprix.ca/fr/flyer", | |
| 592 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot) — sections alimentaires seulement", | |
| 593 | + "connector": "pharmaprix", | |
| 594 | + "status": "actif", | |
| 595 | + "region": "Tout le Québec", | |
| 596 | + "notes": "Merchant Flipp 2170, flyer « Circulaire » (filtre : écarte « Événement »). Pharmacie : keep_categories ne retient que les rayons épicerie du cahier (lait, œufs, boissons, collations…)." | |
| 597 | + }, | |
| 598 | + { | |
| 599 | + "id": "jean_coutu", | |
| 600 | + "name": "Jean Coutu", | |
| 601 | + "url": "https://www.jeancoutu.com", | |
| 602 | + "catalog_url": "https://www.jeancoutu.com/circulaire/", | |
| 603 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot) — sections alimentaires seulement", | |
| 604 | + "connector": "jean_coutu", | |
| 605 | + "status": "actif", | |
| 606 | + "region": "Tout le Québec", | |
| 607 | + "notes": "Merchant Flipp 2521, flyer « Circulaire hebdomadaire » (filtre : écarte « Promotions » et « Circulaire Santé Beauté »). Pharmacie : keep_categories ne retient que les rayons épicerie." | |
| 608 | + }, | |
| 609 | + { | |
| 610 | + "id": "brunet", | |
| 611 | + "name": "Brunet", | |
| 612 | + "url": "https://www.brunet.ca", | |
| 613 | + "catalog_url": "https://www.brunet.ca/circulaires/", | |
| 614 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot) — sections alimentaires seulement", | |
| 615 | + "connector": "brunet", | |
| 616 | + "status": "actif", | |
| 617 | + "region": "Tout le Québec", | |
| 618 | + "notes": "Merchant Flipp 3753, flyer « Circulaire » (filtre : écarte « Clinique »). Pharmacie : keep_categories ne retient que les rayons épicerie." | |
| 619 | + }, | |
| 620 | + { | |
| 621 | + "id": "uniprix", | |
| 622 | + "name": "Uniprix", | |
| 623 | + "url": "https://www.uniprix.com", | |
| 624 | + "catalog_url": "https://www.uniprix.com/circulaires", | |
| 625 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot) — sections alimentaires seulement", | |
| 626 | + "connector": "uniprix", | |
| 627 | + "status": "actif", | |
| 628 | + "region": "Tout le Québec", | |
| 629 | + "notes": "Merchant Flipp 3467, flyer « Circulaire hebdomadaire » (filtre : écarte « Catalogue » ; « Uniprix Sante » = merchant distinct 6788, non alimentaire). Pharmacie : keep_categories ne retient que les rayons épicerie." | |
| 630 | + }, | |
| 631 | + { | |
| 632 | + "id": "adonis_flyer", | |
| 633 | + "name": "Marché Adonis — Circulaire", | |
| 634 | + "url": "https://www.groupeadonis.ca", | |
| 635 | + "catalog_url": "https://www.groupeadonis.ca/circulaires/", | |
| 636 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)", | |
| 637 | + "connector": "adonis_flyer", | |
| 638 | + "status": "actif", | |
| 639 | + "region": "Grand Montréal et Québec", | |
| 640 | + "notes": "Merchant Flipp 3384, cahiers « Weekly » (plusieurs par semaine — dédoublonnage par slug). Rabais hebdo sans crédit Scrapfly — complète la source « adonis »." | |
| 641 | + }, | |
| 642 | + { | |
| 643 | + "id": "avril_flyer", | |
| 644 | + "name": "Avril Supermarché Santé — Circulaire", | |
| 645 | + "url": "https://avril.ca", | |
| 646 | + "catalog_url": "https://avril.ca/fr/circulaire", | |
| 647 | + "tech": "Circulaire hebdomadaire Flipp — API backflipp.wishabi.com (JSON public, sans anti-bot)", | |
| 648 | + "connector": "avril_flyer", | |
| 649 | + "status": "actif", | |
| 650 | + "region": "Québec, Montréal, Estrie…", | |
| 651 | + "notes": "Merchant Flipp 3940, cahier « Circulaire ». Rabais hebdo sans crédit Scrapfly — complète la source « avril »." | |
| 575 | 652 | } |
| 576 | 653 | ] |
| 577 | 654 | } |
modified
docs/connecteurs/INDEX.md
+10 −10
@@ -1,26 +1,26 @@ | ||
| 1 | 1 | # Food-Ka — Documentation des connecteurs |
| 2 | 2 | |
| 3 | −_Générée le 2026-08-18 03:28 par `scripts/gen_connector_docs.py` (rejouable : `.venv/bin/python3 scripts/gen_connector_docs.py`)._ | |
| 3 | +_Générée le 2026-08-18 06:21 par `scripts/gen_connector_docs.py` (rejouable : `.venv/bin/python3 scripts/gen_connector_docs.py`)._ | |
| 4 | 4 | |
| 5 | 5 | ## Vue d'ensemble |
| 6 | 6 | |
| 7 | −- **Sources recensées** : 54 (registre `data/sources.json`) — dont 50 connectées | |
| 8 | −- **Produits en base** : 57 520 — dont 48 846 actifs | |
| 9 | −- **Groupes inter-bannières** : 1 248 (voir [matching](transverse-matching.md)) | |
| 10 | −- **Produits enrichis Open Food Facts** : 56 (voir [nutrition](transverse-nutrition-off.md)) | |
| 11 | −- **Observations de prix** : 64 077 (voir [price_log](transverse-price-log.md)) | |
| 7 | +- **Sources recensées** : 61 (registre `data/sources.json`) — dont 57 connectées | |
| 8 | +- **Produits en base** : 61 860 — dont 53 421 actifs | |
| 9 | +- **Groupes inter-bannières** : 1 518 (voir [matching](transverse-matching.md)) | |
| 10 | +- **Produits enrichis Open Food Facts** : 246 (voir [nutrition](transverse-nutrition-off.md)) | |
| 11 | +- **Observations de prix** : 68 549 (voir [price_log](transverse-price-log.md)) | |
| 12 | 12 | |
| 13 | 13 | ## Fiches par famille de connecteurs |
| 14 | 14 | |
| 15 | 15 | | Famille | Fiche | Sources | Produits actifs | |
| 16 | 16 | |---|---|---|---| |
| 17 | −| `flipp-proximite` | [flipp-proximite.md](flipp-proximite.md) | 19 | 2 408 | | |
| 18 | −| `flyers-grandes-bannieres` | [flyers-grandes-bannieres.md](flyers-grandes-bannieres.md) | 7 | 3 103 | | |
| 19 | −| `scrapfly-asp` | [scrapfly-asp.md](scrapfly-asp.md) | 7 | 6 034 | | |
| 17 | +| `flipp-proximite` | [flipp-proximite.md](flipp-proximite.md) | 23 | 2 669 | | |
| 18 | +| `flyers-grandes-bannieres` | [flyers-grandes-bannieres.md](flyers-grandes-bannieres.md) | 9 | 3 579 | | |
| 19 | +| `scrapfly-asp` | [scrapfly-asp.md](scrapfly-asp.md) | 7 | 6 590 | | |
| 20 | 20 | | `render-js` | [render-js.md](render-js.md) | 3 | 963 | |
| 21 | 21 | | `shopify` | [shopify.md](shopify.md) | 5 | 29 769 | |
| 22 | 22 | | `woocommerce` | [woocommerce.md](woocommerce.md) | 3 | 4 074 | |
| 23 | −| `html` | [html.md](html.md) | 5 | 1 895 | | |
| 23 | +| `html` | [html.md](html.md) | 6 | 5 177 | | |
| 24 | 24 | | `iga-api` | [iga-api.md](iga-api.md) | 1 | 600 | |
| 25 | 25 | | `non-connectables` | [non-connectables.md](non-connectables.md) | 4 | 0 | |
| 26 | 26 | |
modified
docs/connecteurs/flipp-proximite.md
+12 −8
@@ -4,8 +4,8 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 4 | 4 | |
| 5 | 5 | ## Vue d'ensemble |
| 6 | 6 | |
| 7 | −- **Sources membres** : 19 | |
| 8 | −- **Produits actifs (BD)** : 2 408 | |
| 7 | +- **Sources membres** : 23 | |
| 8 | +- **Produits actifs (BD)** : 2 669 | |
| 9 | 9 | - **Socle commun** : `foodka/connectors/_flipp.py` |
| 10 | 10 | |
| 11 | 11 | ## Mécanique du socle (`_flipp.py`) |
@@ -34,32 +34,36 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 34 | 34 | | `axep` | Axep | 3488 | — | Régions du Québec (proximité, affilié Metro) | 92 | 92 | 2026-08-18 02:42 | OK (92 trouvés) | |
| 35 | 35 | | `bonanza` | Marché Bonanza | 3292 | — | Montréal (épiceries indépendantes) | 82 | 82 | 2026-08-18 02:42 | OK (82 trouvés) | |
| 36 | 36 | | `bonichoix` | Marché Bonichoix | 3566 | — | Québec (épiceries de village, bannière Sobeys) | 213 | 213 | 2026-08-18 02:42 | OK (217 trouvés) | |
| 37 | +| `brunet` | Brunet | 3753 | `circulaire` | Tout le Québec | 65 | 65 | 2026-08-18 05:56 | OK (67 trouvés) | | |
| 37 | 38 | | `euro_marche` | Euro Marché | 3300 | — | Montréal (épiceries européennes) | 71 | 71 | 2026-08-18 02:48 | OK (71 trouvés) | |
| 38 | 39 | | `fu_tai` | Marché Fu Tai | 5719 | — | Grand Montréal (supermarché asiatique) | 79 | 79 | 2026-08-18 02:48 | OK (79 trouvés) | |
| 39 | 40 | | `inter_marche` | L'Inter-Marché | 3293 | — | Québec, Saguenay, Mauricie, Estrie, Outaouais (affilié Loblaw) | 92 | 92 | 2026-08-18 02:51 | OK (92 trouvés) | |
| 40 | 41 | | `inter_marche_intl` | L'Inter-Marché International | 4642 | — | Grand Montréal (volet international) | 102 | 102 | 2026-08-18 02:51 | OK (102 trouvés) | |
| 42 | +| `jean_coutu` | Jean Coutu | 2521 | `hebdomadaire` | Tout le Québec | 44 | 44 | 2026-08-18 05:56 | OK (46 trouvés) | | |
| 41 | 43 | | `kim_phat` | Kim Phat | 3290 | — | Grand Montréal | 143 | 143 | 2026-08-18 02:51 | OK (163 trouvés) | |
| 42 | 44 | | `marche_ami` | Marché Ami | 3761 | — | Québec (villages et quartiers, affiliés Metro) | 174 | 174 | 2026-08-18 02:53 | OK (178 trouvés) | |
| 43 | 45 | | `marche_ct` | Marché C&T | 3288 | — | Grand Montréal (supermarchés asiatiques) | 99 | 99 | 2026-08-18 02:55 | OK (204 trouvés) | |
| 44 | 46 | | `marche_vegetarien` | Le Marché Végétarien | 4795 | — | Mauricie et Centre-du-Québec (épiceries santé) | 51 | 51 | 2026-08-18 02:55 | OK (51 trouvés) | |
| 45 | 47 | | `pa_nature` | PA Nature | 3287 | `nature` | Montréal (volet bio/santé du Supermarché PA) | 60 | 60 | 2026-08-18 03:03 | OK (60 trouvés) | |
| 46 | 48 | | `pasquier` | Pasquier | 3791 | — | Saint-Jean-sur-Richelieu et Montérégie | 327 | 327 | 2026-08-18 03:07 | OK (332 trouvés) | |
| 49 | +| `pharmaprix` | Pharmaprix | 2170 | `circulaire` | Tout le Québec | 127 | 127 | 2026-08-18 05:56 | OK (127 trouvés) | | |
| 47 | 50 | | `rachelle_bery` | Rachelle-Béry | 3295 | — | Montréal et environs | 122 | 122 | 2026-08-18 03:12 | OK (144 trouvés) | |
| 48 | 51 | | `richelieu` | Marché Richelieu | 3372 | — | Québec (épiceries de proximité affiliées Metro) | 227 | 227 | 2026-08-18 03:14 | OK (232 trouvés) | |
| 49 | 52 | | `tradition` | Les Marchés Tradition | 4780 | — | Québec (épiceries de quartier, bannière Sobeys) | 261 | 261 | 2026-08-18 03:21 | OK (267 trouvés) | |
| 53 | +| `uniprix` | Uniprix | 3467 | `hebdomadaire` | Tout le Québec | 25 | 25 | 2026-08-18 05:56 | OK (25 trouvés) | | |
| 50 | 54 | | `val_mont` | Marché Val-Mont | 3296 | — | Rive-Nord de Montréal (fruiteries-épiceries) | 51 | 51 | 2026-08-18 03:22 | OK (51 trouvés) | |
| 51 | 55 | |
| 52 | −## Complétude des champs (produits actifs, N = 2 408) | |
| 56 | +## Complétude des champs (produits actifs, N = 2 669) | |
| 53 | 57 | |
| 54 | 58 | | Champ | % rempli | |
| 55 | 59 | |---|---| |
| 56 | 60 | | Prix | 100.0 % | |
| 57 | −| Prix régulier | 33.8 % | | |
| 58 | −| Format (size_label) | 7.8 % | | |
| 59 | −| Prix unitaire | 7.8 % | | |
| 60 | −| Marque | 69.7 % | | |
| 61 | +| Prix régulier | 32.0 % | | |
| 62 | +| Format (size_label) | 10.0 % | | |
| 63 | +| Prix unitaire | 10.0 % | | |
| 64 | +| Marque | 69.0 % | | |
| 61 | 65 | | Images | 100.0 % | |
| 62 | −| Nutrition OFF (details.off) | 0.2 % | | |
| 66 | +| Nutrition OFF (details.off) | 2.7 % | | |
| 63 | 67 | |
| 64 | 68 | ## Gotchas |
| 65 | 69 | |
modified
docs/connecteurs/flyers-grandes-bannieres.md
+11 −9
@@ -4,8 +4,8 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 4 | 4 | |
| 5 | 5 | ## Vue d'ensemble |
| 6 | 6 | |
| 7 | −- **Sources membres** : 7 | |
| 8 | −- **Produits actifs (BD)** : 3 103 | |
| 7 | +- **Sources membres** : 9 | |
| 8 | +- **Produits actifs (BD)** : 3 579 | |
| 9 | 9 | - **Socle commun** : `foodka/connectors/_flipp.py` |
| 10 | 10 | |
| 11 | 11 | ## Mécanique du socle (`_flipp.py`) |
@@ -29,25 +29,27 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 29 | 29 | |
| 30 | 30 | | Source | Nom | merchant_id | flyer_name_filter | Région | Produits actifs | En rabais | Dernier sync | Statut | |
| 31 | 31 | |---|---|---|---|---|---|---|---|---| |
| 32 | +| `adonis_flyer` | Marché Adonis — Circulaire | 3384 | — | Grand Montréal et Québec | 389 | 389 | 2026-08-18 05:56 | OK (418 trouvés) | | |
| 33 | +| `avril_flyer` | Avril Supermarché Santé — Circulaire | 3940 | — | Québec, Montréal, Estrie… | 87 | 87 | 2026-08-18 05:56 | OK (87 trouvés) | | |
| 32 | 34 | | `costco_flyer` | Costco — Circulaire épicerie | 2596 | `grocery` | Tout le Québec | 84 | 84 | 2026-08-18 02:48 | OK (86 trouvés) | |
| 33 | 35 | | `iga_flyer` | IGA — Circulaire | 4592 | `circulaire` | Tout le Québec | 682 | 682 | 2026-08-18 02:51 | OK (768 trouvés) | |
| 34 | 36 | | `maxi_flyer` | Maxi — Circulaire | 2349 | `weekly` | Tout le Québec | 414 | 414 | 2026-08-18 03:01 | OK (428 trouvés) | |
| 35 | 37 | | `metro_flyer` | Metro — Circulaire | 2269 | `quebec` | Tout le Québec | 569 | 569 | 2026-08-18 03:03 | OK (604 trouvés) | |
| 36 | 38 | | `provigo_flyer` | Provigo — Circulaire | 2338 | `weekly` | Tout le Québec | 318 | 318 | 2026-08-18 03:11 | OK (318 trouvés) | |
| 37 | 39 | | `superc_flyer` | Super C — Circulaire | 2585 | `circulaire` | Tout le Québec | 524 | 524 | 2026-08-18 03:16 | OK (554 trouvés) | |
| 38 | −| `walmart_flyer` | Walmart — Circulaire | 234 | `circulaire` | Tout le Québec | 512 | 512 | 2026-08-18 02:35 | OK (633 trouvés) | | |
| 40 | +| `walmart_flyer` | Walmart — Circulaire | 234 | `circulaire` | Tout le Québec | 512 | 512 | 2026-08-18 03:28 | OK (633 trouvés) | | |
| 39 | 41 | |
| 40 | −## Complétude des champs (produits actifs, N = 3 103) | |
| 42 | +## Complétude des champs (produits actifs, N = 3 579) | |
| 41 | 43 | |
| 42 | 44 | | Champ | % rempli | |
| 43 | 45 | |---|---| |
| 44 | 46 | | Prix | 100.0 % | |
| 45 | −| Prix régulier | 17.3 % | | |
| 46 | −| Format (size_label) | 16.4 % | | |
| 47 | −| Prix unitaire | 16.3 % | | |
| 48 | −| Marque | 49.0 % | | |
| 47 | +| Prix régulier | 18.3 % | | |
| 48 | +| Format (size_label) | 14.2 % | | |
| 49 | +| Prix unitaire | 14.2 % | | |
| 50 | +| Marque | 51.3 % | | |
| 49 | 51 | | Images | 100.0 % | |
| 50 | −| Nutrition OFF (details.off) | 0.2 % | | |
| 52 | +| Nutrition OFF (details.off) | 1.1 % | | |
| 51 | 53 | |
| 52 | 54 | ## Gotchas |
| 53 | 55 | |
modified
docs/connecteurs/html.md
+31 −10
@@ -1,11 +1,11 @@ | ||
| 1 | −# Famille `html` — Catalogues HTML rendus serveur — requests direct | |
| 1 | +# Famille `html` — Catalogues HTML rendus serveur ou API directe — requests | |
| 2 | 2 | |
| 3 | 3 | _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._ |
| 4 | 4 | |
| 5 | 5 | ## Vue d'ensemble |
| 6 | 6 | |
| 7 | −- **Sources membres** : 5 | |
| 8 | −- **Produits actifs (BD)** : 1 895 | |
| 7 | +- **Sources membres** : 6 | |
| 8 | +- **Produits actifs (BD)** : 5 177 | |
| 9 | 9 | ## Sources membres (BD live) |
| 10 | 10 | |
| 11 | 11 | | Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut | |
@@ -14,23 +14,25 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 14 | 14 | | `avril` | Avril Supermarché Santé | Magento 2 — prix rendus serveur | Québec, Montréal, Estrie… | 396 | 84 | 2026-08-18 02:42 | OK (376 trouvés) | |
| 15 | 15 | | `maturin` | Maturin | SSR sur mesure — HTML avec prix | Tout le Québec (producteurs d'ici) | 393 | 0 | 2026-08-18 02:56 | OK (381 trouvés) | |
| 16 | 16 | | `mayrand` | Mayrand | HubSpot CMS — HTML statique avec prix | Grand Montréal | 450 | 55 | 2026-08-18 03:01 | OK (450 trouvés) | |
| 17 | +| `saq` | SAQ (Société des alcools du Québec) | Magento 2 + Adobe Live Search — API GraphQL catalog-service.adobe.io en direct (clé publique du storefront, sans anti-bot) | Tout le Québec | 3282 | 22 | 2026-08-18 05:56 | OK (3282 trouvés) | | |
| 17 | 18 | | `tau` | Marché Tau | k-eCommerce — fiches produit rendues serveur (sitemap) | Grand Montréal | 180 | 0 | 2026-08-18 03:19 | OK (180 trouvés) | |
| 18 | 19 | |
| 19 | −## Complétude des champs (produits actifs, N = 1 895) | |
| 20 | +## Complétude des champs (produits actifs, N = 5 177) | |
| 20 | 21 | |
| 21 | 22 | | Champ | % rempli | |
| 22 | 23 | |---|---| |
| 23 | 24 | | Prix | 100.0 % | |
| 24 | −| Prix régulier | 9.2 % | | |
| 25 | −| Format (size_label) | 99.9 % | | |
| 26 | −| Prix unitaire | 95.9 % | | |
| 27 | −| Marque | 94.7 % | | |
| 28 | −| Images | 99.5 % | | |
| 29 | −| Nutrition OFF (details.off) | 0.1 % | | |
| 25 | +| Prix régulier | 3.8 % | | |
| 26 | +| Format (size_label) | 99.3 % | | |
| 27 | +| Prix unitaire | 97.8 % | | |
| 28 | +| Marque | 97.4 % | | |
| 29 | +| Images | 93.6 % | | |
| 30 | +| Nutrition OFF (details.off) | 0.0 % | | |
| 30 | 31 | |
| 31 | 32 | ## Gotchas |
| 32 | 33 | |
| 33 | 34 | - Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, Magento 2, k-eCommerce, SSR maison). |
| 35 | +- SAQ : API GraphQL Adobe Live Search (catalog-service.adobe.io) appelée en direct avec la clé publique du storefront — tout est dans la réponse liste, aucune page détail ; catégorie unique « Alcool », exclue du matching (matching.EXCLUDED_SOURCES). | |
| 34 | 36 | |
| 35 | 37 | ## Mécanique par source (en-têtes des modules) |
| 36 | 38 | |
@@ -70,6 +72,25 @@ Plafonds/env : — | ||
| 70 | 72 | > produits côté serveur (la pagination est purement JavaScript) — un seul GET |
| 71 | 73 | > par rayon suffit. Cartes : .product-card-wrapper (prix, format, image, SKU). |
| 72 | 74 | |
| 75 | +### `saq` — SAQ (Société des alcools du Québec) | |
| 76 | + | |
| 77 | +Plafonds/env : `FOODKA_SAQ_MAX_PRODUCTS` | |
| 78 | + | |
| 79 | +> Prix officiels UNIFORMES dans toute la province (monopole d'État) : une seule | |
| 80 | +> « succursale » suffit. Magento 2 + Adobe Live Search : la grille produit est | |
| 81 | +> montée côté client, mais l'API GraphQL du catalogue | |
| 82 | +> (catalog-service.adobe.io/graphql) répond en DIRECT — clé publique du | |
| 83 | +> storefront (X-Api-Key: search_gql + Magento-Environment-Id lu sur la page). | |
| 84 | +> productSearch paginé 200/page, TOUT dans la réponse liste (prix final et | |
| 85 | +> régulier, format ml, pays, cépages, pastille de goût, dispo en ligne, | |
| 86 | +> image) : zéro Scrapfly, zéro page détail. ~22 requêtes/sync. | |
| 87 | +> Cap ~3 300 produits : premières pages de chaque catégorie dans l'ordre de | |
| 88 | +> pertinence du site (produits mis en avant = les plus populaires) ; le vin | |
| 89 | +> (20 000+ réfs) et les spiritueux (3 300) sont plafonnés, les petites | |
| 90 | +> catégories (bière, cidre…) sont couvertes en entier. | |
| 91 | +> Catégorie canonique unique « Alcool » (exclue du matching inter-bannières — | |
| 92 | +> voir matching.EXCLUDED_SOURCES). | |
| 93 | + | |
| 73 | 94 | ### `tau` — Marché Tau |
| 74 | 95 | |
| 75 | 96 | Plafonds/env : — |
modified
docs/connecteurs/non-connectables.md
+1 −1
@@ -11,6 +11,6 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 11 | 11 | | Source | Nom | Tech | Pourquoi non connectable | |
| 12 | 12 | |---|---|---|---| |
| 13 | 13 | | `bulk_barn` | Bulk Barn | Catalogue sans prix | Le catalogue public n'affiche aucun prix ; commande seulement via Instacart. | |
| 14 | −| `dollarama` | Dollarama | Marque blanche DoorDash — prix par magasin après session | Le rendu public affiche des prix à 0,00 $ (placeholders DoorDash) — aucun prix réel sans session. | | |
| 14 | +| `dollarama` | Dollarama | Marque blanche DoorDash — prix par magasin après session | Le rendu public affiche des prix à 0,00 $ (placeholders DoorDash) — aucun prix réel sans session. Vérifié 2026-08-18 : absent aussi de Flipp (aucun merchant Dollarama sur backflipp, H2X 1Y4 et G1R 4P5). | | |
| 15 | 15 | | `frenco` | Frenco | Vitrine GoDaddy — API boutique en 403 | L'API GoDaddy de la boutique refuse toute requête publique (403) et la page rendue n'affiche aucun prix. | |
| 16 | 16 | | `lufa` | Fermes Lufa | Catalogue lié à une session client | Le marché complet exige un compte (endpoints /superMarket/* de session) — à réévaluer. | |
modified
docs/connecteurs/scrapfly-asp.md
+11 −8
@@ -5,30 +5,30 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 5 | 5 | ## Vue d'ensemble |
| 6 | 6 | |
| 7 | 7 | - **Sources membres** : 7 |
| 8 | −- **Produits actifs (BD)** : 6 034 | |
| 8 | +- **Produits actifs (BD)** : 6 590 | |
| 9 | 9 | ## Sources membres (BD live) |
| 10 | 10 | |
| 11 | 11 | | Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut | |
| 12 | 12 | |---|---|---|---|---|---|---|---| |
| 13 | 13 | | `club_entrepot` | Club Entrepôt | Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP) | Québec (grossiste Loblaw) | 921 | 49 | 2026-08-18 02:45 | OK (731 trouvés) | |
| 14 | 14 | | `maxi` | Maxi | Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP) | Tout le Québec | 1164 | 151 | 2026-08-18 03:01 | OK (866 trouvés) | |
| 15 | −| `metro` | Metro | HTML rendu serveur derrière Cloudflare — Scrapfly (ASP) | Tout le Québec | 838 | 241 | 2026-08-18 03:02 | OK (664 trouvés) | | |
| 15 | +| `metro` | Metro | HTML rendu serveur derrière Cloudflare — Scrapfly (ASP) | Tout le Québec | 1394 | 425 | 2026-08-18 06:09 | OK (1005 trouvés) | | |
| 16 | 16 | | `provigo` | Provigo | Next.js (__NEXT_DATA__) derrière Akamai — Scrapfly (ASP) | Tout le Québec | 1101 | 94 | 2026-08-18 03:11 | OK (753 trouvés) | |
| 17 | 17 | | `superc` | Super C | Même plateforme que Metro — Scrapfly (ASP) | Tout le Québec | 810 | 225 | 2026-08-18 03:16 | OK (735 trouvés) | |
| 18 | 18 | | `tt` | T&T Supermarket | Magento GraphQL derrière Akamai — Scrapfly (ASP) | Montréal (Saint-Laurent) | 400 | 35 | 2026-08-18 03:22 | OK (400 trouvés) | |
| 19 | 19 | | `walmart` | Walmart Canada | Next.js derrière Akamai + PerimeterX — Scrapfly (ASP) | Tout le Québec | 800 | 165 | 2026-08-18 03:26 | OK (681 trouvés) | |
| 20 | 20 | |
| 21 | −## Complétude des champs (produits actifs, N = 6 034) | |
| 21 | +## Complétude des champs (produits actifs, N = 6 590) | |
| 22 | 22 | |
| 23 | 23 | | Champ | % rempli | |
| 24 | 24 | |---|---| |
| 25 | −| Prix | 99.5 % | | |
| 26 | −| Prix régulier | 15.2 % | | |
| 25 | +| Prix | 99.0 % | | |
| 26 | +| Prix régulier | 16.3 % | | |
| 27 | 27 | | Format (size_label) | 90.9 % | |
| 28 | −| Prix unitaire | 88.6 % | | |
| 28 | +| Prix unitaire | 86.9 % | | |
| 29 | 29 | | Marque | 77.4 % | |
| 30 | −| Images | 89.4 % | | |
| 31 | −| Nutrition OFF (details.off) | 0.5 % | | |
| 30 | +| Images | 87.9 % | | |
| 31 | +| Nutrition OFF (details.off) | 1.3 % | | |
| 32 | 32 | |
| 33 | 33 | ## Gotchas |
| 34 | 34 | |
@@ -54,6 +54,9 @@ Plafonds/env : — | ||
| 54 | 54 | Plafonds/env : — |
| 55 | 55 | |
| 56 | 56 | > Anti-bot agressif -> Scrapfly (voir _metro.py) |
| 57 | +> Allées prioritaires (fruits-légumes, viandes, produits laitiers) : 4 pages | |
| 58 | +> au lieu de 2 (~90 produits de plus par allée). Coût mesuré : 40-80 crédits | |
| 59 | +> Scrapfly/page (ASP sans render_js) -> +6 pages ≈ +360 crédits/sync. | |
| 57 | 60 | |
| 58 | 61 | ### `provigo` — Provigo |
| 59 | 62 | |
modified
docs/connecteurs/shopify.md
+1 −1
@@ -35,7 +35,7 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas édite | ||
| 35 | 35 | | Prix unitaire | 24.0 % | |
| 36 | 36 | | Marque | 100.0 % | |
| 37 | 37 | | Images | 98.8 % | |
| 38 | −| Nutrition OFF (details.off) | 0.0 % | | |
| 38 | +| Nutrition OFF (details.off) | 0.2 % | | |
| 39 | 39 | |
| 40 | 40 | ## Gotchas |
| 41 | 41 | |
modified
docs/connecteurs/transverse-matching.md
+20 −18
@@ -20,8 +20,8 @@ _Généré automatiquement par `scripts/gen_connector_docs.py`._ | ||
| 20 | 20 | |
| 21 | 21 | ## État live |
| 22 | 22 | |
| 23 | −- **Groupes de produits identiques** : 1 248 | |
| 24 | −- **Produits rapprochés** : 2 993 | |
| 23 | +- **Groupes de produits identiques** : 1 518 | |
| 24 | +- **Produits rapprochés** : 3 638 | |
| 25 | 25 | - **Règles conservatrices** : marque non vide et strictement égale ; format identique (quantité + unité de base) ; similarité de noms nettoyés ≥ 0,86 (0,95 sans format) ; un groupe couvre ≥ 2 bannières distinctes (`*_flyer` = même bannière que son catalogue). |
| 26 | 26 | - **Garde-fous perf** : MAX_BLOCK 3000, comparaisons en fenêtre triée de 30 (coût linéaire ; rebuild mesuré à ~7 s). |
| 27 | 27 | |
@@ -29,29 +29,31 @@ _Généré automatiquement par `scripts/gen_connector_docs.py`._ | ||
| 29 | 29 | |
| 30 | 30 | | Produits par groupe | Groupes | |
| 31 | 31 | |---|---| |
| 32 | −| 2 | 929 | | |
| 33 | −| 3 | 225 | | |
| 34 | −| 4 | 55 | | |
| 32 | +| 2 | 1127 | | |
| 33 | +| 3 | 278 | | |
| 34 | +| 4 | 67 | | |
| 35 | 35 | | 5 | 22 | |
| 36 | −| 6 | 11 | | |
| 37 | −| 8 | 2 | | |
| 38 | −| 10 | 2 | | |
| 39 | −| 13 | 1 | | |
| 40 | −| 15 | 1 | | |
| 36 | +| 6 | 13 | | |
| 37 | +| 7 | 5 | | |
| 38 | +| 8 | 1 | | |
| 39 | +| 9 | 2 | | |
| 40 | +| 10 | 1 | | |
| 41 | +| 11 | 1 | | |
| 42 | +| 12 | 1 | | |
| 41 | 43 | |
| 42 | 44 | ## Sources les plus rapprochées |
| 43 | 45 | |
| 44 | 46 | | Source | Produits dans un groupe | |
| 45 | 47 | |---|---| |
| 46 | −| `provigo` | 524 | | |
| 47 | −| `maxi` | 498 | | |
| 48 | −| `club_entrepot` | 279 | | |
| 49 | −| `superc` | 217 | | |
| 50 | −| `metro` | 199 | | |
| 51 | −| `nuvo` | 158 | | |
| 52 | −| `iga_flyer` | 98 | | |
| 48 | +| `maxi` | 675 | | |
| 49 | +| `provigo` | 638 | | |
| 50 | +| `club_entrepot` | 345 | | |
| 51 | +| `metro` | 301 | | |
| 52 | +| `superc` | 291 | | |
| 53 | +| `nuvo` | 169 | | |
| 54 | +| `iga_flyer` | 101 | | |
| 55 | +| `pa` | 99 | | |
| 53 | 56 | | `tradition` | 96 | |
| 54 | −| `pa` | 95 | | |
| 55 | 57 | | `aliments_merci` | 79 | |
| 56 | 58 | |
| 57 | 59 | Reconstruit après chaque cycle d'ingestion (table repartie de zéro : idempotent). |
modified
docs/connecteurs/transverse-nutrition-off.md
+2 −2
@@ -26,8 +26,8 @@ _Généré automatiquement par `scripts/gen_connector_docs.py`._ | ||
| 26 | 26 | |
| 27 | 27 | ## État live |
| 28 | 28 | |
| 29 | −- **Clés cherchées (cache)** : 108 — dont 23 correspondances trouvées (21 % de hit) | |
| 30 | −- **Produits actifs enrichis (`details.off`)** : 56 | |
| 29 | +- **Clés cherchées (cache)** : 408 — dont 87 correspondances trouvées (21 % de hit) | |
| 30 | +- **Produits actifs enrichis (`details.off`)** : 246 | |
| 31 | 31 | - **Budget** : 100 requêtes/cycle par défaut, throttle 6 s (limite OFF : 10 recherches/min) + 1 s entre recherche et fiche produit. |
| 32 | 32 | - **Priorisation** : produits avec marque, non encore cherchés (le cache est la vérité : vider `off_cache` pour re-tenter). |
| 33 | 33 | - **Gotcha** : l'ancien `cgi/search.pl` OFF répond 503 (déprécié) — utiliser search.openfoodfacts.org (search-a-licious) + /api/v2/product/{code} pour les ingrédients. |
modified
docs/connecteurs/transverse-price-log.md
+4 −4
@@ -8,7 +8,7 @@ _Généré automatiquement par `scripts/gen_connector_docs.py`._ | ||
| 8 | 8 | |
| 9 | 9 | ## État live |
| 10 | 10 | |
| 11 | −- **Observations** : 64 077 | |
| 12 | −- **Produits suivis** : 57 382 | |
| 13 | −- **Produits avec au moins un changement de prix** : 2 935 | |
| 14 | −- **Période couverte** : 2026-08-12 13:53 → 2026-08-18 03:26 | |
| 11 | +- **Observations** : 68 549 | |
| 12 | +- **Produits suivis** : 61 720 | |
| 13 | +- **Produits avec au moins un changement de prix** : 2 961 | |
| 14 | +- **Période couverte** : 2026-08-12 13:53 → 2026-08-18 06:09 | |
modified
foodka/connectors/_flipp.py
+25 −1
@@ -40,6 +40,9 @@ _SLUG_RE = re.compile(r"[^a-z0-9]+") | ||
| 40 | 40 | # default_category du flyer, sinon « Autres ». |
| 41 | 41 | _NAME_RULES: list[tuple[re.Pattern, str]] = [(re.compile(rf"\b(?:{pat})\b"), cat) for pat, cat in [ |
| 42 | 42 | # désambiguïsations prioritaires |
| 43 | + (r"eaux? de (?:parfum|toilette|cologne)|parfums?|cremes? adhesives?|" | |
| 44 | + r"cremes? (?:pour )?(?:le )?(?:corps|mains?|visage)", "Santé et beauté"), | |
| 45 | + (r"eaux? de javel|javel", "Maison et entretien"), | |
| 43 | 46 | (r"beurres? d.arachides?|tartinades?|vinaigre\w*", "Garde-manger"), |
| 44 | 47 | (r"cremes? glacees?|sorbets?|surgele\w*|congele\w*|pizzas?", "Surgelés"), |
| 45 | 48 | (r"chocolat\w*|croustilles?|chips|biscuits?|bonbons?|craquelins?|" |
@@ -88,6 +91,18 @@ def _categorize(name_fr: str, default: str) -> str: | ||
| 88 | 91 | return default or "Autres" |
| 89 | 92 | |
| 90 | 93 | |
| 94 | +# Rayons « épicerie » de la taxonomie canonique — pour les bannières dont la | |
| 95 | +# circulaire mêle alimentaire et non-alimentaire (pharmacies : cosmétiques, | |
| 96 | +# médicaments…), keep_categories = GROCERY_CATEGORIES ne retient que le | |
| 97 | +# alimentaire. Conservateur : un item non catégorisé (« Autres ») est écarté. | |
| 98 | +GROCERY_CATEGORIES = frozenset({ | |
| 99 | + "Fruits et légumes", "Viandes et volailles", "Poissons et fruits de mer", | |
| 100 | + "Charcuteries et fromages", "Produits laitiers et œufs", "Boulangerie", | |
| 101 | + "Surgelés", "Garde-manger", "Boissons", "Collations et confiseries", | |
| 102 | + "Prêt-à-manger", | |
| 103 | +}) | |
| 104 | + | |
| 105 | + | |
| 91 | 106 | class FlippConnector(BaseConnector): |
| 92 | 107 | """Base des circulaires Flipp — sous-classes : source_id, merchant_id, |
| 93 | 108 | flyer_page (URL humaine de la circulaire, pour le lien produit). |
@@ -104,6 +119,9 @@ class FlippConnector(BaseConnector): | ||
| 104 | 119 | # Si non vide, seuls les flyers dont le nom contient ce texte (insensible |
| 105 | 120 | # à la casse) sont retenus. |
| 106 | 121 | flyer_name_filter: str = "" |
| 122 | + # Si non vide : ne garder que les items dont la catégorie déduite est dans | |
| 123 | + # cet ensemble (ex. GROCERY_CATEGORIES pour les circulaires de pharmacie). | |
| 124 | + keep_categories: frozenset[str] = frozenset() | |
| 107 | 125 | |
| 108 | 126 | # -- récupération ----------------------------------------------------------- |
| 109 | 127 | def _flyers(self) -> list[dict]: |
@@ -156,6 +174,12 @@ class FlippConnector(BaseConnector): | ||
| 156 | 174 | default_cat = self.default_categories.get(flyer_name, "") |
| 157 | 175 | external_id = self._slug(brand, name_fr, size_m.group(1) if size_m else "") |
| 158 | 176 | |
| 177 | + category = _categorize(name_fr, default_cat) | |
| 178 | + # Filtre par rayon AVANT l'appel fiche item : les items écartés | |
| 179 | + # (non alimentaires) ne coûtent aucune requête détail. | |
| 180 | + if self.keep_categories and category not in self.keep_categories: | |
| 181 | + return None | |
| 182 | + | |
| 159 | 183 | # fiche item Flipp (petit texte de la vignette, prix régulier, % de |
| 160 | 184 | # rabais) — mise en cache par id d'item Flipp : l'id change quand la |
| 161 | 185 | # circulaire change, donc un seul appel par item et par semaine. |
@@ -176,7 +200,7 @@ class FlippConnector(BaseConnector): | ||
| 176 | 200 | url=self.flyer_page, |
| 177 | 201 | name=name_fr, |
| 178 | 202 | brand=brand, |
| 179 | − category=_categorize(name_fr, default_cat), | |
| 203 | + category=category, | |
| 180 | 204 | category_raw=flyer_name, |
| 181 | 205 | size_label=size_m.group(1) if size_m else "", |
| 182 | 206 | price=price, |
modified
foodka/connectors/_metro.py
+6 −1
@@ -36,6 +36,10 @@ class MetroPlatformConnector(BaseConnector): | ||
| 36 | 36 | domain: str = "" # ex. "www.metro.ca" |
| 37 | 37 | aisle_prefix: str = "/epicerie-en-ligne/allees" # superc : "/allees" |
| 38 | 38 | pages_per_aisle: int = 2 # ~45 produits/page |
| 39 | + # Approfondissement ciblé : {slug d'allée: nb de pages} — les allées | |
| 40 | + # absentes gardent pages_per_aisle (coût Scrapfly mesuré : 40-80 | |
| 41 | + # crédits/page ASP sans render_js). | |
| 42 | + pages_override: dict[str, int] = {} | |
| 39 | 43 | |
| 40 | 44 | def _parse_tiles(self, html: str, category: str) -> list[Product]: |
| 41 | 45 | soup = BeautifulSoup(html, "html.parser") |
@@ -94,7 +98,8 @@ class MetroPlatformConnector(BaseConnector): | ||
| 94 | 98 | products: list[Product] = [] |
| 95 | 99 | seen: set[str] = set() |
| 96 | 100 | for slug, category in AISLES: |
| 97 | − for page in range(1, self.pages_per_aisle + 1): | |
| 101 | + pages = self.pages_override.get(slug, self.pages_per_aisle) | |
| 102 | + for page in range(1, pages + 1): | |
| 98 | 103 | suffix = slug if page == 1 else f"{slug}-page-{page}" |
| 99 | 104 | url = f"https://{self.domain}{self.aisle_prefix}/{suffix}" |
| 100 | 105 | try: |
added
foodka/connectors/adonis_flyer.py
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Marché Adonis (épiceries méditerranéennes) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Complète la source « adonis » (catalogue Instacart en marque blanche) : ici, | |
| 6 | +# les RABAIS papier de la semaine — merchant 3384, cahiers « Weekly » | |
| 7 | +# (plusieurs cahiers par semaine : le dédoublonnage par slug garde le prix de | |
| 8 | +# la semaine courante). | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +from ._flipp import FlippConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class AdonisFlyerConnector(FlippConnector): | |
| 14 | + source_id = "adonis_flyer" | |
| 15 | + merchant_id = 3384 # Marché Adonis | |
| 16 | + flyer_page = "https://www.groupeadonis.ca/circulaires/" | |
added
foodka/connectors/avril_flyer.py
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Avril Supermarché Santé | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Complète la source « avril » (catalogue Magento avril.ca) : ici, les RABAIS | |
| 6 | +# papier de la semaine — merchant 3940, cahier « Circulaire ». Beaucoup de | |
| 7 | +# produits bio/santé : catégorie par défaut « Bio et santé » quand le nom ne | |
| 8 | +# suffit pas à catégoriser. | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +from ._flipp import FlippConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class AvrilFlyerConnector(FlippConnector): | |
| 14 | + source_id = "avril_flyer" | |
| 15 | + merchant_id = 3940 # Avril Supermarché Santé | |
| 16 | + flyer_page = "https://avril.ca/fr/circulaire" | |
| 17 | + default_categories = {"Circulaire": "Bio et santé"} | |
added
foodka/connectors/brunet.py
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Brunet (pharmacies, groupe Metro) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Pharmacie : keep_categories = GROCERY_CATEGORIES ne retient que les rayons | |
| 6 | +# alimentaires du cahier. Merchant 3753, filtre « circulaire » (écarte le | |
| 7 | +# cahier « Clinique »). | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from ._flipp import GROCERY_CATEGORIES, FlippConnector | |
| 10 | + | |
| 11 | + | |
| 12 | +class BrunetConnector(FlippConnector): | |
| 13 | + source_id = "brunet" | |
| 14 | + merchant_id = 3753 # Brunet | |
| 15 | + flyer_name_filter = "circulaire" # écarte « Clinique » | |
| 16 | + flyer_page = "https://www.brunet.ca/circulaires/" | |
| 17 | + keep_categories = GROCERY_CATEGORIES | |
added
foodka/connectors/jean_coutu.py
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Jean Coutu (pharmacies, groupe Metro) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Pharmacie : keep_categories = GROCERY_CATEGORIES ne retient que les rayons | |
| 6 | +# alimentaires du cahier. Merchant 2521, filtre « hebdomadaire » (écarte les | |
| 7 | +# cahiers « Promotions » et « Circulaire Santé Beauté »). | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +from ._flipp import GROCERY_CATEGORIES, FlippConnector | |
| 10 | + | |
| 11 | + | |
| 12 | +class JeanCoutuConnector(FlippConnector): | |
| 13 | + source_id = "jean_coutu" | |
| 14 | + merchant_id = 2521 # Jean Coutu | |
| 15 | + flyer_name_filter = "hebdomadaire" # écarte « Promotions » et « Santé Beauté » | |
| 16 | + flyer_page = "https://www.jeancoutu.com/circulaire/" | |
| 17 | + keep_categories = GROCERY_CATEGORIES | |
modified
foodka/connectors/metro.py
+8 −0
@@ -2,6 +2,9 @@ | ||
| 2 | 2 | # Food-Ka — connecteur Metro (metro.ca) |
| 3 | 3 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | # Anti-bot agressif -> Scrapfly (voir _metro.py) |
| 5 | +# Allées prioritaires (fruits-légumes, viandes, produits laitiers) : 4 pages | |
| 6 | +# au lieu de 2 (~90 produits de plus par allée). Coût mesuré : 40-80 crédits | |
| 7 | +# Scrapfly/page (ASP sans render_js) -> +6 pages ≈ +360 crédits/sync. | |
| 5 | 8 | # ----------------------------------------------------------------------------- |
| 6 | 9 | from ._metro import MetroPlatformConnector |
| 7 | 10 | |
@@ -9,3 +12,8 @@ from ._metro import MetroPlatformConnector | ||
| 9 | 12 | class MetroConnector(MetroPlatformConnector): |
| 10 | 13 | source_id = "metro" |
| 11 | 14 | domain = "www.metro.ca" |
| 15 | + pages_override = { | |
| 16 | + "fruits-et-legumes": 4, | |
| 17 | + "viandes-et-volailles": 4, | |
| 18 | + "produits-laitiers-et-oeufs": 4, | |
| 19 | + } | |
added
foodka/connectors/pharmaprix.py
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Pharmaprix (pharmacies Loblaw au Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Pharmacie : le cahier mêle cosmétiques, médicaments et ÉPICERIE (lait, œufs, | |
| 6 | +# boissons, collations — souvent parmi les meilleurs prix de la semaine) ; | |
| 7 | +# keep_categories = GROCERY_CATEGORIES ne retient que les rayons alimentaires. | |
| 8 | +# Merchant 2170, filtre « circulaire » (écarte les cahiers « Événement »). | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +from ._flipp import GROCERY_CATEGORIES, FlippConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class PharmaprixConnector(FlippConnector): | |
| 14 | + source_id = "pharmaprix" | |
| 15 | + merchant_id = 2170 # Pharmaprix (Shoppers Drug Mart QC) | |
| 16 | + flyer_name_filter = "circulaire" # écarte « Événement » | |
| 17 | + flyer_page = "https://www.pharmaprix.ca/fr/flyer" | |
| 18 | + keep_categories = GROCERY_CATEGORIES | |
added
foodka/connectors/saq.py
+236 −0
@@ -0,0 +1,236 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur SAQ (saq.com — Société des alcools du Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Prix officiels UNIFORMES dans toute la province (monopole d'État) : une seule | |
| 5 | +# « succursale » suffit. Magento 2 + Adobe Live Search : la grille produit est | |
| 6 | +# montée côté client, mais l'API GraphQL du catalogue | |
| 7 | +# (catalog-service.adobe.io/graphql) répond en DIRECT — clé publique du | |
| 8 | +# storefront (X-Api-Key: search_gql + Magento-Environment-Id lu sur la page). | |
| 9 | +# productSearch paginé 200/page, TOUT dans la réponse liste (prix final et | |
| 10 | +# régulier, format ml, pays, cépages, pastille de goût, dispo en ligne, | |
| 11 | +# image) : zéro Scrapfly, zéro page détail. ~22 requêtes/sync. | |
| 12 | +# Cap ~3 300 produits : premières pages de chaque catégorie dans l'ordre de | |
| 13 | +# pertinence du site (produits mis en avant = les plus populaires) ; le vin | |
| 14 | +# (20 000+ réfs) et les spiritueux (3 300) sont plafonnés, les petites | |
| 15 | +# catégories (bière, cidre…) sont couvertes en entier. | |
| 16 | +# Catégorie canonique unique « Alcool » (exclue du matching inter-bannières — | |
| 17 | +# voir matching.EXCLUDED_SOURCES). | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import os | |
| 22 | + | |
| 23 | +from ..schema import Product, clean_text | |
| 24 | +from .base import BaseConnector | |
| 25 | + | |
| 26 | +GRAPHQL_URL = "https://catalog-service.adobe.io/graphql" | |
| 27 | + | |
| 28 | +# En-têtes publics du storefront saq.com (visibles dans la page HTML — | |
| 29 | +# script LiveSearchAutocomplete). Le customer group est celui des invités. | |
| 30 | +STOREFRONT_HEADERS = { | |
| 31 | + "Content-Type": "application/json", | |
| 32 | + "Origin": "https://www.saq.com", | |
| 33 | + "Referer": "https://www.saq.com/", | |
| 34 | + "X-Api-Key": "search_gql", | |
| 35 | + "Magento-Environment-Id": "2ce24571-9db9-4786-84a9-5f129257ccbb", | |
| 36 | + "Magento-Website-Code": "base", | |
| 37 | + "Magento-Store-Code": "main_website_store", | |
| 38 | + "Magento-Store-View-Code": "fr", | |
| 39 | + "Magento-Customer-Group": "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c", | |
| 40 | +} | |
| 41 | + | |
| 42 | +# (categoryPath, nb de pages de 200) — priorité aux familles populaires. | |
| 43 | +# Totaux live 2026-08 : vin 20 421, spiritueux 3 284, mousseux 1 215, | |
| 44 | +# cooler 416, bière 215, cidre 246, porto 227, vin de dessert 150, saké 131, | |
| 45 | +# apéritif 89, hydromel 35, poire 11, non alcoolisé 35. | |
| 46 | +CATEGORY_PAGES: list[tuple[str, int]] = [ | |
| 47 | + ("produits/vin", 5), | |
| 48 | + ("produits/spiritueux", 3), | |
| 49 | + ("produits/champagne-et-mousseux", 2), | |
| 50 | + ("produits/biere", 2), | |
| 51 | + ("produits/cidre", 2), | |
| 52 | + ("produits/cooler-et-cocktail-premixe", 1), | |
| 53 | + ("produits/porto-et-vin-fortifie", 1), | |
| 54 | + ("produits/sake", 1), | |
| 55 | + ("produits/vin-de-dessert", 1), | |
| 56 | + ("produits/aperitif", 1), | |
| 57 | + ("produits/hydromel", 1), | |
| 58 | + ("produits/poire", 1), | |
| 59 | + ("produits/article-non-alcoolise", 1), | |
| 60 | +] | |
| 61 | + | |
| 62 | +PAGE_SIZE = 200 | |
| 63 | +MAX_PRODUCTS = int(os.environ.get("FOODKA_SAQ_MAX_PRODUCTS", "3400")) | |
| 64 | + | |
| 65 | +_QUERY = """ | |
| 66 | +query FoodKa($phrase: String!, $pageSize: Int!, $currentPage: Int!, | |
| 67 | + $filter: [SearchClauseInput!]) { | |
| 68 | + productSearch(phrase: $phrase, page_size: $pageSize, | |
| 69 | + current_page: $currentPage, filter: $filter) { | |
| 70 | + total_count | |
| 71 | + items { | |
| 72 | + productView { | |
| 73 | + sku | |
| 74 | + name | |
| 75 | + urlKey | |
| 76 | + images { url } | |
| 77 | + attributes { name value } | |
| 78 | + ... on SimpleProductView { | |
| 79 | + price { | |
| 80 | + final { amount { value } } | |
| 81 | + regular { amount { value } } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + ... on ComplexProductView { | |
| 85 | + priceRange { | |
| 86 | + minimum { | |
| 87 | + final { amount { value } } | |
| 88 | + regular { amount { value } } | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + } | |
| 95 | +} | |
| 96 | +""" | |
| 97 | + | |
| 98 | +# attributs SAQ conservés dans details (quand présents et non vides) | |
| 99 | +_KEEP_ATTRS = ( | |
| 100 | + "identite_produit", "pays_origine", "region", "appellation", "couleur", | |
| 101 | + "cepage", "millesime_produit", "pastille_gout", "particularite", | |
| 102 | + "designation_reglementee", "taux_alcool", "degre_alcool", | |
| 103 | + "nom_producteur", "availability_front", | |
| 104 | +) | |
| 105 | + | |
| 106 | + | |
| 107 | +class SaqConnector(BaseConnector): | |
| 108 | + source_id = "saq" | |
| 109 | + request_delay = 1.0 # API partagée Adobe : politesse accrue | |
| 110 | + | |
| 111 | + def _search_page(self, category_path: str, page: int) -> dict: | |
| 112 | + payload = { | |
| 113 | + "query": _QUERY, | |
| 114 | + "variables": { | |
| 115 | + "phrase": "", "pageSize": PAGE_SIZE, "currentPage": page, | |
| 116 | + "filter": [{"attribute": "categoryPath", "eq": category_path}], | |
| 117 | + }, | |
| 118 | + } | |
| 119 | + resp = self.post(GRAPHQL_URL, json=payload, headers=STOREFRONT_HEADERS) | |
| 120 | + data = resp.json() | |
| 121 | + if data.get("errors"): | |
| 122 | + raise RuntimeError(f"saq graphql: {data['errors'][:1]}") | |
| 123 | + return (data.get("data") or {}).get("productSearch") or {} | |
| 124 | + | |
| 125 | + def fetch(self) -> list[Product]: | |
| 126 | + products: list[Product] = [] | |
| 127 | + seen: set[str] = set() | |
| 128 | + for category_path, pages in CATEGORY_PAGES: | |
| 129 | + for page in range(1, pages + 1): | |
| 130 | + if len(products) >= MAX_PRODUCTS: | |
| 131 | + return products | |
| 132 | + result = self._search_page(category_path, page) | |
| 133 | + items = result.get("items") or [] | |
| 134 | + if not items: | |
| 135 | + break | |
| 136 | + for item in items: | |
| 137 | + prod = self._to_product(item.get("productView") or {}, | |
| 138 | + category_path) | |
| 139 | + if prod is not None and prod.uid not in seen: | |
| 140 | + seen.add(prod.uid) | |
| 141 | + products.append(prod) | |
| 142 | + total = result.get("total_count") or 0 | |
| 143 | + if page * PAGE_SIZE >= total: | |
| 144 | + break # catégorie épuisée | |
| 145 | + return products | |
| 146 | + | |
| 147 | + # -- extraction d'une fiche productView ----------------------------------- | |
| 148 | + def _to_product(self, view: dict, category_path: str) -> Product | None: | |
| 149 | + sku = clean_text(str(view.get("sku") or "")) | |
| 150 | + name = clean_text(view.get("name") or "") | |
| 151 | + if not sku or not name: | |
| 152 | + return None | |
| 153 | + | |
| 154 | + price, regular = self._prices(view) | |
| 155 | + if price is None: | |
| 156 | + return None # jamais de prix inventé | |
| 157 | + | |
| 158 | + attrs: dict[str, object] = {} | |
| 159 | + for att in view.get("attributes") or []: | |
| 160 | + key, val = att.get("name"), att.get("value") | |
| 161 | + if key and val not in (None, "", []): | |
| 162 | + attrs[key] = val | |
| 163 | + | |
| 164 | + size_label = self._size_label(attrs.get("format_contenant_ml")) | |
| 165 | + availability = attrs.get("availability_front") or [] | |
| 166 | + if isinstance(availability, str): | |
| 167 | + availability = [availability] | |
| 168 | + | |
| 169 | + keywords = [clean_text(str(k)) for k in ( | |
| 170 | + attrs.get("couleur"), attrs.get("pastille_gout"), | |
| 171 | + attrs.get("particularite")) if k] | |
| 172 | + cepages = attrs.get("cepage") | |
| 173 | + if isinstance(cepages, list): | |
| 174 | + keywords += [clean_text(str(c)) for c in cepages if c] | |
| 175 | + elif cepages: | |
| 176 | + keywords.append(clean_text(str(cepages))) | |
| 177 | + | |
| 178 | + details = {"code_saq": sku} | |
| 179 | + for key in _KEEP_ATTRS: | |
| 180 | + if key in attrs: | |
| 181 | + details[key] = attrs[key] | |
| 182 | + details["categorie_saq"] = category_path.split("/", 1)[-1] | |
| 183 | + | |
| 184 | + url_key = clean_text(view.get("urlKey") or "") or sku | |
| 185 | + images = [img.get("url") for img in (view.get("images") or []) | |
| 186 | + if img.get("url")] | |
| 187 | + | |
| 188 | + return Product( | |
| 189 | + source=self.source_id, | |
| 190 | + external_id=sku, # code SAQ | |
| 191 | + url=f"https://www.saq.com/fr/{url_key}", | |
| 192 | + name=name, | |
| 193 | + brand=clean_text(str(attrs.get("nom_producteur") or "")), | |
| 194 | + category="Alcool", | |
| 195 | + category_raw=clean_text(str(attrs.get("identite_produit") or "")), | |
| 196 | + size_label=size_label, | |
| 197 | + price=price, | |
| 198 | + regular_price=regular, | |
| 199 | + price_label=f"{price:.2f} $".replace(".", ","), | |
| 200 | + on_sale=regular is not None and regular > price, | |
| 201 | + in_stock=("En ligne" in availability) or None, | |
| 202 | + keywords=[k for k in keywords if k], | |
| 203 | + details=details, | |
| 204 | + images=[u.replace("http://", "https://") for u in images], | |
| 205 | + ) | |
| 206 | + | |
| 207 | + @staticmethod | |
| 208 | + def _prices(view: dict) -> tuple[float | None, float | None]: | |
| 209 | + """(prix courant, prix régulier si supérieur) — Simple ou Complex.""" | |
| 210 | + node = view.get("price") or (view.get("priceRange") or {}).get("minimum") or {} | |
| 211 | + | |
| 212 | + def amount(key: str) -> float | None: | |
| 213 | + try: | |
| 214 | + value = float(((node.get(key) or {}).get("amount") or {})["value"]) | |
| 215 | + except (KeyError, TypeError, ValueError): | |
| 216 | + return None | |
| 217 | + return value if 0 < value <= 2000 else None | |
| 218 | + | |
| 219 | + final, regular = amount("final"), amount("regular") | |
| 220 | + if regular is not None and final is not None and regular <= final: | |
| 221 | + regular = None | |
| 222 | + return final, regular | |
| 223 | + | |
| 224 | + @staticmethod | |
| 225 | + def _size_label(raw) -> str: | |
| 226 | + """format_contenant_ml « 750 » -> « 750 ml », « 1500 » -> « 1,5 L ».""" | |
| 227 | + try: | |
| 228 | + ml = float(str(raw).strip()) | |
| 229 | + except (TypeError, ValueError): | |
| 230 | + return "" | |
| 231 | + if ml <= 0: | |
| 232 | + return "" | |
| 233 | + if ml >= 1000: | |
| 234 | + litres = ml / 1000 | |
| 235 | + return (f"{litres:g} L").replace(".", ",") | |
| 236 | + return f"{ml:g} ml" | |
added
foodka/connectors/uniprix.py
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — connecteur circulaire Uniprix (pharmacies, groupe McKesson) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# Circulaire hebdomadaire sur Flipp -> API backflipp (voir _flipp.py). | |
| 5 | +# Pharmacie : keep_categories = GROCERY_CATEGORIES ne retient que les rayons | |
| 6 | +# alimentaires du cahier. Merchant 3467, filtre « hebdomadaire » (écarte le | |
| 7 | +# « Catalogue » saisonnier ; le cahier « Uniprix Sante » est un merchant | |
| 8 | +# distinct, 6788, non alimentaire). | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +from ._flipp import GROCERY_CATEGORIES, FlippConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class UniprixConnector(FlippConnector): | |
| 14 | + source_id = "uniprix" | |
| 15 | + merchant_id = 3467 # Uniprix | |
| 16 | + flyer_name_filter = "hebdomadaire" # écarte « Catalogue » | |
| 17 | + flyer_page = "https://www.uniprix.com/circulaires" | |
| 18 | + keep_categories = GROCERY_CATEGORIES | |
modified
foodka/matching.py
+6 −0
@@ -27,6 +27,10 @@ from .normalize import parse_size, strip_accents | ||
| 27 | 27 | # seuils de similarité (SequenceMatcher sur jetons triés) |
| 28 | 28 | SIM_WITH_SIZE = 0.86 # marque + format identiques -> le nom confirme |
| 29 | 29 | SIM_NO_SIZE = 0.95 # aucun format connu -> quasi-identité exigée |
| 30 | +# Sources hors matching épicerie : la SAQ est une bannière unique à prix | |
| 31 | +# provinciaux uniformes — aucun « même produit ailleurs » pertinent, et ses | |
| 32 | +# vins/bières ne doivent pas se rapprocher des rayons boissons des épiceries. | |
| 33 | +EXCLUDED_SOURCES = frozenset({"saq"}) | |
| 30 | 34 | MAX_BLOCK = 3000 # garde-fou : bloc (marque, format) anormalement gros |
| 31 | 35 | # (comparaisons en fenêtre triée de 30 -> coût linéaire ; |
| 32 | 36 | # les marques maison dépassent 400 depuis les circulaires) |
@@ -107,6 +111,8 @@ def rebuild(con: sqlite3.Connection) -> dict: | ||
| 107 | 111 | # blocs de candidats : même marque + même format (clé exacte) |
| 108 | 112 | blocks: dict[tuple[str, str], list[tuple[str, str, str]]] = {} |
| 109 | 113 | for r in rows: |
| 114 | + if r["source"] in EXCLUDED_SOURCES: | |
| 115 | + continue | |
| 110 | 116 | bk = brand_key(r["brand"]) |
| 111 | 117 | if not bk: |
| 112 | 118 | continue |
modified
foodka/normalize.py
+1 −0
@@ -142,6 +142,7 @@ CATEGORIES = [ | ||
| 142 | 142 | "Animaux", |
| 143 | 143 | "Maison et entretien", |
| 144 | 144 | "Santé et beauté", |
| 145 | + "Alcool", | |
| 145 | 146 | "Autres", |
| 146 | 147 | ] |
| 147 | 148 | |
modified
scripts/gen_connector_docs.py
+7 −2
@@ -6,7 +6,7 @@ | ||
| 6 | 6 | # |
| 7 | 7 | # Génère docs/connecteurs/ (INDEX.md + une fiche par FAMILLE de connecteurs + |
| 8 | 8 | # fiches transverses) en croisant : |
| 9 | −# 1. data/sources.json — registre des sources (54 bannières) ; | |
| 9 | +# 1. data/sources.json — registre des sources (61 bannières) ; | |
| 10 | 10 | # 2. le code des connecteurs — familles par héritage de classe |
| 11 | 11 | # (FlippConnector, ShopifyConnector, WooCommerceConnector…), en-têtes |
| 12 | 12 | # « mécanique » des modules, merchant_id/flyer_name_filter des |
@@ -119,11 +119,16 @@ FAMILIES = OrderedDict([ | ||
| 119 | 119 | "plafonnée par garde-fou.", |
| 120 | 120 | ]}), |
| 121 | 121 | ("html", { |
| 122 | − "title": "Catalogues HTML rendus serveur — requests direct", | |
| 122 | + "title": "Catalogues HTML rendus serveur ou API directe — requests", | |
| 123 | 123 | "base": None, |
| 124 | 124 | "gotchas": [ |
| 125 | 125 | "Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, " |
| 126 | 126 | "Magento 2, k-eCommerce, SSR maison).", |
| 127 | + "SAQ : API GraphQL Adobe Live Search " | |
| 128 | + "(catalog-service.adobe.io) appelée en direct avec la clé " | |
| 129 | + "publique du storefront — tout est dans la réponse liste, " | |
| 130 | + "aucune page détail ; catégorie unique « Alcool », exclue du " | |
| 131 | + "matching (matching.EXCLUDED_SOURCES).", | |
| 127 | 132 | ]}), |
| 128 | 133 | ("iga-api", { |
| 129 | 134 | "title": "IGA — API Voilà (Sobeys Québec)", |
| 130 | 135 | |