rewrote
This commit is contained in:
+276
-367
@@ -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:
|
||||||
|
pass
|
||||||
return default
|
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 = []
|
out = []
|
||||||
for x in\ data:
|
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
|
||||||
|
|
||||||
\ # Fallback: paginated /api/recipe/
|
def _compute_daily_picks(count: int) -> dict:
|
||||||
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()
|
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
|
|
||||||
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:
|
if not available:
|
||||||
available = [r["id"] for r in recipes]
|
available = recipes[:] # fallback: if everything cooked, ignore filter
|
||||||
picks = random.sample(available, k=min(count, len(available)))
|
|
||||||
|
|
||||||
_set_picks_today(picks)
|
chosen = random.sample(available, k=min(count, len(available))) if available else []
|
||||||
return picks
|
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")
|
@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,20 +441,7 @@ metadata:
|
|||||||
namespace: glance-system
|
namespace: glance-system
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: glance-helper
|
app.kubernetes.io/name: glance-helper
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 8000
|
|
||||||
targetPort: 8000
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: glance-helper
|
|
||||||
namespace: glance-system
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: glance-helper
|
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
port: 8000
|
port: 8000
|
||||||
@@ -535,32 +450,26 @@ spec:
|
|||||||
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: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
service:
|
service:
|
||||||
name: glance-helper
|
name: glance-helper
|
||||||
port:
|
port:
|
||||||
number: 8000
|
number: 8000
|
||||||
path: /
|
|
||||||
pathType: Prefix
|
|
||||||
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
|
|
||||||
Reference in New Issue
Block a user