SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

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

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