# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/sitefinder.py # Desc: Connecteur d'ENRICHISSEMENT « site-finder » — découvre le SITE WEB # officiel des restaurants OSM qui n'en publient pas, via l'API Serper # (google.serper.dev/maps, fiches Google Maps). Mode d'extraction : # API JSON commerciale (aucun scraping). Contexte de prix : AUCUN — # ce connecteur n'émet ni fiche ni prix, il remplit la colonne # `website` (et `phone` si vide) des fiches OSM existantes. # # Pourquoi : le connecteur `site-resto` (Palier 3, menus maison à prix # dine-in) ne peut crawler QUE les restos dont OSM connaît le site web # (~3 400 sur ~14 000). Les ~10 600 restants ont pourtant très souvent # un site : ce connecteur le retrouve via la fiche Google Maps du # commerce et débloque ainsi le pipeline menus maison à l'échelle. # # Pipeline par resto (budget SITE_FINDER_BUDGET, incrémental) : # 1. Requête /maps : q = nom du resto, ll = @lat,lng,15z (centrée sur # les coordonnées OSM — décisif pour les chaînes multi-succursales). # 2. Croisement CONSERVATEUR : nom similaire (norm_name/_name_similar # d'inspections.py) ET distance GPS <= 400 m ; ambigu (2 fiches # distinctes qui matchent) -> aucun enrichissement. # 3. Classification du site : domaines sociaux/agrégateurs/livraison # REJETÉS (un lien Uber Eats n'est pas « le site du resto » — et # produirait des prix delivery mal étiquetés dine-in en aval) ; # plateformes de réservation -> details.reservation_url ; # sinon -> colonne `website` + details.site_finder. # 4. `phone` (E.164) complété si la fiche OSM n'en a pas (COALESCE). # # Durabilité : sync_source ré-écrase `website` quand la fiche OSM # change -> le site découvert est AUSSI consigné dans # details.site_finder.website et RÉ-APPLIQUÉ à chaque passage (0 requête # API). Les échecs sont horodatés et re-sondés après REFRESH_DAYS. # ============================================================================== from __future__ import annotations import datetime import json import math import os import re import sys import urllib.parse from ..inspections import _name_similar, norm_name from ..regions import strip_accents from ..schema import Restaurant from .base import BaseConnector, SkipSource from .siteresto import _RESERVATION_DOMAINS, _domain SERPER_MAPS_URL = "https://google.serper.dev/maps" MAX_BUDGET = int(os.environ.get("SITE_FINDER_BUDGET", "3000")) # requêtes/cycle REFRESH_DAYS = 180 # re-sonde des échecs (un site apparaît rarement) MAX_CONSECUTIVE_FAILURES = 5 # API en panne / quota épuisé -> arrêt du cycle MAX_DIST_M = 400.0 # rayon de croisement fiche OSM <-> fiche Google # Domaines qui ne sont PAS « le site du resto » : réseaux sociaux, agrégateurs, # annuaires et plateformes de LIVRAISON (leurs menus sont en prix delivery # majorés — les laisser passer contaminerait le pipeline dine-in de site-resto). _BAD_DOMAINS = ( "facebook.com", "instagram.com", "linktr.ee", "linkin.bio", "tiktok.com", "ubereats.com", "doordash.com", "order.online", "skipthedishes.com", "just-eat.ca", "restoloco.com", "restoloco.ca", "yelp.com", "yelp.ca", "tripadvisor.com", "tripadvisor.ca", "tripadvisor.fr", "google.com", "goo.gl", "business.site", "restomontreal.ca", "tastet.ca", "pagesjaunes.ca", "yellowpages.ca", "411.ca", "wikipedia.org", ) # paramètres de pistage à retirer des URLs (fiches Google truffées d'utm_*) _TRACKING_RE = re.compile(r"^(utm_|fbclid$|gclid$|mc_cid$|mc_eid$|ref$)", re.I) def clean_website(url: str) -> str: """URL de site web nettoyée (schéma http(s), sans fragment ni utm_*) ou ''.""" try: p = urllib.parse.urlsplit((url or "").strip()) except ValueError: return "" if p.scheme not in ("http", "https") or not p.netloc: return "" query = urllib.parse.urlencode( [(k, v) for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True) if not _TRACKING_RE.match(k)]) return urllib.parse.urlunsplit((p.scheme, p.netloc, p.path, query, "")) def classify_website(url: str) -> tuple[str, str]: """('site'|'reservation'|'rejected', url_nettoyée) pour le champ website d'une fiche Google Maps.""" cleaned = clean_website(url) if not cleaned: return "rejected", "" dom = _domain(cleaned) if any(dom == d or dom.endswith("." + d) for d in _RESERVATION_DOMAINS): return "reservation", cleaned if any(dom == d or dom.endswith("." + d) for d in _BAD_DOMAINS): return "rejected", cleaned return "site", cleaned def _dist_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: """Distance équirectangulaire en mètres (suffisant à l'échelle d'un pâté).""" dx = math.radians(lng2 - lng1) * math.cos(math.radians((lat1 + lat2) / 2)) dy = math.radians(lat2 - lat1) return math.hypot(dx, dy) * 6_371_000.0 def _names_match(rname: str, cname: str, addr_tokens: set[str]) -> bool: """Nom similaire (helper conservateur d'inspections.py) OU quasi-identique à une coquille près (« Pizzaria Amos » vs « Pizzéria Amos Inc ») — le repli flou est sûr ici car la distance GPS <= 400 m est déjà exigée.""" if _name_similar(rname, cname, addr_tokens): return True if not rname or not cname: return False import difflib return difflib.SequenceMatcher(None, rname, cname).ratio() >= 0.85 def match_place(row, places: list[dict]) -> dict | None: """Croisement CONSERVATEUR d'un resto OSM avec les fiches Google Maps : nom similaire ET distance GPS <= 400 m. Deux fiches distinctes (cid) qui matchent = ambigu -> None (jamais deviner entre deux commerces).""" rname = norm_name(row["name"]) addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents( f"{row['address'] or ''} {row['city'] or ''}".lower())).split()) hits = [] for p in places or []: lat, lng = p.get("latitude"), p.get("longitude") if not isinstance(lat, (int, float)) or not isinstance(lng, (int, float)): continue d = _dist_m(row["lat"], row["lng"], lat, lng) if d > MAX_DIST_M: continue if not _names_match(rname, norm_name(p.get("title") or ""), addr_tokens): continue hits.append((d, p)) cids = {str(p.get("cid") or id(p)) for _, p in hits} if len(cids) != 1: return None # rien, ou ambigu : on s'abstient return min(hits, key=lambda t: t[0])[1] class SiteFinderConnector(BaseConnector): source_id = "site-finder" request_delay = 0.15 # API commerciale (quota), pas un site à ménager timeout = 30 use_detail_cache = False enrichment_only = True # n'émet aucune fiche (ingest.run) def _now(self) -> str: return datetime.datetime.now(datetime.timezone.utc) \ .strftime("%Y-%m-%dT%H:%M:%SZ") def _fresh(self, stamp: str, now: float, stale_s: float) -> bool: try: ts = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ") \ .replace(tzinfo=datetime.timezone.utc).timestamp() return ts > now - stale_s except (ValueError, TypeError): return False def _reapply(self, con) -> int: """Ré-applique les sites déjà découverts dont la colonne `website` a été ré-écrasée par un sync OSM (0 requête API — details fait foi).""" restored = 0 for row in con.execute( "SELECT uid, json_extract(details,'$.site_finder.website') AS w" " FROM restaurants WHERE source='osm' AND active=1 AND website=''" " AND json_extract(details,'$.site_finder.website') IS NOT NULL" ).fetchall(): con.execute("UPDATE restaurants SET website=? WHERE uid=?", (row["w"], row["uid"])) restored += 1 return restored def _lookup(self, name: str, lat: float, lng: float) -> list[dict]: """Fiches Google Maps autour des coordonnées OSM (API Serper /maps).""" key = os.environ["SERPER_API_KEY"] resp = self.post(SERPER_MAPS_URL, json={"q": name[:96], "ll": f"@{lat},{lng},15z", "gl": "ca", "hl": "fr"}, headers={"X-API-KEY": key, "Content-Type": "application/json"}) return resp.json().get("places") or [] def fetch(self) -> list[Restaurant]: if not os.environ.get("SERPER_API_KEY"): raise SkipSource("SERPER_API_KEY manquant (.env) — découverte de " "sites web impossible sans l'API Serper") import time as _time from .. import db from ..normalize import normalize_phone con = db.connect() now = _time.time() stale_s = REFRESH_DAYS * 86400.0 budget = MAX_BUDGET restored = self._reapply(con) con.commit() enriched = reservations = phones = misses = failures_row = 0 rows = con.execute( "SELECT uid, name, address, city, lat, lng, phone, details" " FROM restaurants WHERE source='osm' AND active=1" " AND dup_of IS NULL AND website='' AND name<>''" " AND lat IS NOT NULL AND lng IS NOT NULL" # indépendants d'abord : c'est là que vivent les menus maison que # site-resto pourra capter (les chaînes ont leurs propres paliers) " ORDER BY chain IS NULL DESC, region<>'' DESC, city" ).fetchall() for row in rows: if budget <= 0: break if failures_row >= MAX_CONSECUTIVE_FAILURES: print("[resto-ka] site-finder: API Serper en échec " f"{failures_row} fois de suite — arrêt du cycle", file=sys.stderr) break try: details = json.loads(row["details"] or "{}") except ValueError: details = {} probe = details.get("site_finder") or {} if probe.get("website"): continue # déjà trouvé (ré-appliqué plus haut) if self._fresh(probe.get("fetched_at", ""), now, stale_s): continue # sondé récemment : re-visite dans 180 j budget -= 1 try: places = self._lookup(row["name"], row["lat"], row["lng"]) except Exception as exc: failures_row += 1 print(f"[resto-ka] site-finder: {row['uid']} erreur: {exc}", file=sys.stderr) continue failures_row = 0 hit = match_place(row, places) stamp = self._now() if hit is None: db.merge_details(con, row["uid"], {"site_finder": { "miss": "introuvable ou ambigu", "fetched_at": stamp}}) misses += 1 con.commit() continue # téléphone de la fiche Google : COALESCE si la fiche OSM n'en a pas phone = normalize_phone(hit.get("phoneNumber") or "") if phone and not (row["phone"] or "").strip(): db.enrich_contact(con, row["uid"], phone=phone) phones += 1 kind, url = classify_website(hit.get("website") or "") if kind == "site": con.execute("UPDATE restaurants SET website=? WHERE uid=?", (url, row["uid"])) db.merge_details(con, row["uid"], {"site_finder": { "website": url, "cid": str(hit.get("cid") or ""), "title": hit.get("title") or "", "fetched_at": stamp}}) enriched += 1 elif kind == "reservation": db.enrich_contact(con, row["uid"], details={"reservation_url": url}) db.merge_details(con, row["uid"], {"site_finder": { "miss": "site = plateforme de réservation", "cid": str(hit.get("cid") or ""), "fetched_at": stamp}}) reservations += 1 else: db.merge_details(con, row["uid"], {"site_finder": { "miss": "fiche Google sans site web (ou domaine rejeté)", "cid": str(hit.get("cid") or ""), "fetched_at": stamp}}) misses += 1 con.commit() con.commit() con.close() self.enriched_count = enriched self.enrich_message = ( f"{enriched} site(s) web découverts, {restored} ré-appliqué(s), " f"{phones} téléphone(s), {reservations} lien(s) de réservation, " f"{misses} sans site, budget restant {max(budget, 0)} requête(s)") print(f"[resto-ka] site-finder: {self.enrich_message}") return []