Files

266 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()