Skip to content

Commit cac0088

Browse files
committed
Initial release: db-travel skill for OpenClaw
0 parents  commit cac0088

File tree

4 files changed

+356
-0
lines changed

4 files changed

+356
-0
lines changed

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Mario Michelli
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# db-travel
2+
3+
An [OpenClaw](https://github.com/openclaw/openclaw) skill for planning journeys across Germany and Europe using the Deutsche Bahn API.
4+
5+
Covers **all modes** — ICE, IC/EC, regional trains, S-Bahn, U-Bahn, trams, buses, ferries — with real-time delays.
6+
7+
## Features
8+
9+
- **Trip planning** — A to B with transfers, platforms, real-time delays
10+
- **Departure & arrival boards** — next departures/arrivals at any station
11+
- **Station search** — geocode place names to station IDs
12+
- **International trains** — IC/EC connections to neighboring countries
13+
14+
No API key required. Free public API.
15+
16+
## Install
17+
18+
Download `db-travel.skill` from [releases](../../releases) and install in OpenClaw:
19+
20+
```
21+
openclaw skills install db-travel.skill
22+
```
23+
24+
Or clone this repo into your skills directory.
25+
26+
## Usage (standalone)
27+
28+
```bash
29+
python3 scripts/db-travel.py search "Berlin Hbf"
30+
python3 scripts/db-travel.py trip "Berlin Hbf" "Flughafen BER"
31+
python3 scripts/db-travel.py departures 8011160 --limit 5
32+
```
33+
34+
## Pair with entur-travel
35+
36+
For Norway, use [entur-travel](https://github.com/mmichelli/entur-travel). Together they cover Scandinavia + Germany/Europe.
37+
38+
## License
39+
40+
MIT

SKILL.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
name: db-travel
3+
description: "Plan journeys across Germany and Europe using the Deutsche Bahn API (v6.db.transport.rest). Covers ICE, IC, regional trains, S-Bahn, U-Bahn, trams, buses, ferries. Use when: (1) planning trips in Germany or cross-border European rail, (2) checking departure/arrival boards at German/European stations, (3) finding station IDs, (4) navigating cities like Berlin, Munich, Hamburg. Triggers on questions about German trains, DB, BVG, getting around Berlin, European rail connections."
4+
---
5+
6+
# DB Travel
7+
8+
Plan journeys across Germany and Europe via the Deutsche Bahn REST API. Covers ICE, IC/EC, regional trains, S-Bahn, U-Bahn, trams, buses, and ferries — with real-time delays.
9+
10+
**No API key required.** Free public API. Rate limit: 100 req/min.
11+
12+
## Script
13+
14+
`scripts/db-travel.py` — standalone Python 3 CLI, no dependencies beyond stdlib.
15+
16+
### Commands
17+
18+
```bash
19+
# Search for stations/stops
20+
python3 scripts/db-travel.py search "Berlin Hbf"
21+
22+
# Plan a trip (place names auto-resolve)
23+
python3 scripts/db-travel.py trip "Berlin Hbf" "Munich Hbf"
24+
25+
# Depart at specific time
26+
python3 scripts/db-travel.py trip "Berlin Hbf" "Flughafen BER" --time "2025-03-01T10:00:00+01:00"
27+
28+
# Arrive by a specific time
29+
python3 scripts/db-travel.py trip "Hamburg" "Berlin" --time "2025-03-01T14:00:00" --arrive
30+
31+
# Departure board
32+
python3 scripts/db-travel.py departures 8011160 --limit 10
33+
34+
# Arrival board
35+
python3 scripts/db-travel.py arrivals 8011160 --duration 60
36+
37+
# Station details
38+
python3 scripts/db-travel.py stop 8011160
39+
```
40+
41+
All output is JSON.
42+
43+
### Common Station IDs
44+
45+
Use `search` to find IDs. Some common ones:
46+
- Berlin Hbf: `8011160`
47+
- München Hbf: `8000261`
48+
- Hamburg Hbf: `8002549`
49+
- Frankfurt (Main) Hbf: `8000105`
50+
- Köln Hbf: `8000207`
51+
- Flughafen BER: `8089110`
52+
53+
### Products
54+
55+
The API returns product types: `nationalExpress` (ICE), `national` (IC/EC), `regionalExpress`, `regional`, `suburban` (S-Bahn), `subway` (U-Bahn), `tram`, `bus`, `ferry`, `taxi`.
56+
57+
## Presenting Results
58+
59+
When presenting trip results to users:
60+
- Show times in HH:MM local format, not ISO
61+
- Summarize legs concisely: "🚄 ICE 507 Berlin Hbf → München Hbf (10:29–14:25, platform 3)"
62+
- Flag delays: if delay_min > 0, mention it
63+
- For departure boards, filter to relevant modes (skip trams if user asked about long-distance)
64+
- Walking legs can usually be omitted unless distance is significant
65+
66+
## Limitations
67+
68+
- Primarily Germany, but includes international trains (IC/EC to neighboring countries)
69+
- Rate limited to 100 req/min — don't hammer it
70+
- No ticket purchasing
71+
- For Norway, use the entur-travel skill instead

scripts/db-travel.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env python3
2+
"""Deutsche Bahn / European transit API client via v6.db.transport.rest.
3+
4+
Covers Germany + international trains (ICE, IC, EC, regional, S-Bahn, buses, ferries, trams).
5+
6+
Usage:
7+
db-travel.py search <query> Search for stops/stations
8+
db-travel.py trip <from> <to> [--time ISO] [--arrive] [--results N]
9+
db-travel.py departures <stop_id> [--duration M] Departure board
10+
db-travel.py arrivals <stop_id> [--duration M] Arrival board
11+
db-travel.py stop <stop_id> Stop details
12+
13+
All output is JSON.
14+
"""
15+
16+
import argparse
17+
import json
18+
import sys
19+
import urllib.request
20+
import urllib.parse
21+
22+
BASE = "https://v6.db.transport.rest"
23+
24+
25+
def _get(path: str, params: dict | None = None) -> dict | list:
26+
url = f"{BASE}{path}"
27+
if params:
28+
# Filter None values
29+
params = {k: v for k, v in params.items() if v is not None}
30+
url += "?" + urllib.parse.urlencode(params)
31+
req = urllib.request.Request(url)
32+
with urllib.request.urlopen(req, timeout=15) as resp:
33+
return json.loads(resp.read())
34+
35+
36+
def cmd_search(args):
37+
results = _get("/locations", {"query": args.query, "results": args.limit or 5})
38+
out = []
39+
for r in results:
40+
loc = r.get("location", {})
41+
out.append({
42+
"id": r.get("id"),
43+
"name": r.get("name"),
44+
"type": r.get("type"),
45+
"lat": loc.get("latitude"),
46+
"lon": loc.get("longitude"),
47+
})
48+
print(json.dumps(out, indent=2, ensure_ascii=False))
49+
50+
51+
def cmd_trip(args):
52+
from_loc = _resolve(args.from_place)
53+
to_loc = _resolve(args.to_place)
54+
55+
if not from_loc or not to_loc:
56+
print(json.dumps({"error": "Could not resolve origin or destination"}))
57+
sys.exit(1)
58+
59+
params = {
60+
"from": from_loc["id"] if "id" in from_loc else None,
61+
"from.latitude": from_loc.get("lat"),
62+
"from.longitude": from_loc.get("lon"),
63+
"from.name": from_loc.get("name"),
64+
"to": to_loc["id"] if "id" in to_loc else None,
65+
"to.latitude": to_loc.get("lat"),
66+
"to.longitude": to_loc.get("lon"),
67+
"to.name": to_loc.get("name"),
68+
"results": args.results or 3,
69+
"stopovers": "false",
70+
"language": "en",
71+
}
72+
73+
if args.time:
74+
if args.arrive:
75+
params["arrival"] = args.time
76+
else:
77+
params["departure"] = args.time
78+
79+
data = _get("/journeys", params)
80+
journeys = data.get("journeys", [])
81+
82+
trips = []
83+
for j in journeys:
84+
legs = []
85+
for leg in j.get("legs", []):
86+
leg_info = {
87+
"mode": leg.get("line", {}).get("mode") or leg.get("walking") and "walk" or "transfer",
88+
"from": leg.get("origin", {}).get("name"),
89+
"to": leg.get("destination", {}).get("name"),
90+
"depart": leg.get("departure"),
91+
"arrive": leg.get("arrival"),
92+
}
93+
if leg.get("line"):
94+
leg_info["line"] = leg["line"].get("name")
95+
leg_info["product"] = leg["line"].get("product")
96+
leg_info["operator"] = leg["line"].get("operator", {}).get("name") if leg["line"].get("operator") else None
97+
if leg.get("departurePlatform"):
98+
leg_info["platform"] = leg["departurePlatform"]
99+
if leg.get("departureDelay") and leg["departureDelay"] != 0:
100+
leg_info["delay_min"] = leg["departureDelay"] // 60
101+
if leg.get("walking"):
102+
leg_info["mode"] = "walk"
103+
if leg.get("distance"):
104+
leg_info["distance_m"] = leg["distance"]
105+
legs.append(leg_info)
106+
107+
trips.append({
108+
"legs": legs,
109+
})
110+
111+
print(json.dumps(trips, indent=2, ensure_ascii=False))
112+
113+
114+
def cmd_departures(args):
115+
params = {
116+
"duration": args.duration or 30,
117+
"results": args.limit or 10,
118+
"language": "en",
119+
}
120+
data = _get(f"/stops/{args.stop_id}/departures", params)
121+
122+
deps = []
123+
for d in data.get("departures", data) if isinstance(data, dict) else data:
124+
line = d.get("line", {})
125+
deps.append({
126+
"line": line.get("name"),
127+
"product": line.get("product"),
128+
"direction": d.get("direction"),
129+
"planned": d.get("plannedWhen"),
130+
"expected": d.get("when"),
131+
"delay_min": (d.get("delay") or 0) // 60 if d.get("delay") else 0,
132+
"platform": d.get("platform"),
133+
"operator": line.get("operator", {}).get("name") if line.get("operator") else None,
134+
})
135+
136+
print(json.dumps(deps, indent=2, ensure_ascii=False))
137+
138+
139+
def cmd_arrivals(args):
140+
params = {
141+
"duration": args.duration or 30,
142+
"results": args.limit or 10,
143+
"language": "en",
144+
}
145+
data = _get(f"/stops/{args.stop_id}/arrivals", params)
146+
147+
arrs = []
148+
for a in data.get("arrivals", data) if isinstance(data, dict) else data:
149+
line = a.get("line", {})
150+
arrs.append({
151+
"line": line.get("name"),
152+
"product": line.get("product"),
153+
"origin": a.get("provenance") or a.get("origin", {}).get("name"),
154+
"planned": a.get("plannedWhen"),
155+
"expected": a.get("when"),
156+
"delay_min": (a.get("delay") or 0) // 60 if a.get("delay") else 0,
157+
"platform": a.get("platform"),
158+
})
159+
160+
print(json.dumps(arrs, indent=2, ensure_ascii=False))
161+
162+
163+
def cmd_stop(args):
164+
data = _get(f"/stops/{args.stop_id}", {"language": "en"})
165+
print(json.dumps(data, indent=2, ensure_ascii=False))
166+
167+
168+
def _resolve(text: str) -> dict | None:
169+
"""Resolve a place string to {id, name} or {lat, lon, name}."""
170+
# If it's a numeric ID, use directly
171+
if text.isdigit():
172+
return {"id": text}
173+
# Geocode
174+
results = _get("/locations", {"query": text, "results": 1})
175+
if not results:
176+
return None
177+
r = results[0]
178+
loc = r.get("location", {})
179+
if r.get("id"):
180+
return {"id": r["id"], "name": r.get("name")}
181+
elif loc.get("latitude"):
182+
return {"lat": loc["latitude"], "lon": loc["longitude"], "name": r.get("name")}
183+
return None
184+
185+
186+
def main():
187+
parser = argparse.ArgumentParser(description="Deutsche Bahn / European transit API client")
188+
sub = parser.add_subparsers(dest="command")
189+
190+
s = sub.add_parser("search", help="Search for stops/stations")
191+
s.add_argument("query")
192+
s.add_argument("--limit", type=int, default=5)
193+
194+
t = sub.add_parser("trip", help="Plan a journey")
195+
t.add_argument("from_place", metavar="from")
196+
t.add_argument("to_place", metavar="to")
197+
t.add_argument("--time", help="ISO datetime for departure/arrival")
198+
t.add_argument("--arrive", action="store_true", help="Time is arrival time")
199+
t.add_argument("--results", type=int, default=3)
200+
201+
d = sub.add_parser("departures", help="Departure board")
202+
d.add_argument("stop_id")
203+
d.add_argument("--duration", type=int, default=30, help="Minutes to look ahead")
204+
d.add_argument("--limit", type=int, default=10)
205+
206+
a = sub.add_parser("arrivals", help="Arrival board")
207+
a.add_argument("stop_id")
208+
a.add_argument("--duration", type=int, default=30)
209+
a.add_argument("--limit", type=int, default=10)
210+
211+
st = sub.add_parser("stop", help="Stop details")
212+
st.add_argument("stop_id")
213+
214+
args = parser.parse_args()
215+
if not args.command:
216+
parser.print_help()
217+
sys.exit(1)
218+
219+
cmds = {"search": cmd_search, "trip": cmd_trip, "departures": cmd_departures, "arrivals": cmd_arrivals, "stop": cmd_stop}
220+
cmds[args.command](args)
221+
222+
223+
if __name__ == "__main__":
224+
main()

0 commit comments

Comments
 (0)