From 94a0922b07e81a35c623215a96181829db2729e4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Jan 2026 12:01:23 +0100 Subject: [PATCH] rewrote --- glance-system/glance-helper.yaml | 767 ++++++++++++++----------------- 1 file changed, 338 insertions(+), 429 deletions(-) diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml index e5d1e89..9e93a0d 100644 --- a/glance-system/glance-helper.yaml +++ b/glance-system/glance-helper.yaml @@ -10,80 +10,6 @@ spec: requests: storage: 200Mi --- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: glance-helper - namespace: glance-system - annotations: - reloader.stakater.com/auto: "true" -spec: - replicas: 1 - selector: - matchLabels: - app: glance-helper - template: - metadata: - labels: - app: glance-helper - spec: - containers: - - name: glance-helper - image: python:3.12-bookworm - imagePullPolicy: IfNotPresent - env: - - name: IDOKEP_URL - value: https://www.idokep.hu/idojaras/Budapest%20VII.%20ker - - name: PLACE_NAME - value: Budapest VII. ker - - name: TANDOOR_INTERNAL_URL - value: http://tandoor.tandoor-system.svc.cluster.local:8080 - - name: TANDOOR_PUBLIC_URL - value: https://tandoor.dooplex.hu - - name: TANDOOR_TOKEN - value: tda_8a8b169c_5d1f_4962_83a2_0f2719c7d61a - - name: GLANCE_HELPER_PUBLIC_URL - value: https://glance-helper.dooplex.hu - - name: DATA_DIR - value: /data - - name: GLANCE_HELPER_KEY - value: oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT - - name: TZ - value: Europe/Budapest - ports: - - containerPort: 8000 - command: - - /bin/sh - - -lc - args: - - 'set -eux; - - export DEBIAN_FRONTEND=noninteractive; - - apt-get update; - - apt-get install -y --no-install-recommends curl ca-certificates iputils-ping - dnsutils tzdata net-tools; - - rm -rf /var/lib/apt/lists/*; - - pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client; - - python -c "import uvicorn; uvicorn.run(''app:APP'', host=''0.0.0.0'', port=8000)"' - volumeMounts: - - name: app - mountPath: /app - - name: data - mountPath: /data - workingDir: /app - volumes: - - name: app - configMap: - name: glance-helper-app - - name: data - persistentVolumeClaim: - claimName: glance-helper-data ---- apiVersion: v1 kind: ConfigMap metadata: @@ -94,417 +20,419 @@ data: import os import time import re - from typing import List, Dict, Any,\ Optional - from datetime import datetime, timezone - from zoneinfo import ZoneInfo - from urllib.parse import urlparse, urlunparse - - import json - import random - from datetime import datetime - from\ zoneinfo import ZoneInfo - from pathlib import Path - from urllib.parse import\ urlparse, urlunparse - + from typing import List, Dict, Any, Optional + import requests from bs4 import BeautifulSoup - from\ fastapi import FastAPI, Response, Request, HTTPException, Query - from fastapi.responses\ import JSONResponse, RedirectResponse - from prometheus_client import Counter,\ Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST - + from fastapi import FastAPI, Response + from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST + APP = FastAPI() - + IDOKEP_URL = os.getenv( "IDOKEP_URL", - "https://www.idokep.hu/idojaras/Budapest%20VIII.%20ker", + "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker", ) - PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VIII. ker") - SOURCE_NAME\ = "Időkép" - + PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker") + SOURCE_NAME = "Időkép" + UA = os.getenv( "USER_AGENT", - "Mozilla/5.0\ (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari", ) - - # Glance-helper config - DATA_DIR = os.getenv("DATA_DIR", "/data") - TANDOOR_INTERNAL_URL\ = os.getenv("TANDOOR_INTERNAL_URL", "").rstrip("/") - TANDOOR_PUBLIC_URL\ = os.getenv("TANDOOR_PUBLIC_URL", "").rstrip("/") - GLANCE_HELPER_PUBLIC_URL\ = os.getenv("GLANCE_HELPER_PUBLIC_URL", "").rstrip("/") - GLANCE_HELPER_KEY\ = os.getenv("GLANCE_HELPER_KEY", "") - DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) - DATA_DIR.mkdir(parents=True, exist_ok=True) - COOKED_PATH = DATA_DIR / "tandoor-cooked.json" - PICKS_PATH = DATA_DIR / "tandoor-picks.json" - + # Prometheus metrics (optional) - SCRAPES = Counter("idokep_scrapes_total", "Total Időkép scrapes",\ ["place", "status"]) + SCRAPES = Counter("idokep_scrapes_total", "Total Időkép scrapes", ["place", "status"]) SCRAPE_SECONDS = Histogram("idokep_scrape_seconds", "Időkép scrape duration in seconds", ["place"]) - CURRENT_TEMP =\ Gauge("idokep_current_temp_c", "Current temperature in Celsius", ["place"]) - DAILY_TMIN = Gauge("idokep_daily_tmin_c", "Daily minimum temperature in\ Celsius", ["place", "dow"]) + CURRENT_TEMP = Gauge("idokep_current_temp_c", "Current temperature in Celsius", ["place"]) + DAILY_TMIN = Gauge("idokep_daily_tmin_c", "Daily minimum temperature in Celsius", ["place", "dow"]) DAILY_TMAX = Gauge("idokep_daily_tmax_c", "Daily maximum temperature in Celsius", ["place", "dow"]) - HOURLY_TEMP\ = Gauge("idokep_hourly_temp_c", "Hourly temperature in Celsius", ["place", "time"]) - - + HOURLY_TEMP = Gauge("idokep_hourly_temp_c", "Hourly temperature in Celsius", ["place", "time"]) + + def _abs_url(maybe_relative: Optional[str]) -> Optional[str]: - \ if not maybe_relative: + if not maybe_relative: return None if maybe_relative.startswith("http://") or maybe_relative.startswith("https://"): return maybe_relative - \ # Időkép uses /assets/... paths - return "https://www.idokep.hu"\ + maybe_relative - - + # Időkép uses /assets/... paths + return "https://www.idokep.hu" + maybe_relative + + def _to_int_temp(s: str) -> Optional[float]: - if not\ s: + if not s: return None s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "") try: return float(s) - \ except Exception: + except Exception: return None - - + + def scrape() -> Dict[str, Any]: - \ headers = {"User-Agent": UA} - r = requests.get(IDOKEP_URL, headers=headers,\ timeout=15) + headers = {"User-Agent": UA} + r = requests.get(IDOKEP_URL, headers=headers, timeout=15) r.raise_for_status() - + soup = BeautifulSoup(r.text, "html.parser") - + # Current cur_temp_el = soup.select_one(".current-temperature") cur_cond_el = soup.select_one(".current-weather") - cur_icon_el =\ soup.select_one(".forecast-bigicon") - - cur_temp = _to_int_temp(cur_temp_el.get_text(strip=True)\ if cur_temp_el else "") - cur_cond = cur_cond_el.get_text(strip=True) if\ cur_cond_el else "" - cur_icon = _abs_url(cur_icon_el.get("src") if cur_icon_el\ else None) - + cur_icon_el = soup.select_one(".forecast-bigicon") + + cur_temp = _to_int_temp(cur_temp_el.get_text(strip=True) if cur_temp_el else "") + cur_cond = cur_cond_el.get_text(strip=True) if cur_cond_el else "" + cur_icon = _abs_url(cur_icon_el.get("src") if cur_icon_el else None) + # Hourly cards (the block you highlighted in devtools: .ik.hourly-forecast-card) - \ hourly: List[Dict[str, Any]] = [] + hourly: List[Dict[str, Any]] = [] for card in soup.select(".ik.hourly-forecast-card")[:8]: t_el = card.select_one(".ik.hourly-forecast-hour") - \ temp_el = card.select_one(".ik.temperature-circled") + temp_el = card.select_one(".ik.temperature-circled") icon_el = card.select_one("img.ik.forecast-icon") - - t = t_el.get_text(strip=True) if t_el else\ "" - temp = _to_int_temp(temp_el.get_text(strip=True) if temp_el else\ "") + + t = t_el.get_text(strip=True) if t_el else "" + temp = _to_int_temp(temp_el.get_text(strip=True) if temp_el else "") icon = _abs_url(icon_el.get("src") if icon_el else None) - - \ if t and temp is not None: + + if t and temp is not None: hourly.append( - \ { + { "time": t, # e.g. "18:00" - \ "temp_c": temp, # e.g. -2 + "temp_c": temp, # e.g. -2 "icon_url": icon, # absolute URL } ) - - \ # Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol) - \ daily: List[Dict[str, Any]] = [] - for col in soup.select(".ik.daily-forecast-container\ .ik.dailyForecastCol")[:15]: + + # Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol) + daily: List[Dict[str, Any]] = [] + for col in soup.select(".ik.daily-forecast-container .ik.dailyForecastCol")[:15]: dow_el = col.select_one(".ik.dfDay") icon_el = col.select_one("img.ik.forecast-icon") - daynum_el\ = col.select_one(".ik.dfDayNum") - + daynum_el = col.select_one(".ik.dfDayNum") + # Normal structure (most days) - \ tmax_el = col.select_one("div.ik.max") + tmax_el = col.select_one("div.ik.max") tmin_el = col.select_one("div.ik.min") - - daynum = daynum_el.get_text(strip=True) if daynum_el\ else "" + + daynum = daynum_el.get_text(strip=True) if daynum_el else "" dow = dow_el.get_text(strip=True) if dow_el else "" - \ icon = _abs_url(icon_el.get("src") if icon_el else None) - - \ tmax = _to_int_temp(tmax_el.get_text(strip=True) if tmax_el else "") - \ tmin = _to_int_temp(tmin_el.get_text(strip=True) if tmin_el else "") - - # Fallback structure (e.g. "vacation" days) where div.ik.max/min are\ missing - # In those cases the visible temps are usually the first two\ numeric texts + icon = _abs_url(icon_el.get("src") if icon_el else None) + + tmax = _to_int_temp(tmax_el.get_text(strip=True) if tmax_el else "") + tmin = _to_int_temp(tmin_el.get_text(strip=True) if tmin_el else "") + + # Fallback structure (e.g. "vacation" days) where div.ik.max/min are missing + # In those cases the visible temps are usually the first two numeric texts # inside .ik.min-max-container (order: max, min). - \ if tmax is None or tmin is None: + if tmax is None or tmin is None: vals: List[str] = [] - \ for a in col.select(".ik.min-max-container a"): - \ txt = a.get_text(strip=True) + for a in col.select(".ik.min-max-container a"): + txt = a.get_text(strip=True) if re.fullmatch(r"-?\d+", txt or ""): vals.append(txt) - - if len(vals)\ >= 2: + + if len(vals) >= 2: tmax = _to_int_temp(vals[0]) - tmin =\ _to_int_temp(vals[1]) - + tmin = _to_int_temp(vals[1]) + # Keep only rows that look valid - if\ dow and (tmin is not None) and (tmax is not None): + if dow and (tmin is not None) and (tmax is not None): daily.append( - \ { + { "daynum": daynum, - \ "dow": dow, # e.g. "Cs", "P", "Sz" - \ "tmin_c": tmin, + "dow": dow, # e.g. "Cs", "P", "Sz" + "tmin_c": tmin, "tmax_c": tmax, - \ "icon_url": icon, + "icon_url": icon, } ) - - # Limit to 5\ days for your widget (first 5 columns in the table, including "vacation" days) - \ daily = daily[:5] - + + # Limit to 5 days for your widget (first 5 columns in the table, including "vacation" days) + daily = daily[:5] + return { - "source": {"name": SOURCE_NAME,\ "url": IDOKEP_URL}, + "source": {"name": SOURCE_NAME, "url": IDOKEP_URL}, "location": {"name": PLACE_NAME}, - \ "current": {"temp_c": cur_temp, "condition": cur_cond, "icon_url": cur_icon}, + "current": {"temp_c": cur_temp, "condition": cur_cond, "icon_url": cur_icon}, "hourly": hourly, "daily": daily, - \ "fetched_at_unix": int(time.time()), + "fetched_at_unix": int(time.time()), } - - + + @APP.get("/api") def api(): - \ status = "ok" + status = "ok" with SCRAPE_SECONDS.labels(place=PLACE_NAME).time(): - \ try: + try: data = scrape() except Exception: - \ status = "error" + status = "error" SCRAPES.labels(place=PLACE_NAME, status=status).inc() - \ raise - + raise + SCRAPES.labels(place=PLACE_NAME, status=status).inc() - + # Update Prometheus gauges (best-effort) try: if data.get("current", {}).get("temp_c") is not None: CURRENT_TEMP.labels(place=PLACE_NAME).set(float(data["current"]["temp_c"])) for d in data.get("daily", []): - \ DAILY_TMIN.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmin_c"])) + DAILY_TMIN.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmin_c"])) DAILY_TMAX.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmax_c"])) for h in data.get("hourly", []): - HOURLY_TEMP.labels(place=PLACE_NAME,\ time=h["time"]).set(float(h["temp_c"])) + HOURLY_TEMP.labels(place=PLACE_NAME, time=h["time"]).set(float(h["temp_c"])) except Exception: - \ pass - + pass + # IMPORTANT: force JSON content-type so Glance exposes `.JSON` - \ import json - return Response(content=json.dumps(data, ensure_ascii=False),\ media_type="application/json; charset=utf-8") - - + import json + return Response(content=json.dumps(data, ensure_ascii=False), media_type="application/json; charset=utf-8") + + @APP.get("/metrics") def metrics(): return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) - - - # ------------------------------- - # Tandoor helpers - # ------------------------------- + + ersion: v1 + : Service + data: + me: idokep-scraper + mespace: glance-system + : + lector: + app: idokep-scraper + rts: + - name: http + port: 8000 + + # ------------------------- + # Tandoor "Meal of the Day" + # ------------------------- + from datetime import datetime, timezone + from zoneinfo import ZoneInfo + from urllib.parse import urlencode + from fastapi import HTTPException, Query + from fastapi.responses import RedirectResponse + import json + import random + from pathlib import Path + + TANDOOR_INTERNAL_URL = os.getenv("TANDOOR_INTERNAL_URL", "").rstrip("/") + TANDOOR_PUBLIC_URL = os.getenv("TANDOOR_PUBLIC_URL", "").rstrip("/") + GLANCE_HELPER_PUBLIC_URL = os.getenv("GLANCE_HELPER_PUBLIC_URL", "").rstrip("/") + GLANCE_HELPER_KEY = os.getenv("GLANCE_HELPER_KEY", "") + DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) + DATA_DIR.mkdir(parents=True, exist_ok=True) + COOKED_PATH = DATA_DIR / "tandoor-cooked.json" + PICKS_PATH = DATA_DIR / "tandoor-picks.json" + def _today_str() -> str: - # Use Europe/Budapest for "day" boundaries (fallback\ to UTC if tzdata missing) + """YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC.""" try: - return datetime.now(tz=ZoneInfo("Europe/Budapest")).date().isoformat() + tz = ZoneInfo("Europe/Budapest") + return datetime.now(tz).strftime("%Y-%m-%d") except Exception: - return\ datetime.utcnow().date().isoformat() - + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + def _load_json(path: Path, default): - \ try: - with path.open("r", encoding="utf-8") as f: - \ return json.load(f) + try: + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) except Exception: - return default - - def\ _save_json(path: Path, data) -> None: - tmp = path.with_suffix(path.suffix\ + ".tmp") - with tmp.open("w", encoding="utf-8") as f: - json.dump(data,\ f, ensure_ascii=False, indent=2) + pass + return default + + def _save_json(path: Path, obj) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path) - - def _tandoor_headers()\ -> Dict[str, str]: + + def _tandoor_headers(): token = os.getenv("TANDOOR_TOKEN", "") - if not\ token: - return {"Accept": "application/json"} - return {"Accept": "application/json", "Authorization": f"Bearer {token}"} - - def _rewrite_to_public(maybe_url:\ Optional[str]) -> Optional[str]: - if not maybe_url: - return None - - # Relative path -> public - if maybe_url.startswith("/"): - \ return TANDOOR_PUBLIC_URL + maybe_url - - # If the API returns internal host\ URLs, rewrite scheme+host to public - try: - u = urlparse(maybe_url) - \ pub = urlparse(TANDOOR_PUBLIC_URL) - internal = urlparse(TANDOOR_INTERNAL_URL) - \ if u.netloc and internal.netloc and u.netloc == internal.netloc: - \ u = u._replace(scheme=pub.scheme, netloc=pub.netloc) - return\ urlunparse(u) - except Exception: - pass - - return maybe_url - - def _fetch_recipes_flat() -> List[Dict[str, Any]]: - # Prefer /api/recipe/flat/\ because it's already {id,name,image} list - flat_url = f"{TANDOOR_INTERNAL_URL}/api/recipe/flat/" - r = requests.get(flat_url, headers=_tandoor_headers(), timeout=15) - \ if r.status_code == 200: - data = r.json() - # Expected: list - \ if isinstance(data, list): - out = [] - for x in\ data: - out.append({ - "id": int(x.get("id", 0)), - "name": str(x.get("name", "")), - \ "image": _rewrite_to_public(x.get("image")), - \ }) - return [x for x in out if x["id"] and x["name"]] - - \ # Fallback: paginated /api/recipe/ - list_url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=250" - r = requests.get(list_url, headers=_tandoor_headers(), timeout=15) - \ r.raise_for_status() - data = r.json() - items = data.get("results", []) if isinstance(data, dict) else [] + if not token: + raise HTTPException(status_code=500, detail="TANDOOR_TOKEN is not set") + return {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + def _rewrite_to_public(url: str) -> str: + """Turn internal URLs into public ones (images/links).""" + if not url: + return url + if TANDOOR_PUBLIC_URL and TANDOOR_INTERNAL_URL and url.startswith(TANDOOR_INTERNAL_URL): + return TANDOOR_PUBLIC_URL + url[len(TANDOOR_INTERNAL_URL):] + return url + + def _fetch_all_recipes() -> list[dict]: + if not TANDOOR_INTERNAL_URL: + raise HTTPException(status_code=500, detail="TANDOOR_INTERNAL_URL is not set") + # Prefer paginated /api/recipe/ + url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=200" out = [] - for x in items: - \ out.append({ - "id": int(x.get("id", 0)), - \ "name": str(x.get("name", "")), - "image": _rewrite_to_public(x.get("image")), - }) - return [x for x in out if x["id"] and x["name"]] - - def _get_cooked_for_today() -> List[int]: + for _ in range(100): # safety + r = requests.get(url, headers=_tandoor_headers(), timeout=15) + if r.status_code != 200: + raise HTTPException(status_code=502, detail=f"Tandoor returned {r.status_code}: {r.text[:200]}") + j = r.json() + results = j.get("results", []) + out.extend(results) + url = j.get("next") + if not url: + break + # `next` may be absolute internal URL already; keep as-is. + return out + + def _compute_daily_picks(count: int) -> dict: today = _today_str() - \ cooked = _load_json(COOKED_PATH, {}) - ids = cooked.get(today, []) - \ # normalize - try: - return [int(i) for i in ids] - except Exception: - \ return [] - - def _set_cooked_today(ids: List[int]) -> None: - today\ = _today_str() cooked = _load_json(COOKED_PATH, {}) - cooked[today]\ = sorted(list({int(i) for i in ids})) - # Optional cleanup: keep only last\ 14 days - try: - keys = sorted(cooked.keys()) - if len(keys)\ > 14: - for k in keys[:-14]: - cooked.pop(k, None) - \ except Exception: - pass - _save_json(COOKED_PATH, cooked) - - def _get_picks_today() -> List[int]: - today = _today_str() - picks = _load_json(PICKS_PATH,\ {}) - ids = picks.get(today, []) - try: - return [int(i) for i\ in ids] - except Exception: - return [] - - def _set_picks_today(ids:\ List[int]) -> None: - today = _today_str() - picks = _load_json(PICKS_PATH,\ {}) - picks[today] = [int(i) for i in ids if int(i) > 0] - # cleanup old\ days - try: - keys = sorted(picks.keys()) - if len(keys) >\ 14: - for k in keys[:-14]: - picks.pop(k, None) - \ except Exception: - pass - _save_json(PICKS_PATH, picks) - - def\ _ensure_daily_picks(recipes: List[Dict[str, Any]], count: int) -> List[int]: - \ cooked = set(_get_cooked_for_today()) - picks = _get_picks_today() - - \ # Remove picks that are cooked today - picks = [i for i in picks if i\ not in cooked] - - # Top up to requested count if needed - if len(picks)\ < count: - available = [r["id"] for r in recipes if r["id"] not in\ cooked and r["id"] not in picks] - # If everything is cooked (or too\ few recipes), allow repeats from all recipes - if len(available) < (count\ - len(picks)): - available = [r["id"] for r in recipes if r["id"] not in picks] - - need = max(0, count - len(picks)) - if need\ > 0 and available: - picks += random.sample(available, k=min(need,\ len(available))) - - # If no picks yet (first call today), choose fresh - \ if not picks: - available = [r["id"] for r in recipes if r["id"] not in cooked] - if not available: - available = [r["id"] for r in recipes] - picks = random.sample(available, k=min(count, len(available))) - - _set_picks_today(picks) - return picks - - @APP.get("/tandoor/daily") - def tandoor_daily(count: int = Query(3, ge=1, le=10)): - try: - recipes\ = _fetch_recipes_flat() - except Exception as e: - raise HTTPException(status_code=502,\ detail=f"Failed to fetch recipes from Tandoor: {e}") - - if not recipes: - \ return JSONResponse({"date": _today_str(), "total_recipes": 0, "items": []}) - - ids = _ensure_daily_picks(recipes, count) - by_id = {r["id"]: r for r in recipes} - + cooked_today = set(cooked.get(today, [])) + + picks_doc = _load_json(PICKS_PATH, {}) + if picks_doc.get("date") == today and isinstance(picks_doc.get("ids"), list) and picks_doc.get("count") == count: + return picks_doc # stable for the day + + recipes = _fetch_all_recipes() + # Make a stable deterministic base random seed once, but still "true daily random": + # created once per day and stored. + available = [r for r in recipes if r.get("id") not in cooked_today] + if not available: + available = recipes[:] # fallback: if everything cooked, ignore filter + + chosen = random.sample(available, k=min(count, len(available))) if available else [] + picks_doc = {"date": today, "count": count, "ids": [r.get("id") for r in chosen if r.get("id") is not None]} + _save_json(PICKS_PATH, picks_doc) + return picks_doc + + def _build_items_from_ids(ids: list[int]) -> tuple[list[dict], int]: + recipes = _fetch_all_recipes() + by_id = {r.get("id"): r for r in recipes if r.get("id") is not None} items = [] for rid in ids: - r =\ by_id.get(rid) + r = by_id.get(rid) if not r: continue - items.append({ - \ "id": r["id"], - "name": r["name"], - \ "image": r.get("image"), - "url": f"{TANDOOR_PUBLIC_URL}/recipe/{r['id']}", - # state-changing endpoint requires key if set - "cook_url": f"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?id={r['id']}" + (f"&key={GLANCE_HELPER_KEY}"\ if GLANCE_HELPER_KEY else ""), - }) - - return JSONResponse({ - \ "date": _today_str(), - "total_recipes": len(recipes), - \ "items": items, - }) - + img = _rewrite_to_public(r.get("image") or "") + url = f"{TANDOOR_PUBLIC_URL}/recipe/{rid}" if TANDOOR_PUBLIC_URL else "" + cook_params = {"id": rid} + if GLANCE_HELPER_KEY: + cook_params["key"] = GLANCE_HELPER_KEY + cook_url = f"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?{urlencode(cook_params)}" if GLANCE_HELPER_PUBLIC_URL else "" + items.append({"id": rid, "name": r.get("name") or "", "image": img, "url": url, "cook_url": cook_url}) + return items, len(recipes) + + @APP.get("/tandoor/daily") + def tandoor_daily(count: int = Query(3, ge=1, le=10)): + picks_doc = _compute_daily_picks(count) + ids = picks_doc.get("ids", []) + items, total = _build_items_from_ids(ids) + return {"date": picks_doc.get("date"), "total_recipes": total, "items": items} + @APP.get("/tandoor/cook") - def tandoor_cook( - \ id: int = Query(..., ge=1), - key: str = Query("", alias="key"), - \ redirect: str = Query("", alias="redirect") - ): - # Protect state-changing\ calls with a shared key (recommended) + def tandoor_cook(id: int, key: str | None = None, redirect: str | None = None): if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY: - \ raise HTTPException(status_code=403, detail="Forbidden") - - cooked\ = set(_get_cooked_for_today()) - cooked.add(int(id)) - _set_cooked_today(list(cooked)) - - # Also remove from today's picks (so daily list can refill) - picks =\ [i for i in _get_picks_today() if i != int(id)] - _set_picks_today(picks) - + raise HTTPException(status_code=403, detail="Invalid key") + + today = _today_str() + cooked = _load_json(COOKED_PATH, {}) + cooked_today = set(cooked.get(today, [])) + cooked_today.add(id) + cooked[today] = sorted(list(cooked_today)) + _save_json(COOKED_PATH, cooked) + + # Remove from today's picks and try to refill to keep count + picks = _load_json(PICKS_PATH, {}) + if picks.get("date") == today and isinstance(picks.get("ids"), list): + ids = [x for x in picks["ids"] if x != id] + target = int(picks.get("count") or len(ids)) + if len(ids) < target: + recipes = _fetch_all_recipes() + avoid = set(ids) | cooked_today + candidates = [r.get("id") for r in recipes if r.get("id") is not None and r.get("id") not in avoid] + if candidates: + ids.append(random.choice(candidates)) + picks["ids"] = ids[:target] + _save_json(PICKS_PATH, picks) + if redirect: return RedirectResponse(url=redirect, status_code=302) - - return JSONResponse({"ok": True, "date": _today_str(), "cooked_today": sorted(list(cooked))}) + return {"ok": True, "date": today, "cooked": id} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: glance-helper + namespace: glance-system + labels: + app.kubernetes.io/name: glance-helper +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: glance-helper + template: + metadata: + labels: + app.kubernetes.io/name: glance-helper + spec: + containers: + - name: glance-helper + image: python:3.12-bookworm + ports: + - containerPort: 8000 + env: + - name: IDOKEP_URL + value: "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker" + - name: PLACE_NAME + value: "Budapest VII. ker" + - name: TZ + value: "Europe/Budapest" + - name: TANDOOR_INTERNAL_URL + value: "http://tandoor.tandoor-system.svc.cluster.local:8080" + - name: TANDOOR_PUBLIC_URL + value: "https://tandoor.dooplex.hu" + - name: GLANCE_HELPER_PUBLIC_URL + value: "https://glance-helper.dooplex.hu" + - name: GLANCE_HELPER_KEY + value: "oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT" + - name: DATA_DIR + value: "/data" + - name: TANDOOR_TOKEN + value: "tda_8a8b169c_5d1f_4962_83a2_0f2719c7d61a" + volumeMounts: + - name: app + mountPath: /app + - name: data + mountPath: /data + command: ["/bin/bash","-lc"] + args: + - | + set -e + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl tzdata dnsutils iputils-ping + pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus_client + cd /app + uvicorn app:APP --host 0.0.0.0 --port 8000 + volumes: + - name: app + configMap: + name: glance-helper-app + - name: data + persistentVolumeClaim: + claimName: glance-helper-data +--- +apiVersion: v1 +kind: Service +metadata: + name: glance-helper + namespace: glance-system +spec: + selector: + app.kubernetes.io/name: glance-helper + ports: + - name: http + port: 8000 + targetPort: 8000 --- apiVersion: v1 kind: Service @@ -513,54 +441,35 @@ metadata: namespace: glance-system spec: selector: - app: glance-helper + app.kubernetes.io/name: glance-helper ports: - - name: http - port: 8000 - targetPort: 8000 ---- -apiVersion: v1 -kind: Service -metadata: - name: glance-helper - namespace: glance-system -spec: - selector: - app: glance-helper - ports: - - name: http - port: 8000 - targetPort: 8000 + - name: http + port: 8000 + targetPort: 8000 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - annotations: - argocd.argoproj.io/tracking-id: glance:networking.k8s.io/Ingress:glance-system/glance-helper - cert-manager.io/cluster-issuer: letsencrypt-prod - external-dns.alpha.kubernetes.io/hostname: glance-helper.dooplex.hu,glance-helper.home - nginx.ingress.kubernetes.io/proxy-body-size: 10m - nginx.ingress.kubernetes.io/ssl-redirect: '"true"' name: glance-helper namespace: glance-system + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + external-dns.alpha.kubernetes.io/hostname: glance-helper.dooplex.hu + nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: ingressClassName: nginx-internal rules: - - host: glance-helper.dooplex.hu - http: - paths: - - backend: - service: - name: glance-helper - port: - number: 8000 - path: / - pathType: Prefix + - host: glance-helper.dooplex.hu + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: glance-helper + port: + number: 8000 tls: - - hosts: - - glance-helper.dooplex.hu - secretName: glance-helper-tls -status: - loadBalancer: - ingress: - - ip: 192.168.0.192 \ No newline at end of file + - hosts: + - glance-helper.dooplex.hu + secretName: glance-helper-tls