# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/doordash.py # Desc: Connecteur d'ENRICHISSEMENT DoorDash (menus livraison + notes) — # même patron conservateur qu'ubereats.py. Découverte par le sitemap # public QC (cdn.doordash.com/sitemaps/…/sitemap-doordash-qc-stores.xml, # ~11 700 magasins, index local data/doordash-stores.json, 30 j). # Chaque page magasin embarque du JSON-LD schema.org : un bloc # `Restaurant` (adresse, GPS, cuisines, priceRange, aggregateRating — # PAS de téléphone) et un bloc `Menu` (hasMenuSection imbriqué -> # hasMenuItem {name, description, offers.price "$X.XX"}). # Croisement CONSERVATEUR avec un resto EXISTANT seulement (GPS <120 m # + nom similaire, OU numéro civique + ville + nom similaire) : n'émet # AUCUNE fiche. Menu -> table menus en price_context `delivery` (prix # majorés ~25-30 %, CLAUDE.md §6.2), note -> details.doordash. # # Mode d'extraction : Scrapfly ASP (anti-bot ; les gros contenus # reviennent en « large_object » à re-télécharger), budget # DOORDASH_BUDGET (défaut 40) pages/cycle, verdict par magasin en # cache, re-visite 30 j. Conformité §15 : extraction minimale, usage # d'appoint, prix toujours étiquetés `delivery`, lien source conservé. # ============================================================================== from __future__ import annotations import datetime import json import os import re import sys import time import urllib.parse from pathlib import Path import requests from ..inspections import _CIVIC_RE, _name_similar, norm_name from ..regions import strip_accents from ..schema import Restaurant from .base import BaseConnector, SkipSource SITEMAP_QC = ("https://cdn.doordash.com/sitemaps/sitemaps/" "sitemap-doordash-qc-stores.xml") INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "doordash-stores.json" INDEX_REFRESH_DAYS = 30 MAX_BUDGET = int(os.environ.get("DOORDASH_BUDGET", "40")) # pages/cycle REFRESH_DAYS = 30 MAX_CANDIDATES = 3 MAX_CONSECUTIVE_FAILURES = 3 MAX_GPS_M = 120.0 # /en-CA/store/-/ — les /convenience/store/ (épiceries) sont exclus _STORE_URL_RE = re.compile(r"doordash\.com/en-CA/store/([^/]+?)-(\d+)/?$") _LDJSON_RE = re.compile( r'', re.S) _PRICE_RE = re.compile(r"(\d+(?:[.,]\d{1,2})?)") def parse_ldjson(page_html: str) -> tuple[dict | None, dict | None]: """Extrait les blocs JSON-LD `Restaurant` et `Menu` d'une page magasin.""" resto = menu = None for raw in _LDJSON_RE.findall(page_html): try: data = json.loads(raw) except ValueError: continue for obj in (data if isinstance(data, list) else [data]): if not isinstance(obj, dict): continue if obj.get("@type") == "Restaurant" and resto is None: resto = obj elif obj.get("@type") == "Menu" and menu is None: menu = obj return resto, menu def _parse_price(offers) -> float | None: """« $13.20 » -> 13.20 ; None si absent/illisible.""" raw = str((offers or {}).get("price") or "") m = _PRICE_RE.search(raw.replace(",", ".")) if not m: return None price = float(m.group(1)) return price if 0 < price <= 10000 else None def _flatten_sections(node, out: list[dict]) -> None: """hasMenuSection est parfois une liste de listes — aplatir récursivement.""" if isinstance(node, list): for x in node: _flatten_sections(x, out) elif isinstance(node, dict): out.append(node) def build_menu(ld_menu: dict, captured_at: str) -> dict | None: """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis le JSON-LD `Menu`.""" raw_sections: list[dict] = [] _flatten_sections((ld_menu or {}).get("hasMenuSection"), raw_sections) sections_out: list[dict] = [] seen: set[str] = set() for sec in raw_sections: name = (sec.get("name") or "").strip() if not name or name in seen: continue items = [] for it in sec.get("hasMenuItem") or []: title = (it.get("name") or "").strip() if not title: continue items.append({ "name": title, "description": (it.get("description") or "").strip(), "price": _parse_price(it.get("offers")), }) if items: seen.add(name) sections_out.append({"name": name, "items": items}) if not sections_out: return None return { "price_context": "delivery", "price_source": "doordash", "currency": "CAD", "captured_at": captured_at, "sections": sections_out, } 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 _norm_city(city: str) -> str: return re.sub(r"[^a-z0-9]+", "", strip_accents((city or "").lower())) def verify_store(row, ld: dict) -> str: """Vérifie qu'une page magasin DoorDash correspond bien au resto de la base — CONSERVATEUR (le JSON-LD n'a PAS de téléphone) : GPS <120 m + nom similaire, OU numéro civique + ville + nom similaire. Retourne le mode de croisement ('' = pas le même établissement).""" addr = ld.get("address") or {} geo = ld.get("geo") or {} street = (addr.get("streetAddress") or "").strip() addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", strip_accents(street.lower())).split()) similar = _name_similar(norm_name(row["name"]), norm_name(ld.get("name") or ""), addr_tokens) try: lat, lng = float(geo.get("latitude")), float(geo.get("longitude")) except (TypeError, ValueError): lat = lng = None if (lat 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 and similar): return "gps+nom" civic_m = _CIVIC_RE.match(street) rcivic_m = _CIVIC_RE.match(row["address"] or "") if (civic_m and rcivic_m and civic_m.group(1) == rcivic_m.group(1) and _norm_city(addr.get("addressLocality") or "") == _norm_city(row["city"] or "") and similar): return "civique+ville+nom" return "" def slugify(name: str) -> str: """Slug façon DoorDash : minuscules sans accents, tirets.""" s = strip_accents((name or "").lower()) s = re.sub(r"['’´`.]", "", s) s = re.sub(r"&", " ", s) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s class DoorDashConnector(BaseConnector): source_id = "doordash" 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) # -- Scrapfly / large_object ------------------------------------------------- def _content(self, url: str, **kw) -> tuple[int, str]: """Page via Scrapfly ASP ; résout les réponses « large_object » (contenu volumineux renvoyé comme URL à re-télécharger avec la clé).""" result = self.scrapfly(url, render_js=False, **kw) status = result.get("status_code") or 0 content = result.get("content") or "" if content.startswith("https://api.scrapfly.io/scrape/large_object/"): resp = requests.get(content, params={"key": os.environ["SCRAPFLY_KEY"]}, timeout=120) content = resp.text if resp.ok else "" return status, content # -- index sitemap ----------------------------------------------------------- def load_index(self) -> dict[str, str]: """Index slug -> URL magasin depuis le sitemap QC public, en cache local 30 jours (data/doordash-stores.json). Épiceries/dépanneurs (/convenience/store/) exclus.""" 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 # le CDN sitemap répond en direct ; Scrapfly en secours (large_object) try: xml = self.get(SITEMAP_QC).text except Exception: _, xml = self._content(SITEMAP_QC) stores: dict[str, str] = {} for loc in re.findall(r"([^<]+)", xml): if "/convenience/" in loc: continue mm = _STORE_URL_RE.search(urllib.parse.unquote(loc)) if mm: stores[f"{mm.group(1)}-{mm.group(2)}".lower()] = loc if not stores: raise RuntimeError("sitemap QC DoorDash vide (blocage ?)") 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] doordash: index sitemap rafraîchi — " f"{len(stores)} magasin(s) QC") return stores @staticmethod def candidates_for(name: str, slugs: list[str]) -> list[str]: """Slugs DoorDash candidats pour un nom de resto (préfixe strict — le slug DoorDash embarque la ville et l'id : nom-ville-123456).""" 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, ld: dict, url: str, how: str) -> dict: rating = ld.get("aggregateRating") or {} return { "rating": rating.get("ratingValue"), "review_count": rating.get("reviewCount"), "price_range": ld.get("priceRange"), "cuisines": ld.get("servesCuisine"), "url": url.split("?")[0], "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(urllib.parse.unquote(url)) store_id = mm.group(2) if mm else url cached = db.get_cached_detail(con, self.source_id, store_id, "verdict-v1") if cached is not None and cached.get("matched_uid") != row["uid"]: return False, False # déjà identifié comme un autre resto status, content = self._content(url) if status in (400, 404, 410, 451): # magasin retiré de DoorDash db.put_cached_detail(con, self.source_id, store_id, "verdict-v1", {"matched_uid": None, "gone": status}) return False, False if status != 200: raise RuntimeError(f"HTTP {status}") ld_resto, ld_menu = parse_ldjson(content) if not ld_resto: if ld_menu or "application/ld+json" in content: # variante de page sans bloc Restaurant (constat de terrain) : # identité invérifiable -> miss prudent, PAS un blocage db.put_cached_detail(con, self.source_id, store_id, "verdict-v1", {"matched_uid": None, "no_restaurant_ld": True}) return False, False raise RuntimeError("JSON-LD absent (page non rendue ?)") how = verify_store(row, ld_resto) if not how: db.put_cached_detail(con, self.source_id, store_id, "verdict-v1", {"matched_uid": None}) return False, False db.put_cached_detail(con, self.source_id, store_id, "verdict-v1", {"matched_uid": row["uid"]}) menu = build_menu(ld_menu or {}, self._now()) menu_added = False if menu: # validation stricte du schéma menu (prix implausibles, etc.) Restaurant(source=self.source_id, external_id=store_id, 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"], {"doordash": self._details_payload(ld_resto, 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) — DoorDash est " "derrière un anti-bot, 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 ((lat IS NOT NULL AND lng IS NOT NULL)" " OR (address<>'' AND city<>''))" " ORDER BY has_menu ASC, lat IS NULL ASC, updated_at DESC" ).fetchall() for row in rows: if budget <= 0: break if failures_row >= MAX_CONSECUTIVE_FAILURES: print("[resto-ka] doordash: 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 = {} dd = details.get("doordash") or {} if self._fresh(dd.get("fetched_at", ""), now, stale_s): continue # déjà frais (<30 j) probe = details.get("doordash_probe") or {} if self._fresh(probe.get("fetched_at", ""), now, stale_s): continue # échec récent : re-visite dans 30 j if dd.get("url"): # déjà croisé : rafraîchir directement urls = [dd["url"]] else: cand = self.candidates_for(row["name"], slugs) urls = [index[s] for s in cand if s in index] 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] doordash: {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 dd.get("url"): db.merge_details(con, row["uid"], {"doordash_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 DoorDash " f"({menus} menu(s) livraison), {misses} sans " f"correspondance, budget restant " f"{max(budget, 0)} page(s)") print(f"[resto-ka] doordash: {self.enrich_message}") return []