266 lines
8.6 KiB
Python
266 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
import datetime as dt
|
||
import os
|
||
import re
|
||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
from fpdf import FPDF
|
||
|
||
MONTHS = (
|
||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||
)
|
||
WEEKDAYS = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
|
||
DATE_PATTERNS = (
|
||
(re.compile(r"(?<!\d)(\d{4})-(\d{1,2})-(\d{1,2})(?!\d)"), (1, 2, 3)),
|
||
(re.compile(r"(?<!\d)(\d{1,2})\.(\d{1,2})\.(\d{4})(?!\d)"), (3, 2, 1)),
|
||
)
|
||
PDF_TRANSLATION = str.maketrans({
|
||
"„": '"', "“": '"', "‚": "'", "‘": "'", "’": "'",
|
||
"–": "-", "—": "-", "…": "...", "\xa0": " ",
|
||
})
|
||
TABLE_COLUMNS = (
|
||
(20, "Datum", "C"),
|
||
(15, "Tag", "C"),
|
||
(20, "Uhrzeit", "C"),
|
||
(75, "Veranstaltung", "L"),
|
||
(60, "Ort", "C"),
|
||
)
|
||
|
||
|
||
def pdf_safe(text):
|
||
text = "" if text is None else str(text)
|
||
return text.translate(PDF_TRANSLATION).encode("latin-1", "replace").decode("latin-1")
|
||
|
||
|
||
def parse_date(value):
|
||
for pattern, order in DATE_PATTERNS:
|
||
match = pattern.search(str(value or ""))
|
||
if match:
|
||
parts = match.groups()
|
||
try:
|
||
return dt.date(*(int(parts[index - 1]) for index in order))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def calendar_cells(html):
|
||
cells = {}
|
||
for element in BeautifulSoup(html, "html.parser").find_all(attrs={"data-date": True}):
|
||
day = parse_date(element.get("data-date"))
|
||
if day:
|
||
cells.setdefault(day, element)
|
||
return cells
|
||
|
||
|
||
def has_style(value, declaration):
|
||
return bool(value) and declaration in value.replace(" ", "").lower()
|
||
|
||
|
||
def event_rows(cell):
|
||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||
|
||
for title in titles:
|
||
time_text = ""
|
||
block = title.find_parent("div", class_="calEventBox")
|
||
details = block and block.find(
|
||
style=lambda value: has_style(value, "font-weight:normal")
|
||
)
|
||
if details:
|
||
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||
if parts and ":" in parts[0]:
|
||
time_text = parts.pop(0)
|
||
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
|
||
location = block and block.find(attrs={"name": "show_place"})
|
||
place = location.get_text(" ", strip=True).lstrip(", ") if location else ""
|
||
yield time_text, title.get_text(strip=True), "" if place == "--" else place
|
||
|
||
|
||
def next_month(month):
|
||
if month.month == 12:
|
||
return dt.date(month.year + 1, 1, 1)
|
||
return dt.date(month.year, month.month + 1, 1)
|
||
|
||
|
||
def target_months(today):
|
||
current = today.replace(day=1)
|
||
return current, next_month(current)
|
||
|
||
|
||
def month_days(month):
|
||
count = (next_month(month) - month).days
|
||
return (month + dt.timedelta(days=offset) for offset in range(count))
|
||
|
||
|
||
def month_end(month):
|
||
return next_month(month) - dt.timedelta(days=1)
|
||
|
||
|
||
def missing_days(cells, month):
|
||
return [day for day in month_days(month) if day not in cells]
|
||
|
||
|
||
def require_month(cells, month):
|
||
missing = missing_days(cells, month)
|
||
if not missing:
|
||
return
|
||
|
||
label = month.strftime("%m.%Y")
|
||
if len(missing) == (next_month(month) - month).days:
|
||
raise RuntimeError(f"CKalender enthält keine Daten für {label}.")
|
||
|
||
preview = ", ".join(day.strftime("%d.%m.") for day in missing[:3])
|
||
suffix = " …" if len(missing) > 3 else ""
|
||
raise RuntimeError(
|
||
f"CKalender-Daten für {label} sind unvollständig "
|
||
f"({len(missing)} Tage fehlen: {preview}{suffix})."
|
||
)
|
||
|
||
|
||
def calendar_url_for_period(url, start, end, kind):
|
||
parts = urlsplit(url)
|
||
overridden = {"calendar_kind", "from_date", "until_date"}
|
||
query = [
|
||
item for item in parse_qsl(parts.query, keep_blank_values=True)
|
||
if item[0] not in overridden
|
||
]
|
||
query += [
|
||
("calendar_kind", kind),
|
||
("from_date", start.isoformat()),
|
||
("until_date", end.isoformat()),
|
||
]
|
||
return parts._replace(query=urlencode(query)).geturl()
|
||
|
||
|
||
def fetch_calendar_cells(url, start, end, kind):
|
||
response = requests.get(calendar_url_for_period(url, start, end, kind), timeout=30)
|
||
response.raise_for_status()
|
||
return calendar_cells(response.text)
|
||
|
||
|
||
def load_calendar_cells(url, today):
|
||
months = target_months(today)
|
||
cells = fetch_calendar_cells(url, months[0], month_end(months[1]), "year")
|
||
|
||
for month in months:
|
||
if not missing_days(cells, month):
|
||
continue
|
||
end = month_end(month)
|
||
fetched = fetch_calendar_cells(url, month, end, "month")
|
||
cells.update({
|
||
day: cell for day, cell in fetched.items()
|
||
if month <= day <= end
|
||
})
|
||
return cells
|
||
|
||
|
||
def add_table_header(pdf):
|
||
pdf.set_font("helvetica", "B", 10)
|
||
for index, (width, label, align) in enumerate(TABLE_COLUMNS):
|
||
pdf.cell(width, 5, label, 1, int(index == len(TABLE_COLUMNS) - 1), align)
|
||
|
||
|
||
def add_footer(pdf, now):
|
||
pdf.set_font("helvetica", "", 8)
|
||
pdf.cell(0, 1, " ", 0, 1)
|
||
pdf.cell(0, 0.1, " ", 1, 1)
|
||
pdf.cell(0, 1, " ", 0, 1)
|
||
pdf.cell(153, 3, "gemeindeleitung@nak-reinickendorf.de", 0, 0)
|
||
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
|
||
pdf.set_font("helvetica", "I", 8)
|
||
pdf.cell(153, 3, f"Berlin, {now:%d.%m.%Y %H:%M}", 0, 0)
|
||
pdf.set_font("helvetica", "", 8)
|
||
pdf.cell(30, 3, "nak-reinickendorf.de", 0, 1)
|
||
|
||
|
||
def add_month(pdf, cells, month, today=None):
|
||
for day in month_days(month):
|
||
pdf.set_fill_color(130)
|
||
pdf.cell(0, 0.3, " ", 0, 1, fill=True)
|
||
pdf.set_fill_color(255 if day.day % 2 else 220)
|
||
pdf.set_font("helvetica", "B" if day.weekday() >= 5 else "", 8)
|
||
if day.weekday() == 6:
|
||
pdf.set_fill_color(170, 210, 255)
|
||
if day == today:
|
||
pdf.set_fill_color(255, 255, 0)
|
||
|
||
pdf.cell(20, 4, day.strftime("%d.%m.%Y"), 0, 0, "C", fill=True)
|
||
pdf.cell(15, 4, WEEKDAYS[day.weekday()], 0, 0, "C", fill=True)
|
||
|
||
events = list(event_rows(cells[day])) if day in cells else []
|
||
if not events:
|
||
pdf.cell(0, 4, " ", 0, 1, fill=True)
|
||
continue
|
||
|
||
for index, (time_text, title, place) in enumerate(events):
|
||
if index:
|
||
pdf.cell(35, 4, " ", 0, 0, fill=True)
|
||
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
|
||
pdf.cell(75, 4, pdf_safe(title), 0, 0, fill=True)
|
||
pdf.cell(60, 4, pdf_safe(place), 0, 1, "C", fill=True)
|
||
|
||
|
||
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||
name = MONTHS[month.month - 1]
|
||
pdf.set_font("helvetica", "B", 16)
|
||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||
if not os.path.isfile(logo):
|
||
raise RuntimeError(f"Logo fehlt: {logo}")
|
||
pdf.image(logo, 188, pdf.get_y() - 7.5, 11)
|
||
pdf.set_font("helvetica", "", 10)
|
||
subtitle = (
|
||
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
||
)
|
||
pdf.cell(160, 5, pdf_safe(subtitle), 0, 1)
|
||
pdf.cell(0, 2, " ", 0, 1)
|
||
add_table_header(pdf)
|
||
add_month(pdf, cells, month, today)
|
||
add_footer(pdf, now)
|
||
|
||
|
||
def create_pdf(cells, output_path, now=None):
|
||
now = now or dt.datetime.now()
|
||
today = now.date()
|
||
months = target_months(today)
|
||
for month in months:
|
||
require_month(cells, month)
|
||
|
||
pdf = FPDF("P", "mm", "A4")
|
||
pdf.set_auto_page_break(auto=True, margin=15)
|
||
pdf.add_page()
|
||
|
||
for index, month in enumerate(months):
|
||
if index and pdf.page_no() == 1:
|
||
pdf.add_page()
|
||
elif index:
|
||
pdf.cell(0, 6, " ", 0, 1)
|
||
add_month_section(
|
||
pdf, cells, month, now,
|
||
today=today if index == 0 else None,
|
||
planning_note=index == 1,
|
||
)
|
||
|
||
output_dir = os.path.dirname(output_path)
|
||
if output_dir:
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
pdf.output(output_path)
|
||
|
||
|
||
def main():
|
||
url = os.environ.get("CKAL_CALENDAR_URL")
|
||
if not url:
|
||
raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
|
||
|
||
now = dt.datetime.now()
|
||
cells = load_calendar_cells(url, now.date())
|
||
create_pdf(cells, os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf"), now)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|