217 lines
6.3 KiB
Python
217 lines
6.3 KiB
Python
import os
|
|
import re
|
|
import tempfile
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
|
|
AJAX_URL = "https://www.igmg.org/wp-content/themes/igmg/include/gebetskalender_ajax_api.php"
|
|
|
|
TZ_NAME = os.environ.get("TZ", "Europe/Berlin")
|
|
TZ = ZoneInfo(TZ_NAME)
|
|
|
|
CITY_ID = os.environ.get("CITY_ID", "20020")
|
|
CITY_SLUG = os.environ.get("CITY_SLUG", "Blumberg_(DE)")
|
|
LANG = os.environ.get("LANG", "de")
|
|
ICS_FILENAME = os.environ.get("ICS_FILENAME", f"igmg-{CITY_ID}.ics")
|
|
|
|
OUT_ICS = f"/data/{ICS_FILENAME}"
|
|
OUT_INDEX = "/data/index.html"
|
|
SOURCE_PAGE = f"https://www.igmg.org/gebetskalender?stadt={CITY_SLUG}&id={CITY_ID}"
|
|
|
|
DATE_RE = re.compile(r"^\s*(\d{2})\.(\d{2})\.(\d{4})\s*$")
|
|
TIME_RE = re.compile(r"^([01]?\d|2[0-3]):[0-5]\d$")
|
|
|
|
PRAYERS = [
|
|
("imsak_time", "Imsak"),
|
|
("gunes_time", "Sonnenaufgang"),
|
|
("ogle_time", "Dhuhr / Mittag"),
|
|
("ikindi_time", "Asr / Nachmittag"),
|
|
("aksam_time", "Maghrib / Abend"),
|
|
("yatsi_time", "Isha / Nacht"),
|
|
]
|
|
|
|
def fetch_month(year: int, month: int) -> str:
|
|
data = {
|
|
"show_ajax_variable": CITY_ID,
|
|
"show_month": str(month),
|
|
"show_year": str(year),
|
|
"lang": LANG,
|
|
}
|
|
headers = {
|
|
"User-Agent": "igmg-ics/1.0",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"Referer": "https://www.igmg.org/gebetskalender/",
|
|
}
|
|
r = requests.post(AJAX_URL, data=data, headers=headers, timeout=30)
|
|
r.raise_for_status()
|
|
return r.text
|
|
|
|
def ics_escape(s: str) -> str:
|
|
return (
|
|
s.replace("\\", "\\\\")
|
|
.replace(";", "\\;")
|
|
.replace(",", "\\,")
|
|
.replace("\n", "\\n")
|
|
)
|
|
|
|
def fmt_dt(dt: datetime) -> str:
|
|
return dt.strftime("%Y%m%dT%H%M%S")
|
|
|
|
def build_ics(calname: str, events: list[dict]) -> str:
|
|
now_utc = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
|
lines = [
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//igmg-ics//Prayer Times//DE",
|
|
"CALSCALE:GREGORIAN",
|
|
"METHOD:PUBLISH",
|
|
f"X-WR-CALNAME:{ics_escape(calname)}",
|
|
f"X-WR-TIMEZONE:{TZ_NAME}",
|
|
"REFRESH-INTERVAL;VALUE=DURATION:P1D",
|
|
"X-PUBLISHED-TTL:P1D",
|
|
]
|
|
for e in events:
|
|
lines += [
|
|
"BEGIN:VEVENT",
|
|
f"UID:{e['uid']}",
|
|
f"DTSTAMP:{now_utc}",
|
|
f"SUMMARY:{ics_escape(e['summary'])}",
|
|
f"DTSTART;TZID={TZ_NAME}:{fmt_dt(e['start'])}",
|
|
f"DTEND;TZID={TZ_NAME}:{fmt_dt(e['end'])}",
|
|
f"DESCRIPTION:{ics_escape(e['description'])}",
|
|
"END:VEVENT",
|
|
]
|
|
lines.append("END:VCALENDAR")
|
|
return "\r\n".join(lines) + "\r\n"
|
|
|
|
def parse_events(html: str) -> tuple[str, list[dict]]:
|
|
soup = BeautifulSoup(html, "lxml")
|
|
|
|
calname = "IGMG Gebetszeiten"
|
|
h3 = soup.select_one("h3.green")
|
|
if h3 and h3.get_text(strip=True):
|
|
calname = f"IGMG {h3.get_text(strip=True)} Gebetszeiten"
|
|
|
|
rows = soup.select("div.zeiten_box > div.zeiten")
|
|
if not rows:
|
|
raise RuntimeError("Keine 'zeiten' Zeilen gefunden (AJAX-Format geändert?).")
|
|
|
|
events: list[dict] = []
|
|
|
|
for row in rows:
|
|
date_span = row.select_one("span.tarih")
|
|
if not date_span:
|
|
continue
|
|
|
|
date_text = date_span.get_text(strip=True)
|
|
m = DATE_RE.match(date_text)
|
|
if not m:
|
|
continue # Header-Zeile ("Datum")
|
|
|
|
dd, mm, yyyy = map(int, m.groups())
|
|
|
|
for cls, label in PRAYERS:
|
|
tspan = row.select_one(f"span.{cls}")
|
|
if not tspan:
|
|
continue
|
|
ttxt = tspan.get_text(strip=True)
|
|
if not TIME_RE.match(ttxt):
|
|
continue
|
|
|
|
hh, minute = map(int, ttxt.split(":"))
|
|
start = datetime(yyyy, mm, dd, hh, minute, tzinfo=TZ)
|
|
end = start + timedelta(minutes=10)
|
|
|
|
# stabile UID => keine Duplikate in Kalender-Abos
|
|
uid = f"igmg-{CITY_ID}-{yyyy:04d}{mm:02d}{dd:02d}-{cls}@igmg-ics"
|
|
|
|
events.append(
|
|
{
|
|
"uid": uid,
|
|
"summary": label,
|
|
"start": start,
|
|
"end": end,
|
|
"description": f"Quelle: {SOURCE_PAGE}",
|
|
}
|
|
)
|
|
|
|
if not events:
|
|
raise RuntimeError("Zeilen gefunden, aber keine Events extrahiert.")
|
|
|
|
return calname, events
|
|
|
|
def month_add(year: int, month: int, delta: int) -> tuple[int, int]:
|
|
m = month - 1 + delta
|
|
y = year + (m // 12)
|
|
m = (m % 12) + 1
|
|
return y, m
|
|
|
|
def atomic_write(path: str, content: str, mode: int = 0o644):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
prefix=os.path.basename(path) + ".",
|
|
dir=os.path.dirname(path),
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
f.write(content)
|
|
|
|
os.chmod(tmp_path, mode)
|
|
os.replace(tmp_path, path)
|
|
os.chmod(path, mode)
|
|
finally:
|
|
try:
|
|
if os.path.exists(tmp_path):
|
|
os.remove(tmp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
def main():
|
|
now = datetime.now(TZ)
|
|
y, m = now.year, now.month
|
|
|
|
# aktueller + nächster Monat (Abo hat “Vorlauf”)
|
|
months = [(y, m), month_add(y, m, 1)]
|
|
|
|
all_events: list[dict] = []
|
|
calname_final = None
|
|
|
|
for yy, mm in months:
|
|
html = fetch_month(yy, mm)
|
|
calname, events = parse_events(html)
|
|
calname_final = calname_final or calname
|
|
all_events.extend(events)
|
|
|
|
# dedupe nach UID
|
|
unique = {e["uid"]: e for e in all_events}
|
|
all_events = list(unique.values())
|
|
all_events.sort(key=lambda e: e["start"])
|
|
|
|
ics = build_ics(calname_final or "IGMG Gebetszeiten", all_events)
|
|
atomic_write(OUT_ICS, ics)
|
|
|
|
# simple Landing-Page
|
|
updated = datetime.now(TZ).strftime("%Y-%m-%d %H:%M %Z")
|
|
index = f"""<!doctype html>
|
|
<html lang="de">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>IGMG ICS</title></head>
|
|
<body>
|
|
<h1>{calname_final or "IGMG Gebetszeiten"}</h1>
|
|
<p>Letztes Update: <b>{updated}</b></p>
|
|
<p><a href="/{ICS_FILENAME}">/{ICS_FILENAME}</a></p>
|
|
<p>Quelle: <a href="{SOURCE_PAGE}">{SOURCE_PAGE}</a></p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
atomic_write(OUT_INDEX, index)
|
|
|
|
print(f"[ok] wrote {OUT_ICS} ({len(all_events)} events) months={months}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|