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%

docs: documentation standardisée des connecteurs (générateur + 9 fiches famille + 3 transverses)

scripts/gen_connector_docs.py croise data/sources.json, l héritage des
classes connecteurs (familles Flipp/Shopify/WooCommerce/Scrapfly…) et la
BD live (volumétrie, complétude, sync_log, product_links, off_cache,
price_log) pour régénérer docs/connecteurs/ (INDEX.md + fiches).

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

14 changed files +1,241 −0

added docs/connecteurs/INDEX.md +41 −0
@@ -0,0 +1,41 @@
1 +# Food-Ka — Documentation des connecteurs
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`)._
4 +
5 +## Vue d'ensemble
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))
12 +
13 +## Fiches par famille de connecteurs
14 +
15 +| Famille | Fiche | Sources | Produits actifs |
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 |
20 +| `render-js` | [render-js.md](render-js.md) | 3 | 963 |
21 +| `shopify` | [shopify.md](shopify.md) | 5 | 29 769 |
22 +| `woocommerce` | [woocommerce.md](woocommerce.md) | 3 | 4 074 |
23 +| `html` | [html.md](html.md) | 5 | 1 895 |
24 +| `iga-api` | [iga-api.md](iga-api.md) | 1 | 600 |
25 +| `non-connectables` | [non-connectables.md](non-connectables.md) | 4 | 0 |
26 +
27 +## Fiches transverses
28 +
29 +| Sujet | Fiche |
30 +|---|---|
31 +| Matching inter-bannières (product_links) | [transverse-matching.md](transverse-matching.md) |
32 +| Nutrition Open Food Facts (off_cache) | [transverse-nutrition-off.md](transverse-nutrition-off.md) |
33 +| Historique de prix (price_log) | [transverse-price-log.md](transverse-price-log.md) |
34 +
35 +## Historique des vagues (2026-08-18)
36 +
37 +| Commit | Contenu |
38 +|---|---|
39 +| `afdb2e6` | Enrichissement connecteurs + robustesse DB (audit 2026-08-18) |
40 +| `a45aeb4` | Vague 2 : circulaires grandes bannières + comparateur inter-bannières + nutrition Open Food Facts |
41 +| `114aa76` | Matching : MAX_BLOCK 400 → 3000 (marques maison des circulaires ; fenêtre triée de 30 = coût linéaire) |
added docs/connecteurs/flipp-proximite.md +77 −0
@@ -0,0 +1,77 @@
1 +# Famille `flipp-proximite` — Circulaires Flipp — épiceries de proximité (catalogue = circulaire)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 19
8 +- **Produits actifs (BD)** : 2 408
9 +- **Socle commun** : `foodka/connectors/_flipp.py`
10 +
11 +## Mécanique du socle (`_flipp.py`)
12 +
13 +> connectors/_flipp.py : socle commun des bannières « circulaire seulement »
14 +> Beaucoup d'épiceries de proximité (affiliés Metro et Sobeys) n'ont aucun
15 +> catalogue en ligne : leur seule donnée de prix est la circulaire hebdo,
16 +> hébergée sur Flipp. Le backend Flipp (backflipp.wishabi.com) est une API
17 +> JSON publique sans clé ni anti-bot :
18 +> 1. /flipp/flyers?locale=fr-ca&postal_code=… -> circulaires de la zone
19 +> (filtrer côté client sur merchant_id — le paramètre d'URL ne filtre pas)
20 +> 2. /flipp/flyers/{flyer_id}?locale=fr-ca -> items avec prix
21 +> Une seule circulaire provinciale par bannière par semaine (vérifié sur
22 +> 4 codes postaux) : un poste montréalais suffit. Le flyer_id change chaque
23 +> semaine — toujours résolu dynamiquement via l'étape 1.
24 +> Les items n'ont ni SKU ni catégorie ni unité (« /lb ») : external_id =
25 +> slug stable marque+nom+format (continuité du price_log d'une semaine à
26 +> l'autre), catégorie déduite du nom du produit.
27 +
28 +## Sources membres (BD live)
29 +
30 +| Source | Nom | merchant_id | flyer_name_filter | Région | Produits actifs | En rabais | Dernier sync | Statut |
31 +|---|---|---|---|---|---|---|---|---|
32 +| `aliments_mm` | Les Aliments M&M | 2024 | — | Tout le Québec (spécialiste du surgelé) | 70 | 70 | 2026-08-18 02:41 | OK (73 trouvés) |
33 +| `aures` | Supermarché Aurès | 6798 | — | Montréal (épiceries maghrébines/méditerranéennes) | 92 | 92 | 2026-08-18 02:42 | OK (93 trouvés) |
34 +| `axep` | Axep | 3488 | — | Régions du Québec (proximité, affilié Metro) | 92 | 92 | 2026-08-18 02:42 | OK (92 trouvés) |
35 +| `bonanza` | Marché Bonanza | 3292 | — | Montréal (épiceries indépendantes) | 82 | 82 | 2026-08-18 02:42 | OK (82 trouvés) |
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 +| `euro_marche` | Euro Marché | 3300 | — | Montréal (épiceries européennes) | 71 | 71 | 2026-08-18 02:48 | OK (71 trouvés) |
38 +| `fu_tai` | Marché Fu Tai | 5719 | — | Grand Montréal (supermarché asiatique) | 79 | 79 | 2026-08-18 02:48 | OK (79 trouvés) |
39 +| `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 +| `inter_marche_intl` | L'Inter-Marché International | 4642 | — | Grand Montréal (volet international) | 102 | 102 | 2026-08-18 02:51 | OK (102 trouvés) |
41 +| `kim_phat` | Kim Phat | 3290 | — | Grand Montréal | 143 | 143 | 2026-08-18 02:51 | OK (163 trouvés) |
42 +| `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 +| `marche_ct` | Marché C&T | 3288 | — | Grand Montréal (supermarchés asiatiques) | 99 | 99 | 2026-08-18 02:55 | OK (204 trouvés) |
44 +| `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 +| `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 +| `pasquier` | Pasquier | 3791 | — | Saint-Jean-sur-Richelieu et Montérégie | 327 | 327 | 2026-08-18 03:07 | OK (332 trouvés) |
47 +| `rachelle_bery` | Rachelle-Béry | 3295 | — | Montréal et environs | 122 | 122 | 2026-08-18 03:12 | OK (144 trouvés) |
48 +| `richelieu` | Marché Richelieu | 3372 | — | Québec (épiceries de proximité affiliées Metro) | 227 | 227 | 2026-08-18 03:14 | OK (232 trouvés) |
49 +| `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) |
50 +| `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 +
52 +## Complétude des champs (produits actifs, N = 2 408)
53 +
54 +| Champ | % rempli |
55 +|---|---|
56 +| 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 +| Images | 100.0 % |
62 +| Nutrition OFF (details.off) | 0.2 % |
63 +
64 +## Gotchas
65 +
66 +- Le paramètre d'URL de l'API backflipp ne filtre PAS par marchand : filtrer côté client sur `merchant_id`.
67 +- `flyer_name_filter` écarte les cahiers parasites du même merchant (ex. Supermarché PA : « Weekly Flyer » vs « Nature Flyer »).
68 +- Tri des circulaires candidates par `valid_from` croissant : en cas de chevauchement (semaine courante + semaine à venir), on prend la courante.
69 +- Les items Flipp n'ont ni SKU, ni catégorie, ni unité : `external_id` = slug stable marque+nom+format (continuité du price_log d'une semaine à l'autre), catégorie déduite du nom.
70 +- Le `flyer_id` change chaque semaine — toujours résolu dynamiquement via /flipp/flyers (une circulaire provinciale par bannière : un poste montréalais suffit).
71 +
72 +## Erreurs de synchronisation récentes (sync_log, ok = 0)
73 +
74 +| Source | Quand | Message |
75 +|---|---|---|
76 +| `pa_nature` | 2026-08-18 01:37 | FlippConnector._fine_print() takes 1 positional argument but 2 were given |
77 +| `rachelle_bery` | 2026-08-18 01:37 | FlippConnector._fine_print() takes 1 positional argument but 2 were given |
added docs/connecteurs/flyers-grandes-bannieres.md +60 −0
@@ -0,0 +1,60 @@
1 +# Famille `flyers-grandes-bannieres` — Circulaires Flipp — grandes bannières (complément rabais du catalogue)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 7
8 +- **Produits actifs (BD)** : 3 103
9 +- **Socle commun** : `foodka/connectors/_flipp.py`
10 +
11 +## Mécanique du socle (`_flipp.py`)
12 +
13 +> connectors/_flipp.py : socle commun des bannières « circulaire seulement »
14 +> Beaucoup d'épiceries de proximité (affiliés Metro et Sobeys) n'ont aucun
15 +> catalogue en ligne : leur seule donnée de prix est la circulaire hebdo,
16 +> hébergée sur Flipp. Le backend Flipp (backflipp.wishabi.com) est une API
17 +> JSON publique sans clé ni anti-bot :
18 +> 1. /flipp/flyers?locale=fr-ca&postal_code=… -> circulaires de la zone
19 +> (filtrer côté client sur merchant_id — le paramètre d'URL ne filtre pas)
20 +> 2. /flipp/flyers/{flyer_id}?locale=fr-ca -> items avec prix
21 +> Une seule circulaire provinciale par bannière par semaine (vérifié sur
22 +> 4 codes postaux) : un poste montréalais suffit. Le flyer_id change chaque
23 +> semaine — toujours résolu dynamiquement via l'étape 1.
24 +> Les items n'ont ni SKU ni catégorie ni unité (« /lb ») : external_id =
25 +> slug stable marque+nom+format (continuité du price_log d'une semaine à
26 +> l'autre), catégorie déduite du nom du produit.
27 +
28 +## Sources membres (BD live)
29 +
30 +| Source | Nom | merchant_id | flyer_name_filter | Région | Produits actifs | En rabais | Dernier sync | Statut |
31 +|---|---|---|---|---|---|---|---|---|
32 +| `costco_flyer` | Costco — Circulaire épicerie | 2596 | `grocery` | Tout le Québec | 84 | 84 | 2026-08-18 02:48 | OK (86 trouvés) |
33 +| `iga_flyer` | IGA — Circulaire | 4592 | `circulaire` | Tout le Québec | 682 | 682 | 2026-08-18 02:51 | OK (768 trouvés) |
34 +| `maxi_flyer` | Maxi — Circulaire | 2349 | `weekly` | Tout le Québec | 414 | 414 | 2026-08-18 03:01 | OK (428 trouvés) |
35 +| `metro_flyer` | Metro — Circulaire | 2269 | `quebec` | Tout le Québec | 569 | 569 | 2026-08-18 03:03 | OK (604 trouvés) |
36 +| `provigo_flyer` | Provigo — Circulaire | 2338 | `weekly` | Tout le Québec | 318 | 318 | 2026-08-18 03:11 | OK (318 trouvés) |
37 +| `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) |
39 +
40 +## Complétude des champs (produits actifs, N = 3 103)
41 +
42 +| Champ | % rempli |
43 +|---|---|
44 +| Prix | 100.0 % |
45 +| Prix régulier | 17.3 % |
46 +| Format (size_label) | 16.4 % |
47 +| Prix unitaire | 16.3 % |
48 +| Marque | 49.0 % |
49 +| Images | 100.0 % |
50 +| Nutrition OFF (details.off) | 0.2 % |
51 +
52 +## Gotchas
53 +
54 +- Chaque source `*_flyer` complète la source catalogue de la même bannière (metro / metro_flyer) : le matching inter-bannières les traite comme UNE bannière.
55 +- `flyer_name_filter` est indispensable ici : les grands merchants publient plusieurs cahiers (Metro « Metrogo! », Costco cahiers non alimentaires, Walmart livrets thématiques…).
56 +- Tri `valid_from` croissant pour choisir la circulaire de la semaine courante ; flyer_id résolu à chaque sync.
57 +
58 +## Erreurs de synchronisation récentes
59 +
60 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/html.md +86 −0
@@ -0,0 +1,86 @@
1 +# Famille `html` — Catalogues HTML rendus serveur — requests direct
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 5
8 +- **Produits actifs (BD)** : 1 895
9 +## Sources membres (BD live)
10 +
11 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
12 +|---|---|---|---|---|---|---|---|
13 +| `aubut` | Aubut | Site sur mesure — HTML avec prix sans connexion | Montréal et Laval | 476 | 35 | 2026-08-18 02:42 | OK (476 trouvés) |
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 +| `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 +| `mayrand` | Mayrand | HubSpot CMS — HTML statique avec prix | Grand Montréal | 450 | 55 | 2026-08-18 03:01 | OK (450 trouvés) |
17 +| `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 +## Complétude des champs (produits actifs, N = 1 895)
20 +
21 +| Champ | % rempli |
22 +|---|---|
23 +| 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 % |
30 +
31 +## Gotchas
32 +
33 +- Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, Magento 2, k-eCommerce, SSR maison).
34 +
35 +## Mécanique par source (en-têtes des modules)
36 +
37 +### `aubut` — Aubut
38 +
39 +Plafonds/env : —
40 +
41 +> Site maison (Vue.js) : les grilles de catégories (/produits/<cat>?p=N,
42 +> 32 tuiles/page) embarquent un composant <app-product-ecommerce> dont
43 +> l'attribut :product contient TOUT le produit en JSON (prix, format, solde,
44 +> inventaire, image) — parsing trivial et fiable.
45 +
46 +### `avril` — Avril Supermarché Santé
47 +
48 +Plafonds/env : —
49 +
50 +> Magento 2 (thème Hyvä/Alpine) : les sous-catégories d'épicerie
51 +> (/fr/epicerie/<sous-cat>.html?p=N, 24 cartes/page) sont rendues côté
52 +> serveur. Cloudflare en façade mais le GET direct passe ; repli Scrapfly
53 +> (ASP) si 403. NB : dans le balisage Avril, .price = prix courant et
54 +> .special-price = prix régulier barré (nommage inversé).
55 +
56 +### `maturin` — Maturin
57 +
58 +Plafonds/env : —
59 +
60 +> Site maison rendu côté serveur : /categorie/<cat>?page=N (24 cartes/page,
61 +> défilement infini côté client = simple paramètre page). Cartes
62 +> .masonry-product : titre, producteur (marque), prix « À partir de X$ / fmt »,
63 +> image /image/crop/....
64 +
65 +### `mayrand` — Mayrand
66 +
67 +Plafonds/env : —
68 +
69 +> HubSpot CMS : chaque page de rayon (/fr/nos-produits/<rayon>) rend TOUS les
70 +> produits côté serveur (la pagination est purement JavaScript) — un seul GET
71 +> par rayon suffit. Cartes : .product-card-wrapper (prix, format, image, SKU).
72 +
73 +### `tau` — Marché Tau
74 +
75 +Plafonds/env : —
76 +
77 +> k-eCommerce : les grilles de catégories sont chargées en AJAX (aucun prix
78 +> dans le HTML), mais les FICHES produit sont rendues côté serveur
79 +> (« 16,99$ CAD », fil d'Ariane, marque, image). Stratégie : sitemap
80 +> kSitemap-1.xml (~14 000 URLs), filtrage des slugs produit (suffixe de
81 +> format « -227gr »), échantillon réparti sur tout le catalogue, puis une
82 +> requête par fiche (throttling de BaseConnector).
83 +
84 +## Erreurs de synchronisation récentes
85 +
86 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/iga-api.md +42 −0
@@ -0,0 +1,42 @@
1 +# Famille `iga-api` — IGA — API Voilà (Sobeys Québec)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 1
8 +- **Produits actifs (BD)** : 600
9 +## Sources membres (BD live)
10 +
11 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
12 +|---|---|---|---|---|---|---|---|
13 +| `iga` | IGA (Voilà) | SPA React (plateforme Voilà/Sobeys) — Scrapfly avec rendu JavaScript | Tout le Québec | 600 | 569 | 2026-08-18 02:49 | OK (600 trouvés) |
14 +
15 +## Complétude des champs (produits actifs, N = 600)
16 +
17 +| Champ | % rempli |
18 +|---|---|
19 +| Prix | 100.0 % |
20 +| Prix régulier | 94.8 % |
21 +| Format (size_label) | 97.2 % |
22 +| Prix unitaire | 91.5 % |
23 +| Marque | 95.2 % |
24 +| Images | 100.0 % |
25 +| Nutrition OFF (details.off) | 0.7 % |
26 +
27 +## Gotchas
28 +
29 +- Voie principale : API REST publique des promotions de voila.ca (JSON complet, curseur, sans anti-bot) ; complément optionnel par recherche SPA rendue via Scrapfly (render_js).
30 +- `regionId` public requis par l'API (région de livraison Québec).
31 +
32 +## Mécanique (`foodka/connectors/iga.py`)
33 +
34 +> iga.net délègue l'épicerie en ligne à voila.ca. Deux voies :
35 +> 1. (principale) API REST publique des promotions — JSON complet avec prix,
36 +> prix promo, format et catégorie, pagination par curseur, sans anti-bot.
37 +> 2. (complément, optionnelle) recherche de la SPA rendue via Scrapfly
38 +> (render_js) pour les produits hors promotion.
39 +
40 +## Erreurs de synchronisation récentes
41 +
42 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/non-connectables.md +16 −0
@@ -0,0 +1,16 @@
1 +# Famille `non-connectables` — Sources recensées non connectables
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 4
8 +
9 +## Sources
10 +
11 +| Source | Nom | Tech | Pourquoi non connectable |
12 +|---|---|---|---|
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. |
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 +| `lufa` | Fermes Lufa | Catalogue lié à une session client | Le marché complet exige un compte (endpoints /superMarket/* de session) — à réévaluer. |
added docs/connecteurs/render-js.md +72 −0
@@ -0,0 +1,72 @@
1 +# Famille `render-js` — Catalogues SPA — Scrapfly avec rendu JavaScript
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 3
8 +- **Produits actifs (BD)** : 963
9 +## Sources membres (BD live)
10 +
11 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
12 +|---|---|---|---|---|---|---|---|
13 +| `adonis` | Marché Adonis | Marque blanche Instacart — Scrapfly avec rendu JavaScript | Grand Montréal et Québec | 513 | 351 | 2026-08-18 02:39 | OK (370 trouvés) |
14 +| `costco` | Costco Canada | Next.js derrière Akamai — Scrapfly avec rendu JavaScript | Tout le Québec (livraison) | 321 | 0 | 2026-08-18 02:48 | OK (314 trouvés) |
15 +| `loco` | Épiceries LOCO | Square Online (SPA) — Scrapfly avec rendu JavaScript | Montréal (zéro déchet) | 129 | 0 | 2026-08-18 02:53 | OK (129 trouvés) |
16 +
17 +## Complétude des champs (produits actifs, N = 963)
18 +
19 +| Champ | % rempli |
20 +|---|---|
21 +| Prix | 100.0 % |
22 +| Prix régulier | 36.4 % |
23 +| Format (size_label) | 81.7 % |
24 +| Prix unitaire | 77.8 % |
25 +| Marque | 0.0 % |
26 +| Images | 100.0 % |
27 +| Nutrition OFF (details.off) | 0.0 % |
28 +
29 +## Gotchas
30 +
31 +- Le rendu JavaScript Scrapfly est encore plus coûteux que l'ASP simple : nombre de pages par catégorie plafonné, syncs espacés.
32 +
33 +## Mécanique par source (en-têtes des modules)
34 +
35 +### `adonis` — Marché Adonis
36 +
37 +Plafonds/env : —
38 +
39 +> Boutique Instacart en marque blanche (SPA React) : rendu JavaScript via
40 +> Scrapfly + attente des cartes produit a[data-item-card-button]. Les prix
41 +> s'affichent sans session (adresse de livraison par défaut d'Instacart) —
42 +> prix courant et prix d'origine exposés en texte lecteur d'écran
43 +> (« Current price: … » / « Original Price: … »).
44 +> Collections : /store/adonis/collections/{slug} (~20 produits rendus/page).
45 +
46 +### `costco` — Costco Canada
47 +
48 +Plafonds/env : —
49 +
50 +> Nouveau catalogue Next.js (App Router) derrière Akamai : les tuiles produit
51 +> sont montées côté client — rendu JavaScript via Scrapfly + attente du
52 +> sélecteur [data-testid^="ProductTile"]. Chaque page feuille (ex. crackers)
53 +> affiche une grille de tuiles avec prix ; les pages « rayon » (ex. snacks)
54 +> ne contiennent que des vignettes de sous-catégories, on cible donc les
55 +> feuilles directement. Numéro d'article Costco = id dans l'URL produit.
56 +> ⚠️ Le frais (produce, dairy, meat…) est « Warehouse Only » : aucun prix en
57 +> ligne (« Warehouse pricing may vary ») — on ne couvre que l'épicerie livrée
58 +> (Costco Grocery, non périssable) et on ignore les tuiles sans prix.
59 +
60 +### `loco` — Épiceries LOCO
61 +
62 +Plafonds/env : —
63 +
64 +> Square Online (SPA Vue) : aucun prix dans le HTML statique ni d'API catalogue
65 +> publique — les grilles /shop/<slug>/<id> sont rendues côté client. Stratégie :
66 +> Scrapfly render_js sur une sélection de catégories alimentaires (?limit=120,
67 +> une page suffit : ~176 produits alimentaires au total), cartes
68 +> div.product-group (titre p.w-product-title, prix « $7.30 »).
69 +
70 +## Erreurs de synchronisation récentes
71 +
72 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/scrapfly-asp.md +92 −0
@@ -0,0 +1,92 @@
1 +# Famille `scrapfly-asp` — Catalogues derrière anti-bot — Scrapfly (ASP)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 7
8 +- **Produits actifs (BD)** : 6 034
9 +## Sources membres (BD live)
10 +
11 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
12 +|---|---|---|---|---|---|---|---|
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 +| `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) |
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 +| `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 +| `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 +| `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 +
21 +## Complétude des champs (produits actifs, N = 6 034)
22 +
23 +| Champ | % rempli |
24 +|---|---|
25 +| Prix | 99.5 % |
26 +| Prix régulier | 15.2 % |
27 +| Format (size_label) | 90.9 % |
28 +| Prix unitaire | 88.6 % |
29 +| Marque | 77.4 % |
30 +| Images | 89.4 % |
31 +| Nutrition OFF (details.off) | 0.5 % |
32 +
33 +## Gotchas
34 +
35 +- Chaque requête passe par Scrapfly en mode ASP (anti-scraping protection) : coût par appel — les plafonds de pagination par allée/catégorie bornent le budget de chaque sync.
36 +- Metro et Super C partagent le socle `_metro.py` (tuiles rendues serveur, pagination « <allée>-page-N ») ; Maxi, Provigo et Club Entrepôt partagent `_loblaw.py` (grille complète dans `__NEXT_DATA__`).
37 +
38 +## Mécanique par source (en-têtes des modules)
39 +
40 +### `club_entrepot` — Club Entrepôt
41 +
42 +Plafonds/env : —
43 +
44 +> Même plateforme Next.js que Maxi/Provigo -> Scrapfly (voir _loblaw.py)
45 +
46 +### `maxi` — Maxi
47 +
48 +Plafonds/env : —
49 +
50 +> Site Next.js derrière Cloudflare -> Scrapfly (voir _loblaw.py)
51 +
52 +### `metro` — Metro
53 +
54 +Plafonds/env : —
55 +
56 +> Anti-bot agressif -> Scrapfly (voir _metro.py)
57 +
58 +### `provigo` — Provigo
59 +
60 +Plafonds/env : —
61 +
62 +> Site Next.js derrière Cloudflare -> Scrapfly (voir _loblaw.py)
63 +
64 +### `superc` — Super C
65 +
66 +Plafonds/env : —
67 +
68 +> Même plateforme que metro.ca -> Scrapfly (voir _metro.py)
69 +
70 +### `tt` — T&T Supermarket
71 +
72 +Plafonds/env : —
73 +
74 +> Adobe Commerce (Magento PWA) headless derrière Akamai : requêtes directes
75 +> refusées (403), mais l'API GraphQL /graphql répond via Scrapfly ASP en GET
76 +> — SANS rendu JavaScript (1 requête légère par catégorie). Prix courant et
77 +> prix régulier dans price_range.minimum_price, stock, image et url_key.
78 +> Catégories via categoryList (uid base64) ; magasin « default » (EN).
79 +
80 +### `walmart` — Walmart Canada
81 +
82 +Plafonds/env : —
83 +
84 +> Site Next.js derrière Akamai/PerimeterX (curl direct → HTTP 418) : on lit la
85 +> grille produits embarquée dans <script id="__NEXT_DATA__"> via Scrapfly ASP
86 +> (pas de rendu JavaScript nécessaire — le JSON est côté serveur).
87 +> URLs de rayon : /fr/browse/epicerie/{slug}/10019_{id} — seul l'id compte,
88 +> le slug est décoratif ; pagination par ?page=N (55 produits/page).
89 +
90 +## Erreurs de synchronisation récentes
91 +
92 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/shopify.md +47 −0
@@ -0,0 +1,47 @@
1 +# Famille `shopify` — Boutiques Shopify — /products.json ouvert
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 5
8 +- **Produits actifs (BD)** : 29 769
9 +- **Socle commun** : `foodka/connectors/_shopify.py`
10 +
11 +## Mécanique du socle (`_shopify.py`)
12 +
13 +> connectors/_shopify.py : socle commun des épiceries sur Shopify
14 +> Toute boutique Shopify expose son catalogue public en JSON :
15 +> /products.json?limit=250&page=N (ou /collections/{handle}/products.json
16 +> pour cibler des rayons précis). Aucun anti-bot, requests direct suffit.
17 +
18 +## Sources membres (BD live)
19 +
20 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
21 +|---|---|---|---|---|---|---|---|
22 +| `boite_a_grains` | La Boîte à Grains | Shopify — /products.json ouvert | Outaouais | 6000 | 741 | 2026-08-18 02:42 | OK (0 trouvés) |
23 +| `epipresto` | Epipresto | Shopify — /products.json ouvert | Grand Montréal | 6000 | 25 | 2026-08-18 02:48 | OK (6000 trouvés) |
24 +| `giant_tiger` | Giant Tiger | Shopify — /products.json ouvert | Québec et Canada | 9274 | 545 | 2026-08-18 02:49 | OK (9274 trouvés) |
25 +| `nuvo` | Marché Nuvo | Shopify — /products.json ouvert | Grand Montréal | 5018 | 455 | 2026-08-18 03:03 | OK (1750 trouvés) |
26 +| `pa` | PA Supermarché | Shopify — /products.json ouvert | Montréal | 3477 | 0 | 2026-08-18 03:03 | OK (0 trouvés) |
27 +
28 +## Complétude des champs (produits actifs, N = 29 769)
29 +
30 +| Champ | % rempli |
31 +|---|---|
32 +| Prix | 99.9 % |
33 +| Prix régulier | 5.9 % |
34 +| Format (size_label) | 32.8 % |
35 +| Prix unitaire | 24.0 % |
36 +| Marque | 100.0 % |
37 +| Images | 98.8 % |
38 +| Nutrition OFF (details.off) | 0.0 % |
39 +
40 +## Gotchas
41 +
42 +- Catalogue public JSON sans anti-bot : /products.json?limit=250&page=N (pagination plafonnée par garde-fou).
43 +- Le format vient des variantes ; prix unitaire recalculé (normalize.unit_price) quand la taille est parsable.
44 +
45 +## Erreurs de synchronisation récentes
46 +
47 +Aucune erreur dans le sync_log pour les sources de cette famille.
added docs/connecteurs/transverse-matching.md +57 −0
@@ -0,0 +1,57 @@
1 +# Transverse — Matching inter-bannières (`product_links`)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py`._
4 +
5 +## Mécanique (`foodka/matching.py`)
6 +
7 +> matching.py : rapprochement inter-bannières — « le même produit ailleurs »
8 +> Après chaque cycle d'ingestion, reconstruit la table product_links
9 +> (group_id, uid) : groupes de produits ACTIFS identiques vendus par des
10 +> bannières DIFFÉRENTES, rapprochés par clé normalisée
11 +> marque + nom nettoyé + format.
12 +> Matching CONSERVATEUR (mieux vaut rater un rapprochement qu'en inventer) :
13 +> - marque non vide obligatoire (égalité stricte après normalisation) ;
14 +> - format identique (quantité + unité de base via parse_size) ;
15 +> - similarité élevée entre noms nettoyés (accents/casse/mots vides/
16 +> format retirés, jetons triés) — seuil 0,86, ou 0,95 si aucun format
17 +> n'est connu de part et d'autre.
18 +> Une source « *_flyer » est la même bannière que sa source catalogue
19 +> (metro / metro_flyer) : un groupe doit couvrir >= 2 bannières distinctes.
20 +
21 +## État live
22 +
23 +- **Groupes de produits identiques** : 1 248
24 +- **Produits rapprochés** : 2 993
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 +- **Garde-fous perf** : MAX_BLOCK 3000, comparaisons en fenêtre triée de 30 (coût linéaire ; rebuild mesuré à ~7 s).
27 +
28 +## Taille des groupes
29 +
30 +| Produits par groupe | Groupes |
31 +|---|---|
32 +| 2 | 929 |
33 +| 3 | 225 |
34 +| 4 | 55 |
35 +| 5 | 22 |
36 +| 6 | 11 |
37 +| 8 | 2 |
38 +| 10 | 2 |
39 +| 13 | 1 |
40 +| 15 | 1 |
41 +
42 +## Sources les plus rapprochées
43 +
44 +| Source | Produits dans un groupe |
45 +|---|---|
46 +| `provigo` | 524 |
47 +| `maxi` | 498 |
48 +| `club_entrepot` | 279 |
49 +| `superc` | 217 |
50 +| `metro` | 199 |
51 +| `nuvo` | 158 |
52 +| `iga_flyer` | 98 |
53 +| `tradition` | 96 |
54 +| `pa` | 95 |
55 +| `aliments_merci` | 79 |
56 +
57 +Reconstruit après chaque cycle d'ingestion (table repartie de zéro : idempotent).
added docs/connecteurs/transverse-nutrition-off.md +33 −0
@@ -0,0 +1,33 @@
1 +# Transverse — Nutrition Open Food Facts (`off_cache`)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py`._
4 +
5 +## Mécanique (`foodka/nutrition.py`)
6 +
7 +> nutrition.py : enrichissement nutritionnel via Open Food Facts (OFF)
8 +> Les bannières n'exposent pas leurs fiches nutritionnelles sans anti-bot
9 +> (voila.ca : pages PDP derrière Incapsula ; Shopify : pas de code-barres
10 +> dans /products.json public). On croise donc avec la base ouverte OFF :
11 +> https://search.openfoodfacts.org/search (« search-a-licious » : plein
12 +> texte + filtre brands_tags, JSON) — nutriscore, groupe NOVA, nutriments
13 +> /100 g ; les INGRÉDIENTS viennent d'un second appel /api/v2/product/
14 +> {code} (l'index de recherche ne les porte pas). NB : l'ancien
15 +> cgi/search.pl répond 503 (déprécié) — ne pas y revenir.
16 +> Croisement CONSERVATEUR (mieux vaut rater que fusionner faux) :
17 +> - marque non vide obligatoire, et retrouvée dans le champ brands d'OFF ;
18 +> - similarité élevée des noms nettoyés (0,80 si le format concorde
19 +> aussi, 0,90 sinon) ;
20 +> - si les deux formats sont connus, ils doivent concorder (±2 %).
21 +> Budget par cycle (défaut 100 requêtes) + throttle 6 s (limite OFF :
22 +> 10 recherches/min) + cache BD off_cache (une marque+nom+format n'est
23 +> cherchée qu'une seule fois, trouvée ou non — vider off_cache pour
24 +> re-tenter). Résultat stocké dans details.off, ré-appliqué à chaque cycle
25 +> (les mises à jour de produit écrasent details ; le cache est la vérité).
26 +
27 +## État live
28 +
29 +- **Clés cherchées (cache)** : 108 — dont 23 correspondances trouvées (21 % de hit)
30 +- **Produits actifs enrichis (`details.off`)** : 56
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 +- **Priorisation** : produits avec marque, non encore cherchés (le cache est la vérité : vider `off_cache` pour re-tenter).
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.
added docs/connecteurs/transverse-price-log.md +14 −0
@@ -0,0 +1,14 @@
1 +# Transverse — Historique de prix (`price_log`)
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py`._
4 +
5 +## Mécanique
6 +
7 +À chaque cycle d'ingestion, tout changement de prix observé est journalisé : `(uid, ts, price)` — `price` NULL = produit retiré de l'affichage. C'est la matière première des tendances de prix et des graphiques d'historique ; la continuité repose sur des `external_id` stables (d'où les slugs marque+nom+format des circulaires Flipp).
8 +
9 +## État live
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
added docs/connecteurs/woocommerce.md +45 −0
@@ -0,0 +1,45 @@
1 +# Famille `woocommerce` — Épiceries WooCommerce — Store API ouverte
2 +
3 +_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._
4 +
5 +## Vue d'ensemble
6 +
7 +- **Sources membres** : 3
8 +- **Produits actifs (BD)** : 4 074
9 +- **Socle commun** : `foodka/connectors/_woocommerce.py`
10 +
11 +## Mécanique du socle (`_woocommerce.py`)
12 +
13 +> connectors/_woocommerce.py : socle commun des épiceries sur WooCommerce
14 +> L'API publique "Store API" expose le catalogue en JSON sans clé :
15 +> /wp-json/wc/store/v1/products?per_page=100&page=N. Les prix y sont en
16 +> unités mineures (cents) — "1299" + currency_minor_unit=2 -> 12,99 $.
17 +
18 +## Sources membres (BD live)
19 +
20 +| Source | Nom | Tech | Région | Produits actifs | En rabais | Dernier sync | Statut |
21 +|---|---|---|---|---|---|---|---|
22 +| `akhavan` | Akhavan | WooCommerce — Store API ouverte | Montréal (épicerie moyen-orientale) | 688 | 21 | 2026-08-18 02:39 | OK (688 trouvés) |
23 +| `aliments_merci` | Aliments Merci | WooCommerce — Store API ouverte | Grand Montréal | 2765 | 52 | 2026-08-18 02:41 | OK (2765 trouvés) |
24 +| `bocoboco` | BocoBoco | WooCommerce — Store API ouverte | Grand Montréal | 621 | 15 | 2026-08-18 02:42 | OK (621 trouvés) |
25 +
26 +## Complétude des champs (produits actifs, N = 4 074)
27 +
28 +| Champ | % rempli |
29 +|---|---|
30 +| Prix | 99.7 % |
31 +| Prix régulier | 2.2 % |
32 +| Format (size_label) | 21.0 % |
33 +| Prix unitaire | 28.8 % |
34 +| Marque | 59.5 % |
35 +| Images | 99.1 % |
36 +| Nutrition OFF (details.off) | 0.0 % |
37 +
38 +## Gotchas
39 +
40 +- Prix en unités mineures (cents) : « 1299 » + currency_minor_unit=2 -> 12,99 $.
41 +- /wp-json/wc/store/v1/products?per_page=100&page=N, pagination plafonnée par garde-fou.
42 +
43 +## Erreurs de synchronisation récentes
44 +
45 +Aucune erreur dans le sync_log pour les sources de cette famille.
added scripts/gen_connector_docs.py +559 −0
@@ -0,0 +1,559 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# scripts/gen_connector_docs.py : documentation standardisée des connecteurs
6 +#
7 +# Génère docs/connecteurs/ (INDEX.md + une fiche par FAMILLE de connecteurs +
8 +# fiches transverses) en croisant :
9 +# 1. data/sources.json — registre des sources (54 bannières) ;
10 +# 2. le code des connecteurs — familles par héritage de classe
11 +# (FlippConnector, ShopifyConnector, WooCommerceConnector…), en-têtes
12 +# « mécanique » des modules, merchant_id/flyer_name_filter des
13 +# circulaires, backends, plafonds env (FOODKA_*) ;
14 +# 3. la BD live data/foodka.db — volumétrie, complétude par champ,
15 +# dernier sync et erreurs (sync_log), matching inter-bannières
16 +# (product_links), nutrition OFF (off_cache), historique de prix.
17 +#
18 +# Rejouable à volonté (BD ouverte en lecture seule, docs régénérés) :
19 +# .venv/bin/python3 scripts/gen_connector_docs.py
20 +# -----------------------------------------------------------------------------
21 +from __future__ import annotations
22 +
23 +import datetime
24 +import json
25 +import re
26 +import sqlite3
27 +import sys
28 +from collections import Counter, OrderedDict
29 +from pathlib import Path
30 +
31 +ROOT = Path(__file__).resolve().parent.parent
32 +sys.path.insert(0, str(ROOT))
33 +
34 +DB_PATH = ROOT / "data" / "foodka.db"
35 +SOURCES_PATH = ROOT / "data" / "sources.json"
36 +CONNECTORS_DIR = ROOT / "foodka" / "connectors"
37 +OUT_DIR = ROOT / "docs" / "connecteurs"
38 +
39 +# Champs de complétude (produits actifs) : libellé -> expression SQL "rempli"
40 +FIELDS = OrderedDict([
41 + ("Prix", "price IS NOT NULL"),
42 + ("Prix régulier", "regular_price IS NOT NULL"),
43 + ("Format (size_label)", "size_label IS NOT NULL AND size_label != ''"),
44 + ("Prix unitaire", "unit_price IS NOT NULL"),
45 + ("Marque", "brand IS NOT NULL AND brand != ''"),
46 + ("Images", "images IS NOT NULL AND images NOT IN ('', '[]')"),
47 + ("Nutrition OFF (details.off)", "details LIKE '%\"off\"%'"),
48 +])
49 +
50 +# --- Définition des familles : (slug, titre, module socle éventuel, gotchas) --
51 +FAMILIES = OrderedDict([
52 + ("flipp-proximite", {
53 + "title": "Circulaires Flipp — épiceries de proximité (catalogue = circulaire)",
54 + "base": "_flipp.py",
55 + "gotchas": [
56 + "Le paramètre d'URL de l'API backflipp ne filtre PAS par marchand : "
57 + "filtrer côté client sur `merchant_id`.",
58 + "`flyer_name_filter` écarte les cahiers parasites du même merchant "
59 + "(ex. Supermarché PA : « Weekly Flyer » vs « Nature Flyer »).",
60 + "Tri des circulaires candidates par `valid_from` croissant : en cas "
61 + "de chevauchement (semaine courante + semaine à venir), on prend la "
62 + "courante.",
63 + "Les items Flipp n'ont ni SKU, ni catégorie, ni unité : "
64 + "`external_id` = slug stable marque+nom+format (continuité du "
65 + "price_log d'une semaine à l'autre), catégorie déduite du nom.",
66 + "Le `flyer_id` change chaque semaine — toujours résolu dynamiquement "
67 + "via /flipp/flyers (une circulaire provinciale par bannière : un "
68 + "poste montréalais suffit).",
69 + ]}),
70 + ("flyers-grandes-bannieres", {
71 + "title": "Circulaires Flipp — grandes bannières (complément rabais du catalogue)",
72 + "base": "_flipp.py",
73 + "gotchas": [
74 + "Chaque source `*_flyer` complète la source catalogue de la même "
75 + "bannière (metro / metro_flyer) : le matching inter-bannières les "
76 + "traite comme UNE bannière.",
77 + "`flyer_name_filter` est indispensable ici : les grands merchants "
78 + "publient plusieurs cahiers (Metro « Metrogo! », Costco cahiers non "
79 + "alimentaires, Walmart livrets thématiques…).",
80 + "Tri `valid_from` croissant pour choisir la circulaire de la semaine "
81 + "courante ; flyer_id résolu à chaque sync.",
82 + ]}),
83 + ("scrapfly-asp", {
84 + "title": "Catalogues derrière anti-bot — Scrapfly (ASP)",
85 + "base": None,
86 + "gotchas": [
87 + "Chaque requête passe par Scrapfly en mode ASP (anti-scraping "
88 + "protection) : coût par appel — les plafonds de pagination par "
89 + "allée/catégorie bornent le budget de chaque sync.",
90 + "Metro et Super C partagent le socle `_metro.py` (tuiles rendues "
91 + "serveur, pagination « <allée>-page-N ») ; Maxi, Provigo et Club "
92 + "Entrepôt partagent `_loblaw.py` (grille complète dans "
93 + "`__NEXT_DATA__`).",
94 + ]}),
95 + ("render-js", {
96 + "title": "Catalogues SPA — Scrapfly avec rendu JavaScript",
97 + "base": None,
98 + "gotchas": [
99 + "Le rendu JavaScript Scrapfly est encore plus coûteux que l'ASP "
100 + "simple : nombre de pages par catégorie plafonné, syncs espacés.",
101 + ]}),
102 + ("shopify", {
103 + "title": "Boutiques Shopify — /products.json ouvert",
104 + "base": "_shopify.py",
105 + "gotchas": [
106 + "Catalogue public JSON sans anti-bot : "
107 + "/products.json?limit=250&page=N (pagination plafonnée par "
108 + "garde-fou).",
109 + "Le format vient des variantes ; prix unitaire recalculé "
110 + "(normalize.unit_price) quand la taille est parsable.",
111 + ]}),
112 + ("woocommerce", {
113 + "title": "Épiceries WooCommerce — Store API ouverte",
114 + "base": "_woocommerce.py",
115 + "gotchas": [
116 + "Prix en unités mineures (cents) : « 1299 » + "
117 + "currency_minor_unit=2 -> 12,99 $.",
118 + "/wp-json/wc/store/v1/products?per_page=100&page=N, pagination "
119 + "plafonnée par garde-fou.",
120 + ]}),
121 + ("html", {
122 + "title": "Catalogues HTML rendus serveur — requests direct",
123 + "base": None,
124 + "gotchas": [
125 + "Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, "
126 + "Magento 2, k-eCommerce, SSR maison).",
127 + ]}),
128 + ("iga-api", {
129 + "title": "IGA — API Voilà (Sobeys Québec)",
130 + "base": None,
131 + "gotchas": [
132 + "Voie principale : API REST publique des promotions de voila.ca "
133 + "(JSON complet, curseur, sans anti-bot) ; complément optionnel par "
134 + "recherche SPA rendue via Scrapfly (render_js).",
135 + "`regionId` public requis par l'API (région de livraison Québec).",
136 + ]}),
137 + ("non-connectables", {
138 + "title": "Sources recensées non connectables",
139 + "base": None,
140 + "gotchas": []}),
141 +])
142 +
143 +HISTORIQUE = """\
144 +## Historique des vagues (2026-08-18)
145 +
146 +| Commit | Contenu |
147 +|---|---|
148 +| `afdb2e6` | Enrichissement connecteurs + robustesse DB (audit 2026-08-18) |
149 +| `a45aeb4` | Vague 2 : circulaires grandes bannières + comparateur inter-bannières + nutrition Open Food Facts |
150 +| `114aa76` | Matching : MAX_BLOCK 400 → 3000 (marques maison des circulaires ; fenêtre triée de 30 = coût linéaire) |
151 +"""
152 +
153 +
154 +# --------------------------------------------------------------------------- #
155 +# Introspection du code
156 +# --------------------------------------------------------------------------- #
157 +
158 +def get_registry():
159 + import foodka.connectors as reg
160 + return reg.CONNECTORS
161 +
162 +
163 +def classify(source: dict, registry) -> str:
164 + if source.get("status") == "non connectable":
165 + return "non-connectables"
166 + sid = source["id"]
167 + if sid == "iga":
168 + return "iga-api"
169 + cls = registry.get(sid)
170 + bases = {b.__name__ for b in cls.__mro__} if cls else set()
171 + if "FlippConnector" in bases:
172 + return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite"
173 + if "ShopifyConnector" in bases:
174 + return "shopify"
175 + if "WooStoreConnector" in bases:
176 + return "woocommerce"
177 + tech = source.get("tech", "")
178 + if "Flipp" in tech: # connecteur Flipp sur mesure hors socle
179 + return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite"
180 + if "WooCommerce" in tech: # ex. akhavan : Store API, impl. sur mesure
181 + return "woocommerce"
182 + if "rendu JavaScript" in tech:
183 + return "render-js"
184 + if "Scrapfly" in tech:
185 + return "scrapfly-asp"
186 + return "html"
187 +
188 +
189 +def module_header(path: Path, marker: str | None = None) -> str:
190 + """Bloc de commentaires du haut du fichier, rendu en citation Markdown.
191 + Si `marker` est donné, on démarre à la ligne qui commence par ce texte."""
192 + lines, started = [], marker is None
193 + for raw in path.read_text(encoding="utf-8").splitlines():
194 + if not raw.startswith("#") or raw.startswith("#!"):
195 + if raw.startswith("#!"):
196 + continue
197 + break
198 + body = raw.lstrip("#").strip()
199 + if not started:
200 + if marker and body.startswith(marker):
201 + started = True
202 + lines.append(body)
203 + continue
204 + if set(body) <= {"-"} and len(body) > 10:
205 + break
206 + lines.append(body)
207 + if not lines:
208 + return "_(pas d'en-tête trouvé)_"
209 + return "\n".join("> " + l for l in lines)
210 +
211 +
212 +def source_module_header(sid: str, registry) -> str:
213 + cls = registry.get(sid)
214 + if not cls:
215 + return "_(pas de connecteur)_"
216 + mod = cls.__module__.rsplit(".", 1)[-1]
217 + path = CONNECTORS_DIR / f"{mod}.py"
218 + # démarrer après les 2 lignes standard (titre projet + auteur)
219 + text = path.read_text(encoding="utf-8").splitlines()
220 + lines, seen_author = [], False
221 + for raw in text:
222 + if not raw.startswith("#"):
223 + break
224 + body = raw.lstrip("#").strip()
225 + if set(body) <= {"-"} and len(body) > 10:
226 + if seen_author and lines:
227 + break
228 + continue
229 + if body.startswith("Auteur :"):
230 + seen_author = True
231 + continue
232 + if body.startswith("Food-Ka —"):
233 + continue
234 + if seen_author:
235 + lines.append(body)
236 + return "\n".join("> " + l for l in lines) if lines else "_(pas d'en-tête)_"
237 +
238 +
239 +def detect_caps(sid: str, registry) -> str:
240 + cls = registry.get(sid)
241 + if not cls:
242 + return "—"
243 + mod = cls.__module__.rsplit(".", 1)[-1]
244 + src = (CONNECTORS_DIR / f"{mod}.py").read_text(encoding="utf-8")
245 + caps = sorted(set(re.findall(r"FOODKA_[A-Z_]+", src)))
246 + hard = sorted(set(re.findall(r"(?:MAX|LIMIT)_[A-Z_]+\s*=\s*\d+",
247 + src)))[:3]
248 + out = caps + [h.replace(" ", "") for h in hard]
249 + return ", ".join(f"`{c}`" for c in out) if out else "—"
250 +
251 +
252 +# --------------------------------------------------------------------------- #
253 +# BD live
254 +# --------------------------------------------------------------------------- #
255 +
256 +def q1(db, sql, args=()):
257 + return db.execute(sql, args).fetchone()
258 +
259 +
260 +def completeness(db, source_ids: list[str]) -> tuple[int, OrderedDict]:
261 + if not source_ids:
262 + return 0, OrderedDict((l, 0.0) for l in FIELDS)
263 + ph = ",".join("?" * len(source_ids))
264 + parts = ", ".join(f"SUM(CASE WHEN {expr} THEN 1 ELSE 0 END)"
265 + for expr in FIELDS.values())
266 + row = q1(db, f"SELECT COUNT(*), {parts} FROM products "
267 + f"WHERE active = 1 AND source IN ({ph})", source_ids)
268 + n = row[0] or 0
269 + out = OrderedDict()
270 + for (label, _), filled in zip(FIELDS.items(), row[1:]):
271 + out[label] = (100.0 * (filled or 0) / n) if n else 0.0
272 + return n, out
273 +
274 +
275 +def source_stats(db, sid: str) -> dict:
276 + tot, act, sale = q1(db, "SELECT COUNT(*), SUM(active), "
277 + "SUM(active = 1 AND on_sale = 1) FROM products "
278 + "WHERE source = ?", (sid,))
279 + last = q1(db, "SELECT ts, ok, found, message FROM sync_log "
280 + "WHERE source = ? ORDER BY ts DESC LIMIT 1", (sid,))
281 + errs = q1(db, "SELECT COUNT(*) FROM (SELECT ok FROM sync_log "
282 + "WHERE source = ? ORDER BY ts DESC LIMIT 10) WHERE ok = 0",
283 + (sid,))[0]
284 + return {"total": tot or 0, "active": act or 0, "sale": sale or 0,
285 + "last": last, "errs10": errs}
286 +
287 +
288 +def fmt_ts(ts) -> str:
289 + if not ts:
290 + return "—"
291 + return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
292 +
293 +
294 +def recent_errors(db, source_ids: list[str], limit=8) -> list[tuple]:
295 + if not source_ids:
296 + return []
297 + ph = ",".join("?" * len(source_ids))
298 + return db.execute(
299 + f"SELECT source, ts, message FROM sync_log "
300 + f"WHERE ok = 0 AND source IN ({ph}) ORDER BY ts DESC LIMIT ?",
301 + (*source_ids, limit)).fetchall()
302 +
303 +
304 +# --------------------------------------------------------------------------- #
305 +# Rendu des fiches
306 +# --------------------------------------------------------------------------- #
307 +
308 +def nfmt(n: int) -> str:
309 + return f"{n:,}".replace(",", " ")
310 +
311 +
312 +def render_family_fiche(db, slug: str, members: list[dict], registry) -> str:
313 + meta = FAMILIES[slug]
314 + sids = [s["id"] for s in members]
315 + n, comp = completeness(db, sids)
316 +
317 + lines = [f"# Famille `{slug}` — {meta['title']}", "",
318 + "_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._",
319 + "", "## Vue d'ensemble", "",
320 + f"- **Sources membres** : {len(members)}"]
321 + if slug != "non-connectables":
322 + act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products WHERE source IN "
323 + f"({','.join('?' * len(sids))})", sids)[0]
324 + lines.append(f"- **Produits actifs (BD)** : {nfmt(act)}")
325 + if meta["base"]:
326 + lines.append(f"- **Socle commun** : `foodka/connectors/{meta['base']}`")
327 +
328 + if meta["base"]:
329 + lines += ["", f"## Mécanique du socle (`{meta['base']}`)", "",
330 + module_header(CONNECTORS_DIR / meta["base"],
331 + f"connectors/{meta['base']}"), ""]
332 +
333 + # ---- tableau des membres
334 + if slug == "non-connectables":
335 + lines += ["", "## Sources", "",
336 + "| Source | Nom | Tech | Pourquoi non connectable |",
337 + "|---|---|---|---|"]
338 + for s in sorted(members, key=lambda x: x["id"]):
339 + lines.append(f"| `{s['id']}` | {s['name']} | {s.get('tech', '')} | "
340 + f"{(s.get('notes') or '').replace('|', '—')} |")
341 + lines.append("")
342 + return "\n".join(lines)
343 +
344 + if slug in ("flipp-proximite", "flyers-grandes-bannieres"):
345 + header = ("| Source | Nom | merchant_id | flyer_name_filter | Région | "
346 + "Produits actifs | En rabais | Dernier sync | Statut |")
347 + sep = "|---|---|---|---|---|---|---|---|---|"
348 + else:
349 + header = ("| Source | Nom | Tech | Région | Produits actifs | "
350 + "En rabais | Dernier sync | Statut |")
351 + sep = "|---|---|---|---|---|---|---|---|"
352 + lines += ["## Sources membres (BD live)", "", header, sep]
353 +
354 + for s in sorted(members, key=lambda x: x["id"]):
355 + st = source_stats(db, s["id"])
356 + last = st["last"]
357 + when = fmt_ts(last[0]) if last else "jamais"
358 + status = ("OK" if last and last[1] else "ERREUR") + \
359 + (f" ({last[2] or 0} trouvés)" if last else "")
360 + if slug in ("flipp-proximite", "flyers-grandes-bannieres"):
361 + cls = registry.get(s["id"])
362 + mid = getattr(cls, "merchant_id", "—") if cls else "—"
363 + filt = getattr(cls, "flyer_name_filter", "") if cls else ""
364 + lines.append(f"| `{s['id']}` | {s['name']} | {mid} | "
365 + f"{('`' + filt + '`') if filt else '—'} | "
366 + f"{s.get('region', '—')} | {st['active']} | "
367 + f"{st['sale']} | {when} | {status} |")
368 + else:
369 + lines.append(f"| `{s['id']}` | {s['name']} | "
370 + f"{(s.get('tech') or '—').replace('|', '—')} | "
371 + f"{s.get('region', '—')} | {st['active']} | "
372 + f"{st['sale']} | {when} | {status} |")
373 + lines.append("")
374 +
375 + # ---- complétude
376 + lines += [f"## Complétude des champs (produits actifs, N = {nfmt(n)})", "",
377 + "| Champ | % rempli |", "|---|---|"]
378 + lines += [f"| {label} | {pct:.1f} % |" for label, pct in comp.items()]
379 + lines.append("")
380 +
381 + # ---- gotchas
382 + if meta["gotchas"]:
383 + lines += ["## Gotchas", ""]
384 + lines += [f"- {g}" for g in meta["gotchas"]]
385 + lines.append("")
386 +
387 + # ---- mécanique par source (familles sans socle unique)
388 + if not meta["base"] and slug != "iga-api":
389 + lines += ["## Mécanique par source (en-têtes des modules)", ""]
390 + for s in sorted(members, key=lambda x: x["id"]):
391 + lines += [f"### `{s['id']}` — {s['name']}", "",
392 + f"Plafonds/env : {detect_caps(s['id'], registry)}", "",
393 + source_module_header(s["id"], registry), ""]
394 + elif slug == "iga-api":
395 + lines += ["## Mécanique (`foodka/connectors/iga.py`)", "",
396 + source_module_header("iga", registry), ""]
397 +
398 + errs = recent_errors(db, sids)
399 + if errs:
400 + lines += ["## Erreurs de synchronisation récentes (sync_log, ok = 0)", "",
401 + "| Source | Quand | Message |", "|---|---|---|"]
402 + for sid, ts, msg in errs:
403 + lines.append(f"| `{sid}` | {fmt_ts(ts)} | "
404 + f"{(msg or '').replace('|', '—')[:160]} |")
405 + lines.append("")
406 + else:
407 + lines += ["## Erreurs de synchronisation récentes", "",
408 + "Aucune erreur dans le sync_log pour les sources de cette famille.", ""]
409 + return "\n".join(lines)
410 +
411 +
412 +def render_transverse_matching(db) -> str:
413 + groups, uids = q1(db, "SELECT COUNT(DISTINCT group_id), COUNT(*) FROM product_links")
414 + dist = db.execute(
415 + "SELECT n, COUNT(*) FROM (SELECT group_id, COUNT(*) n FROM product_links "
416 + "GROUP BY group_id) GROUP BY n ORDER BY n").fetchall()
417 + top_src = db.execute(
418 + "SELECT p.source, COUNT(*) c FROM product_links l "
419 + "JOIN products p ON p.uid = l.uid GROUP BY p.source "
420 + "ORDER BY c DESC LIMIT 10").fetchall()
421 + lines = ["# Transverse — Matching inter-bannières (`product_links`)", "",
422 + "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",
423 + "## Mécanique (`foodka/matching.py`)", "",
424 + module_header(ROOT / "foodka" / "matching.py", "matching.py"), "",
425 + "## État live", "",
426 + f"- **Groupes de produits identiques** : {nfmt(groups)}",
427 + f"- **Produits rapprochés** : {nfmt(uids)}",
428 + "- **Règles conservatrices** : marque non vide et strictement égale ; "
429 + "format identique (quantité + unité de base) ; similarité de noms "
430 + "nettoyés ≥ 0,86 (0,95 sans format) ; un groupe couvre ≥ 2 bannières "
431 + "distinctes (`*_flyer` = même bannière que son catalogue).",
432 + "- **Garde-fous perf** : MAX_BLOCK 3000, comparaisons en fenêtre "
433 + "triée de 30 (coût linéaire ; rebuild mesuré à ~7 s).",
434 + "", "## Taille des groupes", "",
435 + "| Produits par groupe | Groupes |", "|---|---|"]
436 + lines += [f"| {n} | {c} |" for n, c in dist]
437 + lines += ["", "## Sources les plus rapprochées", "",
438 + "| Source | Produits dans un groupe |", "|---|---|"]
439 + lines += [f"| `{s}` | {c} |" for s, c in top_src]
440 + lines += ["", "Reconstruit après chaque cycle d'ingestion (table repartie "
441 + "de zéro : idempotent).", ""]
442 + return "\n".join(lines)
443 +
444 +
445 +def render_transverse_nutrition(db) -> str:
446 + tot, found = q1(db, "SELECT COUNT(*), COALESCE(SUM(found), 0) FROM off_cache")
447 + enriched = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 "
448 + "AND details LIKE '%\"off\"%'")[0]
449 + lines = ["# Transverse — Nutrition Open Food Facts (`off_cache`)", "",
450 + "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",
451 + "## Mécanique (`foodka/nutrition.py`)", "",
452 + module_header(ROOT / "foodka" / "nutrition.py", "nutrition.py"), "",
453 + "## État live", "",
454 + f"- **Clés cherchées (cache)** : {nfmt(tot)} — "
455 + f"dont {nfmt(found)} correspondances trouvées "
456 + f"({100.0 * found / tot:.0f} % de hit)" if tot else
457 + "- **Cache vide**",
458 + f"- **Produits actifs enrichis (`details.off`)** : {nfmt(enriched)}",
459 + "- **Budget** : 100 requêtes/cycle par défaut, throttle 6 s "
460 + "(limite OFF : 10 recherches/min) + 1 s entre recherche et fiche "
461 + "produit.",
462 + "- **Priorisation** : produits avec marque, non encore cherchés "
463 + "(le cache est la vérité : vider `off_cache` pour re-tenter).",
464 + "- **Gotcha** : l'ancien `cgi/search.pl` OFF répond 503 (déprécié) "
465 + "— utiliser search.openfoodfacts.org (search-a-licious) + "
466 + "/api/v2/product/{code} pour les ingrédients.", ""]
467 + return "\n".join(lines)
468 +
469 +
470 +def render_transverse_pricelog(db) -> str:
471 + rows, uids, t0, t1 = q1(db, "SELECT COUNT(*), COUNT(DISTINCT uid), "
472 + "MIN(ts), MAX(ts) FROM price_log")
473 + multi = q1(db, "SELECT COUNT(*) FROM (SELECT uid FROM price_log "
474 + "GROUP BY uid HAVING COUNT(DISTINCT price) > 1)")[0]
475 + lines = ["# Transverse — Historique de prix (`price_log`)", "",
476 + "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",
477 + "## Mécanique", "",
478 + "À chaque cycle d'ingestion, tout changement de prix observé est "
479 + "journalisé : `(uid, ts, price)` — `price` NULL = produit retiré "
480 + "de l'affichage. C'est la matière première des tendances de prix "
481 + "et des graphiques d'historique ; la continuité repose sur des "
482 + "`external_id` stables (d'où les slugs marque+nom+format des "
483 + "circulaires Flipp).", "",
484 + "## État live", "",
485 + f"- **Observations** : {nfmt(rows)}",
486 + f"- **Produits suivis** : {nfmt(uids)}",
487 + f"- **Produits avec au moins un changement de prix** : {nfmt(multi)}",
488 + f"- **Période couverte** : {fmt_ts(t0)} → {fmt_ts(t1)}", ""]
489 + return "\n".join(lines)
490 +
491 +
492 +def render_index(db, groups: OrderedDict, sources: list[dict]) -> str:
493 + total, active = q1(db, "SELECT COUNT(*), SUM(active) FROM products")
494 + links = q1(db, "SELECT COUNT(DISTINCT group_id) FROM product_links")[0]
495 + off = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 "
496 + "AND details LIKE '%\"off\"%'")[0]
497 + plog = q1(db, "SELECT COUNT(*) FROM price_log")[0]
498 + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
499 + n_conn = sum(1 for s in sources if s.get("connector"))
500 +
501 + lines = ["# Food-Ka — Documentation des connecteurs", "",
502 + f"_Générée le {now} par `scripts/gen_connector_docs.py`"
503 + " (rejouable : `.venv/bin/python3 scripts/gen_connector_docs.py`)._",
504 + "", "## Vue d'ensemble", "",
505 + f"- **Sources recensées** : {len(sources)} (registre "
506 + f"`data/sources.json`) — dont {n_conn} connectées",
507 + f"- **Produits en base** : {nfmt(total)} — dont {nfmt(active)} actifs",
508 + f"- **Groupes inter-bannières** : {nfmt(links)} "
509 + "(voir [matching](transverse-matching.md))",
510 + f"- **Produits enrichis Open Food Facts** : {nfmt(off)} "
511 + "(voir [nutrition](transverse-nutrition-off.md))",
512 + f"- **Observations de prix** : {nfmt(plog)} "
513 + "(voir [price_log](transverse-price-log.md))",
514 + "", "## Fiches par famille de connecteurs", "",
515 + "| Famille | Fiche | Sources | Produits actifs |",
516 + "|---|---|---|---|"]
517 + for slug, members in groups.items():
518 + sids = [s["id"] for s in members]
519 + act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products "
520 + f"WHERE source IN ({','.join('?' * len(sids))})", sids)[0] \
521 + if sids else 0
522 + lines.append(f"| `{slug}` | [{slug}.md]({slug}.md) | {len(members)} | "
523 + f"{nfmt(act)} |")
524 +
525 + lines += ["", "## Fiches transverses", "",
526 + "| Sujet | Fiche |", "|---|---|",
527 + "| Matching inter-bannières (product_links) | [transverse-matching.md](transverse-matching.md) |",
528 + "| Nutrition Open Food Facts (off_cache) | [transverse-nutrition-off.md](transverse-nutrition-off.md) |",
529 + "| Historique de prix (price_log) | [transverse-price-log.md](transverse-price-log.md) |",
530 + "", HISTORIQUE]
531 + return "\n".join(lines)
532 +
533 +
534 +# --------------------------------------------------------------------------- #
535 +
536 +def main() -> None:
537 + sources = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
538 + registry = get_registry()
539 + groups: OrderedDict[str, list] = OrderedDict((k, []) for k in FAMILIES)
540 + for s in sources:
541 + groups[classify(s, registry)].append(s)
542 +
543 + db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
544 + OUT_DIR.mkdir(parents=True, exist_ok=True)
545 +
546 + for slug, members in groups.items():
547 + (OUT_DIR / f"{slug}.md").write_text(
548 + render_family_fiche(db, slug, members, registry), encoding="utf-8")
549 + print(f" fiche {slug}.md ({len(members)} source(s))")
550 +
551 + (OUT_DIR / "transverse-matching.md").write_text(render_transverse_matching(db), encoding="utf-8")
552 + (OUT_DIR / "transverse-nutrition-off.md").write_text(render_transverse_nutrition(db), encoding="utf-8")
553 + (OUT_DIR / "transverse-price-log.md").write_text(render_transverse_pricelog(db), encoding="utf-8")
554 + (OUT_DIR / "INDEX.md").write_text(render_index(db, groups, sources), encoding="utf-8")
555 + print(f"OK — {len(groups)} fiches famille + 3 transverses + INDEX.md dans {OUT_DIR}")
556 +
557 +
558 +if __name__ == "__main__":
559 + main()
560