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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/db-search.mjs +268 -0
  4. 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
+ }