This commit is contained in:
2026-01-15 14:54:06 +01:00
parent fc4ad142e8
commit 434e1eedfe
2 changed files with 140 additions and 167 deletions
+100 -142
View File
@@ -27,191 +27,149 @@ data:
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()
# Configuration # Configuration
IDOKEP_URL = os.getenv("IDOKEP_URL", "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker") IDOKEP_URL = os.getenv("IDOKEP_URL", "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("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"]) 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"])
def _abs_url(maybe_relative: Optional[str]) -> Optional[str]: def _abs_url(url): return "https://www.idokep.hu" + url if url and not url.startswith("http") else url
if not maybe_relative: return None def _to_float(s):
if maybe_relative.startswith("http"): return maybe_relative try: return float(s.strip().replace("˚C", "").replace("°C", "").replace("°", "").replace(",", "."))
return "https://www.idokep.hu" + maybe_relative except: return None
def _to_float(s: str) -> Optional[float]: def _calculate_gradient_data(daily_items):
if not s: return None if not daily_items: return daily_items
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]]: # 1. Global Min/Max for the week
"""
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] 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] 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
if not mins or not maxs:
return daily_items
g_min = min(mins) g_min = min(mins) - 1.0
g_max = max(maxs) g_max = max(maxs) + 1.0
# 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 span = g_max - g_min
if span <= 0: span = 1.0 if span <= 0: span = 1.0
# 2. Calculate Gradient Stops relative to this week's range # 2. Calculate percentages for key colors relative to this week's range
# Formula: (TargetTemp - GlobalMin) / Span * 100 # -10 (White), 0 (Blue), 15 (Purple), 25 (Pink), 35 (Red)
def get_pos(temp): def get_pct(t): return (t - g_min) / span * 100.0
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 s_wht = get_pct(-10)
grad_parts = [] s_blu = get_pct(0)
for temp, color in stops: s_pur = get_pct(15)
pos = get_pos(temp) s_pnk = get_pct(25)
grad_parts.append(f"{color} {pos:.1f}%") s_red = get_pct(35)
grad_string = f"linear-gradient(90deg, {', '.join(grad_parts)})"
# 3. Calculate positioning for each day # 3. Generate CSS Variables for each row
for d in daily_items: for d in daily_items:
tmin = d["tmin_c"] tmin, tmax = d["tmin_c"], d["tmax_c"]
tmax = d["tmax_c"] if tmin is None or tmax is None: continue
if tmin is None or tmax is None:
continue
# Bar position relative to track (0-100%) # Bar Geometry
left_pct = (tmin - g_min) / span * 100.0 w_pct = (tmax - tmin) / span * 100.0
width_pct = (tmax - tmin) / span * 100.0 if w_pct < 2: w_pct = 2
l_pct = (tmin - g_min) / span * 100.0
if width_pct < 1: width_pct = 1 # Minimum width visibility
# Inner Gradient Logic: # Gradient Compensation (Zoom & Shift)
# The inner div needs to be exactly as wide as the PARENT track. inner_w = (100.0 / w_pct) * 100.0
# If the bar is width W% (relative to track), the inner div needs to be (100/W)% relative to bar. inner_ml = -(l_pct / w_pct) * 100.0
# 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}%;" # We pass ONLY variables to avoid HTML sanitization issues
d["grad_style"] = f"width: {inner_w_pct:.1f}%; margin-left: {inner_l_pct:.1f}%; background: {grad_string};" d["css_vars"] = (
f"--l: {l_pct:.1f}%; --w: {w_pct:.1f}%; "
f"--gw: {inner_w:.1f}%; --ml: {inner_ml:.1f}%; "
f"--s-wht: {s_wht:.1f}%; --s-blu: {s_blu:.1f}%; "
f"--s-pur: {s_pur:.1f}%; --s-pnk: {s_pnk:.1f}%; --s-red: {s_red:.1f}%;"
)
return daily_items return daily_items
def scrape() -> Dict[str, Any]: def scrape() -> Dict[str, Any]:
headers = {"User-Agent": UA} try:
r = requests.get(IDOKEP_URL, headers=headers, timeout=15) r = requests.get(IDOKEP_URL, headers={"User-Agent": UA}, timeout=15)
r.raise_for_status() r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser") soup = BeautifulSoup(r.text, "html.parser")
# Current Data # 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_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 "" # Hourly
temp = _to_float(temp_el.get_text(strip=True) if temp_el else "") hourly = []
icon = _abs_url(icon_el.get("src") if icon_el else None) for card in soup.select(".ik.hourly-forecast-card")[:8]:
t = card.select_one(".ik.hourly-forecast-hour")
if t and temp is not None: temp = card.select_one(".ik.temperature-circled")
hourly.append({"time": t, "temp_c": temp, "icon_url": icon}) icon = card.select_one("img.ik.forecast-icon")
if t and temp:
hourly.append({
"time": t.get_text(strip=True),
"temp_c": _to_float(temp.get_text(strip=True)),
"icon_url": _abs_url(icon.get("src"))
})
# Daily # Daily
daily = [] 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 = col.select_one(".ik.dfDay")
icon_el = col.select_one("img.ik.forecast-icon") daynum = col.select_one(".ik.dfDayNum")
daynum_el = col.select_one(".ik.dfDayNum") icon = col.select_one("img.ik.forecast-icon")
tmax_el = col.select_one("div.ik.max") tmax = col.select_one("div.ik.max")
tmin_el = col.select_one("div.ik.min") tmin = col.select_one("div.ik.min")
tmax = _to_float(tmax_el.get_text(strip=True) if tmax_el else "") # Fallback for holiday layout
tmin = _to_float(tmin_el.get_text(strip=True) if tmin_el else "") v_tmax, v_tmin = None, None
if tmax and tmin:
v_tmax = _to_float(tmax.get_text(strip=True))
v_tmin = _to_float(tmin.get_text(strip=True))
else:
vals = [a.get_text(strip=True) for a in col.select(".ik.min-max-container a")]
vals = [v for v in vals if re.fullmatch(r"-?\d+", v)]
if len(vals) >= 2:
v_tmax, v_tmin = _to_float(vals[0]), _to_float(vals[1])
# Fallback for "Vacation/Holiday" styling where selectors differ if v_tmax is not None and v_tmin is not None:
if tmax is None or tmin is None: daily.append({
vals = [] "daynum": daynum.get_text(strip=True) if daynum else "",
for a in col.select(".ik.min-max-container a"): "dow": dow.get_text(strip=True) if dow else "",
txt = a.get_text(strip=True) "tmin_c": v_tmin,
if re.fullmatch(r"-?\d+", txt or ""): vals.append(txt) "tmax_c": v_tmax,
if len(vals) >= 2: "icon_url": _abs_url(icon.get("src") if icon else None),
tmax = _to_float(vals[0]) })
tmin = _to_float(vals[1])
if (tmin is not None) and (tmax is not None): daily = _calculate_gradient_data(daily[:5])
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] return {
"source": {"name": SOURCE_NAME, "url": IDOKEP_URL},
# --- NEW: Calculate Gradient CSS in Python --- "location": {"name": PLACE_NAME},
daily = _calculate_gradient_data(daily) "current": {
# --------------------------------------------- "temp_c": _to_float(cur_temp_el.get_text(strip=True)) if cur_temp_el else 0,
"condition": cur_cond_el.get_text(strip=True) if cur_cond_el else "",
return { "icon_url": _abs_url(cur_icon_el.get("src")) if cur_icon_el else ""
"source": {"name": SOURCE_NAME, "url": IDOKEP_URL}, },
"location": {"name": PLACE_NAME}, "hourly": hourly,
"current": {"temp_c": cur_temp, "condition": cur_cond, "icon_url": cur_icon}, "daily": daily,
"hourly": hourly, "fetched_at_unix": int(time.time()),
"daily": daily, }
"fetched_at_unix": int(time.time()), except Exception as e:
} print(f"Scrape error: {e}")
raise
@APP.get("/api") @APP.get("/api")
def api(): def api():
# Standard API endpoint logic...
status = "ok" status = "ok"
try: try:
data = scrape() data = scrape()
except Exception as e: except:
status = "error" status = "error"
print(f"Error scraping: {e}") SCRAPES.labels(place=PLACE_NAME, status=status).inc()
raise raise
# Prometheus Metrics update (simplified for brevity)
SCRAPES.labels(place=PLACE_NAME, status=status).inc() SCRAPES.labels(place=PLACE_NAME, status=status).inc()
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")
+40 -25
View File
@@ -737,9 +737,9 @@ data:
<div class="idokep-bar"> <div class="idokep-bar">
<div class="idokep-bar-track"></div> <div class="idokep-bar-track"></div>
{{/* Use the Python-calculated styles directly */}} {{/* Inject safe CSS Variables only */}}
<div class="idokep-bar-fill" style="{{ .String "bar_style" }}"> <div class="idokep-bar-fill" style="{{ .String "css_vars" }}">
<div class="idokep-bar-gradient" style="{{ .String "grad_style" }}"></div> <div class="idokep-bar-gradient"></div>
</div> </div>
</div> </div>
@@ -2013,26 +2013,6 @@ data:
} }
.idokep-dow { opacity: 0.7; font-weight: 700; } .idokep-dow { opacity: 0.7; font-weight: 700; }
.idokep-dayicon img { width: 22px; height: 22px; opacity: 0.95; } .idokep-dayicon img { width: 22px; height: 22px; opacity: 0.95; }
.idokep-bar {
position: relative;
height: 10px;
/* container-type removed - not needed with Python approach */
}
.idokep-bar-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255,255,255,0.10);
}
.idokep-bar-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
overflow: hidden;
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; }
.idokep-dow { .idokep-dow {
display: grid; display: grid;
@@ -2047,11 +2027,46 @@ data:
opacity: 0.75; opacity: 0.75;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.idokep-bar {
position: relative;
height: 10px;
}
.idokep-bar-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255,255,255,0.10);
}
.idokep-bar-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset;
/* Position controlled by Python variables */
left: var(--l, 0%);
width: var(--w, 0%);
}
/* This element holds the gradient */ /* This element holds the gradient */
.idokep-bar-gradient { .idokep-bar-gradient {
position: absolute; position: absolute;
top: 0; bottom: 0; top: 0;
/* Width and margin are calculated in Python to match the track width exactly */ bottom: 0;
/* Compensation geometry controlled by Python variables */
width: var(--gw, 100%);
margin-left: var(--ml, 0%);
/* The Dynamic Gradient */
background: linear-gradient(90deg,
#ffffff var(--s-wht),
#60a5fa var(--s-blu),
#a78bfa var(--s-pur),
#fb7185 var(--s-pnk),
#ef4444 var(--s-red)
);
} }
--- ---
apiVersion: apps/v1 apiVersion: apps/v1