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/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