SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 99ea4f9

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