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