Initial commit
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal 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
7
README.md
Normal 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
44
docker-compose.yml
Normal 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
16
nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
traefik/dynamic/igmg-ics.yml
Normal file
15
traefik/dynamic/igmg-ics.yml
Normal 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
21
updater/Dockerfile
Normal 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
4
updater/crontab
Normal 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
8
updater/entrypoint.sh
Normal 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
209
updater/update.py
Normal 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()
|
||||||
Reference in New Issue
Block a user