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%
8.1 KB · 193 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/gloriafood.py4# Desc:   Connecteur templatisé GloriaFood/Oracle (Palier 1 — widget de5#         commande embarqué sur le site du resto -> PRIX RÉELS, contexte6#         `takeout`). Le widget s'identifie par data-glf-cuid/data-glf-ruid7#         dans le HTML du site ; le connecteur site-resto consigne ces ids8#         dans data/gloriafood-discovered.json AVEC l'uid du resto porteur.9#         Menu via l'API publique du widget :10#           GET pos.globalfoodsoft.com/pos/menu11#           (Authorization: <ruid>, Glf-Api-Version: 2, Accept: JSON)12#         -> categories[].items[] {name, description, price, sizes[]}.13#         ENRICHISSEMENT PUR : n'émet aucune fiche — le menu est posé sur le14#         resto porteur (db.upsert_menu, price_context takeout) + traçabilité15#         details.gloriafood. CONNECTEUR DORMANT tant qu'aucune intégration16#         n'est découverte (SkipSource — pas un échec). L'endpoint refuse les17#         requêtes sans Authorization (403) : chaque intégration est tentée18#         défensivement, un refus n'interrompt pas le cycle.19#20#         Base légale §15 : API du widget de commande du resto lui-même21#         (source de première partie), extraction minimale, lien conservé.22# ==============================================================================23from __future__ import annotations2425import datetime26import json27import sys28import time29from pathlib import Path3031from ..schema import Restaurant32from .base import BaseConnector, SkipSource3334MENU_API = "https://pos.globalfoodsoft.com/pos/menu"35SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"36DISCOVERED = SOURCES_PATH.parent / "gloriafood-discovered.json"37REFRESH_DAYS = 14                 # menus takeout : rafraîchissement §14383940def build_menu(payload: dict, captured_at: str) -> dict | None:41    """Menu standard Resto·Ka (§5.2) depuis le payload GloriaFood — parseur42    TOLÉRANT (structure observée : categories[].items[], variantes `sizes`)."""43    if isinstance(payload, list):          # payload directement en liste44        cats = payload45    else:46        cats = (payload or {}).get("categories")47    sections_out: list[dict] = []48    seen: set[str] = set()49    for cat in cats or []:50        if not isinstance(cat, dict):51            continue52        name = (cat.get("name") or "").strip()53        if not name or name in seen:54            continue55        items = []56        for it in cat.get("items") or []:57            if not isinstance(it, dict):58                continue59            title = (it.get("name") or "").strip()60            if not title:61                continue62            price = it.get("price")63            entry = {64                "name": title,65                "description": (it.get("description") or "").strip(),66                "price": round(float(price), 2)67                if isinstance(price, (int, float)) and price > 0 else None,68            }69            sizes = [s for s in it.get("sizes") or [] if isinstance(s, dict)70                     and (s.get("name") or "").strip()]71            if sizes:72                base = entry["price"] or 0.073                choices = []74                for s in sizes:75                    sp = s.get("price")76                    delta = (round(float(sp) - base, 2)77                             if isinstance(sp, (int, float)) else 0.0)78                    choices.append({"name": s["name"].strip(),79                                    "price_delta": delta})80                entry["options"] = [{"group": "Format", "required": True,81                                     "choices": choices}]82            items.append(entry)83        if items:84            seen.add(name)85            sections_out.append({"name": name, "items": items})86    if not sections_out:87        return None88    return {89        "price_context": "takeout",90        "price_source": "gloriafood",91        "currency": "CAD",92        "captured_at": captured_at,93        "sections": sections_out,94    }959697class GloriaFoodConnector(BaseConnector):98    source_id = "gloriafood"99    request_delay = 1.0100    timeout = 30101    use_detail_cache = False       # fraîcheur gérée via details.gloriafood102    enrichment_only = True         # n'émet aucune fiche (ingest.run)103104    def _integrations(self) -> list[dict]:105        registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))106        out: list[dict] = []107        for src in registry.get("sources", []):108            if src.get("id") == self.source_id:109                out.extend(src.get("integrations") or [])110        if DISCOVERED.exists():111            try:112                extra = json.loads(DISCOVERED.read_text(encoding="utf-8"))113                known = {i.get("key") for i in out}114                out.extend(i for i in extra.get("integrations", [])115                           if i.get("key") not in known)116            except ValueError:117                pass118        return out119120    def _now(self) -> str:121        return datetime.datetime.now(datetime.timezone.utc) \122            .strftime("%Y-%m-%dT%H:%M:%SZ")123124    def _fetch_menu(self, ruid: str) -> dict | None:125        resp = self.get(MENU_API, headers={126            "Authorization": ruid,127            "Accept": "application/json",128            "Glf-Api-Version": "2",129        })130        return build_menu(resp.json(), self._now())131132    def fetch(self) -> list[Restaurant]:133        integrations = [i for i in self._integrations()134                        if i.get("key") and i.get("status") != "inactif"]135        if not integrations:136            raise SkipSource(137                "aucune intégration GloriaFood découverte "138                "(data/gloriafood-discovered.json vide) — connecteur dormant, "139                "en attente des découvertes du connecteur site-resto")140        from .. import db141        con = db.connect()142        now = time.time()143        menus = errors = 0144        for integ in integrations:145            ruid, uid = str(integ["key"]), integ.get("uid") or ""146            if not uid:147                print(f"[resto-ka] gloriafood: {ruid} sans uid porteur — "148                      "ignoré", file=sys.stderr)149                continue150            row = con.execute(151                "SELECT details FROM restaurants WHERE uid=? AND active=1",152                (uid,)).fetchone()153            if row is None:154                continue               # resto porteur disparu155            try:156                details = json.loads(row["details"] or "{}")157            except ValueError:158                details = {}159            gf = details.get("gloriafood") or {}160            try:161                ts = datetime.datetime.strptime(162                    gf.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \163                    .replace(tzinfo=datetime.timezone.utc).timestamp()164                if ts > now - REFRESH_DAYS * 86400:165                    continue           # déjà frais166            except (ValueError, TypeError):167                pass168            try:169                menu = self._fetch_menu(ruid)170            except Exception as exc:   # 403/refus : défensif, on continue171                errors += 1172                print(f"[resto-ka] gloriafood: {uid} ({ruid}) erreur: {exc}",173                      file=sys.stderr)174                continue175            if not menu:176                continue177            # validation stricte du schéma menu avant stockage178            Restaurant(source=self.source_id, external_id=ruid,179                       name=uid, menu=menu)._validate_menu()180            db.upsert_menu(con, uid, menu, time.time())181            db.merge_details(con, uid, {"gloriafood": {182                "ruid": ruid, "cuid": integ.get("cuid"),183                "fetched_at": self._now()}})184            con.commit()185            menus += 1186        con.close()187        self.enriched_count = menus188        self.enrich_message = (f"{menus} menu(s) takeout GloriaFood posés, "189                               f"{errors} refus/erreur(s) sur "190                               f"{len(integrations)} intégration(s)")191        print(f"[resto-ka] gloriafood: {self.enrich_message}")192        return []193