Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/connectors/squareonline.py4# Desc: Connecteur templatisé Square Online (Palier 1 — boutique de commande5# en ligne du resto lui-même -> PRIX RÉELS, contexte `takeout`).6# Les sites <sous-domaine>.square.site sont détectés par le connecteur7# site-resto (lien de commande sur le site du resto) et consignés dans8# data/square-discovered.json AVEC l'uid du resto porteur.9# Les pages Square sont une SPA sans JSON-LD ni prix inline (constat10# de sondage) : la page /s/order est rendue en JS via Scrapfly puis le11# texte est structuré par Claude Haiku (restoka/menullm.py, validation12# stricte des prix). ENRICHISSEMENT PUR : le menu est posé sur le resto13# porteur (db.upsert_menu, price_context takeout) + details.square.14# CONNECTEUR DORMANT tant qu'aucune intégration n'est découverte15# (SkipSource). Budget SQUARE_BUDGET (défaut 15) rendus/cycle (coût16# Scrapfly rendu JS + LLM), re-visite 30 j.17#18# Base légale §15 : boutique de commande du resto lui-même (source de19# première partie), extraction minimale, lien source conservé.20# ==============================================================================21from __future__ import annotations2223import datetime24import json25import os26import sys27import time28from pathlib import Path2930from ..schema import Restaurant31from .base import BaseConnector, SkipSource3233SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"34DISCOVERED = SOURCES_PATH.parent / "square-discovered.json"35MAX_BUDGET = int(os.environ.get("SQUARE_BUDGET", "15")) # rendus JS/cycle36REFRESH_DAYS = 30373839def _html_text(html: str) -> str:40 from bs4 import BeautifulSoup41 soup = BeautifulSoup(html or "", "html.parser")42 for tag in soup(["script", "style", "noscript"]):43 tag.decompose()44 return " ".join(soup.get_text(" ").split())454647class SquareOnlineConnector(BaseConnector):48 source_id = "square"49 request_delay = 1.050 timeout = 6051 use_detail_cache = False # fraîcheur gérée via details.square52 enrichment_only = True # n'émet aucune fiche (ingest.run)5354 def _integrations(self) -> list[dict]:55 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))56 out: list[dict] = []57 for src in registry.get("sources", []):58 if src.get("id") == self.source_id:59 out.extend(src.get("integrations") or [])60 if DISCOVERED.exists():61 try:62 extra = json.loads(DISCOVERED.read_text(encoding="utf-8"))63 known = {i.get("key") for i in out}64 out.extend(i for i in extra.get("integrations", [])65 if i.get("key") not in known)66 except ValueError:67 pass68 return out6970 def _now(self) -> str:71 return datetime.datetime.now(datetime.timezone.utc) \72 .strftime("%Y-%m-%dT%H:%M:%SZ")7374 def _order_page_text(self, site: str) -> tuple[str, str]:75 """(texte, url) de la page de commande rendue en JS."""76 url = f"https://{site}/s/order"77 html = self.get_scrapfly(url, render_js=True, rendering_wait=3000)78 text = _html_text(html)79 if len(text) < 200: # page vide : essayer l'accueil80 url = f"https://{site}/"81 html = self.get_scrapfly(url, render_js=True, rendering_wait=3000)82 text = _html_text(html)83 return text, url8485 def fetch(self) -> list[Restaurant]:86 integrations = [i for i in self._integrations()87 if i.get("key") and i.get("status") != "inactif"]88 if not integrations:89 raise SkipSource(90 "aucune intégration Square Online découverte "91 "(data/square-discovered.json vide) — connecteur dormant, "92 "en attente des découvertes du connecteur site-resto")93 if not os.environ.get("SCRAPFLY_KEY"):94 raise SkipSource("SCRAPFLY_KEY manquant (.env) — les boutiques "95 "Square sont des SPA, rendu JS requis")96 from .. import db, menullm97 con = db.connect()98 now = time.time()99 budget = MAX_BUDGET100 menus = errors = 0101 for integ in integrations:102 if budget <= 0:103 break104 site, uid = str(integ["key"]), integ.get("uid") or ""105 if not uid:106 print(f"[resto-ka] square: {site} sans uid porteur — ignoré",107 file=sys.stderr)108 continue109 row = con.execute(110 "SELECT details FROM restaurants WHERE uid=? AND active=1",111 (uid,)).fetchone()112 if row is None:113 continue # resto porteur disparu114 try:115 details = json.loads(row["details"] or "{}")116 except ValueError:117 details = {}118 sq = details.get("square") or {}119 try:120 ts = datetime.datetime.strptime(121 sq.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \122 .replace(tzinfo=datetime.timezone.utc).timestamp()123 if ts > now - REFRESH_DAYS * 86400:124 continue # déjà frais (hit comme miss)125 except (ValueError, TypeError):126 pass127 budget -= 1128 try:129 text, url = self._order_page_text(site)130 menu_raw = menullm.menu_from_text(text) if text else None131 except Exception as exc:132 errors += 1133 print(f"[resto-ka] square: {uid} ({site}) erreur: {exc}",134 file=sys.stderr)135 continue136 if not menu_raw or not menu_raw.get("sections"):137 # miss horodaté : re-visite dans REFRESH_DAYS, pas à chaque cycle138 db.merge_details(con, uid, {"square": {139 "site": site, "miss": "aucun menu structurable",140 "fetched_at": self._now()}})141 con.commit()142 continue143 menu = {144 "price_context": "takeout",145 "price_source": "square",146 "currency": "CAD",147 "captured_at": self._now(),148 "sections": menu_raw["sections"],149 }150 # validation stricte du schéma menu avant stockage151 Restaurant(source=self.source_id, external_id=site,152 name=uid, menu=menu)._validate_menu()153 db.upsert_menu(con, uid, menu, time.time())154 db.merge_details(con, uid, {"square": {155 "site": site, "url": url, "fetched_at": self._now()}})156 con.commit()157 menus += 1158 con.close()159 self.enriched_count = menus160 self.enrich_message = (f"{menus} menu(s) takeout Square posés, "161 f"{errors} erreur(s), budget restant "162 f"{max(budget, 0)} rendu(s) sur "163 f"{len(integrations)} intégration(s)")164 print(f"[resto-ka] square: {self.enrich_message}")165 return []166