From 434e1eedfeee6e50f5f45fc8aca2dc0df1f27d39 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Jan 2026 14:54:06 +0100 Subject: [PATCH] next try --- glance-system/glance-helper.yaml | 242 ++++++++++++----------------- glance-system/glance-kisfenyo.yaml | 65 +++++--- 2 files changed, 140 insertions(+), 167 deletions(-) diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml index 9e0248b..ac56147 100644 --- a/glance-system/glance-helper.yaml +++ b/glance-system/glance-helper.yaml @@ -27,191 +27,149 @@ data: 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 _abs_url(url): return "https://www.idokep.hu" + url if url and not url.startswith("http") else url + def _to_float(s): + try: return float(s.strip().replace("˚C", "").replace("°C", "").replace("°", "").replace(",", ".")) + except: return None - 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): + if not daily_items: return daily_items - 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 + # 1. Global Min/Max for the week 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 + 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 + g_min = min(mins) - 1.0 + g_max = max(maxs) + 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 - ] + # 2. Calculate percentages for key colors relative to this week's range + # -10 (White), 0 (Blue), 15 (Purple), 25 (Pink), 35 (Red) + def get_pct(t): return (t - g_min) / span * 100.0 - # 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)})" + s_wht = get_pct(-10) + s_blu = get_pct(0) + s_pur = get_pct(15) + s_pnk = get_pct(25) + s_red = get_pct(35) - # 3. Calculate positioning for each day + # 3. Generate CSS Variables for each row for d in daily_items: - tmin = d["tmin_c"] - tmax = d["tmax_c"] - - if tmin is None or tmax is None: - continue + tmin, tmax = d["tmin_c"], 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 + # Bar Geometry + w_pct = (tmax - tmin) / span * 100.0 + if w_pct < 2: w_pct = 2 + l_pct = (tmin - g_min) / span * 100.0 - # 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 + # Gradient Compensation (Zoom & Shift) + inner_w = (100.0 / w_pct) * 100.0 + inner_ml = -(l_pct / w_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};" + # We pass ONLY variables to avoid HTML sanitization issues + 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 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") + try: + r = requests.get(IDOKEP_URL, headers={"User-Agent": UA}, 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") + # 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") - 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}) + # Hourly + hourly = [] + for card in soup.select(".ik.hourly-forecast-card")[:8]: + t = card.select_one(".ik.hourly-forecast-hour") + temp = card.select_one(".ik.temperature-circled") + 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 = [] - 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") + # Daily + daily = [] + for col in soup.select(".ik.daily-forecast-container .ik.dailyForecastCol")[:15]: + dow = col.select_one(".ik.dfDay") + daynum = col.select_one(".ik.dfDayNum") + icon = col.select_one("img.ik.forecast-icon") + tmax = col.select_one("div.ik.max") + tmin = 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 holiday layout + 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 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 v_tmax is not None and v_tmin is not None: + daily.append({ + "daynum": daynum.get_text(strip=True) if daynum else "", + "dow": dow.get_text(strip=True) if dow else "", + "tmin_c": v_tmin, + "tmax_c": v_tmax, + "icon_url": _abs_url(icon.get("src") if icon else None), + }) - 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 = _calculate_gradient_data(daily[:5]) - 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()), - } + return { + "source": {"name": SOURCE_NAME, "url": IDOKEP_URL}, + "location": {"name": PLACE_NAME}, + "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 "", + "icon_url": _abs_url(cur_icon_el.get("src")) if cur_icon_el else "" + }, + "hourly": hourly, + "daily": daily, + "fetched_at_unix": int(time.time()), + } + except Exception as e: + print(f"Scrape error: {e}") + raise @APP.get("/api") def api(): - # Standard API endpoint logic... status = "ok" try: data = scrape() - except Exception as e: + except: status = "error" - print(f"Error scraping: {e}") + SCRAPES.labels(place=PLACE_NAME, status=status).inc() 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") diff --git a/glance-system/glance-kisfenyo.yaml b/glance-system/glance-kisfenyo.yaml index 196dbe8..0b95f49 100644 --- a/glance-system/glance-kisfenyo.yaml +++ b/glance-system/glance-kisfenyo.yaml @@ -737,9 +737,9 @@ data:
- {{/* Use the Python-calculated styles directly */}} -
-
+ {{/* Inject safe CSS Variables only */}} +
+
@@ -2013,26 +2013,6 @@ data: } .idokep-dow { opacity: 0.7; font-weight: 700; } .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-dow { display: grid; @@ -2047,11 +2027,46 @@ data: opacity: 0.75; 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 */ .idokep-bar-gradient { position: absolute; - top: 0; bottom: 0; - /* Width and margin are calculated in Python to match the track width exactly */ + top: 0; + 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