├── .editorconfig ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── LICENSE-APACHE ├── LICENSE-PROSPERITY.md ├── build ├── eva-nrs-by-betreiber-nrs.js └── index.sh ├── example.js ├── index.js ├── lib ├── compare-journey.js ├── helpers.js ├── parse.js └── request.js ├── package.json ├── readme.md ├── test ├── expected-düsseldorf-hanau.json ├── expected-hannover-münchen.json ├── hafas-düsseldorf-hanau.json ├── hafas-hannover-münchen.json ├── index.js ├── results-düsseldorf-hanau.html ├── results-hannover-münchen.html └── when.js └── todo /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | # Use tabs in JavaScript and JSON. 11 | [**.{js, json}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | # Use spaces in YAML. 16 | [**.{yml,yaml}] 17 | indent_style = spaces 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2021 9 | }, 10 | "ignorePatterns": [ 11 | "node_modules" 12 | ], 13 | "rules": { 14 | "no-unused-vars": [ 15 | "error", 16 | { 17 | "vars": "all", 18 | "args": "none", 19 | "ignoreRestSiblings": false 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | include: scope 9 | ignore: 10 | - dependency-name: "*" 11 | update-types: ["version-update:semver-patch", "version-update:semver-minor"] 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: ['16', '18'] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | - name: setup Node v${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | 27 | - run: npm run lint 28 | - run: npm run build 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | npm-debug.log 7 | 8 | /package-lock.json 9 | 10 | /lib/eva-nrs-by-betreiber-nrs.json 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jannis R and contributors. 2 | 3 | This software is licensed under License Zero Prosperity 3.0.0 and contributions are licensed under Apache 2.0. 4 | 5 | The full text of these licenses is contained in the files LICENSE-PROSPERITY.md and LICENSE-APACHE. 6 | 7 | If you require a different license for commercial or other purposes, please contact https://jannisr.de/ for licensing inquiries. 8 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright 2020 generate-db-shop-urls contributors 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /LICENSE-PROSPERITY.md: -------------------------------------------------------------------------------- 1 | # The Prosperity Public License 3.0.0 2 | 3 | Contributor: Jannis R 4 | 5 | Source Code: https://github.com/derhuerst/generate-db-shop-urls 6 | 7 | ## Purpose 8 | 9 | This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days. 10 | 11 | ## Agreement 12 | 13 | In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow. 14 | 15 | ## Notices 16 | 17 | Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above. 18 | 19 | ## Commercial Trial 20 | 21 | Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person. 22 | 23 | ## Contributions Back 24 | 25 | Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose. 26 | 27 | ## Personal Uses 28 | 29 | Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose. 30 | 31 | ## Noncommercial Organizations 32 | 33 | Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding. 34 | 35 | ## Defense 36 | 37 | Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent. 38 | 39 | ## Copyright 40 | 41 | The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it. 42 | 43 | ## Patent 44 | 45 | The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license. 46 | 47 | ## Reliability 48 | 49 | The contributor can't revoke this license. 50 | 51 | ## Excuse 52 | 53 | You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule. 54 | 55 | ## No Liability 56 | 57 | ***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 58 | -------------------------------------------------------------------------------- /build/eva-nrs-by-betreiber-nrs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | 4 | const {pipeline, Transform} = require('stream') 5 | const csvParser = require('csv-parser') 6 | 7 | const KIND_SCORES = Object.assign(Object.create(null), { 8 | 'nur DPN': 1, 9 | 'RV': 2, 10 | 'FV': 3, 11 | }) 12 | 13 | // Betreiber_Nr -> name -> {evaNr, kind} 14 | const evaNrsByBetreiberNrs = {} 15 | 16 | pipeline( 17 | process.stdin, 18 | csvParser({separator: ';'}), 19 | new Transform({ 20 | objectMode: true, 21 | transform: (station, _, cb) => { 22 | for (const field of [ 23 | 'Betreiber_Nr', 24 | 'NAME', 25 | 'EVA_NR', 26 | 'Verkehr', 27 | ]) { 28 | if (!station[field]) { 29 | console.error(`missing ${field}:`, station) 30 | return cb() 31 | } 32 | } 33 | const { 34 | Betreiber_Nr: betreiberNr, 35 | NAME: name, 36 | EVA_NR: evaNr, 37 | Verkehr: kind, 38 | } = station 39 | 40 | if ('number' !== typeof KIND_SCORES[kind]) { 41 | console.error(`invalid/unsupported Verkehr:`, station) 42 | return cb() 43 | } 44 | const kindScore = KIND_SCORES[kind] 45 | 46 | if (!(betreiberNr in evaNrsByBetreiberNrs)) { 47 | evaNrsByBetreiberNrs[betreiberNr] = { 48 | [name]: {kindScore, evaNr}, 49 | } 50 | } else if (!(name in evaNrsByBetreiberNrs[betreiberNr])) { 51 | evaNrsByBetreiberNrs[betreiberNr][name] = {kindScore, evaNr} 52 | return cb() 53 | } else if (evaNrsByBetreiberNrs[betreiberNr][name].kindScore < kindScore) { 54 | // If there are >1 entries for a betreiberNr & name, we keep the one with 55 | // the highest kind score. 56 | evaNrsByBetreiberNrs[betreiberNr][name] = {kindScore, evaNr} 57 | } 58 | 59 | cb() 60 | }, 61 | final: function (cb) { 62 | // transform to plain netreiberNr -> name -> evaNr map 63 | for (const byName of Object.values(evaNrsByBetreiberNrs)) { 64 | for (const [name, {evaNr}] of Object.entries(byName)) { 65 | byName[name] = evaNr 66 | } 67 | } 68 | 69 | this.push(JSON.stringify(evaNrsByBetreiberNrs)) 70 | cb() 71 | }, 72 | }), 73 | process.stdout, 74 | (err) => { 75 | if (!err) return; 76 | console.error(err) 77 | process.exit(1) 78 | }, 79 | ) 80 | -------------------------------------------------------------------------------- /build/index.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -euo pipefail 4 | 5 | cd $(dirname "$0") 6 | set -x 7 | 8 | curl 'https://download-data.deutschebahn.com/static/datasets/haltestellen/D_Bahnhof_2020_alle.CSV' -sfL \ 9 | | ./eva-nrs-by-betreiber-nrs.js \ 10 | >../lib/eva-nrs-by-betreiber-nrs.json 11 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const createHafas = require('db-hafas') 4 | 5 | const generateLink = require('.') 6 | const when = require('./test/when') // Monday in a week, 10am 7 | 8 | const berlin = '8011160' 9 | const hamburg = '8096009' 10 | // const passau = '8000298' 11 | // const paris = '8796001' 12 | 13 | const hafas = createHafas('generate-db-shop-urls example') 14 | 15 | ;(async () => { 16 | // Berlin -> Hamburg, Hamburg -> Berlin 17 | const outbound = await hafas.journeys(berlin, hamburg, { 18 | departure: when.outbound, results: 1 19 | }) 20 | const returning = await hafas.journeys(hamburg, berlin, { 21 | departure: when.returning, results: 1 22 | }) 23 | 24 | const link = await generateLink(outbound.journeys[0], { 25 | returning: returning.journeys[0], 26 | }) 27 | 28 | console.log('open the following link in a *private* browsing session (without bahn.de cookies):') 29 | console.log(link) 30 | })() 31 | .catch((err) => { 32 | console.error(err) 33 | process.exit(1) 34 | }) 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {DateTime} = require('luxon') 4 | const debug = require('debug')('generate-db-shop-urls') 5 | const debugResponse = require('debug')('generate-db-shop-urls:response') 6 | 7 | const request = require('./lib/request') 8 | const parse = require('./lib/parse') 9 | const compareJourney = require('./lib/compare-journey') 10 | const {showDetails} = require('./lib/helpers') 11 | 12 | const START_URL = 'https://reiseauskunft.bahn.de/bin/query.exe/dn' 13 | const timezone = 'Europe/Berlin' 14 | const locale = 'de-DE' 15 | 16 | const isObj = o => o !== null && 'object' === typeof o && !Array.isArray(o) 17 | 18 | const isISO8601String = str => 'string' === typeof str && !Number.isNaN(Date.parse(str)) 19 | 20 | const validateJourney = (j, name) => { 21 | if (!isObj(j)) throw new Error(name + ' must be an object.') 22 | const invalid = new Error(name + ' is invalid.') 23 | if (j.type !== 'journey' || !Array.isArray(j.legs) || !j.legs.length) throw invalid 24 | const firstLeg = j.legs[0] 25 | if (!firstLeg.origin || !isISO8601String(firstLeg.departure)) throw invalid 26 | const lastLeg = j.legs[j.legs.length - 1] 27 | if (!lastLeg.destination || !isISO8601String(lastLeg.arrival)) throw invalid 28 | const orig = firstLeg.origin 29 | if (isObj(orig) && orig.type !== 'station' && orig.type !== 'stop') { 30 | throw new Error(name + '.origin must be a station/stop.') 31 | } 32 | const dest = lastLeg.destination 33 | if (isObj(dest) && dest.type !== 'station' && dest.type !== 'stop') { 34 | throw new Error(name + '.destination must be a station/stop.') 35 | } 36 | // todo: departure, arrival 37 | } 38 | 39 | const formatDate = (d) => { 40 | return DateTime 41 | .fromISO(d, {zone: timezone, locale}) 42 | .toFormat('ccc, dd.MM.yy') 43 | } 44 | const formatTime = (d) => { 45 | return DateTime 46 | .fromISO(d, {zone: timezone, locale}) 47 | .toFormat('HH:mm') 48 | } 49 | 50 | const generateDbShopLink = async (outbound, opt) => { 51 | validateJourney(outbound, 'outbound') 52 | 53 | const options = { 54 | class: '2', // '1' or '2' 55 | // see https://gist.github.com/juliuste/202bb04f450a79f8fa12a2ec3abcd72d 56 | bahncard: '0', // no bahncard 57 | age: 40, // age of the traveller 58 | returning: null, // no return journey 59 | ...opt, 60 | } 61 | 62 | const orig = outbound.legs[0].origin 63 | const originId = orig.station?.id || orig.id || orig 64 | const lastOutboundLeg = outbound.legs[outbound.legs.length - 1] 65 | const dest = lastOutboundLeg.destination 66 | const destinationId = dest.station?.id || dest.id || dest 67 | 68 | if (options.returning) { 69 | validateJourney(options.returning, 'opt.returning') 70 | 71 | const rOrig = options.returning.legs[0].origin 72 | const rOrigId = rOrig.station?.id || rOrig.id || rOrig 73 | if (destinationId !== rOrigId) { 74 | throw new Error('origin.destination !== opt.returning.orgin.') 75 | } 76 | if (Date.parse(lastOutboundLeg.plannedArrival) > Date.parse(options.returning.legs[0].plannedDeparture)) { 77 | throw new Error('origin.destination !== opt.returning.orgin.') 78 | } 79 | } 80 | 81 | if (!['1', '2'].includes(options.class)) { 82 | throw new Error('opt.class must be `1` or `2`.') 83 | } 84 | if ( 85 | typeof options.bahncard !== 'string' 86 | || options.bahncard.length > 1 87 | || options.bahncard.length > 2 88 | ) { 89 | throw new Error('opt.bahncard is invalid.') 90 | } 91 | if ( 92 | typeof options.age !== 'number' 93 | || options.age < 0 94 | || options.age > 200 95 | ) { 96 | throw new Error('opt.age is invalid.') 97 | } 98 | 99 | const req = { 100 | // todo: https://gist.github.com/derhuerst/5abc2e1f74b9bb29a3aeffe59b503103/edit 101 | revia: 'yes', 102 | existOptimizePrice: '1', 103 | REQ0HafasOptimize1: '0:1', 104 | start: 'Suchen', 105 | // HAFAS mgate.exe uses `HYBRID` 106 | rtMode: '12', 107 | HWAI: showDetails(false), 108 | 109 | // WAT. Their API fails if `S` is missing, even though the ID in 110 | // `REQ0JourneyStopsSID` overrides whatever is in `S`. Same for 111 | // `Z` and `REQ0JourneyStopsZID`. 112 | // todo: support POIs and addresses 113 | REQ0JourneyStopsS0A: '1', 114 | S: 'foo', 115 | REQ0JourneyStopsSID: 'A=1@L=00' + originId, 116 | REQ0JourneyStopsZ0A: '1', 117 | Z: 'bar', 118 | REQ0JourneyStopsZID: 'A=1@L=00' + destinationId, 119 | 120 | // todo: a few minutes earlier? 121 | REQ0JourneyDate: formatDate(outbound.legs[0].departure), 122 | REQ0JourneyTime: formatTime(outbound.legs[0].departure), 123 | REQ0HafasSearchForw: '1', 124 | 125 | // todo: a few minutes earlier? 126 | REQ1JourneyDate: options.returning ? formatDate(options.returning.legs[0].departure) : '', 127 | REQ1JourneyTime: options.returning ? formatTime(options.returning.legs[0].departure) : '', 128 | REQ1HafasSearchForw: '1', 129 | 130 | traveller_Nr: '1', 131 | // todo: make customisable 132 | REQ0Tariff_TravellerType: ['E'], 133 | REQ0Tariff_TravellerReductionClass: [options.bahncard], 134 | REQ0Tariff_TravellerAge: [options.age], 135 | REQ0Tariff_Class: options.class, 136 | } 137 | debug('request', req) 138 | 139 | const {data, cookies} = await request(START_URL, req) 140 | debug('cookies', cookies) 141 | debugResponse(data) 142 | const results = parse(outbound, options.returning, false)(data) 143 | debug('outbound results', ...results) 144 | 145 | let result = results.find((f) => { 146 | return compareJourney(outbound, options.returning, f.journey, false) 147 | }) 148 | // todo: return `null` instead? 149 | if (!result) { 150 | const err = new Error('no matching outbound journey found') 151 | err.model = outbound 152 | err.candidates = results 153 | throw err 154 | } 155 | debug('outbound next step', result.nextStep) 156 | 157 | if (options.returning) { 158 | const {data} = await request(result.nextStep, null, cookies) 159 | debugResponse(data) 160 | const results = parse(outbound, options.returning, true)(data) 161 | debug('returning results', ...results) 162 | 163 | result = results.find((f) => { 164 | return compareJourney(outbound, options.returning, f.journey, true) 165 | }) 166 | // todo: return `null` instead? 167 | if (!result) { 168 | const err = new Error('no matching returning journey found') 169 | err.model = outbound 170 | err.candidates = results 171 | throw err 172 | } 173 | debug('returning next step', result.nextStep) 174 | } 175 | 176 | return result.nextStep 177 | } 178 | 179 | module.exports = generateDbShopLink 180 | -------------------------------------------------------------------------------- /lib/compare-journey.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('generate-db-shop-urls') 4 | const isRoughlyEqual = require('is-roughly-equal') 5 | const slugg = require('slugg') 6 | 7 | const compareLegStations = (queryLeg, resultLeg, prop) => { 8 | const queryS = queryLeg[prop] 9 | const resultS = resultLeg[prop] 10 | if (queryS.id && resultS.id) return queryS.id === resultS.id 11 | if (queryS.station?.id && resultS.station?.id) { 12 | return queryS.station.id === resultS.station.id 13 | } 14 | return slugg(queryS.name) === slugg(resultS.name) 15 | } 16 | 17 | const compareJourney = (outbound, returning, j, isReturn) => { 18 | const q = isReturn ? returning : outbound 19 | // see public-transport/friendly-public-transport-format#4 20 | // todo: what about non-walking, non-public-transport legs? 21 | const qLegs = q.legs.filter(l => l.mode !== 'walking') 22 | const jLegs = j.legs.filter(l => l.mode !== 'walking') 23 | if (qLegs.length !== jLegs.length) return false 24 | const l = jLegs.length 25 | 26 | // todo: DRY with find-hafas-data-in-another-hafas/match-journey-leg 27 | for (let i = 0; i < l; i++) { 28 | const qLeg = qLegs[i] // from the query 29 | const jLeg = jLegs[i] // parsed from the DB shop response 30 | 31 | // compare origin id 32 | if (!compareLegStations(qLeg, jLeg, 'origin')) { 33 | debug('leg', i, 'non-matching origins', qLeg, jLeg) 34 | return false 35 | } 36 | 37 | // compare destination id 38 | if (!compareLegStations(qLeg, jLeg, 'destination')) { 39 | debug('leg', i, 'non-matching destinations', qLeg, jLeg) 40 | return false 41 | } 42 | 43 | if (!isRoughlyEqual(1000, +new Date(qLeg.plannedDeparture), +new Date(jLeg.plannedDeparture))) { 44 | debug('leg', i, 'non-matching departures', qLeg, jLeg) 45 | return false 46 | } 47 | if (!isRoughlyEqual(1000, +new Date(qLeg.plannedArrival), +new Date(jLeg.plannedArrival))) { 48 | debug('leg', i, 'non-matching arrivals', qLeg, jLeg) 49 | return false 50 | } 51 | 52 | if ( 53 | qLeg.departurePlatform && jLeg.departurePlatform 54 | && qLeg.departurePlatform !== jLeg.departurePlatform 55 | ) { 56 | debug('leg', i, 'non-matching departure platforms', qLeg, jLeg) 57 | return false 58 | } 59 | if ( 60 | qLeg.arrivalPlatform && jLeg.arrivalPlatform 61 | && qLeg.arrivalPlatform !== jLeg.arrivalPlatform 62 | ) { 63 | debug('leg', i, 'non-matching arrival platforms', qLeg, jLeg) 64 | return false 65 | } 66 | 67 | if (!jLeg.lines.find((l) => { 68 | // todo: DRY with pan-european-public-transport/lib/db.js 69 | const jName = slugg(l.name).replace(/-+/, '') 70 | const qName = slugg(qLeg.line.name).replace(/-+/, '') 71 | const qFahrtNr = qLeg.line.fahrtNr 72 | if (l.fahrtNr && qFahrtNr && l.fahrtNr !== qFahrtNr) return false 73 | return jName === qName 74 | })) { 75 | debug('leg', i, 'non-matching lines', qLeg, jLeg) 76 | return false 77 | } 78 | } 79 | 80 | let qTotal = q.price.amount 81 | if (isReturn) qTotal += outbound.price.amount 82 | if (qTotal && (j.price.amount || j.discount.amount)) { 83 | const jTotal = Math.min(j.price.amount || Infinity, j.discount.amount || Infinity) 84 | // todo: does the HAFAS mobile API return cheaper prices? 85 | if (jTotal > qTotal) { 86 | debug('matched journey too expensive', jTotal, qTotal) 87 | return false 88 | } 89 | } 90 | 91 | return true 92 | } 93 | 94 | module.exports = compareJourney 95 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const qs = require('querystring') 4 | 5 | // This helper generates the crazy query format that the DB shop uses. 6 | // It is url-encoded, with custom delimiters, and a very verbose way 7 | // of specifying for which part of the journey to return how many details. 8 | 9 | // The first number after `C` seems to stand for outbound/returning direction. 10 | // The second number (e.g. `-1`) seems to stand for the index in the list of 11 | // proposed journeys. 12 | const showDetails = (isReturn) => { 13 | // todo: nr of results 14 | const ids = isReturn ? [ 15 | // todo: when is it 1, when is it 2? 16 | 'C1-0', 'C1-1', 'C1-2', 'C1-3', 'C1-4' 17 | // 'C2-0', 'C2-1', 'C2-2', 'C2-3', 'C2-4' 18 | ] : [ 19 | 'C0-0', 'C0-1', 'C0-2', 'C0-3', 'C0-4' 20 | ] 21 | 22 | return ids 23 | .map((id) => { 24 | return 'CONNECTION$' + id + '!' + qs.stringify({ 25 | id, 26 | HwaiConId: id, 27 | HwaiDetailStatus: 'details', 28 | HwaiMoreDetailStatus: 'stInfo' 29 | }, '!') 30 | }) 31 | .join(';') 32 | } 33 | 34 | module.exports = {showDetails} 35 | -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const slugg = require('slugg') 4 | const {strictEqual} = require('assert') 5 | const moment = require('moment-timezone') 6 | const {createHash} = require('crypto') 7 | const trim = require('trim-newlines') 8 | const cheerio = require('cheerio') 9 | const url = require('url') 10 | const debug = require('debug')('generate-db-shop-urls:parse') 11 | 12 | const evaNrsByBetreiberNrs = require('./eva-nrs-by-betreiber-nrs.json') 13 | const {showDetails} = require('./helpers') 14 | 15 | const getEvaNrByBetreiberNr = (betreiberNr, name) => { 16 | const byName = evaNrsByBetreiberNrs[betreiberNr] 17 | if (!byName) return null 18 | 19 | for (const [_name, evaNr] of Object.entries(byName)) { 20 | if (slugg(_name) === slugg(name)) return evaNr 21 | } 22 | return null 23 | } 24 | strictEqual(getEvaNrByBetreiberNr('1071', 'Berlin Hbf'), '8011160') 25 | strictEqual(getEvaNrByBetreiberNr('1071', 'Berlin Hbf (tief)'), '8098160') 26 | strictEqual(getEvaNrByBetreiberNr('1071', 'Berlin Hbf (S-Bahn)'), '8089021') 27 | strictEqual(getEvaNrByBetreiberNr('1071', 'Berlin Hbf (foo)'), null) 28 | strictEqual(getEvaNrByBetreiberNr('bar', 'Berlin Hbf (foo)'), null) 29 | 30 | const nextStepLink = (outbound, returning, $, row) => { 31 | const href = $(".buttonbold", row).attr("href") 32 | if (!href) return null 33 | 34 | const u = url.parse(href, true) 35 | u.query.HWAI = showDetails(true) 36 | delete u.search 37 | 38 | return url.format(u) 39 | } 40 | 41 | const parseDate = (str) => { 42 | const match = /(\d{1,2})\.(\d{1,2})\.(\d{2,4})/.exec(str) 43 | if (!match) return null 44 | return [ 45 | // year 46 | ('20' + match[3]).slice(-4), 47 | // month 48 | ('0' + match[2]).slice(-2), 49 | // day of the month 50 | ('0' + match[1]).slice(-2), 51 | ].join('-') 52 | } 53 | strictEqual(parseDate(' 24.12.22 '), '2022-12-24') 54 | strictEqual(parseDate('3.3.23\n'), '2023-03-03') 55 | 56 | const parseTime = (isoDate, str, tBase = 0) => { 57 | const match = /(\d{2}):(\d{2})/.exec(str) 58 | if (!match || !match[1] || !match[2]) return null 59 | 60 | const _ = moment.tz(isoDate + 'T00:00Z', 'Europe/Berlin') 61 | const dt = moment(_).tz('Europe/Berlin') 62 | .hours(parseInt(match[1])) 63 | .minutes(parseInt(match[2])) 64 | if (dt < tBase) dt.add(1, 'days') 65 | return dt.toISOString() 66 | } 67 | strictEqual(parseTime('2020-04-08', 'ab 20:53 '), '2020-04-08T18:53:00.000Z') 68 | strictEqual(parseTime('2020-04-08', 'an 23:53 \n'), '2020-04-08T21:53:00.000Z') 69 | strictEqual(parseTime('2020-04-08', '00:12'), '2020-04-07T22:12:00.000Z') 70 | strictEqual( 71 | parseTime('2020-04-08', '00:12', Date.parse('2020-04-08T04:00:00Z')), 72 | '2020-04-08T22:12:00.000Z', 73 | ) 74 | 75 | const createParseWhen = (tBase = 0) => { 76 | const parseWhen = (isoDate, node) => { 77 | const timeNode = node.get(0).childNodes.find(n => n.type === 'text') 78 | const plannedWhen = parseTime(isoDate, (timeNode || {}).data || '', tBase) 79 | const when = parseTime( 80 | isoDate, 81 | node.find('.delay, .delayOnTime').text() || '', 82 | tBase, 83 | ) 84 | 85 | let delay = null 86 | if (when && plannedWhen) { 87 | delay = Math.round((new Date(when) - new Date(plannedWhen)) / 1000) 88 | } 89 | tBase = Date.parse(plannedWhen) 90 | return {plannedWhen, when, delay} 91 | } 92 | return parseWhen 93 | } 94 | // todo: assert 95 | 96 | const irrelevantBookingLinkParams = [ 97 | 'protocol', 98 | 'ident', 99 | 'bcrvglpreis', 100 | 'services', 101 | 'showAvail', 102 | ] 103 | const parseJourneyIdFromBookingLink = (link) => { 104 | const u = new URL(link) 105 | const p = Array.from(u.searchParams.entries()) 106 | .filter(([k]) => !irrelevantBookingLinkParams.includes(k)) 107 | .sort(([kA, kB]) => kA < kB ? -1 : (kA > kB ? 1 : 0)) 108 | return createHash('sha1') 109 | .update(new URLSearchParams(p).toString()) 110 | .digest('hex') 111 | } 112 | strictEqual( 113 | parseJourneyIdFromBookingLink(`\ 114 | https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https:&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-1.0@1&oCID=C0-1&orderSOP=yes&showAvail=yes&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau%20Hbf&services=hbma&bcrvglpreis=8260&HWAI=SELCON!lastsel=C0-1!&` 115 | ), 116 | createHash('sha1') 117 | .update(`\ 118 | HWAI=SELCON%21lastsel%3DC0-1%21&orderSOP=yes&ld=43121&seqnr=1&rt=1&rememberSortType=minDeparture&sTID=C0-1.0%401&oCID=C0-1&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau+Hbf`) 119 | .digest('hex'), 120 | ) 121 | 122 | const parseLegs = (outbound, returning, isReturn, $, isoDate, parseWhen) => (data, row) => { 123 | const classes = (row.attribs.class || '').split(/\s+/) 124 | if (classes.includes('intermediate')) return data // skip walking etc. 125 | 126 | const isFirstOfLeg = classes.includes('sectionDeparture') 127 | const isLastOfLeg = classes.includes('sectionArrival') 128 | 129 | let leg 130 | if (isFirstOfLeg) { 131 | // create new leg 132 | leg = {public: true} 133 | } else { 134 | // get last added leg 135 | leg = data.journey.legs[data.journey.legs.length - 1] 136 | } 137 | 138 | if (isFirstOfLeg || isLastOfLeg) { 139 | const station = trim($('.station', row).text().trim().split(/\n+/)[0]) 140 | leg[isFirstOfLeg ? 'origin' : 'destination'] = { 141 | type: 'station', 142 | id: null, 143 | name: station 144 | } 145 | 146 | const platform = $('.platform', row).text().trim().split(/\s+/)[1] 147 | // todo: prognosedPlatform? 148 | leg[isFirstOfLeg ? 'departurePlatform' : 'arrivalPlatform'] = platform 149 | 150 | const {when, plannedWhen, delay} = parseWhen(isoDate, $('.time', row)) 151 | // todo: cancelled, prognosedWhen 152 | leg[isFirstOfLeg ? 'departure' : 'arrival'] = when 153 | leg[isFirstOfLeg ? 'plannedDeparture' : 'plannedArrival'] = plannedWhen 154 | leg[isFirstOfLeg ? 'departureDelay' : 'arrivalDelay'] = delay 155 | } 156 | 157 | if (isFirstOfLeg) { 158 | leg.lines = $('.products a', row).get() 159 | .map((l) => { 160 | let name = $(l).text().trim().replace(/\s+/, ' ') 161 | let fahrtNr = null 162 | if (name.includes('(')) { 163 | const m = /^([\w\s]+)\s\(([^)]+)\)$/.exec(name) 164 | if (m) { 165 | name = m[1] 166 | fahrtNr = m[2] 167 | } 168 | } 169 | return { 170 | type: 'line', 171 | id: slugg(fahrtNr || name), 172 | name, 173 | fahrtNr, 174 | } 175 | }) 176 | .filter(l => !!l.id && !!l.name) 177 | } 178 | 179 | if (isFirstOfLeg) { 180 | data.journey.legs.push(leg) 181 | } 182 | 183 | return data 184 | } 185 | 186 | const tagStationInJourney = (journey, name, id) => { 187 | const normalized = slugg(name) 188 | 189 | // Note: This is almost as brittle as looking through the list of all DB stations and matching by name. Find a better way! 190 | for (let leg of journey.legs) { 191 | if (slugg(leg.origin.name) === normalized) leg.origin.id = id 192 | if (slugg(leg.destination.name) === normalized) leg.destination.id = id 193 | } 194 | } 195 | 196 | const tagStations = ($, row, journey) => { 197 | const stationOptions = $('.stationInfo select option', row).get() 198 | for (let l of stationOptions) { 199 | const betreiberNr = l.attribs.value 200 | if (!betreiberNr) continue 201 | const name = trim($(l).text().trim()) 202 | 203 | const evaNr = getEvaNrByBetreiberNr(betreiberNr, name) 204 | if (!evaNr) continue 205 | 206 | debug(`translating Betreiber-Nr "${betreiberNr}" & name "${name}" to EVA ID "${evaNr}"`) 207 | tagStationInJourney(journey, name, evaNr) 208 | } 209 | } 210 | 211 | const parsePrice = (str) => { 212 | if (!str) return {amount: null, currency: null} 213 | const m = /(\d+),?(\d+)\s+([A-Z]{3})?/.exec(trim(str.trim())) 214 | if (m && m[1]) { 215 | return { 216 | amount: parseInt(m[1]) + (m[2] ? parseInt(m[2]) * .01 : 0), 217 | currency: m[3] || 'EUR' 218 | } 219 | } 220 | return {amount: null, currency: null} 221 | } 222 | 223 | const parse = (outbound, returning, isReturn) => (html) => { 224 | const $ = cheerio.load(html) 225 | 226 | // Within the markup, (wall clock) times implicitly refer to different "base" dates. 227 | let isoDate = parseDate($('#tp_overview_headline_date').text()) 228 | let tBase = 0 229 | 230 | return $('.scheduledCon, .liveCon, .dateDividerText', '#resultsOverviewContainer').get() 231 | .map((row, journeyI) => { 232 | const $Row = $(row) 233 | 234 | if ($Row.hasClass('dateDividerText')) { 235 | isoDate = parseDate($Row.text()) 236 | tBase = +moment.tz(isoDate + 'T00:00', 'Europe/Berlin') 237 | debug(`using ${tBase} (${isoDate}) as tBase from here`) 238 | return null // skip row 239 | } 240 | const parseWhen = createParseWhen(tBase) 241 | 242 | const nextStep = nextStepLink(outbound, returning, $, row) 243 | if (!nextStep) { 244 | debug('journey without next step (booking link)', journeyI) 245 | return null 246 | } 247 | 248 | const bookingLink = nextStep // todo 249 | const id = ( 250 | bookingLink && parseJourneyIdFromBookingLink(bookingLink) 251 | || (isReturn ? 'returning' : 'outbound') + '-' + journeyI 252 | ) 253 | 254 | const journey = $('.details .connectionDetails li', row).get() 255 | .reduce(parseLegs(outbound, returning, isReturn, $, isoDate, parseWhen), { 256 | journey: { 257 | type: 'journey', 258 | id, 259 | legs: [] 260 | } 261 | }) 262 | .journey 263 | 264 | if (journey.legs.length === 0) { 265 | debug('journey with 0 legs, ignoring', journeyI, journey) 266 | return null 267 | } 268 | 269 | tagStations($, row, journey) 270 | 271 | // todo: rename discount -> totalDiscount, price -> totalPrice 272 | const discount = parsePrice($('.farePep .fareOutput', row).text()) 273 | const price = parsePrice($('.fareStd .fareOutput', row).text()) 274 | if (!discount.amount && !price.amount) return null 275 | journey.price = price 276 | journey.discount = discount 277 | 278 | return {journey, nextStep} 279 | }) 280 | .filter((j) => !!j) 281 | } 282 | 283 | module.exports = parse 284 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const qs = require('querystring') 4 | const {fetch} = require('fetch-ponyfill')({ 5 | Promise: require('pinkie-promise') 6 | }) 7 | const ct = require('content-type') 8 | const {decode} = require('iconv-lite') 9 | 10 | const parseCookies = (res) => { 11 | const cookies = Object.create(null) 12 | res.headers.forEach((value, name) => { 13 | if (name !== 'set-cookie') return; 14 | // todo: ? 15 | // Object.assign(cookies, cookie.parse(value)) 16 | const v = value.split(';')[0] 17 | Object.assign(cookies, qs.parse(v, ';')) 18 | }) 19 | return cookies 20 | } 21 | 22 | const formatCookies = (cookies) => qs.stringify(cookies, '; ') 23 | 24 | const request = async (endpoint, query, cookies) => { 25 | const target = query ? endpoint + '?' + qs.stringify(query) : endpoint 26 | 27 | const headers = { 28 | 'user-agent': 'https://github.com/derhuerst/generate-db-shop-urls' 29 | } 30 | if (cookies) headers.cookie = formatCookies(cookies) 31 | 32 | const res = await fetch(target, { 33 | cache: 'no-store', 34 | redirect: 'follow', 35 | headers 36 | }) 37 | if (!res.ok) { 38 | const err = new Error('response not ok: ' + res.status) 39 | err.response = res 40 | throw err 41 | } 42 | 43 | const raw = await res.buffer() 44 | let data 45 | const c = ct.parse(res.headers.get('content-type')) 46 | if (c.parameters && c.parameters.charset) { 47 | data = decode(raw, c.parameters.charset) 48 | } else { 49 | data = raw.toString('utf8') 50 | } 51 | 52 | return { 53 | data, 54 | // todo: parse cookies and use them in the next request 55 | cookies: parseCookies(res), 56 | } 57 | } 58 | 59 | module.exports = request 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate-db-shop-urls", 3 | "description": "Magically generate Deutsche Bahn ticket URLs.", 4 | "version": "4.0.0", 5 | "main": "index.js", 6 | "files": [ 7 | "index.js", 8 | "lib/*", 9 | "example.js", 10 | "LICENSE", 11 | "LICENSE-PROSPERITY.md", 12 | "LICENSE-APACHE" 13 | ], 14 | "keywords": [ 15 | "db", 16 | "deutsche bahn", 17 | "bahn.de", 18 | "tickets", 19 | "shop", 20 | "link" 21 | ], 22 | "author": "Jannis R ", 23 | "homepage": "https://github.com/derhuerst/generate-db-shop-urls", 24 | "repository": "derhuerst/generate-db-shop-urls", 25 | "bugs": "https://github.com/derhuerst/generate-db-shop-urls/issues", 26 | "license": "(Apache-2.0 AND Prosperity-3.0.0)", 27 | "funding": [ 28 | { 29 | "type": "github", 30 | "url": "https://github.com/sponsors/derhuerst" 31 | }, 32 | { 33 | "type": "patreon", 34 | "url": "https://patreon.com/derhuerst" 35 | } 36 | ], 37 | "engines": { 38 | "node": ">=16" 39 | }, 40 | "dependencies": { 41 | "cheerio": "^1.0.0-rc.2", 42 | "content-type": "^1.0.2", 43 | "debug": "^4.0.0", 44 | "fetch-ponyfill": "^7.1.0", 45 | "iconv-lite": "^0.6.0", 46 | "is-roughly-equal": "^0.1.0", 47 | "luxon": "^3.1.1", 48 | "moment-timezone": "^0.5.14", 49 | "pinkie-promise": "^2.0.1", 50 | "slugg": "^1.2.0", 51 | "trim-newlines": "^3.0.1" 52 | }, 53 | "peerDependencies": { 54 | "db-hafas": "^5.0.0", 55 | "eslint": "^8.11.0", 56 | "hafas-client": "^5.0.0" 57 | }, 58 | "devDependencies": { 59 | "csv-parser": "^3.0.0", 60 | "db-hafas": "^5.0.1", 61 | "tape": "^5.0.0" 62 | }, 63 | "scripts": { 64 | "lint": "eslint .", 65 | "build": "./build/index.sh", 66 | "test": "env NODE_ENV=dev node test/index.js", 67 | "prepublishOnly": "npm run lint && npm run build && npm test" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # generate-db-shop-urls 2 | 3 | **Magically generate Deutsche Bahn ticket URLs.** Given a [`journey` queried with `hafas-client@5`](https://github.com/public-transport/hafas-client/blob/5/docs/journeys.md), it tries to generate a matching ticket link in the [Deutsche Bahn shop](https://www.bahn.de/). Caveats: 4 | 5 | - Uses a lot of scraping, as there is no (publicly accessible) machine-readable interface to the ticket system. This makes `generate-db-shop-urls` brittle. 6 | - Because of how (bad) the shop works, the generated links will only be valid with a browser session that hasn't recently been used to search for a connection, or one without any cookies/session. 7 | 8 | [![npm version](https://img.shields.io/npm/v/generate-db-shop-urls.svg)](https://www.npmjs.com/package/generate-db-shop-urls) 9 | [![Prosperity/Apache license](https://img.shields.io/static/v1?label=license&message=Prosperity%2FApache&color=0997E8)](#license) 10 | [![support me via GitHub Sponsors](https://img.shields.io/badge/support%20me-donate-fa7664.svg)](https://github.com/sponsors/derhuerst) 11 | [![chat with me on Twitter](https://img.shields.io/badge/chat%20with%20me-on%20Twitter-1da1f2.svg)](https://twitter.com/derhuerst) 12 | 13 | 14 | ## Installing 15 | 16 | ```shell 17 | npm install generate-db-shop-urls 18 | ``` 19 | 20 | 21 | ## Usage 22 | 23 | `generate-db-shop-urls` expects one (outbound) or two (outbound & returning) [`journey`s queried with `hafas-client@5`](https://github.com/public-transport/hafas-client/blob/5/docs/journeys.md) as input. 24 | 25 | ```js 26 | const createHafas = require('db-hafas') 27 | const generateTicketLink = require('generate-db-shop-urls') 28 | 29 | const berlin = '8096003' 30 | const hamburg = '8000157' 31 | const hafas = createHafas('my-awesome-program') 32 | 33 | const outbound = await hafas.journeys(berlin, hamburg, { 34 | departure: new Date('2017-05-18T05:00+0200'), 35 | results: 1, 36 | }) 37 | const returning = await hafas.journeys(hamburg, berlin, { 38 | departure: new Date('2017-05-19T12:00+0200'), 39 | results: 1, 40 | }) 41 | 42 | const link = await generateTicketLink(outbound.journeys[0], { 43 | returning: returning.journeys[0], 44 | }) 45 | console.log(link) 46 | ``` 47 | 48 | ## API 49 | 50 | ```js 51 | async (outbound, opt = {}) => {} 52 | ``` 53 | 54 | `opt` overrides the default options which look as follows: 55 | 56 | ```js 57 | { 58 | // type of BahnCard, '0' = no bahncard 59 | // see https://gist.github.com/juliuste/202bb04f450a79f8fa12a2ec3abcd72d 60 | bahncard: '0', 61 | class: '2', // '1' or '2' 62 | age: 40, // age of the traveller 63 | returning: null // returning journey to match (optional) 64 | } 65 | ``` 66 | 67 | 68 | ## License 69 | 70 | This project is dual-licensed: **My contributions are licensed under the [*Prosperity Public License*](https://prosperitylicense.com), [contributions of other people](https://github.com/derhuerst/generate-db-shop-urls/graphs/contributors) are licensed as [Apache 2.0](https://apache.org/licenses/LICENSE-2.0)**. 71 | 72 | > This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days. 73 | 74 | > Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn’t count as use for a commercial purpose. 75 | 76 | [Get in touch with me](https://jannisr.de/) to buy a commercial license or read more about [why I sell private licenses for my projects](https://gist.github.com/derhuerst/0ef31ee82b6300d2cafd03d10dd522f7). 77 | 78 | The [DB *Haltestellendaten* dataset](https://data.deutschebahn.com/dataset/data-haltestellen.html) used by this project is licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). 79 | 80 | 81 | ## Contributing 82 | 83 | If you have a question, found a bug or want to propose a feature, have a look at [the issues page](https://github.com/derhuerst/generate-db-shop-urls/issues). 84 | 85 | By contributing, you agree to release your modifications under the [Apache 2.0 license](LICENSE-APACHE). 86 | -------------------------------------------------------------------------------- /test/expected-düsseldorf-hanau.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "journey": { 4 | "type": "journey", 5 | "id": "af7d582d0ea3d0e2cb63436a1612cc1954c63997", 6 | "legs": [ 7 | { 8 | "public": true, 9 | "origin": { 10 | "type": "station", 11 | "id": null, 12 | "name": "Düsseldorf Hbf" 13 | }, 14 | "departurePlatform": "16", 15 | "departure": "2022-06-01T13:18:00.000Z", 16 | "plannedDeparture": "2022-06-01T13:11:00.000Z", 17 | "departureDelay": 420, 18 | "lines": [ 19 | { 20 | "type": "line", 21 | "id": "ice-723", 22 | "name": "ICE 723", 23 | "fahrtNr": null 24 | } 25 | ], 26 | "destination": { 27 | "type": "station", 28 | "id": null, 29 | "name": "Frankfurt(M) Flughafen Fernbf" 30 | }, 31 | "arrivalPlatform": "Fern", 32 | "arrival": "2022-06-01T14:33:00.000Z", 33 | "plannedArrival": "2022-06-01T14:33:00.000Z", 34 | "arrivalDelay": 0 35 | }, 36 | { 37 | "public": true, 38 | "origin": { 39 | "type": "station", 40 | "id": null, 41 | "name": "Frankfurt(M) Flughafen Fernbf" 42 | }, 43 | "departurePlatform": "Fern", 44 | "departure": "2022-06-01T14:46:00.000Z", 45 | "plannedDeparture": "2022-06-01T14:46:00.000Z", 46 | "departureDelay": 0, 47 | "lines": [ 48 | { 49 | "type": "line", 50 | "id": "28647", 51 | "name": "HLB RB58", 52 | "fahrtNr": "28647" 53 | } 54 | ], 55 | "destination": { 56 | "type": "station", 57 | "id": null, 58 | "name": "Frankfurt(Main)Süd" 59 | }, 60 | "arrivalPlatform": "8", 61 | "arrival": "2022-06-01T15:00:00.000Z", 62 | "plannedArrival": "2022-06-01T15:00:00.000Z", 63 | "arrivalDelay": 0 64 | }, 65 | { 66 | "public": true, 67 | "origin": { 68 | "type": "station", 69 | "id": null, 70 | "name": "Frankfurt(Main)Süd" 71 | }, 72 | "departurePlatform": "9", 73 | "departure": null, 74 | "plannedDeparture": "2022-06-01T15:09:00.000Z", 75 | "departureDelay": null, 76 | "lines": [ 77 | { 78 | "type": "line", 79 | "id": "15556", 80 | "name": "RB 51", 81 | "fahrtNr": "15556" 82 | } 83 | ], 84 | "destination": { 85 | "type": "station", 86 | "id": null, 87 | "name": "Hanau Hbf" 88 | }, 89 | "arrivalPlatform": "7", 90 | "arrival": null, 91 | "plannedArrival": "2022-06-01T15:21:00.000Z", 92 | "arrivalDelay": null 93 | } 94 | ], 95 | "price": { 96 | "amount": 82.6, 97 | "currency": "EUR" 98 | }, 99 | "discount": { 100 | "amount": null, 101 | "currency": null 102 | } 103 | }, 104 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https%3A&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-0.0%401&oCID=C0-0&orderSOP=yes&showAvail=yes&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau%20Hbf&services=hbma&bcrvglpreis=8260&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 105 | }, 106 | { 107 | "journey": { 108 | "type": "journey", 109 | "id": "d41e6c78ca3238b006f04f708e25350522813573", 110 | "legs": [ 111 | { 112 | "public": true, 113 | "origin": { 114 | "type": "station", 115 | "id": null, 116 | "name": "Düsseldorf Hbf" 117 | }, 118 | "departurePlatform": "16", 119 | "departure": "2022-06-01T13:18:00.000Z", 120 | "plannedDeparture": "2022-06-01T13:11:00.000Z", 121 | "departureDelay": 420, 122 | "lines": [ 123 | { 124 | "type": "line", 125 | "id": "ice-723", 126 | "name": "ICE 723", 127 | "fahrtNr": null 128 | } 129 | ], 130 | "destination": { 131 | "type": "station", 132 | "id": null, 133 | "name": "Frankfurt(M) Flughafen Fernbf" 134 | }, 135 | "arrivalPlatform": "Fern", 136 | "arrival": "2022-06-01T14:33:00.000Z", 137 | "plannedArrival": "2022-06-01T14:33:00.000Z", 138 | "arrivalDelay": 0 139 | }, 140 | { 141 | "public": true, 142 | "origin": { 143 | "type": "station", 144 | "id": null, 145 | "name": "Frankfurt(M) Flughafen Fernbf" 146 | }, 147 | "departurePlatform": "Fern", 148 | "departure": "2022-06-01T14:46:00.000Z", 149 | "plannedDeparture": "2022-06-01T14:46:00.000Z", 150 | "departureDelay": 0, 151 | "lines": [ 152 | { 153 | "type": "line", 154 | "id": "28647", 155 | "name": "HLB RB58", 156 | "fahrtNr": "28647" 157 | } 158 | ], 159 | "destination": { 160 | "type": "station", 161 | "id": null, 162 | "name": "Hanau Hbf" 163 | }, 164 | "arrivalPlatform": "103", 165 | "arrival": "2022-06-01T15:22:00.000Z", 166 | "plannedArrival": "2022-06-01T15:22:00.000Z", 167 | "arrivalDelay": 0 168 | } 169 | ], 170 | "price": { 171 | "amount": 82.6, 172 | "currency": "EUR" 173 | }, 174 | "discount": { 175 | "amount": null, 176 | "currency": null 177 | } 178 | }, 179 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https%3A&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-1.0%401&oCID=C0-1&orderSOP=yes&showAvail=yes&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau%20Hbf&services=hbma&bcrvglpreis=8260&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 180 | }, 181 | { 182 | "journey": { 183 | "type": "journey", 184 | "id": "95a05a54de488d1f6cc47bb931317f5563347068", 185 | "legs": [ 186 | { 187 | "public": true, 188 | "origin": { 189 | "type": "station", 190 | "id": null, 191 | "name": "Düsseldorf Hbf" 192 | }, 193 | "departurePlatform": "16", 194 | "departure": "2022-06-01T13:27:00.000Z", 195 | "plannedDeparture": "2022-06-01T13:27:00.000Z", 196 | "departureDelay": 0, 197 | "lines": [ 198 | { 199 | "type": "line", 200 | "id": "ice-927", 201 | "name": "ICE 927", 202 | "fahrtNr": null 203 | } 204 | ], 205 | "destination": { 206 | "type": "station", 207 | "id": null, 208 | "name": "Köln Messe/Deutz Gl.11-12" 209 | }, 210 | "arrivalPlatform": "11", 211 | "arrival": "2022-06-01T13:46:00.000Z", 212 | "plannedArrival": "2022-06-01T13:46:00.000Z", 213 | "arrivalDelay": 0 214 | }, 215 | { 216 | "public": true, 217 | "origin": { 218 | "type": "station", 219 | "id": null, 220 | "name": "Köln Messe/Deutz Gl.11-12" 221 | }, 222 | "departurePlatform": "11", 223 | "departure": null, 224 | "plannedDeparture": "2022-06-01T14:01:00.000Z", 225 | "departureDelay": null, 226 | "lines": [ 227 | { 228 | "type": "line", 229 | "id": "ice-819", 230 | "name": "ICE 819", 231 | "fahrtNr": null 232 | } 233 | ], 234 | "destination": { 235 | "type": "station", 236 | "id": null, 237 | "name": "Frankfurt(Main)Hbf" 238 | }, 239 | "arrivalPlatform": "17", 240 | "arrival": null, 241 | "plannedArrival": "2022-06-01T15:24:00.000Z", 242 | "arrivalDelay": null 243 | }, 244 | { 245 | "public": true, 246 | "origin": { 247 | "type": "station", 248 | "id": null, 249 | "name": "Frankfurt(Main)Hbf" 250 | }, 251 | "departurePlatform": "6", 252 | "departure": null, 253 | "plannedDeparture": "2022-06-01T15:34:00.000Z", 254 | "departureDelay": null, 255 | "lines": [ 256 | { 257 | "type": "line", 258 | "id": "4625", 259 | "name": "RE 55", 260 | "fahrtNr": "4625" 261 | } 262 | ], 263 | "destination": { 264 | "type": "station", 265 | "id": null, 266 | "name": "Hanau Hbf" 267 | }, 268 | "arrivalPlatform": "103", 269 | "arrival": null, 270 | "plannedArrival": "2022-06-01T15:55:00.000Z", 271 | "arrivalDelay": null 272 | } 273 | ], 274 | "price": { 275 | "amount": 82.6, 276 | "currency": "EUR" 277 | }, 278 | "discount": { 279 | "amount": null, 280 | "currency": null 281 | } 282 | }, 283 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https%3A&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-2.0%401&oCID=C0-2&orderSOP=yes&showAvail=yes&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau%20Hbf&services=hbma&bcrvglpreis=8260&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 284 | }, 285 | { 286 | "journey": { 287 | "type": "journey", 288 | "id": "eec7312a630df029f92471ef611c770d892b15f1", 289 | "legs": [ 290 | { 291 | "public": true, 292 | "origin": { 293 | "type": "station", 294 | "id": null, 295 | "name": "Düsseldorf Hbf" 296 | }, 297 | "departurePlatform": "16", 298 | "departure": "2022-06-01T13:27:00.000Z", 299 | "plannedDeparture": "2022-06-01T13:27:00.000Z", 300 | "departureDelay": 0, 301 | "lines": [ 302 | { 303 | "type": "line", 304 | "id": "ice-927", 305 | "name": "ICE 927", 306 | "fahrtNr": null 307 | } 308 | ], 309 | "destination": { 310 | "type": "station", 311 | "id": null, 312 | "name": "Hanau Hbf" 313 | }, 314 | "arrivalPlatform": "103", 315 | "arrival": "2022-06-01T16:28:00.000Z", 316 | "plannedArrival": "2022-06-01T16:28:00.000Z", 317 | "arrivalDelay": 0 318 | } 319 | ], 320 | "price": { 321 | "amount": null, 322 | "currency": null 323 | }, 324 | "discount": { 325 | "amount": 55.9, 326 | "currency": "EUR" 327 | } 328 | }, 329 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https%3A&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-3.1%401830&oCID=C0-3&showAvail=yes&completeFulfillment=1&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 330 | }, 331 | { 332 | "journey": { 333 | "type": "journey", 334 | "id": "7f603078289b6b554e9d8399b28cc55194c0e339", 335 | "legs": [ 336 | { 337 | "public": true, 338 | "origin": { 339 | "type": "station", 340 | "id": null, 341 | "name": "Düsseldorf Hbf" 342 | }, 343 | "departurePlatform": "16", 344 | "departure": null, 345 | "plannedDeparture": "2022-06-01T14:08:00.000Z", 346 | "departureDelay": null, 347 | "lines": [ 348 | { 349 | "type": "line", 350 | "id": "ice-1123", 351 | "name": "ICE 1123", 352 | "fahrtNr": null 353 | } 354 | ], 355 | "destination": { 356 | "type": "station", 357 | "id": null, 358 | "name": "Frankfurt(M) Flughafen Fernbf" 359 | }, 360 | "arrivalPlatform": "Fern", 361 | "arrival": null, 362 | "plannedArrival": "2022-06-01T15:17:00.000Z", 363 | "arrivalDelay": null 364 | }, 365 | { 366 | "public": true, 367 | "origin": { 368 | "type": "station", 369 | "id": null, 370 | "name": "Frankfurt(M) Flughafen Fernbf" 371 | }, 372 | "departurePlatform": "Fern", 373 | "departure": null, 374 | "plannedDeparture": "2022-06-01T15:27:00.000Z", 375 | "departureDelay": null, 376 | "lines": [ 377 | { 378 | "type": "line", 379 | "id": "ice-17", 380 | "name": "ICE 17", 381 | "fahrtNr": null 382 | } 383 | ], 384 | "destination": { 385 | "type": "station", 386 | "id": null, 387 | "name": "Frankfurt(Main)Hbf" 388 | }, 389 | "arrivalPlatform": "20", 390 | "arrival": null, 391 | "plannedArrival": "2022-06-01T15:40:00.000Z", 392 | "arrivalDelay": null 393 | }, 394 | { 395 | "public": true, 396 | "origin": { 397 | "type": "station", 398 | "id": null, 399 | "name": "Frankfurt(Main)Hbf" 400 | }, 401 | "departurePlatform": "5", 402 | "departure": null, 403 | "plannedDeparture": "2022-06-01T15:50:00.000Z", 404 | "departureDelay": null, 405 | "lines": [ 406 | { 407 | "type": "line", 408 | "id": "4544", 409 | "name": "RE 50", 410 | "fahrtNr": "4544" 411 | } 412 | ], 413 | "destination": { 414 | "type": "station", 415 | "id": null, 416 | "name": "Hanau Hbf" 417 | }, 418 | "arrivalPlatform": "7", 419 | "arrival": null, 420 | "plannedArrival": "2022-06-01T16:06:00.000Z", 421 | "arrivalDelay": null 422 | } 423 | ], 424 | "price": { 425 | "amount": 82.6, 426 | "currency": "EUR" 427 | }, 428 | "discount": { 429 | "amount": null, 430 | "currency": null 431 | } 432 | }, 433 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=43121&protocol=https%3A&seqnr=1&ident=1j.020193121.1654088623&rt=1&rememberSortType=minDeparture&sTID=C0-4.0%401&oCID=C0-4&orderSOP=yes&showAvail=yes&completeFulfillment=1&hafasSessionExpires=0106221518&zielorth=Hanau&zielortb=rheinmain&zielorta=DEU&xcoorda=8929003&ycoorda=50120957&distancea=193&zielortm=Hanau%20Hbf&services=hbma&bcrvglpreis=8260&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 434 | } 435 | ] 436 | -------------------------------------------------------------------------------- /test/expected-hannover-münchen.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "journey": 4 | { 5 | "type": "journey", 6 | "id": "7c4e204780a615b22dfaeae8842ff626f3b0088e", 7 | "legs": 8 | [ 9 | { 10 | "public": true, 11 | "origin": 12 | { 13 | "type": "station", 14 | "id": "8000152", 15 | "name": "Hannover Hbf" 16 | }, 17 | "departurePlatform": "3", 18 | "departure": null, 19 | "plannedDeparture": "2022-12-24T21:36:00.000Z", 20 | "departureDelay": null, 21 | "lines": 22 | [ 23 | { 24 | "type": "line", 25 | "id": "82837", 26 | "name": "ME RE2", 27 | "fahrtNr": "82837" 28 | } 29 | ], 30 | "destination": 31 | { 32 | "type": "station", 33 | "id": "8000128", 34 | "name": "Göttingen" 35 | }, 36 | "arrivalPlatform": "6", 37 | "arrival": null, 38 | "plannedArrival": "2022-12-24T22:49:00.000Z", 39 | "arrivalDelay": null 40 | }, 41 | { 42 | "public": true, 43 | "origin": 44 | { 45 | "type": "station", 46 | "id": "8000128", 47 | "name": "Göttingen" 48 | }, 49 | "departurePlatform": "6", 50 | "departure": null, 51 | "plannedDeparture": "2022-12-25T00:13:00.000Z", 52 | "departureDelay": null, 53 | "lines": 54 | [ 55 | { 56 | "type": "line", 57 | "id": "ice-1689", 58 | "name": "ICE 1689", 59 | "fahrtNr": null 60 | } 61 | ], 62 | "destination": 63 | { 64 | "type": "station", 65 | "id": "8098263", 66 | "name": "München Hbf" 67 | }, 68 | "arrivalPlatform": "20", 69 | "arrival": null, 70 | "plannedArrival": "2022-12-25T05:04:00.000Z", 71 | "arrivalDelay": null 72 | } 73 | ], 74 | "price": 75 | { 76 | "amount": null, 77 | "currency": null 78 | }, 79 | "discount": 80 | { 81 | "amount": 44.9, 82 | "currency": "EUR" 83 | } 84 | }, 85 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=4398&country=DEU&protocol=https%3A&seqnr=1&ident=ih.01924798.1670715863&rt=1&rememberSortType=minDeparture&sTID=C0-0.2%401830&oCID=C0-0&showAvail=yes&completeFulfillment=1&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 86 | }, 87 | { 88 | "journey": 89 | { 90 | "type": "journey", 91 | "id": "7233eefa208d4abb7da4358c7002ce23c8d9ed30", 92 | "legs": 93 | [ 94 | { 95 | "public": true, 96 | "origin": 97 | { 98 | "type": "station", 99 | "id": "8000152", 100 | "name": "Hannover Hbf" 101 | }, 102 | "departurePlatform": "7", 103 | "departure": null, 104 | "plannedDeparture": "2022-12-24T23:03:00.000Z", 105 | "departureDelay": null, 106 | "lines": 107 | [ 108 | { 109 | "type": "line", 110 | "id": "ice-1689", 111 | "name": "ICE 1689", 112 | "fahrtNr": null 113 | } 114 | ], 115 | "destination": 116 | { 117 | "type": "station", 118 | "id": "8098263", 119 | "name": "München Hbf" 120 | }, 121 | "arrivalPlatform": "20", 122 | "arrival": null, 123 | "plannedArrival": "2022-12-25T05:04:00.000Z", 124 | "arrivalDelay": null 125 | } 126 | ], 127 | "price": 128 | { 129 | "amount": null, 130 | "currency": null 131 | }, 132 | "discount": 133 | { 134 | "amount": 35.9, 135 | "currency": "EUR" 136 | } 137 | }, 138 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=4398&country=DEU&protocol=https%3A&seqnr=1&ident=ih.01924798.1670715863&rt=1&rememberSortType=minDeparture&sTID=C0-1.2%401830&oCID=C0-1&showAvail=yes&completeFulfillment=1&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 139 | }, 140 | { 141 | "journey": 142 | { 143 | "type": "journey", 144 | "id": "e6ea6eb067b31ce47ed384f564cfe4d9d0783c55", 145 | "legs": 146 | [ 147 | { 148 | "public": true, 149 | "origin": 150 | { 151 | "type": "station", 152 | "id": "8000152", 153 | "name": "Hannover Hbf" 154 | }, 155 | "departurePlatform": "10", 156 | "departure": null, 157 | "plannedDeparture": "2022-12-24T23:30:00.000Z", 158 | "departureDelay": null, 159 | "lines": 160 | [ 161 | { 162 | "type": "line", 163 | "id": "ic-60471", 164 | "name": "IC 60471", 165 | "fahrtNr": null 166 | } 167 | ], 168 | "destination": 169 | { 170 | "type": "station", 171 | "id": "8000150", 172 | "name": "Hanau Hbf" 173 | }, 174 | "arrivalPlatform": "6", 175 | "arrival": null, 176 | "plannedArrival": "2022-12-25T03:15:00.000Z", 177 | "arrivalDelay": null 178 | }, 179 | { 180 | "public": true, 181 | "origin": 182 | { 183 | "type": "station", 184 | "id": "8000150", 185 | "name": "Hanau Hbf" 186 | }, 187 | "departurePlatform": "103", 188 | "departure": null, 189 | "plannedDeparture": "2022-12-25T05:10:00.000Z", 190 | "departureDelay": null, 191 | "lines": 192 | [ 193 | { 194 | "type": "line", 195 | "id": "ice-521", 196 | "name": "ICE 521", 197 | "fahrtNr": null 198 | } 199 | ], 200 | "destination": 201 | { 202 | "type": "station", 203 | "id": "8098263", 204 | "name": "München Hbf" 205 | }, 206 | "arrivalPlatform": "23", 207 | "arrival": null, 208 | "plannedArrival": "2022-12-25T08:06:00.000Z", 209 | "arrivalDelay": null 210 | } 211 | ], 212 | "price": 213 | { 214 | "amount": null, 215 | "currency": null 216 | }, 217 | "discount": 218 | { 219 | "amount": 43.9, 220 | "currency": "EUR" 221 | } 222 | }, 223 | "nextStep": "https://reiseauskunft.bahn.de/bin/query.exe/dn?ld=4398&country=DEU&protocol=https%3A&seqnr=1&ident=ih.01924798.1670715863&rt=1&rememberSortType=minDeparture&sTID=C0-2.2%401830&oCID=C0-2&showAvail=yes&completeFulfillment=1&HWAI=CONNECTION%24C1-0!id%3DC1-0!HwaiConId%3DC1-0!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-1!id%3DC1-1!HwaiConId%3DC1-1!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-2!id%3DC1-2!HwaiConId%3DC1-2!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-3!id%3DC1-3!HwaiConId%3DC1-3!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo%3BCONNECTION%24C1-4!id%3DC1-4!HwaiConId%3DC1-4!HwaiDetailStatus%3Ddetails!HwaiMoreDetailStatus%3DstInfo" 224 | } 225 | ] 226 | -------------------------------------------------------------------------------- /test/hafas-düsseldorf-hanau.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "journey", 3 | "legs": [ 4 | { 5 | "origin": { 6 | "type": "stop", 7 | "id": "8000085", 8 | "name": "Düsseldorf Hbf", 9 | "location": { 10 | "type": "location", 11 | "id": "8000085", 12 | "latitude": 51.219708, 13 | "longitude": 6.794011 14 | }, 15 | "products": { 16 | "nationalExpress": true, 17 | "national": true, 18 | "regionalExp": true, 19 | "regional": true, 20 | "suburban": true, 21 | "bus": true, 22 | "ferry": false, 23 | "subway": true, 24 | "tram": true, 25 | "taxi": false 26 | } 27 | }, 28 | "destination": { 29 | "type": "stop", 30 | "id": "8070003", 31 | "name": "Frankfurt(M) Flughafen Fernbf", 32 | "location": { 33 | "type": "location", 34 | "id": "8070003", 35 | "latitude": 50.052926, 36 | "longitude": 8.569776 37 | }, 38 | "products": { 39 | "nationalExpress": true, 40 | "national": true, 41 | "regionalExp": true, 42 | "regional": true, 43 | "suburban": true, 44 | "bus": false, 45 | "ferry": false, 46 | "subway": false, 47 | "tram": false, 48 | "taxi": false 49 | } 50 | }, 51 | "departure": "2022-06-01T15:19:00+02:00", 52 | "plannedDeparture": "2022-06-01T15:11:00+02:00", 53 | "departureDelay": 480, 54 | "arrival": "2022-06-01T16:33:00+02:00", 55 | "plannedArrival": "2022-06-01T16:33:00+02:00", 56 | "arrivalDelay": 0, 57 | "reachable": true, 58 | "tripId": "1|208431|0|80|1062022", 59 | "line": { 60 | "type": "line", 61 | "id": "ice-723", 62 | "fahrtNr": "723", 63 | "name": "ICE 723", 64 | "public": true, 65 | "adminCode": "80____", 66 | "productName": "ICE", 67 | "mode": "train", 68 | "product": "nationalExpress", 69 | "operator": { 70 | "type": "operator", 71 | "id": "db-fernverkehr-ag", 72 | "name": "DB Fernverkehr AG" 73 | } 74 | }, 75 | "direction": "München Hbf", 76 | "currentLocation": { 77 | "type": "location", 78 | "latitude": 51.435998, 79 | "longitude": 6.781273 80 | }, 81 | "arrivalPlatform": "Fern 4", 82 | "plannedArrivalPlatform": "Fern 4", 83 | "departurePlatform": "16", 84 | "plannedDeparturePlatform": "16", 85 | "loadFactor": "low-to-medium" 86 | }, 87 | { 88 | "origin": { 89 | "type": "stop", 90 | "id": "8070003", 91 | "name": "Frankfurt(M) Flughafen Fernbf", 92 | "location": { 93 | "type": "location", 94 | "id": "8070003", 95 | "latitude": 50.052926, 96 | "longitude": 8.569776 97 | }, 98 | "products": { 99 | "nationalExpress": true, 100 | "national": true, 101 | "regionalExp": true, 102 | "regional": true, 103 | "suburban": true, 104 | "bus": false, 105 | "ferry": false, 106 | "subway": false, 107 | "tram": false, 108 | "taxi": false 109 | } 110 | }, 111 | "destination": { 112 | "type": "stop", 113 | "id": "8002041", 114 | "name": "Frankfurt(Main)Süd", 115 | "location": { 116 | "type": "location", 117 | "id": "8002041", 118 | "latitude": 50.099302, 119 | "longitude": 8.686187 120 | }, 121 | "products": { 122 | "nationalExpress": true, 123 | "national": true, 124 | "regionalExp": true, 125 | "regional": true, 126 | "suburban": true, 127 | "bus": true, 128 | "ferry": false, 129 | "subway": true, 130 | "tram": true, 131 | "taxi": false 132 | } 133 | }, 134 | "departure": "2022-06-01T16:46:00+02:00", 135 | "plannedDeparture": "2022-06-01T16:46:00+02:00", 136 | "departureDelay": 0, 137 | "arrival": "2022-06-01T17:00:00+02:00", 138 | "plannedArrival": "2022-06-01T17:00:00+02:00", 139 | "arrivalDelay": 0, 140 | "reachable": true, 141 | "tripId": "1|263903|0|80|1062022", 142 | "line": { 143 | "type": "line", 144 | "id": "hlb-rb58", 145 | "fahrtNr": "28647", 146 | "name": "HLB RB58", 147 | "public": true, 148 | "adminCode": "K4RB__", 149 | "productName": "HLB", 150 | "mode": "train", 151 | "product": "regional", 152 | "operator": { 153 | "type": "operator", 154 | "id": "hessische-landesbahn", 155 | "name": "Hessische Landesbahn" 156 | }, 157 | "additionalName": "HLB RB58" 158 | }, 159 | "direction": "Aschaffenburg Hbf", 160 | "arrivalPlatform": "8", 161 | "plannedArrivalPlatform": "8", 162 | "departurePlatform": "Fern 4", 163 | "plannedDeparturePlatform": "Fern 4" 164 | }, 165 | { 166 | "origin": { 167 | "type": "stop", 168 | "id": "8002041", 169 | "name": "Frankfurt(Main)Süd", 170 | "location": { 171 | "type": "location", 172 | "id": "8002041", 173 | "latitude": 50.099302, 174 | "longitude": 8.686187 175 | }, 176 | "products": { 177 | "nationalExpress": true, 178 | "national": true, 179 | "regionalExp": true, 180 | "regional": true, 181 | "suburban": true, 182 | "bus": true, 183 | "ferry": false, 184 | "subway": true, 185 | "tram": true, 186 | "taxi": false 187 | } 188 | }, 189 | "destination": { 190 | "type": "stop", 191 | "id": "8000150", 192 | "name": "Hanau Hbf", 193 | "location": { 194 | "type": "location", 195 | "id": "8000150", 196 | "latitude": 50.120903, 197 | "longitude": 8.92921 198 | }, 199 | "products": { 200 | "nationalExpress": true, 201 | "national": true, 202 | "regionalExp": true, 203 | "regional": true, 204 | "suburban": true, 205 | "bus": true, 206 | "ferry": false, 207 | "subway": false, 208 | "tram": false, 209 | "taxi": true 210 | } 211 | }, 212 | "departure": "2022-06-01T17:09:00+02:00", 213 | "plannedDeparture": "2022-06-01T17:09:00+02:00", 214 | "departureDelay": null, 215 | "arrival": "2022-06-01T17:21:00+02:00", 216 | "plannedArrival": "2022-06-01T17:21:00+02:00", 217 | "arrivalDelay": null, 218 | "reachable": true, 219 | "tripId": "1|237232|0|80|1062022", 220 | "line": { 221 | "type": "line", 222 | "id": "rb-51", 223 | "fahrtNr": "15556", 224 | "name": "RB 51", 225 | "public": true, 226 | "adminCode": "8005KG", 227 | "productName": "RB", 228 | "mode": "train", 229 | "product": "regional", 230 | "operator": { 231 | "type": "operator", 232 | "id": "db-regio-ag-mitte", 233 | "name": "DB Regio AG Mitte" 234 | }, 235 | "additionalName": "RB 51" 236 | }, 237 | "direction": "Bad Soden-Salmünster", 238 | "arrivalPlatform": "7", 239 | "plannedArrivalPlatform": "7", 240 | "departurePlatform": "9", 241 | "plannedDeparturePlatform": "9" 242 | } 243 | ], 244 | "refreshToken": "¶HKI¶T$A=1@O=Düsseldorf Hbf@L=8000085@a=128@$A=1@O=Frankfurt(M) Flughafen Fernbf@L=8070003@a=128@$202206011511$202206011633$ICE 723$$1$$$$§T$A=1@O=Frankfurt(M) Flughafen Fernbf@L=8070003@a=128@$A=1@O=Frankfurt(Main)Süd@L=8002041@a=128@$202206011646$202206011700$HLB28647$$1$$$$§T$A=1@O=Frankfurt(Main)Süd@L=8002041@a=128@$A=1@O=Hanau Hbf@L=8000150@a=128@$202206011709$202206011721$RB 15556$$1$$$$", 245 | "remarks": [], 246 | "price": { 247 | "amount": 82.6, 248 | "currency": "EUR", 249 | "hint": null 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /test/hafas-hannover-münchen.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "journey", 3 | "id": "21", 4 | "origin": { 5 | "type": "station", 6 | "id": "8000152", 7 | "name": "Hannover Hbf" 8 | }, 9 | "destination": { 10 | "type": "station", 11 | "id": "8000261", 12 | "name": "München Hbf" 13 | }, 14 | "legs": [ 15 | { 16 | "origin": { 17 | "type": "station", 18 | "id": "8000152", 19 | "name": "Hannover Hbf" 20 | }, 21 | "departure": "2022-12-24T21:36:00.000Z", 22 | "departurePlatform": "3", 23 | "destination": { 24 | "type": "station", 25 | "id": "8000128", 26 | "name": "Göttingen" 27 | }, 28 | "arrival": "2022-12-24T22:49:00.000Z", 29 | "arrivalPlatform": "6", 30 | "line": { 31 | "type": "line", 32 | "id": "me-82837", 33 | "name": "ME 82837", 34 | "mode": null, 35 | "product": "ME" 36 | }, 37 | "product": "ME" 38 | }, 39 | { 40 | "origin": { 41 | "type": "station", 42 | "id": "8000128", 43 | "name": "Göttingen" 44 | }, 45 | "departure": "2022-12-25T00:13:00.000Z", 46 | "departurePlatform": "6", 47 | "destination": { 48 | "type": "station", 49 | "id": "8000261", 50 | "name": "München Hbf" 51 | }, 52 | "arrival": "2022-12-25T05:04:00.000Z", 53 | "arrivalPlatform": "20", 54 | "line": { 55 | "type": "line", 56 | "id": "ice-1689", 57 | "name": "ICE 1689", 58 | "mode": "train", 59 | "product": "ICE" 60 | }, 61 | "product": "ICE" 62 | } 63 | ], 64 | "price": { 65 | "currency": "EUR", 66 | "amount": 44.9, 67 | "discount": true, 68 | "name": "Super Sparpreis", 69 | "description": "You can use all trains indicated on your ticket. You can use any local train (i.e. RE, RB, S). Passengers on train services with mandatory reservation must reserve a seat. A 3-D Secure Code may be required for credit card payments.
Cancellation (exchange or refund) of your ticket is excluded.", 70 | "anyTrain": false 71 | }, 72 | "nightTrain": false, 73 | "formattedPrice": { 74 | "euros": "44", 75 | "cents": "90" 76 | }, 77 | "duration": 26880000, 78 | "cheapest": false 79 | } 80 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tape') 4 | const createHafas = require('db-hafas') 5 | const {join} = require('path') 6 | const {readFileSync} = require('fs') 7 | const cheerio = require('cheerio') 8 | 9 | const request = require('../lib/request') 10 | const parse = require('../lib/parse') 11 | const link = require('..') 12 | const when = require('./when') 13 | 14 | const düsseldorfHanauOutbound = require('./hafas-düsseldorf-hanau.json') 15 | const düsseldorfHanauHTML = readFileSync(join(__dirname, 'results-düsseldorf-hanau.html'), {encoding: 'utf8'}) 16 | const düsseldorfHanauExpected = require('./expected-düsseldorf-hanau.json') 17 | const hannoverMünchenOutbound = require('./hafas-hannover-münchen.json') 18 | const hannoverMünchenHTML = readFileSync(join(__dirname, 'results-hannover-münchen.html'), {encoding: 'utf8'}) 19 | const hannoverMünchenExpected = require('./expected-hannover-münchen.json') 20 | 21 | const berlin = '8011160' 22 | const hamburg = '8002549' 23 | const passau = '8000298' 24 | 25 | const hafas = createHafas('generate-db-shop-urls test') 26 | 27 | const isBookingPage = async (url) => { 28 | const {data} = await request(url, null, null) 29 | const $ = cheerio.load(data) 30 | const nextButton = $('.booking a[href]').get(0) 31 | const availContinueButton = $('#availContinueButton').get(0) 32 | // this is a really really brittle way to tell if the link generation 33 | // worked, hence if we're on the right page. 34 | // todo: find a more robust way, compare prices 35 | return !!(nextButton || availContinueButton) 36 | } 37 | 38 | test('parsing works Düsseldorf Hbf -> Hanau Hbf', (t) => { 39 | const res = parse(düsseldorfHanauOutbound, null, false)(düsseldorfHanauHTML) 40 | t.deepEqual(res, düsseldorfHanauExpected) 41 | t.end() 42 | }) 43 | 44 | test('parsing works Hannover Hbf -> Göttingen -> München Hbf', (t) => { 45 | const res = parse(hannoverMünchenOutbound, null, false)(hannoverMünchenHTML) 46 | t.deepEqual(res, hannoverMünchenExpected) 47 | t.end() 48 | }) 49 | 50 | test('works Berlin Hbf -> Hamburg Hbf', {timeout: 10000}, async (t) => { 51 | const outbound = await hafas.journeys(berlin, hamburg, { 52 | departure: when.outbound, results: 1 53 | }) 54 | const res = await link(outbound.journeys[0]) 55 | t.ok(await isBookingPage(res), 'res is not a booking page link') 56 | }) 57 | 58 | test('works Berlin Hbf -> Hamburg Hbf and back', {timeout: 10000}, async (t) => { 59 | const [outbound, returning] = await Promise.all([ 60 | hafas.journeys(berlin, hamburg, { 61 | departure: when.outbound, results: 1 62 | }), 63 | hafas.journeys(hamburg, berlin, { 64 | departure: when.returning, results: 1 65 | }) 66 | ]) 67 | const res = await link(outbound.journeys[0], { 68 | returning: returning.journeys[0], 69 | }) 70 | t.ok(await isBookingPage(res), 'res is not a booking page link') 71 | }) 72 | 73 | test('works Berlin Hbf -> Passau', {timeout: 10000}, async (t) => { 74 | const outbound = await hafas.journeys(berlin, passau, { 75 | departure: when.outbound, results: 1 76 | }) 77 | const res = await link(outbound.journeys[0]) 78 | t.ok(await isBookingPage(res), 'res is not a booking page link') 79 | }) 80 | -------------------------------------------------------------------------------- /test/results-hannover-münchen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Deutsche Bahn: bahn.de - Verbindungen - Ihre Auskunft 5 | 6 | 7 | 8 | 9 | 10 | 91 | 92 | 93 | 94 | 95 | 98 | 119 | 123 | 141 | 142 | 143 | 147 | 148 | 149 | 150 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 171 | 657 | 662 | 663 | 664 | 676 | 677 | 678 |
679 |
680 | 688 |
689 |
690 |
691 | 692 | 700 |
701 |
    702 |
  • Suche
  • 703 |
  • Auswahl
  • 704 |
  • Ticket & Reservierung
  • 705 |
  • Zahlung
  • 706 |
  • Prüfen & Buchen
  • 707 |
  • Bestätigung
  • 708 |
709 |
710 |
711 | 714 |
715 |
716 | 717 | 718 | 719 | 720 |

Hinfahrt

721 |
722 | 723 |
724 |
725 | 726 | 727 | 728 | 729 | 730 |
731 | 745 | 751 |
752 |
753 | 754 | 757 | 758 |
759 | 760 |
761 |
762 | 763 | 764 | 765 | 766 | 767 |
768 | 782 |
783 |
784 |
785 |
786 |
787 |
788 | 789 | 790 | 791 |
792 |
793 |
794 |
795 | 800 | 801 | 822 |
823 |
824 |
825 |
826 |
827 |
828 | 829 | 830 | 831 |
832 |
833 |
834 |
835 | 836 |
837 | Angaben zur Preisberechnung 838 |
839 |
840 |
841 |
842 | 843 | 861 |
862 |
863 | 864 |
865 | Für Gruppen ab 6 Reisenden bieten wir besondere Gruppen-Sparangebote 866 |
867 |
868 |
869 |
870 |
871 |
872 | TEST ~ TEST ~ TEST 873 | 874 |
875 |
876 |
877 | 878 | 884 |
885 |
886 |
887 |
888 |
889 |
890 | 891 | 907 |
908 | 909 |
910 |
911 |
912 |
913 |
914 |
915 |
Reisende zwischen 27 und 64 Jahren können von den altersunabhängigen Super Sparpreis- und Sparpreis-Angeboten profitieren.
916 |
917 |
918 |
919 |
920 |
921 | TEST ~ TEST ~ TEST 922 | 923 |
924 |
925 |
926 | 927 | 935 |
936 |
937 |
938 |
939 |
940 |
941 | 942 | 956 |
957 | 958 |
959 |
960 |
961 |
962 |
963 |
964 |
965 |
966 |
967 |
968 |
969 |
970 | TEST ~ TEST ~ TEST 971 | 972 |
973 |
974 |
975 | 976 | 984 |
985 |
986 |
987 |
988 |
989 |
990 | 991 | 1005 |
1006 | 1007 |
1008 |
1009 |
1010 |
1011 |
1012 |
1013 |
1014 |
1015 |
1016 |
1017 |
1018 |
1019 | TEST ~ TEST ~ TEST 1020 | 1021 |
1022 |
1023 |
1024 | 1025 | 1033 |
1034 |
1035 |
1036 |
1037 |
1038 |
1039 | 1040 | 1054 |
1055 | 1056 |
1057 |
1058 |
1059 |
1060 |
1061 |
1062 |
1063 |
1064 |
1065 |
1066 |
1067 |
1068 | TEST ~ TEST ~ TEST 1069 | 1070 |
1071 |
1072 |
1073 | 1074 | 1082 |
1083 |
1084 |
1085 |
1086 |
1087 |
1088 | 1089 | 1103 |
1104 | 1105 |
1106 |
1107 |
1108 |
1109 |
1110 |
1111 |
1112 |
1113 |
1114 |
1115 | 1118 | 1119 |
1120 | Bei Fahrten mit Fernverkehrsanteil können bis zu 4 Kinder von 6 bis 14 Jahren in Begleitung von Reisenden ab 15 Jahren kostenfrei reisen, müssen aber bei der Buchung angegeben werden.

Kinder, die nicht in Begleitung von Reisenden ab 15 Jahren reisen, erhalten in der Regel 50% Rabatt.

Bei Reisen ausschließlich im Nahverkehr gilt in der Regel eine kostenfreie Mitnahme für bis zu 3 Kinder. Bei Landes- und Verbundtarifen kann es Abweichungen von dieser Regelung geben.

Samstags und sonntags bietet die DB in ausgewählten Zügen Kinderbetreuung an.

Bei Aktionsangeboten kann es Abweichungen von diesen Regelungen geben. 1121 |

1122 | Mehr Informationen 1123 |
1124 |
1125 | 1126 | Kinder von 0-5 Jahren reisen immer kostenfrei und ohne eigene Fahrkarte.

Bitte geben Sie mitreisende Kleinkinder hier an, wenn für diese ein eigener Sitzplatz reserviert werden soll.

Die kostenfreie Reservierung von Fahrradstellplätzen für Kleinkinder ist derzeit online nicht möglich. 1127 |
1128 | 1129 | 1130 |

1131 | Mehr Informationen 1132 |
1133 |
1134 | Profitieren Sie vom BahnCard Rabatt bei jedem Fahrkartenkauf. 1135 |

1136 | Mehr Informationen 1137 |
1138 |
1139 | Reisende zwischen 15 und 26 Jahren können von speziellen Angeboten profitieren.

Die Angebote gelten nur für Reisende unter 27 Jahren. Ausschlaggebend ist das Alter am ersten Reisetag. 1140 |
1141 |
1142 | Reisende zwischen 27 und 64 Jahren können von den altersunabhängigen Super Sparpreis- und Sparpreis-Angeboten profitieren. 1143 |
1144 |
1145 | Reisende ab 65 Jahren können von speziellen Angeboten profitieren.

Die Angebote gelten nur für Reisende ab 65 Jahren. Ausschlaggebend ist das Alter am ersten Reisetag. 1146 |
1147 |
1148 |
1149 | 1161 |
1162 |
1163 |
1164 |
1165 | 1166 | 1167 | 1168 | 1169 |
1170 |
1171 |
1172 | HinweisEine Mischung der Ermäßigungsarten und ist nicht möglich. 1173 |
1174 |
1175 |
1176 | 1177 |

1178 | 1179 |

1180 |
1181 |
1182 |
1183 |
1184 | 1191 |
1192 |
1193 | Hannover Hbf 1194 |
1195 |
1196 | München Hbf 1197 |
1198 |
1199 | ab: 1200 | 22:36 1201 |
1202 | 1203 |
1204 |
1205 | 1 reisende Person 27-64 Jahre, 2. Klasse 1206 |
1207 |
1208 | 1209 | Angaben ändern 1210 | 1211 |
1212 |
1213 | 1 reisende Person 27-64 Jahre, 2. Klasse 1214 |
1215 |
1216 |
HinweisEine Mischung der Ermäßigungsarten und ist nicht möglich.
1217 |
1218 |
1219 | 1220 | Angaben ändern 1221 | 1222 |
1223 |
1224 |
1225 | 1237 |
1238 |
1239 |
1240 |
1241 | Tagesbestpreis
1242 | 1245 |

Hinfahrt am 24.12.22

1246 |
1247 |
1248 |
1249 | 1250 |
1251 |
1252 |
1253 |
1254 |
1255 |
1256 |
Früher
1357 | 1379 |
1380 |
1381 |
1382 |
1383 |
1384 |
1385 |
1386 | 1387 | 22:36 1388 | 1389 | 1390 | 06:04 1391 |
1392 |
1393 |
+ 1Tag
|7h 28min
,
1394 | 1 Umstiege 1395 |
1396 |
1397 |
1398 |
1399 |
1400 |
1401 | 1402 | 1403 | 1404 | 1405 |
1406 |
1407 |
1408 |
Mittlere Auslastung erwartet
1409 |
ME RE2ME
1410 |
ICE 1689ICE
1411 | 1412 |
1413 |
1414 |
1415 | Hannover Hbf 1416 |
1417 |
1418 | München Hbf 1419 |
1420 |
1421 |
1422 | Details verbergen 1423 |
1424 |
1425 |
1426 |
1427 |
ab 44,90 €
1428 | 1429 |
1430 |
1431 |
1432 | Rückfahrt hinzufügen 1433 |
1434 |
1435 |
1436 |
1437 |
    1438 |
  • 1439 |
    1440 |
    1441 | 22:361h 13min
    1442 |
    1443 |
    1444 | Hannover Hbf 1445 |
    1446 |
    1447 | 1448 | 1449 | ME RE2 (82837) 1450 | 1451 |
    1452 |
    Göttingen
    1453 |
    1454 |
    1455 |
    1456 |
    1457 | Gl. 3 1458 |
    1459 |
    1460 |
    1461 | 1464 |
  • 1465 |
  • 1466 |
    1467 |
    1468 | 23:49
    1469 |
    1470 |
    1471 |
    1472 |
    1473 | Göttingen 1474 |
    1475 |
    1476 |
    1477 |
    Gl. 6
    1478 |
    1479 |
  • 1480 |
  • 1481 |
    1482 |
    1:24 h
    1483 |
    1484 | Umsteigezeit anpassen 1485 | 1486 |
    1487 |
    1488 |
  • 1489 |
  • 1490 |
    1491 | 1492 | So, 25.12.22 1493 | 1494 |
  • 1495 |
  • 1496 |
    1497 |
    1498 | 01:134h 51min
    1499 |
    1500 |
    1501 | Göttingen 1502 |
    1503 |
    1504 | 1505 | 1506 | ICE 1689 1507 | 1508 |
    1509 |
    München Hbf
    1510 |
    Mittlere Auslastung erwartet
    1511 |
    1512 |
    1513 |
    1514 |
    1515 | Gl. 6 1516 |
    1517 |
    1518 |
    1519 | 1522 |
  • 1523 |
  • 1524 |
    1525 |
    1526 | 06:04
    1527 |
    1528 |
    1529 |
    1530 |
    1531 | München Hbf 1532 |
    1533 |
    1534 |
    1535 |
    Gl. 20
    1536 |
    1537 |
  • 1538 |
1539 |
1540 | 1545 |
1546 |
1547 |

Hinweise

1548 |
1549 |
Wir erwarten im Verlauf Ihrer Reise eine mittlere Auslastung.
Reservieren Sie bereits jetzt Ihren Wunschplatz.
1550 | nicht täglich,
Verkehrstage 1551 |
1552 | Hinweis: Längerer Aufenthalt  1553 |
Informationen zur Ausstattung des Bahnhofs und deren Betriebsfähigkeit finden Sie unter www.bahnhof.de
1554 |
1555 |
1556 | 1557 | 1563 |
1564 |
1565 | 1566 | 1567 | 1568 |
1569 |
1570 |
1571 |
1572 |
1573 |
1574 |
1575 |
1576 |
1577 | 1578 | 1584 |
1585 |
1586 | 1587 | 1588 | 1589 |
1590 |
1591 |
1592 |
1593 |
1594 |
1595 | 1605 | 1671 | 1686 |
1687 |
1688 | Sonntag, 25.12.22 1689 |
1690 |
1691 |
1692 |
1693 |
1694 |
1695 |
1696 |
1697 | 1698 | 00:03 1699 | 1700 | 1701 | 06:04 1702 |
1703 |
1704 |
|6h 01min
,
1705 | 0 Umstiege 1706 |
1707 |
1708 |
1709 |
1710 |
1711 |
1712 | 1713 | 1714 | 1715 | 1716 |
1717 |
1718 |
1719 |
Mittlere Auslastung erwartet
1720 |
ICE 1689ICE
1721 | 1722 |
1723 |
1724 |
1725 | Hannover Hbf 1726 |
1727 |
1728 | München Hbf 1729 |
1730 |
1731 |
1732 | Details verbergen 1733 |
1734 |
1735 |
1736 |
1737 |
ab 35,90 €
1738 | 1739 |
1740 |
1741 |
1742 | Rückfahrt hinzufügen 1743 |
1744 |
1745 |
1746 |
1747 |
    1748 |
  • 1749 |
    1750 |
    1751 | 00:036h 1min
    1752 |
    1753 |
    1754 | Hannover Hbf 1755 |
    1756 |
    1757 | 1758 | 1759 | ICE 1689 1760 | 1761 |
    1762 |
    München Hbf
    1763 |
    Mittlere Auslastung erwartet
    1764 |
    1765 |
    1766 |
    1767 |
    1768 | Gl. 7 1769 |
    1770 |
    1771 |
    1772 | 1775 |
  • 1776 |
  • 1777 |
    1778 |
    1779 | 06:04
    1780 |
    1781 |
    1782 |
    1783 |
    1784 | München Hbf 1785 |
    1786 |
    1787 |
    1788 |
    Gl. 20
    1789 |
    1790 |
  • 1791 |
1792 |
1793 | 1798 |
1799 |
1800 |

Hinweise

1801 |
1802 |
Wir erwarten im Verlauf Ihrer Reise eine mittlere Auslastung.
Reservieren Sie bereits jetzt Ihren Wunschplatz.
1803 | nicht täglich,
Verkehrstage 1804 |
Informationen zur Ausstattung des Bahnhofs und deren Betriebsfähigkeit finden Sie unter www.bahnhof.de
1805 |
1806 |
1807 | 1808 | 1814 |
1815 |
1816 | 1817 | 1818 | 1819 |
1820 |
1821 |
1822 |
1823 |
1824 |
1825 |
1826 |
1827 |
1828 | 1829 | 1835 |
1836 |
1837 | 1838 | 1839 | 1840 |
1841 |
1842 |
1843 |
1844 |
1845 |
1846 | 1856 | 1957 | 1979 |
1980 |
1981 |
1982 |
1983 |
1984 |
1985 |
1986 | 1987 | 00:30 1988 | 1989 | 1990 | 09:06 1991 |
1992 |
1993 |
|8h 36min
,
1994 | 1 Umstiege 1995 |
1996 |
1997 |
1998 |
1999 |
2000 |
2001 | 2002 | 2003 | 2004 | 2005 |
2006 |
2007 |
2008 |
Geringe Auslastung erwartet
2009 |
IC 60471IC
2010 |
ICE 521ICE
2011 | 2012 |
2013 |
2014 |
2015 | Hannover Hbf 2016 |
2017 |
2018 | München Hbf 2019 |
2020 |
2021 |
2022 | Details verbergen 2023 |
2024 |
2025 |
2026 |
2027 |
ab 43,90 €
2028 | 2029 |
2030 |
2031 |
2032 | Rückfahrt hinzufügen 2033 |
2034 |
2035 |
2036 |
2037 |
    2038 |
  • 2039 |
    2040 |
    2041 | 00:303h 45min
    2042 |
    2043 |
    2044 | Hannover Hbf 2045 |
    2046 |
    2047 | 2048 | 2049 | IC 60471 2050 | 2051 |
    2052 |
    Zürich HB
    2053 |
    2054 |
    2055 |
    2056 |
    2057 | Gl. 10 2058 |
    2059 |
    2060 |
    2061 | 2064 |
  • 2065 |
  • 2066 |
    2067 |
    2068 | 04:15
    2069 |
    2070 |
    2071 |
    2072 |
    2073 | Hanau Hbf 2074 |
    2075 |
    2076 |
    2077 |
    Gl. 6
    2078 |
    2079 |
  • 2080 |
  • 2081 |
    2082 |
    1:55 h
    2083 |
    2084 | Umsteigezeit anpassen 2085 | 2086 |
    2087 |
    2088 |
  • 2089 |
  • 2090 |
    2091 |
    2092 | 06:102h 56min
    2093 |
    2094 |
    2095 | Hanau Hbf 2096 |
    2097 |
    2098 | 2099 | 2100 | ICE 521 2101 | 2102 |
    2103 |
    München Hbf
    2104 |
    Geringe Auslastung erwartet
    2105 |
    2106 |
    2107 |
    2108 |
    2109 | Gl. 103 2110 |
    2111 |
    2112 |
    2113 | 2116 |
  • 2117 |
  • 2118 |
    2119 |
    2120 | 09:06
    2121 |
    2122 |
    2123 |
    2124 |
    2125 | München Hbf 2126 |
    2127 |
    2128 |
    2129 |
    Gl. 23
    2130 |
    2131 |
  • 2132 |
2133 |
2134 | 2139 |
2140 |
2141 |

Hinweise

2142 |
2143 |
2144 | nicht täglich,
Verkehrstage 2145 |
2146 | Hinweis: Längerer Aufenthalt  2147 |
Informationen zur Ausstattung des Bahnhofs und deren Betriebsfähigkeit finden Sie unter www.bahnhof.de
2148 |
2149 |
2150 | 2151 | 2157 |
2158 |
2159 | 2160 | 2161 | 2162 |
2163 |
2164 |
2165 |
2166 |
2167 |
2168 |
2169 |
2170 |
2171 | 2172 | 2178 |
2179 |
2180 | 2181 | 2182 | 2183 |
2184 |
2185 |
2186 |
2187 |
2188 |
2189 | 2199 |
2200 |
2201 | Schließen 2202 |
2203 |
2204 |
2205 |
2206 |
2207 | 2213 |
2214 |
2215 | 2288 |
2289 |

Weitere Angebote rund um Ihre Reise

2290 |
2291 | 2308 |
2309 |
2310 | 2325 |
2326 |
2327 |
2328 | Anzeige 2329 |
2330 | 2347 |
2348 |
2349 | 2364 |
2365 |
2366 |
2367 |
2368 |
2369 |

Symbolerklärung

2370 | 2386 | 2424 |
2425 |
Mittlere Auslastung erwartet
2426 |
2427 |
Geringe Auslastung erwartet
2428 |
2429 |
Günstigster, an dieser Stelle ermittelbarer Preis
2430 |
1
2431 |
Zeigt die günstigsten, an dieser Stelle ermittelbaren Preise für die angefragte Verbindung am gewählten Tag. Für Verbindungen, die wir verkaufen und für die hier keine Preise angezeigt werden können (z.B. bestimmte Verbundtarife), sind diese im nächsten Schritt ermittelbar und können günstiger sein.
2432 |
2433 |
2434 |

Hinweise

2435 |
Alle Angaben ohne Gewähr.
Weitere Angebote finden Sie in unserem
Fahrkartenshop
2436 | Aufgrund fehlender Echtzeit-Daten einiger Drittanbieter können sich unter Umständen Abweichungen in der Verbindung ergeben 2437 |
2438 |
2439 |
2440 |
2441 |

Besuchen Sie uns auf

2442 |
2443 |
2444 |
2445 |

Mögliche Zahlungsarten

2446 |
2447 |
2448 | 2455 |
2456 | 2476 |
2477 | 2478 | 2479 | 2480 | 2571 | 2574 | 2578 | 2579 | 2584 | 2591 |
2592 |
2593 |
2594 |
2595 |
2596 | 2597 | 2598 | 2599 |
2600 |
2601 |

Kundenumfrage - Ihre Meinung zählt!

2602 |
2603 | 2604 |

Wir würden uns freuen, wenn Sie an unserer Umfrage teilnehmen und uns dadurch helfen unser Angebot und unseren Service weiter zu verbessern.

2605 |

Die Befragung dauert etwa 5-10 Minuten. Das Ausfüllen des Fragebogens ist freiwillig. Die Auswertung erfolgt selbstverständlich anonym und unter Einhaltung der gesetzlichen Vorschriften des Datenschutzes und der Datenschutzgrundsätze der Deutschen Bahn. Für Ihr Mitwirken bedanken wir uns bereits im Vorfeld.

2606 |

Ihr Team von www.bahn.de

2607 |
2608 |
2609 |
2610 | 2611 | Zur Umfrage 2612 | 2613 |
2614 |
2615 |
2616 | 2617 |
2618 |

Login erforderlich

2619 |
2620 |
2621 | Um Benachrichtigungen zur Reise zu aktivieren, loggen Sie sich bitte über "Meine Bahn" ein. Falls Sie noch kein Benutzerkonto haben, können Sie sich dort auch für bahn.de registrieren. 2622 |
2623 |
2624 | Hinweis: Nach dem Login bzw. der Neuanmeldung muss die Verbindungssuche erneut durchgeführt werden. 2625 |
2626 |
2627 |
2628 | 2629 | 2630 | -------------------------------------------------------------------------------- /test/when.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {DateTime} = require('luxon') 4 | 5 | const createWhen = (days = 0, hours = 0) => { 6 | return DateTime.fromMillis(Date.now(), { 7 | zone: 'Europe/Berlin', 8 | locale: 'de-DE', 9 | }) 10 | .startOf('week') 11 | .plus({weeks: 1, days, hours}) 12 | .toJSDate() 13 | } 14 | 15 | const outbound = createWhen(0, 10) // Monday of next week, 10am 16 | const returning = createWhen(1, 8) // Tuesday of next week, 8am 17 | 18 | module.exports = {outbound, returning} 19 | -------------------------------------------------------------------------------- /todo: -------------------------------------------------------------------------------- 1 | next booking step: type of ticket 2 | is the cookie passing necessary? 3 | ÖBB 4 | --------------------------------------------------------------------------------