Initial OSMCal to Mobilizon importer
This commit is contained in:
commit
cbadfc4c7c
664
import-osmcal-to-mobilizon.py
Executable file
664
import-osmcal-to-mobilizon.py
Executable file
@ -0,0 +1,664 @@
|
|||||||
|
#!/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()
|
||||||
117
readme.md
Normal file
117
readme.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# OSMCal → Mobilizon Importer
|
||||||
|
EN: Import events from [OSMCal](https://osmcal.org/) into a Mobilizon group, e.g. `https://rheinneckar.events`.
|
||||||
|
DE: Importiert Veranstaltungen aus dem OpenStreetMap Calendar nach Mobilizon, filtert sie lokal nach Entfernung um Heidelberg und legt sie als Gruppenveranstaltungen an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features / Funktionen
|
||||||
|
|
||||||
|
- Fetches German OSMCal events via API
|
||||||
|
Lädt deutsche OSMCal-Veranstaltungen über die API
|
||||||
|
|
||||||
|
- Filters events by radius around Heidelberg
|
||||||
|
Filtert Veranstaltungen nach Radius um Heidelberg
|
||||||
|
|
||||||
|
- Reads the original description from each event’s `.ics` file
|
||||||
|
Liest die Originalbeschreibung aus der jeweiligen `.ics`-Datei
|
||||||
|
|
||||||
|
- Imports into Mobilizon via GraphQL
|
||||||
|
Importiert nach Mobilizon per GraphQL
|
||||||
|
|
||||||
|
- Supports Mobilizon group posting
|
||||||
|
Unterstützt Veröffentlichung als Mobilizon-Gruppe
|
||||||
|
|
||||||
|
- Avoids duplicate imports using a local state file
|
||||||
|
Verhindert doppelte Importe über eine lokale Statusdatei
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements / Voraussetzungen
|
||||||
|
|
||||||
|
- Python 3
|
||||||
|
- Mobilizon account
|
||||||
|
- Membership in the target Mobilizon group
|
||||||
|
- OAuth token for the Mobilizon API
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install requests python-dotenv icalendar
|
||||||
|
|
||||||
|
#auf dem eigenen Rechner voraussetzungen schaffen
|
||||||
|
#Verzeichnis erstellen und darin ausführen:
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
## Configuration / Konfiguration
|
||||||
|
|
||||||
|
Create a .env file:
|
||||||
|
|
||||||
|
MOBILIZON_INSTANCE="https://rheinneckar.events"
|
||||||
|
MOBILIZON_TOKEN="API token"
|
||||||
|
MOBILIZON_ORGANIZER_ACTOR_ID="profile actor ID"
|
||||||
|
MOBILIZON_ATTRIBUTED_TO_ID="group actor ID"
|
||||||
|
|
||||||
|
#The helper script setup-mobilizon.py can create this .env file.
|
||||||
|
|
||||||
|
## Usage / Nutzung
|
||||||
|
|
||||||
|
Run the importer:
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
./import-osmcal-to-mobilizon.py
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
70 Veranstaltungen aus OSMCal geladen
|
||||||
|
9 Veranstaltungen im 15-km-Umkreis um Heidelberg
|
||||||
|
Fertig. Importiert: 9, übersprungen: 0
|
||||||
|
|
||||||
|
## Settings / Einstellungen
|
||||||
|
Edit these values in import-osmcal-to-mobilizon.py.
|
||||||
|
Diese Werte in dieser Datei anpassen
|
||||||
|
|
||||||
|
Radius / Radius um die angegebenen Koordinaten
|
||||||
|
RADIUS_KM = 18.0
|
||||||
|
|
||||||
|
Draft mode / Entwurfsmodus
|
||||||
|
DRAFT = True
|
||||||
|
Creates drafts / Veranstaltungen werden als Entwurf veröffentlicht.
|
||||||
|
DRAFT = False
|
||||||
|
Publishes directly / Veranstaltungen werden direkt veröffentlicht.
|
||||||
|
|
||||||
|
Event duration / Veranstaltungsdauer
|
||||||
|
EVENT_DURATION_HOURS = 3
|
||||||
|
|
||||||
|
## Duplicate handling / Umgang mit bereits importierten Veranstaltungen
|
||||||
|
|
||||||
|
Imported events are stored in:
|
||||||
|
imported-osmcal-events.json
|
||||||
|
|
||||||
|
Veranstaltungen in dieser Datei werden bei einem erneuten Lauf des Skriptes übersprungen.
|
||||||
|
Events listed there are skipped on future runs.
|
||||||
|
|
||||||
|
Reset for a new test / Zurücksetzen vor einen neuen Lauf:
|
||||||
|
```bash
|
||||||
|
echo '{}' > imported-osmcal-events.json
|
||||||
|
|
||||||
|
Vorher alte Entwürfe oder Events in Mobilizon löschen, sonst entstehen Duplikate.
|
||||||
|
Delete old Mobilizon drafts or events first, otherwise duplicates may be created.
|
||||||
|
|
||||||
|
Notes / Hinweise
|
||||||
|
|
||||||
|
DE:
|
||||||
|
Bestehende Mobilizon-Events werden nicht aktualisiert.
|
||||||
|
Bestehende Entwürfe werden nicht automatisch veröffentlicht.
|
||||||
|
Wenn DRAFT von True auf False geändert wird, werden bereits importierte Events trotzdem übersprungen.
|
||||||
|
Der Access-Token kann ablaufen und muss dann erneuert werden.
|
||||||
|
|
||||||
|
EN:
|
||||||
|
Existing Mobilizon events are not updated.
|
||||||
|
Existing drafts are not automatically published.
|
||||||
|
If DRAFT is changed from True to False, already imported events are still skipped.
|
||||||
|
The access token may expire and then has to be renewed.
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
certifi==2026.4.22
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
icalendar==7.1.0
|
||||||
|
idna==3.13
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
requests==2.33.1
|
||||||
|
six==1.17.0
|
||||||
|
tzdata==2026.2
|
||||||
|
urllib3==2.6.3
|
||||||
310
setup-mobilizon.py
Executable file
310
setup-mobilizon.py
Executable file
@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
INSTANCE = "https://rheinneckar.events"
|
||||||
|
|
||||||
|
# Wichtig:
|
||||||
|
# "read" wird benötigt, damit loggedUser.memberships gelesen werden darf.
|
||||||
|
# Die write:event:* Scopes werden später für das Erstellen/Aktualisieren/Löschen von Events genutzt.
|
||||||
|
SCOPES = "read write:event:create write:event:update write:event:delete"
|
||||||
|
|
||||||
|
APP_FILE = Path("mobilizon-app.json")
|
||||||
|
TOKEN_FILE = Path("mobilizon-token.json")
|
||||||
|
ENV_FILE = Path(".env")
|
||||||
|
|
||||||
|
|
||||||
|
def post_form(url, data):
|
||||||
|
response = requests.post(url, data=data, timeout=30)
|
||||||
|
print(f"POST {url} -> HTTP {response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
print("Keine JSON-Antwort:")
|
||||||
|
print(response.text)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
print("Fehlerantwort:")
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def graphql(access_token, query, variables=None):
|
||||||
|
response = requests.post(
|
||||||
|
f"{INSTANCE}/api",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": query,
|
||||||
|
"variables": variables or {},
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"GraphQL -> HTTP {response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
print("Keine JSON-Antwort:")
|
||||||
|
print(response.text)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if response.status_code >= 400 or payload.get("errors"):
|
||||||
|
print("GraphQL-Fehler:")
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
return payload["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_app():
|
||||||
|
print("1. App wird registriert...")
|
||||||
|
|
||||||
|
app = post_form(
|
||||||
|
f"{INSTANCE}/apps",
|
||||||
|
{
|
||||||
|
"name": "OSMCal Importer",
|
||||||
|
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
"website": INSTANCE,
|
||||||
|
"scope": SCOPES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
APP_FILE.write_text(
|
||||||
|
json.dumps(app, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def request_device_code(client_id):
|
||||||
|
print()
|
||||||
|
print("2. Login-Code wird angefordert...")
|
||||||
|
|
||||||
|
device = post_form(
|
||||||
|
f"{INSTANCE}/login/device/code",
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"scope": SCOPES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Öffne diese Adresse im Browser:")
|
||||||
|
print(device["verification_uri"])
|
||||||
|
print()
|
||||||
|
print("Gib dort diesen Code ein:")
|
||||||
|
print(device["user_code"])
|
||||||
|
print()
|
||||||
|
print("Danach hier warten. Das Script fragt automatisch ab, ob du bestätigt hast.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_token(client_id, device):
|
||||||
|
interval = int(device.get("interval", 5))
|
||||||
|
expires_in = int(device.get("expires_in", 900))
|
||||||
|
deadline = time.time() + expires_in
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{INSTANCE}/oauth/token",
|
||||||
|
data={
|
||||||
|
"client_id": client_id,
|
||||||
|
"device_code": device["device_code"],
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
print("Keine JSON-Antwort beim Token-Abruf:")
|
||||||
|
print(response.text)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if "access_token" in payload:
|
||||||
|
print("Token erhalten.")
|
||||||
|
|
||||||
|
TOKEN_FILE.write_text(
|
||||||
|
json.dumps(payload, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
error = payload.get("error")
|
||||||
|
|
||||||
|
if error == "authorization_pending":
|
||||||
|
print("Noch nicht bestätigt...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if error == "slow_down":
|
||||||
|
print("Server bittet um langsameres Abfragen...")
|
||||||
|
interval += 5
|
||||||
|
continue
|
||||||
|
|
||||||
|
print("Token-Abruf fehlgeschlagen:")
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print("Der Login-Code ist abgelaufen. Script neu starten.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_memberships(access_token):
|
||||||
|
print()
|
||||||
|
print("3. Gruppenmitgliedschaften werden abgefragt...")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query LoggedUserMemberships($page: Int, $limit: Int) {
|
||||||
|
loggedUser {
|
||||||
|
id
|
||||||
|
memberships(page: $page, limit: $limit) {
|
||||||
|
total
|
||||||
|
elements {
|
||||||
|
role
|
||||||
|
actor {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
preferredUsername
|
||||||
|
name
|
||||||
|
}
|
||||||
|
parent {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
preferredUsername
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = graphql(
|
||||||
|
access_token,
|
||||||
|
query,
|
||||||
|
variables={
|
||||||
|
"page": 1,
|
||||||
|
"limit": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
memberships_data = data.get("loggedUser", {}).get("memberships")
|
||||||
|
|
||||||
|
if not memberships_data:
|
||||||
|
print()
|
||||||
|
print("Keine Gruppenmitgliedschaften gefunden.")
|
||||||
|
print("Mögliche Ursachen:")
|
||||||
|
print("- Du bist mit diesem Account in keiner Gruppe Mitglied.")
|
||||||
|
print("- Die App wurde nicht mit dem Scope 'read' autorisiert.")
|
||||||
|
print("- Der verwendete Account ist nicht der erwartete Mobilizon-Account.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
memberships = memberships_data.get("elements", [])
|
||||||
|
|
||||||
|
if not memberships:
|
||||||
|
print()
|
||||||
|
print("Du bist mit diesem Account in keiner Gruppe Mitglied.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
return memberships
|
||||||
|
|
||||||
|
|
||||||
|
def choose_membership(memberships):
|
||||||
|
print()
|
||||||
|
print("Deine Gruppen:")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for index, membership in enumerate(memberships, start=1):
|
||||||
|
profile = membership["actor"]
|
||||||
|
group = membership["parent"]
|
||||||
|
role = membership["role"]
|
||||||
|
|
||||||
|
profile_name = profile.get("name") or profile.get("preferredUsername") or "Profil"
|
||||||
|
group_name = group.get("name") or group.get("preferredUsername") or "Gruppe"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{index}. Gruppe: {group_name} "
|
||||||
|
f"(group_id={group['id']}) | "
|
||||||
|
f"Profil: {profile_name} "
|
||||||
|
f"(profile_id={profile['id']}) | "
|
||||||
|
f"Rolle: {role}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
choice = input("Welche Gruppe soll für den Import verwendet werden? Nummer eingeben: ").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = memberships[int(choice) - 1]
|
||||||
|
except Exception:
|
||||||
|
print("Ungültige Auswahl.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
role = selected["role"]
|
||||||
|
|
||||||
|
if role not in ("MEMBER", "MODERATOR", "ADMINISTRATOR"):
|
||||||
|
print()
|
||||||
|
print(f"Warnung: Deine Rolle ist {role}.")
|
||||||
|
print("Falls der Import später fehlschlägt, darf dieses Profil vermutlich keine Gruppen-Events veröffentlichen.")
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(app, token, membership):
|
||||||
|
organizer_actor_id = membership["actor"]["id"]
|
||||||
|
attributed_to_id = membership["parent"]["id"]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f'MOBILIZON_INSTANCE="{INSTANCE}"',
|
||||||
|
f'MOBILIZON_CLIENT_ID="{app["client_id"]}"',
|
||||||
|
f'MOBILIZON_CLIENT_SECRET="{app.get("client_secret", "")}"',
|
||||||
|
f'MOBILIZON_TOKEN="{token["access_token"]}"',
|
||||||
|
f'MOBILIZON_REFRESH_TOKEN="{token.get("refresh_token", "")}"',
|
||||||
|
f'MOBILIZON_ORGANIZER_ACTOR_ID="{organizer_actor_id}"',
|
||||||
|
f'MOBILIZON_ATTRIBUTED_TO_ID="{attributed_to_id}"',
|
||||||
|
]
|
||||||
|
|
||||||
|
ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
ENV_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Fertig. .env wurde geschrieben.")
|
||||||
|
print()
|
||||||
|
print("Gesetzte Werte:")
|
||||||
|
print(f"MOBILIZON_ORGANIZER_ACTOR_ID={organizer_actor_id}")
|
||||||
|
print(f"MOBILIZON_ATTRIBUTED_TO_ID={attributed_to_id}")
|
||||||
|
print()
|
||||||
|
print("Hinweis: Der Access-Token läuft nach einigen Stunden ab.")
|
||||||
|
print("Für erste Tests reicht das. Für Cron-Betrieb sollte später Refresh-Token-Unterstützung ergänzt werden.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = register_app()
|
||||||
|
device = request_device_code(app["client_id"])
|
||||||
|
token = wait_for_token(app["client_id"], device)
|
||||||
|
memberships = get_memberships(token["access_token"])
|
||||||
|
selected = choose_membership(memberships)
|
||||||
|
write_env(app, token, selected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user