SPB Git

spb/resto-ka

Public

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

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 (Aug 23, 2026) parent 04e3a20 Browse files

4 changed files +412 −1

modified .env.example +8 −0
@@ -22,6 +22,14 @@ YELP_API_KEY=
22 # Budget de requêtes Yelp par passage (quota quotidien 500) 22 # Budget de requêtes Yelp par passage (quota quotidien 500)
23 # YELP_BUDGET=450 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 # SSO Groupe KA (« Se connecter avec KA ») + sessions 33 # SSO Groupe KA (« Se connecter avec KA ») + sessions
26 KA_SSO_SECRET= 34 KA_SSO_SECRET=
27 SESSION_SECRET= 35 SESSION_SECRET=
modified data/sources.json +14 −1
@@ -296,6 +296,19 @@
296 "cadence": "quotidien par petits lots (80 pages), re-vérification 30 jours", 296 "cadence": "quotidien par petits lots (80 pages), re-vérification 30 jours",
297 "status": "actif" 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 "id": "ubereats", 313 "id": "ubereats",
301 "name": "Uber Eats (menus livraison + notes)", 314 "name": "Uber Eats (menus livraison + notes)",
@@ -323,4 +336,4 @@
323 "status": "actif" 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
Menu

Theme