This commit is contained in:
2026-01-15 11:36:35 +01:00
parent b6b2e95993
commit 898c579cf7
+412 -189
View File
@@ -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 <a> 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 <a> 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