Files
homelab-manifests/glance-system/glance-helper.yaml
T
2026-01-15 14:44:05 +01:00

512 lines
18 KiB
YAML

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: glance-helper-data
namespace: glance-system
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Mi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: glance-helper-app
namespace: glance-system
data:
app.py: |-
import os
import time
import re
from typing import List, Dict, Any, Optional
import requests
from bs4 import BeautifulSoup
from fastapi import FastAPI, Response
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
APP = FastAPI()
# Configuration
IDOKEP_URL = os.getenv("IDOKEP_URL", "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker")
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")
# Metrics
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"])
def _abs_url(maybe_relative: Optional[str]) -> Optional[str]:
if not maybe_relative: return None
if maybe_relative.startswith("http"): return maybe_relative
return "https://www.idokep.hu" + maybe_relative
def _to_float(s: str) -> Optional[float]:
if not s: return None
s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "").replace(",", ".")
try:
return float(s)
except Exception:
return None
def _calculate_gradient_data(daily_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Enhances daily items with pre-calculated CSS styles for the temperature bars.
"""
if not daily_items:
return daily_items
# 1. Find Global Min/Max for the week
# We assume the API returns valid floats in tmin_c/tmax_c
mins = [d["tmin_c"] for d in daily_items if d["tmin_c"] is not None]
maxs = [d["tmax_c"] for d in daily_items if d["tmax_c"] is not None]
if not mins or not maxs:
return daily_items
g_min = min(mins)
g_max = max(maxs)
# Add a small visual buffer (e.g., 1 degree) so bars don't hit the exact edges
g_min -= 1.0
g_max += 1.0
span = g_max - g_min
if span <= 0: span = 1.0
# 2. Calculate Gradient Stops relative to this week's range
# Formula: (TargetTemp - GlobalMin) / Span * 100
def get_pos(temp):
return (temp - g_min) / span * 100.0
# Define the fixed color points
stops = [
(-10, "#ffffff"), # White below -10
(0, "#60a5fa"), # Blue at 0
(15, "#a78bfa"), # Purple at 15
(25, "#fb7185"), # Pink/Red transition at 25
(35, "#ef4444"), # Red at 35
]
# Build the linear-gradient string
grad_parts = []
for temp, color in stops:
pos = get_pos(temp)
grad_parts.append(f"{color} {pos:.1f}%")
grad_string = f"linear-gradient(90deg, {', '.join(grad_parts)})"
# 3. Calculate positioning for each day
for d in daily_items:
tmin = d["tmin_c"]
tmax = d["tmax_c"]
if tmin is None or tmax is None:
continue
# Bar position relative to track (0-100%)
left_pct = (tmin - g_min) / span * 100.0
width_pct = (tmax - tmin) / span * 100.0
if width_pct < 1: width_pct = 1 # Minimum width visibility
# Inner Gradient Logic:
# The inner div needs to be exactly as wide as the PARENT track.
# If the bar is width W% (relative to track), the inner div needs to be (100/W)% relative to bar.
# It also needs to be shifted left by L% (relative to track), which is -(L/W)% relative to bar.
inner_w_pct = (100.0 / width_pct) * 100.0
inner_l_pct = -(left_pct / width_pct) * 100.0
d["bar_style"] = f"left: {left_pct:.1f}%; width: {width_pct:.1f}%;"
d["grad_style"] = f"width: {inner_w_pct:.1f}%; margin-left: {inner_l_pct:.1f}%; background: {grad_string};"
return daily_items
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 Data
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_float(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
hourly = []
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_float(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, "temp_c": temp, "icon_url": icon})
# Daily
daily = []
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")
tmax_el = col.select_one("div.ik.max")
tmin_el = col.select_one("div.ik.min")
tmax = _to_float(tmax_el.get_text(strip=True) if tmax_el else "")
tmin = _to_float(tmin_el.get_text(strip=True) if tmin_el else "")
# Fallback for "Vacation/Holiday" styling where selectors differ
if tmax is None or tmin is None:
vals = []
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_float(vals[0])
tmin = _to_float(vals[1])
if (tmin is not None) and (tmax is not None):
daily.append({
"daynum": daynum_el.get_text(strip=True) if daynum_el else "",
"dow": dow_el.get_text(strip=True) if dow_el else "",
"tmin_c": tmin,
"tmax_c": tmax,
"icon_url": _abs_url(icon_el.get("src") if icon_el else None),
})
daily = daily[:5]
# --- NEW: Calculate Gradient CSS in Python ---
daily = _calculate_gradient_data(daily)
# ---------------------------------------------
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():
# Standard API endpoint logic...
status = "ok"
try:
data = scrape()
except Exception as e:
status = "error"
print(f"Error scraping: {e}")
raise
# Prometheus Metrics update (simplified for brevity)
SCRAPES.labels(place=PLACE_NAME, status=status).inc()
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 "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:
"""YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC."""
try:
tz = ZoneInfo("Europe/Budapest")
return datetime.now(tz).strftime("%Y-%m-%d")
except Exception:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _load_json(path: Path, default):
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
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():
token = os.getenv("TANDOOR_TOKEN", "")
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 _ 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, {})
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)
if not r:
continue
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, key: str | None = None, redirect: str | None = None):
if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY:
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 {"ok": True, "date": today, "cooked": id}
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
argocd.argoproj.io/tracking-id: glance:apps/Deployment:glance-system/glance-helper
deployment.kubernetes.io/revision: "9"
reloader.stakater.com/auto: "true"
name: glance-helper
namespace: glance-system
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: glance-helper
strategy:
type: Recreate
template:
metadata:
creationTimestamp: null
labels:
app: glance-helper
app.kubernetes.io/name: glance-helper
spec:
containers:
- 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)"
command:
- /bin/sh
- -lc
env:
- name: STAKATER_GLANCE_HELPER_APP_CONFIGMAP
value: c6e34df8429997db4bd9c1b119c5a6f1e2337ecb
- 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
image: python:3.12-bookworm
imagePullPolicy: IfNotPresent
name: glance-helper
ports:
- containerPort: 8000
protocol: TCP
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /app
name: app
- mountPath: /data
name: data
workingDir: /app
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
volumes:
- configMap:
defaultMode: 420
name: glance-helper-app
name: 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
metadata:
name: idokep-scraper
namespace: glance-system
spec:
selector:
app.kubernetes.io/name: glance-helper
ports:
- name: http
port: 8000
targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
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:
- path: /
pathType: Prefix
backend:
service:
name: glance-helper
port:
number: 8000
tls:
- hosts:
- glance-helper.dooplex.hu
secretName: glance-helper-tls