# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/squareonline.py # Desc: Connecteur templatisé Square Online (Palier 1 — boutique de commande # en ligne du resto lui-même -> PRIX RÉELS, contexte `takeout`). # Les sites .square.site sont détectés par le connecteur # site-resto (lien de commande sur le site du resto) et consignés dans # data/square-discovered.json AVEC l'uid du resto porteur. # Les pages Square sont une SPA sans JSON-LD ni prix inline (constat # de sondage) : la page /s/order est rendue en JS via Scrapfly puis le # texte est structuré par Claude Haiku (restoka/menullm.py, validation # stricte des prix). ENRICHISSEMENT PUR : le menu est posé sur le resto # porteur (db.upsert_menu, price_context takeout) + details.square. # CONNECTEUR DORMANT tant qu'aucune intégration n'est découverte # (SkipSource). Budget SQUARE_BUDGET (défaut 15) rendus/cycle (coût # Scrapfly rendu JS + LLM), re-visite 30 j. # # Base légale §15 : boutique de commande du resto lui-même (source de # première partie), extraction minimale, lien source conservé. # ============================================================================== from __future__ import annotations import datetime import json import os import sys import time from pathlib import Path from ..schema import Restaurant from .base import BaseConnector, SkipSource SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json" DISCOVERED = SOURCES_PATH.parent / "square-discovered.json" MAX_BUDGET = int(os.environ.get("SQUARE_BUDGET", "15")) # rendus JS/cycle REFRESH_DAYS = 30 def _html_text(html: str) -> str: from bs4 import BeautifulSoup soup = BeautifulSoup(html or "", "html.parser") for tag in soup(["script", "style", "noscript"]): tag.decompose() return " ".join(soup.get_text(" ").split()) class SquareOnlineConnector(BaseConnector): source_id = "square" request_delay = 1.0 timeout = 60 use_detail_cache = False # fraîcheur gérée via details.square enrichment_only = True # n'émet aucune fiche (ingest.run) def _integrations(self) -> list[dict]: registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) out: list[dict] = [] for src in registry.get("sources", []): if src.get("id") == self.source_id: out.extend(src.get("integrations") or []) if DISCOVERED.exists(): try: extra = json.loads(DISCOVERED.read_text(encoding="utf-8")) known = {i.get("key") for i in out} out.extend(i for i in extra.get("integrations", []) if i.get("key") not in known) except ValueError: pass return out def _now(self) -> str: return datetime.datetime.now(datetime.timezone.utc) \ .strftime("%Y-%m-%dT%H:%M:%SZ") def _order_page_text(self, site: str) -> tuple[str, str]: """(texte, url) de la page de commande rendue en JS.""" url = f"https://{site}/s/order" html = self.get_scrapfly(url, render_js=True, rendering_wait=3000) text = _html_text(html) if len(text) < 200: # page vide : essayer l'accueil url = f"https://{site}/" html = self.get_scrapfly(url, render_js=True, rendering_wait=3000) text = _html_text(html) return text, url def fetch(self) -> list[Restaurant]: integrations = [i for i in self._integrations() if i.get("key") and i.get("status") != "inactif"] if not integrations: raise SkipSource( "aucune intégration Square Online découverte " "(data/square-discovered.json vide) — connecteur dormant, " "en attente des découvertes du connecteur site-resto") if not os.environ.get("SCRAPFLY_KEY"): raise SkipSource("SCRAPFLY_KEY manquant (.env) — les boutiques " "Square sont des SPA, rendu JS requis") from .. import db, menullm con = db.connect() now = time.time() budget = MAX_BUDGET menus = errors = 0 for integ in integrations: if budget <= 0: break site, uid = str(integ["key"]), integ.get("uid") or "" if not uid: print(f"[resto-ka] square: {site} sans uid porteur — ignoré", file=sys.stderr) continue row = con.execute( "SELECT details FROM restaurants WHERE uid=? AND active=1", (uid,)).fetchone() if row is None: continue # resto porteur disparu try: details = json.loads(row["details"] or "{}") except ValueError: details = {} sq = details.get("square") or {} try: ts = datetime.datetime.strptime( sq.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \ .replace(tzinfo=datetime.timezone.utc).timestamp() if ts > now - REFRESH_DAYS * 86400: continue # déjà frais (hit comme miss) except (ValueError, TypeError): pass budget -= 1 try: text, url = self._order_page_text(site) menu_raw = menullm.menu_from_text(text) if text else None except Exception as exc: errors += 1 print(f"[resto-ka] square: {uid} ({site}) erreur: {exc}", file=sys.stderr) continue if not menu_raw or not menu_raw.get("sections"): # miss horodaté : re-visite dans REFRESH_DAYS, pas à chaque cycle db.merge_details(con, uid, {"square": { "site": site, "miss": "aucun menu structurable", "fetched_at": self._now()}}) con.commit() continue menu = { "price_context": "takeout", "price_source": "square", "currency": "CAD", "captured_at": self._now(), "sections": menu_raw["sections"], } # validation stricte du schéma menu avant stockage Restaurant(source=self.source_id, external_id=site, name=uid, menu=menu)._validate_menu() db.upsert_menu(con, uid, menu, time.time()) db.merge_details(con, uid, {"square": { "site": site, "url": url, "fetched_at": self._now()}}) con.commit() menus += 1 con.close() self.enriched_count = menus self.enrich_message = (f"{menus} menu(s) takeout Square posés, " f"{errors} erreur(s), budget restant " f"{max(budget, 0)} rendu(s) sur " f"{len(integrations)} intégration(s)") print(f"[resto-ka] square: {self.enrich_message}") return []