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