moved gradient calculation to backend

This commit is contained in:
2026-01-15 14:42:24 +01:00
parent 65dfdaa013
commit daa9d05421
2 changed files with 135 additions and 161 deletions
+109 -87
View File
@@ -17,11 +17,9 @@ metadata:
namespace: glance-system namespace: glance-system
data: data:
app.py: |- app.py: |-
import os
import time import time
import re import re
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi import FastAPI, Response from fastapi import FastAPI, Response
@@ -29,129 +27,167 @@ data:
APP = FastAPI() APP = FastAPI()
IDOKEP_URL = os.getenv( # Configuration
"IDOKEP_URL", IDOKEP_URL = os.getenv("IDOKEP_URL", "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker")
"https://www.idokep.hu/idojaras/Budapest%20VII.%20ker",
)
PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker") PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker")
SOURCE_NAME = "Időkép" 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")
UA = os.getenv( # Metrics
"USER_AGENT",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
# 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"])
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]: 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"): return maybe_relative
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 return "https://www.idokep.hu" + maybe_relative
def _to_float(s: str) -> Optional[float]:
def _to_int_temp(s: str) -> Optional[float]: if not s: return None
if not s: s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "").replace(",", ".")
return None
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 _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]: 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")
# Current # Current Data
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_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_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
hourly: List[Dict[str, Any]] = [] hourly = []
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_float(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, "temp_c": temp, "icon_url": icon})
{
"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
daily: List[Dict[str, Any]] = [] daily = []
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)
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 "" tmax = _to_float(tmax_el.get_text(strip=True) if tmax_el else "")
dow = dow_el.get_text(strip=True) if dow_el else "" tmin = _to_float(tmin_el.get_text(strip=True) if tmin_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 "") # Fallback for "Vacation/Holiday" styling where selectors differ
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: if tmax is None or tmin is None:
vals: List[str] = [] vals = []
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_float(vals[0])
tmin = _to_int_temp(vals[1]) tmin = _to_float(vals[1])
# Keep only rows that look valid if (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_el.get_text(strip=True) if daynum_el else "",
{ "dow": dow_el.get_text(strip=True) if dow_el else "",
"daynum": daynum,
"dow": dow, # e.g. "Cs", "P", "Sz"
"tmin_c": tmin, "tmin_c": tmin,
"tmax_c": tmax, "tmax_c": tmax,
"icon_url": icon, "icon_url": _abs_url(icon_el.get("src") if icon_el else None),
} })
)
# Limit to 5 days for your widget (first 5 columns in the table, including "vacation" days)
daily = daily[:5] daily = daily[:5]
# --- NEW: Calculate Gradient CSS in Python ---
daily = _calculate_gradient_data(daily)
# ---------------------------------------------
return { return {
"source": {"name": SOURCE_NAME, "url": IDOKEP_URL}, "source": {"name": SOURCE_NAME, "url": IDOKEP_URL},
"location": {"name": PLACE_NAME}, "location": {"name": PLACE_NAME},
@@ -161,37 +197,23 @@ data:
"fetched_at_unix": int(time.time()), "fetched_at_unix": int(time.time()),
} }
@APP.get("/api") @APP.get("/api")
def api(): def api():
# Standard API endpoint logic...
status = "ok" status = "ok"
with SCRAPE_SECONDS.labels(place=PLACE_NAME).time():
try: try:
data = scrape() data = scrape()
except Exception: except Exception as e:
status = "error" status = "error"
SCRAPES.labels(place=PLACE_NAME, status=status).inc() print(f"Error scraping: {e}")
raise raise
# Prometheus Metrics update (simplified for brevity)
SCRAPES.labels(place=PLACE_NAME, status=status).inc() 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 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)
+14 -62
View File
@@ -693,30 +693,20 @@ data:
template: | template: |
{{ $loc := .JSON.String "location.name" }} {{ $loc := .JSON.String "location.name" }}
{{ if eq $loc "" }}{{ $loc = .JSON.String "place" }}{{ end }} {{ if eq $loc "" }}{{ $loc = .JSON.String "place" }}{{ end }}
{{ $curTemp := .JSON.Float "current.temp_c" }} {{ $curTemp := .JSON.Float "current.temp_c" }}
{{ $curIcon := .JSON.String "current.icon_url" }} {{ $curIcon := .JSON.String "current.icon_url" }}
{{ $daily := .JSON.Array "daily" }} {{ $daily := .JSON.Array "daily" }}
{{ $hourly := .JSON.Array "hourly" }} {{ $hourly := .JSON.Array "hourly" }}
<div class="idokep"> <div class="idokep">
<div class="idokep-top"> <div class="idokep-top">
<div class="idokep-top-left"> <div class="idokep-top-left">
{{ if $curIcon }} {{ if $curIcon }}<img class="idokep-icon" src="{{ $curIcon }}" alt="" />{{ end }}
<img class="idokep-icon" src="{{ $curIcon }}" alt="" /> <div class="idokep-temp">{{ printf "%.0f" $curTemp }}°C</div>
{{ end }}
<div class="idokep-temp">
{{ printf "%.0f" $curTemp }}°C
</div> </div>
</div>
<div class="idokep-top-right"> <div class="idokep-top-right">
<div class="idokep-loc">{{ $loc }}</div> <div class="idokep-loc">{{ $loc }}</div>
<div class="idokep-src"> <div class="idokep-src">Forrás: <a href="{{ .JSON.String "source.url" }}" target="_blank">Időkép</a></div>
Forrás:
<a href="{{ .JSON.String "source.url" }}" target="_blank" rel="noreferrer">Időkép</a>
</div>
</div> </div>
</div> </div>
@@ -725,66 +715,35 @@ data:
{{ range $i, $h := $hourly }} {{ range $i, $h := $hourly }}
<div class="idokep-hour"> <div class="idokep-hour">
<div class="idokep-hour-time">{{ $h.String "time" }}</div> <div class="idokep-hour-time">{{ $h.String "time" }}</div>
{{ $hicon := $h.String "icon_url" }} {{ if $h.String "icon_url" }}<img class="idokep-hour-icon" src="{{ $h.String "icon_url" }}" alt="" />{{ end }}
{{ if $hicon }}
<img class="idokep-hour-icon" src="{{ $hicon }}" alt="" />
{{ end }}
<div class="idokep-hour-temp">{{ printf "%.0f" ($h.Float "temp_c") }}°</div> <div class="idokep-hour-temp">{{ printf "%.0f" ($h.Float "temp_c") }}°</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ else }}
<div class="idokep-muted">No hourly data returned by scraper yet.</div>
{{ end }} {{ end }}
{{ if gt (len $daily) 0 }} {{ if gt (len $daily) 0 }}
{{/* compute global min/max */}}
{{ $gmin := 9999.0 }}
{{ $gmax := -9999.0 }}
{{ range $daily }}
{{ $tmin := .Float "tmin_c" }}
{{ $tmax := .Float "tmax_c" }}
{{ if lt $tmin $gmin }}{{ $gmin = $tmin }}{{ end }}
{{ if gt $tmax $gmax }}{{ $gmax = $tmax }}{{ end }}
{{ end }}
{{ $span := sub $gmax $gmin }}
{{ if eq $span 0.0 }}{{ $span = 1.0 }}{{ end }}
{{/* CALCULATE GRADIENT POSITIONS RELATIVE TO CURRENT RANGE */}}
{{ $p_wht := printf "%.1f" (mul (div (sub -10.0 $gmin) $span) 100.0) }}
{{ $p_blu := printf "%.1f" (mul (div (sub 0.0 $gmin) $span) 100.0) }}
{{ $p_pur := printf "%.1f" (mul (div (sub 15.0 $gmin) $span) 100.0) }}
{{ $p_pnk := printf "%.1f" (mul (div (sub 25.0 $gmin) $span) 100.0) }}
{{ $p_red := printf "%.1f" (mul (div (sub 35.0 $gmin) $span) 100.0) }}
{{/* Generate the dynamic CSS gradient string */}}
{{ $grad := printf "linear-gradient(90deg, #ffffff %s%%, #60a5fa %s%%, #a78bfa %s%%, #fb7185 %s%%, #ef4444 %s%%)" $p_wht $p_blu $p_pur $p_pnk $p_red }}
<div class="idokep-daily"> <div class="idokep-daily">
{{ range $daily }} {{ range $daily }}
{{ $tmin := .Float "tmin_c" }}
{{ $tmax := .Float "tmax_c" }}
{{ $left := mul (div (sub $tmin $gmin) $span) 100.0 }}
{{ $wid := mul (div (sub $tmax $tmin) $span) 100.0 }}
<div class="idokep-row"> <div class="idokep-row">
<div class="idokep-dow"> <div class="idokep-dow">
<span class="idokep-downame">{{ .String "dow" }}</span> <span class="idokep-downame">{{ .String "dow" }}</span>
<span class="idokep-daynum">{{ .String "daynum" }}</span> <span class="idokep-daynum">{{ .String "daynum" }}</span>
</div> </div>
{{ $dicon := .String "icon_url" }}
<div class="idokep-dayicon"> <div class="idokep-dayicon">
{{ if $dicon }}<img src="{{ $dicon }}" alt="" />{{ end }} {{ if .String "icon_url" }}<img src="{{ .String "icon_url" }}" alt="" />{{ end }}
</div> </div>
<div class="idokep-min">{{ printf "%.0f" $tmin }}°</div> <div class="idokep-min">{{ printf "%.0f" (.Float "tmin_c") }}°</div>
<div class="idokep-bar"> <div class="idokep-bar">
<div class="idokep-bar-track"></div> <div class="idokep-bar-track"></div>
{{/* Pass calculated offset and gradient to CSS variables */}} {{/* Use the Python-calculated styles directly */}}
<div class="idokep-bar-fill" style="left: {{ printf "%.1f" $left }}%; width: {{ printf "%.1f" $wid }}%;"> <div class="idokep-bar-fill" style="{{ .String "bar_style" }}">
<div class="idokep-bar-gradient" style="--off: {{ printf "%.1f" $left }}; --grad: {{ $grad }};"></div> <div class="idokep-bar-gradient" style="{{ .String "grad_style" }}"></div>
</div> </div>
</div> </div>
<div class="idokep-max">{{ printf "%.0f" $tmax }}°</div>
<div class="idokep-max">{{ printf "%.0f" (.Float "tmax_c") }}°</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
@@ -2058,7 +2017,7 @@ data:
.idokep-bar { .idokep-bar {
position: relative; position: relative;
height: 10px; height: 10px;
container-type: inline-size; /* Allows children to know the full track width */ /* container-type removed - not needed with Python approach */
} }
.idokep-bar-track { .idokep-bar-track {
position: absolute; position: absolute;
@@ -2071,7 +2030,7 @@ data:
top: 0; top: 0;
bottom: 0; bottom: 0;
border-radius: 999px; border-radius: 999px;
overflow: hidden; /* Clips the inner gradient to this bar's size */ overflow: hidden;
box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset; box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset;
} }
.idokep-min, .idokep-max { text-align: right; font-weight: 700; opacity: 0.8; } .idokep-min, .idokep-max { text-align: right; font-weight: 700; opacity: 0.8; }
@@ -2092,14 +2051,7 @@ data:
.idokep-bar-gradient { .idokep-bar-gradient {
position: absolute; position: absolute;
top: 0; bottom: 0; top: 0; bottom: 0;
width: 100cqw; /* Forces width to match the PARENT TRACK, not the fill */ /* Width and margin are calculated in Python to match the track width exactly */
/* Shift left by the percentage the fill was pushed right.
1cqw equals 1% of the track width.
So we multiple the offset variable by -1cqw to realign perfectly. */
left: calc(var(--off) * -1cqw);
background: var(--grad);
} }
--- ---
apiVersion: apps/v1 apiVersion: apps/v1