SPB Git forge

spb/resto-ka

Public

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

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

Major upgrade connecteurs : 7 nouvelles sources (mtl-alim, tastet, chownow, gloriafood, square, doordash, skip) + site-resto hub de découverte multi-plateformes

- mtl-alim : découverte massive Montréal (CSV open data, ~7 500 fiches food-service)
- tastet : découverte éditoriale (1 516 critiques, coordonnées + lien source)
- chownow/gloriafood/square : Palier 1 takeout (prix réels), dormants jusqu'à
  découverte de leurs intégrations par site-resto (*-discovered.json avec uid porteur)
- doordash/skip : Palier 2 delivery, enrichissement conservateur (jamais de fiche,
  croisement téléphone/postal+civique/GPS+nom, menus étiquetés delivery)
- siteresto : détection des widgets GloriaFood/ChowNow/Square/Clover sur les sites
- dedup : priorités mtl-alim/tastet ; registre sources.json complété (17 sources)
- 7 fichiers de tests (110 tests verts), smoke-runs live validés

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 19425cb

18 changed files +2,320 −10

modified .gitignore +2 −0
@@ -15,3 +15,5 @@ frontend/dist/
15 15 frontend/tsconfig.tsbuildinfo
16 16 .DS_Store
17 17 data/ubereats-stores.json
18 +data/doordash-stores.json
19 +data/skip-stores.json
modified data/sources.json +91 −0
@@ -334,6 +334,97 @@
334 334 "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.",
335 335 "cadence": "hebdomadaire (guard 6 jours dans ingest.enrich)",
336 336 "status": "actif"
337 + },
338 + {
339 + "id": "mtl-alim",
340 + "name": "Montréal — Établissements alimentaires (données ouvertes)",
341 + "url": "https://donnees.montreal.ca/dataset/etablissements-alimentaires",
342 + "platform": "donnees-montreal",
343 + "connector": "mtl-alim",
344 + "tier": 4,
345 + "price_context": null,
346 + "extraction": "CSV ouvert businesses.csv (donnees.montreal.ca, ~54 000 lignes) : whitelist de 12 types d'établissements où l'on mange (Restaurant, service rapide, casse-croûte, bar, boulangerie-pâtisserie, traiteur, camion-cuisine…), statut Ouvert seulement, GPS bornés à l'île de Montréal. Nom, adresse, ville, GPS, type -> fiches de découverte SANS menu (les connecteurs de menus s'y greffent par déduplication). ~7 500 fiches.",
347 + "acces_legal": "Données ouvertes de la Ville de Montréal, licence CC-BY 4.0 — attribution affichée (page Sources). Un seul téléchargement CSV par cycle, UA identifiable.",
348 + "cadence": "mensuel",
349 + "status": "actif"
350 + },
351 + {
352 + "id": "tastet",
353 + "name": "Tastet (média food — découverte éditoriale)",
354 + "url": "https://tastet.ca",
355 + "platform": "tastet",
356 + "connector": "tastet",
357 + "tier": 4,
358 + "price_context": null,
359 + "extraction": "Sitemaps Yoast reviews-sitemap*.xml -> pages critiques ; chaque page embarque un lien Google Maps (nom + adresse complète) et un lien tel:. Fiches de découverte SANS menu (nom, adresse, ville, postal, téléphone, photo og:image, lien critique) ; la déduplication fusionne avec OSM/UEAT et la fiche canonique gagne le lien éditorial. Cache BD par page (clé lastmod), budget TASTET_BUDGET (80 pages/cycle), corpus incrémental.",
360 + "acces_legal": "Média public québécois, découverte minimale (coordonnées + lien sortant vers la critique = attribution). Politesse 1,2 s, UA identifiable, pas d'anti-bot à contourner (CLAUDE.md §15).",
361 + "cadence": "hebdomadaire par lots (80 pages), re-crawl si lastmod change",
362 + "status": "actif"
363 + },
364 + {
365 + "id": "chownow",
366 + "name": "ChowNow (commande en ligne)",
367 + "url": "https://www.chownow.com",
368 + "platform": "chownow",
369 + "connector": "chownow",
370 + "tier": 1,
371 + "price_context": "takeout",
372 + "extraction": "API JSON publique non authentifiée : api.chownow.com/api/restaurant/<id> (fiche complète : nom, adresse, téléphone, GPS, site) + /<id>/menu (menu_categories + graphe complet des modificateurs -> options §5.2). 2 requêtes/resto, politesse 0,8 s. Émet des fiches complètes avec menu takeout ; filtre state == QC. Intégrations découvertes automatiquement par site-resto (liens direct.chownow.com sur les sites des restos -> data/chownow-discovered.json).",
373 + "acces_legal": "Source de première partie (plateforme de commande du resto lui-même, prix réels non majorés — CLAUDE.md §9 Palier 1, §15). API ouverte, extraction minimale, lien de commande conservé.",
374 + "cadence": "hebdomadaire",
375 + "status": "dormant (aucune intégration découverte encore — SkipSource)"
376 + },
377 + {
378 + "id": "gloriafood",
379 + "name": "GloriaFood / Oracle (widget de commande)",
380 + "url": "https://www.gloriafood.com",
381 + "platform": "gloriafood",
382 + "connector": "gloriafood",
383 + "tier": 1,
384 + "price_context": "takeout",
385 + "extraction": "API publique du widget : GET pos.globalfoodsoft.com/pos/menu (Authorization: <ruid>, Glf-Api-Version: 2) -> categories[].items[] {name, description, price, sizes}. ENRICHISSEMENT PUR : le menu est posé sur le resto porteur (uid consigné par site-resto avec les data-glf-cuid/ruid détectés dans le HTML -> data/gloriafood-discovered.json). Re-visite 14 j, refus 403 défensifs.",
386 + "acces_legal": "API du widget de commande du resto lui-même (première partie, prix réels — CLAUDE.md §9 Palier 1, §15). Extraction minimale, lien conservé.",
387 + "cadence": "hebdomadaire, re-visite 14 jours",
388 + "status": "dormant (aucune intégration découverte encore — SkipSource)"
389 + },
390 + {
391 + "id": "square",
392 + "name": "Square Online (boutiques de commande)",
393 + "url": "https://squareup.com/ca/fr/online-store",
394 + "platform": "square-online",
395 + "connector": "square",
396 + "tier": 1,
397 + "price_context": "takeout",
398 + "extraction": "Boutiques <sous-domaine>.square.site détectées par site-resto (lien de commande sur le site du resto -> data/square-discovered.json avec l'uid porteur). SPA sans JSON-LD ni prix inline : page /s/order rendue en JS via Scrapfly puis texte structuré par Claude Haiku (menullm, validation stricte des prix). ENRICHISSEMENT PUR : menu takeout posé sur le resto porteur + details.square. Budget SQUARE_BUDGET (15 rendus/cycle), re-visite 30 j.",
399 + "acces_legal": "Boutique de commande du resto lui-même (première partie, prix réels — CLAUDE.md §9 Palier 1, §15). Extraction minimale, lien source conservé.",
400 + "cadence": "hebdomadaire par petits lots (15 rendus), re-visite 30 jours",
401 + "status": "dormant (aucune boutique découverte encore — SkipSource)"
402 + },
403 + {
404 + "id": "doordash",
405 + "name": "DoorDash (menus livraison + notes)",
406 + "url": "https://www.doordash.com",
407 + "platform": "doordash",
408 + "connector": "doordash",
409 + "tier": 2,
410 + "price_context": "delivery",
411 + "extraction": "Découverte par le sitemap public QC (sitemap-doordash-qc-stores.xml, ~11 700 URLs /en-CA/store/, index local data/doordash-stores.json rafraîchi 30 j). Chaque page resto embarque du JSON-LD schema.org (Restaurant + Menu -> sections/items/prix, note, adresse, GPS). Croisement conservateur avec un resto EXISTANT (GPS<120 m + nom similaire OU civique+ville+nom) — n'émet aucune fiche. Menu -> table menus en price_context delivery (prix majorés ~25-30 %), note -> details.doordash. Budget DOORDASH_BUDGET (40 pages/cycle), verdicts en cache, re-visite 30 j.",
412 + "acces_legal": "Palier 2 (CLAUDE.md §9, §15) : CGU restrictives, anti-bot — Scrapfly ASP. 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.",
413 + "cadence": "quotidien par petits lots (40 pages), re-vérification 30 jours",
414 + "status": "actif"
415 + },
416 + {
417 + "id": "skip",
418 + "name": "SkipTheDishes (menus livraison)",
419 + "url": "https://www.skipthedishes.com",
420 + "platform": "skip",
421 + "connector": "skip",
422 + "tier": 2,
423 + "price_context": "delivery",
424 + "extraction": "Découverte par sitemaps publics (sitemap2..7.xml, URLs partenaires), index local data/skip-partners.json rafraîchi 30 j. Chaque page partenaire embarque __NEXT_DATA__ (menu complet en cents, téléphone dans les disclaimers, ville/province/postal). Croisement conservateur avec un resto EXISTANT (téléphone identique OU postal+civique), rejet hors-QC — n'émet aucune fiche. Menu -> table menus en price_context delivery, note -> details.skip. Budget SKIP_BUDGET (40 pages/cycle), verdicts en cache, re-visite 30 j.",
425 + "acces_legal": "Palier 2 (CLAUDE.md §9, §15) : CGU restrictives, anti-bot — Scrapfly ASP. Usage d'appoint minimal : menus pour restos sans source dine-in/takeout, prix TOUJOURS étiquetés delivery, lien vers la fiche source conservé.",
426 + "cadence": "quotidien par petits lots (40 pages), re-vérification 30 jours",
427 + "status": "actif"
337 428 }
338 429 ]
339 430 }
\ No newline at end of file
added restoka/connectors/chownow.py +204 −0
@@ -0,0 +1,204 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/chownow.py
4 +# Desc: Connecteur templatisé ChowNow (Palier 1 — plateforme de commande en
5 +# ligne des restos eux-mêmes -> PRIX RÉELS, contexte `takeout`).
6 +# API JSON publique et non authentifiée :
7 +# GET api.chownow.com/api/restaurant/<id> (fiche : nom,
8 +# adresse, téléphone, GPS « lat,lng », cuisines, site web)
9 +# GET api.chownow.com/api/restaurant/<id>/menu (menu_categories[]
10 +# .items[] {name, description, price, size} + graphe complet
11 +# des modificateurs -> options §5.2)
12 +# Une intégration = un id de resto ChowNow, consigné dans
13 +# data/sources.json (source « chownow » -> integrations[]) et/ou
14 +# découvert automatiquement par le connecteur site-resto
15 +# (data/chownow-discovered.json). CONNECTEUR DORMANT tant qu'aucune
16 +# intégration n'est connue (SkipSource — pas un échec).
17 +# Seuls les restos au Québec (state == QC) sont émis.
18 +#
19 +# Mode d'extraction : API directe (2 requêtes/resto), politesse 0.6 s.
20 +# Base légale §15 : API ouverte de la plateforme du resto (source de
21 +# première partie), extraction minimale, lien source conservé.
22 +# ==============================================================================
23 +from __future__ import annotations
24 +
25 +import json
26 +import sys
27 +from pathlib import Path
28 +
29 +from ..schema import Restaurant
30 +from .base import BaseConnector, SkipSource
31 +
32 +API = "https://api.chownow.com/api/restaurant"
33 +SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"
34 +
35 +
36 +def parse_geo(geo: str) -> tuple[float | None, float | None]:
37 + """« 45.5017,-73.5673 » -> (lat, lng)."""
38 + try:
39 + lat_s, lng_s = (geo or "").split(",", 1)
40 + return float(lat_s), float(lng_s)
41 + except (ValueError, AttributeError):
42 + return None, None
43 +
44 +
45 +def build_options(item: dict, mod_cats: dict[str, dict],
46 + mods: dict[str, dict]) -> list[dict]:
47 + """Options §5.2 depuis le graphe de modificateurs ChowNow (item ->
48 + modifier_categories -> modifiers {name, price})."""
49 + options: list[dict] = []
50 + for cat_id in item.get("modifier_categories") or []:
51 + cat = mod_cats.get(str(cat_id))
52 + if not cat:
53 + continue
54 + choices = []
55 + for mod_id in cat.get("modifiers") or []:
56 + mod = mods.get(str(mod_id))
57 + if not mod or not (mod.get("name") or "").strip():
58 + continue
59 + price = mod.get("price")
60 + choices.append({
61 + "name": mod["name"].strip(),
62 + "price_delta": round(float(price), 2)
63 + if isinstance(price, (int, float)) else 0.0,
64 + })
65 + if choices:
66 + options.append({
67 + "group": (cat.get("name") or "").strip() or "Options",
68 + "required": bool(cat.get("min_qty")),
69 + "choices": choices,
70 + })
71 + return options
72 +
73 +
74 +def build_menu(menu_payload: dict, captured_at: str) -> dict | None:
75 + """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis menu_categories."""
76 + mod_cats = {str(c.get("id")): c
77 + for c in menu_payload.get("modifier_categories") or []}
78 + mods = {str(m.get("id")): m for m in menu_payload.get("modifiers") or []}
79 + sections_out: list[dict] = []
80 + seen: set[str] = set()
81 + for cat in menu_payload.get("menu_categories") or []:
82 + name = (cat.get("name") or "").strip()
83 + if not name or name in seen:
84 + continue
85 + items = []
86 + for it in cat.get("items") or []:
87 + title = (it.get("name") or "").strip()
88 + if not title:
89 + continue
90 + size = (it.get("size") or "").strip()
91 + if size and size.lower() not in ("regular", "régulier"):
92 + title = f"{title} ({size})"
93 + price = it.get("price")
94 + entry = {
95 + "name": title,
96 + "description": (it.get("description") or "").strip(),
97 + "price": round(float(price), 2)
98 + if isinstance(price, (int, float)) and price > 0 else None,
99 + }
100 + options = build_options(it, mod_cats, mods)
101 + if options:
102 + entry["options"] = options
103 + items.append(entry)
104 + if items:
105 + seen.add(name)
106 + sections_out.append({"name": name, "items": items})
107 + if not sections_out:
108 + return None
109 + return {
110 + "price_context": "takeout",
111 + "price_source": "chownow",
112 + "currency": "CAD",
113 + "captured_at": captured_at,
114 + "sections": sections_out,
115 + }
116 +
117 +
118 +class ChowNowConnector(BaseConnector):
119 + source_id = "chownow"
120 + request_delay = 0.8
121 + timeout = 30
122 + use_detail_cache = False # 2 requêtes API légères par resto
123 +
124 + def _integrations(self) -> list[dict]:
125 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
126 + out: list[dict] = []
127 + for src in registry.get("sources", []):
128 + if src.get("id") == self.source_id:
129 + out.extend(src.get("integrations") or [])
130 + # ids découverts automatiquement par site-resto (widget ChowNow
131 + # détecté sur le site d'un resto) — voir siteresto.py
132 + discovered = SOURCES_PATH.parent / "chownow-discovered.json"
133 + if discovered.exists():
134 + try:
135 + extra = json.loads(discovered.read_text(encoding="utf-8"))
136 + known = {str(i.get("key")) for i in out}
137 + out.extend(i for i in extra.get("integrations", [])
138 + if str(i.get("key")) not in known)
139 + except ValueError:
140 + pass
141 + return out
142 +
143 + def _now(self) -> str:
144 + import datetime
145 + return datetime.datetime.now(datetime.timezone.utc) \
146 + .strftime("%Y-%m-%dT%H:%M:%SZ")
147 +
148 + def _fetch_restaurant(self, cn_id: str) -> Restaurant | None:
149 + info = self.get(f"{API}/{cn_id}",
150 + headers={"Accept": "application/json"}).json()
151 + addr = info.get("address") or {}
152 + state = (addr.get("state") or "").strip().upper()
153 + if state and state != "QC":
154 + print(f"[resto-ka] chownow: {cn_id} hors Québec ({state}) — ignoré",
155 + file=sys.stderr)
156 + return None
157 + name = (info.get("name") or "").strip()
158 + if not name:
159 + return None
160 + lat, lng = parse_geo(addr.get("geo") or "")
161 + menu_payload = self.get(f"{API}/{cn_id}/menu",
162 + headers={"Accept": "application/json"}).json()
163 + menu = build_menu(menu_payload or {}, self._now())
164 + services = ["takeout"]
165 + if (info.get("fulfillment") or {}).get("delivery") \
166 + or info.get("has_low_cost_delivery"):
167 + services.append("delivery")
168 + return Restaurant(
169 + source=self.source_id,
170 + external_id=str(cn_id),
171 + name=name,
172 + url=f"https://direct.chownow.com/order/{cn_id}/locations",
173 + address=(addr.get("street_address1") or "").strip(),
174 + city=(addr.get("city") or "").strip(),
175 + postal_code=(addr.get("zip") or "").strip(),
176 + lat=lat, lng=lng,
177 + phone=(info.get("phone") or "").strip(),
178 + website=(info.get("website_url") or "").strip(),
179 + services=services,
180 + menu=menu,
181 + )
182 +
183 + def fetch(self) -> list[Restaurant]:
184 + integrations = [i for i in self._integrations()
185 + if i.get("key") and i.get("status") != "inactif"]
186 + if not integrations:
187 + raise SkipSource(
188 + "aucune intégration ChowNow connue (data/sources.json et "
189 + "data/chownow-discovered.json vides) — connecteur dormant, "
190 + "en attente des découvertes du connecteur site-resto")
191 + out: list[Restaurant] = []
192 + for integ in integrations:
193 + cn_id = str(integ["key"])
194 + try:
195 + resto = self._fetch_restaurant(cn_id)
196 + except Exception as exc: # un resto ne bloque pas les autres
197 + print(f"[resto-ka] chownow: intégration {cn_id} ignorée : "
198 + f"{exc}", file=sys.stderr)
199 + continue
200 + if resto is not None:
201 + out.append(resto)
202 + print(f"[resto-ka] chownow: {len(integrations)} intégration(s) -> "
203 + f"{len(out)} resto(s) QC avec menu takeout")
204 + return out
added restoka/connectors/doordash.py +411 −0
@@ -0,0 +1,411 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/doordash.py
4 +# Desc: Connecteur d'ENRICHISSEMENT DoorDash (menus livraison + notes) —
5 +# même patron conservateur qu'ubereats.py. Découverte par le sitemap
6 +# public QC (cdn.doordash.com/sitemaps/…/sitemap-doordash-qc-stores.xml,
7 +# ~11 700 magasins, index local data/doordash-stores.json, 30 j).
8 +# Chaque page magasin embarque du JSON-LD schema.org : un bloc
9 +# `Restaurant` (adresse, GPS, cuisines, priceRange, aggregateRating —
10 +# PAS de téléphone) et un bloc `Menu` (hasMenuSection imbriqué ->
11 +# hasMenuItem {name, description, offers.price "$X.XX"}).
12 +# Croisement CONSERVATEUR avec un resto EXISTANT seulement (GPS <120 m
13 +# + nom similaire, OU numéro civique + ville + nom similaire) : n'émet
14 +# AUCUNE fiche. Menu -> table menus en price_context `delivery` (prix
15 +# majorés ~25-30 %, CLAUDE.md §6.2), note -> details.doordash.
16 +#
17 +# Mode d'extraction : Scrapfly ASP (anti-bot ; les gros contenus
18 +# reviennent en « large_object » à re-télécharger), budget
19 +# DOORDASH_BUDGET (défaut 40) pages/cycle, verdict par magasin en
20 +# cache, re-visite 30 j. Conformité §15 : extraction minimale, usage
21 +# d'appoint, prix toujours étiquetés `delivery`, lien source conservé.
22 +# ==============================================================================
23 +from __future__ import annotations
24 +
25 +import datetime
26 +import json
27 +import os
28 +import re
29 +import sys
30 +import time
31 +import urllib.parse
32 +from pathlib import Path
33 +
34 +import requests
35 +
36 +from ..inspections import _CIVIC_RE, _name_similar, norm_name
37 +from ..regions import strip_accents
38 +from ..schema import Restaurant
39 +from .base import BaseConnector, SkipSource
40 +
41 +SITEMAP_QC = ("https://cdn.doordash.com/sitemaps/sitemaps/"
42 + "sitemap-doordash-qc-stores.xml")
43 +INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "doordash-stores.json"
44 +INDEX_REFRESH_DAYS = 30
45 +MAX_BUDGET = int(os.environ.get("DOORDASH_BUDGET", "40")) # pages/cycle
46 +REFRESH_DAYS = 30
47 +MAX_CANDIDATES = 3
48 +MAX_CONSECUTIVE_FAILURES = 3
49 +MAX_GPS_M = 120.0
50 +
51 +# /en-CA/store/<slug>-<id>/ — les /convenience/store/ (épiceries) sont exclus
52 +_STORE_URL_RE = re.compile(r"doordash\.com/en-CA/store/([^/]+?)-(\d+)/?$")
53 +
54 +_LDJSON_RE = re.compile(
55 + r'<script type="application/ld\+json"[^>]*>(.*?)</script>', re.S)
56 +_PRICE_RE = re.compile(r"(\d+(?:[.,]\d{1,2})?)")
57 +
58 +
59 +def parse_ldjson(page_html: str) -> tuple[dict | None, dict | None]:
60 + """Extrait les blocs JSON-LD `Restaurant` et `Menu` d'une page magasin."""
61 + resto = menu = None
62 + for raw in _LDJSON_RE.findall(page_html):
63 + try:
64 + data = json.loads(raw)
65 + except ValueError:
66 + continue
67 + for obj in (data if isinstance(data, list) else [data]):
68 + if not isinstance(obj, dict):
69 + continue
70 + if obj.get("@type") == "Restaurant" and resto is None:
71 + resto = obj
72 + elif obj.get("@type") == "Menu" and menu is None:
73 + menu = obj
74 + return resto, menu
75 +
76 +
77 +def _parse_price(offers) -> float | None:
78 + """« $13.20 » -> 13.20 ; None si absent/illisible."""
79 + raw = str((offers or {}).get("price") or "")
80 + m = _PRICE_RE.search(raw.replace(",", "."))
81 + if not m:
82 + return None
83 + price = float(m.group(1))
84 + return price if 0 < price <= 10000 else None
85 +
86 +
87 +def _flatten_sections(node, out: list[dict]) -> None:
88 + """hasMenuSection est parfois une liste de listes — aplatir récursivement."""
89 + if isinstance(node, list):
90 + for x in node:
91 + _flatten_sections(x, out)
92 + elif isinstance(node, dict):
93 + out.append(node)
94 +
95 +
96 +def build_menu(ld_menu: dict, captured_at: str) -> dict | None:
97 + """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis le JSON-LD `Menu`."""
98 + raw_sections: list[dict] = []
99 + _flatten_sections((ld_menu or {}).get("hasMenuSection"), raw_sections)
100 + sections_out: list[dict] = []
101 + seen: set[str] = set()
102 + for sec in raw_sections:
103 + name = (sec.get("name") or "").strip()
104 + if not name or name in seen:
105 + continue
106 + items = []
107 + for it in sec.get("hasMenuItem") or []:
108 + title = (it.get("name") or "").strip()
109 + if not title:
110 + continue
111 + items.append({
112 + "name": title,
113 + "description": (it.get("description") or "").strip(),
114 + "price": _parse_price(it.get("offers")),
115 + })
116 + if items:
117 + seen.add(name)
118 + sections_out.append({"name": name, "items": items})
119 + if not sections_out:
120 + return None
121 + return {
122 + "price_context": "delivery",
123 + "price_source": "doordash",
124 + "currency": "CAD",
125 + "captured_at": captured_at,
126 + "sections": sections_out,
127 + }
128 +
129 +
130 +def _haversine_m(lat1, lng1, lat2, lng2) -> float:
131 + import math
132 + r = 6371000.0
133 + p1, p2 = math.radians(lat1), math.radians(lat2)
134 + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
135 + a = (math.sin(dp / 2) ** 2
136 + + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2)
137 + return 2 * r * math.asin(math.sqrt(a))
138 +
139 +
140 +def _norm_city(city: str) -> str:
141 + return re.sub(r"[^a-z0-9]+", "", strip_accents((city or "").lower()))
142 +
143 +
144 +def verify_store(row, ld: dict) -> str:
145 + """Vérifie qu'une page magasin DoorDash correspond bien au resto de la
146 + base — CONSERVATEUR (le JSON-LD n'a PAS de téléphone) : GPS <120 m + nom
147 + similaire, OU numéro civique + ville + nom similaire. Retourne le mode de
148 + croisement ('' = pas le même établissement)."""
149 + addr = ld.get("address") or {}
150 + geo = ld.get("geo") or {}
151 + street = (addr.get("streetAddress") or "").strip()
152 + addr_tokens = set(re.sub(r"[^a-z0-9]+", " ",
153 + strip_accents(street.lower())).split())
154 + similar = _name_similar(norm_name(row["name"]),
155 + norm_name(ld.get("name") or ""), addr_tokens)
156 + try:
157 + lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))
158 + except (TypeError, ValueError):
159 + lat = lng = None
160 + if (lat is not None and row["lat"] is not None and row["lng"] is not None
161 + and _haversine_m(row["lat"], row["lng"], lat, lng) <= MAX_GPS_M
162 + and similar):
163 + return "gps+nom"
164 + civic_m = _CIVIC_RE.match(street)
165 + rcivic_m = _CIVIC_RE.match(row["address"] or "")
166 + if (civic_m and rcivic_m and civic_m.group(1) == rcivic_m.group(1)
167 + and _norm_city(addr.get("addressLocality") or "")
168 + == _norm_city(row["city"] or "") and similar):
169 + return "civique+ville+nom"
170 + return ""
171 +
172 +
173 +def slugify(name: str) -> str:
174 + """Slug façon DoorDash : minuscules sans accents, tirets."""
175 + s = strip_accents((name or "").lower())
176 + s = re.sub(r"['’´`.]", "", s)
177 + s = re.sub(r"&", " ", s)
178 + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
179 + return s
180 +
181 +
182 +class DoorDashConnector(BaseConnector):
183 + source_id = "doordash"
184 + request_delay = 1.0
185 + timeout = 60
186 + use_detail_cache = False # verdicts gérés à la main (detail_cache)
187 + enrichment_only = True # n'émet aucune fiche (ingest.run)
188 +
189 + # -- Scrapfly / large_object -------------------------------------------------
190 + def _content(self, url: str, **kw) -> tuple[int, str]:
191 + """Page via Scrapfly ASP ; résout les réponses « large_object »
192 + (contenu volumineux renvoyé comme URL à re-télécharger avec la clé)."""
193 + result = self.scrapfly(url, render_js=False, **kw)
194 + status = result.get("status_code") or 0
195 + content = result.get("content") or ""
196 + if content.startswith("https://api.scrapfly.io/scrape/large_object/"):
197 + resp = requests.get(content,
198 + params={"key": os.environ["SCRAPFLY_KEY"]},
199 + timeout=120)
200 + content = resp.text if resp.ok else ""
201 + return status, content
202 +
203 + # -- index sitemap -----------------------------------------------------------
204 + def load_index(self) -> dict[str, str]:
205 + """Index slug -> URL magasin depuis le sitemap QC public, en cache
206 + local 30 jours (data/doordash-stores.json). Épiceries/dépanneurs
207 + (/convenience/store/) exclus."""
208 + if INDEX_PATH.exists():
209 + try:
210 + data = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
211 + if time.time() - float(data.get("fetched_at") or 0) \
212 + < INDEX_REFRESH_DAYS * 86400:
213 + return data.get("stores") or {}
214 + except (ValueError, OSError):
215 + pass
216 + # le CDN sitemap répond en direct ; Scrapfly en secours (large_object)
217 + try:
218 + xml = self.get(SITEMAP_QC).text
219 + except Exception:
220 + _, xml = self._content(SITEMAP_QC)
221 + stores: dict[str, str] = {}
222 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
223 + if "/convenience/" in loc:
224 + continue
225 + mm = _STORE_URL_RE.search(urllib.parse.unquote(loc))
226 + if mm:
227 + stores[f"{mm.group(1)}-{mm.group(2)}".lower()] = loc
228 + if not stores:
229 + raise RuntimeError("sitemap QC DoorDash vide (blocage ?)")
230 + INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
231 + INDEX_PATH.write_text(json.dumps(
232 + {"fetched_at": time.time(), "stores": stores},
233 + ensure_ascii=False), encoding="utf-8")
234 + print(f"[resto-ka] doordash: index sitemap rafraîchi — "
235 + f"{len(stores)} magasin(s) QC")
236 + return stores
237 +
238 + @staticmethod
239 + def candidates_for(name: str, slugs: list[str]) -> list[str]:
240 + """Slugs DoorDash candidats pour un nom de resto (préfixe strict —
241 + le slug DoorDash embarque la ville et l'id : nom-ville-123456)."""
242 + base = slugify(name)
243 + if len(base) < 5:
244 + return []
245 + import bisect
246 + i = bisect.bisect_left(slugs, base)
247 + out = []
248 + while i < len(slugs) and len(out) < MAX_CANDIDATES:
249 + s = slugs[i]
250 + if s == base or s.startswith(base + "-"):
251 + out.append(s)
252 + i += 1
253 + else:
254 + break
255 + return out
256 +
257 + # -- cycle -------------------------------------------------------------------
258 + def _now(self) -> str:
259 + return datetime.datetime.now(datetime.timezone.utc) \
260 + .strftime("%Y-%m-%dT%H:%M:%SZ")
261 +
262 + def _fresh(self, stamp: str, now: float, stale_s: float) -> bool:
263 + try:
264 + ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \
265 + .replace(tzinfo=datetime.timezone.utc).timestamp()
266 + return ts > now - stale_s
267 + except (ValueError, TypeError):
268 + return False
269 +
270 + def _details_payload(self, ld: dict, url: str, how: str) -> dict:
271 + rating = ld.get("aggregateRating") or {}
272 + return {
273 + "rating": rating.get("ratingValue"),
274 + "review_count": rating.get("reviewCount"),
275 + "price_range": ld.get("priceRange"),
276 + "cuisines": ld.get("servesCuisine"),
277 + "url": url.split("?")[0],
278 + "matched_by": how,
279 + "fetched_at": self._now(),
280 + }
281 +
282 + def _probe_store(self, con, row, url: str) -> tuple[bool, bool]:
283 + """Visite une page magasin et l'attache au resto si c'est le même
284 + établissement. Retourne (matched, menu_added)."""
285 + from .. import db
286 + mm = _STORE_URL_RE.search(urllib.parse.unquote(url))
287 + store_id = mm.group(2) if mm else url
288 + cached = db.get_cached_detail(con, self.source_id, store_id,
289 + "verdict-v1")
290 + if cached is not None and cached.get("matched_uid") != row["uid"]:
291 + return False, False # déjà identifié comme un autre resto
292 + status, content = self._content(url)
293 + if status in (400, 404, 410, 451): # magasin retiré de DoorDash
294 + db.put_cached_detail(con, self.source_id, store_id,
295 + "verdict-v1", {"matched_uid": None,
296 + "gone": status})
297 + return False, False
298 + if status != 200:
299 + raise RuntimeError(f"HTTP {status}")
300 + ld_resto, ld_menu = parse_ldjson(content)
301 + if not ld_resto:
302 + if ld_menu or "application/ld+json" in content:
303 + # variante de page sans bloc Restaurant (constat de terrain) :
304 + # identité invérifiable -> miss prudent, PAS un blocage
305 + db.put_cached_detail(con, self.source_id, store_id,
306 + "verdict-v1", {"matched_uid": None,
307 + "no_restaurant_ld": True})
308 + return False, False
309 + raise RuntimeError("JSON-LD absent (page non rendue ?)")
310 + how = verify_store(row, ld_resto)
311 + if not how:
312 + db.put_cached_detail(con, self.source_id, store_id,
313 + "verdict-v1", {"matched_uid": None})
314 + return False, False
315 + db.put_cached_detail(con, self.source_id, store_id, "verdict-v1",
316 + {"matched_uid": row["uid"]})
317 + menu = build_menu(ld_menu or {}, self._now())
318 + menu_added = False
319 + if menu:
320 + # validation stricte du schéma menu (prix implausibles, etc.)
321 + Restaurant(source=self.source_id, external_id=store_id,
322 + name=row["name"], menu=menu)._validate_menu()
323 + db.upsert_menu(con, row["uid"], menu, time.time())
324 + menu_added = True
325 + db.merge_details(con, row["uid"],
326 + {"doordash": self._details_payload(ld_resto, url, how)})
327 + con.commit()
328 + return True, menu_added
329 +
330 + def fetch(self) -> list[Restaurant]:
331 + if not os.environ.get("SCRAPFLY_KEY"):
332 + raise SkipSource("SCRAPFLY_KEY manquant (.env) — DoorDash est "
333 + "derrière un anti-bot, scraping direct impossible")
334 + from .. import db
335 + index = self.load_index()
336 + slugs = sorted(index.keys())
337 + con = db.connect()
338 + now = time.time()
339 + stale_s = REFRESH_DAYS * 86400.0
340 + budget = MAX_BUDGET
341 + matched = menus = misses = failures_row = 0
342 + rows = con.execute(
343 + "SELECT uid, name, address, city, postal_code, phone, lat, lng,"
344 + " details,"
345 + " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"
346 + " AS has_menu"
347 + " FROM restaurants WHERE active=1 AND dup_of IS NULL AND name<>''"
348 + " AND ((lat IS NOT NULL AND lng IS NOT NULL)"
349 + " OR (address<>'' AND city<>''))"
350 + " ORDER BY has_menu ASC, lat IS NULL ASC, updated_at DESC"
351 + ).fetchall()
352 + for row in rows:
353 + if budget <= 0:
354 + break
355 + if failures_row >= MAX_CONSECUTIVE_FAILURES:
356 + print("[resto-ka] doordash: Scrapfly bloqué "
357 + f"{failures_row} fois de suite — arrêt du cycle",
358 + file=sys.stderr)
359 + break
360 + try:
361 + details = json.loads(row["details"] or "{}")
362 + except ValueError:
363 + details = {}
364 + dd = details.get("doordash") or {}
365 + if self._fresh(dd.get("fetched_at", ""), now, stale_s):
366 + continue # déjà frais (<30 j)
367 + probe = details.get("doordash_probe") or {}
368 + if self._fresh(probe.get("fetched_at", ""), now, stale_s):
369 + continue # échec récent : re-visite dans 30 j
370 + if dd.get("url"): # déjà croisé : rafraîchir directement
371 + urls = [dd["url"]]
372 + else:
373 + cand = self.candidates_for(row["name"], slugs)
374 + urls = [index[s] for s in cand if s in index]
375 + if not urls:
376 + continue # aucun candidat : pas de marqueur,
377 + # l'index du mois prochain peut changer
378 + found = False
379 + for url in urls[:MAX_CANDIDATES]:
380 + if budget <= 0:
381 + break
382 + budget -= 1
383 + try:
384 + ok, menu_added = self._probe_store(con, row, url)
385 + except Exception as exc:
386 + failures_row += 1
387 + print(f"[resto-ka] doordash: {row['uid']} erreur: {exc}",
388 + file=sys.stderr)
389 + continue
390 + failures_row = 0
391 + if ok:
392 + matched += 1
393 + menus += 1 if menu_added else 0
394 + found = True
395 + break
396 + if not found and not dd.get("url"):
397 + db.merge_details(con, row["uid"],
398 + {"doordash_probe":
399 + {"miss": "aucun magasin correspondant",
400 + "fetched_at": self._now()}})
401 + con.commit()
402 + misses += 1
403 + con.commit()
404 + con.close()
405 + self.enriched_count = matched
406 + self.enrich_message = (f"{matched} resto(s) croisés DoorDash "
407 + f"({menus} menu(s) livraison), {misses} sans "
408 + f"correspondance, budget restant "
409 + f"{max(budget, 0)} page(s)")
410 + print(f"[resto-ka] doordash: {self.enrich_message}")
411 + return []
added restoka/connectors/gloriafood.py +192 −0
@@ -0,0 +1,192 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/gloriafood.py
4 +# Desc: Connecteur templatisé GloriaFood/Oracle (Palier 1 — widget de
5 +# commande embarqué sur le site du resto -> PRIX RÉELS, contexte
6 +# `takeout`). Le widget s'identifie par data-glf-cuid/data-glf-ruid
7 +# dans le HTML du site ; le connecteur site-resto consigne ces ids
8 +# dans data/gloriafood-discovered.json AVEC l'uid du resto porteur.
9 +# Menu via l'API publique du widget :
10 +# GET pos.globalfoodsoft.com/pos/menu
11 +# (Authorization: <ruid>, Glf-Api-Version: 2, Accept: JSON)
12 +# -> categories[].items[] {name, description, price, sizes[]}.
13 +# ENRICHISSEMENT PUR : n'émet aucune fiche — le menu est posé sur le
14 +# resto porteur (db.upsert_menu, price_context takeout) + traçabilité
15 +# details.gloriafood. CONNECTEUR DORMANT tant qu'aucune intégration
16 +# n'est découverte (SkipSource — pas un échec). L'endpoint refuse les
17 +# requêtes sans Authorization (403) : chaque intégration est tentée
18 +# défensivement, un refus n'interrompt pas le cycle.
19 +#
20 +# Base légale §15 : API du widget de commande du resto lui-même
21 +# (source de première partie), extraction minimale, lien conservé.
22 +# ==============================================================================
23 +from __future__ import annotations
24 +
25 +import datetime
26 +import json
27 +import sys
28 +import time
29 +from pathlib import Path
30 +
31 +from ..schema import Restaurant
32 +from .base import BaseConnector, SkipSource
33 +
34 +MENU_API = "https://pos.globalfoodsoft.com/pos/menu"
35 +SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"
36 +DISCOVERED = SOURCES_PATH.parent / "gloriafood-discovered.json"
37 +REFRESH_DAYS = 14 # menus takeout : rafraîchissement §14
38 +
39 +
40 +def build_menu(payload: dict, captured_at: str) -> dict | None:
41 + """Menu standard Resto·Ka (§5.2) depuis le payload GloriaFood — parseur
42 + TOLÉRANT (structure observée : categories[].items[], variantes `sizes`)."""
43 + if isinstance(payload, list): # payload directement en liste
44 + cats = payload
45 + else:
46 + cats = (payload or {}).get("categories")
47 + sections_out: list[dict] = []
48 + seen: set[str] = set()
49 + for cat in cats or []:
50 + if not isinstance(cat, dict):
51 + continue
52 + name = (cat.get("name") or "").strip()
53 + if not name or name in seen:
54 + continue
55 + items = []
56 + for it in cat.get("items") or []:
57 + if not isinstance(it, dict):
58 + continue
59 + title = (it.get("name") or "").strip()
60 + if not title:
61 + continue
62 + price = it.get("price")
63 + entry = {
64 + "name": title,
65 + "description": (it.get("description") or "").strip(),
66 + "price": round(float(price), 2)
67 + if isinstance(price, (int, float)) and price > 0 else None,
68 + }
69 + sizes = [s for s in it.get("sizes") or [] if isinstance(s, dict)
70 + and (s.get("name") or "").strip()]
71 + if sizes:
72 + base = entry["price"] or 0.0
73 + choices = []
74 + for s in sizes:
75 + sp = s.get("price")
76 + delta = (round(float(sp) - base, 2)
77 + if isinstance(sp, (int, float)) else 0.0)
78 + choices.append({"name": s["name"].strip(),
79 + "price_delta": delta})
80 + entry["options"] = [{"group": "Format", "required": True,
81 + "choices": choices}]
82 + items.append(entry)
83 + if items:
84 + seen.add(name)
85 + sections_out.append({"name": name, "items": items})
86 + if not sections_out:
87 + return None
88 + return {
89 + "price_context": "takeout",
90 + "price_source": "gloriafood",
91 + "currency": "CAD",
92 + "captured_at": captured_at,
93 + "sections": sections_out,
94 + }
95 +
96 +
97 +class GloriaFoodConnector(BaseConnector):
98 + source_id = "gloriafood"
99 + request_delay = 1.0
100 + timeout = 30
101 + use_detail_cache = False # fraîcheur gérée via details.gloriafood
102 + enrichment_only = True # n'émet aucune fiche (ingest.run)
103 +
104 + def _integrations(self) -> list[dict]:
105 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
106 + out: list[dict] = []
107 + for src in registry.get("sources", []):
108 + if src.get("id") == self.source_id:
109 + out.extend(src.get("integrations") or [])
110 + if DISCOVERED.exists():
111 + try:
112 + extra = json.loads(DISCOVERED.read_text(encoding="utf-8"))
113 + known = {i.get("key") for i in out}
114 + out.extend(i for i in extra.get("integrations", [])
115 + if i.get("key") not in known)
116 + except ValueError:
117 + pass
118 + return out
119 +
120 + def _now(self) -> str:
121 + return datetime.datetime.now(datetime.timezone.utc) \
122 + .strftime("%Y-%m-%dT%H:%M:%SZ")
123 +
124 + def _fetch_menu(self, ruid: str) -> dict | None:
125 + resp = self.get(MENU_API, headers={
126 + "Authorization": ruid,
127 + "Accept": "application/json",
128 + "Glf-Api-Version": "2",
129 + })
130 + return build_menu(resp.json(), self._now())
131 +
132 + def fetch(self) -> list[Restaurant]:
133 + integrations = [i for i in self._integrations()
134 + if i.get("key") and i.get("status") != "inactif"]
135 + if not integrations:
136 + raise SkipSource(
137 + "aucune intégration GloriaFood découverte "
138 + "(data/gloriafood-discovered.json vide) — connecteur dormant, "
139 + "en attente des découvertes du connecteur site-resto")
140 + from .. import db
141 + con = db.connect()
142 + now = time.time()
143 + menus = errors = 0
144 + for integ in integrations:
145 + ruid, uid = str(integ["key"]), integ.get("uid") or ""
146 + if not uid:
147 + print(f"[resto-ka] gloriafood: {ruid} sans uid porteur — "
148 + "ignoré", file=sys.stderr)
149 + continue
150 + row = con.execute(
151 + "SELECT details FROM restaurants WHERE uid=? AND active=1",
152 + (uid,)).fetchone()
153 + if row is None:
154 + continue # resto porteur disparu
155 + try:
156 + details = json.loads(row["details"] or "{}")
157 + except ValueError:
158 + details = {}
159 + gf = details.get("gloriafood") or {}
160 + try:
161 + ts = datetime.datetime.strptime(
162 + gf.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \
163 + .replace(tzinfo=datetime.timezone.utc).timestamp()
164 + if ts > now - REFRESH_DAYS * 86400:
165 + continue # déjà frais
166 + except (ValueError, TypeError):
167 + pass
168 + try:
169 + menu = self._fetch_menu(ruid)
170 + except Exception as exc: # 403/refus : défensif, on continue
171 + errors += 1
172 + print(f"[resto-ka] gloriafood: {uid} ({ruid}) erreur: {exc}",
173 + file=sys.stderr)
174 + continue
175 + if not menu:
176 + continue
177 + # validation stricte du schéma menu avant stockage
178 + Restaurant(source=self.source_id, external_id=ruid,
179 + name=uid, menu=menu)._validate_menu()
180 + db.upsert_menu(con, uid, menu, time.time())
181 + db.merge_details(con, uid, {"gloriafood": {
182 + "ruid": ruid, "cuid": integ.get("cuid"),
183 + "fetched_at": self._now()}})
184 + con.commit()
185 + menus += 1
186 + con.close()
187 + self.enriched_count = menus
188 + self.enrich_message = (f"{menus} menu(s) takeout GloriaFood posés, "
189 + f"{errors} refus/erreur(s) sur "
190 + f"{len(integrations)} intégration(s)")
191 + print(f"[resto-ka] gloriafood: {self.enrich_message}")
192 + return []
added restoka/connectors/mtlalim.py +129 −0
@@ -0,0 +1,129 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/mtlalim.py
4 +# Desc: Connecteur de DÉCOUVERTE massif « mtl-alim » (Palier 4) — registre
5 +# officiel des établissements alimentaires inspectés par la Ville de
6 +# Montréal (données ouvertes, ~17 000 établissements OUVERTS dont
7 +# ~7 500 de restauration). Complète OSM sur l'île de Montréal : chaque
8 +# resto détenteur d'un permis y figure, y compris ceux absents des
9 +# cartes communautaires.
10 +#
11 +# Mode d'extraction : téléchargement direct du CSV
12 +# (donnees.montreal.ca, licence CC-BY 4.0 — attribution « Ville de
13 +# Montréal » sur la page Sources). Une seule requête par run.
14 +# Base légale : données ouvertes, réutilisation permise avec
15 +# attribution. Filtres : statut == « Ouvert » + whitelist des types
16 +# food-service (restaurants, casse-croûtes, bars, boulangeries,
17 +# traiteurs, camions-cuisine…) — épiceries, garderies, cafétérias
18 +# institutionnelles et distributrices EXCLUES (hors périmètre §2).
19 +#
20 +# Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).
21 +# ==============================================================================
22 +from __future__ import annotations
23 +
24 +import csv
25 +import io
26 +import sys
27 +
28 +from ..schema import Restaurant
29 +from .base import BaseConnector
30 +
31 +CSV_URL = ("https://donnees.montreal.ca/dataset/"
32 + "c1d65779-d3cb-44e8-af0a-b9f2c5f7766d/resource/"
33 + "28a4957d-732e-48f9-8adb-0624867d9bb0/download/businesses.csv")
34 +
35 +# type d'établissement (registre Ville) -> establishment_type Resto·Ka.
36 +# Whitelist STRICTE : tout type absent d'ici est ignoré (épiceries,
37 +# distributrices, écoles, hôpitaux, entrepôts… — on ne mange pas « au resto »).
38 +TYPE_MAP = {
39 + "Restaurant": "restaurant",
40 + "Restaurant service rapide": "fast-food",
41 + "Restaurant mets pour emporter": "fast-food",
42 + "Casse-croûte": "casse-croute",
43 + "Bar salon, taverne": "bar",
44 + "Brasserie": "bar",
45 + "Pâtisserie/Boulangerie": "boulangerie-patisserie",
46 + "Boulangerie": "boulangerie-patisserie",
47 + "Traiteur": "traiteur",
48 + "Camion-cuisine": "food-truck",
49 + "Bar laitier": "casse-croute",
50 + "Bar laitier saisonnier": "casse-croute",
51 +}
52 +
53 +# ces types n'offrent pas de salle : takeout seulement
54 +_TAKEOUT_ONLY = {"fast-food", "traiteur", "food-truck", "casse-croute",
55 + "boulangerie-patisserie"}
56 +
57 +# borne île de Montréal (lat/lng du CSV parfois 0/0 ou aberrants)
58 +_LAT = (45.35, 45.75)
59 +_LNG = (-74.05, -73.35)
60 +
61 +
62 +class MtlAlimConnector(BaseConnector):
63 + source_id = "mtl-alim"
64 + request_delay = 1.0
65 + timeout = 120
66 + use_detail_cache = False # un seul CSV, pas de pages détail
67 +
68 + def _rows(self) -> list[dict]:
69 + resp = self.get(CSV_URL)
70 + resp.encoding = "utf-8"
71 + return list(csv.DictReader(io.StringIO(resp.text)))
72 +
73 + def _to_restaurant(self, row: dict) -> Restaurant | None:
74 + etype = TYPE_MAP.get((row.get("type") or "").strip())
75 + if not etype:
76 + return None
77 + name = (row.get("name") or "").strip()
78 + ext_id = (row.get("business_id") or "").strip()
79 + if not name or not ext_id:
80 + return None
81 + lat = lng = None
82 + try:
83 + lat, lng = float(row.get("latitude") or ""), float(row.get("longitude") or "")
84 + except ValueError:
85 + pass
86 + if lat is not None and not (_LAT[0] <= lat <= _LAT[1]
87 + and _LNG[0] <= lng <= _LNG[1]):
88 + lat = lng = None
89 + services = (["takeout"] if etype in _TAKEOUT_ONLY
90 + else ["salle", "takeout"])
91 + return Restaurant(
92 + source=self.source_id,
93 + external_id=ext_id,
94 + name=name,
95 + url=("https://donnees.montreal.ca/dataset/"
96 + "inspection-aliments-contrevenants"),
97 + establishment_type=etype,
98 + address=(row.get("address") or "").strip(),
99 + city=(row.get("city") or "").strip() or "Montréal",
100 + region="Montréal", # registre = île de Montréal
101 + lat=lat, lng=lng,
102 + services=services,
103 + languages=["fr"],
104 + menu=None, # découverte : pas de menu ici
105 + )
106 +
107 + def fetch(self) -> list[Restaurant]:
108 + rows = self._rows()
109 + out: dict[str, Restaurant] = {}
110 + skipped_types: dict[str, int] = {}
111 + for row in rows:
112 + if (row.get("statut") or "").strip() != "Ouvert":
113 + continue
114 + resto = self._to_restaurant(row)
115 + if resto is None:
116 + t = (row.get("type") or "?").strip()
117 + skipped_types[t] = skipped_types.get(t, 0) + 1
118 + continue
119 + out[resto.uid] = resto # business_id unique -> dédup interne
120 + # garde-fou : un CSV tronqué/vidé ne doit pas déclencher des retraits
121 + # de masse — on signale le run comme partiel sous un plancher plausible
122 + if len(out) < 3000:
123 + self.partial_run = True
124 + print(f"[resto-ka] mtl-alim: seulement {len(out)} fiches "
125 + "(plancher 3000) — run marqué partiel, retraits suspendus",
126 + file=sys.stderr)
127 + print(f"[resto-ka] mtl-alim: {len(rows)} lignes CSV -> "
128 + f"{len(out)} établissements food-service ouverts")
129 + return list(out.values())
modified restoka/connectors/siteresto.py +96 −9
@@ -62,6 +62,21 @@ _UEAT_RE = re.compile(
62 62 r"(?:order\.ueat\.io/integration/|order-online\.ai.{0,200}?integration/)"
63 63 r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.I)
64 64 _ORDER_ONLINE_RE = re.compile(r"https?://([a-z0-9-]+)\.order-online\.ai", re.I)
65 +
66 +# autres plateformes de commande (CLAUDE.md §9 P1) détectables sur le site du
67 +# resto — consignées dans data/<plateforme>-discovered.json, couvertes par
68 +# leurs connecteurs dédiés (gloriafood.py, chownow.py, squareonline.py ;
69 +# Clover : consigné comme candidat, pas de connecteur — SPA opaque)
70 +_GLF_RUID_RE = re.compile(r'data-glf-ruid="([0-9a-f-]{30,40})"', re.I)
71 +_GLF_CUID_RE = re.compile(r'data-glf-cuid="([0-9a-f-]{30,40})"', re.I)
72 +_CHOWNOW_RE = re.compile(
73 + r"(?:direct\.chownow\.com/order/|ordering\.chownow\.com/order/"
74 + r"|api\.chownow\.com/api/restaurant/|chownow\.com/order/)(\d+)", re.I)
75 +_SQUARE_SITE_RE = re.compile(r"https?://([a-z0-9-]+\.square\.site)", re.I)
76 +_CLOVER_RE = re.compile(r"clover\.com/online-ordering/([a-z0-9-]+)", re.I)
77 +# plateformes dont le connecteur dédié capte un MENU structuré : quand l'une
78 +# d'elles est détectée, site-resto ne crawle pas le menu maison (comme UEAT)
79 +_MENU_PLATFORMS = ("gloriafood", "chownow", "square")
65 80 _MENU_WORD_RE = re.compile(r"menu|carte|nos-plats|nos_plats|food", re.I)
66 81 _PRICE_HINT_RE = re.compile(r"\d{1,3}[.,]\d{2}\s*\$|\$\s*\d{1,3}[.,]\d{2}|\d{1,3}\s*\$")
67 82
@@ -309,6 +324,53 @@ class SiteRestoConnector(BaseConnector):
309 324 with _UEAT_FILE_LOCK:
310 325 self._record_ueat_locked(guid, name, domain)
311 326
327 + def _discover_platforms(self, html: str) -> list[dict]:
328 + """Plateformes de commande tierces (hors UEAT) embarquées sur le site."""
329 + out: list[dict] = []
330 + m = _GLF_RUID_RE.search(html)
331 + if m:
332 + cuid = _GLF_CUID_RE.search(html)
333 + entry = {"name": "gloriafood", "key": m.group(1)}
334 + if cuid:
335 + entry["cuid"] = cuid.group(1)
336 + out.append(entry)
337 + m = _CHOWNOW_RE.search(html)
338 + if m:
339 + out.append({"name": "chownow", "key": m.group(1)})
340 + m = _SQUARE_SITE_RE.search(html)
341 + if m:
342 + out.append({"name": "square", "key": m.group(1).lower()})
343 + m = _CLOVER_RE.search(html)
344 + if m:
345 + out.append({"name": "clover", "key": m.group(1)})
346 + return out
347 +
348 + def _record_platform(self, platform: dict, name: str, domain: str,
349 + uid: str) -> None:
350 + """Consigne une intégration découverte dans
351 + data/<plateforme>-discovered.json (avec l'uid du resto porteur pour
352 + les connecteurs d'enrichissement gloriafood/square)."""
353 + path = DATA_DIR / f"{platform['name']}-discovered.json"
354 + with _UEAT_FILE_LOCK:
355 + data = {"integrations": []}
356 + if path.exists():
357 + try:
358 + data = json.loads(path.read_text(encoding="utf-8"))
359 + except ValueError:
360 + pass
361 + if any(i.get("key") == platform["key"]
362 + for i in data.get("integrations", [])):
363 + return
364 + entry = {"key": platform["key"], "uid": uid,
365 + "label": f"{name} (découvert via {domain})"}
366 + if platform.get("cuid"):
367 + entry["cuid"] = platform["cuid"]
368 + data.setdefault("integrations", []).append(entry)
369 + path.write_text(json.dumps(data, ensure_ascii=False, indent=1),
370 + encoding="utf-8")
371 + print(f"[resto-ka] site-resto: plateforme {platform['name']} "
372 + f"découverte chez {name} ({platform['key']}) -> {path.name}")
373 +
312 374 def _record_ueat_locked(self, guid: str, name: str, domain: str) -> None:
313 375 data = {"integrations": []}
314 376 if UEAT_DISCOVERED.exists():
@@ -335,9 +397,16 @@ class SiteRestoConnector(BaseConnector):
335 397 return None
336 398 contact = self._extract_contact(home, website)
337 399 images = self._extract_images(home, website)
400 + platforms = self._discover_platforms(home)
338 401 guid = self._discover_ueat(home, website)
339 402 if guid:
340 − return {"ueat": guid, "contact": contact, "images": images}
403 + return {"ueat": guid, "contact": contact, "images": images,
404 + "platforms": platforms}
405 + if any(p["name"] in _MENU_PLATFORMS for p in platforms):
406 + # une plateforme à menu structuré couvre ce resto : son connecteur
407 + # dédié prendra le relais (comme UEAT) — pas de crawl maison
408 + return {"contact": contact, "images": images,
409 + "platforms": platforms}
341 410 pages, pdfs = self._menu_links(home, website)
342 411 # 1) pages HTML de menu
343 412 for page_url in pages:
@@ -355,7 +424,8 @@ class SiteRestoConnector(BaseConnector):
355 424 menu = menullm.menu_from_text(text)
356 425 if menu:
357 426 return {"sections": menu["sections"], "url": page_url,
358 − "contact": contact, "images": images}
427 + "contact": contact, "images": images,
428 + "platforms": platforms}
359 429 # 2) PDF de menu
360 430 for pdf_url in pdfs:
361 431 pdf = self._get_pdf(pdf_url)
@@ -363,17 +433,20 @@ class SiteRestoConnector(BaseConnector):
363 433 menu = menullm.menu_from_pdf(pdf)
364 434 if menu:
365 435 return {"sections": menu["sections"], "url": pdf_url,
366 − "contact": contact, "images": images}
436 + "contact": contact, "images": images,
437 + "platforms": platforms}
367 438 # 3) la page d'accueil elle-même contient parfois le menu
368 439 text = self._html_text(home)
369 440 if len(_PRICE_HINT_RE.findall(text)) >= 8:
370 441 menu = menullm.menu_from_text(text)
371 442 if menu:
372 443 return {"sections": menu["sections"], "url": website,
373 − "contact": contact, "images": images}
374 − if any(v for v in contact.values()) or images:
375 − # pas de menu, mais du contact et/ou des photos captés
376 − return {"contact": contact, "images": images}
444 + "contact": contact, "images": images,
445 + "platforms": platforms}
446 + if any(v for v in contact.values()) or images or platforms:
447 + # pas de menu, mais du contact/photos/plateformes captés
448 + return {"contact": contact, "images": images,
449 + "platforms": platforms}
377 450 return None
378 451
379 452 # -- reconstruction des fiches déjà captées ------------------------------------------
@@ -427,8 +500,8 @@ class SiteRestoConnector(BaseConnector):
427 500
428 501 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN
429 502 if os.environ.get("FIRECRAWL_API_KEY") else 0]
430 − stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0,
431 − "contacts": 0, "photos": 0}
503 + stats = {"crawled": 0, "menus": 0, "ueat": 0, "plateformes": 0,
504 + "vides": 0, "contacts": 0, "photos": 0}
432 505 now = time.time()
433 506 # enrichissements à appliquer APRÈS sync_source (la fiche site-resto
434 507 # n'existe en base qu'après l'upsert) — consommé par ingest.run
@@ -514,12 +587,26 @@ class SiteRestoConnector(BaseConnector):
514 587 (json.dumps(images), row["uid"]))
515 588 con.commit()
516 589 stats["photos"] += 1
590 + # plateformes de commande découvertes : consignées ici (fil
591 + # principal seulement — les workers n'écrivent ni BD ni fichiers)
592 + for p in (result or {}).get("platforms") or []:
593 + self._record_platform(p, row["name"], domain, row["uid"])
517 594 if result and result.get("ueat"):
518 595 self._record_ueat(result["ueat"], row["name"], domain)
519 596 self._set_status(domain, {"status": "ueat",
520 597 "guid": result["ueat"]})
521 598 stats["ueat"] += 1
522 599 continue
600 + blocking = [p for p in (result or {}).get("platforms") or []
601 + if p["name"] in _MENU_PLATFORMS]
602 + if blocking and not (result and result.get("sections")):
603 + # menu servi par une plateforme connue : le connecteur
604 + # dédié (gloriafood/chownow/square) prendra le relais
605 + self._set_status(domain, {"status": "platform",
606 + "platforms":
607 + [p["name"] for p in blocking]})
608 + stats["plateformes"] += 1
609 + continue
523 610 if not result or not result.get("sections"):
524 611 self._set_status(domain, {"status": "no_menu"})
525 612 stats["vides"] += 1
added restoka/connectors/skip.py +383 −0
@@ -0,0 +1,383 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/skip.py
4 +# Desc: Connecteur d'ENRICHISSEMENT SkipTheDishes (menus livraison + score) —
5 +# même patron conservateur qu'ubereats.py/doordash.py. Découverte par
6 +# les sitemaps publics (sitemap2..7.xml, pages resto à la racine
7 +# /<slug>, doublons /fr/ exclus ; index local data/skip-stores.json,
8 +# 30 j). Chaque page resto embarque un état Next.js (__NEXT_DATA__)
9 +# avec props.pageProps.partner (nom, rue, « Ville, PROV, POSTAL, CAN »,
10 +# score Skip, téléphone dans les textes du disclaimerModal) et
11 +# menuV2.categories[].menuItems[] (name, description, subtotal en
12 +# cents). Croisement CONSERVATEUR avec un resto EXISTANT seulement
13 +# (téléphone identique OU code postal + numéro civique identiques ;
14 +# province ≠ QC rejetée d'emblée) : n'émet AUCUNE fiche. Menu -> table
15 +# menus en price_context `delivery` (prix majorés ~25-30 %, §6.2),
16 +# score -> details.skip.
17 +#
18 +# Mode d'extraction : Scrapfly ASP (anti-bot ; gros contenus résolus
19 +# via « large_object »), budget SKIP_BUDGET (défaut 40) pages/cycle,
20 +# verdict par resto en cache, re-visite 30 j. Conformité §15 :
21 +# extraction minimale, usage d'appoint, prix étiquetés `delivery`.
22 +# ==============================================================================
23 +from __future__ import annotations
24 +
25 +import datetime
26 +import json
27 +import os
28 +import re
29 +import sys
30 +import time
31 +from pathlib import Path
32 +
33 +import requests
34 +
35 +from ..inspections import _CIVIC_RE
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 +BASE = "https://www.skipthedishes.com"
42 +SITEMAPS = [f"{BASE}/sitemap{i}.xml" for i in range(2, 8)]
43 +INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "skip-stores.json"
44 +INDEX_REFRESH_DAYS = 30
45 +MAX_BUDGET = int(os.environ.get("SKIP_BUDGET", "40")) # pages/cycle
46 +REFRESH_DAYS = 30
47 +MAX_CANDIDATES = 3
48 +MAX_CONSECUTIVE_FAILURES = 3
49 +
50 +_NEXT_DATA_RE = re.compile(
51 + r'<script id="__NEXT_DATA__" type="application/json"[^>]*>(.*?)</script>',
52 + re.S)
53 +# pages resto = un seul segment à la racine ; /fr/... = doublon francophone
54 +_STORE_URL_RE = re.compile(r"skipthedishes\.com/([a-z0-9-]+)/?$")
55 +# « Windsor, ON, N9E 1S3, CAN »
56 +_LOCDETAILS_RE = re.compile(
57 + r"^\s*(.+?),\s*([A-Z]{2}),\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d),", re.I)
58 +
59 +
60 +def decode_next_data(page_html: str) -> dict | None:
61 + """Décode le bloc __NEXT_DATA__ (JSON brut) d'une page Skip."""
62 + m = _NEXT_DATA_RE.search(page_html)
63 + if not m:
64 + return None
65 + try:
66 + return json.loads(m.group(1))
67 + except ValueError:
68 + return None
69 +
70 +
71 +def partner_payload(state: dict) -> tuple[dict | None, dict | None]:
72 + """(partner, menuV2) depuis props.pageProps."""
73 + pp = ((state or {}).get("props") or {}).get("pageProps") or {}
74 + partner = pp.get("partner")
75 + menu = pp.get("menuV2") or (partner or {}).get("menuV2")
76 + return (partner if isinstance(partner, dict) else None,
77 + menu if isinstance(menu, dict) else None)
78 +
79 +
80 +def partner_phone(partner: dict) -> str:
81 + """Téléphone du resto, enfoui dans les textes du disclaimerModal."""
82 + dm = (partner or {}).get("disclaimerModal") or {}
83 + for d in dm.get("disclaimers") or []:
84 + phone = normalize_phone(d.get("text") or "")
85 + if phone:
86 + return phone
87 + return ""
88 +
89 +
90 +def parse_location_details(details: str) -> tuple[str, str, str]:
91 + """« Windsor, ON, N9E 1S3, CAN » -> (ville, province, code postal)."""
92 + m = _LOCDETAILS_RE.match(details or "")
93 + if not m:
94 + return "", "", ""
95 + return (m.group(1).strip(), m.group(2).upper(),
96 + m.group(3).replace(" ", "").upper())
97 +
98 +
99 +def build_menu(menu_v2: dict, captured_at: str) -> dict | None:
100 + """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis menuV2.categories.
101 + Prix Skip en cents (`subtotal`) -> dollars, contexte `delivery`."""
102 + sections_out: list[dict] = []
103 + seen: set[str] = set()
104 + for cat in (menu_v2 or {}).get("categories") or []:
105 + name = (cat.get("name") or "").strip()
106 + if not name or name in seen:
107 + continue
108 + items = []
109 + for it in cat.get("menuItems") or []:
110 + title = (it.get("name") or "").strip()
111 + if not title:
112 + continue
113 + sub = it.get("subtotal")
114 + items.append({
115 + "name": title,
116 + "description": (it.get("description") or "").strip(),
117 + "price": round(sub / 100.0, 2)
118 + if isinstance(sub, (int, float)) and sub > 0 else None,
119 + })
120 + if items:
121 + seen.add(name)
122 + sections_out.append({"name": name, "items": items})
123 + if not sections_out:
124 + return None
125 + return {
126 + "price_context": "delivery",
127 + "price_source": "skip",
128 + "currency": "CAD",
129 + "captured_at": captured_at,
130 + "sections": sections_out,
131 + }
132 +
133 +
134 +def verify_partner(row, partner: dict) -> str:
135 + """Vérifie qu'une page Skip correspond bien au resto de la base —
136 + CONSERVATEUR : province QC obligatoire, puis téléphone identique OU
137 + code postal + numéro civique identiques. Retourne le mode de croisement
138 + ('' = pas le même établissement)."""
139 + _, prov, postal = parse_location_details(
140 + partner.get("locationDetails") or "")
141 + if prov and prov != "QC":
142 + return ""
143 + phone = partner_phone(partner)
144 + if phone and normalize_phone(row["phone"]) == phone:
145 + return "telephone"
146 + rpostal = (row["postal_code"] or "").replace(" ", "").upper()
147 + civic_m = _CIVIC_RE.match(partner.get("location") or "")
148 + rcivic_m = _CIVIC_RE.match(row["address"] or "")
149 + if postal and rpostal == postal and civic_m and rcivic_m \
150 + and civic_m.group(1) == rcivic_m.group(1):
151 + return "postal+civique"
152 + return ""
153 +
154 +
155 +def slugify(name: str) -> str:
156 + """Slug façon Skip (cleanUrl) : minuscules sans accents, tirets."""
157 + s = strip_accents((name or "").lower())
158 + s = re.sub(r"['’´`.]", "", s)
159 + s = re.sub(r"&", " ", s)
160 + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
161 + return s
162 +
163 +
164 +class SkipConnector(BaseConnector):
165 + source_id = "skip"
166 + request_delay = 1.0
167 + timeout = 60
168 + use_detail_cache = False # verdicts gérés à la main (detail_cache)
169 + enrichment_only = True # n'émet aucune fiche (ingest.run)
170 +
171 + # -- Scrapfly / large_object -------------------------------------------------
172 + def _content(self, url: str, **kw) -> tuple[int, str]:
173 + result = self.scrapfly(url, render_js=False, **kw)
174 + status = result.get("status_code") or 0
175 + content = result.get("content") or ""
176 + if content.startswith("https://api.scrapfly.io/scrape/large_object/"):
177 + resp = requests.get(content,
178 + params={"key": os.environ["SCRAPFLY_KEY"]},
179 + timeout=120)
180 + content = resp.text if resp.ok else ""
181 + return status, content
182 +
183 + # -- index sitemap -----------------------------------------------------------
184 + def load_index(self) -> dict[str, str]:
185 + """Index slug -> URL resto depuis sitemap2..7 (pages à la racine),
186 + en cache local 30 jours (data/skip-stores.json). Les /fr/… (doublons)
187 + et sitemap1 (pages villes/cuisines) sont exclus."""
188 + if INDEX_PATH.exists():
189 + try:
190 + data = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
191 + if time.time() - float(data.get("fetched_at") or 0) \
192 + < INDEX_REFRESH_DAYS * 86400:
193 + return data.get("stores") or {}
194 + except (ValueError, OSError):
195 + pass
196 + stores: dict[str, str] = {}
197 + for sm_url in SITEMAPS:
198 + _, xml = self._content(sm_url)
199 + for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
200 + mm = _STORE_URL_RE.search(loc)
201 + if mm:
202 + stores.setdefault(mm.group(1).lower(), loc)
203 + if not stores:
204 + raise RuntimeError("sitemaps Skip vides (blocage ?)")
205 + INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
206 + INDEX_PATH.write_text(json.dumps(
207 + {"fetched_at": time.time(), "stores": stores},
208 + ensure_ascii=False), encoding="utf-8")
209 + print(f"[resto-ka] skip: index sitemap rafraîchi — "
210 + f"{len(stores)} resto(s) Canada")
211 + return stores
212 +
213 + @staticmethod
214 + def candidates_for(name: str, slugs: list[str]) -> list[str]:
215 + """Slugs Skip candidats pour un nom de resto (préfixe strict — le
216 + slug Skip est le nom seul, parfois suffixé de la rue/ville)."""
217 + base = slugify(name)
218 + if len(base) < 5:
219 + return []
220 + import bisect
221 + i = bisect.bisect_left(slugs, base)
222 + out = []
223 + while i < len(slugs) and len(out) < MAX_CANDIDATES:
224 + s = slugs[i]
225 + if s == base or s.startswith(base + "-"):
226 + out.append(s)
227 + i += 1
228 + else:
229 + break
230 + return out
231 +
232 + # -- cycle -------------------------------------------------------------------
233 + def _now(self) -> str:
234 + return datetime.datetime.now(datetime.timezone.utc) \
235 + .strftime("%Y-%m-%dT%H:%M:%SZ")
236 +
237 + def _fresh(self, stamp: str, now: float, stale_s: float) -> bool:
238 + try:
239 + ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \
240 + .replace(tzinfo=datetime.timezone.utc).timestamp()
241 + return ts > now - stale_s
242 + except (ValueError, TypeError):
243 + return False
244 +
245 + def _details_payload(self, partner: dict, url: str, how: str) -> dict:
246 + score = partner.get("score") or {}
247 + return {
248 + "score": score.get("formatted") or score.get("value"),
249 + "url": url.split("?")[0],
250 + "partner_id": partner.get("id"),
251 + "matched_by": how,
252 + "fetched_at": self._now(),
253 + }
254 +
255 + def _probe_store(self, con, row, url: str) -> tuple[bool, bool]:
256 + """Visite une page resto Skip et l'attache au resto si c'est le même
257 + établissement. Retourne (matched, menu_added)."""
258 + from .. import db
259 + mm = _STORE_URL_RE.search(url)
260 + slug = mm.group(1) if mm else url
261 + cached = db.get_cached_detail(con, self.source_id, slug, "verdict-v1")
262 + if cached is not None and cached.get("matched_uid") != row["uid"]:
263 + return False, False # déjà identifié comme un autre resto
264 + status, content = self._content(url)
265 + if status in (400, 404, 410, 451): # resto retiré de Skip
266 + db.put_cached_detail(con, self.source_id, slug, "verdict-v1",
267 + {"matched_uid": None, "gone": status})
268 + return False, False
269 + if status != 200:
270 + raise RuntimeError(f"HTTP {status}")
271 + state = decode_next_data(content)
272 + if not state:
273 + raise RuntimeError("__NEXT_DATA__ absent (page non rendue ?)")
274 + partner, menu_v2 = partner_payload(state)
275 + if not partner:
276 + # page rendue mais sans fiche partner (page de groupe/chaîne) :
277 + # identité invérifiable -> miss prudent, PAS un blocage
278 + db.put_cached_detail(con, self.source_id, slug, "verdict-v1",
279 + {"matched_uid": None, "no_partner": True})
280 + return False, False
281 + how = verify_partner(row, partner)
282 + if not how:
283 + db.put_cached_detail(con, self.source_id, slug, "verdict-v1",
284 + {"matched_uid": None})
285 + return False, False
286 + db.put_cached_detail(con, self.source_id, slug, "verdict-v1",
287 + {"matched_uid": row["uid"]})
288 + menu = build_menu(menu_v2 or {}, self._now())
289 + menu_added = False
290 + if menu:
291 + # validation stricte du schéma menu (prix implausibles, etc.)
292 + Restaurant(source=self.source_id, external_id=slug,
293 + name=row["name"], menu=menu)._validate_menu()
294 + db.upsert_menu(con, row["uid"], menu, time.time())
295 + menu_added = True
296 + db.merge_details(con, row["uid"],
297 + {"skip": self._details_payload(partner, url, how)})
298 + con.commit()
299 + return True, menu_added
300 +
301 + def fetch(self) -> list[Restaurant]:
302 + if not os.environ.get("SCRAPFLY_KEY"):
303 + raise SkipSource("SCRAPFLY_KEY manquant (.env) — SkipTheDishes "
304 + "est derrière un anti-bot, scraping direct "
305 + "impossible")
306 + from .. import db
307 + index = self.load_index()
308 + slugs = sorted(index.keys())
309 + con = db.connect()
310 + now = time.time()
311 + stale_s = REFRESH_DAYS * 86400.0
312 + budget = MAX_BUDGET
313 + matched = menus = misses = failures_row = 0
314 + # Skip n'expose ni GPS ni téléphone en clair : le croisement passe par
315 + # le téléphone (disclaimer) ou postal+civique -> exiger ces champs
316 + rows = con.execute(
317 + "SELECT uid, name, address, city, postal_code, phone, lat, lng,"
318 + " details,"
319 + " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"
320 + " AS has_menu"
321 + " FROM restaurants WHERE active=1 AND dup_of IS NULL AND name<>''"
322 + " AND (phone<>'' OR (postal_code<>'' AND address<>''))"
323 + " ORDER BY has_menu ASC, phone<>'' DESC, updated_at DESC"
324 + ).fetchall()
325 + for row in rows:
326 + if budget <= 0:
327 + break
328 + if failures_row >= MAX_CONSECUTIVE_FAILURES:
329 + print("[resto-ka] skip: Scrapfly bloqué "
330 + f"{failures_row} fois de suite — arrêt du cycle",
331 + file=sys.stderr)
332 + break
333 + try:
334 + details = json.loads(row["details"] or "{}")
335 + except ValueError:
336 + details = {}
337 + sk = details.get("skip") or {}
338 + if self._fresh(sk.get("fetched_at", ""), now, stale_s):
339 + continue # déjà frais (<30 j)
340 + probe = details.get("skip_probe") or {}
341 + if self._fresh(probe.get("fetched_at", ""), now, stale_s):
342 + continue # échec récent : re-visite dans 30 j
343 + if sk.get("url"): # déjà croisé : rafraîchir directement
344 + urls = [sk["url"]]
345 + else:
346 + cand = self.candidates_for(row["name"], slugs)
347 + urls = [index[s] for s in cand if s in index]
348 + if not urls:
349 + continue # aucun candidat : pas de marqueur
350 + found = False
351 + for url in urls[:MAX_CANDIDATES]:
352 + if budget <= 0:
353 + break
354 + budget -= 1
355 + try:
356 + ok, menu_added = self._probe_store(con, row, url)
357 + except Exception as exc:
358 + failures_row += 1
359 + print(f"[resto-ka] skip: {row['uid']} erreur: {exc}",
360 + file=sys.stderr)
361 + continue
362 + failures_row = 0
363 + if ok:
364 + matched += 1
365 + menus += 1 if menu_added else 0
366 + found = True
367 + break
368 + if not found and not sk.get("url"):
369 + db.merge_details(con, row["uid"],
370 + {"skip_probe":
371 + {"miss": "aucun resto correspondant",
372 + "fetched_at": self._now()}})
373 + con.commit()
374 + misses += 1
375 + con.commit()
376 + con.close()
377 + self.enriched_count = matched
378 + self.enrich_message = (f"{matched} resto(s) croisés Skip "
379 + f"({menus} menu(s) livraison), {misses} sans "
380 + f"correspondance, budget restant "
381 + f"{max(budget, 0)} page(s)")
382 + print(f"[resto-ka] skip: {self.enrich_message}")
383 + return []
added restoka/connectors/squareonline.py +165 −0
@@ -0,0 +1,165 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/squareonline.py
4 +# Desc: Connecteur templatisé Square Online (Palier 1 — boutique de commande
5 +# en ligne du resto lui-même -> PRIX RÉELS, contexte `takeout`).
6 +# Les sites <sous-domaine>.square.site sont détectés par le connecteur
7 +# site-resto (lien de commande sur le site du resto) et consignés dans
8 +# data/square-discovered.json AVEC l'uid du resto porteur.
9 +# Les pages Square sont une SPA sans JSON-LD ni prix inline (constat
10 +# de sondage) : la page /s/order est rendue en JS via Scrapfly puis le
11 +# texte est structuré par Claude Haiku (restoka/menullm.py, validation
12 +# stricte des prix). ENRICHISSEMENT PUR : le menu est posé sur le resto
13 +# porteur (db.upsert_menu, price_context takeout) + details.square.
14 +# CONNECTEUR DORMANT tant qu'aucune intégration n'est découverte
15 +# (SkipSource). Budget SQUARE_BUDGET (défaut 15) rendus/cycle (coût
16 +# Scrapfly rendu JS + LLM), re-visite 30 j.
17 +#
18 +# Base légale §15 : boutique de commande du resto lui-même (source de
19 +# première partie), extraction minimale, lien source conservé.
20 +# ==============================================================================
21 +from __future__ import annotations
22 +
23 +import datetime
24 +import json
25 +import os
26 +import sys
27 +import time
28 +from pathlib import Path
29 +
30 +from ..schema import Restaurant
31 +from .base import BaseConnector, SkipSource
32 +
33 +SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"
34 +DISCOVERED = SOURCES_PATH.parent / "square-discovered.json"
35 +MAX_BUDGET = int(os.environ.get("SQUARE_BUDGET", "15")) # rendus JS/cycle
36 +REFRESH_DAYS = 30
37 +
38 +
39 +def _html_text(html: str) -> str:
40 + from bs4 import BeautifulSoup
41 + soup = BeautifulSoup(html or "", "html.parser")
42 + for tag in soup(["script", "style", "noscript"]):
43 + tag.decompose()
44 + return " ".join(soup.get_text(" ").split())
45 +
46 +
47 +class SquareOnlineConnector(BaseConnector):
48 + source_id = "square"
49 + request_delay = 1.0
50 + timeout = 60
51 + use_detail_cache = False # fraîcheur gérée via details.square
52 + enrichment_only = True # n'émet aucune fiche (ingest.run)
53 +
54 + def _integrations(self) -> list[dict]:
55 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))
56 + out: list[dict] = []
57 + for src in registry.get("sources", []):
58 + if src.get("id") == self.source_id:
59 + out.extend(src.get("integrations") or [])
60 + if DISCOVERED.exists():
61 + try:
62 + extra = json.loads(DISCOVERED.read_text(encoding="utf-8"))
63 + known = {i.get("key") for i in out}
64 + out.extend(i for i in extra.get("integrations", [])
65 + if i.get("key") not in known)
66 + except ValueError:
67 + pass
68 + return out
69 +
70 + def _now(self) -> str:
71 + return datetime.datetime.now(datetime.timezone.utc) \
72 + .strftime("%Y-%m-%dT%H:%M:%SZ")
73 +
74 + def _order_page_text(self, site: str) -> tuple[str, str]:
75 + """(texte, url) de la page de commande rendue en JS."""
76 + url = f"https://{site}/s/order"
77 + html = self.get_scrapfly(url, render_js=True, rendering_wait=3000)
78 + text = _html_text(html)
79 + if len(text) < 200: # page vide : essayer l'accueil
80 + url = f"https://{site}/"
81 + html = self.get_scrapfly(url, render_js=True, rendering_wait=3000)
82 + text = _html_text(html)
83 + return text, url
84 +
85 + def fetch(self) -> list[Restaurant]:
86 + integrations = [i for i in self._integrations()
87 + if i.get("key") and i.get("status") != "inactif"]
88 + if not integrations:
89 + raise SkipSource(
90 + "aucune intégration Square Online découverte "
91 + "(data/square-discovered.json vide) — connecteur dormant, "
92 + "en attente des découvertes du connecteur site-resto")
93 + if not os.environ.get("SCRAPFLY_KEY"):
94 + raise SkipSource("SCRAPFLY_KEY manquant (.env) — les boutiques "
95 + "Square sont des SPA, rendu JS requis")
96 + from .. import db, menullm
97 + con = db.connect()
98 + now = time.time()
99 + budget = MAX_BUDGET
100 + menus = errors = 0
101 + for integ in integrations:
102 + if budget <= 0:
103 + break
104 + site, uid = str(integ["key"]), integ.get("uid") or ""
105 + if not uid:
106 + print(f"[resto-ka] square: {site} sans uid porteur — ignoré",
107 + file=sys.stderr)
108 + continue
109 + row = con.execute(
110 + "SELECT details FROM restaurants WHERE uid=? AND active=1",
111 + (uid,)).fetchone()
112 + if row is None:
113 + continue # resto porteur disparu
114 + try:
115 + details = json.loads(row["details"] or "{}")
116 + except ValueError:
117 + details = {}
118 + sq = details.get("square") or {}
119 + try:
120 + ts = datetime.datetime.strptime(
121 + sq.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \
122 + .replace(tzinfo=datetime.timezone.utc).timestamp()
123 + if ts > now - REFRESH_DAYS * 86400:
124 + continue # déjà frais (hit comme miss)
125 + except (ValueError, TypeError):
126 + pass
127 + budget -= 1
128 + try:
129 + text, url = self._order_page_text(site)
130 + menu_raw = menullm.menu_from_text(text) if text else None
131 + except Exception as exc:
132 + errors += 1
133 + print(f"[resto-ka] square: {uid} ({site}) erreur: {exc}",
134 + file=sys.stderr)
135 + continue
136 + if not menu_raw or not menu_raw.get("sections"):
137 + # miss horodaté : re-visite dans REFRESH_DAYS, pas à chaque cycle
138 + db.merge_details(con, uid, {"square": {
139 + "site": site, "miss": "aucun menu structurable",
140 + "fetched_at": self._now()}})
141 + con.commit()
142 + continue
143 + menu = {
144 + "price_context": "takeout",
145 + "price_source": "square",
146 + "currency": "CAD",
147 + "captured_at": self._now(),
148 + "sections": menu_raw["sections"],
149 + }
150 + # validation stricte du schéma menu avant stockage
151 + Restaurant(source=self.source_id, external_id=site,
152 + name=uid, menu=menu)._validate_menu()
153 + db.upsert_menu(con, uid, menu, time.time())
154 + db.merge_details(con, uid, {"square": {
155 + "site": site, "url": url, "fetched_at": self._now()}})
156 + con.commit()
157 + menus += 1
158 + con.close()
159 + self.enriched_count = menus
160 + self.enrich_message = (f"{menus} menu(s) takeout Square posés, "
161 + f"{errors} erreur(s), budget restant "
162 + f"{max(budget, 0)} rendu(s) sur "
163 + f"{len(integrations)} intégration(s)")
164 + print(f"[resto-ka] square: {self.enrich_message}")
165 + return []
added restoka/connectors/tastet.py +173 −0
@@ -0,0 +1,173 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/tastet.py
4 +# Desc: Connecteur de DÉCOUVERTE Tastet (Palier 4, média food québécois) —
5 +# les critiques de tastet.ca couvrent des milliers de restos (surtout
6 +# Montréal/Québec) avec nom, adresse complète et téléphone. Chaque
7 +# page critique embarque un lien Google Maps
8 +# (…/maps/search/?api=1&query=Nom+Adresse) et un lien tel: — pas de
9 +# menu structuré (média éditorial). Émet des fiches de découverte
10 +# SANS menu ; la déduplication les fusionne avec OSM/UEAT et la fiche
11 +# canonique gagne le lien éditorial.
12 +#
13 +# Mode d'extraction : requêtes directes polies (pas d'anti-bot),
14 +# sitemaps Yoast reviews-sitemap*.xml -> pages critiques, cache BD
15 +# par page (re-crawl seulement si <lastmod> change), budget
16 +# TASTET_BUDGET (défaut 80) nouvelles pages/cycle — le corpus se
17 +# construit incrémentalement de cycle en cycle.
18 +# Base légale §15 : découverte minimale (nom, adresse, téléphone,
19 +# lien vers la critique), attribution par lien sortant vers Tastet.
20 +#
21 +# Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).
22 +# ==============================================================================
23 +from __future__ import annotations
24 +
25 +import html as html_mod
26 +import re
27 +import os
28 +import sys
29 +import urllib.parse
30 +
31 +from ..normalize import normalize_phone
32 +from ..regions import VILLE_REGION, _key
33 +from ..schema import Restaurant
34 +from .base import BaseConnector
35 +
36 +SITEMAP_INDEX = "https://tastet.ca/sitemap_index.xml"
37 +_REVIEWS_SM_RE = re.compile(r"(https://tastet\.ca/reviews-sitemap\d*\.xml)")
38 +MAX_BUDGET = int(os.environ.get("TASTET_BUDGET", "80")) # nouvelles pages/cycle
39 +
40 +_URL_ENTRY_RE = re.compile(
41 + r"<url>\s*<loc>([^<]+)</loc>(?:.*?<lastmod>([^<]+)</lastmod>)?.*?</url>",
42 + re.S)
43 +_MAPS_RE = re.compile(
44 + r'href="https://www\.google\.com/maps/search/\?api=1&(?:amp;)?query=([^"]+)"')
45 +_TEL_RE = re.compile(r'href="tel:([^"]+)"')
46 +_OGIMG_RE = re.compile(
47 + r'<meta property="og:image" content="([^"]+)"')
48 +_POSTAL_RE = re.compile(r"([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)\s*$")
49 +
50 +
51 +def parse_maps_query(query: str) -> tuple[str, str, str, str]:
52 + """« Nom+1556 Avenue Laurier Est Montréal H2J 1H9 » ->
53 + (nom, adresse-rue, ville, code postal). Le nom est séparé de l'adresse
54 + par le premier « + » ; la ville est le plus long suffixe de tokens connu
55 + de la table VILLE_REGION ; le code postal termine la chaîne."""
56 + # « + » est le SÉPARATEUR nom/adresse (les espaces sont littéraux ou %20)
57 + q = html_mod.unescape(urllib.parse.unquote(query)).strip()
58 + name, _, rest = q.partition("+")
59 + name, rest = name.strip(), rest.strip()
60 + postal = ""
61 + m = _POSTAL_RE.search(rest)
62 + if m:
63 + postal = (m.group(1) + m.group(2)).upper()
64 + rest = rest[:m.start()].strip().rstrip(",")
65 + city = ""
66 + tokens = rest.split()
67 + for n in range(min(4, len(tokens) - 1), 0, -1): # garder >=1 token d'adresse
68 + cand = " ".join(tokens[-n:])
69 + if _key(cand) in VILLE_REGION:
70 + city = cand
71 + rest = " ".join(tokens[:-n]).rstrip(",").strip()
72 + break
73 + return name, rest, city, postal
74 +
75 +
76 +def parse_review(page_html: str) -> dict | None:
77 + """Extrait {name, address, city, postal_code, phone, image} d'une page
78 + critique Tastet. None si la page n'a pas de fiche resto (pas de lien
79 + Google Maps — liste, éditorial générique…)."""
80 + m = _MAPS_RE.search(page_html)
81 + if not m:
82 + return None
83 + name, address, city, postal = parse_maps_query(m.group(1))
84 + if not name:
85 + return None
86 + tel = _TEL_RE.search(page_html)
87 + img = _OGIMG_RE.search(page_html)
88 + return {
89 + "name": name,
90 + "address": address,
91 + "city": city,
92 + "postal_code": postal,
93 + "phone": normalize_phone(tel.group(1)) if tel else "",
94 + "image": html_mod.unescape(img.group(1)) if img else "",
95 + }
96 +
97 +
98 +class TastetConnector(BaseConnector):
99 + source_id = "tastet"
100 + request_delay = 1.2 # politesse : média indépendant
101 + timeout = 30
102 + use_detail_cache = False # cache géré à la main (clé lastmod)
103 +
104 + def _review_urls(self) -> list[tuple[str, str]]:
105 + """[(url, lastmod)] des pages critiques, via l'index Yoast."""
106 + index = self.get(SITEMAP_INDEX).text
107 + sitemaps = _REVIEWS_SM_RE.findall(index)
108 + if not sitemaps:
109 + raise RuntimeError("index sitemap Tastet sans reviews-sitemap")
110 + out: list[tuple[str, str]] = []
111 + seen: set[str] = set()
112 + for sm in sitemaps:
113 + xml = self.get(sm).text
114 + for loc, lastmod in _URL_ENTRY_RE.findall(xml):
115 + loc = loc.strip()
116 + if "/en/" in loc or loc in seen: # doublons anglophones
117 + continue
118 + seen.add(loc)
119 + out.append((loc, (lastmod or "").strip()))
120 + return out
121 +
122 + @staticmethod
123 + def _ext_id(url: str) -> str:
124 + return url.rstrip("/").rsplit("/", 1)[-1]
125 +
126 + def fetch(self) -> list[Restaurant]:
127 + from .. import db
128 + con = db.connect()
129 + urls = self._review_urls()
130 + budget = MAX_BUDGET
131 + fetched = 0
132 + out: list[Restaurant] = []
133 + for url, lastmod in urls:
134 + ext_id = self._ext_id(url)
135 + key = f"page-v1:{lastmod or 'v1'}"
136 + payload = db.get_cached_detail(con, self.source_id, ext_id, key)
137 + if payload is None:
138 + if budget <= 0:
139 + continue # au prochain cycle (corpus incrémental)
140 + budget -= 1
141 + fetched += 1
142 + try:
143 + page = self.get(url).text
144 + except Exception as exc:
145 + print(f"[resto-ka] tastet: {url} erreur: {exc}",
146 + file=sys.stderr)
147 + continue
148 + payload = parse_review(page) or {"no_restaurant": True}
149 + db.put_cached_detail(con, self.source_id, ext_id, key, payload)
150 + if payload.get("no_restaurant") or not payload.get("name"):
151 + continue
152 + out.append(Restaurant(
153 + source=self.source_id,
154 + external_id=ext_id,
155 + name=payload["name"],
156 + url=url,
157 + address=payload.get("address") or "",
158 + city=payload.get("city") or "",
159 + postal_code=payload.get("postal_code") or "",
160 + phone=payload.get("phone") or "",
161 + images=[payload["image"]] if payload.get("image") else [],
162 + languages=["fr"],
163 + menu=None, # média éditorial : pas de menu structuré
164 + ))
165 + con.close()
166 + # le corpus se construit page par page : tant que des critiques
167 + # restent à crawler, ne jamais retirer les fiches pas encore émises
168 + if budget <= 0:
169 + self.partial_run = True
170 + print(f"[resto-ka] tastet: {len(urls)} critiques au sitemap, "
171 + f"{fetched} page(s) crawlée(s) ce cycle, "
172 + f"{len(out)} fiches resto émises")
173 + return out
modified restoka/dedup.py +2 −1
@@ -23,7 +23,8 @@ from .regions import strip_accents
23 23 # la découverte (osm) toujours en dernier — sa fiche s'efface derrière celle
24 24 # qui porte un menu.
25 25 SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow",
26 − "site-resto", "ubereats", "doordash", "skip", "osm"]
26 + "site-resto", "ubereats", "doordash", "skip", "osm",
27 + "mtl-alim", "tastet"]
27 28
28 29
29 30 def _name_key(name: str) -> str:
added tests/test_chownow.py +58 −0
@@ -0,0 +1,58 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_chownow.py
4 +# Desc: Connecteur ChowNow (Palier 1, takeout) — parse_geo, build_options
5 +# (graphe des modificateurs -> options §5.2), build_menu (size suffixé,
6 +# doublons de sections écartés), prix réels non majorés.
7 +# ==============================================================================
8 +from restoka.connectors.chownow import build_menu, build_options, parse_geo
9 +
10 +
11 +def test_parse_geo():
12 + assert parse_geo("45.5017,-73.5673") == (45.5017, -73.5673)
13 + assert parse_geo("") == (None, None)
14 + assert parse_geo("n/a") == (None, None)
15 +
16 +
17 +def test_build_options_graphe_modificateurs():
18 + item = {"modifier_categories": [10, 99]} # 99 inconnu : ignoré
19 + mod_cats = {"10": {"name": "Format", "min_qty": 1, "modifiers": [1, 2, 3]}}
20 + mods = {"1": {"name": "10 pouces", "price": 0},
21 + "2": {"name": "14 pouces", "price": 6.0},
22 + "3": {"name": "", "price": 1.0}} # sans nom : écarté
23 + opts = build_options(item, mod_cats, mods)
24 + assert len(opts) == 1
25 + assert opts[0]["group"] == "Format" and opts[0]["required"] is True
26 + assert opts[0]["choices"] == [
27 + {"name": "10 pouces", "price_delta": 0.0},
28 + {"name": "14 pouces", "price_delta": 6.0}]
29 +
30 +
31 +PAYLOAD = {
32 + "menu_categories": [
33 + {"name": "Pizzas", "items": [
34 + {"name": "Margherita", "description": "Tomate, basilic",
35 + "price": 16.5, "size": "Regular",
36 + "modifier_categories": [10]},
37 + {"name": "Végé", "price": 18.0, "size": "14 pouces"},
38 + ]},
39 + {"name": "Pizzas", "items": [{"name": "Dup", "price": 1.0}]},
40 + {"name": "", "items": [{"name": "Orphelin", "price": 2.0}]},
41 + ],
42 + "modifier_categories": [
43 + {"id": 10, "name": "Croûte", "min_qty": 0, "modifiers": [1]}],
44 + "modifiers": [{"id": 1, "name": "Sans gluten", "price": 3.0}],
45 +}
46 +
47 +
48 +def test_build_menu():
49 + m = build_menu(PAYLOAD, "2026-08-25T00:00:00Z")
50 + assert m["price_context"] == "takeout" and m["price_source"] == "chownow"
51 + assert len(m["sections"]) == 1 # doublon + sans-nom écartés
52 + items = m["sections"][0]["items"]
53 + assert items[0]["name"] == "Margherita" # size Regular : pas suffixé
54 + assert items[0]["price"] == 16.50
55 + assert items[0]["options"][0]["group"] == "Croûte"
56 + assert items[0]["options"][0]["required"] is False
57 + assert items[1]["name"] == "Végé (14 pouces)" # size suffixé au nom
58 + assert build_menu({}, "x") is None
added tests/test_doordash.py +99 −0
@@ -0,0 +1,99 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_doordash.py
4 +# Desc: Connecteur DoorDash (enrichissement livraison) — parsing JSON-LD
5 +# (Restaurant + Menu, sections imbriquées, prix « $X.XX »), croisement
6 +# CONSERVATEUR verify_store (gps+nom / civique+ville+nom, jamais de
7 +# fusion hasardeuse), slugs candidats par préfixe.
8 +# ==============================================================================
9 +import json
10 +
11 +from restoka.connectors.doordash import (DoorDashConnector, build_menu,
12 + parse_ldjson, slugify, verify_store)
13 +
14 +LD_RESTO = {
15 + "@type": "Restaurant",
16 + "name": "Chez Test",
17 + "address": {"streetAddress": "123 Rue Principale",
18 + "addressLocality": "Montréal", "addressRegion": "QC"},
19 + "geo": {"latitude": "45.5200", "longitude": "-73.5800"},
20 + "servesCuisine": ["Pizza"],
21 + "priceRange": "$$",
22 + "aggregateRating": {"ratingValue": 4.6, "reviewCount": 210},
23 +}
24 +
25 +LD_MENU = {
26 + "@type": "Menu",
27 + "hasMenuSection": [[{
28 + "name": "Pizzas",
29 + "hasMenuItem": [
30 + {"name": "Margherita", "description": "Tomate, basilic",
31 + "offers": {"price": "$16.50"}},
32 + {"name": "Sans prix", "offers": {}},
33 + ],
34 + }, {
35 + "name": "Pizzas", # doublon : ignoré
36 + "hasMenuItem": [{"name": "Dup", "offers": {"price": "$1.00"}}],
37 + }]],
38 +}
39 +
40 +
41 +def _page(*blocks) -> str:
42 + return "".join(
43 + f'<script type="application/ld+json">{json.dumps(b)}</script>'
44 + for b in blocks)
45 +
46 +
47 +def test_parse_ldjson_et_build_menu():
48 + resto, menu = parse_ldjson(_page(LD_RESTO, LD_MENU))
49 + assert resto["name"] == "Chez Test"
50 + m = build_menu(menu, "2026-08-25T00:00:00Z")
51 + assert m["price_context"] == "delivery" and m["price_source"] == "doordash"
52 + assert len(m["sections"]) == 1 # doublon de section écarté
53 + items = m["sections"][0]["items"]
54 + assert items[0]["price"] == 16.50
55 + assert items[1]["price"] is None # prix absent toléré
56 + assert build_menu({}, "x") is None
57 +
58 +
59 +def test_parse_ldjson_page_sans_blocs():
60 + assert parse_ldjson("<html>rien</html>") == (None, None)
61 +
62 +
63 +def _row(**over):
64 + base = {"uid": "osm:1", "name": "Chez Test",
65 + "address": "123 Rue Principale", "city": "Montréal",
66 + "postal_code": "H2X1Y4", "phone": "",
67 + "lat": 45.5201, "lng": -73.5801}
68 + base.update(over)
69 + return base
70 +
71 +
72 +def test_verify_store_gps_nom():
73 + assert verify_store(_row(), LD_RESTO) == "gps+nom"
74 +
75 +
76 +def test_verify_store_civique_ville_nom():
77 + # GPS absent côté base : retombe sur civique + ville + nom
78 + assert verify_store(_row(lat=None, lng=None), LD_RESTO) \
79 + == "civique+ville+nom"
80 +
81 +
82 +def test_verify_store_rejets():
83 + # autre resto au même endroit : nom différent -> rejet
84 + autre = dict(LD_RESTO, name="Pizzeria Autre Chose Complètement")
85 + assert verify_store(_row(), autre) == ""
86 + # même nom mais loin (>120 m) et civique différent -> rejet
87 + loin = dict(LD_RESTO, geo={"latitude": "45.60", "longitude": "-73.70"},
88 + address={"streetAddress": "999 Autre Rue",
89 + "addressLocality": "Laval"})
90 + assert verify_store(_row(), loin) == ""
91 +
92 +
93 +def test_slugify_et_candidats():
94 + assert slugify("Café L'Époque & Fils") == "cafe-lepoque-fils"
95 + slugs = sorted(["chez-test-montreal-123", "chez-test-laval-456",
96 + "chez-testeur-999", "autre-resto-1"])
97 + cand = DoorDashConnector.candidates_for("Chez Test", slugs)
98 + assert set(cand) == {"chez-test-montreal-123", "chez-test-laval-456"}
99 + assert DoorDashConnector.candidates_for("Ab", slugs) == [] # trop court
added tests/test_gloriafood.py +46 −0
@@ -0,0 +1,46 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_gloriafood.py
4 +# Desc: Connecteur GloriaFood (Palier 1, takeout, enrichissement) —
5 +# build_menu tolérant : categories[].items[] avec variantes `sizes`
6 +# -> options Format en price_delta, payload liste toléré, doublons
7 +# de catégories écartés.
8 +# ==============================================================================
9 +from restoka.connectors.gloriafood import build_menu
10 +
11 +PAYLOAD = {"categories": [
12 + {"name": "Pizzas", "items": [
13 + {"name": "Margherita", "description": "Tomate, basilic",
14 + "price": 12.0,
15 + "sizes": [{"name": "Petite", "price": 12.0},
16 + {"name": "Grande", "price": 18.5},
17 + {"name": "", "price": 99.0}]}, # sans nom : écartée
18 + {"name": "Sans prix", "price": 0},
19 + ]},
20 + {"name": "Pizzas", "items": [{"name": "Dup", "price": 1.0}]},
21 + "pas-un-dict",
22 +]}
23 +
24 +
25 +def test_build_menu_sizes_en_options():
26 + m = build_menu(PAYLOAD, "2026-08-25T00:00:00Z")
27 + assert m["price_context"] == "takeout"
28 + assert m["price_source"] == "gloriafood"
29 + assert len(m["sections"]) == 1 # doublon écarté
30 + items = m["sections"][0]["items"]
31 + assert items[0]["price"] == 12.0
32 + fmt = items[0]["options"][0]
33 + assert fmt["group"] == "Format" and fmt["required"] is True
34 + assert fmt["choices"] == [
35 + {"name": "Petite", "price_delta": 0.0},
36 + {"name": "Grande", "price_delta": 6.5}]
37 + assert items[1]["price"] is None # prix 0 -> inconnu
38 + assert "options" not in items[1]
39 +
40 +
41 +def test_build_menu_payload_liste_et_vide():
42 + # payload directement en liste de catégories : toléré
43 + m = build_menu(PAYLOAD["categories"], "x")
44 + assert m and m["sections"]
45 + assert build_menu({}, "x") is None
46 + assert build_menu({"categories": []}, "x") is None
added tests/test_mtlalim.py +72 −0
@@ -0,0 +1,72 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_mtlalim.py
4 +# Desc: Connecteur mtl-alim (découverte Montréal) — whitelist des types
5 +# food-service, filtre GPS île de Montréal, services takeout-only,
6 +# fiches sans menu qui passent finalize().
7 +# ==============================================================================
8 +from restoka.connectors.mtlalim import MtlAlimConnector, TYPE_MAP
9 +
10 +
11 +def _row(**over):
12 + base = {
13 + "business_id": "12345",
14 + "name": "Chez Test",
15 + "type": "Restaurant",
16 + "statut": "Ouvert",
17 + "address": "123 Rue Principale",
18 + "city": "Montréal",
19 + "latitude": "45.52",
20 + "longitude": "-73.58",
21 + }
22 + base.update(over)
23 + return base
24 +
25 +
26 +def test_type_whitelist():
27 + c = MtlAlimConnector()
28 + assert c._to_restaurant(_row()) is not None
29 + # types hors périmètre (épicerie, distributrice…) : ignorés
30 + assert c._to_restaurant(_row(type="Épicerie")) is None
31 + assert c._to_restaurant(_row(type="Distributrice")) is None
32 + assert c._to_restaurant(_row(type="")) is None
33 +
34 +
35 +def test_mapping_et_services():
36 + c = MtlAlimConnector()
37 + r = c._to_restaurant(_row(type="Camion-cuisine"))
38 + assert r.establishment_type == "food-truck"
39 + assert r.services == ["takeout"] # pas de salle
40 + r = c._to_restaurant(_row(type="Restaurant"))
41 + assert r.establishment_type == "restaurant"
42 + assert "salle" in r.services
43 + r = c._to_restaurant(_row(type="Bar laitier saisonnier"))
44 + assert r.establishment_type == "casse-croute"
45 +
46 +
47 +def test_gps_hors_ile_rejete():
48 + c = MtlAlimConnector()
49 + r = c._to_restaurant(_row(latitude="0", longitude="0"))
50 + assert r.lat is None and r.lng is None # aberrant -> géocodage aval
51 + r = c._to_restaurant(_row(latitude="46.81", longitude="-71.21"))
52 + assert r.lat is None # Québec ≠ île de Montréal
53 + r = c._to_restaurant(_row())
54 + assert r.lat == 45.52 and r.lng == -73.58
55 +
56 +
57 +def test_champs_obligatoires_et_finalize():
58 + c = MtlAlimConnector()
59 + assert c._to_restaurant(_row(name="")) is None
60 + assert c._to_restaurant(_row(business_id="")) is None
61 + r = c._to_restaurant(_row())
62 + r.finalize()
63 + assert r.uid == "mtl-alim:12345"
64 + assert r.region == "Montréal"
65 + assert r.menu is None # découverte : pas de menu
66 +
67 +
68 +def test_type_map_couvre_le_perimetre():
69 + # garde-fou : la whitelist reste food-service seulement
70 + assert set(TYPE_MAP.values()) <= {
71 + "restaurant", "fast-food", "casse-croute", "bar",
72 + "boulangerie-patisserie", "traiteur", "food-truck"}
added tests/test_siteresto_platforms.py +55 −0
@@ -0,0 +1,55 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_siteresto_platforms.py
4 +# Desc: site-resto, hub de découverte multi-plateformes — détection des
5 +# widgets GloriaFood (data-glf-*), ChowNow, Square Online et Clover
6 +# dans le HTML, consignation dans data/<plateforme>-discovered.json
7 +# avec l'uid du resto porteur (dédup par key).
8 +# ==============================================================================
9 +import json
10 +
11 +import restoka.connectors.siteresto as sr
12 +
13 +HTML = """
14 +<html><body>
15 +<span data-glf-cuid="a1b2c3d4-1111-2222-3333-444455556666"
16 + data-glf-ruid="f9e8d7c6-9999-8888-7777-666655554444"></span>
17 +<a href="https://direct.chownow.com/order/2717/locations">Commander</a>
18 +<a href="https://mon-resto.square.site/s/order">Menu</a>
19 +<a href="https://www.clover.com/online-ordering/mon-resto-mtl">Clover</a>
20 +</body></html>
21 +"""
22 +
23 +
24 +def test_discover_platforms():
25 + c = sr.SiteRestoConnector()
26 + ps = {p["name"]: p for p in c._discover_platforms(HTML)}
27 + assert set(ps) == {"gloriafood", "chownow", "square", "clover"}
28 + assert ps["gloriafood"]["key"] == "f9e8d7c6-9999-8888-7777-666655554444"
29 + assert ps["gloriafood"]["cuid"] == "a1b2c3d4-1111-2222-3333-444455556666"
30 + assert ps["chownow"]["key"] == "2717"
31 + assert ps["square"]["key"] == "mon-resto.square.site"
32 + assert ps["clover"]["key"] == "mon-resto-mtl"
33 + assert c._discover_platforms("<html>rien</html>") == []
34 +
35 +
36 +def test_menu_platforms_couvertes_par_connecteurs():
37 + # les plateformes « bloquantes » ont toutes un connecteur dédié
38 + assert set(sr._MENU_PLATFORMS) == {"gloriafood", "chownow", "square"}
39 +
40 +
41 +def test_record_platform(tmp_path, monkeypatch):
42 + monkeypatch.setattr(sr, "DATA_DIR", tmp_path)
43 + c = sr.SiteRestoConnector()
44 + p = {"name": "gloriafood", "key": "ruid-1", "cuid": "cuid-1"}
45 + c._record_platform(p, "Chez Test", "cheztest.ca", "osm:123")
46 + c._record_platform(p, "Chez Test", "cheztest.ca", "osm:123") # dédup
47 + c._record_platform({"name": "chownow", "key": "2717"},
48 + "Autre", "autre.ca", "osm:456")
49 + gf = json.loads((tmp_path / "gloriafood-discovered.json").read_text())
50 + assert len(gf["integrations"]) == 1
51 + integ = gf["integrations"][0]
52 + assert integ["key"] == "ruid-1" and integ["uid"] == "osm:123"
53 + assert integ["cuid"] == "cuid-1"
54 + cn = json.loads((tmp_path / "chownow-discovered.json").read_text())
55 + assert cn["integrations"][0]["key"] == "2717"
added tests/test_skip.py +86 −0
@@ -0,0 +1,86 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_skip.py
4 +# Desc: Connecteur SkipTheDishes (enrichissement livraison) — décodage
5 +# __NEXT_DATA__, téléphone dans les disclaimers, locationDetails
6 +# « Ville, PROV, POSTAL, CAN », menu en cents -> dollars, croisement
7 +# CONSERVATEUR verify_partner (rejet hors-QC, téléphone ou
8 +# postal+civique seulement).
9 +# ==============================================================================
10 +import json
11 +
12 +from restoka.connectors.skip import (build_menu, decode_next_data,
13 + parse_location_details, partner_payload,
14 + partner_phone, verify_partner)
15 +
16 +PARTNER = {
17 + "name": "Chez Test",
18 + "location": "123 Rue Principale",
19 + "locationDetails": "Montréal, QC, H2X 1Y4, CAN",
20 + "disclaimerModal": {"disclaimers": [
21 + {"text": "Pour toute question, appelez le (514) 555-1234."},
22 + ]},
23 +}
24 +
25 +MENU_V2 = {"categories": [
26 + {"name": "Poutines", "menuItems": [
27 + {"name": "Classique", "description": "Frites, fromage, sauce",
28 + "subtotal": 1250},
29 + {"name": "Sans prix", "subtotal": 0},
30 + ]},
31 + {"name": "", "menuItems": [{"name": "Orphelin", "subtotal": 100}]},
32 +]}
33 +
34 +
35 +def test_decode_next_data_et_payload():
36 + state = {"props": {"pageProps": {"partner": PARTNER, "menuV2": MENU_V2}}}
37 + html = ('<script id="__NEXT_DATA__" type="application/json">'
38 + + json.dumps(state) + "</script>")
39 + decoded = decode_next_data(html)
40 + partner, menu = partner_payload(decoded)
41 + assert partner["name"] == "Chez Test"
42 + assert menu["categories"]
43 + assert decode_next_data("<html></html>") is None
44 +
45 +
46 +def test_partner_phone_et_location_details():
47 + assert partner_phone(PARTNER) == "+15145551234"
48 + assert parse_location_details("Windsor, ON, N9E 1S3, CAN") \
49 + == ("Windsor", "ON", "N9E1S3")
50 + assert parse_location_details("n'importe quoi") == ("", "", "")
51 +
52 +
53 +def test_build_menu_cents_vers_dollars():
54 + m = build_menu(MENU_V2, "2026-08-25T00:00:00Z")
55 + assert m["price_context"] == "delivery" and m["price_source"] == "skip"
56 + assert len(m["sections"]) == 1 # section sans nom écartée
57 + items = m["sections"][0]["items"]
58 + assert items[0]["price"] == 12.50 # 1250 cents
59 + assert items[1]["price"] is None # subtotal 0 -> prix inconnu
60 + assert build_menu({}, "x") is None
61 +
62 +
63 +def _row(**over):
64 + base = {"uid": "osm:1", "name": "Chez Test",
65 + "address": "123 Rue Principale", "city": "Montréal",
66 + "postal_code": "H2X 1Y4", "phone": "514-555-1234"}
67 + base.update(over)
68 + return base
69 +
70 +
71 +def test_verify_partner_telephone():
72 + assert verify_partner(_row(), PARTNER) == "telephone"
73 +
74 +
75 +def test_verify_partner_postal_civique():
76 + assert verify_partner(_row(phone=""), PARTNER) == "postal+civique"
77 +
78 +
79 +def test_verify_partner_rejets():
80 + # hors Québec : rejet immédiat même si le téléphone matche
81 + hors_qc = dict(PARTNER, locationDetails="Windsor, ON, N9E 1S3, CAN")
82 + assert verify_partner(_row(), hors_qc) == ""
83 + # ni téléphone ni postal+civique : rejet
84 + assert verify_partner(
85 + _row(phone="", postal_code="G1A 1A1", address="999 Autre Rue"),
86 + PARTNER) == ""
added tests/test_tastet.py +56 −0
@@ -0,0 +1,56 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_tastet.py
4 +# Desc: Connecteur Tastet (découverte éditoriale) — parse_maps_query
5 +# (le « + » est le SÉPARATEUR nom/adresse, ville par suffixe
6 +# VILLE_REGION, code postal en fin) et parse_review (lien Maps,
7 +# tel:, og:image ; pages sans fiche resto -> None).
8 +# ==============================================================================
9 +from restoka.connectors.tastet import parse_maps_query, parse_review
10 +
11 +
12 +def test_parse_maps_query_complet():
13 + q = "Cantine%20Dukana+1556 Avenue Laurier Est Montréal H2J 1H9"
14 + name, addr, city, postal = parse_maps_query(q)
15 + assert name == "Cantine Dukana"
16 + assert addr == "1556 Avenue Laurier Est"
17 + assert city == "Montréal"
18 + assert postal == "H2J1H9"
19 +
20 +
21 +def test_parse_maps_query_ville_multi_tokens():
22 + q = "Le Resto+12 Rue Racine Saguenay G7H 1Z5"
23 + name, addr, city, postal = parse_maps_query(q)
24 + assert (name, city, postal) == ("Le Resto", "Saguenay", "G7H1Z5")
25 + assert addr == "12 Rue Racine"
26 +
27 +
28 +def test_parse_maps_query_sans_postal_ni_ville():
29 + name, addr, city, postal = parse_maps_query("Chez Nous+99 Rue Untel")
30 + assert name == "Chez Nous" and addr == "99 Rue Untel"
31 + assert city == "" and postal == ""
32 +
33 +
34 +PAGE = """
35 +<html><head>
36 +<meta property="og:image" content="https://tastet.ca/img/dukana.jpg&#038;x=1">
37 +</head><body>
38 +<a href="https://www.google.com/maps/search/?api=1&amp;query=Cantine%20Dukana+1556 Avenue Laurier Est Montréal H2J 1H9">Maps</a>
39 +<a href="tel:+15144190669">Appeler</a>
40 +</body></html>
41 +"""
42 +
43 +
44 +def test_parse_review():
45 + p = parse_review(PAGE)
46 + assert p["name"] == "Cantine Dukana"
47 + assert p["city"] == "Montréal"
48 + assert p["postal_code"] == "H2J1H9"
49 + assert p["phone"] == "+15144190669"
50 + assert p["image"].startswith("https://tastet.ca/img/")
51 +
52 +
53 +def test_parse_review_sans_fiche():
54 + # page éditoriale sans lien Google Maps : pas une fiche resto
55 + assert parse_review("<html><body>Top 10 des terrasses</body></html>") \
56 + is None
57