Spanien (Renfe + Cercanías), Polen, Norwegen, Dänemark, Österreich (ÖBB + Wiener Linien), Schweden (Key)

- mehrere Quellen je Land (country), Grenzen aus Natural Earth, OSM und Kachelausschnitt erweitert
- Fahrpläne glätten: Unterordner im Zip, aufgefüllte Felder, fehlende parent_station, level_id
- Gattung aus der Zugnummer (ÖBB 'RJ 658'), Fernverkehr auch über Routentyp 101/102/105
- Echtzeit: Verspätung vor Uhrzeit (Renfe), Fahrten ohne Datum, Key in der Adresse (Trafiklab)
- httpx-Logs aus (würden Keys zeigen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-23 09:53:56 +02:00
parent 650f8dca8c
commit ef67df6e00
12 changed files with 247 additions and 57 deletions
+43 -7
View File
@@ -43,7 +43,9 @@ def timetable_year(today: dt.date) -> int:
def gtfs_url(src: str) -> str:
url = C.SOURCES[src]["gtfs_url"]
cfg = C.SOURCES[src]
# {year}: ÖBB (Datei je Fahrplanjahr), {key}: Schweden (Key in der Adresse)
url = cfg["gtfs_url"].replace("{year}", str(timetable_year(dt.date.today()))).replace("{key}", cfg["rt_key"])
if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
@@ -132,6 +134,28 @@ def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path,
return n, seen
def normalize_zip(zip_path: Path, work: Path) -> Path:
"""Eigenheiten mancher Fahrpläne glätten: Dateien in einem Unterordner (ÖBB) und mit Leerzeichen
aufgefüllte Felder ("10T0001C1 ", Renfe Cercanías). Sonst die Datei unverändert."""
z = zipfile.ZipFile(zip_path)
members = {Path(n).name: n for n in z.namelist() if n.endswith(".txt")}
padded = any(c != c.strip() for n in members.values() for c in rows(z, n)[0])
if not padded and all(members[k] == k for k in members):
return zip_path
out = work / "normalized.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as o:
for base, name in members.items():
with z.open(name) as src, o.open(base, "w") as dst:
if not padded:
shutil.copyfileobj(src, dst, 1 << 20)
continue
with io.TextIOWrapper(dst, encoding="utf-8", newline="") as t: # schliessen = alles schreiben
w = csv.writer(t)
for row in csv.reader(io.TextIOWrapper(src, encoding="utf-8-sig", newline="")):
w.writerow([c.strip() for c in row])
return out
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
@@ -159,9 +183,12 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id")
h, rd = rows(z, "stops.txt")
sid, par = h.index("stop_id"), h.index("parent_station")
sid = h.index("stop_id")
stops = list(rd)
parents = {r[par] for r in stops if r[sid] in used_stops}
parents = {r[h.index("parent_station")] for r in stops if r[sid] in used_stops} if "parent_station" in h else set()
if "level_id" in h: # levels.txt kopieren wir nicht (ÖBB) -> Verweise leeren, sonst bricht pfaedle ab
for r in stops:
r[h.index("level_id")] = ""
write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"):
@@ -223,7 +250,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm),
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(C.country(source)), str(ensure_osm()), "-o", str(osm),
"--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time()
@@ -308,10 +335,16 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
version = None
cfg, version = C.SOURCES[source], None
if zip_path:
name = zip_path.name
else:
if cfg.get("static_needs_key") and not cfg["rt_key"]:
log.info("[%s] Kein Key eingetragen – übersprungen", source)
return
if time.time() - state.get(source + "@checked", 0) < cfg.get("check_hours", 0) * 3600 and not force:
return # Anbieter mit knappem Abruf-Kontingent (Schweden) nicht stündlich fragen
state[source + "@checked"] = time.time()
url = gtfs_url(source)
name, version = remote_id(source, url)
if f"{name} #{FORMAT}" == state.get(source) and not force:
@@ -322,10 +355,12 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
download(url, zip_path)
t = time.time()
version = version or zip_path.stem # (nach dem Glätten hiesse die Datei sonst "normalized")
zip_path = normalize_zip(zip_path, work)
meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if zip_path.parent == work:
zip_path.unlink()
for p in (work / "gtfs.zip", work / "normalized.zip"):
p.unlink(missing_ok=True)
shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None
@@ -347,6 +382,7 @@ def main() -> None:
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # würde Adressen samt Key (Schweden) loggen
if args.zip and not args.source:
ap.error("--zip braucht --source")