665 lines
17 KiB
Python
Executable File
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()
|