This commit is contained in:
2026-01-15 12:01:23 +01:00
parent 6b3735c6c9
commit 94a0922b07
+290 -381
View File
@@ -10,80 +10,6 @@ spec:
requests: requests:
storage: 200Mi 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 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
@@ -94,80 +20,58 @@ data:
import os import os
import time import time
import re import re
from typing import List, Dict, Any,\ Optional 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
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from\ fastapi import FastAPI, Response, Request, HTTPException, Query from fastapi import FastAPI, Response
from fastapi.responses\ import JSONResponse, RedirectResponse from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
from prometheus_client import Counter,\ Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
APP = FastAPI() APP = FastAPI()
IDOKEP_URL = os.getenv( IDOKEP_URL = os.getenv(
"IDOKEP_URL", "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") PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker")
SOURCE_NAME\ = "Időkép" SOURCE_NAME = "Időkép"
UA = os.getenv( UA = os.getenv(
"USER_AGENT", "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) # 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"]) 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"]) 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_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"]) 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]: def _abs_url(maybe_relative: Optional[str]) -> Optional[str]:
\ if not maybe_relative: if not maybe_relative:
return None return None
if maybe_relative.startswith("http://") or maybe_relative.startswith("https://"): if maybe_relative.startswith("http://") or maybe_relative.startswith("https://"):
return maybe_relative return maybe_relative
\ # Időkép uses /assets/... paths # Időkép uses /assets/... paths
return "https://www.idokep.hu"\ + maybe_relative return "https://www.idokep.hu" + maybe_relative
def _to_int_temp(s: str) -> Optional[float]: def _to_int_temp(s: str) -> Optional[float]:
if not\ s: if not s:
return None return None
s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "") s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "")
try: try:
return float(s) return float(s)
\ except Exception: except Exception:
return None return None
def scrape() -> Dict[str, Any]: def scrape() -> Dict[str, Any]:
\ headers = {"User-Agent": UA} headers = {"User-Agent": UA}
r = requests.get(IDOKEP_URL, headers=headers,\ timeout=15) r = requests.get(IDOKEP_URL, headers=headers, timeout=15)
r.raise_for_status() r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser") soup = BeautifulSoup(r.text, "html.parser")
@@ -175,99 +79,99 @@ data:
# Current # Current
cur_temp_el = soup.select_one(".current-temperature") cur_temp_el = soup.select_one(".current-temperature")
cur_cond_el = soup.select_one(".current-weather") cur_cond_el = soup.select_one(".current-weather")
cur_icon_el =\ soup.select_one(".forecast-bigicon") 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_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_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 = _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 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]: for card in soup.select(".ik.hourly-forecast-card")[:8]:
t_el = card.select_one(".ik.hourly-forecast-hour") 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") icon_el = card.select_one("img.ik.forecast-icon")
t = t_el.get_text(strip=True) if t_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\ "") 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) 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( hourly.append(
\ { {
"time": t, # e.g. "18:00" "time": t, # e.g. "18:00"
\ "temp_c": temp, # e.g. -2 "temp_c": temp, # e.g. -2
"icon_url": icon, # absolute URL "icon_url": icon, # absolute URL
} }
) )
\ # Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol) # Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol)
\ daily: List[Dict[str, Any]] = [] daily: List[Dict[str, Any]] = []
for col in soup.select(".ik.daily-forecast-container\ .ik.dailyForecastCol")[:15]: for col in soup.select(".ik.daily-forecast-container .ik.dailyForecastCol")[:15]:
dow_el = col.select_one(".ik.dfDay") dow_el = col.select_one(".ik.dfDay")
icon_el = col.select_one("img.ik.forecast-icon") 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) # 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") 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 "" dow = dow_el.get_text(strip=True) if dow_el else ""
\ icon = _abs_url(icon_el.get("src") if icon_el else None) 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 "") 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 "") 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 # 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 <a> texts # In those cases the visible temps are usually the first two numeric <a> texts
# inside .ik.min-max-container (order: max, min). # 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] = [] vals: List[str] = []
\ for a in col.select(".ik.min-max-container a"): for a in col.select(".ik.min-max-container a"):
\ txt = a.get_text(strip=True) txt = a.get_text(strip=True)
if re.fullmatch(r"-?\d+", txt or ""): if re.fullmatch(r"-?\d+", txt or ""):
vals.append(txt) vals.append(txt)
if len(vals)\ >= 2: if len(vals) >= 2:
tmax = _to_int_temp(vals[0]) tmax = _to_int_temp(vals[0])
tmin =\ _to_int_temp(vals[1]) tmin = _to_int_temp(vals[1])
# Keep only rows that look valid # 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( daily.append(
\ { {
"daynum": daynum, "daynum": daynum,
\ "dow": dow, # e.g. "Cs", "P", "Sz" "dow": dow, # e.g. "Cs", "P", "Sz"
\ "tmin_c": tmin, "tmin_c": tmin,
"tmax_c": tmax, "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) # Limit to 5 days for your widget (first 5 columns in the table, including "vacation" days)
\ daily = daily[:5] daily = daily[:5]
return { return {
"source": {"name": SOURCE_NAME,\ "url": IDOKEP_URL}, "source": {"name": SOURCE_NAME, "url": IDOKEP_URL},
"location": {"name": PLACE_NAME}, "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, "hourly": hourly,
"daily": daily, "daily": daily,
\ "fetched_at_unix": int(time.time()), "fetched_at_unix": int(time.time()),
} }
@APP.get("/api") @APP.get("/api")
def api(): def api():
\ status = "ok" status = "ok"
with SCRAPE_SECONDS.labels(place=PLACE_NAME).time(): with SCRAPE_SECONDS.labels(place=PLACE_NAME).time():
\ try: try:
data = scrape() data = scrape()
except Exception: except Exception:
\ status = "error" status = "error"
SCRAPES.labels(place=PLACE_NAME, status=status).inc() SCRAPES.labels(place=PLACE_NAME, status=status).inc()
\ raise raise
SCRAPES.labels(place=PLACE_NAME, status=status).inc() SCRAPES.labels(place=PLACE_NAME, status=status).inc()
@@ -276,235 +180,259 @@ data:
if data.get("current", {}).get("temp_c") is not None: if data.get("current", {}).get("temp_c") is not None:
CURRENT_TEMP.labels(place=PLACE_NAME).set(float(data["current"]["temp_c"])) CURRENT_TEMP.labels(place=PLACE_NAME).set(float(data["current"]["temp_c"]))
for d in data.get("daily", []): 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"])) DAILY_TMAX.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmax_c"]))
for h in data.get("hourly", []): 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: except Exception:
\ pass pass
# IMPORTANT: force JSON content-type so Glance exposes `.JSON` # IMPORTANT: force JSON content-type so Glance exposes `.JSON`
\ import json import json
return Response(content=json.dumps(data, ensure_ascii=False),\ media_type="application/json; charset=utf-8") return Response(content=json.dumps(data, ensure_ascii=False), media_type="application/json; charset=utf-8")
@APP.get("/metrics") @APP.get("/metrics")
def metrics(): def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
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"
# -------------------------------
# Tandoor helpers
# -------------------------------
def _today_str() -> str: 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: try:
return datetime.now(tz=ZoneInfo("Europe/Budapest")).date().isoformat() tz = ZoneInfo("Europe/Budapest")
return datetime.now(tz).strftime("%Y-%m-%d")
except Exception: except Exception:
return\ datetime.utcnow().date().isoformat() return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _load_json(path: Path, default): def _load_json(path: Path, default):
\ try: try:
with path.open("r", encoding="utf-8") as f: if path.exists():
\ return json.load(f) return json.loads(path.read_text(encoding="utf-8"))
except Exception: except Exception:
return default pass
return default
def\ _save_json(path: Path, data) -> None: def _save_json(path: Path, obj) -> None:
tmp = path.with_suffix(path.suffix\ + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f: tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
json.dump(data,\ f, ensure_ascii=False, indent=2)
tmp.replace(path) tmp.replace(path)
def _tandoor_headers()\ -> Dict[str, str]: def _tandoor_headers():
token = os.getenv("TANDOOR_TOKEN", "") token = os.getenv("TANDOOR_TOKEN", "")
if not\ token: if not token:
return {"Accept": "application/json"} raise HTTPException(status_code=500, detail="TANDOOR_TOKEN is not set")
return {"Accept": "application/json", "Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
def _rewrite_to_public(maybe_url:\ Optional[str]) -> Optional[str]: def _rewrite_to_public(url: str) -> str:
if not maybe_url: """Turn internal URLs into public ones (images/links)."""
return None 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
# Relative path -> public def _fetch_all_recipes() -> list[dict]:
if maybe_url.startswith("/"): if not TANDOOR_INTERNAL_URL:
\ return TANDOOR_PUBLIC_URL + maybe_url raise HTTPException(status_code=500, detail="TANDOOR_INTERNAL_URL is not set")
# Prefer paginated /api/recipe/
# If the API returns internal host\ URLs, rewrite scheme+host to public url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=200"
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 []
out = [] out = []
for x in items: for _ in range(100): # safety
\ out.append({ r = requests.get(url, headers=_tandoor_headers(), timeout=15)
"id": int(x.get("id", 0)), if r.status_code != 200:
\ "name": str(x.get("name", "")), raise HTTPException(status_code=502, detail=f"Tandoor returned {r.status_code}: {r.text[:200]}")
"image": _rewrite_to_public(x.get("image")), j = r.json()
}) results = j.get("results", [])
return [x for x in out if x["id"] and x["name"]] out.extend(results)
url = j.get("next")
if not url:
break
# `next` may be absolute internal URL already; keep as-is.
return out
def _get_cooked_for_today() -> List[int]: def _compute_daily_picks(count: int) -> dict:
today = _today_str() 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 = _load_json(COOKED_PATH, {})
cooked[today]\ = sorted(list({int(i) for i in ids})) cooked_today = set(cooked.get(today, []))
# 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]: picks_doc = _load_json(PICKS_PATH, {})
today = _today_str() if picks_doc.get("date") == today and isinstance(picks_doc.get("ids"), list) and picks_doc.get("count") == count:
picks = _load_json(PICKS_PATH,\ {}) return picks_doc # stable for the day
ids = picks.get(today, [])
try:
return [int(i) for i\ in ids]
except Exception:
return []
def _set_picks_today(ids:\ List[int]) -> None: recipes = _fetch_all_recipes()
today = _today_str() # Make a stable deterministic base random seed once, but still "true daily random":
picks = _load_json(PICKS_PATH,\ {}) # created once per day and stored.
picks[today] = [int(i) for i in ids if int(i) > 0] available = [r for r in recipes if r.get("id") not in cooked_today]
# cleanup old\ days if not available:
try: available = recipes[:] # fallback: if everything cooked, ignore filter
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]: chosen = random.sample(available, k=min(count, len(available))) if available else []
\ cooked = set(_get_cooked_for_today()) picks_doc = {"date": today, "count": count, "ids": [r.get("id") for r in chosen if r.get("id") is not None]}
picks = _get_picks_today() _save_json(PICKS_PATH, picks_doc)
return picks_doc
\ # Remove picks that are cooked today def _build_items_from_ids(ids: list[int]) -> tuple[list[dict], int]:
picks = [i for i in picks if i\ not in cooked] recipes = _fetch_all_recipes()
by_id = {r.get("id"): r for r in recipes if r.get("id") is not None}
# Top up to requested count if needed items = []
if len(picks)\ < count: for rid in ids:
available = [r["id"] for r in recipes if r["id"] not in\ cooked and r["id"] not in picks] r = by_id.get(rid)
# If everything is cooked (or too\ few recipes), allow repeats from all recipes if not r:
if len(available) < (count\ - len(picks)): continue
available = [r["id"] for r in recipes if r["id"] not in picks] img = _rewrite_to_public(r.get("image") or "")
url = f"{TANDOOR_PUBLIC_URL}/recipe/{rid}" if TANDOOR_PUBLIC_URL else ""
need = max(0, count - len(picks)) cook_params = {"id": rid}
if need\ > 0 and available: if GLANCE_HELPER_KEY:
picks += random.sample(available, k=min(need,\ len(available))) cook_params["key"] = GLANCE_HELPER_KEY
cook_url = f"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?{urlencode(cook_params)}" if GLANCE_HELPER_PUBLIC_URL else ""
# If no picks yet (first call today), choose fresh items.append({"id": rid, "name": r.get("name") or "", "image": img, "url": url, "cook_url": cook_url})
\ if not picks: return items, len(recipes)
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") @APP.get("/tandoor/daily")
def tandoor_daily(count: int = Query(3, ge=1, le=10)): def tandoor_daily(count: int = Query(3, ge=1, le=10)):
try: picks_doc = _compute_daily_picks(count)
recipes\ = _fetch_recipes_flat() ids = picks_doc.get("ids", [])
except Exception as e: items, total = _build_items_from_ids(ids)
raise HTTPException(status_code=502,\ detail=f"Failed to fetch recipes from Tandoor: {e}") return {"date": picks_doc.get("date"), "total_recipes": total, "items": items}
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}
items = []
for rid in ids:
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,
})
@APP.get("/tandoor/cook") @APP.get("/tandoor/cook")
def tandoor_cook( def tandoor_cook(id: int, key: str | None = None, redirect: str | None = None):
\ id: int = Query(..., ge=1),
key: str = Query("", alias="key"),
\ redirect: str = Query("", alias="redirect")
):
# Protect state-changing\ calls with a shared key (recommended)
if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY: if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY:
\ raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Invalid key")
cooked\ = set(_get_cooked_for_today()) today = _today_str()
cooked.add(int(id)) cooked = _load_json(COOKED_PATH, {})
_set_cooked_today(list(cooked)) cooked_today = set(cooked.get(today, []))
cooked_today.add(id)
cooked[today] = sorted(list(cooked_today))
_save_json(COOKED_PATH, cooked)
# Also remove from today's picks (so daily list can refill) # Remove from today's picks and try to refill to keep count
picks =\ [i for i in _get_picks_today() if i != int(id)] picks = _load_json(PICKS_PATH, {})
_set_picks_today(picks) 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: if redirect:
return RedirectResponse(url=redirect, status_code=302) return RedirectResponse(url=redirect, status_code=302)
return {"ok": True, "date": today, "cooked": id}
return JSONResponse({"ok": True, "date": _today_str(), "cooked_today": sorted(list(cooked))}) ---
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 apiVersion: v1
kind: Service kind: Service
@@ -513,54 +441,35 @@ metadata:
namespace: glance-system namespace: glance-system
spec: spec:
selector: selector:
app: glance-helper app.kubernetes.io/name: glance-helper
ports: ports:
- name: http - name: http
port: 8000 port: 8000
targetPort: 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
--- ---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: 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 name: glance-helper
namespace: glance-system 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: spec:
ingressClassName: nginx-internal ingressClassName: nginx-internal
rules: rules:
- host: glance-helper.dooplex.hu - host: glance-helper.dooplex.hu
http: http:
paths: paths:
- backend: - path: /
service: pathType: Prefix
name: glance-helper backend:
port: service:
number: 8000 name: glance-helper
path: / port:
pathType: Prefix number: 8000
tls: tls:
- hosts: - hosts:
- glance-helper.dooplex.hu - glance-helper.dooplex.hu
secretName: glance-helper-tls secretName: glance-helper-tls
status:
loadBalancer:
ingress:
- ip: 192.168.0.192