Vague 3 : avis/notes sans clé API + menus livraison + permis d alcool
- yelp-scrape : notes/avis Yelp SANS clé (1 page de recherche yelp.ca/resto via Scrapfly ASP, cache Apollo embarqué) -> details.yelp, croisement conservateur nom+civique+ville, budget 80 pages/cycle, re-visite 30 j, indépendants d abord. Yelp Fusion (yelp) reprend la main si clé fournie. - ubereats : menus livraison + notes via sitemaps publics (index local 37 087 slugs CA, 30 j) + __REACT_QUERY_STATE__ des pages resto ; croisement conservateur (téléphone / postal+civique / GPS<120 m + nom) avec restos EXISTANTS seulement, menus en price_context delivery, budget 60 pages/cycle. - permits (racj) : permis d alcool en vigueur (Données Québec, CC-BY 4.0), regroupés par établissement et croisés comme MAPAQ -> details.permis_alcool (1 924 restos croisés au premier run). - /api/stats : with_yelp_rating, with_ubereats, with_alcohol_permit ; ingest.enrich appelle permits.sync (guard 6 j) ; registre sources.json (9) + docs générées ; 73 tests verts (9 nouveaux, tout hors ligne). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 changed files +1,480 −10
modified
.gitignore
+1 −0
@@ -14,3 +14,4 @@ frontend/node_modules/ | |||||
| 14 | frontend/dist/ | 14 | frontend/dist/ | ||
| 15 | frontend/tsconfig.tsbuildinfo | 15 | frontend/tsconfig.tsbuildinfo | ||
| 16 | .DS_Store | 16 | .DS_Store | ||
| 17 | +data/ubereats-stores.json | ||||
modified
data/sources.json
+39 −0
@@ -282,6 +282,45 @@ | |||||
| 282 | "acces_legal": "API officielle avec clé, Display Requirements Yelp : attribution affichée avec la note.", | 282 | "acces_legal": "API officielle avec clé, Display Requirements Yelp : attribution affichée avec la note.", | ||
| 283 | "cadence": "hebdomadaire, re-vérification 30 jours", | 283 | "cadence": "hebdomadaire, re-vérification 30 jours", | ||
| 284 | "status": "clé requise (YELP_API_KEY absente de .env — connecteur livré, SkipSource)" | 284 | "status": "clé requise (YELP_API_KEY absente de .env — connecteur livré, SkipSource)" | ||
| 285 | + }, | ||||
| 286 | + { | ||||
| 287 | + "id": "yelp-scrape", | ||||
| 288 | + "name": "Yelp (avis et notes, sans clé)", | ||||
| 289 | + "url": "https://www.yelp.ca", | ||||
| 290 | + "platform": "yelp", | ||||
| 291 | + "connector": "yelp-scrape", | ||||
| 292 | + "tier": 5, | ||||
| 293 | + "price_context": "aucun", | ||||
| 294 | + "extraction": "Une page de RECHERCHE yelp.ca par resto (find_desc=nom&find_loc=ville) via Scrapfly ASP : le cache Apollo embarqué (scripts data-apollo-state) expose alias, note, nb d'avis, fourchette de prix, catégories, ville et civique par commerce -> details.yelp (mêmes clés que Yelp Fusion). Croisement conservateur : nom similaire + même numéro civique + ville compatible ; ambigu = ignoré. Budget YELP_SCRAPE_BUDGET (80 pages/cycle), re-visite 30 j (hit comme miss), priorité aux restos avec menu. N'émet aucune fiche. Le connecteur `yelp` (API Fusion) reprend la main dès qu'une YELP_API_KEY existe.", | ||||
| 295 | + "acces_legal": "Pages publiques yelp.ca, extraction minimale (note agrégée + lien fiche), attribution Yelp affichée avec la note (mêmes conditions d'affichage que Fusion). Anti-bot réel : Scrapfly ASP, ~30 crédits/page — budget serré. À remplacer par l'API Fusion (clé gratuite) dès que possible (CLAUDE.md §15).", | ||||
| 296 | + "cadence": "quotidien par petits lots (80 pages), re-vérification 30 jours", | ||||
| 297 | + "status": "actif" | ||||
| 298 | + }, | ||||
| 299 | + { | ||||
| 300 | + "id": "ubereats", | ||||
| 301 | + "name": "Uber Eats (menus livraison + notes)", | ||||
| 302 | + "url": "https://www.ubereats.com", | ||||
| 303 | + "platform": "ubereats", | ||||
| 304 | + "connector": "ubereats", | ||||
| 305 | + "tier": 2, | ||||
| 306 | + "price_context": "delivery", | ||||
| 307 | + "extraction": "Découverte par sitemaps publics (robots.txt -> sitemap-store-*.xml.gz, URLs /ca/store/, index local data/ubereats-stores.json rafraîchi 30 j). Chaque page resto embarque __REACT_QUERY_STATE__ (getStoreV1) : menu complet (sections/items/prix en cents), note, adresse, téléphone, GPS. Croisement conservateur avec un resto EXISTANT (téléphone identique OU postal+civique OU GPS<120 m + nom similaire) — n'émet aucune fiche. Menu -> table menus en price_context delivery (prix majorés ~25-30 %), note -> details.ubereats. Budget UBEREATS_BUDGET (60 pages/cycle), verdicts en cache, re-visite 30 j.", | ||||
| 308 | + "acces_legal": "Palier 2 (CLAUDE.md §9, §15) : CGU restrictives, Cloudflare — Scrapfly ASP (~1 crédit/page constaté). Usage d'appoint minimal : menus pour restos sans source dine-in/takeout, prix TOUJOURS étiquetés delivery, lien vers la fiche source conservé. Préférer UEAT/site du resto dès que disponible.", | ||||
| 309 | + "cadence": "quotidien par petits lots (60 pages), re-vérification 30 jours", | ||||
| 310 | + "status": "actif" | ||||
| 311 | + }, | ||||
| 312 | + { | ||||
| 313 | + "id": "racj", | ||||
| 314 | + "name": "RACJ — Permis d'alcool en vigueur", | ||||
| 315 | + "url": "https://www.donneesquebec.ca/recherche/dataset/racj-alcool-detaillant", | ||||
| 316 | + "platform": "donnees-quebec", | ||||
| 317 | + "connector": "permits (module d'enrichissement, pas de fiches)", | ||||
| 318 | + "tier": 5, | ||||
| 319 | + "price_context": "aucun", | ||||
| 320 | + "extraction": "CSV ouvert racj-alcool-detaillant.csv (Données Québec, registre des permis de détaillant d'alcool en vigueur) : regroupement par établissement (NoEtablissement) puis croisement CONSERVATEUR avec les restaurants (nom normalisé + code postal/ville+civique, ou postal + civique + similarité de nom — mêmes règles que MAPAQ). Catégories de permis (Bar, Restaurant pour vendre/servir…), capacité et titulaire -> details.permis_alcool.", | ||||
| 321 | + "acces_legal": "Données ouvertes du gouvernement du Québec (RACJ), licence CC-BY 4.0 — attribution RACJ/Données Québec affichée avec l'information de permis.", | ||||
| 322 | + "cadence": "hebdomadaire (guard 6 jours dans ingest.enrich)", | ||||
| 323 | + "status": "actif" | ||||
| 285 | } | 324 | } | ||
| 286 | ] | 325 | ] | ||
| 287 | } | 326 | } | ||
modified
data/ueat-discovered.json
+72 −0
@@ -87,6 +87,78 @@ | |||||
| 87 | { | 87 | { | ||
| 88 | "key": "e4de92c1-12d0-49db-bb59-7baf0202faf8", | 88 | "key": "e4de92c1-12d0-49db-bb59-7baf0202faf8", | ||
| 89 | "label": "Nora Gray (découvert via noragray.com)" | 89 | "label": "Nora Gray (découvert via noragray.com)" | ||
| 90 | + }, | ||||
| 91 | + { | ||||
| 92 | + "key": "5f4f560b-830d-4af0-892d-b15be0ed653c", | ||||
| 93 | + "label": "Lucille’s Oyster Dive (découvert via lucillesoyster.com)" | ||||
| 94 | + }, | ||||
| 95 | + { | ||||
| 96 | + "key": "12fe0fc4-f986-40e3-abbc-670c01f37247", | ||||
| 97 | + "label": "Jun I (découvert via juni.ca)" | ||||
| 98 | + }, | ||||
| 99 | + { | ||||
| 100 | + "key": "484faa4f-19f5-435b-9428-d5c8fd439f75", | ||||
| 101 | + "label": "La Boîte À Huîtres (découvert via laboiteauxhuitres.com)" | ||||
| 102 | + }, | ||||
| 103 | + { | ||||
| 104 | + "key": "791c777b-4e38-4fa6-850d-4e9f322a90d8", | ||||
| 105 | + "label": "Sushi Sakura (découvert via sushisakura.ca)" | ||||
| 106 | + }, | ||||
| 107 | + { | ||||
| 108 | + "key": "23c2c440-9ad3-4d3f-b198-3e2d565f7999", | ||||
| 109 | + "label": "Boîte Geisha (découvert via boitegeisha.com)" | ||||
| 110 | + }, | ||||
| 111 | + { | ||||
| 112 | + "key": "d94f44b9-67ac-42f1-99e5-a56040210f87", | ||||
| 113 | + "label": "Monza (découvert via restaurantmonza.com)" | ||||
| 114 | + }, | ||||
| 115 | + { | ||||
| 116 | + "key": "c147a584-441d-4db2-9ab5-384c250ab925", | ||||
| 117 | + "label": "Barranco (découvert via barrancorestaurant.com)" | ||||
| 118 | + }, | ||||
| 119 | + { | ||||
| 120 | + "key": "403f3002-0bae-4f8c-9ebb-391a2e69caeb", | ||||
| 121 | + "label": "Seau de Crabe (découvert via seaudecrabe.ca)" | ||||
| 122 | + }, | ||||
| 123 | + { | ||||
| 124 | + "key": "2881e6e6-58fd-4b26-b8b9-8cfc4569fa14", | ||||
| 125 | + "label": "Shūshū — Mile-End (découvert via shushumtl.com)" | ||||
| 126 | + }, | ||||
| 127 | + { | ||||
| 128 | + "key": "073dc1c3-04eb-49f7-8c06-7c71c572ddf7", | ||||
| 129 | + "label": "L'Œil du dragon (découvert via oeildudragon.com)" | ||||
| 130 | + }, | ||||
| 131 | + { | ||||
| 132 | + "key": "3ad538c1-56c1-43e4-a308-fb0ef963cf07", | ||||
| 133 | + "label": "Ristorante Giorgio (découvert via giorgio.ca)" | ||||
| 134 | + }, | ||||
| 135 | + { | ||||
| 136 | + "key": "89422724-84db-4302-ac12-ebf532434604", | ||||
| 137 | + "label": "Pizzeria Zac (découvert via chezzacpizza.com)" | ||||
| 138 | + }, | ||||
| 139 | + { | ||||
| 140 | + "key": "ab96cf7a-cf9c-4178-8d52-9b3729709aec", | ||||
| 141 | + "label": "La galette libanaise (découvert via lagalettelibanaise.com)" | ||||
| 142 | + }, | ||||
| 143 | + { | ||||
| 144 | + "key": "4d9578ba-f905-472e-90fa-e39e184b0597", | ||||
| 145 | + "label": "Stratos Pizzeria (découvert via stratos-pizzeria.com)" | ||||
| 146 | + }, | ||||
| 147 | + { | ||||
| 148 | + "key": "51236cf5-13f5-4fb4-a08c-d30b31519709", | ||||
| 149 | + "label": "Umai Soo She (découvert via umaisooshe.ca)" | ||||
| 150 | + }, | ||||
| 151 | + { | ||||
| 152 | + "key": "388d78df-d9c9-4aaa-a974-7b8d696312eb", | ||||
| 153 | + "label": "Torii Izakaya (découvert via toriiizakaya.ca)" | ||||
| 154 | + }, | ||||
| 155 | + { | ||||
| 156 | + "key": "7ec72df5-e520-4335-9935-e2be36db5761", | ||||
| 157 | + "label": "Küto (découvert via kuto.ca)" | ||||
| 158 | + }, | ||||
| 159 | + { | ||||
| 160 | + "key": "d42e5dd2-815c-4803-930d-ae0ee95b1e4b", | ||||
| 161 | + "label": "Bol de Vie (découvert via boldevie.ca)" | ||||
| 90 | } | 162 | } | ||
| 91 | ] | 163 | ] | ||
| 92 | } | 164 | } | ||
| \ No newline at end of file | |||||
modified
docs/connecteurs/INDEX.md
+5 −2
@@ -1,8 +1,8 @@ | |||||
| 1 | # Resto-Ka — Index des connecteurs | 1 | # Resto-Ka — Index des connecteurs | ||
| 2 | 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._ | 3 | +_Généré automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | −**6 sources au registre** · **14989 restos actifs** en BD. | 5 | +**9 sources au registre** · **14989 restos actifs** en BD. | ||
| 6 | 6 | ||||
| 7 | | Source | Nom | Tier | Type d'accès | Backend | Actifs/Total | GPS | Horaires | Cuisines | État | Dernier sync OK | | 7 | | Source | Nom | Tier | Type d'accès | Backend | Actifs/Total | GPS | Horaires | Cuisines | État | Dernier sync OK | | ||
| 8 | |---|---|---|---|---|---|---|---|---|---|---| | 8 | |---|---|---|---|---|---|---|---|---|---|---| | ||
@@ -12,3 +12,6 @@ _Généré automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 03: | |||||
| 12 | | [`sthubert`](sthubert.md) | St-Hubert (commande en ligne) | T6 | — | — | 0/0 | — | — | — | à faire | — | | 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 | | 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 | — | | 14 | | [`yelp`](yelp.md) | Yelp Fusion (avis et notes) | T5 | API JSON | direct | 0/0 | — | — | — | clé requise | — | | ||
| 15 | +| [`yelp-scrape`](yelp-scrape.md) | Yelp (avis et notes, sans clé) | T5 | pages HTML (rendu serveur) | Scrapfly | 0/0 | — | — | — | actif | — | | ||||
| 16 | +| [`ubereats`](ubereats.md) | Uber Eats (menus livraison + notes) | T2 | sitemap XML + pages HTML | Scrapfly | 0/0 | — | — | — | actif | — | | ||||
| 17 | +| [`racj`](racj.md) | RACJ — Permis d'alcool en vigueur | T5 | jeu de données ouvert (CSV, Données Qué… | direct | 0/0 | — | — | — | actif | 2026-08-18 05:47 | | ||||
modified
docs/connecteurs/mapaq.md
+1 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # MAPAQ — Inspections alimentaires (condamnations) — connecteur `mapaq` | 1 | # MAPAQ — Inspections alimentaires (condamnations) — connecteur `mapaq` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : actif** · Palier (tier) : 5 · Backend : direct | 5 | **État : actif** · Palier (tier) : 5 · Backend : direct | ||
| 6 | 6 | ||||
modified
docs/connecteurs/osm.md
+3 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # OpenStreetMap (découverte) — connecteur `osm` | 1 | # OpenStreetMap (découverte) — connecteur `osm` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : actif** · Palier (tier) : 4 · Backend : direct · Restos actifs : 13969/13969 | 5 | **État : actif** · Palier (tier) : 4 · Backend : direct · Restos actifs : 13969/13969 | ||
| 6 | 6 | ||||
@@ -47,6 +47,8 @@ Le connecteur alimente la table `restaurants` (et `menus` le cas échéant). Com | |||||
| 47 | | `images` | Photos (JSON) | 0 % | — | | 47 | | `images` | Photos (JSON) | 0 % | — | | ||
| 48 | | `url` | URL de la fiche source | 100 % | https://www.openstreetmap.org/node/112637139 | | 48 | | `url` | URL de la fiche source | 100 % | https://www.openstreetmap.org/node/112637139 | | ||
| 49 | 49 | ||||
| 50 | +**Menus** : 13 menus rattachés (728 items, 1 contexte(s) de prix, dernière capture 2026-08-18T10:05:33Z) — table `menus` (sections → items → options, prix CAD). | ||||
| 51 | + | ||||
| 50 | ## Fréquence & budget | 52 | ## Fréquence & budget | ||
| 51 | 53 | ||||
| 52 | - **Cadence déclarée (registre)** : mensuel | 54 | - **Cadence déclarée (registre)** : mensuel | ||
added
docs/connecteurs/racj.md
+54 −0
@@ -0,0 +1,54 @@ | |||||
| 1 | +# RACJ — Permis d'alcool en vigueur — connecteur `racj` | ||||
| 2 | + | ||||
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — 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 | +Enrichissement RACJ — permis d'alcool en vigueur (Régie des alcools, des courses et des jeux, Données Québec, licence CC-BY 4.0). Télécharge le registre CSV des permis de détaillant d'alcool (bar, restaurant pour vendre/servir…), regroupe par établissement et croise de façon CONSERVATRICE avec les restaurants (mêmes règles que les inspections MAPAQ : nom exact + lieu, ou postal + civique + nom similaire — jamais fusionner deux établissements). Résultat dans details.permis_alcool (catégories de permis, capacité, titulaire). Gratuit, sans clé API. Cadence hebdomadaire (guard 6 jours). | ||||
| 10 | + | ||||
| 11 | +- **Plateforme** : donnees-quebec · **Site** : https://www.donneesquebec.ca/recherche/dataset/racj-alcool-detaillant | ||||
| 12 | +- **Extraction (registre)** : CSV ouvert racj-alcool-detaillant.csv (Données Québec, registre des permis de détaillant d'alcool en vigueur) : regroupement par établissement (NoEtablissement) puis croisement CONSERVATEUR avec les restaurants (nom normalisé + code postal/ville+civique, ou postal + civique + similarité de nom — mêmes règles que MAPAQ). Catégories de permis (Bar, Restaurant pour vendre/servir…), capacité et titulaire -> details.permis_alcool. | ||||
| 13 | +- **Contexte de prix** : aucun | ||||
| 14 | +- **Module** : `restoka/permits.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/d817c9f7-76c7-44af-882d-0d673056ef86/resource/6b69360c-af8d-4c57-b5bf-1c38d5461de3/download/racj-alcool-detaillant.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 | +Aucune fiche en BD pour cette source (connecteur en attente ou clé manquante) — schéma cible : table `restaurants` + `menus`. | ||||
| 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 05:47 — 18686 trouvées, +1924 / ~0 / -0 | ||||
| 34 | + | ||||
| 35 | +## Volumétrie & complétude | ||||
| 36 | + | ||||
| 37 | +- Aucune donnée en BD pour cette source. | ||||
| 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 racj` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='racj' 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 (RACJ), licence CC-BY 4.0 — attribution RACJ/Données Québec affichée avec l'information de permis. | ||||
| 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`). | ||||
modified
docs/connecteurs/site-resto.md
+1 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # Sites web des restos (menus maison) — connecteur `site-resto` | 1 | # Sites web des restos (menus maison) — connecteur `site-resto` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : actif** · Palier (tier) : 3 · Backend : Firecrawl · Restos actifs : 86/86 | 5 | **État : actif** · Palier (tier) : 3 · Backend : Firecrawl · Restos actifs : 86/86 | ||
| 6 | 6 | ||||
modified
docs/connecteurs/sthubert.md
+1 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # St-Hubert (commande en ligne) — connecteur `sthubert` | 1 | # St-Hubert (commande en ligne) — connecteur `sthubert` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : à faire** · Palier (tier) : 6 · Backend : — | 5 | **État : à faire** · Palier (tier) : 6 · Backend : — | ||
| 6 | 6 | ||||
added
docs/connecteurs/ubereats.md
+55 −0
@@ -0,0 +1,55 @@ | |||||
| 1 | +# Uber Eats (menus livraison + notes) — connecteur `ubereats` | ||||
| 2 | + | ||||
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||||
| 4 | + | ||||
| 5 | +**État : actif** · Palier (tier) : 2 · Backend : Scrapfly | ||||
| 6 | + | ||||
| 7 | +## Description de la source | ||||
| 8 | + | ||||
| 9 | +Connecteur d'ENRICHISSEMENT Uber Eats (menus livraison + notes) — SANS clé API. Découverte par les sitemaps publics (robots.txt -> sitemap-store-*.xml.gz, URLs /ca/store/<slug>/<uuid>, index local data/ubereats-stores.json rafraîchi tous les 30 j). Chaque page de resto embarque un état React Query (__REACT_QUERY_STATE__) avec le menu complet (sections + items + prix en cents), la note Uber Eats, l'adresse, le téléphone et les GPS. Croisement CONSERVATEUR avec un resto EXISTANT seulement (téléphone identique, OU code postal + numéro civique identiques, OU GPS <120 m + nom similaire) : n'émet AUCUNE fiche. Menu -> table menus en price_context `delivery` (prix majorés ~25-30 %, CLAUDE.md §6.2), note -> details.ubereats. Économe : Scrapfly ASP (1 page ≈ 1 crédit constaté), budget UBEREATS_BUDGET (défaut 60) pages resto/cycle, verdict par magasin mis en cache (detail_cache), re-visite 30 j. Conformité §15 : extraction minimale, usage d'appoint (restos sans menu ailleurs), prix toujours étiquetés `delivery`, lien source conservé. | ||||
| 10 | + | ||||
| 11 | +- **Plateforme** : ubereats · **Site** : https://www.ubereats.com | ||||
| 12 | +- **Extraction (registre)** : Découverte par sitemaps publics (robots.txt -> sitemap-store-*.xml.gz, URLs /ca/store/, index local data/ubereats-stores.json rafraîchi 30 j). Chaque page resto embarque __REACT_QUERY_STATE__ (getStoreV1) : menu complet (sections/items/prix en cents), note, adresse, téléphone, GPS. Croisement conservateur avec un resto EXISTANT (téléphone identique OU postal+civique OU GPS<120 m + nom similaire) — n'émet aucune fiche. Menu -> table menus en price_context delivery (prix majorés ~25-30 %), note -> details.ubereats. Budget UBEREATS_BUDGET (60 pages/cycle), verdicts en cache, re-visite 30 j. | ||||
| 13 | +- **Contexte de prix** : delivery | ||||
| 14 | +- **Module** : `restoka/connectors/ubereats.py` — `UberEatsConnector` | ||||
| 15 | + | ||||
| 16 | +## Accès | ||||
| 17 | + | ||||
| 18 | +- **Type d'accès** : sitemap XML + pages HTML | ||||
| 19 | +- **Endpoint de base** : https://www.ubereats.com/robots.txt | ||||
| 20 | +- **URLs du module** : https://www.ubereats.com/robots.txt | ||||
| 21 | +- **Pagination** : réponse unique (pas de pagination) | ||||
| 22 | +- **Backend anti-bot / rendu** : Scrapfly (asp + render_js — contournement anti-bot) | ||||
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 60 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 | +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)** : quotidien par petits lots (60 pages), re-vérification 30 jours | ||||
| 33 | +- **Cadence observée** (médiane sync_log) : — | ||||
| 34 | +- **Caps / budgets du module** : `MAX_CANDIDATES` = 3, `MAX_CONSECUTIVE_FAILURES` = 3, `MAX_GPS_M` = 120.0 | ||||
| 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 | +Rejouer la source seule : `python3 run.py sync ubereats` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='ubereats' ORDER BY ts DESC LIMIT 5;`). | ||||
| 46 | + | ||||
| 47 | +## Licence, attribution & conditions | ||||
| 48 | + | ||||
| 49 | +- **Cadre d'accès (registre, `acces_legal`)** : Palier 2 (CLAUDE.md §9, §15) : CGU restrictives, Cloudflare — Scrapfly ASP (~1 crédit/page constaté). Usage d'appoint minimal : menus pour restos sans source dine-in/takeout, prix TOUJOURS étiquetés delivery, lien vers la fiche source conservé. Préférer UEAT/site du resto dès que disponible. | ||||
| 50 | +- **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. | ||||
| 51 | +- Retrait sur demande : contact@spboucher.ai. | ||||
| 52 | + | ||||
| 53 | +## Historique | ||||
| 54 | + | ||||
| 55 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | ||||
modified
docs/connecteurs/ueat.md
+1 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # UEAT (commande en ligne) — connecteur `ueat` | 1 | # UEAT (commande en ligne) — connecteur `ueat` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : actif** · Palier (tier) : 1 · Backend : direct · Restos actifs : 934/936 | 5 | **État : actif** · Palier (tier) : 1 · Backend : direct · Restos actifs : 934/936 | ||
| 6 | 6 | ||||
added
docs/connecteurs/yelp-scrape.md
+55 −0
@@ -0,0 +1,55 @@ | |||||
| 1 | +# Yelp (avis et notes, sans clé) — connecteur `yelp-scrape` | ||||
| 2 | + | ||||
| 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||||
| 4 | + | ||||
| 5 | +**État : actif** · Palier (tier) : 5 · Backend : Scrapfly | ||||
| 6 | + | ||||
| 7 | +## Description de la source | ||||
| 8 | + | ||||
| 9 | +Connecteur d'ENRICHISSEMENT Yelp SANS clé API (avis/notes par scraping léger). Une seule page de RECHERCHE yelp.ca par resto (find_desc=<nom>&find_loc=<ville>) via Scrapfly ASP : la page embarque un cache Apollo (scripts data-apollo-state) avec, PAR commerce, alias, nom, note, nombre d'avis, fourchette de prix, catégories et adresse (ville + civique). Croisement CONSERVATEUR : nom similaire + même numéro civique + ville compatible (jamais fusionner deux établissements). Résultat -> details.yelp {rating, review_count, price, categories, url} (mêmes clés que Yelp Fusion — le connecteur officiel `yelp` reprend la main dès qu'une YELP_API_KEY existe). Économe : budget YELP_SCRAPE_BUDGET (défaut 80) pages/cycle, re-visite 30 jours (hit comme miss), priorité aux restos avec menu. Base légale : pages publiques, extraction minimale (note agrégée + lien vers la fiche Yelp, attribution affichée) — voir CLAUDE.md §15. | ||||
| 10 | + | ||||
| 11 | +- **Plateforme** : yelp · **Site** : https://www.yelp.ca | ||||
| 12 | +- **Extraction (registre)** : Une page de RECHERCHE yelp.ca par resto (find_desc=nom&find_loc=ville) via Scrapfly ASP : le cache Apollo embarqué (scripts data-apollo-state) expose alias, note, nb d'avis, fourchette de prix, catégories, ville et civique par commerce -> details.yelp (mêmes clés que Yelp Fusion). Croisement conservateur : nom similaire + même numéro civique + ville compatible ; ambigu = ignoré. Budget YELP_SCRAPE_BUDGET (80 pages/cycle), re-visite 30 j (hit comme miss), priorité aux restos avec menu. N'émet aucune fiche. Le connecteur `yelp` (API Fusion) reprend la main dès qu'une YELP_API_KEY existe. | ||||
| 13 | +- **Contexte de prix** : aucun | ||||
| 14 | +- **Module** : `restoka/connectors/yelp_scrape.py` — `YelpScrapeConnector` | ||||
| 15 | + | ||||
| 16 | +## Accès | ||||
| 17 | + | ||||
| 18 | +- **Type d'accès** : pages HTML (rendu serveur) | ||||
| 19 | +- **Endpoint de base** : https://www.yelp.ca/search?find_desc={desc}&find_loc={loc} | ||||
| 20 | +- **URLs du module** : https://www.yelp.ca/search?find_desc={desc}&find_loc={loc} · https://www.yelp.ca/biz/ | ||||
| 21 | +- **Pagination** : réponse unique (pas de pagination) | ||||
| 22 | +- **Backend anti-bot / rendu** : Scrapfly (asp + render_js — contournement anti-bot) | ||||
| 23 | +- **Politesse** : 1.0 s entre requêtes, timeout 60 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 | +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)** : quotidien par petits lots (80 pages), re-vérification 30 jours | ||||
| 33 | +- **Cadence observée** (médiane sync_log) : — | ||||
| 34 | +- **Caps / budgets du module** : `MAX_CONSECUTIVE_FAILURES` = 3 | ||||
| 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 | +Rejouer la source seule : `python3 run.py sync yelp-scrape` · vérifier `sync_log` (`SELECT * FROM sync_log WHERE source='yelp-scrape' ORDER BY ts DESC LIMIT 5;`). | ||||
| 46 | + | ||||
| 47 | +## Licence, attribution & conditions | ||||
| 48 | + | ||||
| 49 | +- **Cadre d'accès (registre, `acces_legal`)** : Pages publiques yelp.ca, extraction minimale (note agrégée + lien fiche), attribution Yelp affichée avec la note (mêmes conditions d'affichage que Fusion). Anti-bot réel : Scrapfly ASP, ~30 crédits/page — budget serré. À remplacer par l'API Fusion (clé gratuite) dès que possible (CLAUDE.md §15). | ||||
| 50 | +- **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. | ||||
| 51 | +- Retrait sur demande : contact@spboucher.ai. | ||||
| 52 | + | ||||
| 53 | +## Historique | ||||
| 54 | + | ||||
| 55 | +- 2026-08-18 — vague d'enrichissement : standardisation de la documentation des connecteurs (fiche générée par `scripts/gen_connector_docs.py`). | ||||
modified
docs/connecteurs/yelp.md
+1 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | # Yelp Fusion (avis et notes) — connecteur `yelp` | 1 | # Yelp Fusion (avis et notes) — connecteur `yelp` | ||
| 2 | 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._ | 3 | +_Fiche générée automatiquement par `scripts/gen_connector_docs.py` le 2026-08-18 06:06 — ne pas éditer à la main, régénérer._ | ||
| 4 | 4 | ||||
| 5 | **État : clé requise** · Palier (tier) : 5 · Backend : direct | 5 | **État : clé requise** · Palier (tier) : 5 · Backend : direct | ||
| 6 | 6 | ||||
added
restoka/connectors/ubereats.py
+393 −0
@@ -0,0 +1,393 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/connectors/ubereats.py | ||||
| 4 | +# Desc: Connecteur d'ENRICHISSEMENT Uber Eats (menus livraison + notes) — | ||||
| 5 | +# SANS clé API. Découverte par les sitemaps publics (robots.txt -> | ||||
| 6 | +# sitemap-store-*.xml.gz, URLs /ca/store/<slug>/<uuid>, index local | ||||
| 7 | +# data/ubereats-stores.json rafraîchi tous les 30 j). Chaque page de | ||||
| 8 | +# resto embarque un état React Query (__REACT_QUERY_STATE__) avec le | ||||
| 9 | +# menu complet (sections + items + prix en cents), la note Uber Eats, | ||||
| 10 | +# l'adresse, le téléphone et les GPS. Croisement CONSERVATEUR avec un | ||||
| 11 | +# resto EXISTANT seulement (téléphone identique, OU code postal + numéro | ||||
| 12 | +# civique identiques, OU GPS <120 m + nom similaire) : n'émet AUCUNE | ||||
| 13 | +# fiche. Menu -> table menus en price_context `delivery` (prix majorés | ||||
| 14 | +# ~25-30 %, CLAUDE.md §6.2), note -> details.ubereats. | ||||
| 15 | +# | ||||
| 16 | +# Économe : Scrapfly ASP (1 page ≈ 1 crédit constaté), budget | ||||
| 17 | +# UBEREATS_BUDGET (défaut 60) pages resto/cycle, verdict par magasin | ||||
| 18 | +# mis en cache (detail_cache), re-visite 30 j. Conformité §15 : | ||||
| 19 | +# extraction minimale, usage d'appoint (restos sans menu ailleurs), | ||||
| 20 | +# prix toujours étiquetés `delivery`, lien source conservé. | ||||
| 21 | +# ============================================================================== | ||||
| 22 | +from __future__ import annotations | ||||
| 23 | + | ||||
| 24 | +import base64 | ||||
| 25 | +import datetime | ||||
| 26 | +import gzip | ||||
| 27 | +import json | ||||
| 28 | +import os | ||||
| 29 | +import re | ||||
| 30 | +import sys | ||||
| 31 | +import time | ||||
| 32 | +import urllib.parse | ||||
| 33 | +from pathlib import Path | ||||
| 34 | + | ||||
| 35 | +from ..inspections import _CIVIC_RE, _name_similar, norm_name | ||||
| 36 | +from ..normalize import normalize_phone | ||||
| 37 | +from ..regions import strip_accents | ||||
| 38 | +from ..schema import Restaurant | ||||
| 39 | +from .base import BaseConnector, SkipSource | ||||
| 40 | + | ||||
| 41 | +ROBOTS_URL = "https://www.ubereats.com/robots.txt" | ||||
| 42 | +INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "ubereats-stores.json" | ||||
| 43 | +INDEX_REFRESH_DAYS = 30 # les sitemaps bougent peu | ||||
| 44 | +MAX_BUDGET = int(os.environ.get("UBEREATS_BUDGET", "60")) # pages resto/cycle | ||||
| 45 | +REFRESH_DAYS = 30 # re-visite d'un resto (hit comme miss) | ||||
| 46 | +MAX_CANDIDATES = 3 # magasins testés par resto, max | ||||
| 47 | +MAX_CONSECUTIVE_FAILURES = 3 | ||||
| 48 | +MAX_GPS_M = 120.0 | ||||
| 49 | + | ||||
| 50 | +_STATE_RE = re.compile(r"__REACT_QUERY_STATE__\">(.*?)</script>", re.S) | ||||
| 51 | +_STORE_URL_RE = re.compile(r"/ca/store/([^/]+)/([A-Za-z0-9_-]{20,})") | ||||
| 52 | + | ||||
| 53 | + | ||||
| 54 | +def decode_state(page_html: str) -> dict | None: | ||||
| 55 | + """Décode le bloc __REACT_QUERY_STATE__ (JSON avec « " » encodé \\u0022 | ||||
| 56 | + et « \\ » encodé %5C) d'une page Uber Eats.""" | ||||
| 57 | + m = _STATE_RE.search(page_html) | ||||
| 58 | + if not m: | ||||
| 59 | + return None | ||||
| 60 | + t = m.group(1).strip().replace("\\u0022", '"').replace("%5C", "\\") | ||||
| 61 | + try: | ||||
| 62 | + return json.loads(t) | ||||
| 63 | + except ValueError: | ||||
| 64 | + return None | ||||
| 65 | + | ||||
| 66 | + | ||||
| 67 | +def store_payload(state: dict) -> dict | None: | ||||
| 68 | + """Extrait le payload getStoreV1 (fiche resto complète) de l'état.""" | ||||
| 69 | + for q in (state or {}).get("queries") or []: | ||||
| 70 | + qk = q.get("queryKey") | ||||
| 71 | + if isinstance(qk, list) and qk and qk[0] == "getStoreV1": | ||||
| 72 | + data = (q.get("state") or {}).get("data") | ||||
| 73 | + if isinstance(data, dict): | ||||
| 74 | + return data | ||||
| 75 | + return None | ||||
| 76 | + | ||||
| 77 | + | ||||
| 78 | +def build_menu(store: dict, captured_at: str) -> dict | None: | ||||
| 79 | + """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis catalogSectionsMap. | ||||
| 80 | + Prix Uber Eats en cents -> dollars, contexte `delivery` obligatoire.""" | ||||
| 81 | + csm = store.get("catalogSectionsMap") or {} | ||||
| 82 | + sections_out: list[dict] = [] | ||||
| 83 | + seen: set[str] = set() | ||||
| 84 | + for sections in csm.values(): | ||||
| 85 | + for sec in sections or []: | ||||
| 86 | + payload = ((sec.get("payload") or {}) | ||||
| 87 | + .get("standardItemsPayload") or {}) | ||||
| 88 | + name = ((payload.get("title") or {}).get("text") or "").strip() | ||||
| 89 | + items_in = payload.get("catalogItems") or [] | ||||
| 90 | + if not name or not items_in or name in seen: | ||||
| 91 | + continue | ||||
| 92 | + items = [] | ||||
| 93 | + for it in items_in: | ||||
| 94 | + title = (it.get("title") or "").strip() | ||||
| 95 | + if not title: | ||||
| 96 | + continue | ||||
| 97 | + price = it.get("price") | ||||
| 98 | + items.append({ | ||||
| 99 | + "name": title, | ||||
| 100 | + "description": (it.get("itemDescription") or "").strip(), | ||||
| 101 | + "price": round(price / 100.0, 2) | ||||
| 102 | + if isinstance(price, (int, float)) and price > 0 else None, | ||||
| 103 | + }) | ||||
| 104 | + if items: | ||||
| 105 | + seen.add(name) | ||||
| 106 | + sections_out.append({"name": name, "items": items}) | ||||
| 107 | + break # une seule vue du catalogue suffit | ||||
| 108 | + if not sections_out: | ||||
| 109 | + return None | ||||
| 110 | + return { | ||||
| 111 | + "price_context": "delivery", | ||||
| 112 | + "price_source": "ubereats", | ||||
| 113 | + "currency": store.get("currencyCode") or "CAD", | ||||
| 114 | + "captured_at": captured_at, | ||||
| 115 | + "sections": sections_out, | ||||
| 116 | + } | ||||
| 117 | + | ||||
| 118 | + | ||||
| 119 | +def _review_count(store: dict) -> int: | ||||
| 120 | + raw = str((store.get("rating") or {}).get("reviewCount") or "") | ||||
| 121 | + m = re.search(r"\d+", raw.replace(",", "").replace(" ", "")) | ||||
| 122 | + return int(m.group(0)) if m else 0 | ||||
| 123 | + | ||||
| 124 | + | ||||
| 125 | +def _haversine_m(lat1, lng1, lat2, lng2) -> float: | ||||
| 126 | + import math | ||||
| 127 | + r = 6371000.0 | ||||
| 128 | + p1, p2 = math.radians(lat1), math.radians(lat2) | ||||
| 129 | + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) | ||||
| 130 | + a = (math.sin(dp / 2) ** 2 | ||||
| 131 | + + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2) | ||||
| 132 | + return 2 * r * math.asin(math.sqrt(a)) | ||||
| 133 | + | ||||
| 134 | + | ||||
| 135 | +def verify_store(row, store: dict) -> str: | ||||
| 136 | + """Vérifie qu'une page magasin Uber Eats correspond bien au resto de la | ||||
| 137 | + base — CONSERVATEUR : téléphone identique, OU code postal + numéro civique | ||||
| 138 | + identiques, OU GPS <120 m + nom similaire. Retourne le mode de croisement | ||||
| 139 | + ('' = pas le même établissement).""" | ||||
| 140 | + loc = store.get("location") or {} | ||||
| 141 | + phone = normalize_phone(store.get("phoneNumber")) | ||||
| 142 | + if phone and normalize_phone(row["phone"]) == phone: | ||||
| 143 | + return "telephone" | ||||
| 144 | + postal = (loc.get("postalCode") or "").replace(" ", "").upper() | ||||
| 145 | + rpostal = (row["postal_code"] or "").replace(" ", "").upper() | ||||
| 146 | + civic_m = _CIVIC_RE.match(loc.get("streetAddress") or "") | ||||
| 147 | + rcivic_m = _CIVIC_RE.match(row["address"] or "") | ||||
| 148 | + if postal and rpostal == postal and civic_m and rcivic_m \ | ||||
| 149 | + and civic_m.group(1) == rcivic_m.group(1): | ||||
| 150 | + return "postal+civique" | ||||
| 151 | + lat, lng = loc.get("latitude"), loc.get("longitude") | ||||
| 152 | + if (lat is not None and lng is not None | ||||
| 153 | + and row["lat"] is not None and row["lng"] is not None | ||||
| 154 | + and _haversine_m(row["lat"], row["lng"], lat, lng) <= MAX_GPS_M): | ||||
| 155 | + addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents( | ||||
| 156 | + (loc.get("address") or "").lower())).split()) | ||||
| 157 | + if _name_similar(norm_name(row["name"]), | ||||
| 158 | + norm_name(store.get("title") or ""), addr_tokens): | ||||
| 159 | + return "gps+nom" | ||||
| 160 | + return "" | ||||
| 161 | + | ||||
| 162 | + | ||||
| 163 | +def slugify(name: str) -> str: | ||||
| 164 | + """Slug façon Uber Eats : « McDonald's » -> « mcdonalds », | ||||
| 165 | + « Café Dépôt » -> « cafe-depot » (apostrophes supprimées, pas remplacées).""" | ||||
| 166 | + s = strip_accents((name or "").lower()) | ||||
| 167 | + s = re.sub(r"['’´`.]", "", s) | ||||
| 168 | + s = re.sub(r"&", " and ", s) | ||||
| 169 | + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") | ||||
| 170 | + return s | ||||
| 171 | + | ||||
| 172 | + | ||||
| 173 | +class UberEatsConnector(BaseConnector): | ||||
| 174 | + source_id = "ubereats" | ||||
| 175 | + request_delay = 1.0 | ||||
| 176 | + timeout = 60 | ||||
| 177 | + use_detail_cache = False # verdicts gérés à la main (detail_cache) | ||||
| 178 | + enrichment_only = True # n'émet aucune fiche (ingest.run) | ||||
| 179 | + | ||||
| 180 | + # -- index sitemap --------------------------------------------------------- | ||||
| 181 | + def _fetch_sitemap(self, url: str) -> str: | ||||
| 182 | + result = self.scrapfly(url, render_js=False) | ||||
| 183 | + content = result.get("content") or "" | ||||
| 184 | + if url.endswith(".gz") or content[:20].startswith("H4sI"): | ||||
| 185 | + try: | ||||
| 186 | + return gzip.decompress(base64.b64decode(content)) \ | ||||
| 187 | + .decode("utf-8", "replace") | ||||
| 188 | + except (ValueError, OSError): | ||||
| 189 | + return content | ||||
| 190 | + return content | ||||
| 191 | + | ||||
| 192 | + def load_index(self) -> dict[str, list[str]]: | ||||
| 193 | + """Index slug -> [URLs /ca/store/…] depuis les sitemaps publics, | ||||
| 194 | + en cache local 30 jours (data/ubereats-stores.json).""" | ||||
| 195 | + if INDEX_PATH.exists(): | ||||
| 196 | + try: | ||||
| 197 | + data = json.loads(INDEX_PATH.read_text(encoding="utf-8")) | ||||
| 198 | + if time.time() - float(data.get("fetched_at") or 0) \ | ||||
| 199 | + < INDEX_REFRESH_DAYS * 86400: | ||||
| 200 | + return data.get("stores") or {} | ||||
| 201 | + except (ValueError, OSError): | ||||
| 202 | + pass | ||||
| 203 | + robots = self._fetch_sitemap(ROBOTS_URL) | ||||
| 204 | + sitemaps = re.findall(r"Sitemap:\s*(\S*sitemap-store\S*)", robots) | ||||
| 205 | + if not sitemaps: | ||||
| 206 | + raise RuntimeError("robots.txt Uber Eats sans sitemap-store " | ||||
| 207 | + "(blocage ?)") | ||||
| 208 | + stores: dict[str, list[str]] = {} | ||||
| 209 | + for sm_url in sitemaps: | ||||
| 210 | + xml = self._fetch_sitemap(sm_url) | ||||
| 211 | + for loc in re.findall(r"<loc>([^<]+)</loc>", xml): | ||||
| 212 | + mm = _STORE_URL_RE.search(loc) | ||||
| 213 | + if mm: | ||||
| 214 | + slug = urllib.parse.unquote(mm.group(1)).lower() | ||||
| 215 | + stores.setdefault(slug, []) | ||||
| 216 | + if loc not in stores[slug]: | ||||
| 217 | + stores[slug].append(loc) | ||||
| 218 | + INDEX_PATH.parent.mkdir(parents=True, exist_ok=True) | ||||
| 219 | + INDEX_PATH.write_text(json.dumps( | ||||
| 220 | + {"fetched_at": time.time(), "stores": stores}, | ||||
| 221 | + ensure_ascii=False), encoding="utf-8") | ||||
| 222 | + print(f"[resto-ka] ubereats: index sitemap rafraîchi — " | ||||
| 223 | + f"{len(stores)} slug(s) canadiens") | ||||
| 224 | + return stores | ||||
| 225 | + | ||||
| 226 | + @staticmethod | ||||
| 227 | + def candidates_for(name: str, slugs: list[str]) -> list[str]: | ||||
| 228 | + """Slugs Uber Eats candidats pour un nom de resto (préfixe strict).""" | ||||
| 229 | + base = slugify(name) | ||||
| 230 | + if len(base) < 5: | ||||
| 231 | + return [] | ||||
| 232 | + import bisect | ||||
| 233 | + i = bisect.bisect_left(slugs, base) | ||||
| 234 | + out = [] | ||||
| 235 | + while i < len(slugs) and len(out) < MAX_CANDIDATES: | ||||
| 236 | + s = slugs[i] | ||||
| 237 | + if s == base or s.startswith(base + "-"): | ||||
| 238 | + out.append(s) | ||||
| 239 | + i += 1 | ||||
| 240 | + else: | ||||
| 241 | + break | ||||
| 242 | + return out | ||||
| 243 | + | ||||
| 244 | + # -- cycle ----------------------------------------------------------------- | ||||
| 245 | + def _now(self) -> str: | ||||
| 246 | + return datetime.datetime.now(datetime.timezone.utc) \ | ||||
| 247 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | ||||
| 248 | + | ||||
| 249 | + def _fresh(self, stamp: str, now: float, stale_s: float) -> bool: | ||||
| 250 | + try: | ||||
| 251 | + ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \ | ||||
| 252 | + .replace(tzinfo=datetime.timezone.utc).timestamp() | ||||
| 253 | + return ts > now - stale_s | ||||
| 254 | + except (ValueError, TypeError): | ||||
| 255 | + return False | ||||
| 256 | + | ||||
| 257 | + def _details_payload(self, store: dict, url: str, how: str) -> dict: | ||||
| 258 | + rating = store.get("rating") or {} | ||||
| 259 | + return { | ||||
| 260 | + "rating": rating.get("ratingValue"), | ||||
| 261 | + "review_count": _review_count(store), | ||||
| 262 | + "price_bucket": store.get("priceBucket"), | ||||
| 263 | + "url": url.split("?")[0], | ||||
| 264 | + "uuid": store.get("uuid"), | ||||
| 265 | + "matched_by": how, | ||||
| 266 | + "fetched_at": self._now(), | ||||
| 267 | + } | ||||
| 268 | + | ||||
| 269 | + def _probe_store(self, con, row, url: str) -> tuple[bool, bool]: | ||||
| 270 | + """Visite une page magasin et l'attache au resto si c'est le même | ||||
| 271 | + établissement. Retourne (matched, menu_added).""" | ||||
| 272 | + from .. import db | ||||
| 273 | + mm = _STORE_URL_RE.search(url) | ||||
| 274 | + store_uuid = mm.group(2) if mm else url | ||||
| 275 | + cached = db.get_cached_detail(con, self.source_id, store_uuid, | ||||
| 276 | + "verdict-v1") | ||||
| 277 | + if cached is not None and cached.get("matched_uid") != row["uid"]: | ||||
| 278 | + return False, False # déjà identifié comme un autre resto | ||||
| 279 | + result = self.scrapfly(url, render_js=False) | ||||
| 280 | + status = result.get("status_code") or 0 | ||||
| 281 | + if status in (400, 404, 410, 451): # magasin retiré d'Uber Eats | ||||
| 282 | + db.put_cached_detail(con, self.source_id, store_uuid, | ||||
| 283 | + "verdict-v1", {"matched_uid": None, | ||||
| 284 | + "gone": status}) | ||||
| 285 | + return False, False | ||||
| 286 | + if status != 200: | ||||
| 287 | + raise RuntimeError(f"HTTP {status}") | ||||
| 288 | + state = decode_state(result.get("content") or "") | ||||
| 289 | + store = store_payload(state or {}) | ||||
| 290 | + if not store: | ||||
| 291 | + raise RuntimeError("payload getStoreV1 absent") | ||||
| 292 | + how = verify_store(row, store) | ||||
| 293 | + if not how: | ||||
| 294 | + db.put_cached_detail(con, self.source_id, store_uuid, | ||||
| 295 | + "verdict-v1", {"matched_uid": None}) | ||||
| 296 | + return False, False | ||||
| 297 | + db.put_cached_detail(con, self.source_id, store_uuid, "verdict-v1", | ||||
| 298 | + {"matched_uid": row["uid"]}) | ||||
| 299 | + menu = build_menu(store, self._now()) | ||||
| 300 | + menu_added = False | ||||
| 301 | + if menu: | ||||
| 302 | + # validation stricte du schéma menu (prix implausibles, etc.) | ||||
| 303 | + Restaurant(source=self.source_id, external_id=store_uuid, | ||||
| 304 | + name=row["name"], menu=menu)._validate_menu() | ||||
| 305 | + db.upsert_menu(con, row["uid"], menu, time.time()) | ||||
| 306 | + menu_added = True | ||||
| 307 | + db.merge_details(con, row["uid"], | ||||
| 308 | + {"ubereats": self._details_payload(store, url, how)}) | ||||
| 309 | + con.commit() | ||||
| 310 | + return True, menu_added | ||||
| 311 | + | ||||
| 312 | + def fetch(self) -> list[Restaurant]: | ||||
| 313 | + if not os.environ.get("SCRAPFLY_KEY"): | ||||
| 314 | + raise SkipSource("SCRAPFLY_KEY manquant (.env) — Uber Eats est " | ||||
| 315 | + "derrière Cloudflare, scraping direct impossible") | ||||
| 316 | + from .. import db | ||||
| 317 | + index = self.load_index() | ||||
| 318 | + slugs = sorted(index.keys()) | ||||
| 319 | + con = db.connect() | ||||
| 320 | + now = time.time() | ||||
| 321 | + stale_s = REFRESH_DAYS * 86400.0 | ||||
| 322 | + budget = MAX_BUDGET | ||||
| 323 | + matched = menus = misses = failures_row = 0 | ||||
| 324 | + rows = con.execute( | ||||
| 325 | + "SELECT uid, name, address, city, postal_code, phone, lat, lng," | ||||
| 326 | + " details," | ||||
| 327 | + " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" | ||||
| 328 | + " AS has_menu" | ||||
| 329 | + " FROM restaurants WHERE active=1 AND dup_of IS NULL AND name<>''" | ||||
| 330 | + " AND (phone<>'' OR (postal_code<>'' AND address<>'')" | ||||
| 331 | + " OR (lat IS NOT NULL AND lng IS NOT NULL))" | ||||
| 332 | + " ORDER BY has_menu ASC, phone<>'' DESC, updated_at DESC" | ||||
| 333 | + ).fetchall() | ||||
| 334 | + for row in rows: | ||||
| 335 | + if budget <= 0: | ||||
| 336 | + break | ||||
| 337 | + if failures_row >= MAX_CONSECUTIVE_FAILURES: | ||||
| 338 | + print("[resto-ka] ubereats: Scrapfly bloqué " | ||||
| 339 | + f"{failures_row} fois de suite — arrêt du cycle", | ||||
| 340 | + file=sys.stderr) | ||||
| 341 | + break | ||||
| 342 | + try: | ||||
| 343 | + details = json.loads(row["details"] or "{}") | ||||
| 344 | + except ValueError: | ||||
| 345 | + details = {} | ||||
| 346 | + ue = details.get("ubereats") or {} | ||||
| 347 | + if self._fresh(ue.get("fetched_at", ""), now, stale_s): | ||||
| 348 | + continue # déjà frais (<30 j) | ||||
| 349 | + probe = details.get("ubereats_probe") or {} | ||||
| 350 | + if self._fresh(probe.get("fetched_at", ""), now, stale_s): | ||||
| 351 | + continue # échec récent : re-visite dans 30 j | ||||
| 352 | + if ue.get("url"): # déjà croisé : rafraîchir directement | ||||
| 353 | + urls = [ue["url"]] | ||||
| 354 | + else: | ||||
| 355 | + cand = self.candidates_for(row["name"], slugs) | ||||
| 356 | + urls = [u for s in cand for u in index.get(s, [])] | ||||
| 357 | + if not urls: | ||||
| 358 | + continue # aucun candidat : pas de marqueur, | ||||
| 359 | + # l'index du mois prochain peut changer | ||||
| 360 | + found = False | ||||
| 361 | + for url in urls[:MAX_CANDIDATES]: | ||||
| 362 | + if budget <= 0: | ||||
| 363 | + break | ||||
| 364 | + budget -= 1 | ||||
| 365 | + try: | ||||
| 366 | + ok, menu_added = self._probe_store(con, row, url) | ||||
| 367 | + except Exception as exc: | ||||
| 368 | + failures_row += 1 | ||||
| 369 | + print(f"[resto-ka] ubereats: {row['uid']} erreur: {exc}", | ||||
| 370 | + file=sys.stderr) | ||||
| 371 | + continue | ||||
| 372 | + failures_row = 0 | ||||
| 373 | + if ok: | ||||
| 374 | + matched += 1 | ||||
| 375 | + menus += 1 if menu_added else 0 | ||||
| 376 | + found = True | ||||
| 377 | + break | ||||
| 378 | + if not found and not ue.get("url"): | ||||
| 379 | + db.merge_details(con, row["uid"], | ||||
| 380 | + {"ubereats_probe": | ||||
| 381 | + {"miss": "aucun magasin correspondant", | ||||
| 382 | + "fetched_at": self._now()}}) | ||||
| 383 | + con.commit() | ||||
| 384 | + misses += 1 | ||||
| 385 | + con.commit() | ||||
| 386 | + con.close() | ||||
| 387 | + self.enriched_count = matched | ||||
| 388 | + self.enrich_message = (f"{matched} resto(s) croisés Uber Eats " | ||||
| 389 | + f"({menus} menu(s) livraison), {misses} sans " | ||||
| 390 | + f"correspondance, budget restant " | ||||
| 391 | + f"{max(budget, 0)} page(s)") | ||||
| 392 | + print(f"[resto-ka] ubereats: {self.enrich_message}") | ||||
| 393 | + return [] | ||||
added
restoka/connectors/yelp_scrape.py
+238 −0
@@ -0,0 +1,238 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/connectors/yelp_scrape.py | ||||
| 4 | +# Desc: Connecteur d'ENRICHISSEMENT Yelp SANS clé API (avis/notes par | ||||
| 5 | +# scraping léger). Une seule page de RECHERCHE yelp.ca par resto | ||||
| 6 | +# (find_desc=<nom>&find_loc=<ville>) via Scrapfly ASP : la page embarque | ||||
| 7 | +# un cache Apollo (scripts data-apollo-state) avec, PAR commerce, | ||||
| 8 | +# alias, nom, note, nombre d'avis, fourchette de prix, catégories et | ||||
| 9 | +# adresse (ville + civique). Croisement CONSERVATEUR : nom similaire | ||||
| 10 | +# + même numéro civique + ville compatible (jamais fusionner deux | ||||
| 11 | +# établissements). Résultat -> details.yelp {rating, review_count, | ||||
| 12 | +# price, categories, url} (mêmes clés que Yelp Fusion — le connecteur | ||||
| 13 | +# officiel `yelp` reprend la main dès qu'une YELP_API_KEY existe). | ||||
| 14 | +# | ||||
| 15 | +# Économe : budget YELP_SCRAPE_BUDGET (défaut 80) pages/cycle, | ||||
| 16 | +# re-visite 30 jours (hit comme miss), priorité aux restos avec menu. | ||||
| 17 | +# Base légale : pages publiques, extraction minimale (note agrégée + | ||||
| 18 | +# lien vers la fiche Yelp, attribution affichée) — voir CLAUDE.md §15. | ||||
| 19 | +# ============================================================================== | ||||
| 20 | +from __future__ import annotations | ||||
| 21 | + | ||||
| 22 | +import datetime | ||||
| 23 | +import html as html_mod | ||||
| 24 | +import json | ||||
| 25 | +import os | ||||
| 26 | +import re | ||||
| 27 | +import sys | ||||
| 28 | +import urllib.parse | ||||
| 29 | + | ||||
| 30 | +from ..inspections import _CIVIC_RE, _name_similar, norm_name | ||||
| 31 | +from ..regions import strip_accents | ||||
| 32 | +from ..schema import Restaurant | ||||
| 33 | +from .base import BaseConnector, SkipSource | ||||
| 34 | + | ||||
| 35 | +SEARCH_URL = "https://www.yelp.ca/search?find_desc={desc}&find_loc={loc}" | ||||
| 36 | +MAX_BUDGET = int(os.environ.get("YELP_SCRAPE_BUDGET", "80")) # pages/cycle | ||||
| 37 | +REFRESH_DAYS = 30 # re-visite des notes ET des échecs | ||||
| 38 | +MAX_CONSECUTIVE_FAILURES = 3 # Yelp bloque même Scrapfly -> on arrête | ||||
| 39 | + | ||||
| 40 | +_APOLLO_RE = re.compile( | ||||
| 41 | + r"<script[^>]*data-apollo-state[^>]*>\s*<!--(.*?)-->\s*</script>", re.S) | ||||
| 42 | + | ||||
| 43 | + | ||||
| 44 | +def parse_apollo_businesses(page_html: str) -> list[dict]: | ||||
| 45 | + """Extrait les commerces du cache Apollo embarqué dans une page de | ||||
| 46 | + recherche yelp.ca : [{alias, name, rating, review_count, price, | ||||
| 47 | + categories, city, address, closed}].""" | ||||
| 48 | + cache: dict = {} | ||||
| 49 | + for m in _APOLLO_RE.finditer(page_html): | ||||
| 50 | + try: | ||||
| 51 | + cache.update(json.loads(html_mod.unescape(m.group(1)))) | ||||
| 52 | + except ValueError: | ||||
| 53 | + continue | ||||
| 54 | + if not cache: | ||||
| 55 | + return [] | ||||
| 56 | + categories = {k.split(":", 1)[1]: (v.get("title") or "") | ||||
| 57 | + for k, v in cache.items() | ||||
| 58 | + if k.startswith("BusinessCategory:") and isinstance(v, dict)} | ||||
| 59 | + locations = {k.split(":", 1)[1]: (v.get("address") or {}) | ||||
| 60 | + for k, v in cache.items() | ||||
| 61 | + if k.startswith("BusinessLocation:") and isinstance(v, dict)} | ||||
| 62 | + out = [] | ||||
| 63 | + for k, v in cache.items(): | ||||
| 64 | + if not k.startswith("Business:") or not isinstance(v, dict): | ||||
| 65 | + continue | ||||
| 66 | + if not v.get("alias") or not v.get("name"): | ||||
| 67 | + continue | ||||
| 68 | + loc_ref = ((v.get("location") or {}).get("__ref") or "") | ||||
| 69 | + addr = locations.get(loc_ref.split(":", 1)[-1], {}) | ||||
| 70 | + cats = [] | ||||
| 71 | + for c in v.get("categories") or []: | ||||
| 72 | + ref = (c.get("__ref") or "").split(":", 1)[-1] | ||||
| 73 | + if categories.get(ref): | ||||
| 74 | + cats.append(categories[ref]) | ||||
| 75 | + closed = any((a or {}).get("type") == "permclosed" | ||||
| 76 | + for a in [v.get(kk) for kk in v | ||||
| 77 | + if kk.startswith("activeAlert")]) | ||||
| 78 | + price = v.get("priceRange") | ||||
| 79 | + out.append({ | ||||
| 80 | + "alias": v["alias"], | ||||
| 81 | + "name": v["name"], | ||||
| 82 | + "rating": v.get("rating"), | ||||
| 83 | + "review_count": v.get("reviewCount") or 0, | ||||
| 84 | + "price": (price or {}).get("display") if isinstance(price, dict) | ||||
| 85 | + else price, | ||||
| 86 | + "categories": cats, | ||||
| 87 | + "city": (addr or {}).get("city") or "", | ||||
| 88 | + "address": (addr or {}).get("addressLine1") or "", | ||||
| 89 | + "closed": closed, | ||||
| 90 | + }) | ||||
| 91 | + return out | ||||
| 92 | + | ||||
| 93 | + | ||||
| 94 | +def _city_key(city: str) -> str: | ||||
| 95 | + """« Quebec City » / « Québec » -> « quebec » ; « Montréal » -> « montreal ».""" | ||||
| 96 | + s = strip_accents((city or "").lower()) | ||||
| 97 | + s = re.sub(r"[^a-z0-9]+", " ", s).strip() | ||||
| 98 | + return re.sub(r"\bcity\b", "", s).strip() | ||||
| 99 | + | ||||
| 100 | + | ||||
| 101 | +def match_business(row, candidates: list[dict]) -> dict | None: | ||||
| 102 | + """Croisement CONSERVATEUR d'un resto avec les résultats Yelp : | ||||
| 103 | + nom similaire ET même numéro civique ET ville compatible. Sans civique | ||||
| 104 | + des deux côtés : nom normalisé IDENTIQUE + même ville. Ambigu -> None.""" | ||||
| 105 | + rname = norm_name(row["name"]) | ||||
| 106 | + rcity = _city_key(row["city"]) | ||||
| 107 | + rcivic_m = _CIVIC_RE.match(row["address"] or "") | ||||
| 108 | + rcivic = rcivic_m.group(1) if rcivic_m else "" | ||||
| 109 | + addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents( | ||||
| 110 | + (row["address"] or "").lower())).split()) | set(rcity.split()) | ||||
| 111 | + hits = [] | ||||
| 112 | + for c in candidates: | ||||
| 113 | + if c["closed"]: | ||||
| 114 | + continue | ||||
| 115 | + ccity = _city_key(c["city"]) | ||||
| 116 | + if rcity and ccity and rcity != ccity \ | ||||
| 117 | + and rcity not in ccity and ccity not in rcity: | ||||
| 118 | + continue | ||||
| 119 | + cname = norm_name(c["name"]) | ||||
| 120 | + ccivic_m = _CIVIC_RE.match(c["address"] or "") | ||||
| 121 | + ccivic = ccivic_m.group(1) if ccivic_m else "" | ||||
| 122 | + if rcivic and ccivic: | ||||
| 123 | + if rcivic == ccivic and _name_similar(rname, cname, addr_tokens): | ||||
| 124 | + hits.append(c) | ||||
| 125 | + elif rname and rname == cname and rcity and ccity: | ||||
| 126 | + hits.append(c) # nom exact + ville, sans civique | ||||
| 127 | + aliases = {h["alias"] for h in hits} | ||||
| 128 | + if len(aliases) == 1: | ||||
| 129 | + return hits[0] | ||||
| 130 | + return None # rien ou ambigu : on ne fusionne pas | ||||
| 131 | + | ||||
| 132 | + | ||||
| 133 | +class YelpScrapeConnector(BaseConnector): | ||||
| 134 | + source_id = "yelp-scrape" | ||||
| 135 | + request_delay = 1.0 | ||||
| 136 | + timeout = 60 | ||||
| 137 | + use_detail_cache = False | ||||
| 138 | + enrichment_only = True # n'émet aucune fiche (ingest.run) | ||||
| 139 | + | ||||
| 140 | + def _now(self) -> str: | ||||
| 141 | + return datetime.datetime.now(datetime.timezone.utc) \ | ||||
| 142 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | ||||
| 143 | + | ||||
| 144 | + def _fresh(self, stamp: str, now: float, stale_s: float) -> bool: | ||||
| 145 | + try: | ||||
| 146 | + ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \ | ||||
| 147 | + .replace(tzinfo=datetime.timezone.utc).timestamp() | ||||
| 148 | + return ts > now - stale_s | ||||
| 149 | + except (ValueError, TypeError): | ||||
| 150 | + return False | ||||
| 151 | + | ||||
| 152 | + def fetch(self) -> list[Restaurant]: | ||||
| 153 | + if not os.environ.get("SCRAPFLY_KEY"): | ||||
| 154 | + raise SkipSource("SCRAPFLY_KEY manquant (.env) — scraping Yelp " | ||||
| 155 | + "impossible sans contournement anti-bot") | ||||
| 156 | + import time as _time | ||||
| 157 | + from .. import db | ||||
| 158 | + con = db.connect() | ||||
| 159 | + now = _time.time() | ||||
| 160 | + stale_s = REFRESH_DAYS * 86400.0 | ||||
| 161 | + budget = MAX_BUDGET | ||||
| 162 | + enriched = misses = failures_row = 0 | ||||
| 163 | + rows = con.execute( | ||||
| 164 | + "SELECT uid, name, address, city, details," | ||||
| 165 | + " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" | ||||
| 166 | + " AS has_menu" | ||||
| 167 | + " FROM restaurants WHERE active=1 AND dup_of IS NULL" | ||||
| 168 | + " AND name<>'' AND city<>''" | ||||
| 169 | + # indépendants d'abord : les succursales de chaînes n'ont presque | ||||
| 170 | + # jamais d'avis Yelp (budget mieux investi ailleurs) | ||||
| 171 | + " ORDER BY has_menu DESC, chain IS NULL DESC, phone<>'' DESC," | ||||
| 172 | + " updated_at DESC" | ||||
| 173 | + ).fetchall() | ||||
| 174 | + for row in rows: | ||||
| 175 | + if budget <= 0: | ||||
| 176 | + break | ||||
| 177 | + if failures_row >= MAX_CONSECUTIVE_FAILURES: | ||||
| 178 | + print("[resto-ka] yelp-scrape: Scrapfly bloqué " | ||||
| 179 | + f"{failures_row} fois de suite — arrêt du cycle", | ||||
| 180 | + file=sys.stderr) | ||||
| 181 | + break | ||||
| 182 | + try: | ||||
| 183 | + details = json.loads(row["details"] or "{}") | ||||
| 184 | + except ValueError: | ||||
| 185 | + details = {} | ||||
| 186 | + yelp = details.get("yelp") or {} | ||||
| 187 | + if self._fresh(yelp.get("fetched_at", ""), now, stale_s): | ||||
| 188 | + continue # note déjà fraîche (<30 j) | ||||
| 189 | + probe = details.get("yelp_scrape") or {} | ||||
| 190 | + if self._fresh(probe.get("fetched_at", ""), now, stale_s): | ||||
| 191 | + continue # échec récent : re-visite dans 30 j | ||||
| 192 | + url = SEARCH_URL.format( | ||||
| 193 | + desc=urllib.parse.quote(row["name"][:64]), | ||||
| 194 | + loc=urllib.parse.quote(f"{row['city']}, QC")) | ||||
| 195 | + budget -= 1 | ||||
| 196 | + try: | ||||
| 197 | + result = self.scrapfly(url, render_js=False) | ||||
| 198 | + except Exception as exc: | ||||
| 199 | + failures_row += 1 | ||||
| 200 | + print(f"[resto-ka] yelp-scrape: {row['uid']} erreur: {exc}", | ||||
| 201 | + file=sys.stderr) | ||||
| 202 | + continue | ||||
| 203 | + if (result.get("status_code") or 0) != 200: | ||||
| 204 | + failures_row += 1 | ||||
| 205 | + continue | ||||
| 206 | + failures_row = 0 | ||||
| 207 | + candidates = parse_apollo_businesses(result.get("content") or "") | ||||
| 208 | + hit = match_business(row, candidates) | ||||
| 209 | + if hit and hit.get("rating") is not None: | ||||
| 210 | + db.merge_details(con, row["uid"], {"yelp": { | ||||
| 211 | + "url": f"https://www.yelp.ca/biz/" | ||||
| 212 | + f"{urllib.parse.quote(hit['alias'])}", | ||||
| 213 | + "name": hit["name"], | ||||
| 214 | + "rating": hit["rating"], | ||||
| 215 | + "review_count": hit["review_count"], | ||||
| 216 | + "price": hit.get("price"), | ||||
| 217 | + "categories": hit.get("categories") or [], | ||||
| 218 | + "matched_by": "nom+adresse", | ||||
| 219 | + "via": "scrape", | ||||
| 220 | + "fetched_at": self._now(), | ||||
| 221 | + }}) | ||||
| 222 | + enriched += 1 | ||||
| 223 | + else: | ||||
| 224 | + reason = ("fiche sans note" if hit else | ||||
| 225 | + "introuvable ou ambigu") | ||||
| 226 | + db.merge_details(con, row["uid"], | ||||
| 227 | + {"yelp_scrape": {"miss": reason, | ||||
| 228 | + "fetched_at": self._now()}}) | ||||
| 229 | + misses += 1 | ||||
| 230 | + con.commit() | ||||
| 231 | + con.commit() | ||||
| 232 | + con.close() | ||||
| 233 | + self.enriched_count = enriched | ||||
| 234 | + self.enrich_message = (f"{enriched} resto(s) notés (scraping), " | ||||
| 235 | + f"{misses} sans correspondance, " | ||||
| 236 | + f"budget restant {max(budget, 0)} page(s)") | ||||
| 237 | + print(f"[resto-ka] yelp-scrape: {self.enrich_message}") | ||||
| 238 | + return [] | ||||
modified
restoka/ingest.py
+5 −0
@@ -104,6 +104,11 @@ def enrich() -> None: | |||||
| 104 | inspections.sync() | 104 | inspections.sync() | ||
| 105 | except Exception as exc: | 105 | except Exception as exc: | ||
| 106 | print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr) | 106 | print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr) | ||
| 107 | + try: # permis d'alcool RACJ (Données Québec) — cadence hebdo, croisement | ||||
| 108 | + from . import permits | ||||
| 109 | + permits.sync() | ||||
| 110 | + except Exception as exc: | ||||
| 111 | + print(f"[resto-ka] racj: erreur non bloquante: {exc}", file=sys.stderr) | ||||
| 107 | 112 | ||||
| 108 | 113 | ||||
| 109 | def watch(interval_seconds: int = 7 * 86400 // 7) -> None: | 114 | def watch(interval_seconds: int = 7 * 86400 // 7) -> None: | ||
added
restoka/permits.py
+218 −0
@@ -0,0 +1,218 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: restoka/permits.py | ||||
| 4 | +# Desc: Enrichissement RACJ — permis d'alcool en vigueur (Régie des alcools, | ||||
| 5 | +# des courses et des jeux, Données Québec, licence CC-BY 4.0). | ||||
| 6 | +# Télécharge le registre CSV des permis de détaillant d'alcool | ||||
| 7 | +# (bar, restaurant pour vendre/servir…), regroupe par établissement et | ||||
| 8 | +# croise de façon CONSERVATRICE avec les restaurants (mêmes règles que | ||||
| 9 | +# les inspections MAPAQ : nom exact + lieu, ou postal + civique + nom | ||||
| 10 | +# similaire — jamais fusionner deux établissements). Résultat dans | ||||
| 11 | +# details.permis_alcool (catégories de permis, capacité, titulaire). | ||||
| 12 | +# Gratuit, sans clé API. Cadence hebdomadaire (guard 6 jours). | ||||
| 13 | +# ============================================================================== | ||||
| 14 | +from __future__ import annotations | ||||
| 15 | + | ||||
| 16 | +import csv | ||||
| 17 | +import io | ||||
| 18 | +import json | ||||
| 19 | +import re | ||||
| 20 | +import sys | ||||
| 21 | +import time | ||||
| 22 | +from datetime import date | ||||
| 23 | + | ||||
| 24 | +import requests | ||||
| 25 | + | ||||
| 26 | +from .connectors.base import USER_AGENT | ||||
| 27 | +from .inspections import (_CIVIC_RE, _NUMBERED_CO_RE, _name_similar, | ||||
| 28 | + core_name, norm_name) | ||||
| 29 | +from .regions import strip_accents | ||||
| 30 | + | ||||
| 31 | +SOURCE_ID = "racj" | ||||
| 32 | +REFRESH_DAYS = 6 # au plus une fois par cycle hebdo | ||||
| 33 | +CSV_URL = ("https://www.donneesquebec.ca/recherche/dataset/" | ||||
| 34 | + "d817c9f7-76c7-44af-882d-0d673056ef86/resource/" | ||||
| 35 | + "6b69360c-af8d-4c57-b5bf-1c38d5461de3/download/" | ||||
| 36 | + "racj-alcool-detaillant.csv") | ||||
| 37 | + | ||||
| 38 | + | ||||
| 39 | +def _download() -> list[dict]: | ||||
| 40 | + resp = requests.get(CSV_URL, headers={"User-Agent": USER_AGENT}, timeout=120) | ||||
| 41 | + resp.raise_for_status() | ||||
| 42 | + text = None | ||||
| 43 | + for enc in ("utf-8-sig", "latin-1"): | ||||
| 44 | + try: | ||||
| 45 | + text = resp.content.decode(enc) | ||||
| 46 | + break | ||||
| 47 | + except UnicodeDecodeError: | ||||
| 48 | + continue | ||||
| 49 | + if text is None: | ||||
| 50 | + raise RuntimeError("encodage CSV RACJ inconnu") | ||||
| 51 | + return list(csv.DictReader(io.StringIO(text))) | ||||
| 52 | + | ||||
| 53 | + | ||||
| 54 | +def group_establishments(records: list[dict]) -> list[dict]: | ||||
| 55 | + """Regroupe les lignes CSV (une par local/terrasse/permis) par | ||||
| 56 | + établissement (NoEtablissement) : catégories de permis, capacité totale, | ||||
| 57 | + nom, adresse, ville, code postal, titulaire.""" | ||||
| 58 | + by_no: dict[str, dict] = {} | ||||
| 59 | + for rec in records: | ||||
| 60 | + no = (rec.get("NoEtablissement") or "").strip() | ||||
| 61 | + if not no: | ||||
| 62 | + continue | ||||
| 63 | + e = by_no.setdefault(no, { | ||||
| 64 | + "no": no, | ||||
| 65 | + "nom": (rec.get("RaisonSociale") or "").strip(), | ||||
| 66 | + "titulaire": (rec.get("Titulaire") or "").strip(), | ||||
| 67 | + "adresse": (rec.get("Adresse") or "").strip(), | ||||
| 68 | + "ville": (rec.get("Ville") or "").strip(), | ||||
| 69 | + "postal": (rec.get("CodePostal") or "").replace(" ", "").upper(), | ||||
| 70 | + "categories": set(), | ||||
| 71 | + "capacite": 0, | ||||
| 72 | + "permis": set(), | ||||
| 73 | + }) | ||||
| 74 | + cat = (rec.get("Categorie") or "").strip() | ||||
| 75 | + if cat: | ||||
| 76 | + e["categories"].add(cat) | ||||
| 77 | + e["permis"].add((rec.get("NoPermis") or "").strip()) | ||||
| 78 | + try: | ||||
| 79 | + e["capacite"] += int(rec.get("Capacite") or 0) | ||||
| 80 | + except ValueError: | ||||
| 81 | + pass | ||||
| 82 | + return list(by_no.values()) | ||||
| 83 | + | ||||
| 84 | + | ||||
| 85 | +def match(con, establishments: list[dict]) -> dict: | ||||
| 86 | + """Croisement CONSERVATEUR permis <-> restaurants (mêmes règles que | ||||
| 87 | + inspections.match) : | ||||
| 88 | + | ||||
| 89 | + Règle A « nom+lieu » : nom commercial normalisé IDENTIQUE ET (code postal | ||||
| 90 | + identique OU même ville + même numéro civique). | ||||
| 91 | + Règle B « adresse+nom » : code postal identique ET numéro civique | ||||
| 92 | + identique ET similarité de nom (hors tokens d'adresse/ville). | ||||
| 93 | + """ | ||||
| 94 | + restos = con.execute( | ||||
| 95 | + "SELECT uid, name, chain, city, postal_code, address FROM restaurants" | ||||
| 96 | + " WHERE active=1 AND dup_of IS NULL").fetchall() | ||||
| 97 | + by_name: dict[str, list] = {} | ||||
| 98 | + by_postal: dict[str, list] = {} | ||||
| 99 | + for r in restos: | ||||
| 100 | + info = { | ||||
| 101 | + "uid": r["uid"], | ||||
| 102 | + "nname": norm_name(r["name"]), | ||||
| 103 | + "nchain": norm_name(r["chain"] or ""), | ||||
| 104 | + "ncity": strip_accents((r["city"] or "").lower()).strip(), | ||||
| 105 | + "postal": (r["postal_code"] or "").replace(" ", "").upper(), | ||||
| 106 | + } | ||||
| 107 | + mm = _CIVIC_RE.match(r["address"] or "") | ||||
| 108 | + info["civic"] = mm.group(1) if mm else "" | ||||
| 109 | + if info["nname"]: | ||||
| 110 | + by_name.setdefault(info["nname"], []).append(info) | ||||
| 111 | + core = core_name(info["nname"]) | ||||
| 112 | + if core and core != info["nname"]: | ||||
| 113 | + by_name.setdefault(core, []).append(info) | ||||
| 114 | + if info["postal"]: | ||||
| 115 | + by_postal.setdefault(info["postal"], []).append(info) | ||||
| 116 | + | ||||
| 117 | + today = date.today().isoformat() | ||||
| 118 | + matched = 0 | ||||
| 119 | + seen_uids: set[str] = set() | ||||
| 120 | + for est in establishments: | ||||
| 121 | + names = [] | ||||
| 122 | + if est["nom"]: | ||||
| 123 | + names.append(norm_name(est["nom"])) | ||||
| 124 | + tit = est["titulaire"] | ||||
| 125 | + if tit and not _NUMBERED_CO_RE.match(strip_accents(tit.lower())): | ||||
| 126 | + names.append(norm_name(tit)) | ||||
| 127 | + names = [n for n in names if n] | ||||
| 128 | + names += [c for c in (core_name(n) for n in names) | ||||
| 129 | + if c and c not in names] | ||||
| 130 | + if not names: | ||||
| 131 | + continue | ||||
| 132 | + ncity = strip_accents(est["ville"].lower()).strip() | ||||
| 133 | + civic_m = _CIVIC_RE.match(est["adresse"]) | ||||
| 134 | + civic = civic_m.group(1) if civic_m else "" | ||||
| 135 | + postal = est["postal"] | ||||
| 136 | + | ||||
| 137 | + hit, how = None, "" | ||||
| 138 | + # Règle A : nom exact + code postal ou ville+civique | ||||
| 139 | + for n in names: | ||||
| 140 | + for info in by_name.get(n, []): | ||||
| 141 | + same_place = ((postal and info["postal"] == postal) | ||||
| 142 | + or (ncity and info["ncity"] | ||||
| 143 | + and (ncity == info["ncity"] | ||||
| 144 | + or ncity in info["ncity"] | ||||
| 145 | + or info["ncity"] in ncity) | ||||
| 146 | + and civic and info["civic"] == civic)) | ||||
| 147 | + if same_place: | ||||
| 148 | + hit, how = info["uid"], "nom+lieu" | ||||
| 149 | + break | ||||
| 150 | + if hit: | ||||
| 151 | + break | ||||
| 152 | + # Règle B : code postal + civique + similarité de nom | ||||
| 153 | + if hit is None and postal and civic: | ||||
| 154 | + addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents( | ||||
| 155 | + est["adresse"].lower())).split()) | {ncity} | ||||
| 156 | + for info in by_postal.get(postal, []): | ||||
| 157 | + if info["civic"] != civic: | ||||
| 158 | + continue | ||||
| 159 | + if any(_name_similar(n, info["nname"], addr_tokens) | ||||
| 160 | + or (info["nchain"] | ||||
| 161 | + and _name_similar(n, info["nchain"], addr_tokens)) | ||||
| 162 | + for n in names): | ||||
| 163 | + hit, how = info["uid"], "adresse+nom" | ||||
| 164 | + break | ||||
| 165 | + if hit is None or hit in seen_uids: | ||||
| 166 | + continue # jamais deux établissements sur 1 fiche | ||||
| 167 | + seen_uids.add(hit) | ||||
| 168 | + from . import db as _db | ||||
| 169 | + _db.merge_details(con, hit, {"permis_alcool": { | ||||
| 170 | + "categories": sorted(est["categories"]), | ||||
| 171 | + "nb_permis": len(est["permis"]), | ||||
| 172 | + "capacite": est["capacite"] or None, | ||||
| 173 | + "titulaire": est["titulaire"], | ||||
| 174 | + "matched_by": how, | ||||
| 175 | + "source": "RACJ / Données Québec (CC-BY 4.0)", | ||||
| 176 | + "maj": today, | ||||
| 177 | + }}) | ||||
| 178 | + matched += 1 | ||||
| 179 | + con.commit() | ||||
| 180 | + total = con.execute( | ||||
| 181 | + "SELECT COUNT(*) c FROM restaurants WHERE active=1 AND dup_of IS NULL" | ||||
| 182 | + " AND details LIKE '%\"permis_alcool\"%'").fetchone()["c"] | ||||
| 183 | + return {"matched": matched, "with_permit": total} | ||||
| 184 | + | ||||
| 185 | + | ||||
| 186 | +def sync(con=None, force: bool = False) -> dict | None: | ||||
| 187 | + """Télécharge le registre RACJ, regroupe et croise. Cadence hebdo.""" | ||||
| 188 | + from . import db | ||||
| 189 | + own = con is None | ||||
| 190 | + if own: | ||||
| 191 | + con = db.connect() | ||||
| 192 | + try: | ||||
| 193 | + last = con.execute( | ||||
| 194 | + "SELECT MAX(ts) ts FROM sync_log WHERE source=? AND ok=1", | ||||
| 195 | + (SOURCE_ID,)).fetchone()["ts"] | ||||
| 196 | + if not force and last and time.time() - last < REFRESH_DAYS * 86400: | ||||
| 197 | + return None # déjà à jour cette semaine | ||||
| 198 | + records = _download() | ||||
| 199 | + establishments = group_establishments(records) | ||||
| 200 | + m = match(con, establishments) | ||||
| 201 | + stats = {"rows": len(records), "etablissements": len(establishments), | ||||
| 202 | + **m} | ||||
| 203 | + con.execute( | ||||
| 204 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed," | ||||
| 205 | + " ok, message, stats) VALUES (?,?,?,?,0,0,1,?,?)", | ||||
| 206 | + (SOURCE_ID, time.time(), len(establishments), m["matched"], | ||||
| 207 | + f"permis d'alcool croisés : {m['with_permit']} resto(s)", | ||||
| 208 | + json.dumps(stats, ensure_ascii=False))) | ||||
| 209 | + con.commit() | ||||
| 210 | + print(f"[resto-ka] racj: {stats}") | ||||
| 211 | + return stats | ||||
| 212 | + except Exception as exc: | ||||
| 213 | + db.log_failure(con, SOURCE_ID, str(exc)) | ||||
| 214 | + print(f"[resto-ka] racj: erreur non bloquante: {exc}", file=sys.stderr) | ||||
| 215 | + return {"error": str(exc)} | ||||
| 216 | + finally: | ||||
| 217 | + if own: | ||||
| 218 | + con.close() | ||||
modified
restoka/web.py
+6 −1
@@ -452,13 +452,18 @@ def stats(): | |||||
| 452 | SUM(uid IS NOT NULL) inspections_matched, | 452 | SUM(uid IS NOT NULL) inspections_matched, | ||
| 453 | COUNT(DISTINCT uid) restaurants_with_inspections | 453 | COUNT(DISTINCT uid) restaurants_with_inspections | ||
| 454 | FROM inspections""").fetchone()) | 454 | FROM inspections""").fetchone()) | ||
| 455 | + enr = dict(con.execute( | ||||
| 456 | + """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating, | ||||
| 457 | + SUM(details LIKE '%"ubereats":%') with_ubereats, | ||||
| 458 | + SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit | ||||
| 459 | + FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone()) | ||||
| 455 | con.close() | 460 | con.close() | ||
| 456 | try: # nb de sources au registre (data/sources.json), actives ou en attente | 461 | try: # nb de sources au registre (data/sources.json), actives ou en attente | ||
| 457 | registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | 462 | registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | ||
| 458 | registered = len(registry) | 463 | registered = len(registry) | ||
| 459 | except (ValueError, OSError, KeyError): | 464 | except (ValueError, OSError, KeyError): | ||
| 460 | registered = None | 465 | registered = None | ||
| 461 | − return {**head, **m, **insp, "sources_registry": registered, | 466 | + return {**head, **m, **insp, **enr, "sources_registry": registered, | ||
| 462 | "by_region": by_region, "by_context": by_context, | 467 | "by_region": by_region, "by_context": by_context, | ||
| 463 | "recent_syncs": log} | 468 | "recent_syncs": log} | ||
| 464 | 469 | ||||
modified
scripts/gen_connector_docs.py
+2 −1
@@ -26,7 +26,8 @@ from pathlib import Path | |||||
| 26 | 26 | ||||
| 27 | ROOT = Path(__file__).resolve().parents[1] | 27 | ROOT = Path(__file__).resolve().parents[1] | ||
| 28 | CONN_DIR = ROOT / "restoka" / "connectors" | 28 | CONN_DIR = ROOT / "restoka" / "connectors" | ||
| 29 | −EXTRA_MODULES = [ROOT / "restoka" / "inspections.py"] # mapaq (enrichissement) | 29 | +EXTRA_MODULES = [ROOT / "restoka" / "inspections.py", # mapaq (enrichissement) | ||
| 30 | + ROOT / "restoka" / "permits.py"] # racj (enrichissement) | ||||
| 30 | DOCS_DIR = ROOT / "docs" / "connecteurs" | 31 | DOCS_DIR = ROOT / "docs" / "connecteurs" | ||
| 31 | DB_PATH = ROOT / "data" / "restoka.db" | 32 | DB_PATH = ROOT / "data" / "restoka.db" | ||
| 32 | SOURCES_JSON = ROOT / "data" / "sources.json" | 33 | SOURCES_JSON = ROOT / "data" / "sources.json" | ||
added
tests/test_permits.py
+102 −0
@@ -0,0 +1,102 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: tests/test_permits.py | ||||
| 4 | +# Desc: Enrichissement RACJ (permis d'alcool) — regroupement des lignes CSV | ||||
| 5 | +# par établissement, croisement CONSERVATEUR avec les restaurants | ||||
| 6 | +# (nom+lieu, adresse+nom, jamais de fusion hasardeuse) et écriture | ||||
| 7 | +# de details.permis_alcool. Tout HORS LIGNE (aucun téléchargement). | ||||
| 8 | +# ============================================================================== | ||||
| 9 | +import json | ||||
| 10 | + | ||||
| 11 | +from restoka import permits | ||||
| 12 | +from restoka import db as rdb | ||||
| 13 | +from restoka.schema import Restaurant | ||||
| 14 | + | ||||
| 15 | +RECORDS = [ | ||||
| 16 | + { # établissement 1 : deux lignes (salle + terrasse), deux permis | ||||
| 17 | + "NoEtablissement": "22723", "RaisonSociale": "Chez Mamy", | ||||
| 18 | + "Titulaire": "9312-5581 Québec Inc.", "Neq": "1170486295", | ||||
| 19 | + "Adresse": "1999 Rue Sainte-Famille ", "CodeVille": "94068", | ||||
| 20 | + "Ville": "Saguenay", "CodePostal": "G7X4X5", "RegAdmin": "02", | ||||
| 21 | + "NoPermis": "100000067-1", "Categorie": "Restaurant pour vendre", | ||||
| 22 | + "TypeLocal": "Autre", "Capacite": "80", | ||||
| 23 | + }, | ||||
| 24 | + { | ||||
| 25 | + "NoEtablissement": "22723", "RaisonSociale": "Chez Mamy", | ||||
| 26 | + "Titulaire": "9312-5581 Québec Inc.", "Neq": "1170486295", | ||||
| 27 | + "Adresse": "1999 Rue Sainte-Famille ", "CodeVille": "94068", | ||||
| 28 | + "Ville": "Saguenay", "CodePostal": "G7X4X5", "RegAdmin": "02", | ||||
| 29 | + "NoPermis": "100000068-1", "Categorie": "Bar", | ||||
| 30 | + "TypeLocal": "Terrasse", "Capacite": "40", | ||||
| 31 | + }, | ||||
| 32 | + { # établissement 2 : croisement par postal + civique + nom similaire | ||||
| 33 | + "NoEtablissement": "31000", "RaisonSociale": "Resto-Bar Pizza Bella", | ||||
| 34 | + "Titulaire": "Gestion Pizza Bella Inc.", "Neq": "", | ||||
| 35 | + "Adresse": "300 Rue Principale", "CodeVille": "23027", | ||||
| 36 | + "Ville": "Québec", "CodePostal": "G1K3Y2", "RegAdmin": "03", | ||||
| 37 | + "NoPermis": "200000001-1", "Categorie": "Restaurant pour servir", | ||||
| 38 | + "TypeLocal": "Autre", "Capacite": "120", | ||||
| 39 | + }, | ||||
| 40 | + { # piège : homonyme dans une AUTRE ville — ne doit RIEN croiser | ||||
| 41 | + "NoEtablissement": "40000", "RaisonSociale": "Chez Mamy", | ||||
| 42 | + "Titulaire": "8888-0000 Québec Inc.", "Neq": "", | ||||
| 43 | + "Adresse": "714 Rang Ouest", "CodeVille": "11111", | ||||
| 44 | + "Ville": "Saint-Jean-de-Dieu", "CodePostal": "G0L3M0", | ||||
| 45 | + "RegAdmin": "01", "NoPermis": "300000001-1", "Categorie": "Bar", | ||||
| 46 | + "TypeLocal": "Autre", "Capacite": "60", | ||||
| 47 | + }, | ||||
| 48 | +] | ||||
| 49 | + | ||||
| 50 | + | ||||
| 51 | +def _seed(con): | ||||
| 52 | + restos = [ | ||||
| 53 | + Restaurant(source="osm", external_id="node/1", name="Chez Mamy", | ||||
| 54 | + address="1999 Rue Sainte-Famille", city="Saguenay", | ||||
| 55 | + postal_code="G7X 4X5", lat=48.42, lng=-71.06), | ||||
| 56 | + Restaurant(source="osm", external_id="node/2", name="Bella Pizza Resto", | ||||
| 57 | + address="300 Rue Principale", city="Québec", | ||||
| 58 | + postal_code="G1K 3Y2", lat=46.81, lng=-71.21), | ||||
| 59 | + # piège : même nom mais autre ville (le permis 40000 ne doit pas venir ici) | ||||
| 60 | + Restaurant(source="osm", external_id="node/3", name="Chez Mamy", | ||||
| 61 | + address="12 Rue Untel", city="Gatineau", | ||||
| 62 | + postal_code="J8X 1A1", lat=45.47, lng=-75.70), | ||||
| 63 | + ] | ||||
| 64 | + rdb.sync_source(con, "osm", [r.finalize() for r in restos]) | ||||
| 65 | + | ||||
| 66 | + | ||||
| 67 | +def test_group_establishments(): | ||||
| 68 | + ests = permits.group_establishments(RECORDS) | ||||
| 69 | + assert len(ests) == 3 | ||||
| 70 | + e = next(x for x in ests if x["no"] == "22723") | ||||
| 71 | + assert e["categories"] == {"Restaurant pour vendre", "Bar"} | ||||
| 72 | + assert e["capacite"] == 120 # 80 + 40 (salle + terrasse) | ||||
| 73 | + assert len(e["permis"]) == 2 | ||||
| 74 | + assert e["postal"] == "G7X4X5" | ||||
| 75 | + | ||||
| 76 | + | ||||
| 77 | +def test_match_conservateur(con): | ||||
| 78 | + _seed(con) | ||||
| 79 | + stats = permits.match(con, permits.group_establishments(RECORDS)) | ||||
| 80 | + assert stats["matched"] == 2 and stats["with_permit"] == 2 | ||||
| 81 | + | ||||
| 82 | + # nom+lieu -> le Chez Mamy de Saguenay, jamais celui de Gatineau | ||||
| 83 | + d1 = json.loads(con.execute( | ||||
| 84 | + "SELECT details FROM restaurants WHERE uid='osm:node/1'") | ||||
| 85 | + .fetchone()["details"]) | ||||
| 86 | + pa = d1["permis_alcool"] | ||||
| 87 | + assert pa["categories"] == ["Bar", "Restaurant pour vendre"] | ||||
| 88 | + assert pa["capacite"] == 120 and pa["nb_permis"] == 2 | ||||
| 89 | + assert pa["matched_by"] == "nom+lieu" | ||||
| 90 | + | ||||
| 91 | + # adresse+nom (CP + civique + token « pizza/bella » partagé) | ||||
| 92 | + d2 = json.loads(con.execute( | ||||
| 93 | + "SELECT details FROM restaurants WHERE uid='osm:node/2'") | ||||
| 94 | + .fetchone()["details"]) | ||||
| 95 | + assert d2["permis_alcool"]["matched_by"] == "adresse+nom" | ||||
| 96 | + assert d2["permis_alcool"]["categories"] == ["Restaurant pour servir"] | ||||
| 97 | + | ||||
| 98 | + # l'homonyme d'une autre ville n'est PAS épinglé sur le resto de Gatineau | ||||
| 99 | + d3 = json.loads(con.execute( | ||||
| 100 | + "SELECT details FROM restaurants WHERE uid='osm:node/3'") | ||||
| 101 | + .fetchone()["details"] or "{}") | ||||
| 102 | + assert "permis_alcool" not in d3 | ||||
added
tests/test_ubereats.py
+127 −0
@@ -0,0 +1,127 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: tests/test_ubereats.py | ||||
| 4 | +# Desc: Connecteur Uber Eats (menus livraison + notes) — décodage HORS LIGNE | ||||
| 5 | +# de l'état __REACT_QUERY_STATE__ (« " » en ", « \ » en %5C), | ||||
| 6 | +# construction du menu standard (prix en cents -> dollars, contexte | ||||
| 7 | +# delivery), vérification CONSERVATRICE d'un magasin (téléphone, | ||||
| 8 | +# postal+civique, GPS+nom) et slugs/candidats de découverte. | ||||
| 9 | +# ============================================================================== | ||||
| 10 | +import json | ||||
| 11 | + | ||||
| 12 | +from restoka.connectors.ubereats import (UberEatsConnector, _review_count, | ||||
| 13 | + build_menu, decode_state, slugify, | ||||
| 14 | + store_payload, verify_store) | ||||
| 15 | + | ||||
| 16 | +STORE = { | ||||
| 17 | + "title": "Chez Testo (Rosemont)", | ||||
| 18 | + "uuid": "a52d9225-dfe5-4f43-ac49-19833a61628f", | ||||
| 19 | + "slug": "chez-testo-rosemont", | ||||
| 20 | + "citySlug": "montreal", | ||||
| 21 | + "currencyCode": "CAD", | ||||
| 22 | + "rating": {"ratingValue": 4.3, "reviewCount": "15000+"}, | ||||
| 23 | + "priceBucket": "$$", | ||||
| 24 | + "phoneNumber": "+15142774385", | ||||
| 25 | + "location": { | ||||
| 26 | + "address": "1275, Boul. Rosemont, Montréal, QC H2S 3L9", | ||||
| 27 | + "streetAddress": "1275, Boul. Rosemont", | ||||
| 28 | + "city": "Montréal", "country": "CA", "postalCode": "H2S 3L9", | ||||
| 29 | + "region": "QC", "latitude": 45.5366491, "longitude": -73.5936433, | ||||
| 30 | + }, | ||||
| 31 | + "catalogSectionsMap": { | ||||
| 32 | + "cat-1": [ | ||||
| 33 | + {"payload": {"standardItemsPayload": { | ||||
| 34 | + "title": {"text": "Poutines"}, | ||||
| 35 | + "catalogItems": [ | ||||
| 36 | + {"title": "Poutine classique", "price": 1139, | ||||
| 37 | + "itemDescription": "Frites, sauce, fromage"}, | ||||
| 38 | + {"title": "Poutine galvaude", "price": 1499, | ||||
| 39 | + "itemDescription": ""}, | ||||
| 40 | + {"title": "Item sans prix", "price": 0, | ||||
| 41 | + "itemDescription": ""}, | ||||
| 42 | + ]}}}, | ||||
| 43 | + {"payload": {"standardItemsPayload": { | ||||
| 44 | + "title": {"text": "Breuvages"}, | ||||
| 45 | + "catalogItems": [ | ||||
| 46 | + {"title": "Liqueur", "price": 349, | ||||
| 47 | + "itemDescription": ""}, | ||||
| 48 | + ]}}}, | ||||
| 49 | + {"payload": {"standardItemsPayload": { | ||||
| 50 | + "title": {"text": "Section vide"}, "catalogItems": []}}}, | ||||
| 51 | + ], | ||||
| 52 | + }, | ||||
| 53 | +} | ||||
| 54 | + | ||||
| 55 | + | ||||
| 56 | +def _encode_state(state: dict) -> str: | ||||
| 57 | + """Encode comme sur ubereats.com : « " » -> \\u0022, « \\ » -> %5C.""" | ||||
| 58 | + blob = json.dumps(state, ensure_ascii=False) | ||||
| 59 | + blob = blob.replace("\\", "%5C").replace('"', "\\u0022") | ||||
| 60 | + return ('<html><script type="application/json" ' | ||||
| 61 | + f'id="__REACT_QUERY_STATE__">{blob}</script></html>') | ||||
| 62 | + | ||||
| 63 | + | ||||
| 64 | +def _state() -> dict: | ||||
| 65 | + return {"mutations": [], "queries": [ | ||||
| 66 | + {"state": {"data": {"isLoggedIn": False}}, | ||||
| 67 | + "queryKey": ["getUserV1", {}, None], | ||||
| 68 | + "queryHash": '[\\"getUserV1\\",{},null]'}, | ||||
| 69 | + {"state": {"data": STORE}, | ||||
| 70 | + "queryKey": ["getStoreV1", {"storeUuid": STORE["uuid"]}]}, | ||||
| 71 | + ]} | ||||
| 72 | + | ||||
| 73 | + | ||||
| 74 | +def test_decode_state_et_store_payload(): | ||||
| 75 | + page = _encode_state(_state()) | ||||
| 76 | + state = decode_state(page) | ||||
| 77 | + assert state and len(state["queries"]) == 2 | ||||
| 78 | + store = store_payload(state) | ||||
| 79 | + assert store and store["title"] == "Chez Testo (Rosemont)" | ||||
| 80 | + assert store["location"]["postalCode"] == "H2S 3L9" | ||||
| 81 | + assert _review_count(store) == 15000 | ||||
| 82 | + | ||||
| 83 | + | ||||
| 84 | +def test_build_menu_delivery(): | ||||
| 85 | + menu = build_menu(STORE, "2026-08-18T00:00:00Z") | ||||
| 86 | + assert menu["price_context"] == "delivery" | ||||
| 87 | + assert menu["price_source"] == "ubereats" | ||||
| 88 | + assert menu["currency"] == "CAD" | ||||
| 89 | + assert [s["name"] for s in menu["sections"]] == ["Poutines", "Breuvages"] | ||||
| 90 | + items = menu["sections"][0]["items"] | ||||
| 91 | + assert items[0]["price"] == 11.39 # cents -> dollars | ||||
| 92 | + assert items[2]["price"] is None # 0 = pas de prix | ||||
| 93 | + assert menu["sections"][1]["items"][0]["price"] == 3.49 | ||||
| 94 | + | ||||
| 95 | + | ||||
| 96 | +def _row(**kw): | ||||
| 97 | + base = {"uid": "osm:node/9", "name": "Chez Testo", | ||||
| 98 | + "address": "1275 Boulevard Rosemont", "city": "Montréal", | ||||
| 99 | + "postal_code": "H2S 3L9", "phone": "+15142774385", | ||||
| 100 | + "lat": 45.5366, "lng": -73.5936} | ||||
| 101 | + base.update(kw) | ||||
| 102 | + return base | ||||
| 103 | + | ||||
| 104 | + | ||||
| 105 | +def test_verify_store_conservateur(): | ||||
| 106 | + assert verify_store(_row(), STORE) == "telephone" | ||||
| 107 | + # sans téléphone : postal + civique | ||||
| 108 | + assert verify_store(_row(phone=""), STORE) == "postal+civique" | ||||
| 109 | + # sans téléphone ni postal : GPS <120 m + nom similaire | ||||
| 110 | + assert verify_store(_row(phone="", postal_code=""), STORE) == "gps+nom" | ||||
| 111 | + # un AUTRE resto au bout de la rue : rien ne matche -> jamais fusionné | ||||
| 112 | + autre = _row(phone="+15140000000", postal_code="H2S 9Z9", | ||||
| 113 | + address="2001 Boulevard Rosemont", name="Autre Bistro", | ||||
| 114 | + lat=45.55, lng=-73.60) | ||||
| 115 | + assert verify_store(autre, STORE) == "" | ||||
| 116 | + | ||||
| 117 | + | ||||
| 118 | +def test_slugify_et_candidats(): | ||||
| 119 | + assert slugify("McDonald's") == "mcdonalds" | ||||
| 120 | + assert slugify("Café Dépôt") == "cafe-depot" | ||||
| 121 | + assert slugify("Chez Testo") == "chez-testo" | ||||
| 122 | + slugs = sorted(["chez-testo", "chez-testo-rosemont", "chez-testons", | ||||
| 123 | + "mcdonalds-rosemont", "pizza-x"]) | ||||
| 124 | + cand = UberEatsConnector.candidates_for("Chez Testo", slugs) | ||||
| 125 | + assert cand == ["chez-testo", "chez-testo-rosemont"] | ||||
| 126 | + # nom trop court/générique : aucun candidat | ||||
| 127 | + assert UberEatsConnector.candidates_for("Ora", slugs) == [] | ||||
added
tests/test_yelp_scrape.py
+100 −0
@@ -0,0 +1,100 @@ | |||||
| 1 | +# ============================================================================== | ||||
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 3 | +# File: tests/test_yelp_scrape.py | ||||
| 4 | +# Desc: Connecteur yelp-scrape (avis/notes sans clé API) — parsing HORS | ||||
| 5 | +# LIGNE du cache Apollo embarqué dans une page de recherche yelp.ca | ||||
| 6 | +# (scripts data-apollo-state HTML-échappés) et croisement CONSERVATEUR | ||||
| 7 | +# nom + civique + ville (ambigu ou fermé = ignoré). | ||||
| 8 | +# ============================================================================== | ||||
| 9 | +import html | ||||
| 10 | +import json | ||||
| 11 | + | ||||
| 12 | +from restoka.connectors.yelp_scrape import (_city_key, match_business, | ||||
| 13 | + parse_apollo_businesses) | ||||
| 14 | + | ||||
| 15 | +# Cache Apollo minimal, encodé comme sur yelp.ca (" dans un commentaire) | ||||
| 16 | +_CACHE = { | ||||
| 17 | + "BusinessCategory:cat1": {"__typename": "BusinessCategory", | ||||
| 18 | + "encid": "cat1", "title": "Poutineries"}, | ||||
| 19 | + "BusinessLocation:loc1": { | ||||
| 20 | + "__typename": "BusinessLocation", "encid": "loc1", | ||||
| 21 | + "address": {"__typename": "BusinessAddress", | ||||
| 22 | + "city": "Quebec City", | ||||
| 23 | + "addressLine1": "640 Grande Allée E"}, | ||||
| 24 | + "neighborhoods": [], | ||||
| 25 | + }, | ||||
| 26 | + "Business:biz1": { | ||||
| 27 | + "__typename": "Business", "encid": "biz1", | ||||
| 28 | + "alias": "chez-ashton-québec-2", "name": "Chez Ashton", | ||||
| 29 | + "rating": 3.5, "reviewCount": 42, | ||||
| 30 | + "categories": [{"__ref": "BusinessCategory:cat1"}], | ||||
| 31 | + "priceRange": {"__typename": "PriceRange", "display": "$"}, | ||||
| 32 | + "location": {"__ref": "BusinessLocation:loc1"}, | ||||
| 33 | + 'activeAlert({"deviceType":"WWW"})': None, | ||||
| 34 | + }, | ||||
| 35 | + "BusinessLocation:loc2": { | ||||
| 36 | + "__typename": "BusinessLocation", "encid": "loc2", | ||||
| 37 | + "address": {"__typename": "BusinessAddress", | ||||
| 38 | + "city": "Levis", "addressLine1": "5430 Rue Wilfrid-Hallé"}, | ||||
| 39 | + "neighborhoods": [], | ||||
| 40 | + }, | ||||
| 41 | + "Business:biz2": { # fermé définitivement -> jamais croisé | ||||
| 42 | + "__typename": "Business", "encid": "biz2", | ||||
| 43 | + "alias": "chez-ashton-levis", "name": "Chez Ashton", | ||||
| 44 | + "rating": 4.0, "reviewCount": 3, | ||||
| 45 | + "categories": [], "priceRange": None, | ||||
| 46 | + "location": {"__ref": "BusinessLocation:loc2"}, | ||||
| 47 | + 'activeAlert({"deviceType":"WWW"})': {"__typename": "BusinessAlert", | ||||
| 48 | + "type": "permclosed"}, | ||||
| 49 | + }, | ||||
| 50 | +} | ||||
| 51 | + | ||||
| 52 | + | ||||
| 53 | +def _page(cache: dict) -> str: | ||||
| 54 | + blob = html.escape(json.dumps(cache, ensure_ascii=False), quote=True) | ||||
| 55 | + return ('<html><body><script data-apollo-state="x" ' | ||||
| 56 | + f'type="application/json"><!--{blob}--></script></body></html>') | ||||
| 57 | + | ||||
| 58 | + | ||||
| 59 | +def test_parse_apollo_businesses(): | ||||
| 60 | + biz = parse_apollo_businesses(_page(_CACHE)) | ||||
| 61 | + assert len(biz) == 2 | ||||
| 62 | + b1 = next(b for b in biz if b["alias"] == "chez-ashton-québec-2") | ||||
| 63 | + assert b1["rating"] == 3.5 and b1["review_count"] == 42 | ||||
| 64 | + assert b1["price"] == "$" and b1["categories"] == ["Poutineries"] | ||||
| 65 | + assert b1["city"] == "Quebec City" | ||||
| 66 | + assert b1["address"] == "640 Grande Allée E" | ||||
| 67 | + assert b1["closed"] is False | ||||
| 68 | + b2 = next(b for b in biz if b["alias"] == "chez-ashton-levis") | ||||
| 69 | + assert b2["closed"] is True | ||||
| 70 | + | ||||
| 71 | + | ||||
| 72 | +def test_city_key(): | ||||
| 73 | + assert _city_key("Quebec City") == "quebec" | ||||
| 74 | + assert _city_key("Québec") == "quebec" | ||||
| 75 | + assert _city_key("Montréal") == "montreal" | ||||
| 76 | + assert _city_key("Saint-Nicolas") == "saint nicolas" | ||||
| 77 | + | ||||
| 78 | + | ||||
| 79 | +def test_match_business_conservateur(): | ||||
| 80 | + candidates = parse_apollo_businesses(_page(_CACHE)) | ||||
| 81 | + resto = {"name": "Chez Ashton", "city": "Québec", | ||||
| 82 | + "address": "640 Grande Allée Est"} | ||||
| 83 | + hit = match_business(resto, candidates) | ||||
| 84 | + assert hit and hit["alias"] == "chez-ashton-québec-2" | ||||
| 85 | + | ||||
| 86 | + # mauvais civique -> aucun croisement (voisin de la même rue) | ||||
| 87 | + assert match_business({"name": "Chez Ashton", "city": "Québec", | ||||
| 88 | + "address": "700 Grande Allée Est"}, | ||||
| 89 | + candidates) is None | ||||
| 90 | + # le permclosed de Lévis n'est jamais retenu, même ville exacte | ||||
| 91 | + assert match_business({"name": "Chez Ashton", "city": "Lévis", | ||||
| 92 | + "address": "5430 Rue Wilfrid-Hallé"}, | ||||
| 93 | + candidates) is None | ||||
| 94 | + # ambiguïté (deux commerces distincts qui passent) -> None | ||||
| 95 | + cache2 = dict(_CACHE) | ||||
| 96 | + cache2["Business:biz3"] = dict(_CACHE["Business:biz1"], | ||||
| 97 | + encid="biz3", alias="ashton-restaurant", | ||||
| 98 | + name="Restaurant Ashton") | ||||
| 99 | + cands2 = parse_apollo_businesses(_page(cache2)) | ||||
| 100 | + assert match_business(resto, cands2) is None | ||||
| 101 | |||||