# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/ubereats.py # Desc: Connecteur d'ENRICHISSEMENT Uber Eats (menus livraison + notes) — # SANS clé API. Découverte par les sitemaps publics (robots.txt -> # sitemap-store-*.xml.gz, URLs /ca/store//, index local # data/ubereats-stores.json rafraîchi tous les 30 j). Chaque page de # resto embarque un état React Query (__REACT_QUERY_STATE__) avec le # menu complet (sections + items + prix en cents), la note Uber Eats, # l'adresse, le téléphone et les GPS. Croisement CONSERVATEUR avec un # resto EXISTANT seulement (téléphone identique, OU code postal + numéro # civique identiques, OU GPS <120 m + nom similaire) : n'émet AUCUNE # fiche. Menu -> table menus en price_context `delivery` (prix majorés # ~25-30 %, CLAUDE.md §6.2), note -> details.ubereats. # # Économe : Scrapfly ASP (1 page ≈ 1 crédit constaté), budget # UBEREATS_BUDGET (défaut 60) pages resto/cycle, verdict par magasin # mis en cache (detail_cache), re-visite 30 j. Conformité §15 : # extraction minimale, usage d'appoint (restos sans menu ailleurs), # prix toujours étiquetés `delivery`, lien source conservé. # ============================================================================== from __future__ import annotations import base64 import datetime import gzip import json import os import re import sys import time import urllib.parse from pathlib import Path from ..inspections import _CIVIC_RE, _name_similar, norm_name from ..normalize import normalize_phone from ..regions import strip_accents from ..schema import Restaurant from .base import BaseConnector, SkipSource ROBOTS_URL = "https://www.ubereats.com/robots.txt" INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "ubereats-stores.json" INDEX_REFRESH_DAYS = 30 # les sitemaps bougent peu MAX_BUDGET = int(os.environ.get("UBEREATS_BUDGET", "60")) # pages resto/cycle REFRESH_DAYS = 30 # re-visite d'un resto (hit comme miss) MAX_CANDIDATES = 3 # magasins testés par resto, max MAX_CONSECUTIVE_FAILURES = 3 MAX_GPS_M = 120.0 _STATE_RE = re.compile(r"__REACT_QUERY_STATE__\">(.*?)", re.S) _STORE_URL_RE = re.compile(r"/ca/store/([^/]+)/([A-Za-z0-9_-]{20,})") def decode_state(page_html: str) -> dict | None: """Décode le bloc __REACT_QUERY_STATE__ (JSON avec « " » encodé \\u0022 et « \\ » encodé %5C) d'une page Uber Eats.""" m = _STATE_RE.search(page_html) if not m: return None t = m.group(1).strip().replace("\\u0022", '"').replace("%5C", "\\") try: return json.loads(t) except ValueError: return None def store_payload(state: dict) -> dict | None: """Extrait le payload getStoreV1 (fiche resto complète) de l'état.""" for q in (state or {}).get("queries") or []: qk = q.get("queryKey") if isinstance(qk, list) and qk and qk[0] == "getStoreV1": data = (q.get("state") or {}).get("data") if isinstance(data, dict): return data return None def build_menu(store: dict, captured_at: str) -> dict | None: """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis catalogSectionsMap. Prix Uber Eats en cents -> dollars, contexte `delivery` obligatoire.""" csm = store.get("catalogSectionsMap") or {} sections_out: list[dict] = [] seen: set[str] = set() for sections in csm.values(): for sec in sections or []: payload = ((sec.get("payload") or {}) .get("standardItemsPayload") or {}) name = ((payload.get("title") or {}).get("text") or "").strip() items_in = payload.get("catalogItems") or [] if not name or not items_in or name in seen: continue items = [] for it in items_in: title = (it.get("title") or "").strip() if not title: continue price = it.get("price") items.append({ "name": title, "description": (it.get("itemDescription") or "").strip(), "price": round(price / 100.0, 2) if isinstance(price, (int, float)) and price > 0 else None, }) if items: seen.add(name) sections_out.append({"name": name, "items": items}) break # une seule vue du catalogue suffit if not sections_out: return None return { "price_context": "delivery", "price_source": "ubereats", "currency": store.get("currencyCode") or "CAD", "captured_at": captured_at, "sections": sections_out, } def _review_count(store: dict) -> int: raw = str((store.get("rating") or {}).get("reviewCount") or "") m = re.search(r"\d+", raw.replace(",", "").replace(" ", "")) return int(m.group(0)) if m else 0 def _haversine_m(lat1, lng1, lat2, lng2) -> float: import math r = 6371000.0 p1, p2 = math.radians(lat1), math.radians(lat2) dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) a = (math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2) return 2 * r * math.asin(math.sqrt(a)) def verify_store(row, store: dict) -> str: """Vérifie qu'une page magasin Uber Eats correspond bien au resto de la base — CONSERVATEUR : téléphone identique, OU code postal + numéro civique identiques, OU GPS <120 m + nom similaire. Retourne le mode de croisement ('' = pas le même établissement).""" loc = store.get("location") or {} phone = normalize_phone(store.get("phoneNumber")) if phone and normalize_phone(row["phone"]) == phone: return "telephone" postal = (loc.get("postalCode") or "").replace(" ", "").upper() rpostal = (row["postal_code"] or "").replace(" ", "").upper() civic_m = _CIVIC_RE.match(loc.get("streetAddress") or "") rcivic_m = _CIVIC_RE.match(row["address"] or "") if postal and rpostal == postal and civic_m and rcivic_m \ and civic_m.group(1) == rcivic_m.group(1): return "postal+civique" lat, lng = loc.get("latitude"), loc.get("longitude") if (lat is not None and lng is not None and row["lat"] is not None and row["lng"] is not None and _haversine_m(row["lat"], row["lng"], lat, lng) <= MAX_GPS_M): addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents( (loc.get("address") or "").lower())).split()) if _name_similar(norm_name(row["name"]), norm_name(store.get("title") or ""), addr_tokens): return "gps+nom" return "" def slugify(name: str) -> str: """Slug façon Uber Eats : « McDonald's » -> « mcdonalds », « Café Dépôt » -> « cafe-depot » (apostrophes supprimées, pas remplacées).""" s = strip_accents((name or "").lower()) s = re.sub(r"['’´`.]", "", s) s = re.sub(r"&", " and ", s) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s class UberEatsConnector(BaseConnector): source_id = "ubereats" request_delay = 1.0 timeout = 60 use_detail_cache = False # verdicts gérés à la main (detail_cache) enrichment_only = True # n'émet aucune fiche (ingest.run) # -- index sitemap --------------------------------------------------------- def _fetch_sitemap(self, url: str) -> str: result = self.scrapfly(url, render_js=False) content = result.get("content") or "" if url.endswith(".gz") or content[:20].startswith("H4sI"): try: return gzip.decompress(base64.b64decode(content)) \ .decode("utf-8", "replace") except (ValueError, OSError): return content return content def load_index(self) -> dict[str, list[str]]: """Index slug -> [URLs /ca/store/…] depuis les sitemaps publics, en cache local 30 jours (data/ubereats-stores.json).""" if INDEX_PATH.exists(): try: data = json.loads(INDEX_PATH.read_text(encoding="utf-8")) if time.time() - float(data.get("fetched_at") or 0) \ < INDEX_REFRESH_DAYS * 86400: return data.get("stores") or {} except (ValueError, OSError): pass robots = self._fetch_sitemap(ROBOTS_URL) sitemaps = re.findall(r"Sitemap:\s*(\S*sitemap-store\S*)", robots) if not sitemaps: raise RuntimeError("robots.txt Uber Eats sans sitemap-store " "(blocage ?)") stores: dict[str, list[str]] = {} for sm_url in sitemaps: xml = self._fetch_sitemap(sm_url) for loc in re.findall(r"([^<]+)", xml): mm = _STORE_URL_RE.search(loc) if mm: slug = urllib.parse.unquote(mm.group(1)).lower() stores.setdefault(slug, []) if loc not in stores[slug]: stores[slug].append(loc) INDEX_PATH.parent.mkdir(parents=True, exist_ok=True) INDEX_PATH.write_text(json.dumps( {"fetched_at": time.time(), "stores": stores}, ensure_ascii=False), encoding="utf-8") print(f"[resto-ka] ubereats: index sitemap rafraîchi — " f"{len(stores)} slug(s) canadiens") return stores @staticmethod def candidates_for(name: str, slugs: list[str]) -> list[str]: """Slugs Uber Eats candidats pour un nom de resto (préfixe strict).""" base = slugify(name) if len(base) < 5: return [] import bisect i = bisect.bisect_left(slugs, base) out = [] while i < len(slugs) and len(out) < MAX_CANDIDATES: s = slugs[i] if s == base or s.startswith(base + "-"): out.append(s) i += 1 else: break return out # -- cycle ----------------------------------------------------------------- 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 _details_payload(self, store: dict, url: str, how: str) -> dict: rating = store.get("rating") or {} return { "rating": rating.get("ratingValue"), "review_count": _review_count(store), "price_bucket": store.get("priceBucket"), "url": url.split("?")[0], "uuid": store.get("uuid"), "matched_by": how, "fetched_at": self._now(), } def _probe_store(self, con, row, url: str) -> tuple[bool, bool]: """Visite une page magasin et l'attache au resto si c'est le même établissement. Retourne (matched, menu_added).""" from .. import db mm = _STORE_URL_RE.search(url) store_uuid = mm.group(2) if mm else url cached = db.get_cached_detail(con, self.source_id, store_uuid, "verdict-v1") if cached is not None and cached.get("matched_uid") != row["uid"]: return False, False # déjà identifié comme un autre resto result = self.scrapfly(url, render_js=False) status = result.get("status_code") or 0 if status in (400, 404, 410, 451): # magasin retiré d'Uber Eats db.put_cached_detail(con, self.source_id, store_uuid, "verdict-v1", {"matched_uid": None, "gone": status}) return False, False if status != 200: raise RuntimeError(f"HTTP {status}") state = decode_state(result.get("content") or "") store = store_payload(state or {}) if not store: raise RuntimeError("payload getStoreV1 absent") how = verify_store(row, store) if not how: db.put_cached_detail(con, self.source_id, store_uuid, "verdict-v1", {"matched_uid": None}) return False, False db.put_cached_detail(con, self.source_id, store_uuid, "verdict-v1", {"matched_uid": row["uid"]}) menu = build_menu(store, self._now()) menu_added = False if menu: # validation stricte du schéma menu (prix implausibles, etc.) Restaurant(source=self.source_id, external_id=store_uuid, name=row["name"], menu=menu)._validate_menu() db.upsert_menu(con, row["uid"], menu, time.time()) menu_added = True db.merge_details(con, row["uid"], {"ubereats": self._details_payload(store, url, how)}) con.commit() return True, menu_added def fetch(self) -> list[Restaurant]: if not os.environ.get("SCRAPFLY_KEY"): raise SkipSource("SCRAPFLY_KEY manquant (.env) — Uber Eats est " "derrière Cloudflare, scraping direct impossible") from .. import db index = self.load_index() slugs = sorted(index.keys()) con = db.connect() now = time.time() stale_s = REFRESH_DAYS * 86400.0 budget = MAX_BUDGET matched = menus = misses = failures_row = 0 rows = con.execute( "SELECT uid, name, address, city, postal_code, phone, lat, lng," " details," " EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" " AS has_menu" " FROM restaurants WHERE active=1 AND dup_of IS NULL AND name<>''" " AND (phone<>'' OR (postal_code<>'' AND address<>'')" " OR (lat IS NOT NULL AND lng IS NOT NULL))" " ORDER BY has_menu ASC, phone<>'' DESC, updated_at DESC" ).fetchall() for row in rows: if budget <= 0: break if failures_row >= MAX_CONSECUTIVE_FAILURES: print("[resto-ka] ubereats: Scrapfly bloqué " f"{failures_row} fois de suite — arrêt du cycle", file=sys.stderr) break try: details = json.loads(row["details"] or "{}") except ValueError: details = {} ue = details.get("ubereats") or {} if self._fresh(ue.get("fetched_at", ""), now, stale_s): continue # déjà frais (<30 j) probe = details.get("ubereats_probe") or {} if self._fresh(probe.get("fetched_at", ""), now, stale_s): continue # échec récent : re-visite dans 30 j if ue.get("url"): # déjà croisé : rafraîchir directement urls = [ue["url"]] else: cand = self.candidates_for(row["name"], slugs) urls = [u for s in cand for u in index.get(s, [])] if not urls: continue # aucun candidat : pas de marqueur, # l'index du mois prochain peut changer found = False for url in urls[:MAX_CANDIDATES]: if budget <= 0: break budget -= 1 try: ok, menu_added = self._probe_store(con, row, url) except Exception as exc: failures_row += 1 print(f"[resto-ka] ubereats: {row['uid']} erreur: {exc}", file=sys.stderr) continue failures_row = 0 if ok: matched += 1 menus += 1 if menu_added else 0 found = True break if not found and not ue.get("url"): db.merge_details(con, row["uid"], {"ubereats_probe": {"miss": "aucun magasin correspondant", "fetched_at": self._now()}}) con.commit() misses += 1 con.commit() con.close() self.enriched_count = matched self.enrich_message = (f"{matched} resto(s) croisés Uber Eats " f"({menus} menu(s) livraison), {misses} sans " f"correspondance, budget restant " f"{max(budget, 0)} page(s)") print(f"[resto-ka] ubereats: {self.enrich_message}") return []