Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/connectors/sitefinder.py4# Desc: Connecteur d'ENRICHISSEMENT « site-finder » — découvre le SITE WEB5# officiel des restaurants OSM qui n'en publient pas, via l'API Serper6# (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 colonne9# `website` (et `phone` si vide) des fiches OSM existantes.10#11# Pourquoi : le connecteur `site-resto` (Palier 3, menus maison à prix12# dine-in) ne peut crawler QUE les restos dont OSM connaît le site web13# (~3 400 sur ~14 000). Les ~10 600 restants ont pourtant très souvent14# un site : ce connecteur le retrouve via la fiche Google Maps du15# 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 sur19# les coordonnées OSM — décisif pour les chaînes multi-succursales).20# 2. Croisement CONSERVATEUR : nom similaire (norm_name/_name_similar21# d'inspections.py) ET distance GPS <= 400 m ; ambigu (2 fiches22# distinctes qui matchent) -> aucun enrichissement.23# 3. Classification du site : domaines sociaux/agrégateurs/livraison24# REJETÉS (un lien Uber Eats n'est pas « le site du resto » — et25# 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 OSM31# change -> le site découvert est AUSSI consigné dans32# details.site_finder.website et RÉ-APPLIQUÉ à chaque passage (0 requête33# API). Les échecs sont horodatés et re-sondés après REFRESH_DAYS.34# ==============================================================================35from __future__ import annotations3637import datetime38import json39import math40import os41import re42import sys43import urllib.parse4445from ..inspections import _name_similar, norm_name46from ..regions import strip_accents47from ..schema import Restaurant48from .base import BaseConnector, SkipSource49from .siteresto import _RESERVATION_DOMAINS, _domain5051SERPER_MAPS_URL = "https://google.serper.dev/maps"52MAX_BUDGET = int(os.environ.get("SITE_FINDER_BUDGET", "3000")) # requêtes/cycle53REFRESH_DAYS = 180 # re-sonde des échecs (un site apparaît rarement)54MAX_CONSECUTIVE_FAILURES = 5 # API en panne / quota épuisé -> arrêt du cycle55MAX_DIST_M = 400.0 # rayon de croisement fiche OSM <-> fiche Google5657# 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 delivery59# 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)6869# 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)717273def 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, ""))858687def classify_website(url: str) -> tuple[str, str]:88 """('site'|'reservation'|'rejected', url_nettoyée) pour le champ website89 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", cleaned96 if any(dom == d or dom.endswith("." + d) for d in _BAD_DOMAINS):97 return "rejected", cleaned98 return "site", cleaned99100101def _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.0106107108def _names_match(rname: str, cname: str, addr_tokens: set[str]) -> bool:109 """Nom similaire (helper conservateur d'inspections.py) OU quasi-identique110 à une coquille près (« Pizzaria Amos » vs « Pizzéria Amos Inc ») — le repli111 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 True114 if not rname or not cname:115 return False116 import difflib117 return difflib.SequenceMatcher(None, rname, cname).ratio() >= 0.85118119120def 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) qui123 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 continue132 d = _dist_m(row["lat"], row["lng"], lat, lng)133 if d > MAX_DIST_M:134 continue135 if not _names_match(rname, norm_name(p.get("title") or ""), addr_tokens):136 continue137 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'abstient141 return min(hits, key=lambda t: t[0])[1]142143144class SiteFinderConnector(BaseConnector):145 source_id = "site-finder"146 request_delay = 0.15 # API commerciale (quota), pas un site à ménager147 timeout = 30148 use_detail_cache = False149 enrichment_only = True # n'émet aucune fiche (ingest.run)150151 def _now(self) -> str:152 return datetime.datetime.now(datetime.timezone.utc) \153 .strftime("%Y-%m-%dT%H:%M:%SZ")154155 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_s160 except (ValueError, TypeError):161 return False162163 def _reapply(self, con) -> int:164 """Ré-applique les sites déjà découverts dont la colonne `website` a165 été ré-écrasée par un sync OSM (0 requête API — details fait foi)."""166 restored = 0167 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 += 1175 return restored176177 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 []186187 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 _time192 from .. import db193 from ..normalize import normalize_phone194 con = db.connect()195 now = _time.time()196 stale_s = REFRESH_DAYS * 86400.0197 budget = MAX_BUDGET198 restored = self._reapply(con)199 con.commit()200 enriched = reservations = phones = misses = failures_row = 0201 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 que207 # 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 break213 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 break218 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 j227 budget -= 1228 try:229 places = self._lookup(row["name"], row["lat"], row["lng"])230 except Exception as exc:231 failures_row += 1232 print(f"[resto-ka] site-finder: {row['uid']} erreur: {exc}",233 file=sys.stderr)234 continue235 failures_row = 0236 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 += 1242 con.commit()243 continue244 # téléphone de la fiche Google : COALESCE si la fiche OSM n'en a pas245 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 += 1249 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 += 1257 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 += 1264 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 += 1269 con.commit()270 con.commit()271 con.close()272 self.enriched_count = enriched273 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 []279