# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/skip.py # Desc: Connecteur d'ENRICHISSEMENT SkipTheDishes (menus livraison + score) — # même patron conservateur qu'ubereats.py/doordash.py. Découverte par # les sitemaps publics (sitemap2..7.xml, pages resto à la racine # /, doublons /fr/ exclus ; index local data/skip-stores.json, # 30 j). Chaque page resto embarque un état Next.js (__NEXT_DATA__) # avec props.pageProps.partner (nom, rue, « Ville, PROV, POSTAL, CAN », # score Skip, téléphone dans les textes du disclaimerModal) et # menuV2.categories[].menuItems[] (name, description, subtotal en # cents). Croisement CONSERVATEUR avec un resto EXISTANT seulement # (téléphone identique OU code postal + numéro civique identiques ; # province ≠ QC rejetée d'emblée) : n'émet AUCUNE fiche. Menu -> table # menus en price_context `delivery` (prix majorés ~25-30 %, §6.2), # score -> details.skip. # # Mode d'extraction : Scrapfly ASP (anti-bot ; gros contenus résolus # via « large_object »), budget SKIP_BUDGET (défaut 40) pages/cycle, # verdict par resto en cache, re-visite 30 j. Conformité §15 : # extraction minimale, usage d'appoint, prix étiquetés `delivery`. # ============================================================================== from __future__ import annotations import datetime import json import os import re import sys import time from pathlib import Path import requests from ..inspections import _CIVIC_RE from ..normalize import normalize_phone from ..regions import strip_accents from ..schema import Restaurant from .base import BaseConnector, SkipSource BASE = "https://www.skipthedishes.com" SITEMAPS = [f"{BASE}/sitemap{i}.xml" for i in range(2, 8)] INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "skip-stores.json" INDEX_REFRESH_DAYS = 30 MAX_BUDGET = int(os.environ.get("SKIP_BUDGET", "40")) # pages/cycle REFRESH_DAYS = 30 MAX_CANDIDATES = 3 MAX_CONSECUTIVE_FAILURES = 3 _NEXT_DATA_RE = re.compile( r'', re.S) # pages resto = un seul segment à la racine ; /fr/... = doublon francophone _STORE_URL_RE = re.compile(r"skipthedishes\.com/([a-z0-9-]+)/?$") # « Windsor, ON, N9E 1S3, CAN » _LOCDETAILS_RE = re.compile( r"^\s*(.+?),\s*([A-Z]{2}),\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d),", re.I) def decode_next_data(page_html: str) -> dict | None: """Décode le bloc __NEXT_DATA__ (JSON brut) d'une page Skip.""" m = _NEXT_DATA_RE.search(page_html) if not m: return None try: return json.loads(m.group(1)) except ValueError: return None def partner_payload(state: dict) -> tuple[dict | None, dict | None]: """(partner, menuV2) depuis props.pageProps.""" pp = ((state or {}).get("props") or {}).get("pageProps") or {} partner = pp.get("partner") menu = pp.get("menuV2") or (partner or {}).get("menuV2") return (partner if isinstance(partner, dict) else None, menu if isinstance(menu, dict) else None) def partner_phone(partner: dict) -> str: """Téléphone du resto, enfoui dans les textes du disclaimerModal.""" dm = (partner or {}).get("disclaimerModal") or {} for d in dm.get("disclaimers") or []: phone = normalize_phone(d.get("text") or "") if phone: return phone return "" def parse_location_details(details: str) -> tuple[str, str, str]: """« Windsor, ON, N9E 1S3, CAN » -> (ville, province, code postal).""" m = _LOCDETAILS_RE.match(details or "") if not m: return "", "", "" return (m.group(1).strip(), m.group(2).upper(), m.group(3).replace(" ", "").upper()) def build_menu(menu_v2: dict, captured_at: str) -> dict | None: """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis menuV2.categories. Prix Skip en cents (`subtotal`) -> dollars, contexte `delivery`.""" sections_out: list[dict] = [] seen: set[str] = set() for cat in (menu_v2 or {}).get("categories") or []: name = (cat.get("name") or "").strip() if not name or name in seen: continue items = [] for it in cat.get("menuItems") or []: title = (it.get("name") or "").strip() if not title: continue sub = it.get("subtotal") items.append({ "name": title, "description": (it.get("description") or "").strip(), "price": round(sub / 100.0, 2) if isinstance(sub, (int, float)) and sub > 0 else None, }) if items: seen.add(name) sections_out.append({"name": name, "items": items}) if not sections_out: return None return { "price_context": "delivery", "price_source": "skip", "currency": "CAD", "captured_at": captured_at, "sections": sections_out, } def verify_partner(row, partner: dict) -> str: """Vérifie qu'une page Skip correspond bien au resto de la base — CONSERVATEUR : province QC obligatoire, puis téléphone identique OU code postal + numéro civique identiques. Retourne le mode de croisement ('' = pas le même établissement).""" _, prov, postal = parse_location_details( partner.get("locationDetails") or "") if prov and prov != "QC": return "" phone = partner_phone(partner) if phone and normalize_phone(row["phone"]) == phone: return "telephone" rpostal = (row["postal_code"] or "").replace(" ", "").upper() civic_m = _CIVIC_RE.match(partner.get("location") 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" return "" def slugify(name: str) -> str: """Slug façon Skip (cleanUrl) : 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 SkipConnector(BaseConnector): source_id = "skip" 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]: 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 resto depuis sitemap2..7 (pages à la racine), en cache local 30 jours (data/skip-stores.json). Les /fr/… (doublons) et sitemap1 (pages villes/cuisines) sont 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 stores: dict[str, str] = {} for sm_url in SITEMAPS: _, xml = self._content(sm_url) for loc in re.findall(r"([^<]+)", xml): mm = _STORE_URL_RE.search(loc) if mm: stores.setdefault(mm.group(1).lower(), loc) if not stores: raise RuntimeError("sitemaps Skip vides (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] skip: index sitemap rafraîchi — " f"{len(stores)} resto(s) Canada") return stores @staticmethod def candidates_for(name: str, slugs: list[str]) -> list[str]: """Slugs Skip candidats pour un nom de resto (préfixe strict — le slug Skip est le nom seul, parfois suffixé de la rue/ville).""" 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, partner: dict, url: str, how: str) -> dict: score = partner.get("score") or {} return { "score": score.get("formatted") or score.get("value"), "url": url.split("?")[0], "partner_id": partner.get("id"), "matched_by": how, "fetched_at": self._now(), } def _probe_store(self, con, row, url: str) -> tuple[bool, bool]: """Visite une page resto Skip 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) slug = mm.group(1) if mm else url cached = db.get_cached_detail(con, self.source_id, slug, "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): # resto retiré de Skip db.put_cached_detail(con, self.source_id, slug, "verdict-v1", {"matched_uid": None, "gone": status}) return False, False if status != 200: raise RuntimeError(f"HTTP {status}") state = decode_next_data(content) if not state: raise RuntimeError("__NEXT_DATA__ absent (page non rendue ?)") partner, menu_v2 = partner_payload(state) if not partner: # page rendue mais sans fiche partner (page de groupe/chaîne) : # identité invérifiable -> miss prudent, PAS un blocage db.put_cached_detail(con, self.source_id, slug, "verdict-v1", {"matched_uid": None, "no_partner": True}) return False, False how = verify_partner(row, partner) if not how: db.put_cached_detail(con, self.source_id, slug, "verdict-v1", {"matched_uid": None}) return False, False db.put_cached_detail(con, self.source_id, slug, "verdict-v1", {"matched_uid": row["uid"]}) menu = build_menu(menu_v2 or {}, self._now()) menu_added = False if menu: # validation stricte du schéma menu (prix implausibles, etc.) Restaurant(source=self.source_id, external_id=slug, 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"], {"skip": self._details_payload(partner, 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) — SkipTheDishes " "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 # Skip n'expose ni GPS ni téléphone en clair : le croisement passe par # le téléphone (disclaimer) ou postal+civique -> exiger ces champs 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<>''))" " 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] skip: 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 = {} sk = details.get("skip") or {} if self._fresh(sk.get("fetched_at", ""), now, stale_s): continue # déjà frais (<30 j) probe = details.get("skip_probe") or {} if self._fresh(probe.get("fetched_at", ""), now, stale_s): continue # échec récent : re-visite dans 30 j if sk.get("url"): # déjà croisé : rafraîchir directement urls = [sk["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 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] skip: {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 sk.get("url"): db.merge_details(con, row["uid"], {"skip_probe": {"miss": "aucun resto 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 Skip " f"({menus} menu(s) livraison), {misses} sans " f"correspondance, budget restant " f"{max(budget, 0)} page(s)") print(f"[resto-ka] skip: {self.enrich_message}") return []