Initial commit

This commit is contained in:
2025-12-26 14:51:20 +01:00
commit a0fa0ef9d9
9 changed files with 335 additions and 0 deletions

11
.env.example Normal file
View File

@@ -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

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
# IGMG Gebetszeiten -> ICS (Traefik File Provider)
## Start
```bash
cp .env.example .env
nano .env
docker compose up -d --build

44
docker-compose.yml Normal file
View File

@@ -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

16
nginx.conf Normal file
View File

@@ -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";
}
}

View File

@@ -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

21
updater/Dockerfile Normal file
View File

@@ -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"]

4
updater/crontab Normal file
View File

@@ -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

8
updater/entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
echo "[updater] Initial update…"
python /app/update.py || true
echo "[updater] Starting cron…"
exec cron -f

209
updater/update.py Normal file
View File

@@ -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"""<!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()