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%

connecteur site-finder : découverte des sites web des restos OSM (Serper /maps)

Enrichissement (aucune fiche, aucun prix) : retrouve le site web officiel
des ~10 600 restos OSM sans site connu via leur fiche Google Maps (API
Serper, ll centré sur les coordonnées OSM). Croisement conservateur nom +
GPS <= 400 m, ambigu ignoré ; domaines sociaux/agrégateurs/livraison
rejetés, réservation -> details.reservation_url. Remplit website (+ phone
si vide) et débloque le pipeline site-resto (menus maison dine-in).
Tests hors ligne sur payloads réels (7 passent) ; .env.example
(SERPER_API_KEY, SITE_FINDER_BUDGET) et data/sources.json documentés.

Travail réalisé par une mission agent KA, commité a posteriori.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 04e3a20

4 changed files +412 −1

modified .env.example +8 −0
@@ -22,6 +22,14 @@ YELP_API_KEY=
22 22 # Budget de requêtes Yelp par passage (quota quotidien 500)
23 23 # YELP_BUDGET=450
24 24
25 +# Serper (google.serper.dev) — API Google Maps, découverte des SITES WEB des
26 +# restos OSM qui n'en publient pas (connecteur site-finder, 3 crédits/requête).
27 +# Sans clé, la source est simplement sautée (SkipSource).
28 +SERPER_API_KEY=
29 +
30 +# Budget de requêtes Serper par passage (défaut 3000 — ~10 600 restos à couvrir)
31 +# SITE_FINDER_BUDGET=3000
32 +
25 33 # SSO Groupe KA (« Se connecter avec KA ») + sessions
26 34 KA_SSO_SECRET=
27 35 SESSION_SECRET=
modified data/sources.json +14 −1
@@ -296,6 +296,19 @@
296 296 "cadence": "quotidien par petits lots (80 pages), re-vérification 30 jours",
297 297 "status": "actif"
298 298 },
299 + {
300 + "id": "site-finder",
301 + "name": "Découverte des sites web (fiches Google Maps)",
302 + "url": "https://serper.dev",
303 + "platform": "serper",
304 + "connector": "site-finder",
305 + "tier": 4,
306 + "price_context": "aucun",
307 + "extraction": "Une requête API Serper /maps par resto OSM sans site web (q = nom, ll = @lat,lng,15z centrée sur la fiche — décisif pour les chaînes multi-succursales) : la fiche Google Maps expose website, téléphone, GPS et cid. Croisement conservateur : nom similaire + distance GPS <= 400 m, ambigu = ignoré. Domaines sociaux/agrégateurs/livraison REJETÉS (un lien Uber Eats n'est pas le site du resto), plateformes de réservation -> details.reservation_url, sinon -> colonne website (+ phone si vide) et details.site_finder (ré-appliqué si un sync OSM ré-écrase la colonne). N'émet aucune fiche : il DÉBLOQUE le connecteur site-resto (menus maison dine-in) pour les ~10 600 restos OSM sans site connu. Budget SITE_FINDER_BUDGET (3000 requêtes/cycle), échecs re-sondés 180 j.",
308 + "acces_legal": "API commerciale Serper (compte payant, quota contractuel) — aucune requête vers Google ni scraping direct. Seul le site web officiel et le téléphone publiés par le commerce lui-même sont conservés ; le crawl du menu se fait ensuite sur le site du resto (première partie, CLAUDE.md §15) par site-resto.",
309 + "cadence": "hebdomadaire par lots (3000 requêtes), échecs re-sondés 180 jours",
310 + "status": "actif"
311 + },
299 312 {
300 313 "id": "ubereats",
301 314 "name": "Uber Eats (menus livraison + notes)",
@@ -323,4 +336,4 @@
323 336 "status": "actif"
324 337 }
325 338 ]
326 }
339 +}
\ No newline at end of file
added restoka/connectors/sitefinder.py +278 −0
@@ -0,0 +1,278 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/connectors/sitefinder.py
4 +# Desc: Connecteur d'ENRICHISSEMENT « site-finder » — découvre le SITE WEB
5 +# officiel des restaurants OSM qui n'en publient pas, via l'API Serper
6 +# (google.serper.dev/maps, fiches Google Maps). Mode d'extraction :
7 +# API JSON commerciale (aucun scraping). Contexte de prix : AUCUN —
8 +# ce connecteur n'émet ni fiche ni prix, il remplit la colonne
9 +# `website` (et `phone` si vide) des fiches OSM existantes.
10 +#
11 +# Pourquoi : le connecteur `site-resto` (Palier 3, menus maison à prix
12 +# dine-in) ne peut crawler QUE les restos dont OSM connaît le site web
13 +# (~3 400 sur ~14 000). Les ~10 600 restants ont pourtant très souvent
14 +# un site : ce connecteur le retrouve via la fiche Google Maps du
15 +# commerce et débloque ainsi le pipeline menus maison à l'échelle.
16 +#
17 +# Pipeline par resto (budget SITE_FINDER_BUDGET, incrémental) :
18 +# 1. Requête /maps : q = nom du resto, ll = @lat,lng,15z (centrée sur
19 +# les coordonnées OSM — décisif pour les chaînes multi-succursales).
20 +# 2. Croisement CONSERVATEUR : nom similaire (norm_name/_name_similar
21 +# d'inspections.py) ET distance GPS <= 400 m ; ambigu (2 fiches
22 +# distinctes qui matchent) -> aucun enrichissement.
23 +# 3. Classification du site : domaines sociaux/agrégateurs/livraison
24 +# REJETÉS (un lien Uber Eats n'est pas « le site du resto » — et
25 +# produirait des prix delivery mal étiquetés dine-in en aval) ;
26 +# plateformes de réservation -> details.reservation_url ;
27 +# sinon -> colonne `website` + details.site_finder.
28 +# 4. `phone` (E.164) complété si la fiche OSM n'en a pas (COALESCE).
29 +#
30 +# Durabilité : sync_source ré-écrase `website` quand la fiche OSM
31 +# change -> le site découvert est AUSSI consigné dans
32 +# details.site_finder.website et RÉ-APPLIQUÉ à chaque passage (0 requête
33 +# API). Les échecs sont horodatés et re-sondés après REFRESH_DAYS.
34 +# ==============================================================================
35 +from __future__ import annotations
36 +
37 +import datetime
38 +import json
39 +import math
40 +import os
41 +import re
42 +import sys
43 +import urllib.parse
44 +
45 +from ..inspections import _name_similar, norm_name
46 +from ..regions import strip_accents
47 +from ..schema import Restaurant
48 +from .base import BaseConnector, SkipSource
49 +from .siteresto import _RESERVATION_DOMAINS, _domain
50 +
51 +SERPER_MAPS_URL = "https://google.serper.dev/maps"
52 +MAX_BUDGET = int(os.environ.get("SITE_FINDER_BUDGET", "3000")) # requêtes/cycle
53 +REFRESH_DAYS = 180 # re-sonde des échecs (un site apparaît rarement)
54 +MAX_CONSECUTIVE_FAILURES = 5 # API en panne / quota épuisé -> arrêt du cycle
55 +MAX_DIST_M = 400.0 # rayon de croisement fiche OSM <-> fiche Google
56 +
57 +# Domaines qui ne sont PAS « le site du resto » : réseaux sociaux, agrégateurs,
58 +# annuaires et plateformes de LIVRAISON (leurs menus sont en prix delivery
59 +# majorés — les laisser passer contaminerait le pipeline dine-in de site-resto).
60 +_BAD_DOMAINS = (
61 + "facebook.com", "instagram.com", "linktr.ee", "linkin.bio", "tiktok.com",
62 + "ubereats.com", "doordash.com", "order.online", "skipthedishes.com",
63 + "just-eat.ca", "restoloco.com", "restoloco.ca",
64 + "yelp.com", "yelp.ca", "tripadvisor.com", "tripadvisor.ca", "tripadvisor.fr",
65 + "google.com", "goo.gl", "business.site", "restomontreal.ca", "tastet.ca",
66 + "pagesjaunes.ca", "yellowpages.ca", "411.ca", "wikipedia.org",
67 +)
68 +
69 +# paramètres de pistage à retirer des URLs (fiches Google truffées d'utm_*)
70 +_TRACKING_RE = re.compile(r"^(utm_|fbclid$|gclid$|mc_cid$|mc_eid$|ref$)", re.I)
71 +
72 +
73 +def clean_website(url: str) -> str:
74 + """URL de site web nettoyée (schéma http(s), sans fragment ni utm_*) ou ''."""
75 + try:
76 + p = urllib.parse.urlsplit((url or "").strip())
77 + except ValueError:
78 + return ""
79 + if p.scheme not in ("http", "https") or not p.netloc:
80 + return ""
81 + query = urllib.parse.urlencode(
82 + [(k, v) for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True)
83 + if not _TRACKING_RE.match(k)])
84 + return urllib.parse.urlunsplit((p.scheme, p.netloc, p.path, query, ""))
85 +
86 +
87 +def classify_website(url: str) -> tuple[str, str]:
88 + """('site'|'reservation'|'rejected', url_nettoyée) pour le champ website
89 + d'une fiche Google Maps."""
90 + cleaned = clean_website(url)
91 + if not cleaned:
92 + return "rejected", ""
93 + dom = _domain(cleaned)
94 + if any(dom == d or dom.endswith("." + d) for d in _RESERVATION_DOMAINS):
95 + return "reservation", cleaned
96 + if any(dom == d or dom.endswith("." + d) for d in _BAD_DOMAINS):
97 + return "rejected", cleaned
98 + return "site", cleaned
99 +
100 +
101 +def _dist_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
102 + """Distance équirectangulaire en mètres (suffisant à l'échelle d'un pâté)."""
103 + dx = math.radians(lng2 - lng1) * math.cos(math.radians((lat1 + lat2) / 2))
104 + dy = math.radians(lat2 - lat1)
105 + return math.hypot(dx, dy) * 6_371_000.0
106 +
107 +
108 +def _names_match(rname: str, cname: str, addr_tokens: set[str]) -> bool:
109 + """Nom similaire (helper conservateur d'inspections.py) OU quasi-identique
110 + à une coquille près (« Pizzaria Amos » vs « Pizzéria Amos Inc ») — le repli
111 + flou est sûr ici car la distance GPS <= 400 m est déjà exigée."""
112 + if _name_similar(rname, cname, addr_tokens):
113 + return True
114 + if not rname or not cname:
115 + return False
116 + import difflib
117 + return difflib.SequenceMatcher(None, rname, cname).ratio() >= 0.85
118 +
119 +
120 +def match_place(row, places: list[dict]) -> dict | None:
121 + """Croisement CONSERVATEUR d'un resto OSM avec les fiches Google Maps :
122 + nom similaire ET distance GPS <= 400 m. Deux fiches distinctes (cid) qui
123 + matchent = ambigu -> None (jamais deviner entre deux commerces)."""
124 + rname = norm_name(row["name"])
125 + addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents(
126 + f"{row['address'] or ''} {row['city'] or ''}".lower())).split())
127 + hits = []
128 + for p in places or []:
129 + lat, lng = p.get("latitude"), p.get("longitude")
130 + if not isinstance(lat, (int, float)) or not isinstance(lng, (int, float)):
131 + continue
132 + d = _dist_m(row["lat"], row["lng"], lat, lng)
133 + if d > MAX_DIST_M:
134 + continue
135 + if not _names_match(rname, norm_name(p.get("title") or ""), addr_tokens):
136 + continue
137 + hits.append((d, p))
138 + cids = {str(p.get("cid") or id(p)) for _, p in hits}
139 + if len(cids) != 1:
140 + return None # rien, ou ambigu : on s'abstient
141 + return min(hits, key=lambda t: t[0])[1]
142 +
143 +
144 +class SiteFinderConnector(BaseConnector):
145 + source_id = "site-finder"
146 + request_delay = 0.15 # API commerciale (quota), pas un site à ménager
147 + timeout = 30
148 + use_detail_cache = False
149 + enrichment_only = True # n'émet aucune fiche (ingest.run)
150 +
151 + def _now(self) -> str:
152 + return datetime.datetime.now(datetime.timezone.utc) \
153 + .strftime("%Y-%m-%dT%H:%M:%SZ")
154 +
155 + def _fresh(self, stamp: str, now: float, stale_s: float) -> bool:
156 + try:
157 + ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \
158 + .replace(tzinfo=datetime.timezone.utc).timestamp()
159 + return ts > now - stale_s
160 + except (ValueError, TypeError):
161 + return False
162 +
163 + def _reapply(self, con) -> int:
164 + """Ré-applique les sites déjà découverts dont la colonne `website` a
165 + été ré-écrasée par un sync OSM (0 requête API — details fait foi)."""
166 + restored = 0
167 + for row in con.execute(
168 + "SELECT uid, json_extract(details,'$.site_finder.website') AS w"
169 + " FROM restaurants WHERE source='osm' AND active=1 AND website=''"
170 + " AND json_extract(details,'$.site_finder.website') IS NOT NULL"
171 + ).fetchall():
172 + con.execute("UPDATE restaurants SET website=? WHERE uid=?",
173 + (row["w"], row["uid"]))
174 + restored += 1
175 + return restored
176 +
177 + def _lookup(self, name: str, lat: float, lng: float) -> list[dict]:
178 + """Fiches Google Maps autour des coordonnées OSM (API Serper /maps)."""
179 + key = os.environ["SERPER_API_KEY"]
180 + resp = self.post(SERPER_MAPS_URL,
181 + json={"q": name[:96], "ll": f"@{lat},{lng},15z",
182 + "gl": "ca", "hl": "fr"},
183 + headers={"X-API-KEY": key,
184 + "Content-Type": "application/json"})
185 + return resp.json().get("places") or []
186 +
187 + def fetch(self) -> list[Restaurant]:
188 + if not os.environ.get("SERPER_API_KEY"):
189 + raise SkipSource("SERPER_API_KEY manquant (.env) — découverte de "
190 + "sites web impossible sans l'API Serper")
191 + import time as _time
192 + from .. import db
193 + from ..normalize import normalize_phone
194 + con = db.connect()
195 + now = _time.time()
196 + stale_s = REFRESH_DAYS * 86400.0
197 + budget = MAX_BUDGET
198 + restored = self._reapply(con)
199 + con.commit()
200 + enriched = reservations = phones = misses = failures_row = 0
201 + rows = con.execute(
202 + "SELECT uid, name, address, city, lat, lng, phone, details"
203 + " FROM restaurants WHERE source='osm' AND active=1"
204 + " AND dup_of IS NULL AND website='' AND name<>''"
205 + " AND lat IS NOT NULL AND lng IS NOT NULL"
206 + # indépendants d'abord : c'est là que vivent les menus maison que
207 + # site-resto pourra capter (les chaînes ont leurs propres paliers)
208 + " ORDER BY chain IS NULL DESC, region<>'' DESC, city"
209 + ).fetchall()
210 + for row in rows:
211 + if budget <= 0:
212 + break
213 + if failures_row >= MAX_CONSECUTIVE_FAILURES:
214 + print("[resto-ka] site-finder: API Serper en échec "
215 + f"{failures_row} fois de suite — arrêt du cycle",
216 + file=sys.stderr)
217 + break
218 + try:
219 + details = json.loads(row["details"] or "{}")
220 + except ValueError:
221 + details = {}
222 + probe = details.get("site_finder") or {}
223 + if probe.get("website"):
224 + continue # déjà trouvé (ré-appliqué plus haut)
225 + if self._fresh(probe.get("fetched_at", ""), now, stale_s):
226 + continue # sondé récemment : re-visite dans 180 j
227 + budget -= 1
228 + try:
229 + places = self._lookup(row["name"], row["lat"], row["lng"])
230 + except Exception as exc:
231 + failures_row += 1
232 + print(f"[resto-ka] site-finder: {row['uid']} erreur: {exc}",
233 + file=sys.stderr)
234 + continue
235 + failures_row = 0
236 + hit = match_place(row, places)
237 + stamp = self._now()
238 + if hit is None:
239 + db.merge_details(con, row["uid"], {"site_finder": {
240 + "miss": "introuvable ou ambigu", "fetched_at": stamp}})
241 + misses += 1
242 + con.commit()
243 + continue
244 + # téléphone de la fiche Google : COALESCE si la fiche OSM n'en a pas
245 + phone = normalize_phone(hit.get("phoneNumber") or "")
246 + if phone and not (row["phone"] or "").strip():
247 + db.enrich_contact(con, row["uid"], phone=phone)
248 + phones += 1
249 + kind, url = classify_website(hit.get("website") or "")
250 + if kind == "site":
251 + con.execute("UPDATE restaurants SET website=? WHERE uid=?",
252 + (url, row["uid"]))
253 + db.merge_details(con, row["uid"], {"site_finder": {
254 + "website": url, "cid": str(hit.get("cid") or ""),
255 + "title": hit.get("title") or "", "fetched_at": stamp}})
256 + enriched += 1
257 + elif kind == "reservation":
258 + db.enrich_contact(con, row["uid"],
259 + details={"reservation_url": url})
260 + db.merge_details(con, row["uid"], {"site_finder": {
261 + "miss": "site = plateforme de réservation",
262 + "cid": str(hit.get("cid") or ""), "fetched_at": stamp}})
263 + reservations += 1
264 + else:
265 + db.merge_details(con, row["uid"], {"site_finder": {
266 + "miss": "fiche Google sans site web (ou domaine rejeté)",
267 + "cid": str(hit.get("cid") or ""), "fetched_at": stamp}})
268 + misses += 1
269 + con.commit()
270 + con.commit()
271 + con.close()
272 + self.enriched_count = enriched
273 + self.enrich_message = (
274 + f"{enriched} site(s) web découverts, {restored} ré-appliqué(s), "
275 + f"{phones} téléphone(s), {reservations} lien(s) de réservation, "
276 + f"{misses} sans site, budget restant {max(budget, 0)} requête(s)")
277 + print(f"[resto-ka] site-finder: {self.enrich_message}")
278 + return []
added tests/test_sitefinder.py +112 −0
@@ -0,0 +1,112 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_sitefinder.py
4 +# Desc: Connecteur site-finder (découverte des sites web via l'API Serper
5 +# /maps) — croisement HORS LIGNE de fiches Google Maps réelles
6 +# (payloads captés le 2026-08-23) : nom + distance GPS <= 400 m,
7 +# ambigu = ignoré ; nettoyage/classification des URLs (utm_* retirés,
8 +# réseaux sociaux et plateformes de livraison rejetés, réservation
9 +# séparée).
10 +# ==============================================================================
11 +from restoka.connectors.sitefinder import (classify_website, clean_website,
12 + match_place, _dist_m)
13 +
14 +# Réponses RÉELLES de l'API Serper /maps (2026-08-23), abrégées aux champs lus.
15 +_AMOS_PLACES = [
16 + {"title": "Pizzéria Amos Inc",
17 + "address": "341 6e Rue O, Amos, QC J9T 2V5, Canada",
18 + "latitude": 48.568842, "longitude": -78.1315157,
19 + "website": "http://pizzeriaamos.com/",
20 + "phoneNumber": "+1 819-732-4398", "cid": "9963617425105215735"},
21 + {"title": "Pizza Salvatoré", # à ~350 m : nom différent -> jamais croisé
22 + "address": "12 Rte 111 Ouest, Amos, Quebec J9T 2X6, Canada",
23 + "latitude": 48.5720841, "longitude": -78.1315975,
24 + "website": "https://salvatore.com/fr/restaurant/sal-amos"
25 + "?utm_id=92&utm_source=google&utm_medium=business-profile",
26 + "phoneNumber": "+1 819-834-5555", "cid": "15827771869290584642"},
27 + {"title": "Mia Pasta - Amos", # à ~1,4 km : hors rayon
28 + "address": "82 1re Avenue E, Amos, QC J9T 2E7, Canada",
29 + "latitude": 48.571788, "longitude": -78.11258169999999,
30 + "website": "https://commandeenligne.miapasta.com/commandeenligne/",
31 + "phoneNumber": "+1 819-727-2021", "cid": "18357615835954532349"},
32 +]
33 +
34 +_TIMS_PLACES = [
35 + {"title": "Tim Hortons",
36 + "address": "208 Bd Saint-Raymond, Gatineau, QC J9A 3H6, Canada",
37 + "latitude": 45.4367899, "longitude": -75.76653309999999,
38 + "website": "https://locations.timhortons.ca/en/qc/gatineau/"
39 + "208-boul-st-raymond",
40 + "phoneNumber": "+1 819-778-6481", "cid": "5309096764678078556"},
41 + {"title": "Tim Hortons", # autre succursale à ~4 km : hors rayon
42 + "address": "200 Prom. du Portage, Gatineau, QC J8X 4B7, Canada",
43 + "latitude": 45.4267914, "longitude": -75.71292389999999,
44 + "website": "https://locations.timhortons.ca/en/qc/gatineau/"
45 + "200-promenade-du-portage",
46 + "phoneNumber": "+1 613-293-7168", "cid": "591025576747174253"},
47 +]
48 +
49 +
50 +def _row(name, address, city, lat, lng):
51 + return {"name": name, "address": address, "city": city,
52 + "lat": lat, "lng": lng, "phone": ""}
53 +
54 +
55 +def test_match_typo_and_legal_suffix():
56 + # fiche OSM réelle « Pizzaria Amos » (coquille) vs « Pizzéria Amos Inc »
57 + row = _row("Pizzaria Amos", "341 6e Rue Ouest", "Amos",
58 + 48.5689429, -78.1316743)
59 + hit = match_place(row, _AMOS_PLACES)
60 + assert hit is not None and hit["cid"] == "9963617425105215735"
61 +
62 +
63 +def test_match_chain_disambiguated_by_distance():
64 + # deux Tim Hortons à Gatineau : seul celui à <400 m est croisé
65 + row = _row("Tim Hortons", "208 Boulevard Saint-Raymond", "Gatineau",
66 + 45.4367943, -75.7665445)
67 + hit = match_place(row, _TIMS_PLACES)
68 + assert hit is not None and hit["cid"] == "5309096764678078556"
69 +
70 +
71 +def test_no_match_far_or_different_name():
72 + # un resto au nom sans rapport, même s'il est proche, n'est pas croisé
73 + row = _row("Sushi Miko", "339 6e Rue Ouest", "Amos",
74 + 48.5689429, -78.1316743)
75 + assert match_place(row, _AMOS_PLACES) is None
76 +
77 +
78 +def test_ambiguous_two_distinct_places():
79 + # deux fiches distinctes (cid) qui matchent toutes deux -> on s'abstient
80 + near = dict(_TIMS_PLACES[1], latitude=45.4368, longitude=-75.7666)
81 + row = _row("Tim Hortons", "208 Boulevard Saint-Raymond", "Gatineau",
82 + 45.4367943, -75.7665445)
83 + assert match_place(row, [_TIMS_PLACES[0], near]) is None
84 +
85 +
86 +def test_dist_m_scale():
87 + assert _dist_m(45.5, -73.5, 45.5, -73.5) == 0.0
88 + assert 340 < _dist_m(48.5689429, -78.1316743,
89 + 48.5720841, -78.1315975) < 400
90 +
91 +
92 +def test_clean_website_strips_tracking():
93 + url = _AMOS_PLACES[1]["website"]
94 + assert clean_website(url) == "https://salvatore.com/fr/restaurant/sal-amos"
95 + assert clean_website("javascript:alert(1)") == ""
96 + assert clean_website("") == ""
97 +
98 +
99 +def test_classify_website():
100 + assert classify_website("http://pizzeriaamos.com/") == \
101 + ("site", "http://pizzeriaamos.com/")
102 + # une page Facebook n'est pas « le site du resto »
103 + kind, _ = classify_website("https://www.facebook.com/cafeologueduquartier/")
104 + assert kind == "rejected"
105 + # un lien de livraison contaminerait le pipeline dine-in de site-resto
106 + assert classify_website("https://www.ubereats.com/ca/store/x")[0] == "rejected"
107 + # plateforme de réservation -> details.reservation_url
108 + assert classify_website("https://www.opentable.ca/r/toque-montreal")[0] == \
109 + "reservation"
110 + # la plateforme de commande en ligne du resto lui-même reste un site valide
111 + assert classify_website(
112 + "https://commandeenligne.miapasta.com/commandeenligne/")[0] == "site"
113