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