docs: documentation standardisée des 6 sources (générateur + fiches)
scripts/gen_connector_docs.py : générateur 100 % programmatique et rejouable qui croise data/sources.json (tier, extraction, acces_legal, cadence), l introspection statique (ast) de restoka/connectors/*.py et inspections.py, et la BD live (restaurants, menus, inspections, sync_log). Écrit docs/connecteurs/INDEX.md + une fiche par source (sections fixes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +977 −0
added
docs/connecteurs/INDEX.md
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# Resto-Ka — Index des connecteurs | |
| 2 | + | |
| 3 | +_Généré automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**6 sources au registre** · **14989 restos actifs** en BD. | |
| 6 | + | |
| 7 | +| Source | Nom | Tier | Type d'accès | Backend | Actifs/Total | GPS | Horaires | Cuisines | État | Dernier sync OK | | |
| 8 | +|---|---|---|---|---|---|---|---|---|---|---| | |
| 9 | +| [`ueat`](ueat.md) | UEAT (commande en ligne) | T1 | API GraphQL interne | direct | 934/936 | 100 % | 0 % | 100 % | actif | 2026-08-18 03:25 | | |
| 10 | +| [`osm`](osm.md) | OpenStreetMap (découverte) | T4 | API Overpass (OpenStreetMap) | direct | 13969/13969 | 100 % | 21 % | 100 % | actif | 2026-08-18 03:12 | | |
| 11 | +| [`site-resto`](site-resto.md) | Sites web des restos (menus maison) | T3 | pages HTML (rendu serveur) | Firecrawl | 86/86 | 100 % | 50 % | 100 % | actif | 2026-08-18 03:17 | | |
| 12 | +| [`sthubert`](sthubert.md) | St-Hubert (commande en ligne) | T6 | — | — | 0/0 | — | — | — | à faire | — | | |
| 13 | +| [`mapaq`](mapaq.md) | MAPAQ — Inspections alimentaires (conda… | T5 | jeu de données ouvert (CSV, Données Qué… | direct | 0/0 | — | — | — | actif | 2026-08-18 02:53 | | |
| 14 | +| [`yelp`](yelp.md) | Yelp Fusion (avis et notes) | T5 | API JSON | direct | 0/0 | — | — | — | clé requise | — | | |
added
docs/connecteurs/mapaq.md
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +# MAPAQ — Inspections alimentaires (condamnations) — connecteur `mapaq` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Palier (tier) : 5 · Backend : direct | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur MAPAQ — « Condamnations des établissements alimentaires » (Données Québec, jeu 515374ee…, CSV listecondamnation.csv, licence CC-BY 4.0). Inspections alimentaires condamnées : établissement, adresse, dates, amende, motif (INSALUBRITE…). Alimente la table LIÉE `inspections` + croisement CONSERVATEUR avec les restaurants (jamais de fusion hasardeuse : nom normalisé + ville/code postal, ou code postal + civique + similarité de nom). Ce n'est PAS une source de fiches restaurant : c'est un enrichissement conformité/qualité appelé par ingest.enrich() (cadence hebdomadaire, guard via sync_log source='mapaq'). | |
| 10 | + | |
| 11 | +- **Plateforme** : donnees-quebec · **Site** : https://www.donneesquebec.ca/recherche/dataset/condamnations-des-etablissements-alimentaires-et-condamnations-concernant-le-bien-etre-des-anim | |
| 12 | +- **Extraction (registre)** : CSV ouvert listecondamnation.csv (Données Québec) -> table liée `inspections` + croisement CONSERVATEUR avec les restaurants (nom normalisé + ville/code postal, ou code postal + civique + similarité de nom). Résumé conformité dans details.mapaq, endpoint GET /api/restaurants/{uid}/inspections. | |
| 13 | +- **Contexte de prix** : aucun | |
| 14 | +- **Module** : `restoka/inspections.py` — `(module fonctionnel, pas de classe)` | |
| 15 | + | |
| 16 | +## Accès | |
| 17 | + | |
| 18 | +- **Type d'accès** : jeu de données ouvert (CSV, Données Québec) | |
| 19 | +- **Endpoint de base** : https://www.donneesquebec.ca/recherche/dataset/515374ee-ce34-464f-9875-7d1af3fa9b2a/resource/40105615-3abf-414b-bcba-182e8f2c5eb2/download/listecondamnation.csv | |
| 20 | +- **URLs du module** : https://www.donneesquebec.ca/recherche/dataset/ | |
| 21 | +- **Pagination** : réponse unique (pas de pagination) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA RestoKaBot, throttling poli) | |
| 23 | +- **Authentification** : aucune — accès public/anonyme | |
| 24 | + | |
| 25 | +## Champs récupérés → schéma cible | |
| 26 | + | |
| 27 | +Source d'**enrichissement** : alimente la table `inspections` (exploitant, établissement, adresse, dates, amende, motif) puis croisement conservateur avec `restaurants.uid` (nom normalisé + ville/code postal, ou code postal + civique + similarité de nom — colonne `matched_by`). Pas de fiches restaurant propres. | |
| 28 | + | |
| 29 | +## Fréquence & budget | |
| 30 | + | |
| 31 | +- **Cadence déclarée (registre)** : hebdomadaire (guard 6 jours dans ingest.enrich) | |
| 32 | +- **Cadence observée** (médiane sync_log) : — | |
| 33 | +- **Dernier passage OK** : 2026-08-18 02:53 — 2994 trouvées, +2994 / ~0 / -0 | |
| 34 | + | |
| 35 | +## Volumétrie & complétude | |
| 36 | + | |
| 37 | +- **Inspections MAPAQ** : 2994 condamnations (288 croisées avec un resto, amendes cumulées 5 023 013 $), infractions de 2017-03-14 à 2026-06-09 | |
| 38 | +- **Runs journalisés (60 derniers)** : 1, dont 0 en erreur | |
| 39 | + | |
| 40 | +## Erreurs connues & dépannage | |
| 41 | + | |
| 42 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 43 | + | |
| 44 | +Rejouer la source seule : `python3 run.py sync mapaq` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='mapaq' ORDER BY ts DESC LIMIT 5;`). | |
| 45 | + | |
| 46 | +## Licence, attribution & conditions | |
| 47 | + | |
| 48 | +- **Cadre d'accès (registre, `acces_legal`)** : Données ouvertes du gouvernement du Québec, licence CC-BY 4.0 — attribution MAPAQ/Données Québec affichée avec les inspections. | |
| 49 | +- **Licence** : donnée ouverte CC-BY 4.0 (Données Québec) — mention de la source « MAPAQ / Données Québec » affichée. | |
| 50 | +- Retrait sur demande : contact@spboucher.ai. | |
| 51 | + | |
| 52 | +## Historique | |
| 53 | + | |
| 54 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
docs/connecteurs/osm.md
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +# OpenStreetMap (découverte) — connecteur `osm` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Palier (tier) : 4 · Backend : direct · Restos actifs : 13969/13969 | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur de DÉCOUVERTE OpenStreetMap (Palier 4) — TOUS les établissements où l'on mange au Québec : restaurants, fast-foods, cafés, bars/pubs, crèmeries, foires alimentaires, boulangeries- pâtisseries. C'est la couche « exhaustivité » de Resto·Ka : des milliers de fiches (nom, adresse, GPS, cuisine, horaires, contact), SANS menu — les connecteurs de menus (UEAT…) viennent s'y greffer par déduplication. Mode d'extraction : API Overpass (miroirs publics, requêtes par type d'établissement, aire administrative « Québec » 3600061549). Base légale : données © contributeurs OpenStreetMap, licence ODbL — attribution affichée sur le site (pages Sources et pied de page). Politesse : 1 requête par type, timeout large, miroirs en rotation. Contexte de prix produit : AUCUN (pas de menu — fiche de découverte). | |
| 10 | + | |
| 11 | +- **Plateforme** : overpass · **Site** : https://www.openstreetmap.org | |
| 12 | +- **Extraction (registre)** : API Overpass (aire administrative Québec), nœuds + chemins : restaurant, fast_food, cafe, bar, pub, ice_cream, food_court, bakery, pastry — nom, adresse, GPS, cuisine, horaires, contact. Pas de menus (couche d'exhaustivité, les connecteurs de menus s'y greffent par déduplication). | |
| 13 | +- **Contexte de prix** : — | |
| 14 | +- **Module** : `restoka/connectors/osm.py` — `OsmConnector` | |
| 15 | + | |
| 16 | +## Accès | |
| 17 | + | |
| 18 | +- **Type d'accès** : API Overpass (OpenStreetMap) | |
| 19 | +- **Endpoint de base** : https://overpass.kumi.systems/api/interpreter | |
| 20 | +- **URLs du module** : https://overpass.kumi.systems/api/interpreter · https://overpass-api.de/api/interpreter · https://overpass.private.coffee/api/interpreter · https://overpass.osm.jp/api/interpreter | |
| 21 | +- **Pagination** : itération sur 4 racines (constante `MIRRORS`) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA RestoKaBot, throttling poli) | |
| 23 | +- **Politesse** : 3.0 s entre requêtes, timeout 240 s, UA `RestoKaBot/1.0 (+https://www.resto-ka.com/bot)` | |
| 24 | +- **Authentification** : aucune — accès public/anonyme | |
| 25 | + | |
| 26 | +## Champs récupérés → schéma cible | |
| 27 | + | |
| 28 | +Le connecteur alimente la table `restaurants` (et `menus` le cas échéant). Complétude mesurée en SQL sur les 13969 fiches actives ; exemple tiré d'une ligne réelle de la BD. | |
| 29 | + | |
| 30 | +| Colonne `restaurants` | Contenu | Renseignée (actives) | Exemple réel | | |
| 31 | +|---|---|---|---| | |
| 32 | +| `name` | Nom de l'établissement | 100 % | Piazza Romana | | |
| 33 | +| `chain` | Chaîne / bannière | 21 % | — | | |
| 34 | +| `cuisines` | Cuisines (JSON, taxonomie §6.1) | 100 % | ["autre"] | | |
| 35 | +| `establishment_type` | Type d'établissement | 100 % | restaurant | | |
| 36 | +| `price_range` | Fourchette de prix | 0 % | — | | |
| 37 | +| `address` | Adresse civique | 53 % | — | | |
| 38 | +| `city` | Ville | 34 % | — | | |
| 39 | +| `region` | Région administrative | 100 % | Montréal | | |
| 40 | +| `postal_code` | Code postal | 28 % | — | | |
| 41 | +| `lat` | GPS (lat/lng) | 100 % | 45.4287676 | | |
| 42 | +| `phone` | Téléphone | 25 % | — | | |
| 43 | +| `website` | Site web | 24 % | — | | |
| 44 | +| `hours` | Horaires structurés (JSON) | 21 % | {} | | |
| 45 | +| `services` | Services (JSON) | 100 % | ["salle"] | | |
| 46 | +| `dietary_options` | Options alimentaires (JSON) | 5 % | [] | | |
| 47 | +| `images` | Photos (JSON) | 0 % | — | | |
| 48 | +| `url` | URL de la fiche source | 100 % | https://www.openstreetmap.org/node/112637139 | | |
| 49 | + | |
| 50 | +## Fréquence & budget | |
| 51 | + | |
| 52 | +- **Cadence déclarée (registre)** : mensuel | |
| 53 | +- **Cadence observée** (médiane sync_log) : ≈ 30 min | |
| 54 | +- **Dernier passage OK** : 2026-08-18 03:12 — 8616 trouvées, +20 / ~1687 / -0 | |
| 55 | + | |
| 56 | +## Volumétrie & complétude | |
| 57 | + | |
| 58 | +- **Fiches en BD** : 13969 au total, **13969 actives** | |
| 59 | +- **Première ingestion** : 2026-08-17 · **Dernière observation** : 2026-08-18 | |
| 60 | +- **Complétude clé (actives)** : GPS 100 % · adresse 53 % · cuisines 100 % · horaires 21 % · site web 24 % | |
| 61 | +- **Runs journalisés (60 derniers)** : 6, dont 0 en erreur | |
| 62 | + | |
| 63 | +## Erreurs connues & dépannage | |
| 64 | + | |
| 65 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 66 | + | |
| 67 | +Rejouer la source seule : `python3 run.py sync osm` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='osm' ORDER BY ts DESC LIMIT 5;`). | |
| 68 | + | |
| 69 | +## Licence, attribution & conditions | |
| 70 | + | |
| 71 | +- **Cadre d'accès (registre, `acces_legal`)** : Données © contributeurs OpenStreetMap, licence ODbL — attribution affichée (pages Sources et pied de page). API Overpass publique, requêtes espacées, miroirs en rotation. | |
| 72 | +- **Licence** : ODbL — « Données © contributeurs OpenStreetMap » ; attribution affichée sur les pages Sources et le pied de page de Resto-Ka. | |
| 73 | +- Retrait sur demande : contact@spboucher.ai. | |
| 74 | + | |
| 75 | +## Historique | |
| 76 | + | |
| 77 | +- 2026-08-17 — premières fiches de la source ingérées dans la BD. | |
| 78 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
docs/connecteurs/site-resto.md
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +# Sites web des restos (menus maison) — connecteur `site-resto` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Palier (tier) : 3 · Backend : Firecrawl · Restos actifs : 86/86 | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur « sites web des restos » (Palier 3) — capte les MENUS MAISON des restaurants découverts par OSM qui publient un site web. Pipeline par site (budget par passage, incrémental via detail_cache) : 1. GET la page d'accueil (requests direct, UA RestoKaBot). 2. DÉTECTION DE PLATEFORME (CLAUDE.md §9 P3) : si le site embarque UEAT (order.ueat.io/integration/<GUID> ou *.order-online.ai), le GUID est consigné dans data/ueat-discovered.json — le connecteur UEAT (Palier 1, menu structuré parfait) le couvrira au prochain passage. Aucune fiche émise ici. 3. Sinon : trouver le lien « menu/carte », récupérer la page (Firecrawl en secours pour les sites JS, budget limité) ou le PDF du menu. 4. Structuration par Claude Haiku 4.5 (restoka/menullm.py) — sorties structurées JSON + validation STRICTE des prix. 5. Émettre une fiche `site-resto` clonée de la fiche OSM avec le menu ; la déduplication masque la fiche OSM derrière elle. Contexte de prix produit : DINE-IN (menu affiché par le resto lui-même — la référence idéale, §6.2). price_source: site-resto. Les fiches déjà captées sont RÉÉMISES depuis la base à chaque passage (délai de grâce) et re-crawlées après REFRESH_DAYS. | |
| 10 | + | |
| 11 | +- **Plateforme** : sites-web · **Site** : https://www.resto-ka.com/sources | |
| 12 | +- **Extraction (registre)** : Crawl des sites web publiés par les restos découverts via OSM : détection de plateforme (UEAT → GUID consigné dans ueat-discovered.json), sinon page menu HTML (requests, Firecrawl en secours pour le JS) ou PDF → structuration par Claude Haiku 4.5 (sorties structurées JSON) → validation stricte des prix (≥5 plats avec prix, ≥40 % des items, 0,50–500 $). Budget de 250 sites/passage, re-crawl mensuel. | |
| 13 | +- **Contexte de prix** : dine-in | |
| 14 | +- **Module** : `restoka/connectors/siteresto.py` — `SiteRestoConnector` | |
| 15 | + | |
| 16 | +## Accès | |
| 17 | + | |
| 18 | +- **Type d'accès** : pages HTML (rendu serveur) | |
| 19 | +- **Endpoint de base** : https://{m.group(1 | |
| 20 | +- **URLs du module** : https://{m.group(1 | |
| 21 | +- **Pagination** : itération sur 11 racines (constante `_RESERVATION_DOMAINS`) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA RestoKaBot, throttling poli) ; Firecrawl (HTML rendu, JavaScript exécuté) | |
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 20 s, UA `RestoKaBot/1.0 (+https://www.resto-ka.com/bot)` | |
| 24 | +- **Authentification** : aucune — accès public/anonyme | |
| 25 | + | |
| 26 | +## Champs récupérés → schéma cible | |
| 27 | + | |
| 28 | +Le connecteur alimente la table `restaurants` (et `menus` le cas échéant). Complétude mesurée en SQL sur les 86 fiches actives ; exemple tiré d'une ligne réelle de la BD. | |
| 29 | + | |
| 30 | +| Colonne `restaurants` | Contenu | Renseignée (actives) | Exemple réel | | |
| 31 | +|---|---|---|---| | |
| 32 | +| `name` | Nom de l'établissement | 100 % | Chez Roger | | |
| 33 | +| `chain` | Chaîne / bannière | 5 % | — | | |
| 34 | +| `cuisines` | Cuisines (JSON, taxonomie §6.1) | 100 % | ["fruits-de-mer", "bbq-grillades", "cafe-dessert", "fast-food"] | | |
| 35 | +| `establishment_type` | Type d'établissement | 100 % | restaurant | | |
| 36 | +| `price_range` | Fourchette de prix | 100 % | $ | | |
| 37 | +| `address` | Adresse civique | 86 % | 2316 Rue Beaubien Est | | |
| 38 | +| `city` | Ville | 10 % | — | | |
| 39 | +| `region` | Région administrative | 100 % | Montréal | | |
| 40 | +| `postal_code` | Code postal | 45 % | — | | |
| 41 | +| `lat` | GPS (lat/lng) | 100 % | 45.5470572 | | |
| 42 | +| `phone` | Téléphone | 83 % | +1 514 593 4200 | | |
| 43 | +| `website` | Site web | 100 % | https://www.chezroger.ca/ | | |
| 44 | +| `hours` | Horaires structurés (JSON) | 50 % | {"osm": "Mo-Su 17:00-23:00", "mon": [["17:00", "23:00"]], "tue": [["17:00", "23:00"]], "w… | | |
| 45 | +| `services` | Services (JSON) | 100 % | ["salle"] | | |
| 46 | +| `dietary_options` | Options alimentaires (JSON) | 70 % | [] | | |
| 47 | +| `images` | Photos (JSON) | 0 % | — | | |
| 48 | +| `url` | URL de la fiche source | 100 % | https://www.chezroger.ca/restaurant/menus.html | | |
| 49 | + | |
| 50 | +**Menus** : 86 menus rattachés (4667 items, 1 contexte(s) de prix, dernière capture 2026-08-18T07:17:34Z) — table `menus` (sections → items → options, prix CAD). | |
| 51 | + | |
| 52 | +## Fréquence & budget | |
| 53 | + | |
| 54 | +- **Cadence déclarée (registre)** : hebdomadaire (batch incrémental), re-crawl mensuel | |
| 55 | +- **Cadence observée** (médiane sync_log) : ≈ 25 min | |
| 56 | +- **Dernier passage OK** : 2026-08-18 03:17 — 86 trouvées, +82 / ~3 / -0 | |
| 57 | + | |
| 58 | +## Volumétrie & complétude | |
| 59 | + | |
| 60 | +- **Fiches en BD** : 86 au total, **86 actives** | |
| 61 | +- **Première ingestion** : 2026-08-18 · **Dernière observation** : 2026-08-18 | |
| 62 | +- **Complétude clé (actives)** : GPS 100 % · adresse 86 % · cuisines 100 % · horaires 50 % · site web 100 % | |
| 63 | +- **Runs journalisés (60 derniers)** : 3, dont 0 en erreur | |
| 64 | + | |
| 65 | +## Erreurs connues & dépannage | |
| 66 | + | |
| 67 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 68 | + | |
| 69 | +Rejouer la source seule : `python3 run.py sync site-resto` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='site-resto' ORDER BY ts DESC LIMIT 5;`). | |
| 70 | + | |
| 71 | +## Licence, attribution & conditions | |
| 72 | + | |
| 73 | +- **Cadre d'accès (registre, `acces_legal`)** : Sites publics de première partie (le menu affiché par le resto lui-même — la voie la plus légitime, CLAUDE.md §15). UA identifiable RestoKaBot, 1 req/s, aucun contournement. | |
| 74 | +- **Scraping / API** : User-Agent identifiable `RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)`, throttling poli, aucun contournement d'accès ; les fiches pointent vers la source d'origine. | |
| 75 | +- Retrait sur demande : contact@spboucher.ai. | |
| 76 | + | |
| 77 | +## Historique | |
| 78 | + | |
| 79 | +- 2026-08-18 — premières fiches de la source ingérées dans la BD. | |
| 80 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
docs/connecteurs/sthubert.md
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +# St-Hubert (commande en ligne) — connecteur `sthubert` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : à faire** · Palier (tier) : 6 · Backend : — | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +- **Plateforme** : st-hubert · **Site** : https://commande.st-hubert.com | |
| 10 | +- **Extraction (registre)** : À déterminer — Akamai anti-bot sur st-hubert.com ; commande.st-hubert.com rend une page vide même via Scrapfly ASP (flux JS multi-étapes, sélection de succursale requise). Prochain essai : rejouer les XHR internes après sélection d'une succursale (js_scenario Scrapfly). | |
| 11 | +- **Contexte de prix** : takeout | |
| 12 | +- **Module** : aucun (connecteur à écrire — voir statut) | |
| 13 | + | |
| 14 | +## Accès | |
| 15 | + | |
| 16 | +- Connecteur non écrit — accès prévu (registre) : À déterminer — Akamai anti-bot sur st-hubert.com ; commande.st-hubert.com rend une page vide même via Scrapfly ASP (flux JS multi-étapes, sélection de succursale requise). Prochain essai : rejouer les XHR internes après sélection d'une succursale (js_scenario Scrapfly). | |
| 17 | + | |
| 18 | +## Champs récupérés → schéma cible | |
| 19 | + | |
| 20 | +Aucune fiche en BD pour cette source (connecteur en attente ou clé manquante) — schéma cible : table `restaurants` + `menus`. | |
| 21 | + | |
| 22 | +## Fréquence & budget | |
| 23 | + | |
| 24 | +- **Cadence déclarée (registre)** : hebdomadaire (visé) | |
| 25 | +- **Cadence observée** (médiane sync_log) : — | |
| 26 | + | |
| 27 | +## Volumétrie & complétude | |
| 28 | + | |
| 29 | +- Aucune donnée en BD pour cette source. | |
| 30 | +- **Runs journalisés (60 derniers)** : 0, dont 0 en erreur | |
| 31 | + | |
| 32 | +## Erreurs connues & dépannage | |
| 33 | + | |
| 34 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 35 | + | |
| 36 | +**Note du registre** : à faire | |
| 37 | + | |
| 38 | +Rejouer la source seule : `python3 run.py sync sthubert` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='sthubert' ORDER BY ts DESC LIMIT 5;`). | |
| 39 | + | |
| 40 | +## Licence, attribution & conditions | |
| 41 | + | |
| 42 | +- **Cadre d'accès (registre, `acces_legal`)** : Site de commande de la chaîne elle-même (prix réels). À industrialiser prudemment (CLAUDE.md §15). | |
| 43 | +- **Scraping / API** : User-Agent identifiable `RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)`, throttling poli, aucun contournement d'accès ; les fiches pointent vers la source d'origine. | |
| 44 | +- Retrait sur demande : contact@spboucher.ai. | |
| 45 | + | |
| 46 | +## Historique | |
| 47 | + | |
| 48 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
docs/connecteurs/ueat.md
+83 −0
@@ -0,0 +1,83 @@ | ||
| 1 | +# UEAT (commande en ligne) — connecteur `ueat` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : actif** · Palier (tier) : 1 · Backend : direct · Restos actifs : 934/936 | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur TEMPLATISÉ pour UEAT (foodtech de Québec — plateforme de commande en ligne marque blanche, très répandue au QC). Couvre tous les restos listés dans data/sources.json -> source « ueat » -> « integrations » (un GUID d'intégration par marque/resto). Mode d'extraction : API GraphQL interne (https://api.ueat.io/graphql), la même que consomme le widget de commande officiel — données structurées, aucun rendu navigateur requis. Auth anonyme : en-têtes `x-ueatapikey: <GUID d'intégration>` + `x-ueatculture: fr`. Contexte de prix produit : TAKEOUT (prix réels de la commande en ligne du resto lui-même, non majorés — source de première partie, voir CLAUDE.md §9 Palier 1 et §15). Couverture par intégration : - restaurant { name url logo } -> identité de la marque - franchises(modeType: TAKEOUT) -> UNE FICHE PAR ADRESSE (succursales reliées par `chain`, jamais fusionnées — §12.1) - menu { categories } + items(categoryId) -> sections & plats (sous-catégories via menu(parentCategoryId), récursif) - variations d'item -> options « Format » - item.question (1er niveau) -> groupes d'options (garnitures, choix de base…) — mis en cache par marque, refait seulement quand le menu change (detail_cache). Limite documentée : le menu est capté au niveau de la MARQUE (menu par défaut du siège). Les écarts locaux de prix entre succursales d'une même bannière ne sont pas captés (menus standardisés §9 P6). | |
| 10 | + | |
| 11 | +- **Plateforme** : ueat · **Site** : https://ueat.io | |
| 12 | +- **Extraction (registre)** : API GraphQL interne (api.ueat.io/graphql), auth anonyme par GUID d'intégration — la même API que le widget officiel de commande | |
| 13 | +- **Contexte de prix** : takeout | |
| 14 | +- **Module** : `restoka/connectors/ueat.py` — `UeatConnector` | |
| 15 | +- **Intégrations recensées** : 50 clés GUID (48 actives, 2 retirées) — voir `data/sources.json` et `data/ueat-discovered.json` | |
| 16 | + | |
| 17 | +## Accès | |
| 18 | + | |
| 19 | +- **Type d'accès** : API GraphQL interne | |
| 20 | +- **Endpoint de base** : https://api.ueat.io/graphql | |
| 21 | +- **URLs du module** : https://api.ueat.io/graphql · https://order.ueat.io/integration/{guid}/fr | |
| 22 | +- **Pagination** : réponse unique (pas de pagination) | |
| 23 | +- **Backend anti-bot / rendu** : requests direct (session UA RestoKaBot, throttling poli) | |
| 24 | +- **Politesse** : 0.35 s entre requêtes, timeout 30 s, UA `RestoKaBot/1.0 (+https://www.resto-ka.com/bot)` | |
| 25 | +- **Authentification** : aucune — accès public/anonyme | |
| 26 | + | |
| 27 | +## Champs récupérés → schéma cible | |
| 28 | + | |
| 29 | +Le connecteur alimente la table `restaurants` (et `menus` le cas échéant). Complétude mesurée en SQL sur les 934 fiches actives ; exemple tiré d'une ligne réelle de la BD. | |
| 30 | + | |
| 31 | +| Colonne `restaurants` | Contenu | Renseignée (actives) | Exemple réel | | |
| 32 | +|---|---|---|---| | |
| 33 | +| `name` | Nom de l'établissement | 100 % | Poulet Rouge - Alexis-Nihon | | |
| 34 | +| `chain` | Chaîne / bannière | 98 % | Poulet Rouge | | |
| 35 | +| `cuisines` | Cuisines (JSON, taxonomie §6.1) | 100 % | ["poulet", "quebecois", "cafe-dessert", "vegetarien-vegan"] | | |
| 36 | +| `establishment_type` | Type d'établissement | 100 % | restaurant | | |
| 37 | +| `price_range` | Fourchette de prix | 91 % | $ | | |
| 38 | +| `address` | Adresse civique | 100 % | 1500 Avenue Atwater | | |
| 39 | +| `city` | Ville | 100 % | Montréal | | |
| 40 | +| `region` | Région administrative | 100 % | Montréal | | |
| 41 | +| `postal_code` | Code postal | 100 % | H3Z 1X5 | | |
| 42 | +| `lat` | GPS (lat/lng) | 100 % | 45.488805 | | |
| 43 | +| `phone` | Téléphone | 100 % | 514 419-2191 | | |
| 44 | +| `website` | Site web | 96 % | https://poulet-rouge.ca/ | | |
| 45 | +| `hours` | Horaires structurés (JSON) | 0 % | {} | | |
| 46 | +| `services` | Services (JSON) | 100 % | ["takeout"] | | |
| 47 | +| `dietary_options` | Options alimentaires (JSON) | 82 % | ["vegetarien"] | | |
| 48 | +| `images` | Photos (JSON) | 100 % | 5 photo(s) — https://storage.googleapis.com/ueat-assets/b914c5ae-48e4-44d2-9df1-03ceeb04f… | | |
| 49 | +| `url` | URL de la fiche source | 100 % | https://order.ueat.io/integration/4268100d-6470-40b9-a755-e11342c91d6d/fr | | |
| 50 | + | |
| 51 | +**Menus** : 934 menus rattachés (91881 items, 1 contexte(s) de prix, dernière capture 2026-08-18T07:25:47Z) — table `menus` (sections → items → options, prix CAD). | |
| 52 | + | |
| 53 | +## Fréquence & budget | |
| 54 | + | |
| 55 | +- **Cadence déclarée (registre)** : hebdomadaire | |
| 56 | +- **Cadence observée** (médiane sync_log) : ≈ 70 min | |
| 57 | +- **Dernier passage OK** : 2026-08-18 03:25 — 934 trouvées, +84 / ~0 / -1 | |
| 58 | +- **Caps / budgets du module** : `MAX_DEPTH` = 3, `MAX_QUESTION_FETCH` = 400 | |
| 59 | +- **Cache des payloads détail** : activé (table `detail_cache`) | |
| 60 | + | |
| 61 | +## Volumétrie & complétude | |
| 62 | + | |
| 63 | +- **Fiches en BD** : 936 au total, **934 actives** | |
| 64 | +- **Première ingestion** : 2026-08-17 · **Dernière observation** : 2026-08-18 | |
| 65 | +- **Complétude clé (actives)** : GPS 100 % · adresse 100 % · cuisines 100 % · horaires 0 % · site web 96 % | |
| 66 | +- **Runs journalisés (60 derniers)** : 6, dont 0 en erreur | |
| 67 | + | |
| 68 | +## Erreurs connues & dépannage | |
| 69 | + | |
| 70 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 71 | + | |
| 72 | +Rejouer la source seule : `python3 run.py sync ueat` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='ueat' ORDER BY ts DESC LIMIT 5;`). | |
| 73 | + | |
| 74 | +## Licence, attribution & conditions | |
| 75 | + | |
| 76 | +- **Cadre d'accès (registre, `acces_legal`)** : Source de première partie : plateforme de commande des restos eux-mêmes, prix réels non majorés (CLAUDE.md §9 Palier 1, §15). API anonyme publique, throttling poli 0,35 s, User-Agent identifiable RestoKaBot. | |
| 77 | +- **Scraping / API** : User-Agent identifiable `RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)`, throttling poli, aucun contournement d'accès ; les fiches pointent vers la source d'origine. | |
| 78 | +- Retrait sur demande : contact@spboucher.ai. | |
| 79 | + | |
| 80 | +## Historique | |
| 81 | + | |
| 82 | +- 2026-08-17 — premières fiches de la source ingérées dans la BD. | |
| 83 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
docs/connecteurs/yelp.md
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +# Yelp Fusion (avis et notes) — connecteur `yelp` | |
| 2 | + | |
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03:30 — ne pas éditer à la main, régénérer._ | |
| 4 | + | |
| 5 | +**État : clé requise** · Palier (tier) : 5 · Backend : direct | |
| 6 | + | |
| 7 | +## Description de la source | |
| 8 | + | |
| 9 | +Connecteur d'ENRICHISSEMENT Yelp Fusion (avis/notes) — gratuit, 500 requêtes/jour. N'émet AUCUNE fiche restaurant : il complète les fiches existantes avec rating, review_count, price, categories et phone dans details.yelp (colonne d'enrichissement, jamais écrasée par les re-crawls des sources). Croisement CONSERVATEUR (jamais fusionner deux établissements) : 1. par TÉLÉPHONE (/businesses/search/phone) — signal fort ; si plusieurs résultats, on exige le GPS le plus proche (<300 m) ; 2. sinon par NOM+ADRESSE+GPS (/businesses/matches) — l'endpoint d'appariement officiel de Yelp, seuil par défaut (conservateur). Clé requise : YELP_API_KEY dans .env (https://www.yelp.com/developers — app gratuite). Sans clé, le connecteur lève SkipSource et le pipeline passe son tour SANS marquer d'échec (statut « clé requise » dans data/sources.json). Base légale : API officielle Yelp Fusion, conditions Display Requirements (attribution Yelp affichée avec la note sur la fiche). | |
| 10 | + | |
| 11 | +- **Plateforme** : yelp · **Site** : https://www.yelp.com/developers | |
| 12 | +- **Extraction (registre)** : API officielle Yelp Fusion (gratuit, 500 req/jour) : rating, review_count, price, categories, phone -> details.yelp. Croisement conservateur par téléphone (/businesses/search/phone, GPS <300 m si ambigu) puis nom+adresse+GPS (/businesses/matches). N'émet aucune fiche. | |
| 13 | +- **Contexte de prix** : aucun | |
| 14 | +- **Module** : `restoka/connectors/yelp.py` — `YelpConnector` | |
| 15 | + | |
| 16 | +## Accès | |
| 17 | + | |
| 18 | +- **Type d'accès** : API JSON | |
| 19 | +- **Endpoint de base** : https://api.yelp.com/v3 | |
| 20 | +- **URLs du module** : https://www.yelp.com/developers · https://api.yelp.com/v3 | |
| 21 | +- **Pagination** : réponse unique (pas de pagination) | |
| 22 | +- **Backend anti-bot / rendu** : requests direct (session UA RestoKaBot, throttling poli) | |
| 23 | +- **Politesse** : 0.35 s entre requêtes, timeout 20 s, UA `RestoKaBot/1.0 (+https://www.resto-ka.com/bot)` | |
| 24 | +- **Authentification** : clé API requise (voir statut) | |
| 25 | + | |
| 26 | +## Champs récupérés → schéma cible | |
| 27 | + | |
| 28 | +Aucune fiche en BD pour cette source (connecteur en attente ou clé manquante) — schéma cible : table `restaurants` + `menus`. | |
| 29 | + | |
| 30 | +## Fréquence & budget | |
| 31 | + | |
| 32 | +- **Cadence déclarée (registre)** : hebdomadaire, re-vérification 30 jours | |
| 33 | +- **Cadence observée** (médiane sync_log) : — | |
| 34 | +- **Caps / budgets du module** : `MAX_PHONE_DISTANCE_M` = 300 | |
| 35 | + | |
| 36 | +## Volumétrie & complétude | |
| 37 | + | |
| 38 | +- Aucune donnée en BD pour cette source. | |
| 39 | +- **Runs journalisés (60 derniers)** : 0, dont 0 en erreur | |
| 40 | + | |
| 41 | +## Erreurs connues & dépannage | |
| 42 | + | |
| 43 | +Aucune erreur dans les 60 derniers runs journalisés. | |
| 44 | + | |
| 45 | +**Note du registre** : clé requise (YELP_API_KEY absente de .env — connecteur livré, SkipSource) | |
| 46 | + | |
| 47 | +Rejouer la source seule : `python3 run.py sync yelp` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='yelp' ORDER BY ts DESC LIMIT 5;`). | |
| 48 | + | |
| 49 | +## Licence, attribution & conditions | |
| 50 | + | |
| 51 | +- **Cadre d'accès (registre, `acces_legal`)** : API officielle avec clé, Display Requirements Yelp : attribution affichée avec la note. | |
| 52 | +- **Scraping / API** : User-Agent identifiable `RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)`, throttling poli, aucun contournement d'accès ; les fiches pointent vers la source d'origine. | |
| 53 | +- Retrait sur demande : contact@spboucher.ai. | |
| 54 | + | |
| 55 | +## Historique | |
| 56 | + | |
| 57 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | |
added
scripts/gen_connector_docs.py
+563 −0
@@ -0,0 +1,563 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================== | |
| 3 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 4 | +# File: scripts/gen_connector_docs.py | |
| 5 | +# Desc: Documentation STANDARDISÉE des connecteurs Resto-Ka — génère | |
| 6 | +# docs/connecteurs/INDEX.md + une fiche docs/connecteurs/<id>.md par | |
| 7 | +# source du registre, de façon 100 % programmatique et rejouable : | |
| 8 | +# 1. registre data/sources.json (nom, tier, extraction, acces_legal, | |
| 9 | +# cadence déclarée, statut) ; | |
| 10 | +# 2. introspection STATIQUE (ast) de restoka/connectors/*.py et | |
| 11 | +# restoka/inspections.py : classe, backend, endpoints, pagination, | |
| 12 | +# constantes de budget — sans exécuter les connecteurs ; | |
| 13 | +# 3. BD live data/restoka.db : volumétrie, complétude des champs par | |
| 14 | +# source (SQL), menus, dernier sync OK, cadence observée, erreurs. | |
| 15 | +# Usage : python3 scripts/gen_connector_docs.py (racine du projet) | |
| 16 | +# ============================================================================== | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import ast | |
| 20 | +import json | |
| 21 | +import re | |
| 22 | +import sqlite3 | |
| 23 | +import statistics | |
| 24 | +from datetime import datetime | |
| 25 | +from pathlib import Path | |
| 26 | + | |
| 27 | +ROOT = Path(__file__).resolve().parents[1] | |
| 28 | +CONN_DIR = ROOT / "restoka" / "connectors" | |
| 29 | +EXTRA_MODULES = [ROOT / "restoka" / "inspections.py"] # mapaq (enrichissement) | |
| 30 | +DOCS_DIR = ROOT / "docs" / "connecteurs" | |
| 31 | +DB_PATH = ROOT / "data" / "restoka.db" | |
| 32 | +SOURCES_JSON = ROOT / "data" / "sources.json" | |
| 33 | + | |
| 34 | +SKIP_MODULES = {"__init__", "base"} | |
| 35 | +URL_RE = re.compile(r"https?://[^\s\"'\\)>,;]+") | |
| 36 | +DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") | |
| 37 | +INFRA_HOSTS = ("api.firecrawl.dev", "api.scrapfly.io", "resto-ka.com", | |
| 38 | + "nominatim") | |
| 39 | + | |
| 40 | +FIELDS = [ | |
| 41 | + ("name", "Nom de l'établissement", "name IS NOT NULL AND name != ''"), | |
| 42 | + ("chain", "Chaîne / bannière", "chain IS NOT NULL AND chain != ''"), | |
| 43 | + ("cuisines", "Cuisines (JSON, taxonomie §6.1)", | |
| 44 | + "cuisines IS NOT NULL AND length(cuisines) > 4"), | |
| 45 | + ("establishment_type", "Type d'établissement", | |
| 46 | + "establishment_type IS NOT NULL AND establishment_type != ''"), | |
| 47 | + ("price_range", "Fourchette de prix", | |
| 48 | + "price_range IS NOT NULL AND price_range != ''"), | |
| 49 | + ("address", "Adresse civique", "address IS NOT NULL AND address != ''"), | |
| 50 | + ("city", "Ville", "city IS NOT NULL AND city != ''"), | |
| 51 | + ("region", "Région administrative", "region IS NOT NULL AND region != ''"), | |
| 52 | + ("postal_code", "Code postal", | |
| 53 | + "postal_code IS NOT NULL AND postal_code != ''"), | |
| 54 | + ("lat", "GPS (lat/lng)", "lat IS NOT NULL AND lng IS NOT NULL"), | |
| 55 | + ("phone", "Téléphone", "phone IS NOT NULL AND phone != ''"), | |
| 56 | + ("website", "Site web", "website IS NOT NULL AND website != ''"), | |
| 57 | + ("hours", "Horaires structurés (JSON)", | |
| 58 | + "hours IS NOT NULL AND length(hours) > 4"), | |
| 59 | + ("services", "Services (JSON)", "services IS NOT NULL AND length(services) > 4"), | |
| 60 | + ("dietary_options", "Options alimentaires (JSON)", | |
| 61 | + "dietary_options IS NOT NULL AND length(dietary_options) > 4"), | |
| 62 | + ("images", "Photos (JSON)", "images IS NOT NULL AND length(images) > 4"), | |
| 63 | + ("url", "URL de la fiche source", "url IS NOT NULL AND url != ''"), | |
| 64 | +] | |
| 65 | + | |
| 66 | + | |
| 67 | +def esc(s: str, limit: int = 100) -> str: | |
| 68 | + s = str(s).replace("\\", "\\\\").replace("|", "\\|") | |
| 69 | + s = re.sub(r"\s+", " ", s).strip() | |
| 70 | + return s[: limit - 1] + "…" if len(s) > limit else s | |
| 71 | + | |
| 72 | + | |
| 73 | +def pct(n, d) -> str: | |
| 74 | + return f"{100.0 * (n or 0) / d:.0f} %" if d else "—" | |
| 75 | + | |
| 76 | + | |
| 77 | +def fmt_ts(ts) -> str: | |
| 78 | + if not ts: | |
| 79 | + return "—" | |
| 80 | + return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M") | |
| 81 | + | |
| 82 | + | |
| 83 | +def fmt_secs(sec: float) -> str: | |
| 84 | + if sec < 5400: | |
| 85 | + return f"≈ {sec / 60:.0f} min" | |
| 86 | + if sec < 129600: | |
| 87 | + return f"≈ {sec / 3600:.1f} h" | |
| 88 | + return f"≈ {sec / 86400:.1f} j" | |
| 89 | + | |
| 90 | + | |
| 91 | +def example_value(col: str, value) -> str: | |
| 92 | + if value is None or value == "": | |
| 93 | + return "—" | |
| 94 | + if col == "images": | |
| 95 | + try: | |
| 96 | + imgs = json.loads(value) | |
| 97 | + return esc(f"{len(imgs)} photo(s) — {imgs[0]}", 90) if imgs else "—" | |
| 98 | + except (ValueError, TypeError): | |
| 99 | + return esc(value, 90) | |
| 100 | + return esc(value, 90) | |
| 101 | + | |
| 102 | + | |
| 103 | +# -- introspection statique ------------------------------------------------------ | |
| 104 | + | |
| 105 | +def banner_description(text: str, filename: str) -> str: | |
| 106 | + lines, started = [], False | |
| 107 | + for raw in text.splitlines(): | |
| 108 | + if not raw.startswith("#"): | |
| 109 | + if started: | |
| 110 | + break | |
| 111 | + continue | |
| 112 | + body = raw.lstrip("#").strip() | |
| 113 | + if set(body) <= {"-", "="}: | |
| 114 | + continue | |
| 115 | + if not started: | |
| 116 | + if body.startswith("Desc:"): | |
| 117 | + started = True | |
| 118 | + lines.append(body[len("Desc:"):].strip()) | |
| 119 | + continue | |
| 120 | + if re.match(r"^(Author|File):", body): | |
| 121 | + break | |
| 122 | + lines.append(body) | |
| 123 | + return " ".join(l for l in lines if l).strip() | |
| 124 | + | |
| 125 | + | |
| 126 | +def detect_backends(text: str) -> tuple[list[str], str]: | |
| 127 | + detailed, family = [], "direct" | |
| 128 | + if re.search(r"self\.(get|post)\(|requests\.(get|post)\(", text): | |
| 129 | + detailed.append("requests direct (session UA RestoKaBot, throttling poli)") | |
| 130 | + if ".scrapfly(" in text: | |
| 131 | + detailed.append("Scrapfly (asp + render_js — contournement anti-bot)") | |
| 132 | + family = "Scrapfly" | |
| 133 | + if "get_rendered(" in text: | |
| 134 | + detailed.append("Firecrawl (HTML rendu, JavaScript exécuté)") | |
| 135 | + family = "Firecrawl" if family == "direct" else family | |
| 136 | + if not detailed: | |
| 137 | + detailed.append("requests direct") | |
| 138 | + return detailed, family | |
| 139 | + | |
| 140 | + | |
| 141 | +def detect_flavor(text: str) -> str: | |
| 142 | + low = text.lower() | |
| 143 | + if "graphql" in low: | |
| 144 | + return "API GraphQL interne" | |
| 145 | + if "overpass" in low: | |
| 146 | + return "API Overpass (OpenStreetMap)" | |
| 147 | + if "listecondamnation" in low or "donneesquebec" in low or "données québec" in low: | |
| 148 | + return "jeu de données ouvert (CSV, Données Québec)" | |
| 149 | + if "sitemap" in low: | |
| 150 | + return "sitemap XML + pages HTML" | |
| 151 | + if re.search(r"\.json\(\)", text) and re.search(r"api[./_]", low): | |
| 152 | + return "API JSON" | |
| 153 | + return "pages HTML (rendu serveur)" | |
| 154 | + | |
| 155 | + | |
| 156 | +def detect_pagination(text: str, constants: dict) -> str: | |
| 157 | + hits, low = [], text.lower() | |
| 158 | + if re.search(r"[?&]page=|[\"']page[\"']\s*[:=]|paged", low): | |
| 159 | + hits.append("pagination par numéro de page") | |
| 160 | + if re.search(r"[?&]offset=|[\"']offset[\"']", low): | |
| 161 | + hits.append("pagination par offset") | |
| 162 | + if "cursor" in low: | |
| 163 | + hits.append("curseur de pagination") | |
| 164 | + lists = [k for k, v in constants.items() | |
| 165 | + if isinstance(v, (list, tuple)) and len(v) > 1] | |
| 166 | + if lists: | |
| 167 | + hits.append(f"itération sur {len(constants[lists[0]])} racines " | |
| 168 | + f"(constante `{lists[0]}`)") | |
| 169 | + if not hits: | |
| 170 | + hits.append("réponse unique (pas de pagination)") | |
| 171 | + return " ; ".join(hits) | |
| 172 | + | |
| 173 | + | |
| 174 | +def introspect_module(path: Path) -> list[dict]: | |
| 175 | + text = path.read_text(encoding="utf-8") | |
| 176 | + try: | |
| 177 | + tree = ast.parse(text) | |
| 178 | + except SyntaxError: | |
| 179 | + return [] | |
| 180 | + constants: dict = {} | |
| 181 | + for node in tree.body: | |
| 182 | + if isinstance(node, ast.Assign) and len(node.targets) == 1 \ | |
| 183 | + and isinstance(node.targets[0], ast.Name) \ | |
| 184 | + and node.targets[0].id.isupper(): | |
| 185 | + try: | |
| 186 | + constants[node.targets[0].id] = ast.literal_eval(node.value) | |
| 187 | + except (ValueError, TypeError, SyntaxError): | |
| 188 | + seg = ast.get_source_segment(text, node.value) or "" | |
| 189 | + urls = URL_RE.findall(seg) | |
| 190 | + constants[node.targets[0].id] = urls if len(urls) > 1 else \ | |
| 191 | + (urls[0] if urls else None) | |
| 192 | + urls_all = [] | |
| 193 | + for u in URL_RE.findall(text): | |
| 194 | + u = u.rstrip('".') | |
| 195 | + host = u.split("//", 1)[-1].split("/", 1)[0] | |
| 196 | + if "." not in host: # fragment de f-string, pas une vraie URL | |
| 197 | + continue | |
| 198 | + if not any(h in u for h in INFRA_HOSTS) and u not in urls_all: | |
| 199 | + urls_all.append(u) | |
| 200 | + endpoint = None | |
| 201 | + for name in ("BASE", "BASE_URL", "API", "API_URL", "API_BASE", "GRAPHQL", | |
| 202 | + "CSV_URL", "DATASET_URL", "ROOT", "URL"): | |
| 203 | + v = constants.get(name) | |
| 204 | + if isinstance(v, str) and v.startswith("http"): | |
| 205 | + endpoint = v | |
| 206 | + break | |
| 207 | + if not endpoint and urls_all: | |
| 208 | + endpoint = urls_all[0] | |
| 209 | + budgets = {k: v for k, v in constants.items() | |
| 210 | + if isinstance(v, (int, float)) and not isinstance(v, bool) | |
| 211 | + and re.search(r"MAX|CAP|LIMIT|BUDGET|TTL|PER_PAGE|PAGES|DELAY|BATCH", k)} | |
| 212 | + backends, family = detect_backends(text) | |
| 213 | + rel = path.relative_to(ROOT) | |
| 214 | + base = { | |
| 215 | + "module": path.stem, "path": str(rel), | |
| 216 | + "banner": banner_description(text, path.name), | |
| 217 | + "endpoint": endpoint, "urls": urls_all[:5], "budgets": budgets, | |
| 218 | + "backends": backends, "backend_family": family, | |
| 219 | + "flavor": detect_flavor(text), | |
| 220 | + "pagination": detect_pagination(text, constants), | |
| 221 | + } | |
| 222 | + entries = [] | |
| 223 | + for node in tree.body: | |
| 224 | + if not isinstance(node, ast.ClassDef): | |
| 225 | + continue | |
| 226 | + bases = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases} | |
| 227 | + if "BaseConnector" not in bases: | |
| 228 | + continue | |
| 229 | + attrs = {"request_delay": 0.6, "timeout": 30, "use_detail_cache": True, | |
| 230 | + "source_id": ""} | |
| 231 | + for sub in node.body: | |
| 232 | + if isinstance(sub, ast.Assign) and len(sub.targets) == 1 \ | |
| 233 | + and isinstance(sub.targets[0], ast.Name): | |
| 234 | + try: | |
| 235 | + attrs[sub.targets[0].id] = ast.literal_eval(sub.value) | |
| 236 | + except (ValueError, TypeError, SyntaxError): | |
| 237 | + pass | |
| 238 | + if attrs["source_id"]: | |
| 239 | + entries.append({**base, "class": node.name, | |
| 240 | + "class_doc": ast.get_docstring(node) or "", | |
| 241 | + **attrs}) | |
| 242 | + if not entries and isinstance(constants.get("SOURCE_ID"), str): | |
| 243 | + # module d'enrichissement sans classe (ex. inspections.py / mapaq) | |
| 244 | + entries.append({**base, "class": "(module fonctionnel, pas de classe)", | |
| 245 | + "class_doc": ast.get_docstring(tree) or "", | |
| 246 | + "source_id": constants["SOURCE_ID"], | |
| 247 | + "request_delay": None, "timeout": None, | |
| 248 | + "use_detail_cache": False}) | |
| 249 | + return entries | |
| 250 | + | |
| 251 | + | |
| 252 | +# -- BD live ---------------------------------------------------------------------- | |
| 253 | + | |
| 254 | +def db_stats(con: sqlite3.Connection, sid: str) -> dict: | |
| 255 | + parts = ", ".join( | |
| 256 | + f"sum(CASE WHEN active=1 AND {cond} THEN 1 ELSE 0 END) AS f_{col}" | |
| 257 | + for col, _l, cond in FIELDS) | |
| 258 | + agg = con.execute( | |
| 259 | + f"SELECT count(*) AS total, coalesce(sum(active),0) AS act, " | |
| 260 | + f"min(first_seen) AS first_seen, max(last_seen) AS last_seen, {parts} " | |
| 261 | + f"FROM restaurants WHERE source=?", (sid,)).fetchone() | |
| 262 | + sample = con.execute( | |
| 263 | + "SELECT * FROM restaurants WHERE source=? AND active=1 " | |
| 264 | + "ORDER BY last_seen DESC LIMIT 1", (sid,)).fetchone() | |
| 265 | + menus = con.execute( | |
| 266 | + "SELECT count(*) AS n, coalesce(sum(m.item_count),0) AS items, " | |
| 267 | + "max(m.captured_at) AS fresh, count(DISTINCT m.price_context) AS ctx " | |
| 268 | + "FROM menus m JOIN restaurants r ON r.uid = m.uid WHERE r.source=?", | |
| 269 | + (sid,)).fetchone() | |
| 270 | + inspections = None | |
| 271 | + if sid == "mapaq": | |
| 272 | + inspections = con.execute( | |
| 273 | + "SELECT count(*) AS n, sum(CASE WHEN uid IS NOT NULL THEN 1 ELSE 0 " | |
| 274 | + "END) AS matched, min(date_infraction) AS d0, " | |
| 275 | + "max(date_infraction) AS d1, coalesce(sum(montant_amende),0) " | |
| 276 | + "AS amendes " | |
| 277 | + "FROM inspections").fetchone() | |
| 278 | + runs = con.execute( | |
| 279 | + "SELECT ts, ok, found, added, updated, removed, message FROM sync_log " | |
| 280 | + "WHERE source=? ORDER BY ts DESC LIMIT 60", (sid,)).fetchall() | |
| 281 | + ok_ts = sorted(r["ts"] for r in runs if r["ok"]) | |
| 282 | + cadence = None | |
| 283 | + if len(ok_ts) >= 3: | |
| 284 | + deltas = [b - a for a, b in zip(ok_ts, ok_ts[1:]) if b - a > 60] | |
| 285 | + if deltas: | |
| 286 | + cadence = statistics.median(deltas) | |
| 287 | + return {"agg": agg, "sample": sample, "menus": menus, | |
| 288 | + "inspections": inspections, "runs": runs, "cadence": cadence, | |
| 289 | + "last_ok": next((r for r in runs if r["ok"]), None), | |
| 290 | + "errors": [r for r in runs if not r["ok"]][:5], | |
| 291 | + "err_count": sum(1 for r in runs if not r["ok"])} | |
| 292 | + | |
| 293 | + | |
| 294 | +# -- rendu --------------------------------------------------------------------------- | |
| 295 | + | |
| 296 | +def licence_lines(reg: dict, entry: dict | None) -> list[str]: | |
| 297 | + out = [f"- **Cadre d'accès (registre, `acces_legal`)** : " | |
| 298 | + f"{reg.get('acces_legal', '—')}"] | |
| 299 | + blob = (reg.get("acces_legal") or "") + (reg.get("extraction") or "") | |
| 300 | + low = blob.lower() | |
| 301 | + if "odbl" in low or "openstreetmap" in low: | |
| 302 | + out.append("- **Licence** : ODbL — « Données © contributeurs " | |
| 303 | + "OpenStreetMap » ; attribution affichée sur les pages " | |
| 304 | + "Sources et le pied de page de Resto-Ka.") | |
| 305 | + elif "cc-by" in low or "données québec" in low or ( | |
| 306 | + entry and "donneesquebec" in " ".join(entry.get("urls", []))): | |
| 307 | + out.append("- **Licence** : donnée ouverte CC-BY 4.0 (Données Québec) " | |
| 308 | + "— mention de la source « MAPAQ / Données Québec » affichée.") | |
| 309 | + else: | |
| 310 | + out.append("- **Scraping / API** : User-Agent identifiable " | |
| 311 | + "`RestoKaBot/1.0 (+https://www.resto-ka.com/bot; " | |
| 312 | + "contact@spboucher.ai)`, throttling poli, aucun " | |
| 313 | + "contournement d'accès ; les fiches pointent vers la " | |
| 314 | + "source d'origine.") | |
| 315 | + out.append("- Retrait sur demande : contact@spboucher.ai.") | |
| 316 | + return out | |
| 317 | + | |
| 318 | + | |
| 319 | +def render_fiche(reg: dict, entry: dict | None, stats: dict | None, | |
| 320 | + now: str) -> str: | |
| 321 | + sid = reg["id"] | |
| 322 | + name = reg.get("name", sid) | |
| 323 | + status = reg.get("status", "—") | |
| 324 | + etat = status.split("—")[0].split("(")[0].strip() | |
| 325 | + out = [f"# {name} — connecteur `{sid}`", "", | |
| 326 | + f"_Fiche générée automatiquement par " | |
| 327 | + f"`scripts/gen_connector_docs.py` le {now} — ne pas éditer à la " | |
| 328 | + f"main, régénérer._", ""] | |
| 329 | + agg = stats["agg"] if stats else None | |
| 330 | + vol = f" · Restos actifs : {agg['act']}/{agg['total']}" if agg and \ | |
| 331 | + agg["total"] else "" | |
| 332 | + fam = entry["backend_family"] if entry else "—" | |
| 333 | + out.append(f"**État : {etat}** · Palier (tier) : {reg.get('tier', '—')} · " | |
| 334 | + f"Backend : {fam}{vol}") | |
| 335 | + out.append("") | |
| 336 | + | |
| 337 | + out.append("## Description de la source") | |
| 338 | + out.append("") | |
| 339 | + if entry and entry["banner"]: | |
| 340 | + out.append(entry["banner"]) | |
| 341 | + out.append("") | |
| 342 | + out.append(f"- **Plateforme** : {reg.get('platform', '—')} · **Site** : " | |
| 343 | + f"{reg.get('url', '—')}") | |
| 344 | + out.append(f"- **Extraction (registre)** : {reg.get('extraction', '—')}") | |
| 345 | + out.append(f"- **Contexte de prix** : {reg.get('price_context') or '—'}") | |
| 346 | + if entry: | |
| 347 | + out.append(f"- **Module** : `{entry['path']}` — `{entry['class']}`") | |
| 348 | + else: | |
| 349 | + out.append("- **Module** : aucun (connecteur à écrire — voir statut)") | |
| 350 | + if reg.get("integrations"): | |
| 351 | + n = len(reg["integrations"]) | |
| 352 | + inact = sum(1 for i in reg["integrations"] | |
| 353 | + if i.get("status") == "inactif") | |
| 354 | + out.append(f"- **Intégrations recensées** : {n} clés GUID " | |
| 355 | + f"({n - inact} actives, {inact} retirées) — voir " | |
| 356 | + f"`data/sources.json` et `data/ueat-discovered.json`") | |
| 357 | + out.append("") | |
| 358 | + | |
| 359 | + out.append("## Accès") | |
| 360 | + out.append("") | |
| 361 | + if entry: | |
| 362 | + out.append(f"- **Type d'accès** : {entry['flavor']}") | |
| 363 | + out.append(f"- **Endpoint de base** : {entry['endpoint'] or '—'}") | |
| 364 | + if entry["urls"]: | |
| 365 | + out.append("- **URLs du module** : " + " · ".join(entry["urls"][:4])) | |
| 366 | + out.append(f"- **Pagination** : {entry['pagination']}") | |
| 367 | + out.append(f"- **Backend anti-bot / rendu** : " | |
| 368 | + f"{' ; '.join(entry['backends'])}") | |
| 369 | + if entry["request_delay"] is not None: | |
| 370 | + out.append(f"- **Politesse** : {entry['request_delay']} s entre " | |
| 371 | + f"requêtes, timeout {entry['timeout']} s, UA " | |
| 372 | + f"`RestoKaBot/1.0 (+https://www.resto-ka.com/bot)`") | |
| 373 | + auth = "clé API requise (voir statut)" if "clé" in status else \ | |
| 374 | + "aucune — accès public/anonyme" | |
| 375 | + out.append(f"- **Authentification** : {auth}") | |
| 376 | + else: | |
| 377 | + out.append(f"- Connecteur non écrit — accès prévu (registre) : " | |
| 378 | + f"{reg.get('extraction', '—')}") | |
| 379 | + out.append("") | |
| 380 | + | |
| 381 | + out.append("## Champs récupérés → schéma cible") | |
| 382 | + out.append("") | |
| 383 | + if sid == "mapaq": | |
| 384 | + out.append("Source d'**enrichissement** : alimente la table " | |
| 385 | + "`inspections` (exploitant, établissement, adresse, dates, " | |
| 386 | + "amende, motif) puis croisement conservateur avec " | |
| 387 | + "`restaurants.uid` (nom normalisé + ville/code postal, ou " | |
| 388 | + "code postal + civique + similarité de nom — colonne " | |
| 389 | + "`matched_by`). Pas de fiches restaurant propres.") | |
| 390 | + out.append("") | |
| 391 | + elif agg and agg["total"]: | |
| 392 | + out.append(f"Le connecteur alimente la table `restaurants` (et `menus` " | |
| 393 | + f"le cas échéant). Complétude mesurée en SQL sur les " | |
| 394 | + f"{agg['act']} fiches actives ; exemple tiré d'une ligne " | |
| 395 | + f"réelle de la BD.") | |
| 396 | + out.append("") | |
| 397 | + out.append("| Colonne `restaurants` | Contenu | Renseignée (actives) " | |
| 398 | + "| Exemple réel |") | |
| 399 | + out.append("|---|---|---|---|") | |
| 400 | + sample = stats["sample"] | |
| 401 | + for col, label, _c in FIELDS: | |
| 402 | + ex = example_value(col, sample[col]) if sample is not None else "—" | |
| 403 | + out.append(f"| `{col}` | {label} | {pct(agg[f'f_{col}'], agg['act'])} " | |
| 404 | + f"| {ex} |") | |
| 405 | + out.append("") | |
| 406 | + m = stats["menus"] | |
| 407 | + if m and m["n"]: | |
| 408 | + out.append(f"**Menus** : {m['n']} menus rattachés " | |
| 409 | + f"({m['items']} items, {m['ctx']} contexte(s) de prix, " | |
| 410 | + f"dernière capture {esc(m['fresh'] or '—', 20)}) — table " | |
| 411 | + f"`menus` (sections → items → options, prix CAD).") | |
| 412 | + out.append("") | |
| 413 | + else: | |
| 414 | + out.append("Aucune fiche en BD pour cette source (connecteur en " | |
| 415 | + "attente ou clé manquante) — schéma cible : table " | |
| 416 | + "`restaurants` + `menus`.") | |
| 417 | + out.append("") | |
| 418 | + | |
| 419 | + out.append("## Fréquence & budget") | |
| 420 | + out.append("") | |
| 421 | + out.append(f"- **Cadence déclarée (registre)** : {reg.get('cadence', '—')}") | |
| 422 | + if stats: | |
| 423 | + cad = fmt_secs(stats["cadence"]) if stats["cadence"] else "—" | |
| 424 | + out.append(f"- **Cadence observée** (médiane sync_log) : {cad}") | |
| 425 | + lo = stats["last_ok"] | |
| 426 | + if lo: | |
| 427 | + out.append(f"- **Dernier passage OK** : {fmt_ts(lo['ts'])} — " | |
| 428 | + f"{lo['found'] or 0} trouvées, +{lo['added'] or 0} / " | |
| 429 | + f"~{lo['updated'] or 0} / -{lo['removed'] or 0}") | |
| 430 | + if entry and entry["budgets"]: | |
| 431 | + caps = ", ".join(f"`{k}` = {v}" for k, v in sorted(entry["budgets"].items())) | |
| 432 | + out.append(f"- **Caps / budgets du module** : {caps}") | |
| 433 | + if entry and entry.get("use_detail_cache"): | |
| 434 | + out.append("- **Cache des payloads détail** : activé (table " | |
| 435 | + "`detail_cache`)") | |
| 436 | + out.append("") | |
| 437 | + | |
| 438 | + out.append("## Volumétrie & complétude") | |
| 439 | + out.append("") | |
| 440 | + if agg and agg["total"]: | |
| 441 | + out.append(f"- **Fiches en BD** : {agg['total']} au total, " | |
| 442 | + f"**{agg['act']} actives**") | |
| 443 | + out.append(f"- **Première ingestion** : {fmt_ts(agg['first_seen'])[:10]} " | |
| 444 | + f"· **Dernière observation** : {fmt_ts(agg['last_seen'])[:10]}") | |
| 445 | + out.append(f"- **Complétude clé (actives)** : GPS " | |
| 446 | + f"{pct(agg['f_lat'], agg['act'])} · adresse " | |
| 447 | + f"{pct(agg['f_address'], agg['act'])} · cuisines " | |
| 448 | + f"{pct(agg['f_cuisines'], agg['act'])} · horaires " | |
| 449 | + f"{pct(agg['f_hours'], agg['act'])} · site web " | |
| 450 | + f"{pct(agg['f_website'], agg['act'])}") | |
| 451 | + if stats and stats["inspections"] and stats["inspections"]["n"]: | |
| 452 | + i = stats["inspections"] | |
| 453 | + amendes = f"{i['amendes']:,.0f}".replace(",", " ") | |
| 454 | + out.append(f"- **Inspections MAPAQ** : {i['n']} condamnations " | |
| 455 | + f"({i['matched']} croisées avec un resto, amendes cumulées " | |
| 456 | + f"{amendes} $), infractions de {i['d0']} à {i['d1']}") | |
| 457 | + if not (agg and agg["total"]) and not (stats and stats["inspections"]): | |
| 458 | + out.append("- Aucune donnée en BD pour cette source.") | |
| 459 | + if stats: | |
| 460 | + out.append(f"- **Runs journalisés (60 derniers)** : " | |
| 461 | + f"{len(stats['runs'])}, dont {stats['err_count']} en erreur") | |
| 462 | + out.append("") | |
| 463 | + | |
| 464 | + out.append("## Erreurs connues & dépannage") | |
| 465 | + out.append("") | |
| 466 | + if stats and stats["errors"]: | |
| 467 | + out.append("| Date | Message (sync_log) |") | |
| 468 | + out.append("|---|---|") | |
| 469 | + for r in stats["errors"]: | |
| 470 | + out.append(f"| {fmt_ts(r['ts'])} | {esc(r['message'] or '', 160)} |") | |
| 471 | + out.append("") | |
| 472 | + else: | |
| 473 | + out.append("Aucune erreur dans les 60 derniers runs journalisés.") | |
| 474 | + out.append("") | |
| 475 | + if etat != "actif": | |
| 476 | + out.append(f"**Note du registre** : {status}") | |
| 477 | + out.append("") | |
| 478 | + out.append(f"Rejouer la source seule : `python3 run.py sync {sid}` · " | |
| 479 | + f"vérifier `sync_log` (`SELECT * FROM sync_log WHERE " | |
| 480 | + f"source='{sid}' ORDER BY ts DESC LIMIT 5;`).") | |
| 481 | + out.append("") | |
| 482 | + | |
| 483 | + out.append("## Licence, attribution & conditions") | |
| 484 | + out.append("") | |
| 485 | + out.extend(licence_lines(reg, entry)) | |
| 486 | + out.append("") | |
| 487 | + | |
| 488 | + out.append("## Historique") | |
| 489 | + out.append("") | |
| 490 | + if agg and agg["first_seen"]: | |
| 491 | + out.append(f"- {fmt_ts(agg['first_seen'])[:10]} — premières fiches de " | |
| 492 | + f"la source ingérées dans la BD.") | |
| 493 | + blob = " ".join(str(reg.get(k, "")) for k in ("status", "notes", | |
| 494 | + "extraction", "acces_legal")) | |
| 495 | + for d in sorted({m.group(0) for m in DATE_RE.finditer(blob)}): | |
| 496 | + out.append(f"- {d} — date mentionnée au registre (voir `status`/notes).") | |
| 497 | + out.append("- 2026-08-18 — vague d'enrichissement : standardisation de la " | |
| 498 | + "documentation des connecteurs (fiche générée par " | |
| 499 | + "`scripts/gen_connector_docs.py`).") | |
| 500 | + out.append("") | |
| 501 | + return "\n".join(out) | |
| 502 | + | |
| 503 | + | |
| 504 | +def main() -> None: | |
| 505 | + now = datetime.now().strftime("%Y-%m-%d %H:%M") | |
| 506 | + registry = json.loads(SOURCES_JSON.read_text(encoding="utf-8"))["sources"] | |
| 507 | + con = sqlite3.connect(DB_PATH) | |
| 508 | + con.row_factory = sqlite3.Row | |
| 509 | + | |
| 510 | + by_sid: dict[str, dict] = {} | |
| 511 | + for path in sorted(CONN_DIR.glob("*.py")) + EXTRA_MODULES: | |
| 512 | + if path.stem in SKIP_MODULES or not path.exists(): | |
| 513 | + continue | |
| 514 | + for e in introspect_module(path): | |
| 515 | + by_sid[e["source_id"]] = e | |
| 516 | + | |
| 517 | + DOCS_DIR.mkdir(parents=True, exist_ok=True) | |
| 518 | + for old in DOCS_DIR.glob("*.md"): | |
| 519 | + old.unlink() | |
| 520 | + | |
| 521 | + rows = [] | |
| 522 | + for reg in registry: | |
| 523 | + sid = reg["id"] | |
| 524 | + entry = by_sid.get(sid) | |
| 525 | + stats = db_stats(con, sid) | |
| 526 | + (DOCS_DIR / f"{sid}.md").write_text( | |
| 527 | + render_fiche(reg, entry, stats, now), encoding="utf-8") | |
| 528 | + agg = stats["agg"] | |
| 529 | + lo = stats["last_ok"] | |
| 530 | + etat = reg.get("status", "—").split("—")[0].split("(")[0].strip() | |
| 531 | + rows.append( | |
| 532 | + f"| [`{sid}`]({sid}.md) | {esc(reg.get('name', sid), 40)} " | |
| 533 | + f"| T{reg.get('tier', '—')} " | |
| 534 | + f"| {esc(entry['flavor'] if entry else '—', 40)} " | |
| 535 | + f"| {entry['backend_family'] if entry else '—'} " | |
| 536 | + f"| {agg['act']}/{agg['total']} " | |
| 537 | + f"| {pct(agg['f_lat'], agg['act'])} " | |
| 538 | + f"| {pct(agg['f_hours'], agg['act'])} " | |
| 539 | + f"| {pct(agg['f_cuisines'], agg['act'])} " | |
| 540 | + f"| {esc(etat, 30)} | {fmt_ts(lo['ts']) if lo else '—'} |") | |
| 541 | + | |
| 542 | + total_act = con.execute( | |
| 543 | + "SELECT coalesce(sum(active),0) FROM restaurants").fetchone()[0] | |
| 544 | + idx = [ | |
| 545 | + "# Resto-Ka — Index des connecteurs", "", | |
| 546 | + f"_Généré automatiquement par `scripts/gen_connector_docs.py` le {now} " | |
| 547 | + f"— ne pas éditer à la main, régénérer._", "", | |
| 548 | + f"**{len(registry)} sources au registre** · **{total_act} restos " | |
| 549 | + f"actifs** en BD.", "", | |
| 550 | + "| Source | Nom | Tier | Type d'accès | Backend | Actifs/Total | GPS " | |
| 551 | + "| Horaires | Cuisines | État | Dernier sync OK |", | |
| 552 | + "|---|---|---|---|---|---|---|---|---|---|---|", | |
| 553 | + ] | |
| 554 | + idx.extend(rows) | |
| 555 | + idx.append("") | |
| 556 | + (DOCS_DIR / "INDEX.md").write_text("\n".join(idx), encoding="utf-8") | |
| 557 | + con.close() | |
| 558 | + print(f"[gen_connector_docs] {len(registry)} fiches + INDEX.md écrits dans " | |
| 559 | + f"{DOCS_DIR}") | |
| 560 | + | |
| 561 | + | |
| 562 | +if __name__ == "__main__": | |
| 563 | + main() | |
| 564 | ||