dbahn-search 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/db-search.mjs +268 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henrique Magalhaes
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# db-search
|
|
2
|
+
|
|
3
|
+
CLI tool for searching Deutsche Bahn train journeys. Built on [db-vendo-client](https://github.com/public-transport/db-vendo-client), the current library for DB's Vendo/Movas API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g db-search
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly with npx:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx db-search "Berlin Hbf" "Hamburg Hbf"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
db-search <from> <to> [datetime] [options]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Examples
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Search for trains departing now
|
|
27
|
+
db-search "Berlin Hbf" "Hamburg Hbf"
|
|
28
|
+
|
|
29
|
+
# Specific date and time
|
|
30
|
+
db-search "Berlin Hbf" "München Hbf" "2026-04-20 10:00"
|
|
31
|
+
|
|
32
|
+
# Time only (uses today's date)
|
|
33
|
+
db-search "Frankfurt Hbf" "Köln Hbf" "14:30"
|
|
34
|
+
|
|
35
|
+
# Find cheapest journeys across the day
|
|
36
|
+
db-search "Berlin Hbf" "München Hbf" "2026-04-20" --bestprice
|
|
37
|
+
|
|
38
|
+
# With BahnCard 50 discount
|
|
39
|
+
db-search "Berlin Hbf" "Hamburg Hbf" --bahncard 50
|
|
40
|
+
|
|
41
|
+
# With Deutschlandticket
|
|
42
|
+
db-search "Berlin Hbf" "Dresden Hbf" --deutschlandticket
|
|
43
|
+
|
|
44
|
+
# Combine discounts
|
|
45
|
+
db-search "Berlin Hbf" "München Hbf" "2026-04-20" --bahncard 50 --deutschlandticket --bestprice
|
|
46
|
+
|
|
47
|
+
# Limit transfers
|
|
48
|
+
db-search "Marburg (Lahn)" "Berlin Hbf" --transfers 1
|
|
49
|
+
|
|
50
|
+
# JSON output (for scripting)
|
|
51
|
+
db-search "Berlin Hbf" "Hamburg Hbf" --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Options
|
|
55
|
+
|
|
56
|
+
| Flag | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `--results N` | Number of results (default: 5) |
|
|
59
|
+
| `--bestprice` | Find cheapest journeys across the day |
|
|
60
|
+
| `--first-class` | Search first class prices |
|
|
61
|
+
| `--transfers N` | Maximum number of transfers |
|
|
62
|
+
| `--bahncard N` | Apply BahnCard discount (25, 50, or 100) |
|
|
63
|
+
| `--deutschlandticket` | Show prices with Deutschlandticket |
|
|
64
|
+
| `--json` | Output raw JSON |
|
|
65
|
+
| `-h, --help` | Show help |
|
|
66
|
+
|
|
67
|
+
### Output
|
|
68
|
+
|
|
69
|
+
Each result shows departure/arrival times, duration, transfers, train types, price, and a direct booking link to bahn.de:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Berlin Hbf -> Hamburg Hbf · Sat, 20 Apr 2026
|
|
73
|
+
────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
#1 10:02 -> 11:45 (1h 43m) · direct · ICE 1007
|
|
76
|
+
Price: 23.90 EUR
|
|
77
|
+
https://www.bahn.de/buchung/fahrplan/suche#...
|
|
78
|
+
|
|
79
|
+
#2 11:02 -> 12:45 (1h 43m) · direct · ICE 1009
|
|
80
|
+
Price: 27.90 EUR
|
|
81
|
+
https://www.bahn.de/buchung/fahrplan/suche#...
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
db-search queries Deutsche Bahn's current APIs via [db-vendo-client](https://github.com/public-transport/db-vendo-client). Station names are resolved using both the DB Navigator API and bahn.de's location API (for generating accurate booking links).
|
|
87
|
+
|
|
88
|
+
Prices shown are Sparpreis/Super Sparpreis fares when available. For exact fares and booking, follow the provided bahn.de links.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/db-search.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {createClient} from 'db-vendo-client';
|
|
4
|
+
import {profile as dbnavProfile} from 'db-vendo-client/p/dbnav/index.js';
|
|
5
|
+
import {data as loyaltyCards} from 'db-vendo-client/format/loyalty-cards.js';
|
|
6
|
+
|
|
7
|
+
const client = createClient(dbnavProfile, 'db-search-cli');
|
|
8
|
+
|
|
9
|
+
// --- Argument parsing ---
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const flags = {};
|
|
13
|
+
const positional = [];
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
if (args[i] === '--results' && args[i + 1]) {
|
|
17
|
+
flags.results = parseInt(args[i + 1], 10);
|
|
18
|
+
i++;
|
|
19
|
+
} else if (args[i] === '--transfers' && args[i + 1]) {
|
|
20
|
+
flags.transfers = parseInt(args[i + 1], 10);
|
|
21
|
+
i++;
|
|
22
|
+
} else if (args[i] === '--bestprice') {
|
|
23
|
+
flags.bestprice = true;
|
|
24
|
+
} else if (args[i] === '--first-class') {
|
|
25
|
+
flags.firstClass = true;
|
|
26
|
+
} else if (args[i] === '--bahncard' && args[i + 1]) {
|
|
27
|
+
flags.bahncard = parseInt(args[i + 1], 10);
|
|
28
|
+
i++;
|
|
29
|
+
} else if (args[i] === '--deutschlandticket') {
|
|
30
|
+
flags.deutschlandticket = true;
|
|
31
|
+
} else if (args[i] === '--json') {
|
|
32
|
+
flags.json = true;
|
|
33
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
34
|
+
printUsage();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
} else {
|
|
37
|
+
positional.push(args[i]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (positional.length < 2) {
|
|
42
|
+
printUsage();
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [fromQuery, toQuery, ...datetimeParts] = positional;
|
|
47
|
+
const datetimeStr = datetimeParts.join(' ') || null;
|
|
48
|
+
|
|
49
|
+
function printUsage() {
|
|
50
|
+
console.log(`Usage: db-search <from> <to> [datetime] [options]
|
|
51
|
+
|
|
52
|
+
Arguments:
|
|
53
|
+
from Origin station (e.g. "Berlin Hbf")
|
|
54
|
+
to Destination station (e.g. "Hamburg Hbf")
|
|
55
|
+
datetime Departure date/time (default: now)
|
|
56
|
+
Formats: "2026-03-10 10:00", "2026-03-10", "10:00"
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
--results N Number of results (default: 5)
|
|
60
|
+
--bestprice Find cheapest journeys across the day
|
|
61
|
+
--first-class Search first class prices
|
|
62
|
+
--transfers N Maximum transfers
|
|
63
|
+
--bahncard N Apply BahnCard discount (25, 50, or 100)
|
|
64
|
+
--deutschlandticket Show prices with Deutschlandticket
|
|
65
|
+
--json Output raw JSON
|
|
66
|
+
-h, --help Show this help`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Date parsing ---
|
|
70
|
+
|
|
71
|
+
function parseDatetime(str) {
|
|
72
|
+
if (!str) return new Date();
|
|
73
|
+
|
|
74
|
+
// Time only: "10:00" or "10:30"
|
|
75
|
+
if (/^\d{1,2}:\d{2}$/.test(str)) {
|
|
76
|
+
const today = new Date();
|
|
77
|
+
const [h, m] = str.split(':').map(Number);
|
|
78
|
+
today.setHours(h, m, 0, 0);
|
|
79
|
+
return today;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Date only: "2026-03-10"
|
|
83
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) {
|
|
84
|
+
return new Date(str + 'T00:00:00');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Full: "2026-03-10 10:00"
|
|
88
|
+
const d = new Date(str.replace(' ', 'T'));
|
|
89
|
+
if (isNaN(d.getTime())) {
|
|
90
|
+
console.error(`Error: Invalid date/time "${str}"`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
return d;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Station resolution ---
|
|
97
|
+
|
|
98
|
+
async function resolveStation(query) {
|
|
99
|
+
const results = await client.locations(query, {results: 10});
|
|
100
|
+
const stops = results.filter(r => r.type === 'stop' || r.type === 'station');
|
|
101
|
+
if (stops.length === 0) {
|
|
102
|
+
console.error(`Error: Station not found: "${query}"`);
|
|
103
|
+
console.error('Try a more specific name (e.g. "Berlin Hbf" instead of "Berlin")');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const hbf = stops.find(s => s.name && /Hbf|Hauptbahnhof/i.test(s.name));
|
|
107
|
+
return hbf || stops[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function resolveBahnDeStation(query) {
|
|
111
|
+
const url = `https://www.bahn.de/web/api/reiseloesung/orte?suchbegriff=${encodeURIComponent(query)}&typ=ALL&limit=5`;
|
|
112
|
+
const res = await fetch(url);
|
|
113
|
+
if (!res.ok) return null;
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
const stations = data.filter(s => s.type === 'ST');
|
|
116
|
+
if (stations.length === 0) return null;
|
|
117
|
+
const hbf = stations.find(s => /Hbf|Hauptbahnhof/i.test(s.name));
|
|
118
|
+
return hbf || stations[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Formatting ---
|
|
122
|
+
|
|
123
|
+
function formatDuration(journey) {
|
|
124
|
+
const firstLeg = journey.legs[0];
|
|
125
|
+
const lastLeg = journey.legs[journey.legs.length - 1];
|
|
126
|
+
const dep = new Date(firstLeg.departure || firstLeg.plannedDeparture);
|
|
127
|
+
const arr = new Date(lastLeg.arrival || lastLeg.plannedArrival);
|
|
128
|
+
const mins = Math.round((arr - dep) / 60000);
|
|
129
|
+
const h = Math.floor(mins / 60);
|
|
130
|
+
const m = mins % 60;
|
|
131
|
+
return `${h}h ${String(m).padStart(2, '0')}m`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatTime(isoStr) {
|
|
135
|
+
if (!isoStr) return '??:??';
|
|
136
|
+
const d = new Date(isoStr);
|
|
137
|
+
return d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', hour12: false});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getTrainTypes(journey) {
|
|
141
|
+
return journey.legs
|
|
142
|
+
.filter(leg => !leg.walking && leg.line)
|
|
143
|
+
.map(leg => leg.line.name || leg.line.productName || leg.line.product)
|
|
144
|
+
.join(' > ');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getTransferCount(journey) {
|
|
148
|
+
return journey.legs.filter(l => !l.walking && l.line).length - 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatPrice(journey) {
|
|
152
|
+
if (!journey.price) return null;
|
|
153
|
+
if (journey.price.hint) {
|
|
154
|
+
return `${journey.price.amount.toFixed(2)} EUR (${journey.price.hint})`;
|
|
155
|
+
}
|
|
156
|
+
return `${journey.price.amount.toFixed(2)} EUR`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toLocalIso(date) {
|
|
160
|
+
const d = new Date(date);
|
|
161
|
+
const pad = n => String(n).padStart(2, '0');
|
|
162
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildBookingUrl(fromBahnDe, toBahnDe, departureIso) {
|
|
166
|
+
const hd = toLocalIso(departureIso);
|
|
167
|
+
const so = encodeURIComponent(fromBahnDe.name);
|
|
168
|
+
const zo = encodeURIComponent(toBahnDe.name);
|
|
169
|
+
const soid = encodeURIComponent(fromBahnDe.id);
|
|
170
|
+
const zoid = encodeURIComponent(toBahnDe.id);
|
|
171
|
+
return `https://www.bahn.de/buchung/fahrplan/suche#sts=true&so=${so}&zo=${zo}&kl=2&soid=${soid}&zoid=${zoid}&sot=ST&zot=ST&soei=${fromBahnDe.extId}&zoei=${toBahnDe.extId}&hd=${hd}&hza=D&ar=false&s=true&d=false&hz=%5B%5D&fm=false&bp=false`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
function formatDate(date) {
|
|
176
|
+
return date.toLocaleDateString('en-US', {
|
|
177
|
+
weekday: 'short',
|
|
178
|
+
day: 'numeric',
|
|
179
|
+
month: 'short',
|
|
180
|
+
year: 'numeric',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Main ---
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
const departure = parseDatetime(datetimeStr);
|
|
188
|
+
|
|
189
|
+
const [from, to, fromBahnDe, toBahnDe] = await Promise.all([
|
|
190
|
+
resolveStation(fromQuery),
|
|
191
|
+
resolveStation(toQuery),
|
|
192
|
+
resolveBahnDeStation(fromQuery),
|
|
193
|
+
resolveBahnDeStation(toQuery),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const opts = {
|
|
197
|
+
departure,
|
|
198
|
+
results: flags.results || 5,
|
|
199
|
+
bestprice: flags.bestprice || false,
|
|
200
|
+
firstClass: flags.firstClass || false,
|
|
201
|
+
language: 'en',
|
|
202
|
+
};
|
|
203
|
+
if (flags.transfers !== undefined) {
|
|
204
|
+
opts.transfers = flags.transfers;
|
|
205
|
+
}
|
|
206
|
+
if (flags.bahncard) {
|
|
207
|
+
opts.loyaltyCard = {
|
|
208
|
+
type: loyaltyCards.BAHNCARD,
|
|
209
|
+
discount: flags.bahncard,
|
|
210
|
+
class: flags.firstClass ? 1 : 2,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (flags.deutschlandticket) {
|
|
214
|
+
opts.deutschlandTicketDiscount = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const res = await client.journeys(from.id, to.id, opts);
|
|
218
|
+
|
|
219
|
+
if (!res.journeys || res.journeys.length === 0) {
|
|
220
|
+
console.error('No journeys found. Try a different date/time or check station names.');
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const maxResults = flags.results || (flags.bestprice ? 10 : 5);
|
|
225
|
+
const journeys = res.journeys.slice(0, maxResults);
|
|
226
|
+
|
|
227
|
+
if (flags.json) {
|
|
228
|
+
console.log(JSON.stringify(journeys, null, 2));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Formatted output
|
|
233
|
+
console.log(`${from.name} -> ${to.name} · ${formatDate(departure)}`);
|
|
234
|
+
console.log('─'.repeat(60));
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < journeys.length; i++) {
|
|
237
|
+
const j = journeys[i];
|
|
238
|
+
const firstLeg = j.legs[0];
|
|
239
|
+
const lastLeg = j.legs[j.legs.length - 1];
|
|
240
|
+
const depTime = formatTime(firstLeg.departure || firstLeg.plannedDeparture);
|
|
241
|
+
const arrTime = formatTime(lastLeg.arrival || lastLeg.plannedArrival);
|
|
242
|
+
const duration = formatDuration(j);
|
|
243
|
+
const transfers = getTransferCount(j);
|
|
244
|
+
const transferStr = transfers === 0 ? 'direct' : `${transfers} transfer${transfers > 1 ? 's' : ''}`;
|
|
245
|
+
const trains = getTrainTypes(j);
|
|
246
|
+
const price = formatPrice(j);
|
|
247
|
+
const depIso = firstLeg.departure || firstLeg.plannedDeparture;
|
|
248
|
+
const url = fromBahnDe && toBahnDe
|
|
249
|
+
? buildBookingUrl(fromBahnDe, toBahnDe, depIso)
|
|
250
|
+
: null;
|
|
251
|
+
console.log();
|
|
252
|
+
console.log(` #${i + 1} ${depTime} -> ${arrTime} (${duration}) · ${transferStr} · ${trains}`);
|
|
253
|
+
if (price) console.log(` Price: ${price}`);
|
|
254
|
+
if (url) console.log(` ${url}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
console.log();
|
|
258
|
+
console.log('─'.repeat(60));
|
|
259
|
+
const total = res.journeys.length;
|
|
260
|
+
const shown = journeys.length;
|
|
261
|
+
const countMsg = total > shown ? `${shown} of ${total} journeys shown` : `${shown} journeys found`;
|
|
262
|
+
console.log(`${countMsg}. Book on bahn.de for exact fares.`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
main().catch(err => {
|
|
266
|
+
console.error(`Error: ${err.message || err}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dbahn-search",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to search Deutsche Bahn train journeys with BahnCard and Deutschlandticket support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"db-search": "db-search.mjs"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"deutsche-bahn",
|
|
11
|
+
"db",
|
|
12
|
+
"train",
|
|
13
|
+
"search",
|
|
14
|
+
"cli",
|
|
15
|
+
"germany",
|
|
16
|
+
"bahncard",
|
|
17
|
+
"deutschlandticket",
|
|
18
|
+
"sparpreis",
|
|
19
|
+
"travel"
|
|
20
|
+
],
|
|
21
|
+
"author": "Henrique Magalhaes <henrique.albertino@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+ssh://git@github.com/magalhaesh/db-search.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/magalhaesh/db-search",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/magalhaesh/db-search/issues"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"db-search.mjs"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"db-vendo-client": "^6.10.8"
|
|
39
|
+
}
|
|
40
|
+
}
|