SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
7.2 KB · 166 lines python
Raw Blame History
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