#!/usr/bin/env python3 """ Importiert OSMCal-Veranstaltungen im angegebenen Umkreis um Heidelberg nach Mobilizon. Datenquellen: - OSMCal API: https://osmcal.org/api/v2/events/?in=de Liefert Titel, Datum, Ort, Koordinaten und Event-URL. - OSMCal Einzel-ICS: https://osmcal.org/event/.ics Liefert die Originalbeschreibung über DESCRIPTION. Ziel: - Mobilizon GraphQL API: https://rheinneckar.events/api Hinweise: - Das Script importiert nur neue Events. - Bereits importierte Events werden in imported-osmcal-events.json gespeichert. - Bestehende Mobilizon-Entwürfe werden NICHT aktualisiert oder veröffentlicht. - Veranstaltungsende wird immer auf Startzeit + 3 Stunden gesetzt. Voraussetzungen: vor Ausführung Api-Schlüssel in die .env Datei speichern Ausführen: source .venv/bin/activate pip install requests python-dotenv icalendar """ import os import re import json import html import hashlib from datetime import datetime, timedelta from math import radians, sin, cos, sqrt, atan2 from pathlib import Path import requests from dotenv import load_dotenv from icalendar import Calendar # ------------------------------------------------------------ # .env laden # ------------------------------------------------------------ SCRIPT_DIR = Path(__file__).resolve().parent load_dotenv(SCRIPT_DIR / ".env") # ------------------------------------------------------------ # Konfiguration # ------------------------------------------------------------ # Zentrum: Heidelberg CENTER_LAT = 49.4094 CENTER_LON = 8.6947 # Radius in Kilometern RADIUS_KM = 18.0 # True = als Entwurf importieren # False = direkt veröffentlichen DRAFT = False # Feste Dauer je Veranstaltung EVENT_DURATION_HOURS = 3 # OSMCal API für kommende Veranstaltungen in Deutschland OSMCAL_API = "https://osmcal.org/api/v2/events/?in=de" # Mobilizon-Instanz aus .env oder Standardwert MOBILIZON_INSTANCE = os.environ.get("MOBILIZON_INSTANCE", "https://rheinneckar.events") MOBILIZON_API = f"{MOBILIZON_INSTANCE.rstrip('/')}/api" # Diese Werte müssen in .env stehen MOBILIZON_TOKEN = os.environ["MOBILIZON_TOKEN"] ORGANIZER_ACTOR_ID = os.environ["MOBILIZON_ORGANIZER_ACTOR_ID"] ATTRIBUTED_TO_ID = os.environ["MOBILIZON_ATTRIBUTED_TO_ID"] # Datei zum Vermeiden von Duplikaten, hier werden bearbeitete Veranstaltungen gespeichert. # vorher leeren wenn alle Veranstaltungen neu importiert werden sollen, mit # echo '{}' > imported-osmcal-events.json STATE_FILE = SCRIPT_DIR / "imported-osmcal-events.json" # ------------------------------------------------------------ # Mobilizon GraphQL Mutation # ------------------------------------------------------------ CREATE_EVENT_MUTATION = """ mutation createEvent( $organizerActorId: ID! $title: String! $attributedToId: ID $description: String! $beginsOn: DateTime! $endsOn: DateTime $status: EventStatus $visibility: EventVisibility $joinOptions: EventJoinOptions $draft: Boolean $tags: [String] $onlineAddress: String $physicalAddress: AddressInput $options: EventOptionsInput ) { createEvent( organizerActorId: $organizerActorId attributedToId: $attributedToId title: $title description: $description beginsOn: $beginsOn endsOn: $endsOn status: $status visibility: $visibility joinOptions: $joinOptions draft: $draft tags: $tags onlineAddress: $onlineAddress physicalAddress: $physicalAddress options: $options ) { id uuid title url } } """ # ------------------------------------------------------------ # Allgemeine Hilfsfunktionen # ------------------------------------------------------------ def clean_text(value): """ Wandelt Werte in saubere Strings um. Gibt None zurück, wenn der Wert leer ist. """ if value is None: return None value = str(value).strip() if not value: return None return value def add_hours_to_iso_datetime(iso_datetime, hours): """ Addiert Stunden auf eine ISO-Datetime-Zeichenkette. Beispiel: 2026-05-18T19:00:00+02:00 -> 2026-05-18T22:00:00+02:00 """ dt = datetime.fromisoformat(iso_datetime) return (dt + timedelta(hours=hours)).isoformat() def distance_km(lat1, lon1, lat2, lon2): """ Berechnet die Distanz zwischen zwei Koordinaten per Haversine-Formel. Rückgabe: Kilometer. """ earth_radius_km = 6371.0 dlat = radians(lat2 - lat1) dlon = radians(lon2 - lon1) a = ( sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 ) return 2 * earth_radius_km * atan2(sqrt(a), sqrt(1 - a)) def extract_osmcal_event_id(event_url): """ Extrahiert die numerische OSMCal-ID aus URLs wie: https://osmcal.org/event/4742/ """ if not event_url: return None match = re.search(r"/event/(\d+)/?", event_url) if not match: return None return match.group(1) def osmcal_event_ics_url(event_url): """ Wandelt eine OSMCal-Web-URL in die Einzel-ICS-URL um. Beispiel: https://osmcal.org/event/4742/ -> https://osmcal.org/event/4742.ics """ event_id = extract_osmcal_event_id(event_url) if not event_id: return None return f"https://osmcal.org/event/{event_id}.ics" # ------------------------------------------------------------ # Statusdatei # ------------------------------------------------------------ def save_state(state): """ Speichert die Liste bereits importierter Events. """ STATE_FILE.write_text( json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8", ) def load_state(): """ Lädt die lokale Import-Statusdatei. Verhalten: - Datei fehlt: wird mit {} angelegt. - Datei ist leer: wird mit {} ersetzt. - Datei ist kaputt: wird gesichert und neu mit {} angelegt. """ if not STATE_FILE.exists(): save_state({}) return {} content = STATE_FILE.read_text(encoding="utf-8").strip() if not content: save_state({}) return {} try: return json.loads(content) except json.JSONDecodeError: backup_file = STATE_FILE.with_suffix(".json.broken") STATE_FILE.rename(backup_file) print(f"Warnung: {STATE_FILE.name} war kein gültiges JSON.") print(f"Defekte Datei wurde gesichert als: {backup_file.name}") print(f"{STATE_FILE.name} wird neu mit {{}} angelegt.") save_state({}) return {} def osmcal_event_key(event): """ Erzeugt einen stabilen Schlüssel für die Duplikatvermeidung. Primär wird die OSMCal-URL genutzt. Falls sie fehlt, wird ein Hash aus Titel, Startzeit und Ort gebildet. """ if event.get("url"): return event["url"] raw = "|".join( [ str(event.get("name", "")), str(event.get("date", {}).get("start", "")), str(event.get("location", {}).get("short", "")), ] ) return hashlib.sha256(raw.encode("utf-8")).hexdigest() # ------------------------------------------------------------ # OSMCal lesen # ------------------------------------------------------------ def fetch_osmcal_events(): """ Holt kommende OSMCal-Events für Deutschland. """ response = requests.get( OSMCAL_API, headers={ "Accept-Language": "de", "Client-App": "rheinneckar-events-import/0.6", }, timeout=30, ) response.raise_for_status() events = response.json() if not isinstance(events, list): raise RuntimeError("OSMCal-Antwort ist keine Event-Liste.") return events def is_near_heidelberg(event): """ Prüft, ob ein Event im konfigurierten Radius um Heidelberg liegt. OSMCal liefert coords als [lon, lat]. """ location = event.get("location") or {} coords = location.get("coords") if not coords or len(coords) != 2: return False try: lon = float(coords[0]) lat = float(coords[1]) except (TypeError, ValueError): return False distance = distance_km(CENTER_LAT, CENTER_LON, lat, lon) return distance <= RADIUS_KM def event_distance(event): """ Gibt die Entfernung eines Events zu Heidelberg zurück. Rückgabe: gerundete Kilometer oder None. """ location = event.get("location") or {} coords = location.get("coords") if not coords or len(coords) != 2: return None try: lon = float(coords[0]) lat = float(coords[1]) except (TypeError, ValueError): return None return round(distance_km(CENTER_LAT, CENTER_LON, lat, lon), 1) def fetch_osmcal_ics_description(event_url): """ Holt die Originalbeschreibung aus der Einzel-ICS-Datei. OSMCal-API liefert in der Eventliste keine Beschreibung. Die Einzel-ICS enthält aber normalerweise DESCRIPTION. """ ics_url = osmcal_event_ics_url(event_url) if not ics_url: return None response = requests.get( ics_url, headers={ "Accept-Language": "de", "Client-App": "rheinneckar-events-import/0.6", }, timeout=30, ) response.raise_for_status() calendar = Calendar.from_ical(response.content) for component in calendar.walk(): if component.name != "VEVENT": continue description = component.get("DESCRIPTION") if description: return str(description).strip() return None # ------------------------------------------------------------ # Umwandlung OSMCal -> Mobilizon # ------------------------------------------------------------ def mobilizon_address_from_osmcal(event): """ Wandelt OSMCal-Ortsdaten in Mobilizon AddressInput um. Wichtig: physicalAddress.description ist ein String, keine Liste. """ location = event.get("location") or {} coords = location.get("coords") if not coords or len(coords) != 2: return None try: lon = float(coords[0]) lat = float(coords[1]) except (TypeError, ValueError): return None venue = clean_text(location.get("venue")) short = clean_text(location.get("short")) detailed = clean_text(location.get("detailed")) description = venue or short or detailed or "Ort" address = { "description": description, "geom": f"{lon};{lat}", "country": "DE", } if short: address["locality"] = short if detailed: address["street"] = detailed return address def mobilizon_description_from_osmcal(event): """ Baut die Mobilizon-Beschreibung. Reihenfolge: 1. Originalbeschreibung aus der OSMCal-ICS 2. Trennlinie 3. Importhinweis und Metadaten Links werden als HTML erzeugt, nicht als Markdown. """ name = clean_text(event.get("name")) or "OSMCal-Veranstaltung" escaped_name = html.escape(name) url = clean_text(event.get("url")) escaped_url = html.escape(url) if url else None location = event.get("location") or {} location_text = clean_text( location.get("venue") or location.get("detailed") or location.get("short") ) distance = event_distance(event) original_description = clean_text( event.get("description") or event.get("details") or event.get("body") or event.get("text") or event.get("summary") or event.get("content") ) if not original_description and url: try: original_description = fetch_osmcal_ics_description(url) except Exception as exc: print(f"Warnung: Beschreibung konnte nicht aus ICS gelesen werden: {url} - {exc}") parts = [] if original_description: escaped_description = html.escape(original_description).replace("\n", "
") parts.append(f"

{escaped_description}

") parts.append("
") parts.append("

Diese Veranstaltung wurde aus dem OpenStreetMap Calendar importiert.

") if escaped_url: parts.append( f'

Quelle: {escaped_url}

' ) if location_text: parts.append( f"

Ort: {html.escape(location_text)}

" ) if distance is not None: parts.append( f"

Entfernung von Heidelberg: {distance} km

" ) parts.append(f"

Originaltitel: {escaped_name}

") return "\n".join(parts) # ------------------------------------------------------------ # Mobilizon schreiben # ------------------------------------------------------------ def create_mobilizon_event(event): """ Legt ein Event in Mobilizon an. Gruppenposting: - organizerActorId = Profil-ID - attributedToId = Gruppen-ID Ende: - OSMCal-Endzeit wird ignoriert. - Mobilizon-Endzeit wird immer auf Startzeit + EVENT_DURATION_HOURS gesetzt. """ title = clean_text(event.get("name")) or "OSMCal-Veranstaltung" date = event.get("date") or {} begins_on = clean_text(date.get("start")) if not begins_on: raise ValueError(f"Event ohne Startzeit: {json.dumps(event, ensure_ascii=False)}") ends_on = add_hours_to_iso_datetime(begins_on, EVENT_DURATION_HOURS) variables = { "organizerActorId": ORGANIZER_ACTOR_ID, "attributedToId": ATTRIBUTED_TO_ID, "title": title, "description": mobilizon_description_from_osmcal(event), "beginsOn": begins_on, "endsOn": ends_on, "status": "CONFIRMED", "visibility": "PUBLIC", "joinOptions": "FREE", "draft": DRAFT, "tags": ["osm", "openstreetmap", "rheinneckar"], "onlineAddress": clean_text(event.get("url")), "physicalAddress": mobilizon_address_from_osmcal(event), "options": { "commentModeration": "CLOSED", "showStartTime": True, "showEndTime": True, }, } # None-Werte entfernen, damit Mobilizon keine unnötigen Nullfelder bekommt. variables = { key: value for key, value in variables.items() if value is not None } response = requests.post( MOBILIZON_API, headers={ "Authorization": f"Bearer {MOBILIZON_TOKEN}", "Content-Type": "application/json", }, json={ "query": CREATE_EVENT_MUTATION, "variables": variables, }, timeout=30, ) try: payload = response.json() except Exception: print("Mobilizon hat keine JSON-Antwort geliefert:") print(response.text) raise if response.status_code >= 400 or payload.get("errors"): print() print("Mobilizon-Fehler beim Import dieses Events:") print(json.dumps(event, indent=2, ensure_ascii=False)) print() print("Gesendete Variablen:") print(json.dumps(variables, indent=2, ensure_ascii=False)) print() print("Antwort von Mobilizon:") print(json.dumps(payload, indent=2, ensure_ascii=False)) raise RuntimeError("Mobilizon-Import fehlgeschlagen.") return payload["data"]["createEvent"] # ------------------------------------------------------------ # Hauptprogramm # ------------------------------------------------------------ def main(): state = load_state() events = fetch_osmcal_events() nearby_events = [] for event in events: if event.get("cancelled", False): continue if is_near_heidelberg(event): nearby_events.append(event) print(f"{len(events)} Veranstaltungen aus OSMCal geladen") print(f"{len(nearby_events)} Veranstaltungen im {RADIUS_KM:g}-km-Umkreis um Heidelberg") imported = 0 skipped = 0 for event in nearby_events: key = osmcal_event_key(event) if key in state: skipped += 1 continue created = create_mobilizon_event(event) state[key] = { "osmcal_url": event.get("url"), "mobilizon_id": created.get("id"), "mobilizon_uuid": created.get("uuid"), "mobilizon_url": created.get("url"), "title": event.get("name"), "start": event.get("date", {}).get("start"), "distance_km": event_distance(event), "draft": DRAFT, "duration_hours": EVENT_DURATION_HOURS, } save_state(state) imported += 1 print( "Importiert:", event.get("name"), "->", created.get("url") or created.get("uuid") or created.get("id"), ) print() print(f"Fertig. Importiert: {imported}, übersprungen: {skipped}") if DRAFT: print() print("Hinweis: DRAFT=True. Die Events wurden als Entwurf angelegt.") print("Für Veröffentlichung im Script DRAFT = False setzen.") if __name__ == "__main__": main()