commit a0fa0ef9d9302236f94274588b331b4c27e10b35 Author: musabe24 Date: Fri Dec 26 14:51:20 2025 +0100 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e60bde3 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +TZ=Europe/Berlin +PUID=1000 +PGID=1000 + +# IGMG Quelle +CITY_ID=20020 +CITY_SLUG=Blumberg_(DE) +LANG=de + +# Output-Datei +ICS_FILENAME=igmg-20020.ics diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cbe3ab --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# IGMG Gebetszeiten -> ICS (Traefik File Provider) + +## Start +```bash +cp .env.example .env +nano .env +docker compose up -d --build diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..683e664 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + igmg-ics-web: + image: nginx:alpine + container_name: igmg-ics-web + restart: unless-stopped + + volumes: + - igmg_ics_data:/data:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - /etc/localtime:/etc/localtime:ro + + networks: + - traefik_web + - igmg_internal + + igmg-ics-updater: + build: ./updater + container_name: igmg-ics-updater + restart: unless-stopped + + volumes: + - igmg_ics_data:/data + - /etc/localtime:/etc/localtime:ro + + environment: + - TZ=${TZ} + - PUID=${PUID} + - PGID=${PGID} + - CITY_ID=${CITY_ID} + - CITY_SLUG=${CITY_SLUG} + - LANG=${LANG} + - ICS_FILENAME=${ICS_FILENAME} + + networks: + - igmg_internal + +volumes: + igmg_ics_data: + +networks: + traefik_web: + external: true + igmg_internal: + driver: bridge diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..4b5b727 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + + # iCalendar MIME-Type + types { + text/calendar ics; + } + + location / { + root /data; + autoindex off; + + add_header Cache-Control "public, max-age=300"; + add_header X-Content-Type-Options "nosniff"; + } +} diff --git a/traefik/dynamic/igmg-ics.yml b/traefik/dynamic/igmg-ics.yml new file mode 100644 index 0000000..edd3e89 --- /dev/null +++ b/traefik/dynamic/igmg-ics.yml @@ -0,0 +1,15 @@ +http: + routers: + igmg-ics: + rule: "Host(`gebetszeiten.musaberdem.de`)" + service: igmg-ics + entryPoints: + - https + tls: + certResolver: le + + services: + igmg-ics: + loadBalancer: + servers: + - url: http://igmg-ics-web:80 diff --git a/updater/Dockerfile b/updater/Dockerfile new file mode 100644 index 0000000..59d99c8 --- /dev/null +++ b/updater/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends cron tzdata ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir requests beautifulsoup4 lxml + +WORKDIR /app +COPY update.py /app/update.py +COPY entrypoint.sh /app/entrypoint.sh +COPY crontab /etc/cron.d/igmg-ics + +RUN chmod +x /app/entrypoint.sh \ + && chmod 0644 /etc/cron.d/igmg-ics \ + && crontab /etc/cron.d/igmg-ics + +CMD ["/app/entrypoint.sh"] diff --git a/updater/crontab b/updater/crontab new file mode 100644 index 0000000..8bf5676 --- /dev/null +++ b/updater/crontab @@ -0,0 +1,4 @@ +SHELL=/bin/sh +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +10 3 1 * * python /app/update.py >> /proc/1/fd/1 2>> /proc/1/fd/2 diff --git a/updater/entrypoint.sh b/updater/entrypoint.sh new file mode 100644 index 0000000..63f785f --- /dev/null +++ b/updater/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "[updater] Initial update…" +python /app/update.py || true + +echo "[updater] Starting cron…" +exec cron -f diff --git a/updater/update.py b/updater/update.py new file mode 100644 index 0000000..305fbef --- /dev/null +++ b/updater/update.py @@ -0,0 +1,209 @@ +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): + 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.replace(tmp_path, path) + 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""" + + +IGMG ICS + +

{calname_final or "IGMG Gebetszeiten"}

+

Letztes Update: {updated}

+

/{ICS_FILENAME}

+

Quelle: {SOURCE_PAGE}

+ + +""" + atomic_write(OUT_INDEX, index) + + print(f"[ok] wrote {OUT_ICS} ({len(all_events)} events) months={months}") + +if __name__ == "__main__": + main()