diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml
index 0145fd4..f14f788 100644
--- a/glance-system/glance-helper.yaml
+++ b/glance-system/glance-helper.yaml
@@ -88,195 +88,418 @@ metadata:
name: glance-helper-app
namespace: glance-system
data:
- app.py: "import os\nimport time\nimport re\nfrom typing import List, Dict, Any,\
- \ Optional\n\nimport json\nimport random\nfrom datetime import datetime\nfrom\
- \ zoneinfo import ZoneInfo\nfrom pathlib import Path\nfrom urllib.parse import\
- \ urlparse, urlunparse\n\nimport requests\nfrom bs4 import BeautifulSoup\nfrom\
- \ fastapi import FastAPI, Response, Request, HTTPException, Query\nfrom fastapi.responses\
- \ import JSONResponse, RedirectResponse\nfrom prometheus_client import Counter,\
- \ Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST\n\nAPP = FastAPI()\n\n\
- IDOKEP_URL = os.getenv(\n \"IDOKEP_URL\",\n \"https://www.idokep.hu/idojaras/Budapest%20VIII.%20ker\"\
- ,\n)\nPLACE_NAME = os.getenv(\"PLACE_NAME\", \"Budapest VIII. ker\")\nSOURCE_NAME\
- \ = \"Id\u0151k\xE9p\"\n\nUA = os.getenv(\n \"USER_AGENT\",\n \"Mozilla/5.0\
- \ (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari\",\n\
- )\n\n# Glance-helper config\nDATA_DIR = os.getenv(\"DATA_DIR\", \"/data\")\nTANDOOR_INTERNAL_URL\
- \ = os.getenv(\"TANDOOR_INTERNAL_URL\", \"\").rstrip(\"/\")\nTANDOOR_PUBLIC_URL\
- \ = os.getenv(\"TANDOOR_PUBLIC_URL\", \"\").rstrip(\"/\")\nGLANCE_HELPER_PUBLIC_URL\
- \ = os.getenv(\"GLANCE_HELPER_PUBLIC_URL\", \"\").rstrip(\"/\")\nGLANCE_HELPER_KEY\
- \ = os.getenv(\"GLANCE_HELPER_KEY\", \"\")\n\n\n# Prometheus metrics (optional)\n\
- SCRAPES = Counter(\"idokep_scrapes_total\", \"Total Id\u0151k\xE9p scrapes\",\
- \ [\"place\", \"status\"])\nSCRAPE_SECONDS = Histogram(\"idokep_scrape_seconds\"\
- , \"Id\u0151k\xE9p scrape duration in seconds\", [\"place\"])\nCURRENT_TEMP =\
- \ Gauge(\"idokep_current_temp_c\", \"Current temperature in Celsius\", [\"place\"\
- ])\nDAILY_TMIN = Gauge(\"idokep_daily_tmin_c\", \"Daily minimum temperature in\
- \ Celsius\", [\"place\", \"dow\"])\nDAILY_TMAX = Gauge(\"idokep_daily_tmax_c\"\
- , \"Daily maximum temperature in Celsius\", [\"place\", \"dow\"])\nHOURLY_TEMP\
- \ = Gauge(\"idokep_hourly_temp_c\", \"Hourly temperature in Celsius\", [\"place\"\
- , \"time\"])\n\n\ndef _abs_url(maybe_relative: Optional[str]) -> Optional[str]:\n\
- \ if not maybe_relative:\n return None\n if maybe_relative.startswith(\"\
- http://\") or maybe_relative.startswith(\"https://\"):\n return maybe_relative\n\
- \ # Id\u0151k\xE9p uses /assets/... paths\n return \"https://www.idokep.hu\"\
- \ + maybe_relative\n\n\ndef _to_int_temp(s: str) -> Optional[float]:\n if not\
- \ s:\n return None\n s = s.strip().replace(\"\u02DAC\", \"\").replace(\"\
- \xB0C\", \"\").replace(\"\xB0\", \"\")\n try:\n return float(s)\n \
- \ except Exception:\n return None\n\n\ndef scrape() -> Dict[str, Any]:\n\
- \ headers = {\"User-Agent\": UA}\n r = requests.get(IDOKEP_URL, headers=headers,\
- \ timeout=15)\n r.raise_for_status()\n\n soup = BeautifulSoup(r.text, \"\
- html.parser\")\n\n # Current\n cur_temp_el = soup.select_one(\".current-temperature\"\
- )\n cur_cond_el = soup.select_one(\".current-weather\")\n cur_icon_el =\
- \ soup.select_one(\".forecast-bigicon\")\n\n cur_temp = _to_int_temp(cur_temp_el.get_text(strip=True)\
- \ if cur_temp_el else \"\")\n cur_cond = cur_cond_el.get_text(strip=True) if\
- \ cur_cond_el else \"\"\n cur_icon = _abs_url(cur_icon_el.get(\"src\") if cur_icon_el\
- \ else None)\n\n # Hourly cards (the block you highlighted in devtools: .ik.hourly-forecast-card)\n\
- \ hourly: List[Dict[str, Any]] = []\n for card in soup.select(\".ik.hourly-forecast-card\"\
- )[:8]:\n t_el = card.select_one(\".ik.hourly-forecast-hour\")\n \
- \ temp_el = card.select_one(\".ik.temperature-circled\")\n icon_el = card.select_one(\"\
- img.ik.forecast-icon\")\n\n t = t_el.get_text(strip=True) if t_el else\
- \ \"\"\n temp = _to_int_temp(temp_el.get_text(strip=True) if temp_el else\
- \ \"\")\n icon = _abs_url(icon_el.get(\"src\") if icon_el else None)\n\n\
- \ if t and temp is not None:\n hourly.append(\n \
- \ {\n \"time\": t, # e.g. \"18:00\"\n \
- \ \"temp_c\": temp, # e.g. -2\n \"\
- icon_url\": icon, # absolute URL\n }\n )\n\n\
- \ # Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol)\n\
- \ daily: List[Dict[str, Any]] = []\n for col in soup.select(\".ik.daily-forecast-container\
- \ .ik.dailyForecastCol\")[:15]:\n dow_el = col.select_one(\".ik.dfDay\"\
- )\n icon_el = col.select_one(\"img.ik.forecast-icon\")\n daynum_el\
- \ = col.select_one(\".ik.dfDayNum\")\n\n # Normal structure (most days)\n\
- \ tmax_el = col.select_one(\"div.ik.max\")\n tmin_el = col.select_one(\"\
- div.ik.min\")\n \n daynum = daynum_el.get_text(strip=True) if daynum_el\
- \ else \"\"\n dow = dow_el.get_text(strip=True) if dow_el else \"\"\n \
- \ icon = _abs_url(icon_el.get(\"src\") if icon_el else None)\n\n \
- \ tmax = _to_int_temp(tmax_el.get_text(strip=True) if tmax_el else \"\")\n \
- \ tmin = _to_int_temp(tmin_el.get_text(strip=True) if tmin_el else \"\")\n\
- \n # Fallback structure (e.g. \"vacation\" days) where div.ik.max/min are\
- \ missing\n # In those cases the visible temps are usually the first two\
- \ numeric texts\n # inside .ik.min-max-container (order: max, min).\n\
- \ if tmax is None or tmin is None:\n vals: List[str] = []\n\
- \ for a in col.select(\".ik.min-max-container a\"):\n \
- \ txt = a.get_text(strip=True)\n if re.fullmatch(r\"-?\\d+\"\
- , txt or \"\"):\n vals.append(txt)\n\n if len(vals)\
- \ >= 2:\n tmax = _to_int_temp(vals[0])\n tmin =\
- \ _to_int_temp(vals[1])\n\n # Keep only rows that look valid\n if\
- \ dow and (tmin is not None) and (tmax is not None):\n daily.append(\n\
- \ {\n \"daynum\": daynum,\n \
- \ \"dow\": dow, # e.g. \"Cs\", \"P\", \"Sz\"\n \
- \ \"tmin_c\": tmin,\n \"tmax_c\": tmax,\n \
- \ \"icon_url\": icon,\n }\n )\n\n # Limit to 5\
- \ days for your widget (first 5 columns in the table, including \"vacation\" days)\n\
- \ daily = daily[:5]\n\n return {\n \"source\": {\"name\": SOURCE_NAME,\
- \ \"url\": IDOKEP_URL},\n \"location\": {\"name\": PLACE_NAME},\n \
- \ \"current\": {\"temp_c\": cur_temp, \"condition\": cur_cond, \"icon_url\"\
- : cur_icon},\n \"hourly\": hourly,\n \"daily\": daily,\n \
- \ \"fetched_at_unix\": int(time.time()),\n }\n\n\n@APP.get(\"/api\")\ndef api():\n\
- \ status = \"ok\"\n with SCRAPE_SECONDS.labels(place=PLACE_NAME).time():\n\
- \ try:\n data = scrape()\n except Exception:\n \
- \ status = \"error\"\n SCRAPES.labels(place=PLACE_NAME, status=status).inc()\n\
- \ raise\n\n SCRAPES.labels(place=PLACE_NAME, status=status).inc()\n\
- \n # Update Prometheus gauges (best-effort)\n try:\n if data.get(\"\
- current\", {}).get(\"temp_c\") is not None:\n CURRENT_TEMP.labels(place=PLACE_NAME).set(float(data[\"\
- current\"][\"temp_c\"]))\n for d in data.get(\"daily\", []):\n \
- \ DAILY_TMIN.labels(place=PLACE_NAME, dow=d[\"dow\"]).set(float(d[\"tmin_c\"\
- ]))\n DAILY_TMAX.labels(place=PLACE_NAME, dow=d[\"dow\"]).set(float(d[\"\
- tmax_c\"]))\n for h in data.get(\"hourly\", []):\n HOURLY_TEMP.labels(place=PLACE_NAME,\
- \ time=h[\"time\"]).set(float(h[\"temp_c\"]))\n except Exception:\n \
- \ pass\n\n # IMPORTANT: force JSON content-type so Glance exposes `.JSON`\n\
- \ import json\n return Response(content=json.dumps(data, ensure_ascii=False),\
- \ media_type=\"application/json; charset=utf-8\")\n\n\n@APP.get(\"/metrics\")\n\
- def metrics():\n return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)\n\
- \n\n# -------------------------------\n# Tandoor helpers\n# -------------------------------\n\
- def _today_str() -> str:\n # Use Europe/Budapest for \"day\" boundaries (fallback\
- \ to UTC if tzdata missing)\n try:\n return datetime.now(tz=ZoneInfo(\"\
- Europe/Budapest\")).date().isoformat()\n except Exception:\n return\
- \ datetime.utcnow().date().isoformat()\n\ndef _load_json(path: Path, default):\n\
- \ try:\n with path.open(\"r\", encoding=\"utf-8\") as f:\n \
- \ return json.load(f)\n except Exception:\n return default\n\ndef\
- \ _save_json(path: Path, data) -> None:\n tmp = path.with_suffix(path.suffix\
- \ + \".tmp\")\n with tmp.open(\"w\", encoding=\"utf-8\") as f:\n json.dump(data,\
- \ f, ensure_ascii=False, indent=2)\n tmp.replace(path)\n\ndef _tandoor_headers()\
- \ -> Dict[str, str]:\n token = os.getenv(\"TANDOOR_TOKEN\", \"\")\n if not\
- \ token:\n return {\"Accept\": \"application/json\"}\n return {\"Accept\"\
- : \"application/json\", \"Authorization\": f\"Bearer {token}\"}\n\ndef _rewrite_to_public(maybe_url:\
- \ Optional[str]) -> Optional[str]:\n if not maybe_url:\n return None\n\
- \n # Relative path -> public\n if maybe_url.startswith(\"/\"):\n \
- \ return TANDOOR_PUBLIC_URL + maybe_url\n\n # If the API returns internal host\
- \ URLs, rewrite scheme+host to public\n try:\n u = urlparse(maybe_url)\n\
- \ pub = urlparse(TANDOOR_PUBLIC_URL)\n internal = urlparse(TANDOOR_INTERNAL_URL)\n\
- \ if u.netloc and internal.netloc and u.netloc == internal.netloc:\n \
- \ u = u._replace(scheme=pub.scheme, netloc=pub.netloc)\n return\
- \ urlunparse(u)\n except Exception:\n pass\n\n return maybe_url\n\
- \ndef _fetch_recipes_flat() -> List[Dict[str, Any]]:\n # Prefer /api/recipe/flat/\
- \ because it's already {id,name,image} list\n flat_url = f\"{TANDOOR_INTERNAL_URL}/api/recipe/flat/\"\
- \n r = requests.get(flat_url, headers=_tandoor_headers(), timeout=15)\n \
- \ if r.status_code == 200:\n data = r.json()\n # Expected: list\n\
- \ if isinstance(data, list):\n out = []\n for x in\
- \ data:\n out.append({\n \"id\": int(x.get(\"\
- id\", 0)),\n \"name\": str(x.get(\"name\", \"\")),\n \
- \ \"image\": _rewrite_to_public(x.get(\"image\")),\n \
- \ })\n return [x for x in out if x[\"id\"] and x[\"name\"]]\n\n\
- \ # Fallback: paginated /api/recipe/\n list_url = f\"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=250\"\
- \n r = requests.get(list_url, headers=_tandoor_headers(), timeout=15)\n \
- \ r.raise_for_status()\n data = r.json()\n items = data.get(\"results\"\
- , []) if isinstance(data, dict) else []\n out = []\n for x in items:\n \
- \ out.append({\n \"id\": int(x.get(\"id\", 0)),\n \
- \ \"name\": str(x.get(\"name\", \"\")),\n \"image\": _rewrite_to_public(x.get(\"\
- image\")),\n })\n return [x for x in out if x[\"id\"] and x[\"name\"\
- ]]\n\ndef _get_cooked_for_today() -> List[int]:\n today = _today_str()\n \
- \ cooked = _load_json(COOKED_PATH, {})\n ids = cooked.get(today, [])\n \
- \ # normalize\n try:\n return [int(i) for i in ids]\n except Exception:\n\
- \ return []\n\ndef _set_cooked_today(ids: List[int]) -> None:\n today\
- \ = _today_str()\n cooked = _load_json(COOKED_PATH, {})\n cooked[today]\
- \ = sorted(list({int(i) for i in ids}))\n # Optional cleanup: keep only last\
- \ 14 days\n try:\n keys = sorted(cooked.keys())\n if len(keys)\
- \ > 14:\n for k in keys[:-14]:\n cooked.pop(k, None)\n\
- \ except Exception:\n pass\n _save_json(COOKED_PATH, cooked)\n\n\
- def _get_picks_today() -> List[int]:\n today = _today_str()\n picks = _load_json(PICKS_PATH,\
- \ {})\n ids = picks.get(today, [])\n try:\n return [int(i) for i\
- \ in ids]\n except Exception:\n return []\n\ndef _set_picks_today(ids:\
- \ List[int]) -> None:\n today = _today_str()\n picks = _load_json(PICKS_PATH,\
- \ {})\n picks[today] = [int(i) for i in ids if int(i) > 0]\n # cleanup old\
- \ days\n try:\n keys = sorted(picks.keys())\n if len(keys) >\
- \ 14:\n for k in keys[:-14]:\n picks.pop(k, None)\n\
- \ except Exception:\n pass\n _save_json(PICKS_PATH, picks)\n\ndef\
- \ _ensure_daily_picks(recipes: List[Dict[str, Any]], count: int) -> List[int]:\n\
- \ cooked = set(_get_cooked_for_today())\n picks = _get_picks_today()\n\n\
- \ # Remove picks that are cooked today\n picks = [i for i in picks if i\
- \ not in cooked]\n\n # Top up to requested count if needed\n if len(picks)\
- \ < count:\n available = [r[\"id\"] for r in recipes if r[\"id\"] not in\
- \ cooked and r[\"id\"] not in picks]\n # If everything is cooked (or too\
- \ few recipes), allow repeats from all recipes\n if len(available) < (count\
- \ - len(picks)):\n available = [r[\"id\"] for r in recipes if r[\"\
- id\"] not in picks]\n\n need = max(0, count - len(picks))\n if need\
- \ > 0 and available:\n picks += random.sample(available, k=min(need,\
- \ len(available)))\n\n # If no picks yet (first call today), choose fresh\n\
- \ if not picks:\n available = [r[\"id\"] for r in recipes if r[\"id\"\
- ] not in cooked]\n if not available:\n available = [r[\"id\"\
- ] for r in recipes]\n picks = random.sample(available, k=min(count, len(available)))\n\
- \n _set_picks_today(picks)\n return picks\n\n@APP.get(\"/tandoor/daily\"\
- )\ndef tandoor_daily(count: int = Query(3, ge=1, le=10)):\n try:\n recipes\
- \ = _fetch_recipes_flat()\n except Exception as e:\n raise HTTPException(status_code=502,\
- \ detail=f\"Failed to fetch recipes from Tandoor: {e}\")\n\n if not recipes:\n\
- \ return JSONResponse({\"date\": _today_str(), \"total_recipes\": 0, \"\
- items\": []})\n\n ids = _ensure_daily_picks(recipes, count)\n by_id = {r[\"\
- id\"]: r for r in recipes}\n\n items = []\n for rid in ids:\n r =\
- \ by_id.get(rid)\n if not r:\n continue\n items.append({\n\
- \ \"id\": r[\"id\"],\n \"name\": r[\"name\"],\n \
- \ \"image\": r.get(\"image\"),\n \"url\": f\"{TANDOOR_PUBLIC_URL}/recipe/{r['id']}\"\
- ,\n # state-changing endpoint requires key if set\n \"cook_url\"\
- : f\"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?id={r['id']}\" + (f\"&key={GLANCE_HELPER_KEY}\"\
- \ if GLANCE_HELPER_KEY else \"\"),\n })\n\n return JSONResponse({\n\
- \ \"date\": _today_str(),\n \"total_recipes\": len(recipes),\n \
- \ \"items\": items,\n })\n\n@APP.get(\"/tandoor/cook\")\ndef tandoor_cook(\n\
- \ id: int = Query(..., ge=1),\n key: str = Query(\"\", alias=\"key\"),\n\
- \ redirect: str = Query(\"\", alias=\"redirect\")\n):\n # Protect state-changing\
- \ calls with a shared key (recommended)\n if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY:\n\
- \ raise HTTPException(status_code=403, detail=\"Forbidden\")\n\n cooked\
- \ = set(_get_cooked_for_today())\n cooked.add(int(id))\n _set_cooked_today(list(cooked))\n\
- \n # Also remove from today's picks (so daily list can refill)\n picks =\
- \ [i for i in _get_picks_today() if i != int(id)]\n _set_picks_today(picks)\n\
- \n if redirect:\n return RedirectResponse(url=redirect, status_code=302)\n\
- \n return JSONResponse({\"ok\": True, \"date\": _today_str(), \"cooked_today\"\
- : sorted(list(cooked))})\n"
+ app.py: |-
+ 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
+
+ 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
+
+ APP = FastAPI()
+
+ IDOKEP_URL = os.getenv(
+ "IDOKEP_URL",
+ "https://www.idokep.hu/idojaras/Budapest%20VIII.%20ker",
+ )
+ PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VIII. 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",
+ )
+
+ # 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", "")
+
+
+ # Prometheus metrics (optional)
+ 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"])
+ 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"])
+
+
+ def _abs_url(maybe_relative: Optional[str]) -> Optional[str]:
+ \ 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
+
+
+ def _to_int_temp(s: str) -> Optional[float]:
+ if not\ s:
+ return None
+ s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "")
+ try:
+ return float(s)
+ \ except Exception:
+ return None
+
+
+ def scrape() -> Dict[str, Any]:
+ \ 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)
+
+ # Hourly cards (the block you highlighted in devtools: .ik.hourly-forecast-card)
+ \ 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")
+ 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\ "")
+ icon = _abs_url(icon_el.get("src") if icon_el else None)
+
+ \ if t and temp is not None:
+ hourly.append(
+ \ {
+ "time": t, # e.g. "18:00"
+ \ "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]:
+ dow_el = col.select_one(".ik.dfDay")
+ icon_el = col.select_one("img.ik.forecast-icon")
+ daynum_el\ = col.select_one(".ik.dfDayNum")
+
+ # Normal structure (most days)
+ \ 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 ""
+ 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
+ # inside .ik.min-max-container (order: max, min).
+ \ 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)
+ if re.fullmatch(r"-?\d+", txt or ""):
+ vals.append(txt)
+
+ if len(vals)\ >= 2:
+ tmax = _to_int_temp(vals[0])
+ tmin =\ _to_int_temp(vals[1])
+
+ # Keep only rows that look valid
+ 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,
+ "tmax_c": tmax,
+ \ "icon_url": icon,
+ }
+ )
+
+ # 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},
+ "location": {"name": PLACE_NAME},
+ \ "current": {"temp_c": cur_temp, "condition": cur_cond, "icon_url": cur_icon},
+ "hourly": hourly,
+ "daily": daily,
+ \ "fetched_at_unix": int(time.time()),
+ }
+
+
+ @APP.get("/api")
+ def api():
+ \ status = "ok"
+ with SCRAPE_SECONDS.labels(place=PLACE_NAME).time():
+ \ try:
+ data = scrape()
+ except Exception:
+ \ status = "error"
+ SCRAPES.labels(place=PLACE_NAME, status=status).inc()
+ \ 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_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"]))
+ except Exception:
+ \ 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")
+
+
+ @APP.get("/metrics")
+ def metrics():
+ return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
+
+
+ # -------------------------------
+ # Tandoor helpers
+ # -------------------------------
+ def _today_str() -> str:
+ # Use Europe/Budapest for "day" boundaries (fallback\ to UTC if tzdata missing)
+ try:
+ return datetime.now(tz=ZoneInfo("Europe/Budapest")).date().isoformat()
+ except Exception:
+ return\ datetime.utcnow().date().isoformat()
+
+ def _load_json(path: Path, default):
+ \ try:
+ with path.open("r", encoding="utf-8") as f:
+ \ return json.load(f)
+ 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)
+ tmp.replace(path)
+
+ def _tandoor_headers()\ -> Dict[str, str]:
+ 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 []
+ 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]:
+ 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}
+
+ 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")
+ 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)
+ 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)
+
+ if redirect:
+ return RedirectResponse(url=redirect, status_code=302)
+
+ return JSONResponse({"ok": True, "date": _today_str(), "cooked_today": sorted(list(cooked))})
---
apiVersion: v1
kind: Service