OSMcal_Mobilizon_Import/import-osmcal-to-mobilizon.py

665 lines
17 KiB
Python
Executable File

#!/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/<ID>.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", "<br>")
parts.append(f"<p>{escaped_description}</p>")
parts.append("<hr>")
parts.append("<p>Diese Veranstaltung wurde aus dem OpenStreetMap Calendar importiert.</p>")
if escaped_url:
parts.append(
f'<p><strong>Quelle:</strong> <a href="{escaped_url}">{escaped_url}</a></p>'
)
if location_text:
parts.append(
f"<p><strong>Ort:</strong> {html.escape(location_text)}</p>"
)
if distance is not None:
parts.append(
f"<p><strong>Entfernung von Heidelberg:</strong> {distance} km</p>"
)
parts.append(f"<p><em>Originaltitel:</em> {escaped_name}</p>")
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()