| |
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 [] |