├── .dockerignore ├── .editorconfig ├── .github ├── funding.yml └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── api.js ├── contributing.md ├── cspell.config.json ├── docs ├── api.md ├── arrivals.md ├── db-apis.md ├── departures.md ├── dumps │ ├── PCAPdroid_02_Jan_15_23_29_moreloyaltycards.txt │ ├── PCAPdroid_02_Jan_15_29_10_alter.txt │ ├── PCAPdroid_02_Jan_15_29_15_intl.txt │ ├── PCAPdroid_02_Jan_21_59_35_initial_transfer.txt │ ├── PCAPdroid_02_Jan_22_47_29_tickets.txt │ ├── PCAPdroid_02_Jan_22_47_34_tickets.txt │ ├── PCAPdroid_16_Dec_19_30_43_locsearch.txt │ ├── PCAPdroid_16_Dec_19_31_06_locsearch.txt │ ├── PCAPdroid_16_Dec_19_33_04_routesearch.txt │ ├── PCAPdroid_16_Dec_19_33_08_routesearch.txt │ ├── PCAPdroid_16_Dec_19_35_50_triprecon.txt │ ├── PCAPdroid_16_Dec_19_35_55_triprecon.txt │ ├── PCAPdroid_16_Dec_19_36_19_triprecon.txt │ ├── PCAPdroid_16_Dec_19_36_22_triprecon.txt │ ├── PCAPdroid_16_Dec_19_37_19_locationdetails.txt │ ├── PCAPdroid_16_Dec_19_37_22_locationdetails.txt │ ├── PCAPdroid_16_Dec_19_37_26_departures.txt │ ├── PCAPdroid_16_Dec_19_37_34_departures.txt │ ├── PCAPdroid_16_Dec_19_37_57_departures.txt │ ├── PCAPdroid_16_Dec_19_38_00_departures.txt │ ├── PCAPdroid_16_Dec_19_38_44_arrivals.txt │ ├── PCAPdroid_16_Dec_19_38_47_arrivals.txt │ ├── PCAPdroid_16_Dec_19_39_50_locsearch_station.txt │ ├── PCAPdroid_16_Dec_19_39_53_locsearch_station.txt │ ├── PCAPdroid_16_Dec_19_42_53_specialrouting.txt │ ├── PCAPdroid_16_Dec_19_43_07_specialrouting.txt │ ├── PCAPdroid_16_Dec_19_46_29_bahncard.txt │ ├── PCAPdroid_16_Dec_19_46_32_bahncard.txt │ ├── PCAPdroid_16_Dec_19_47_54_zuglauf.txt │ ├── PCAPdroid_16_Dec_19_47_58_zuglauf.txt │ ├── PCAPdroid_16_Dec_19_48_15_coachseq.txt │ ├── PCAPdroid_16_Dec_19_48_25_coachseq.txt │ ├── PCAPdroid_16_Dec_19_49_08_coachseq.txt │ ├── PCAPdroid_16_Dec_20_18_17_reservierung.txt │ ├── PCAPdroid_16_Dec_20_18_20_reservierung.txt │ ├── PCAPdroid_16_Dec_20_36_36_departureswithnotices.txt │ ├── PCAPdroid_16_Dec_20_36_37_departureswithnotices.txt │ ├── PCAPdroid_18_Dec_22_39_50_departures_ondemand.txt │ ├── PCAPdroid_18_Dec_22_42_29_departures_ferry.txt │ ├── PCAPdroid_18_Dec_22_43_54_departures_IR_flixtrain.txt │ ├── PCAPdroid_18_Dec_23_19_54_locsearch_addr.txt │ ├── PCAPdroid_18_Dec_23_19_57_locsearch_addr.txt │ ├── PCAPdroid_18_Dec_23_20_05_routesearch_fromaddr.txt │ ├── PCAPdroid_18_Dec_23_20_08_routesearch_fromaddr.txt │ └── readme.md ├── journeys.md ├── locations.md ├── nearby.md ├── openapi.yaml ├── readme.md ├── refresh-journey.md ├── stop.md ├── tests.md └── trip.md ├── eslint.config.js ├── format ├── address.js ├── coord.js ├── date.js ├── location-identifier.js ├── location.js ├── loyalty-cards.js ├── poi.js ├── products-filter.js ├── station.js ├── time.js ├── transfers.js └── travellers.js ├── index.js ├── lib ├── age-group.js ├── api-parsers.js ├── default-profile.js ├── errors.js ├── luxon-timezones.js ├── products.js ├── request.js └── validate-profile.js ├── p ├── db │ ├── index.js │ └── trip-req.js ├── dbbahnhof │ ├── base.json │ ├── index.js │ └── station-board-req.js ├── dbnav │ ├── base.json │ ├── header.js │ ├── index.js │ ├── journeys-req.js │ ├── location-filter.js │ ├── locations-req.js │ ├── nearby-req.js │ ├── parse-journey.js │ ├── parse-stop.js │ ├── station-board-req.js │ ├── stop-req.js │ └── trip-req.js ├── dbregioguide │ ├── base.json │ ├── index.js │ ├── station-board-req.js │ └── trip-req.js ├── dbris │ ├── base.json │ ├── index.js │ └── station-board-req.js └── dbweb │ ├── base.json │ ├── example.js │ ├── index.js │ ├── journeys-req.js │ ├── location-filter.js │ ├── locations-req.js │ ├── nearby-req.js │ ├── station-board-req.js │ └── trip-req.js ├── package-lock.json ├── package.json ├── parse ├── arrival-or-departure.js ├── arrival.js ├── date-time.js ├── departure.js ├── hints-by-code.js ├── journey-leg.js ├── journey.js ├── line.js ├── load-factor.js ├── location.js ├── operator.js ├── platform.js ├── polyline.js ├── products.js ├── remarks.js ├── stopover.js ├── tickets.js ├── trip.js └── when.js ├── readme.md ├── retry.js ├── test ├── dbbahnhof-departures.js ├── dbnav-departures.js ├── dbnav-refresh-journey.js ├── dbnav-stop.js ├── dbnav-trip.js ├── dbregioguide-trip.js ├── dbris-arrivals.js ├── dbris-departures.js ├── dbweb-departures.js ├── dbweb-journey.js ├── dbweb-refresh-journey.js ├── dbweb-trip.js ├── e2e │ ├── common.js │ ├── db.js │ ├── dbbahnhof.js │ ├── dbnav.js │ ├── dbregioguide.js │ ├── dbweb.js │ ├── fixtures │ │ └── requests_1722637011 │ │ │ └── recording.har │ └── lib │ │ ├── arrivals.js │ │ ├── departures.js │ │ ├── earlier-later-journeys.js │ │ ├── journeys-fails-with-no-product.js │ │ ├── journeys-station-to-address.js │ │ ├── journeys-station-to-poi.js │ │ ├── journeys-station-to-station.js │ │ ├── journeys-walking-speed.js │ │ ├── journeys-with-detour.js │ │ ├── leg-cycle-alternatives.js │ │ ├── lines.js │ │ ├── refresh-journey.js │ │ ├── remarks.js │ │ ├── util.js │ │ ├── validate-fptf-with.js │ │ └── validators.js ├── fixtures │ ├── dbbahnhof-departures.js │ ├── dbbahnhof-departures.json │ ├── dbnav-departures.js │ ├── dbnav-departures.json │ ├── dbnav-refresh-journey.js │ ├── dbnav-refresh-journey.json │ ├── dbnav-stop.js │ ├── dbnav-stop.json │ ├── dbnav-trip.js │ ├── dbnav-trip.json │ ├── dbregioguide-departures.js │ ├── dbregioguide-departures.json │ ├── dbregioguide-trip.js │ ├── dbregioguide-trip.json │ ├── dbris-arrivals.js │ ├── dbris-arrivals.json │ ├── dbris-departures.js │ ├── dbris-departures.json │ ├── dbweb-departures.js │ ├── dbweb-departures.json │ ├── dbweb-journey.js │ ├── dbweb-journey.json │ ├── dbweb-refresh-journey.js │ ├── dbweb-refresh-journey.json │ ├── dbweb-trip.js │ └── dbweb-trip.json ├── format │ ├── db-trip.js │ ├── dbnav-journeys-query.js │ ├── dbweb-arrivals-query.js │ ├── dbweb-journeys-query.js │ └── products-filter.js ├── lib │ └── request.js └── parse │ ├── date-time.js │ ├── dbnav-journey.js │ ├── line.js │ ├── location.js │ ├── operator.js │ ├── remarks.js │ └── when.js ├── throttle.js └── tools └── debug-cli ├── cli.js └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | liberapay: derhuerst 2 | patreon: derhuerst 3 | github: derhuerst 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build NPM Package & Docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | build-and-push-docker: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | attestations: write 20 | id-token: write 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | - name: Build and push Docker image 36 | id: push 37 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | 44 | build-pkg: 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | packages: write 49 | id-token: write 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: actions/setup-node@v4 53 | with: 54 | node-version: '20.x' 55 | registry-url: 'https://registry.npmjs.org' 56 | - run: npm ci 57 | - run: npm publish --provenance --access public 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request, workflow_call] 4 | 5 | env: 6 | npm_config_cache: /tmp/npm-cache 7 | 8 | jobs: 9 | lint-and-spellcheck: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | steps: 13 | - name: "Checkout code" 14 | uses: actions/checkout@v4 15 | - name: "Use Node.js" 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 'lts/*' 19 | - run: npm install 20 | 21 | - name: Run lint check 22 | run: npm run lint 23 | 24 | - name: Run spell check 25 | run: npm run test-spelling 26 | 27 | unit-tests: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | node-version: 32 | - 18.x 33 | - 20.x 34 | - 22.x 35 | steps: 36 | - name: checkout 37 | uses: actions/checkout@v4 38 | - name: setup Node.js v${{ matrix.node-version }} 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | 43 | - id: cache-npm 44 | name: restore npm cache 45 | uses: actions/cache@v4 46 | with: 47 | key: npm-cache-${{ github.ref_name }}-${{ matrix.node-version }}-unit-tests 48 | path: ${{ env.npm_config_cache }} 49 | - run: npm install 50 | 51 | - run: npm run test-unit 52 | 53 | integration-tests: 54 | runs-on: ubuntu-latest 55 | strategy: 56 | matrix: 57 | node-version: 58 | - 18.x 59 | - 20.x 60 | - 22.x 61 | steps: 62 | - name: checkout 63 | uses: actions/checkout@v4 64 | - name: setup Node.js v${{ matrix.node-version }} 65 | uses: actions/setup-node@v4 66 | with: 67 | node-version: ${{ matrix.node-version }} 68 | 69 | - id: cache-npm 70 | name: restore npm cache 71 | uses: actions/cache@v4 72 | with: 73 | key: npm-cache-${{ github.ref_name }}-${{ matrix.node-version }}-integration-tests 74 | path: ${{ env.npm_config_cache }} 75 | - run: npm install 76 | 77 | - run: npm run test-integration 78 | 79 | e2e-tests: 80 | needs: [unit-tests, integration-tests] 81 | runs-on: ubuntu-latest 82 | strategy: 83 | matrix: 84 | node-version: [18.x] 85 | steps: 86 | - name: checkout 87 | uses: actions/checkout@v4 88 | - name: setup Node.js v${{ matrix.node-version }} 89 | uses: actions/setup-node@v4 90 | with: 91 | node-version: ${{ matrix.node-version }} 92 | 93 | - id: cache-npm 94 | name: restore npm cache 95 | uses: actions/cache@v4 96 | with: 97 | key: npm-cache-${{ github.ref_name }}-${{ matrix.node-version }}-e2e-tests 98 | path: ${{ env.npm_config_cache }} 99 | - run: npm install 100 | 101 | - run: npm run test-e2e 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | npm-debug.log 7 | 8 | /.tap 9 | *.ign.* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | LABEL org.opencontainers.image.title="db-vendo-client" 3 | LABEL org.opencontainers.image.description="A clean REST API wrapping around the new Deutsche Bahn API." 4 | LABEL org.opencontainers.image.authors="Traines " 5 | LABEL org.opencontainers.image.documentation="https://github.com/public-transport/db-vendo-client" 6 | LABEL org.opencontainers.image.source="https://github.com/public-transport/db-vendo-client" 7 | LABEL org.opencontainers.image.licenses="ISC" 8 | WORKDIR /app 9 | 10 | # install dependencies 11 | #RUN apk add --update git 12 | ADD package.json package-lock.json /app/ 13 | RUN npm install && npm cache clean --force 14 | 15 | # add source code 16 | ADD . /app 17 | 18 | EXPOSE 3000 19 | 20 | ENV PORT 3000 21 | 22 | CMD ["node", "api.js"] 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # ISC License 2 | 3 | - Copyright © 2024 Jannis R 4 | - Copyright © 2025 traines-source 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 9 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | import {createClient} from './index.js'; 2 | import {profile as dbProfile} from './p/db/index.js'; 3 | import {profile as dbnavProfile} from './p/dbnav/index.js'; 4 | import {profile as dbwebProfile} from './p/dbweb/index.js'; 5 | import {profile as dbrisProfile} from './p/dbris/index.js'; 6 | import {profile as dbbahnhofProfile} from './p/dbbahnhof/index.js'; 7 | import {profile as dbregioguideProfile} from './p/dbregioguide/index.js'; 8 | import {mapRouteParsers} from './lib/api-parsers.js'; 9 | import {createHafasRestApi as createApi} from 'hafas-rest-api'; 10 | 11 | const config = { 12 | hostname: process.env.HOSTNAME || 'localhost', 13 | port: process.env.PORT ? parseInt(process.env.PORT) : 3000, 14 | name: 'db-vendo-client', 15 | description: 'db-vendo-client', 16 | homepage: 'https://github.com/public-transport/db-vendo-client', 17 | version: '6', 18 | docsLink: 'https://github.com/public-transport/db-vendo-client', 19 | openapiSpec: true, 20 | logging: true, 21 | aboutPage: true, 22 | enrichStations: true, 23 | etags: 'strong', 24 | csp: 'default-src \'none\'; style-src \'self\' \'unsafe-inline\'; img-src https:', 25 | mapRouteParsers, 26 | }; 27 | 28 | const profiles = { 29 | db: dbProfile, 30 | dbnav: dbnavProfile, 31 | dbweb: dbwebProfile, 32 | dbris: dbrisProfile, 33 | dbbahnhof: dbbahnhofProfile, 34 | dbregioguide: dbregioguideProfile, 35 | }; 36 | 37 | const start = async () => { 38 | const vendo = createClient( 39 | profiles[process.env.DB_PROFILE] || dbnavProfile, 40 | process.env.USER_AGENT || 'link-to-your-project-or-email', 41 | config, 42 | ); 43 | const api = await createApi(vendo, config); 44 | 45 | api.listen(config.port, (err) => { 46 | if (err) { 47 | console.error(err); 48 | } 49 | }); 50 | }; 51 | 52 | start(); 53 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for helping! 🙏 4 | 5 | ## Adding integration/end-to-end tests 6 | 7 | Refer to the [testing docs](docs/tests.md). 8 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # `db-vendo-client` API 2 | 3 | Also see the [root readme](https://github.com/public-transport/db-vendo-client) for a shortlist of differences of db-vendo-client to hafas-client and of differences between the profiles. 4 | 5 | - [`journeys(from, to, [opt])`](journeys.md) – get journeys between locations 6 | - [`refreshJourney(refreshToken, [opt])`](refresh-journey.md) – fetch up-to-date/more details of a `journey` 7 | - `journeysFromTrip(tripId, previousStopover, to, [opt])` – not supported 8 | - [`trip(id, lineName, [opt])`](trip.md) – get details for a trip 9 | - `tripsByName(lineNameOrFahrtNr, [opt])` – not supported 10 | - [`departures(station, [opt])`](departures.md) – query the next departures at a station 11 | - [`arrivals(station, [opt])`](arrivals.md) – query the next arrivals at a station 12 | - [`locations(query, [opt])`](locations.md) – find stations, POIs and addresses 13 | - [`stop(id, [opt])`](stop.md) – get details about a stop/station 14 | - [`nearby(location, [opt])`](nearby.md) – show stations & POIs around 15 | - `radar(north, west, south, east, [opt])` – not supported 16 | - `reachableFrom(address, [opt])` – not supported 17 | - `remarks([opt])` – not supported 18 | - `lines(query, [opt])` – not supported 19 | - `serverInfo([opt])` – not supported 20 | -------------------------------------------------------------------------------- /docs/arrivals.md: -------------------------------------------------------------------------------- 1 | # `arrivals(station, [opt])` 2 | 3 | Just like [`departures(station, [opt])`](departures.md), except that it resolves with arrival times instead of departure times. 4 | -------------------------------------------------------------------------------- /docs/db-apis.md: -------------------------------------------------------------------------------- 1 | # New DB Board and Route Planning APIs (beyond HAFAS and IRIS) 2 | 3 | (Beware that a DB journey is what you usually call a trip (a vehicle travelling at a certain time) and a DB trip is what you usually call a journey (result of a route search from A to B).) 4 | 5 | ## RIS::Boards 6 | https://apis.deutschebahn.com/db/apis/ris-boards/v1/public/ 7 | 8 | EPs: 9 | * departures/ 10 | * arrivals/ 11 | 12 | Notes: 13 | * docs (also helpful for other RIS-based APIs below): https://developers.deutschebahn.com/db-api-marketplace/apis/product/ris-boards-transporteure/api/ris-boards-transporteure#/RISBoards_151/overview 14 | * needs an API Key 15 | * provides remarks 16 | * does not provide loadFactor 17 | * no route planning 18 | * uses RIS trip IDs 19 | * boards up to 12 hours 20 | 21 | ## bahnhof.de RIS 22 | https://www.bahnhof.de/api/boards/departures?evaNumbers=8000105&filterTransports=BUS&duration=60&locale=de 23 | 24 | Notes: 25 | * no API Key needed 26 | * provides remarks 27 | * uses RIS trip IDs 28 | * no route planning 29 | * boards up to 6 hours, only from current time (or unknown parameter) 30 | 31 | ## Regio Guide RIS 32 | https://regio-guide.de/@prd/zupo-travel-information/api/public/ri/ 33 | 34 | EPs: 35 | * departure/8000105?modeOfTransport=HIGH_SPEED_TRAIN,REGIONAL_TRAIN,CITY_TRAIN,INTER_REGIONAL_TRAIN,UNKNOWN,BUS,TRAM,SUBWAY&timeStart=2024-12-11T15:08:25.678Z&timeEnd=2024-12-12T01:53:25.678&expandTimeFrame=TIME_END&&occupancy=true 36 | * board/arrival/ 37 | * routing-search (with POST body, see regio-guide.de) 38 | * trip/ 39 | * journey/ 40 | 41 | Notes: 42 | * no API Key needed 43 | * no remarks in boards (or with unknown param), only some in journey 44 | * cancelled trips are completely missing from boards (?) 45 | * uses RIS trip IDs, does not expose them directly in the routing-search response 46 | * loadFactor for some regional services, not for long distance services 47 | * boards up to 12 hours 48 | * routing-search returns polylines (!) 49 | 50 | ## Vendo/Movas Navigator API 51 | https://app.vendo.noncd.db.de/mob/ 52 | 53 | EPs: 54 | * bahnhofstafel/abfahrt 55 | * bahnhofstafel/ankunft 56 | * location/search 57 | * angebote/fahrplan (for route planning) 58 | * zuglauf 59 | * zuglaeufe/ICE_947/halte/by-abfahrt/8000207_2024 (coach sequence) 60 | * angebote/recon (tickets) 61 | * trip/recon (polylines) 62 | 63 | Notes: 64 | * see [traffic dumps](dumps/) 65 | * no API Key needed 66 | * used by new DB Navigator 67 | * HAFAS trip IDs 68 | * boards only 1 hour (or unknown param) 69 | * does not contain machine-readable cancelled info in the boards (only "Halt entfällt" string), but contains relevant remarks 70 | * loadFactor only on journeys (?) 71 | * polylines only for zuglauf and trip/recon 72 | * limited remarks on boards 73 | 74 | ## Vendo/Movas bahn.de API 75 | https://int.bahn.de/web/api/ 76 | 77 | EPs: 78 | * angebote/fahrplan (for route planning) 79 | * reiseloesung/orte 80 | * reiseloesung/orte/nearby 81 | * reiseloesung/verbindung 82 | * reiseloesung/fahrt 83 | * reiseloesung/abfahrten?datum=2024-12-30&zeit=11:55:00&ortExtId=8011160&ortId=A%3D1%40O%3DBerlin+Hbf%40X%3D13369549%40Y%3D52525589%40U%3D80%40L%3D8011160%40i%3DU%C3%97008065969%40&mitVias=true&maxVias=8&verkehrsmittel[]=ICE&verkehrsmittel[]=EC_IC&verkehrsmittel[]=IR&verkehrsmittel[]=REGIONAL 84 | * reiseloesung/ankuenfte 85 | 86 | Notes: 87 | * no API Key needed 88 | * uses HAFAS trip IDs 89 | * provides loadFactor 90 | * polylines only for /verbindung and /fahrt -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_02_Jan_15_23_29_moreloyaltycards.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/fahrplan HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: 68f7ceba-70e7-4a88-b9b0-454809655314_3fbcc823-2a69-46f4-9484-6d94b1c0116a 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: 4779d837-2aa8-4613-9904-8f950367c3c0 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 1120 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"autonomeReservierung":false,"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reiseHin":{"wunsch":{"abgangsLocationId":"A\u003d1@O\u003dMünchen Hbf@X\u003d11558339@Y\u003d48140229@U\u003d81@L\u003d8000261@B\u003d1@p\u003d1734722398@i\u003dU×008020347@","verkehrsmittel":["ALL"],"zeitWunsch":{"reiseDatum":"2025-01-02T15:21:31.877957+01:00","zeitPunktArt":"ABFAHRT"},"zielLocationId":"A\u003d1@O\u003dStuttgart Hbf@X\u003d9181636@Y\u003d48784081@U\u003d81@L\u003d8000096@B\u003d1@p\u003d1734722398@i\u003dU×008029034@"}},"reisendenProfil":{"reisende":[{"ermaessigungen":["CH-GENERAL-ABONNEMENT KLASSE_2","CH-GENERAL-ABONNEMENT KLASSE_1","CH-HALBTAXABO_OHNE_RAILPLUS KLASSENLOS","A-VORTEILSCARD KLASSENLOS"],"reisendenTyp":"SENIOR"},{"ermaessigungen":["CH-GENERAL-ABONNEMENT KLASSE_2","CH-GENERAL-ABONNEMENT KLASSE_1","CH-HALBTAXABO_OHNE_RAILPLUS KLASSENLOS","A-VORTEILSCARD KLASSENLOS"],"reisendenTyp":"SENIOR"},{"ermaessigungen":["KLIMATICKET_OE KLASSENLOS","NL-40_OHNE_RAILPLUS KLASSENLOS","BAHNCARD100 KLASSE_1","BAHNCARD100 KLASSE_2"],"reisendenTyp":"ERWACHSENER"}]},"reservierungsKontingenteVorhanden":false} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_02_Jan_15_29_10_alter.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/recon/autonomereservierung HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | X-Correlation-ID: 68f7ceba-70e7-4a88-b9b0-454809655314_3fbcc823-2a69-46f4-9484-6d94b1c0116a 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 7d70a4fc-751f-4893-b2e2-11c925c0538b 10 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 11 | Content-Length: 1872 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reisendenProfil":{"reisende":[{"alter":63,"ermaessigungen":["KLIMATICKET_OE KLASSENLOS","NL-40_OHNE_RAILPLUS KLASSENLOS"],"reisendenTyp":"ERWACHSENER"}]},"reservierungsKontingenteVorhanden":false,"verbindungHin":{"kontext":"¶HKI¶T$A\u003d1@O\u003dMünchen Hbf@X\u003d11558339@Y\u003d48140229@L\u003d8000261@a\u003d128@$A\u003d1@O\u003dStuttgart Hbf@X\u003d9181636@Y\u003d48784081@L\u003d8000096@a\u003d128@$202501021628$202501021832$ICE 912$$1$$$$$$§T$A\u003d1@O\u003dStuttgart Hbf@X\u003d9181636@Y\u003d48784081@L\u003d8000096@a\u003d128@$A\u003d1@O\u003dParis Est@X\u003d2359120@Y\u003d48876976@L\u003d8700011@a\u003d128@$202501021852$202501022214$TGV 9570$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#81#AM2#0#RT#7#¶KCC¶I1ZFIzEjRVJHIzIjSElOIzAjRUNLIzU2NDAyOHw1NjQwMjh8NTY0Mzc0fDU2NDM3NHwwfDB8NTY1fDU2Mzk1OHwxfDB8MjZ8MHwwfC0yMTQ3NDgzNjQ4I0dBTSMyMDEyNTE2MjgjClojVk4jMSNTVCMxNzM0NzIyMzk4I1BJIzEjWkkjMTk3Mzc3I1RBIzAjREEjMjAxMjUjMVMjODAwMDI2MSMxVCMxNjI4I0xTIzgwMDAwODAjTFQjMjIwMyNQVSM4MSNSVCMxI0NBI0lDRSNaRSM5MTIjWkIjSUNFICA5MTIjUEMjMCNGUiM4MDAwMjYxI0ZUIzE2MjgjVE8jODAwMDA5NiNUVCMxODMyIwpaI1ZOIzEjU1QjMTczNDcyMjM5OCNQSSMxI1pJIzIzOTQ0OSNUQSMwI0RBIzIwMTI1IzFTIzgwMDAwOTYjMVQjMTg1MiNMUyM4NzAwMDExI0xUIzIyMTQjUFUjODEjUlQjMSNDQSNSSFQjWkUjOTU3MCNaQiNUR1YgOTU3MCNQQyMwI0ZSIzgwMDAwOTYjRlQjMTg1MiNUTyM4NzAwMDExI1RUIzIyMTQj¶KRCC¶#VE#1#¶SC¶1_H4sIAAAAAAACA32PS07DMBiEr1J5XarfTtM8JEsmDRWgAhGiCIRYhMZtg/IotlMRRTkHl2HXi/EnEWxA7Dzj8XzjhhykIj6hE8clYyLfDYowmNyHEw+1km/Eb0hR5Qvi2+PuEBAfxqSsTBgbiWEGzAYKjPTmXZp3JrUZBUBr0zec0DF5LepFZtSS+E8NMfW+i0W3NyGG8jLp1MX1HMUhzqq+AphF2ud+03y3HYqRnMj9slwPNVmaYPKUU3HDr46fxXoni9H5y0Y8cEpt27UsTzzyqUunwJgnVtylYsldAGAzKgJ8t+fUsaYOY5bnipSvjh8ALjBAT+AYbYZPLvolsVJ/oqNYpXp0pg1ymWV7lEGPdZ2Z58x+sA5y6T9Yh1IL4Dd2K01UZnWWFugZVcneuiwrVcg6KKsi0cTfxJkeLqJY6yzV5jsr1yUOjHMMNW3bfgFUarsn8gEAAA\u003d\u003d"}} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_02_Jan_22_47_29_tickets.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/recon HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: e1927e98-0d8c-45f2-a161-965622ccd56a_e8cf6ea4-4103-4707-aa44-2287646a87f9 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: dcf2d458-4e11-4e40-a6e3-8922544387e8 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 2749 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reisendenProfil":{"reisende":[{"ermaessigungen":["KEINE_ERMAESSIGUNG KLASSENLOS"],"reisendenTyp":"ERWACHSENER"}]},"reservierungsKontingenteVorhanden":false,"verbindungHin":{"kontext":"¶HKI¶T$A\u003d1@O\u003dMünchen Hbf@X\u003d11558339@Y\u003d48140229@L\u003d8000261@a\u003d128@$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$202501030000$202501030438$ICE 618$$1$$$$$$§T$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$A\u003d1@O\u003dKassel-Wilhelmshöhe@X\u003d9447114@Y\u003d51312558@L\u003d8003200@a\u003d128@$202501030512$202501030644$ICE 1088$$1$$$$$$§T$A\u003d1@O\u003dKassel-Wilhelmshöhe@X\u003d9447114@Y\u003d51312558@L\u003d8003200@a\u003d128@$A\u003d1@O\u003dHamm(Westf)Hbf@X\u003d7807824@Y\u003d51678077@L\u003d8000149@a\u003d128@$202501030703$202501030850$RE 26708$$1$$$$$$§T$A\u003d1@O\u003dHamm(Westf)Hbf@X\u003d7807824@Y\u003d51678077@L\u003d8000149@a\u003d128@$A\u003d1@O\u003dMünster(Westf)Hbf@X\u003d7635716@Y\u003d51956563@L\u003d8000263@a\u003d128@$202501030859$202501030922$RE 32916$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#81#AM2#0#RT#7#¶KCC¶I1ZFIzEjRVJHIzQ1MzE2I0hJTiMwI0VDSyM1NjQ0ODB8NTY0NDgwfDU2NDk1NHw1NjUwNDJ8MHwwfDU2NXw1NjQ0MTN8NHwwfDh8MHwwfC0yMTQ3NDgzNjQ4I0dBTSMzMDEyNTAwMDAjClojVk4jMSNTVCMxNzM0NzIyMzk4I1BJIzEjWkkjMTk1OTUyI1RBIzAjREEjMzAxMjUjMVMjODAwMDI2MSMxVCMwI0xTIzgwMDAxOTkjTFQjMTEyMyNQVSM4MSNSVCMxI0NBI0lDRSNaRSM2MTgjWkIjSUNFICA2MTgjUEMjMCNGUiM4MDAwMjYxI0ZUIzAjVE8jODAwMDEwNSNUVCM0MzgjClojVk4jMSNTVCMxNzM0NzIyMzk4I1BJIzEjWkkjMTkyODc1I1RBIzAjREEjMzAxMjUjMVMjODAwMDEwNSMxVCM1MTIjTFMjODAwMDE5OSNMVCMxMDE4I1BVIzgxI1JUIzEjQ0EjSUNFI1pFIzEwODgjWkIjSUNFIDEwODgjUEMjMCNGUiM4MDAwMTA1I0ZUIzUxMiNUTyM4MDAzMjAwI1RUIzY0NCMKWiNWTiMxI1NUIzE3MzQ3MjIzOTgjUEkjMSNaSSMyMTg2NjIjVEEjMCNEQSMzMDEyNSMxUyM4MDAzMjAwIzFUIzcwMyNMUyM4MDAwMTQ5I0xUIzg1MCNQVSM4MSNSVCMxI0NBI0RQTiNaRSMyNjcwOCNaQiNSRSAyNjcwOCNQQyMzI0ZSIzgwMDMyMDAjRlQjNzAzI1RPIzgwMDAxNDkjVFQjODUwIwpaI1ZOIzEjU1QjMTczNDcyMjM5OCNQSSMxI1pJIzIxNzk5NiNUQSMwI0RBIzMwMTI1IzFTIzgwMDAxNzEjMVQjODQzI0xTIzgwMDAzMTYjTFQjOTUxI1BVIzgxI1JUIzEjQ0EjRFBOI1pFIzMyOTE2I1pCI1JFIDMyOTE2I1BDIzMjRlIjODAwMDE0OSNGVCM4NTkjVE8jODAwMDI2MyNUVCM5MjIj¶KRCC¶#VE#1#¶SC¶1_H4sIAAAAAAACA32P3UrDMACFX0VypVBHfpr+QSB2ZahMV8T5g3hR13SrdO1M0mEpfQ5fxru9mGmLFyJ6l3Nycr6TFuyFBAFAE9cDFhDv2ogonNxFE99oKd5A0IKy3s5AQK3+EIIAWqCqdZRoYcIYYgoRxGAwb/PtYGKbQGisbGg4RRZ4LZtZoeUcBE8t0M2uj8U3i8iEtlXaq4vrqRH7pKh7ZSoJ6J6HTdPNeiw25FTs5tVqrCny1CTPGOILdnX4LFcbUR6dv2T8gSFEqUeIzx+Z7SEbYuzzJfMQnzMPQogdxEPzbseQS2wXY+J7PGfLwweEHsTQeNyMUXr85GxYkkj5J1ppIY/vhdLZyTjAdQh1kWP4FPnUoQ75ySf/8RFE6Dd/LXRcFU2Rl8bTshaDdVnVshRNWNVlqkCQJYUaL+JEqSJX+jsrVlWcyGRrQm3XdV8RCJMF+wEAAA\u003d\u003d"}} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_30_43_locsearch.txt: -------------------------------------------------------------------------------- 1 | POST /mob/location/search HTTP/1.1 2 | Accept: application/x.db.vendo.mob.location.v3+json 3 | X-Correlation-ID: 4f2e274e-6e5c-4711-9125-87229046bba3_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 560187ed-f13a-4654-bc62-b9f69f989563 10 | Content-Type: application/x.db.vendo.mob.location.v3+json 11 | Content-Length: 45 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"locationTypes":["ALL"],"searchTerm":"test"} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_31_06_locsearch.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Mon, 16 Dec 2024 18:29:33 GMT 3 | Content-Type: application/x.db.vendo.mob.location.v3+json 4 | Content-Length: 2994 5 | Connection: keep-alive 6 | server-timing: intid;desc=a0c42ccdea3bbb18 7 | Server-Timing: intid;desc=a0c42ccdea3bbb18 8 | Server-Timing: intid;desc=a0c42ccdea3bbb18 9 | x-correlation-id: 4f2e274e-6e5c-4711-9125-87229046bba3_64466773-556f-4aa8-b128-0948c4d60887 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=a0c42ccdea3bbb18 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd1e15efa531633ee310f49108472c2b1a482b002a4d22b5cf935d3d6970510c0b134616b579c639af340fe261746662484; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | [{"name":"Tessin West","stationId":"7983","locationId":"A=1@O=Tessin West@X=12442572@Y=54034438@U=81@L=8079604@B=1@p=1734031727@i=U×008030295@","evaNr":"8079604","coordinates":{"latitude":54.0344,"longitude":12.442203},"weight":1489,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Testa Grigia","locationId":"A=1@O=Testa Grigia@X=7707540@Y=45934474@U=81@L=8511303@B=1@p=1734031727@i=U×008511303@","evaNr":"8511303","coordinates":{"latitude":45.934475,"longitude":7.70754},"weight":3825,"products":["NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Teschenhagen","stationId":"6172","locationId":"A=1@O=Teschenhagen@X=13374232@Y=54389368@U=81@L=8013104@B=1@p=1734031727@i=U×008028497@","evaNr":"8013104","coordinates":{"latitude":54.38936,"longitude":13.374196},"weight":3825,"products":["NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Testelt","locationId":"A=1@O=Testelt@X=4946863@Y=51009783@U=81@L=8800244@B=1@p=1734031727@i=U×008833266@","evaNr":"8800244","coordinates":{"latitude":51.009785,"longitude":4.946863},"weight":2627,"products":["INTERCITYUNDEUROCITYZUEGE","NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Tessin","stationId":"6174","locationId":"A=1@O=Tessin@X=12462618@Y=54032020@U=81@L=8013106@B=1@p=1734031727@i=U×008027109@","evaNr":"8013106","coordinates":{"latitude":54.032,"longitude":12.461656},"weight":2402,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE"],"locationType":"ST"},{"name":"Chemnitz Zentralhaltestelle","locationId":"A=1@O=Chemnitz Zentralhaltestelle@X=12922263@Y=50831626@U=81@L=8017419@B=1@p=1734031727@i=U×008042918@","evaNr":"8017419","coordinates":{"latitude":50.831627,"longitude":12.922263},"weight":5813,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Teschow","stationId":"6173","locationId":"A=1@O=Teschow@X=11637956@Y=53994355@U=81@L=8013105@B=1@p=1734031727@i=U×008027118@","evaNr":"8013105","coordinates":{"latitude":53.994175,"longitude":11.637687},"weight":1524,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE"],"locationType":"ST"},{"name":"Tesperhude Hudehof, Geesthacht","locationId":"A=1@O=Tesperhude Hudehof, Geesthacht@X=10431253@Y=53404393@U=81@L=694334@B=1@p=1734031727@","evaNr":"694334","coordinates":{"latitude":53.404392,"longitude":10.431253},"weight":912,"products":["BUSSE"],"locationType":"ST"},{"name":"Tesperhude Strandweg, Geesthacht","locationId":"A=1@O=Tesperhude Strandweg, Geesthacht@X=10427424@Y=53402316@U=81@L=694333@B=1@p=1734031727@","evaNr":"694333","coordinates":{"latitude":53.402317,"longitude":10.427424},"weight":912,"products":["BUSSE"],"locationType":"ST"},{"name":"Testorf Umspannwerk, Wangels","locationId":"A=1@O=Testorf Umspannwerk, Wangels@X=10778516@Y=54250161@U=81@L=700240@B=1@p=1734031727@","evaNr":"700240","coordinates":{"latitude":54.25016,"longitude":10.778516},"weight":220,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"}] -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_33_04_routesearch.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/fahrplan HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: 0d0c6c50-3bea-4cb7-b008-09bc92a0d96a 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 679 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"autonomeReservierung":false,"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reiseHin":{"wunsch":{"abgangsLocationId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8011160@B\u003d1@p\u003d1734031727@i\u003dU×008065969@","verkehrsmittel":["ALL"],"zeitWunsch":{"reiseDatum":"2024-12-16T19:28:48.659812+01:00","zeitPunktArt":"ABFAHRT"},"zielLocationId":"A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@U\u003d81@L\u003d8000207@B\u003d1@p\u003d1734031727@i\u003dU×008015458@"}},"reisendenProfil":{"reisende":[{"ermaessigungen":["KEINE_ERMAESSIGUNG KLASSENLOS"],"reisendenTyp":"ERWACHSENER"}]},"reservierungsKontingenteVorhanden":false} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_35_50_triprecon.txt: -------------------------------------------------------------------------------- 1 | POST /mob/trip/recon HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 757b9806-028b-4b0e-bdde-2441c2e1e1ee 10 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 11 | Content-Length: 1162 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"reconCtx":"¶HKI¶T$A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@L\u003d8011160@a\u003d128@$A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@L\u003d8000207@a\u003d128@$202412161946$202412170057$ICE 842$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#81#AM2#0#RT#7#¶KCC¶I1ZFIzEjRVJHIzEjSElOIzAjRUNLIzUzOTc0Nnw1Mzk3NDZ8NTQwMDU3fDU0MDA1N3wwfDB8NTY1fDUzOTcyNXwxfDB8MTA1MHwwfDB8LTIxNDc0ODM2NDgjR0FNIzE2MTIyNDE5NDYjClojVk4jMSNTVCMxNzM0MDMxNzI3I1BJIzEjWkkjMTc3NjUxI1RBIzAjREEjMTYxMjI0IzFTIzgwMTAyNTUjMVQjMTkzNCNMUyM4MDAwMjA3I0xUIzEwMDU3I1BVIzgxI1JUIzEjQ0EjSUNFI1pFIzg0MiNaQiNJQ0UgIDg0MiNQQyMwI0ZSIzgwMTExNjAjRlQjMTk0NiNUTyM4MDAwMjA3I1RUIzEwMDU3Iw\u003d\u003d¶KRCC¶#VE#1#¶SC¶1_H4sIAAAAAAACA32P7UrDMBiFb0Xyu4436WcKgdiV4cfQIk4U8Udds1lJ25mmw1J6Hd6JN7AbM20ZCIrkT87JyXnet0N7oVCI8MwPkIXEhzYijmb38YwarcQ7CjtUNsUCha41XCIUgoWqRsepFiZMgDiYYA+N5l1eDCamJAAw1mZsOMUWeivbhdRqicKnDul2N8SS25vYhIoqG9TF9dyIfSqbsQKIjfrncab563YqNuRM7JbVeqqReWaSZwzzGxYJJfPy5Pxlwx8Ytm2Pug7lj8wl5rgB5SsWYL5kAWCMPeCR+bVj2LcdsLFPfJ6z1eETIADPpR7lZpRaTysuxjlSpf4EXx2+5JFroIFvw4AF6thAfmABCPj/YLHruMFv7FbopJKt2c14WjVitC6rRpWijaqmzGoUblJZTw9JWtcyr/UxK9ZVkqq0MKGu7/tvvH4lCvABAAA\u003d"} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_36_19_triprecon.txt: -------------------------------------------------------------------------------- 1 | POST /mob/trip/recon HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: f30d7ef5-d9a2-428b-acc2-e8bb68c7d705 10 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 11 | Content-Length: 2507 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"reconCtx":"¶HKI¶T$A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@L\u003d8098160@a\u003d128@$A\u003d1@O\u003dHannover Hbf@X\u003d9741017@Y\u003d52376764@L\u003d8000152@a\u003d128@$202412162128$202412162322$ICE 840$$1$$$$$$§T$A\u003d1@O\u003dHannover Hbf@X\u003d9741017@Y\u003d52376764@L\u003d8000152@a\u003d128@$A\u003d1@O\u003dHanau Hbf@X\u003d8929003@Y\u003d50120957@L\u003d8000150@a\u003d128@$202412170030$202412170414$IC 60471$$1$$$$$$§T$A\u003d1@O\u003dHanau Hbf@X\u003d8929003@Y\u003d50120957@L\u003d8000150@a\u003d128@$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$202412170455$202412170515$RB 15501$$1$$$$$$§T$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@L\u003d8000207@a\u003d128@$202412170526$202412170633$ICE 222$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#81#AM2#0#RT#7#¶KCC¶I1ZFIzEjRVJHIzI2MCNISU4jNDU1I0VDSyM1Mzk4NDh8NTM5ODQ4fDU0MDM5M3w1NDAzOTN8MHwwfDU2NXw1Mzk4NDh8NHwwfDE4fDB8MHwtMjE0NzQ4MzY0OCNHQU0jMTYxMjI0MjEyOCMKWiNWTiMxI1NUIzE3MzQwMzE3MjcjUEkjMSNaSSMxNzc2MTkjVEEjMCNEQSMxNjEyMjQjMVMjODA5ODE2MCMxVCMyMTI4I0xTIzgwMDAxNTIjTFQjMjMyMiNQVSM4MSNSVCMxI0NBI0lDRSNaRSM4NDAjWkIjSUNFICA4NDAjUEMjMCNGUiM4MDk4MTYwI0ZUIzIxMjgjVE8jODAwMDE1MiNUVCMyMzIyIwpaI1ZOIzEjU1QjMTczNDAzMTcyNyNQSSMxI1pJIzE3MzY1MSNUQSMwI0RBIzE2MTIyNCMxUyM4MDAyNTUzIzFUIzIxNTIjTFMjODUwMzAwMCNMVCMxMTAwNSNQVSM4MSNSVCMxI0NBI0lDI1pFIzYwNDcxI1pCI0lDIDYwNDcxI1BDIzEjRlIjODAwMDE1MiNGVCMxMDAzMCNUTyM4MDAwMTUwI1RUIzEwNDE0IwpaI1ZOIzEjU1QjMTczNDAzMTcyNyNQSSMxI1pJIzE4NzYxOCNUQSMwI0RBIzE3MTIyNCMxUyM4MDA2MTMyIzFUIzQxOCNMUyM4MDAwMTA1I0xUIzUxNSNQVSM4MSNSVCMxI0NBI1JCI1pFIzE1NTAxI1pCI1JCIDE1NTAxI1BDIzMjRlIjODAwMDE1MCNGVCM0NTUjVE8jODAwMDEwNSNUVCM1MTUjClojVk4jMSNTVCMxNzM0MDMxNzI3I1BJIzEjWkkjMjExNDMzI1RBIzAjREEjMTcxMjI0IzFTIzgwMDAxMDUjMVQjNTI2I0xTIzg0MDAwNTgjTFQjOTI5I1BVIzgxI1JUIzEjQ0EjSUNFI1pFIzIyMiNaQiNJQ0UgIDIyMiNQQyMwI0ZSIzgwMDAxMDUjRlQjNTI2I1RPIzgwMDAyMDcjVFQjNjMzIw\u003d\u003d¶KRCC¶#VE#1#¶SC¶1_H4sIAAAAAAACA32P7UrDMBiFb0Xyu4436WcKgdiV4cfQIk4U8Udds1lJ25mmw1J6Hd6JN7AbM20ZCIrkT87JyXnet0N7oVCI8MwPkIXEhzYijmb38YwarcQ7CjtUNsUCha41XCIUgoWqRsepFiZMgDiYYA+N5l1eDCamJAAw1mZsOMUWeivbhdRqicKnDul2N8SS25vYhIoqG9TF9dyIfSqbsQKIjfrncab563YqNuRM7JbVeqqReWaSZwzzGxYJJfPy5Pxlwx8Ytm2Pug7lj8wl5rgB5SsWYL5kAWCMPeCR+bVj2LcdsLFPfJ6z1eETIADPpR7lZpRaTysuxjlSpf4EXx2+5JFroIFvw4AF6thAfmABCPj/YLHruMFv7FbopJKt2c14WjVitC6rRpWijaqmzGoUblJZTw9JWtcyr/UxK9ZVkqq0MKGu7/tvvH4lCvABAAA\u003d"} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_37_19_locationdetails.txt: -------------------------------------------------------------------------------- 1 | GET /mob/location/details/8011160 HTTP/1.1 2 | Accept: application/x.db.vendo.mob.location.v3+json 3 | Content-Type: application/x.db.vendo.mob.location.v3+json 4 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: b9bd5e93-e6d2-4998-bc5a-17b72ca65ca3 11 | Host: app.vendo.noncd.db.de 12 | Connection: Keep-Alive 13 | Accept-Encoding: gzip 14 | User-Agent: okhttp/4.12.0 15 | 16 | -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_37_22_locationdetails.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Mon, 16 Dec 2024 18:36:50 GMT 3 | Content-Type: application/x.db.vendo.mob.location.v3+json 4 | Content-Length: 1158 5 | Connection: keep-alive 6 | server-timing: intid;desc=5ae5ce547c4a8b38 7 | Server-Timing: intid;desc=5ae5ce547c4a8b38 8 | Server-Timing: intid;desc=5ae5ce547c4a8b38 9 | x-correlation-id: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=5ae5ce547c4a8b38 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd125629ac814505605565f3e87a8081db30d56beae22efb148df982534183fd830148fa0901fa5a36116fab13d52c5d86a; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | {"haltName":"Berlin Hbf","produktGattungen":[{"produktGattung":"HOCHGESCHWINDIGKEITSZUEGE","produkte":[{"name":"ICE"},{"name":"RJ"}]},{"produktGattung":"INTERCITYUNDEUROCITYZUEGE","produkte":[{"name":"EC"},{"name":"IC"},{"name":"NJ"}]},{"produktGattung":"INTERREGIOUNDSCHNELLZUEGE","produkte":[{"name":"BUS"},{"name":"Bus"},{"name":"D"},{"name":"EN"},{"name":"ES"},{"name":"FLX"},{"name":"UEX"}]},{"produktGattung":"NAHVERKEHRSONSTIGEZUEGE","produkte":[{"name":"FEX"},{"name":"HBX"},{"name":"R"},{"name":"RB"},{"name":"RE"},{"name":"RSM"},{"name":"Bus RE3"},{"name":"Bus RE5"},{"name":"Bus RE7"},{"name":"Bus RE8"},{"name":"Bus S7"}]},{"produktGattung":"SBAHNEN","produkte":[{"name":"S 3"},{"name":"S 5"},{"name":"S 7"},{"name":"S 9"},{"name":"S 45"}]},{"produktGattung":"BUSSE","produkte":[{"name":"Bus 120"},{"name":"Bus 123"},{"name":"Bus 142"},{"name":"Bus 147"},{"name":"Bus 245"},{"name":"Bus M41"},{"name":"Bus M85"},{"name":"Bus N5"},{"name":"Bus N20"},{"name":"Bus N40"}]},{"produktGattung":"UBAHN","produkte":[{"name":"U 5"}]},{"produktGattung":"STRASSENBAHN","produkte":[{"name":"STR 12"},{"name":"STR M5"},{"name":"STR M8"},{"name":"STR M10"}]}]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_37_26_departures.txt: -------------------------------------------------------------------------------- 1 | POST /mob/bahnhofstafel/abfahrt HTTP/1.1 2 | Accept: application/x.db.vendo.mob.bahnhofstafeln.v2+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: b2d3fe71-a8d6-4675-bbe5-a5081368a6ab 10 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 11 | Content-Length: 197 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"anfragezeit":"21:28","datum":"2024-12-16","ursprungsBahnhofId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8098160@i\u003dU×008031922@","verkehrsmittel":["ALL"]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_38_00_departures.txt: -------------------------------------------------------------------------------- 1 | POST /mob/bahnhofstafel/abfahrt HTTP/1.1 2 | Accept: application/x.db.vendo.mob.bahnhofstafeln.v2+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 8c20d0c0-3c0b-40ef-bc95-849c65e375df 10 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 11 | Content-Length: 378 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"anfragezeit":"21:28","datum":"2024-12-16","ursprungsBahnhofId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8098160@i\u003dU×008031922@","verkehrsmittel":["HOCHGESCHWINDIGKEITSZUEGE","INTERCITYUNDEUROCITYZUEGE","INTERREGIOUNDSCHNELLZUEGE","NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_38_44_arrivals.txt: -------------------------------------------------------------------------------- 1 | POST /mob/bahnhofstafel/ankunft HTTP/1.1 2 | Accept: application/x.db.vendo.mob.bahnhofstafeln.v2+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: d7e7526d-1d69-41d1-bf48-9c2ed5f4a302 10 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 11 | Content-Length: 378 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"anfragezeit":"21:28","datum":"2024-12-16","ursprungsBahnhofId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8098160@i\u003dU×008031922@","verkehrsmittel":["HOCHGESCHWINDIGKEITSZUEGE","INTERCITYUNDEUROCITYZUEGE","INTERREGIOUNDSCHNELLZUEGE","NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_39_50_locsearch_station.txt: -------------------------------------------------------------------------------- 1 | POST /mob/location/search HTTP/1.1 2 | Accept: application/x.db.vendo.mob.location.v3+json 3 | X-Correlation-ID: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 91946e30-da87-4813-9a13-48dce2cd2cdd 10 | Content-Type: application/x.db.vendo.mob.location.v3+json 11 | Content-Length: 44 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"locationTypes":["ST"],"searchTerm":"test"} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_39_53_locsearch_station.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Mon, 16 Dec 2024 18:39:38 GMT 3 | Content-Type: application/x.db.vendo.mob.location.v3+json 4 | Content-Length: 2994 5 | Connection: keep-alive 6 | server-timing: intid;desc=6e7cf7863b2dbf7b 7 | Server-Timing: intid;desc=6e7cf7863b2dbf7b 8 | Server-Timing: intid;desc=6e7cf7863b2dbf7b 9 | x-correlation-id: 0564dcdf-edbf-4412-a147-7299f6481470_64466773-556f-4aa8-b128-0948c4d60887 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=6e7cf7863b2dbf7b 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd109bdf5d864ee9ff6d510d0c0e6688e510fd271d9dba08fe92a99ec7d68b43cb8eddd472ec4074727d7ed3137ea220214; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | [{"name":"Tessin West","stationId":"7983","locationId":"A=1@O=Tessin West@X=12442572@Y=54034438@U=81@L=8079604@B=1@p=1734031727@i=U×008030295@","evaNr":"8079604","coordinates":{"latitude":54.0344,"longitude":12.442203},"weight":1489,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Testa Grigia","locationId":"A=1@O=Testa Grigia@X=7707540@Y=45934474@U=81@L=8511303@B=1@p=1734031727@i=U×008511303@","evaNr":"8511303","coordinates":{"latitude":45.934475,"longitude":7.70754},"weight":3825,"products":["NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Teschenhagen","stationId":"6172","locationId":"A=1@O=Teschenhagen@X=13374232@Y=54389368@U=81@L=8013104@B=1@p=1734031727@i=U×008028497@","evaNr":"8013104","coordinates":{"latitude":54.38936,"longitude":13.374196},"weight":3825,"products":["NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Testelt","locationId":"A=1@O=Testelt@X=4946863@Y=51009783@U=81@L=8800244@B=1@p=1734031727@i=U×008833266@","evaNr":"8800244","coordinates":{"latitude":51.009785,"longitude":4.946863},"weight":2627,"products":["INTERCITYUNDEUROCITYZUEGE","NAHVERKEHRSONSTIGEZUEGE"],"locationType":"ST"},{"name":"Tessin","stationId":"6174","locationId":"A=1@O=Tessin@X=12462618@Y=54032020@U=81@L=8013106@B=1@p=1734031727@i=U×008027109@","evaNr":"8013106","coordinates":{"latitude":54.032,"longitude":12.461656},"weight":2402,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE"],"locationType":"ST"},{"name":"Chemnitz Zentralhaltestelle","locationId":"A=1@O=Chemnitz Zentralhaltestelle@X=12922263@Y=50831626@U=81@L=8017419@B=1@p=1734031727@i=U×008042918@","evaNr":"8017419","coordinates":{"latitude":50.831627,"longitude":12.922263},"weight":5813,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Teschow","stationId":"6173","locationId":"A=1@O=Teschow@X=11637956@Y=53994355@U=81@L=8013105@B=1@p=1734031727@i=U×008027118@","evaNr":"8013105","coordinates":{"latitude":53.994175,"longitude":11.637687},"weight":1524,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE"],"locationType":"ST"},{"name":"Tesperhude Hudehof, Geesthacht","locationId":"A=1@O=Tesperhude Hudehof, Geesthacht@X=10431253@Y=53404393@U=81@L=694334@B=1@p=1734031727@","evaNr":"694334","coordinates":{"latitude":53.404392,"longitude":10.431253},"weight":912,"products":["BUSSE"],"locationType":"ST"},{"name":"Tesperhude Strandweg, Geesthacht","locationId":"A=1@O=Tesperhude Strandweg, Geesthacht@X=10427424@Y=53402316@U=81@L=694333@B=1@p=1734031727@","evaNr":"694333","coordinates":{"latitude":53.402317,"longitude":10.427424},"weight":912,"products":["BUSSE"],"locationType":"ST"},{"name":"Testorf Umspannwerk, Wangels","locationId":"A=1@O=Testorf Umspannwerk, Wangels@X=10778516@Y=54250161@U=81@L=700240@B=1@p=1734031727@","evaNr":"700240","coordinates":{"latitude":54.25016,"longitude":10.778516},"weight":220,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"}] -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_42_53_specialrouting.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/fahrplan HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: 67b8a500-1983-49f5-a4ff-177d58b395ed_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: be862f65-99be-46af-a622-8bc332dc70df 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 1122 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"autonomeReservierung":false,"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reiseHin":{"wunsch":{"abgangsLocationId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8011160@B\u003d1@p\u003d1734031727@i\u003dU×008065969@","economic":true,"minUmstiegsdauer":20,"verkehrsmittel":["NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"],"viaLocations":[{"locationId":"A\u003d1@O\u003dHannover Hbf@X\u003d9741017@Y\u003d52376764@U\u003d81@L\u003d8000152@B\u003d1@p\u003d1734031727@i\u003dU×008013552@","minUmstiegsdauer":60,"verkehrsmittel":["NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"]}],"zeitWunsch":{"reiseDatum":"2024-12-16T19:28:48.659+01:00","zeitPunktArt":"ABFAHRT"},"zielLocationId":"A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@U\u003d81@L\u003d8000207@B\u003d1@p\u003d1734031727@i\u003dU×008015458@"}},"reisendenProfil":{"reisende":[{"ermaessigungen":["KEINE_ERMAESSIGUNG KLASSENLOS"],"reisendenTyp":"ERWACHSENER"}]},"reservierungsKontingenteVorhanden":false} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_46_29_bahncard.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/fahrplan HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: c752e01a-e03e-42a5-bcd3-26b1cc2574b3 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 783 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"autonomeReservierung":false,"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reiseHin":{"wunsch":{"abgangsLocationId":"A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@U\u003d81@L\u003d8000207@B\u003d1@p\u003d1734031727@i\u003dU×008015458@","minUmstiegsdauer":20,"verkehrsmittel":["NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"],"zeitWunsch":{"reiseDatum":"2024-12-16T19:45:47.459239+01:00","zeitPunktArt":"ABFAHRT"},"zielLocationId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8011160@B\u003d1@p\u003d1734031727@i\u003dU×008065969@"}},"reisendenProfil":{"reisende":[{"ermaessigungen":["BAHNCARD25 KLASSE_2"],"reisendenTyp":"SENIOR"}]},"reservierungsKontingenteVorhanden":false} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_47_54_zuglauf.txt: -------------------------------------------------------------------------------- 1 | GET /mob/zuglauf/2%7C%23VN%231%23ST%231734031727%23PI%231%23ZI%23178229%23TA%230%23DA%23161224%231S%238000207%231T%231926%23LS%238098160%23LT%2310012%23PU%2381%23RT%231%23CA%23ICE%23ZE%23947%23ZB%23ICE%20%20947%23PC%230%23FR%238000207%23FT%231926%23TO%238098160%23TT%2310012%23 HTTP/1.1 2 | Accept: application/x.db.vendo.mob.zuglauf.v2+json 3 | Content-Type: application/x.db.vendo.mob.zuglauf.v2+json 4 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: 57c6969b-e937-4591-accf-0850e7b72278 11 | Host: app.vendo.noncd.db.de 12 | Connection: Keep-Alive 13 | Accept-Encoding: gzip 14 | User-Agent: okhttp/4.12.0 15 | 16 | -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_48_15_coachseq.txt: -------------------------------------------------------------------------------- 1 | GET /mob/zuglaeufe/ICE_947/halte/by-abfahrt/8000207_2024-12-16T19:26:00+01:00/wagenreihung HTTP/1.1 2 | Accept: application/x.db.vendo.mob.wagenreihung.v3+json 3 | Content-Type: application/x.db.vendo.mob.wagenreihung.v3+json 4 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: 4ce19a5d-9da2-480d-a278-543fade2f485 11 | Host: app.vendo.noncd.db.de 12 | Connection: Keep-Alive 13 | Accept-Encoding: gzip 14 | User-Agent: okhttp/4.12.0 15 | 16 | -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_19_48_25_coachseq.txt: -------------------------------------------------------------------------------- 1 | GET /mob/zuglaeufe/ICE_947/halte/by-abfahrt/8000207_2024-12-16T19:26:00+01:00/wagenreihung HTTP/1.1 2 | Accept: application/x.db.vendo.mob.wagenreihung.v3+json 3 | Content-Type: application/x.db.vendo.mob.wagenreihung.v3+json 4 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: 4ce19a5d-9da2-480d-a278-543fade2f485 11 | Host: app.vendo.noncd.db.de 12 | Connection: Keep-Alive 13 | Accept-Encoding: gzip 14 | User-Agent: okhttp/4.12.0 15 | 16 | -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_20_18_17_reservierung.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/recon/autonomereservierung HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: c88c72b9-3515-4acf-b052-3e71578c9461 10 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 11 | Content-Length: 2276 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reisendenProfil":{"reisende":[{"ermaessigungen":["BAHNCARD25 KLASSE_2"],"reisendenTyp":"SENIOR"}]},"reservierungsKontingenteVorhanden":false,"verbindungHin":{"kontext":"¶HKI¶T$A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@L\u003d8000207@a\u003d128@$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$202412162342$202412170220$IC 60403$$1$$$$$$§T$A\u003d1@O\u003dFrankfurt(Main)Hbf@X\u003d8663785@Y\u003d50107149@L\u003d8000105@a\u003d128@$A\u003d1@O\u003dErfurt Hbf@X\u003d11037989@Y\u003d50972352@L\u003d8010101@a\u003d128@$202412170249$202412170518$ICE 698$$1$$$$$$§T$A\u003d1@O\u003dErfurt Hbf@X\u003d11037989@Y\u003d50972352@L\u003d8010101@a\u003d128@$A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@L\u003d8098160@a\u003d128@$202412170527$202412170729$ICE 1606$$1$$$$$$¶KC¶#VE#2#CF#100#CA#0#CM#0#SICT#0#AM#81#AM2#0#RT#7#¶KCC¶I1ZFIzEjRVJHIzQ1MzE1I0hJTiMwI0VDSyM1Mzk5OTV8NTM5OTgyfDU0MDQ0OXw1NDA0NDl8MHwwfDU2NXw1Mzk5NTN8M3wwfDh8MHwwfC0yMTQ3NDgzNjQ4I0dBTSMxNjEyMjQyMzQyIwpaI1ZOIzEjU1QjMTczNDAzMTcyNyNQSSMxI1pJIzE3MzU2MyNUQSMwI0RBIzE2MTIyNCMxUyM4NDAwMDU4IzFUIzIwMTUjTFMjODUwMzAwMCNMVCMxMDgwNSNQVSM4MSNSVCMxI0NBI0lDI1pFIzYwNDAzI1pCI0lDIDYwNDAzI1BDIzEjRlIjODAwMDIwNyNGVCMyMzQyI1RPIzgwMDAxMDUjVFQjMTAyMjAjClojVk4jMSNTVCMxNzM0MDMxNzI3I1BJIzEjWkkjMTc3MTQ3I1RBIzAjREEjMTYxMjI0IzFTIzgwMDAyNjEjMVQjMjE1MSNMUyM4MDk4MTYwI0xUIzEwNzU1I1BVIzgxI1JUIzEjQ0EjSUNFI1pFIzY5OCNaQiNJQ0UgIDY5OCNQQyMwI0ZSIzgwMDAxMDUjRlQjMTAyNDkjVE8jODAxMDEwMSNUVCMxMDUxOCMKWiNWTiMxI1NUIzE3MzQwMzE3MjcjUEkjMSNaSSMxNzQ1MzgjVEEjMCNEQSMxNzEyMjQjMVMjODAxMDEwMSMxVCM1MjcjTFMjODAwMjU1MyNMVCM5MzkjUFUjODEjUlQjMSNDQSNJQ0UjWkUjMTYwNiNaQiNJQ0UgMTYwNiNQQyMwI0ZSIzgwMTAxMDEjRlQjNTI3I1RPIzgwOTgxNjAjVFQjNzI5Iw\u003d\u003d¶KRCC¶#VE#1#¶SC¶1_H4sIAAAAAAACA32P7UrDMBiFb0Xyu443adM2hUDsyvBjaBEnivijrtms9GOm6bCUXod34g3sxkxbBoIi+ZNz3pPzvOnQXioUIDzzfGQh+aGNiMLZfTRjRiv5joIOlU2xQAG1hkuIArBQ1ego0dKECRAHE+yi0bzLisHEzKEAxtqMDafYQm9lu8i1WqLgqUO63Q2x+PYmMqGiSgd1cT03Yp/kzVgBxEb987jT/HU7FRtyKnfLaj3V5Flqkmccixt+dfjKy5Pzl4144C6jvmeDeOQUmGMDYWLFfSyW3AcAAp4IzZsdx57tgI094omMrw6fAD5g6lBfmEVqPX1wMW6RKPUnNpQqz45cbNsG7bABTMyh/g8wxtiFf8AuZS77Dd5KHVd5ayDG06qRo3VZNaqUbVg1ZVqjYJPk9TSIk7rOs1ofs3JdxYlKChPq+r7/Bvf6c1bwAQAA"}} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_16_Dec_20_36_36_departureswithnotices.txt: -------------------------------------------------------------------------------- 1 | POST /mob/bahnhofstafel/abfahrt HTTP/1.1 2 | Accept: application/x.db.vendo.mob.bahnhofstafeln.v2+json 3 | X-Correlation-ID: b8d93b08-71ac-4cc2-836e-cd2683e34478_64466773-556f-4aa8-b128-0948c4d60887 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 2b250673-6250-4bcd-8dc3-8cf83c0a7686 10 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 11 | Content-Length: 376 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"anfragezeit":"20:35","datum":"2024-12-16","ursprungsBahnhofId":"A\u003d1@O\u003dKöln Hbf@X\u003d6958730@Y\u003d50943029@U\u003d81@L\u003d8000207@i\u003dU×008015458@","verkehrsmittel":["HOCHGESCHWINDIGKEITSZUEGE","INTERCITYUNDEUROCITYZUEGE","INTERREGIOUNDSCHNELLZUEGE","NAHVERKEHRSONSTIGEZUEGE","SBAHNEN","BUSSE","SCHIFFE","UBAHN","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_22_39_50_departures_ondemand.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Wed, 18 Dec 2024 21:38:58 GMT 3 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 4 | Content-Length: 590 5 | Connection: keep-alive 6 | server-timing: intid;desc=bf069d97495f8dad 7 | Server-Timing: intid;desc=bf069d97495f8dad 8 | Server-Timing: intid;desc=bf069d97495f8dad 9 | x-correlation-id: faddeefb-53f2-41a9-9879-e1c4edd7845f_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=bf069d97495f8dad 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd1e76575d2a4e5c74d05675d588d2a35d6e7b8b0e5cad991641ebd4a05f4c1b051774f52d1a60eac0d3365250488a0be85; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | {"bahnhofstafelAbfahrtPositionen":[{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#514236#TA#0#DA#181224#1S#541958#1T#1944#LS#548569#LT#2108#PU#81#RT#1#CA#rfb#ZE#8175#ZB#RUF 8175#PC#9#FR#541958#FT#1944#TO#548569#TT#2108#","kurztext":"RUF","mitteltext":"RUF 8175","abfrageOrt":{"name":"Penzing Ortsmitte, Aidenbach","locationId":"A=1@O=Penzing Ortsmitte, Aidenbach@X=13063430@Y=48562182@U=81@L=548572@","evaNr":"548572"},"richtung":"Köching Abzw. Haidenburg Wartehäuschen, Aidenbach","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T21:03:00+01:00","produktGattung":"ANRUFPFLICHTIGEVERKEHRE"}]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_22_42_29_departures_ferry.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Wed, 18 Dec 2024 21:42:04 GMT 3 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 4 | Content-Length: 2498 5 | Connection: keep-alive 6 | server-timing: intid;desc=4e6f4ce0b26b14cb 7 | Server-Timing: intid;desc=4e6f4ce0b26b14cb 8 | Server-Timing: intid;desc=4e6f4ce0b26b14cb 9 | x-correlation-id: faddeefb-53f2-41a9-9879-e1c4edd7845f_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=4e6f4ce0b26b14cb 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd1b5c4411e233c8956b40fa37e9050070c341e1782b80282d570eb1ed0cb63a2fab4db66435d0bad351471a04f949cac0d; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | {"bahnhofstafelAbfahrtPositionen":[{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#989203#TA#17#DA#181224#1S#368702#1T#930#LS#936350#LT#935#PU#81#RT#1#CA#FAE#ZE#FÄ1#ZB#Fähre #PC#6#FR#368702#FT#930#TO#936350#TT#935#","kurztext":"Fähre","mitteltext":"Fähre","abfrageOrt":{"name":"Hohe Düne Fähre, Rostock","locationId":"A=1@O=Hohe Düne Fähre, Rostock@X=12097404@Y=54176710@U=81@L=368702@","evaNr":"368702"},"richtung":"Warnemünde","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T09:30:00+01:00","produktGattung":"SCHIFF"},{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#417851#TA#1#DA#181224#1S#936250#1T#947#LS#939255#LT#1021#PU#81#RT#1#CA#Bus#ZE#18#ZB#Bus 18#PC#5#FR#936250#FT#947#TO#939255#TT#1021#","kurztext":"Bus","mitteltext":"Bus 18","abfrageOrt":{"name":"Hohe Düne Fähre, Rostock","locationId":"A=1@O=Hohe Düne Fähre, Rostock@X=12098627@Y=54176485@U=81@L=936250@","evaNr":"936250"},"richtung":"Dierkower Kreuz","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T09:47:00+01:00","produktGattung":"BUS"},{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#989203#TA#18#DA#181224#1S#368702#1T#950#LS#936350#LT#955#PU#81#RT#1#CA#FAE#ZE#FÄ1#ZB#Fähre #PC#6#FR#368702#FT#950#TO#936350#TT#955#","kurztext":"Fähre","mitteltext":"Fähre","abfrageOrt":{"name":"Hohe Düne Fähre, Rostock","locationId":"A=1@O=Hohe Düne Fähre, Rostock@X=12097404@Y=54176710@U=81@L=368702@","evaNr":"368702"},"richtung":"Warnemünde","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T09:50:00+01:00","produktGattung":"SCHIFF"},{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#420342#TA#0#DA#181224#1S#936250#1T#1007#LS#842059#LT#1018#PU#81#RT#1#CA#Bus#ZE#17#ZB#Bus 17#PC#5#FR#936250#FT#1007#TO#842059#TT#1018#","kurztext":"Bus","mitteltext":"Bus 17","abfrageOrt":{"name":"Hohe Düne Fähre, Rostock","locationId":"A=1@O=Hohe Düne Fähre, Rostock@X=12098627@Y=54176485@U=81@L=936250@","evaNr":"936250"},"richtung":"Markgrafenheide Ost","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T10:07:00+01:00","produktGattung":"BUS"},{"zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#989203#TA#19#DA#181224#1S#368702#1T#1010#LS#936350#LT#1015#PU#81#RT#1#CA#FAE#ZE#FÄ1#ZB#Fähre #PC#6#FR#368702#FT#1010#TO#936350#TT#1015#","kurztext":"Fähre","mitteltext":"Fähre","abfrageOrt":{"name":"Hohe Düne Fähre, Rostock","locationId":"A=1@O=Hohe Düne Fähre, Rostock@X=12097404@Y=54176710@U=81@L=368702@","evaNr":"368702"},"richtung":"Warnemünde","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T10:10:00+01:00","produktGattung":"SCHIFF"}]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_22_43_54_departures_IR_flixtrain.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Wed, 18 Dec 2024 21:43:02 GMT 3 | Content-Type: application/x.db.vendo.mob.bahnhofstafeln.v2+json 4 | Content-Length: 1030 5 | Connection: keep-alive 6 | server-timing: intid;desc=3007cc29ce998300 7 | Server-Timing: intid;desc=3007cc29ce998300 8 | Server-Timing: intid;desc=3007cc29ce998300 9 | x-correlation-id: faddeefb-53f2-41a9-9879-e1c4edd7845f_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=3007cc29ce998300 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd1778d8a6954cb4376d998b52e8b4b9edfa2de0eed859c823593d6a880ffc23b5ab8f107642d7ccfb36b5be2d8c9d6632f; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | {"bahnhofstafelAbfahrtPositionen":[{"gleis":"2","zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#338816#TA#0#DA#181224#1S#8000096#1T#712#LS#8100003#LT#1352#PU#81#RT#1#CA#DPF#ZE#963#ZB#WB 963#PC#2#FR#8000096#FT#712#TO#8100003#TT#1352#","kurztext":"WB","mitteltext":"WB 963","abfrageOrt":{"name":"Stuttgart Hbf","locationId":"A=1@O=Stuttgart Hbf@X=9181636@Y=48784081@U=81@L=8000096@i=U×008029034@","evaNr":"8000096","stationId":"6071"},"echtzeitNotizen":[],"abgangsDatum":"2024-12-18T07:12:00+01:00","produktGattung":"IR"},{"gleis":"8","zuglaufId":"2|#VN#1#ST#1734378327#PI#1#ZI#203510#TA#0#DA#181224#1S#8000096#1T#717#LS#8098160#LT#1315#PU#81#RT#1#CA#DPF#ZE#1240#ZB#FLX 1240#PC#2#FR#8000096#FT#717#TO#8098160#TT#1315#","kurztext":"FLX","mitteltext":"FLX 1240","abfrageOrt":{"name":"Stuttgart Hbf","locationId":"A=1@O=Stuttgart Hbf@X=9181636@Y=48784081@U=81@L=8000096@i=U×008029034@","evaNr":"8000096","stationId":"6071"},"richtung":"Berlin Hbf","echtzeitNotizen":[],"abgangsDatum":"2024-12-18T07:17:00+01:00","produktGattung":"IR"}]} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_23_19_54_locsearch_addr.txt: -------------------------------------------------------------------------------- 1 | POST /mob/location/search HTTP/1.1 2 | Accept: application/x.db.vendo.mob.location.v3+json 3 | X-Correlation-ID: 0079a47f-6208-4507-9d9a-0e02802ef52c_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 4 | X-Device-Os-Name: Android 5 | X-Device-Os-Version: 32 6 | X-Device-Model: Google Pixel 3a 7 | X-App-Version: 24.32.2 8 | Accept-Language: en,de 9 | X-INSTANA-ANDROID: 205f5fd7-f96a-4e07-a47d-bb3edb8c443e 10 | Content-Type: application/x.db.vendo.mob.location.v3+json 11 | Content-Length: 52 12 | Host: app.vendo.noncd.db.de 13 | Connection: Keep-Alive 14 | Accept-Encoding: gzip 15 | User-Agent: okhttp/4.12.0 16 | 17 | {"locationTypes":["ALL"],"searchTerm":"schillerstr"} -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_23_19_57_locsearch_addr.txt: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Wed, 18 Dec 2024 22:19:20 GMT 3 | Content-Type: application/x.db.vendo.mob.location.v3+json 4 | Content-Length: 2877 5 | Connection: keep-alive 6 | server-timing: intid;desc=8a52db25dd785bfe 7 | Server-Timing: intid;desc=8a52db25dd785bfe 8 | Server-Timing: intid;desc=8a52db25dd785bfe 9 | x-correlation-id: 0079a47f-6208-4507-9d9a-0e02802ef52c_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 10 | Strict-Transport-Security: max-age=16070400; includeSubDomains 11 | X-XSS-Protection: 0 12 | server-timing: intid;desc=8a52db25dd785bfe 13 | Content-Security-Policy: frame-ancestors 'none'; 14 | X-Content-Type-Options: nosniff 15 | Set-Cookie: TS01be2125=01d513bcd1de3bcb4b755a3859cad31611f802372756db9780547d536b115fb805b4cb7fe4f2aefad293244318a0660f18cf234872; Path=/; Domain=.app.vendo.noncd.db.de; Secure; HTTPOnly 16 | 17 | [{"name":"Schillerstr., Ofterdingen","locationId":"A=1@O=Schillerstr., Ofterdingen@X=9030635@Y=48420917@U=81@L=750308@B=1@p=1734378327@","evaNr":"750308","coordinates":{"latitude":48.420918,"longitude":9.030635},"weight":995,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Schillerstr., Lutherstadt Wittenberg","locationId":"A=1@O=Schillerstr., Lutherstadt Wittenberg@X=12660057@Y=51875984@U=81@L=963037@B=1@p=1734378327@","evaNr":"963037","coordinates":{"latitude":51.875984,"longitude":12.660057},"weight":943,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Schillerstr., Geithain","locationId":"A=1@O=Schillerstr., Geithain@X=12691420@Y=51058568@U=81@L=200268@B=1@p=1734378327@","evaNr":"200268","coordinates":{"latitude":51.058567,"longitude":12.69142},"weight":941,"products":["BUSSE"],"locationType":"ST"},{"name":"Schillerstr., Zwickau","locationId":"A=1@O=Schillerstr., Zwickau@X=12493001@Y=50715018@U=81@L=983776@B=1@p=1734378327@","evaNr":"983776","coordinates":{"latitude":50.71502,"longitude":12.493001},"weight":941,"products":["NAHVERKEHRSONSTIGEZUEGE","BUSSE","STRASSENBAHN","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Schillerstr., Überherrn","locationId":"A=1@O=Schillerstr., Überherrn@X=6705494@Y=49239322@U=81@L=835123@B=1@p=1734378327@","evaNr":"835123","coordinates":{"latitude":49.239323,"longitude":6.705494},"weight":941,"products":["BUSSE"],"locationType":"ST"},{"name":"Schillerstr./Forstweg, Brieselang","locationId":"A=1@O=Schillerstr./Forstweg, Brieselang@X=12988073@Y=52583830@U=81@L=734788@B=1@p=1734378327@","evaNr":"734788","coordinates":{"latitude":52.58383,"longitude":12.988073},"weight":941,"products":["BUSSE"],"locationType":"ST"},{"name":"Schillerstr., Berlin","locationId":"A=1@O=Schillerstr., Berlin@X=13410584@Y=52591948@U=81@L=732759@B=1@p=1734378327@","evaNr":"732759","coordinates":{"latitude":52.59195,"longitude":13.410584},"weight":412,"products":["STRASSENBAHN"],"locationType":"ST"},{"name":"Schillerstr., Schöneiche b. Berlin","locationId":"A=1@O=Schillerstr., Schöneiche b. Berlin@X=13709386@Y=52476958@U=81@L=738128@B=1@p=1734378327@","evaNr":"738128","coordinates":{"latitude":52.47696,"longitude":13.709386},"weight":412,"products":["STRASSENBAHN"],"locationType":"ST"},{"name":"Schillerstr., Markdorf","locationId":"A=1@O=Schillerstr., Markdorf@X=9386033@Y=47727336@U=81@L=801101@B=1@p=1734378327@","evaNr":"801101","coordinates":{"latitude":47.727337,"longitude":9.386033},"weight":272,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"},{"name":"Schillerstr., Salzwedel","locationId":"A=1@O=Schillerstr., Salzwedel@X=11160072@Y=52850918@U=81@L=957938@B=1@p=1734378327@","evaNr":"957938","coordinates":{"latitude":52.850918,"longitude":11.160072},"weight":272,"products":["BUSSE","ANRUFPFLICHTIGEVERKEHRE"],"locationType":"ST"}] -------------------------------------------------------------------------------- /docs/dumps/PCAPdroid_18_Dec_23_20_05_routesearch_fromaddr.txt: -------------------------------------------------------------------------------- 1 | POST /mob/angebote/fahrplan HTTP/1.1 2 | Accept: application/x.db.vendo.mob.verbindungssuche.v8+json 3 | x-feature-reiseketten-enabled: false 4 | X-Correlation-ID: 84f2ab9b-d4a7-4e98-9a29-113783f0d431_5a24d491-8e2e-4cd8-85b5-ddd1d7d322c8 5 | X-Device-Os-Name: Android 6 | X-Device-Os-Version: 32 7 | X-Device-Model: Google Pixel 3a 8 | X-App-Version: 24.32.2 9 | Accept-Language: en,de 10 | X-INSTANA-ANDROID: a33cf0fb-5fd8-4c2f-befa-d4c1963cf3b7 11 | Content-Type: application/x.db.vendo.mob.verbindungssuche.v8+json 12 | Content-Length: 686 13 | Host: app.vendo.noncd.db.de 14 | Connection: Keep-Alive 15 | Accept-Encoding: gzip 16 | User-Agent: okhttp/4.12.0 17 | 18 | {"autonomeReservierung":false,"einstiegsTypList":["STANDARD"],"klasse":"KLASSE_2","reiseHin":{"wunsch":{"abgangsLocationId":"A\u003d2@O\u003dBerlin - Bohnsdorf, Schillerstraße 10@H\u003d10@X\u003d13585128@Y\u003d52398257@U\u003d92@L\u003d980126874@B\u003d1@p\u003d1706613073@","verkehrsmittel":["ALL"],"zeitWunsch":{"reiseDatum":"2024-12-18T22:38:09.928078+01:00","zeitPunktArt":"ABFAHRT"},"zielLocationId":"A\u003d1@O\u003dBerlin Hbf@X\u003d13369549@Y\u003d52525589@U\u003d81@L\u003d8011160@B\u003d1@p\u003d1734031727@i\u003dU×008065969@"}},"reisendenProfil":{"reisende":[{"ermaessigungen":["BAHNCARD25 KLASSE_2"],"reisendenTyp":"SENIOR"}]},"reservierungsKontingenteVorhanden":false} -------------------------------------------------------------------------------- /docs/dumps/readme.md: -------------------------------------------------------------------------------- 1 | # Dumps from DB Navigator API app.vendo.noncd.db.de 2 | 3 | In this directory, some intercepted traffic from DB Navigator. (Repo license does not apply to this directory.) 4 | 5 | You can browse some responses of the bahn.de API and others in the [fixtures](https://github.com/public-transport/db-vendo-client/tree/main/test/fixtures) and [e2e fixtures](https://github.com/public-transport/db-vendo-client/tree/main/test/e2e/fixtures). 6 | 7 | How to intercept DB Navigator traffic: 8 | 9 | 1. Download/extract Split APK 10 | 2. Merge APK (e.g. using [APKEditor](https://github.com/REAndroid/APKEditor)) 11 | 3. decompile using apktool 12 | 4. edit [res/xml/network_security_config.xml](https://developer.android.com/privacy-and-security/security-config) to allow user CAs not just in debug 13 | 5. recompile using apktool, sign 14 | 6. install on an Android 15 | 7. intercept with a mitm decryption tool of your choice by installing CA cert into Android store (e.g. [PCAPdroid](https://github.com/emanuele-f/PCAPdroid) with [mitm addon](https://github.com/emanuele-f/PCAPdroid-mitm), no root needed) 16 | -------------------------------------------------------------------------------- /docs/locations.md: -------------------------------------------------------------------------------- 1 | # `locations(query, [opt])` 2 | 3 | `query` must be an string (e.g. `'Alexanderplatz'`). 4 | 5 | With `opt`, you can override the default options, which look like this: 6 | 7 | ```js 8 | { 9 | fuzzy: true // not supported 10 | , results: 5 // how many search results? 11 | , stops: true // return stops/stations? 12 | , addresses: true 13 | , poi: true // points of interest 14 | , subStops: true // not supported 15 | , entrances: true // not supported 16 | , linesOfStops: false // not supported 17 | , language: 'en' // language to get results in 18 | } 19 | ``` 20 | 21 | ## Response 22 | 23 | 24 | ```js 25 | import {createClient} from 'db-vendo-client' 26 | import {profile as dbnavProfile} from 'db-vendo-client/p/dbnav/index.js' 27 | 28 | const userAgent = 'link-to-your-project-or-email' // adapt this to your project! 29 | const client = createClient(dbnavProfile, userAgent) 30 | 31 | await client.locations('Alexanderplatz', {results: 3}) 32 | ``` 33 | 34 | The result may look like this: 35 | 36 | ```js 37 | [ { 38 | type: 'stop', 39 | id: '900000100003', 40 | name: 'S+U Alexanderplatz', 41 | location: { 42 | type: 'location', 43 | latitude: 52.521508, 44 | longitude: 13.411267 45 | }, 46 | products: { 47 | suburban: true, 48 | subway: true, 49 | tram: true, 50 | bus: true, 51 | ferry: false, 52 | express: false, 53 | regional: true 54 | } 55 | }, { // point of interest 56 | type: 'location', 57 | id: '900980709', 58 | poi: true, 59 | name: 'Berlin, Holiday Inn Centre Alexanderplatz****', 60 | latitude: 52.523549, 61 | longitude: 13.418441 62 | }, { // point of interest 63 | type: 'location', 64 | id: '900980176', 65 | poi: true, 66 | name: 'Berlin, Hotel Agon am Alexanderplatz', 67 | latitude: 52.524556, 68 | longitude: 13.420266 69 | } ] 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/nearby.md: -------------------------------------------------------------------------------- 1 | # `nearby(location, [opt])` 2 | 3 | This method can be used to find stops/stations & POIs close to a location. Note that it is not supported by every profile/endpoint. 4 | 5 | `location` must be an [*FPTF* `location` object](https://github.com/public-transport/friendly-public-transport-format/blob/3bd36faa721e85d9f5ca58fb0f38cdbedb87bbca/spec/readme.md#location-objects). 6 | 7 | With `opt`, you can override the default options, which look like this: 8 | 9 | ```js 10 | { 11 | results: 8, // maximum number of results 12 | distance: null, // maximum walking distance in meters 13 | poi: false, // not supported 14 | stops: true, // return stops/stations? 15 | subStops: true, // not supported 16 | entrances: true, // not supported 17 | linesOfStops: false, // not supported 18 | language: 'en' // language to get results in 19 | } 20 | ``` 21 | 22 | ## Response 23 | 24 | ```js 25 | import {createClient} from 'db-vendo-client' 26 | import {profile as dbProfile} from 'db-vendo-client/p/db/index.js' 27 | 28 | const userAgent = 'link-to-your-project-or-email' // adapt this to your project! 29 | const client = createClient(dbProfile, userAgent) 30 | 31 | await client.nearby({ 32 | type: 'location', 33 | latitude: 52.5137344, 34 | longitude: 13.4744798 35 | }, {distance: 400}) 36 | ``` 37 | 38 | The result may look like this: 39 | 40 | ```js 41 | [ { 42 | type: 'stop', 43 | id: '900000120001', 44 | name: 'S+U Frankfurter Allee', 45 | location: { 46 | type: 'location', 47 | latitude: 52.513616, 48 | longitude: 13.475298 49 | }, 50 | products: { 51 | suburban: true, 52 | subway: true, 53 | tram: true, 54 | bus: true, 55 | ferry: false, 56 | express: false, 57 | regional: false 58 | }, 59 | distance: 56 60 | }, { 61 | type: 'stop', 62 | id: '900000120540', 63 | name: 'Scharnweberstr./Weichselstr.', 64 | location: { 65 | type: 'location', 66 | latitude: 52.512339, 67 | longitude: 13.470174 68 | }, 69 | products: { /* … */ }, 70 | distance: 330 71 | }, { 72 | type: 'stop', 73 | id: '900000160544', 74 | name: 'Rathaus Lichtenberg', 75 | location: { 76 | type: 'location', 77 | latitude: 52.515908, 78 | longitude: 13.479073 79 | }, 80 | products: { /* … */ }, 81 | distance: 394 82 | } ] 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/refresh-journey.md: -------------------------------------------------------------------------------- 1 | # `refreshJourney(refreshToken, [opt])` 2 | 3 | `refreshToken` must be a string, taken from `journey.refreshToken`. 4 | 5 | With `opt`, you can override the default options, which look like this: 6 | 7 | ```js 8 | { 9 | stopovers: false, // return stations on the way? 10 | polylines: false, // return a shape for each leg? mutually exclusive with tickets 11 | tickets: false, // return tickets? mutually exclusive with polylines 12 | subStops: true, // not supported 13 | entrances: true, // not supported 14 | remarks: true, // parse & expose hints & warnings? 15 | language: 'en' // language to get results in 16 | } 17 | ``` 18 | 19 | ## Response 20 | 21 | ```js 22 | import {createClient} from 'db-vendo-client' 23 | import {profile as dbProfile} from 'db-vendo-client/p/db/index.js' 24 | 25 | const userAgent = 'link-to-your-project-or-email' // adapt this to your project! 26 | const client = createClient(dbProfile, userAgent) 27 | 28 | const {journeys} = await client.journeys('8000105', '8000096', {results: 1}) 29 | 30 | // later, fetch up-to-date info on the journey 31 | const { 32 | journey, 33 | realtimeDataUpdatedAt, 34 | } = await client.refreshJourney(journeys[0].refreshToken, {stopovers: true, remarks: true}) 35 | ``` 36 | 37 | `journey` is a *single* [*Friendly Public Transport Format* v2 draft](https://github.com/public-transport/friendly-public-transport-format/blob/3bd36faa721e85d9f5ca58fb0f38cdbedb87bbca/spec/readme.md) `journey`, in the same format as returned by [`journeys()`](journeys.md). 38 | 39 | `realtimeDataUpdatedAt` is currently not set in db-vendo-client, because the upstream APIs don't provide it. 40 | -------------------------------------------------------------------------------- /docs/stop.md: -------------------------------------------------------------------------------- 1 | # `stop(id, [opt])` 2 | 3 | This endpoint is not available with `dbweb` profile. 4 | 5 | `id` must be in one of these formats: 6 | 7 | ```js 8 | // a stop/station ID, in a format compatible with the profile you use 9 | '900000123456' 10 | 11 | // an FPTF `stop`/`station` object 12 | { 13 | type: 'station', 14 | id: '900000123456', 15 | name: 'foo station', 16 | location: { 17 | type: 'location', 18 | latitude: 1.23, 19 | longitude: 3.21 20 | } 21 | } 22 | ``` 23 | 24 | With `opt`, you can override the default options, which look like this: 25 | 26 | ```js 27 | { 28 | subStops: true, // not supported 29 | entrances: true, // not supported 30 | linesOfStops: false, // parse & expose lines at the stop/station? 31 | language: 'en' // language to get results in 32 | } 33 | ``` 34 | 35 | ## Response 36 | 37 | 38 | ```js 39 | import {createClient} from 'hafas-client' 40 | import {profile as dbProfile} from 'hafas-client/p/db/index.js' 41 | 42 | const userAgent = 'link-to-your-project-or-email' // adapt this to your project! 43 | const client = createClient(dbProfile, userAgent) 44 | 45 | await client.stop('900000042101') // U Spichernstr. 46 | ``` 47 | 48 | The result may look like this: 49 | 50 | ```js 51 | { 52 | type: 'stop', 53 | id: '900000042101', 54 | name: 'U Spichernstr.', 55 | location: { 56 | type: 'location', 57 | latitude: 52.496581, 58 | longitude: 13.330616 59 | }, 60 | products: { 61 | suburban: false, 62 | subway: true, 63 | tram: false, 64 | bus: true, 65 | ferry: false, 66 | express: false, 67 | regional: false 68 | }, 69 | lines: [ { 70 | type: 'line', 71 | id: 'u1', 72 | mode: 'train', 73 | product: 'subway', 74 | public: true, 75 | name: 'U1', 76 | }, 77 | // … 78 | { 79 | type: 'line', 80 | id: 'n9', 81 | mode: 'bus', 82 | product: 'bus', 83 | public: true, 84 | name: 'N9', 85 | } ] 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/tests.md: -------------------------------------------------------------------------------- 1 | # automated tests in `db-vendo-client` 2 | 3 | Because transit data is inherently dynamic (e.g. a different set of departures being returned for a stop now than in 10 minutes), and because it is of paramount importance that `db-vendo-client` actually works with HAFAS endpoints *as they currently work*, its testing setup is a bit unusual. 4 | 5 | `db-vendo-client` has three kinds of automated tests: 6 | - unit tests, which test individual aspects of the case base in isolation (e.g. the parsing of HAFAS-formatted dates & times) – run via `npm run test-unit` 7 | - end-to-end (E2E) tests, which run actual HTTP requests against their respective profile's HAFAS endpoint – run via `npm run test-e2e` 8 | - integration tests, which are the E2E tests running against pre-recorded (and checked-in) HTTP request fixtures – run via `npm run test-integration` 9 | 10 | Because the E2E & integration tests are based on the same code, when changing this code, you should also update the integration test fixtures accordingly. 11 | 12 | *Note:* In order to be as reproducible as possible, the tests query transit data for a certain *fixed* point in time on the future, hard-coded in each profile's test suite (a.k.a. each file `test/e2e/*.js`). In combination with the recording & mocking of HTTP requests, this effectively makes the integration tests deterministic. 13 | 14 | ## adding integration test fixtures 15 | 16 | As an example, let's assume that we have added an entirely new test to [the *DB* profile's E2E tests](../test/e2e/db.js). 17 | 18 | The behaviour of the HTTP request recording (into fixtures) and mocking (using the recorded fixtures) is controlled via an environment variable `$VCR_MODE`: 19 | - By running the test(s) with `VCR_MODE=record`, we can record the HTTP requests being made. The tests will run just like without `$VCR_MODE`, except that they will query data for date+time specified in `T_MOCK` (e.g. [here](https://github.com/public-transport/db-vendo-client/blob/8ff945c07515155380de0acb33584e474d6d547c/test/e2e/db.js#L33)). 20 | - Then, by running the test(s) with `VCR_MODE=playback`, because their HTTP requests match the pre-recorded fixtures, they work on the corresponding mocked HTTP responses. 21 | 22 | Usually, you would not want to update all *already existing* recorded HTTP request fixtures of the test suite you have made changes in, as they are unrelated to the test you have added. To only record your *added* test, temporarily change `tap.test(…)` to read `tap.only(…)`, and run with `TAP_ONLY=1 VCR_MODE=record`; This will skip all unrelated tests entirely. 23 | 24 | Then, check the augmented fixtures (in `test/e2e/fixtures`) into Git, and revert the `tap.only(…)` change. To make sure that everything works, run the entire test suite/file (*without `TAP_ONLY=1`*) with `VCR_MODE=playback`. 25 | 26 | *Note:* It might be that the test suite/file you want to augment hasn't been updated in a while, so that `T_MOCK` is in the past. In this case, recording additional fixtures from actual HTTP requests of your added test is usually not possible because the HAFAS API is unable to serve old transit data. In this case, you will first have to change `T_MOCK` to a future date+time (a weekday as "normal" as possible) and re-record all tests' HTTP requests; Don't hesitate to get in touch with me if you have trouble with this. 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintPluginJs from '@eslint/js'; 2 | import eslintPluginStylistic from '@stylistic/eslint-plugin'; 3 | import globals from 'globals'; 4 | 5 | 6 | const config = [ 7 | eslintPluginJs.configs.recommended, 8 | eslintPluginStylistic.configs['all-flat'], 9 | { 10 | files: ['**/*.js'], 11 | languageOptions: { 12 | ecmaVersion: 'latest', 13 | globals: { 14 | ...globals.node, 15 | }, 16 | sourceType: 'module', 17 | }, 18 | rules: { 19 | '@stylistic/array-bracket-newline': ['error', 'consistent'], 20 | '@stylistic/array-element-newline': ['error', 'consistent'], 21 | '@stylistic/arrow-parens': 'off', 22 | '@stylistic/comma-dangle': ['error', 'always-multiline'], 23 | '@stylistic/dot-location': ['error', 'property'], 24 | '@stylistic/function-call-argument-newline': ['error', 'consistent'], 25 | '@stylistic/function-paren-newline': 'off', 26 | '@stylistic/indent': ['error', 'tab'], 27 | '@stylistic/indent-binary-ops': ['error', 'tab'], 28 | '@stylistic/max-len': 'off', 29 | '@stylistic/multiline-comment-style': 'off', 30 | '@stylistic/multiline-ternary': ['error', 'always-multiline'], 31 | '@stylistic/newline-per-chained-call': ['error', {ignoreChainWithDepth: 1}], 32 | '@stylistic/no-extra-parens': 'off', 33 | '@stylistic/no-mixed-operators': 'off', 34 | '@stylistic/no-tabs': 'off', 35 | '@stylistic/object-property-newline': 'off', 36 | '@stylistic/one-var-declaration-per-line': 'off', 37 | '@stylistic/operator-linebreak': ['error', 'before'], 38 | '@stylistic/padded-blocks': 'off', 39 | '@stylistic/quote-props': ['error', 'consistent-as-needed'], 40 | '@stylistic/quotes': ['error', 'single'], 41 | 'curly': 'error', 42 | 'no-implicit-coercion': 'error', 43 | 'no-unused-vars': [ 44 | 'error', 45 | { 46 | vars: 'all', 47 | args: 'none', 48 | ignoreRestSiblings: false, 49 | }, 50 | ], 51 | }, 52 | }, 53 | { 54 | files: ['test/**', '**/example.js'], 55 | rules: { 56 | 'no-unused-vars': 'off', 57 | '@stylistic/semi': 'off', 58 | }, 59 | }, 60 | ]; 61 | 62 | export default config; 63 | -------------------------------------------------------------------------------- /format/address.js: -------------------------------------------------------------------------------- 1 | import {formatLocationIdentifier} from './location-identifier.js'; 2 | import {formatCoord} from './coord.js'; 3 | 4 | const formatAddress = (a) => { 5 | if (a.type !== 'location' || !a.latitude || !a.longitude || !a.address) { 6 | throw new TypeError('invalid address'); 7 | } 8 | 9 | const data = { 10 | A: '2', // address? 11 | O: a.address, 12 | X: formatCoord(a.longitude), 13 | Y: formatCoord(a.latitude), 14 | }; 15 | if (a.id) { 16 | data.L = a.id; 17 | } 18 | return { 19 | type: 'A', // address 20 | name: a.address, 21 | lid: formatLocationIdentifier(data), 22 | }; 23 | }; 24 | 25 | export { 26 | formatAddress, 27 | }; 28 | -------------------------------------------------------------------------------- /format/coord.js: -------------------------------------------------------------------------------- 1 | const formatCoord = x => Math.round(x * 1000000); 2 | 3 | export { 4 | formatCoord, 5 | }; 6 | -------------------------------------------------------------------------------- /format/date.js: -------------------------------------------------------------------------------- 1 | import {DateTime, IANAZone} from 'luxon'; 2 | import {luxonIANAZonesByProfile as timezones} from '../lib/luxon-timezones.js'; 3 | 4 | // todo: change to `(profile) => (when) => {}` 5 | const formatDate = (profile, when) => { 6 | let timezone; 7 | if (timezones.has(profile)) { 8 | timezone = timezones.get(profile); 9 | } else { 10 | timezone = new IANAZone(profile.timezone); 11 | timezones.set(profile, timezone); 12 | } 13 | 14 | return DateTime 15 | .fromMillis(Number(when), { 16 | locale: profile.locale, 17 | zone: timezone, 18 | }) 19 | .toFormat('yyyy-MM-dd'); 20 | }; 21 | 22 | export { 23 | formatDate, 24 | }; 25 | -------------------------------------------------------------------------------- /format/location-identifier.js: -------------------------------------------------------------------------------- 1 | const sep = '@'; 2 | 3 | const formatLocationIdentifier = (data) => { 4 | let str = ''; 5 | for (let key in data) { 6 | if (!Object.prototype.hasOwnProperty.call(data, key)) { 7 | continue; 8 | } 9 | 10 | str += key + '=' + data[key] + sep; // todo: escape, but how? 11 | } 12 | 13 | return str; 14 | }; 15 | 16 | export { 17 | formatLocationIdentifier, 18 | }; 19 | -------------------------------------------------------------------------------- /format/location.js: -------------------------------------------------------------------------------- 1 | const formatLocation = (profile, l, name = 'location') => { 2 | if ('string' === typeof l) { 3 | return profile.formatStation(l); 4 | } 5 | if ('object' === typeof l && !Array.isArray(l)) { 6 | if (l.type === 'station' || l.type === 'stop') { 7 | return profile.formatStation(l.id); 8 | } 9 | if (l.poi) { 10 | return profile.formatPoi(l); 11 | } 12 | if ('string' === typeof l.address) { 13 | return profile.formatAddress(l); 14 | } 15 | if (!l.type) { 16 | throw new TypeError(`missing ${name}.type`); 17 | } 18 | throw new TypeError(`invalid ${name}.type: ${l.type}`); 19 | } 20 | throw new TypeError(name + ': valid station, address or poi required.'); 21 | }; 22 | 23 | export { 24 | formatLocation, 25 | }; 26 | -------------------------------------------------------------------------------- /format/loyalty-cards.js: -------------------------------------------------------------------------------- 1 | const c = { 2 | NONE: Symbol('no loyalty card'), 3 | BAHNCARD: Symbol('Bahncard'), 4 | VORTEILSCARD: Symbol('VorteilsCard'), 5 | HALBTAXABO: Symbol('HalbtaxAbo'), 6 | VOORDEELURENABO: Symbol('Voordeelurenabo'), 7 | SHCARD: Symbol('SH-Card'), 8 | GENERALABONNEMENT: Symbol('General-Abonnement'), 9 | NL_40: Symbol('NL-40%'), 10 | AT_KLIMATICKET: Symbol('AT-KlimaTicket'), 11 | }; 12 | 13 | const formatLoyaltyCard = (data) => { 14 | if (!data) { 15 | return { 16 | art: 'KEINE_ERMAESSIGUNG', 17 | klasse: 'KLASSENLOS', 18 | }; 19 | } 20 | const cls = data.class === 1 ? 'KLASSE_1' : 'KLASSE_2'; 21 | if (data.type.toString() === c.BAHNCARD.toString()) { 22 | return { 23 | art: 'BAHNCARD' + (data.business ? 'BUSINESS' : '') + data.discount, 24 | klasse: cls, 25 | }; 26 | } 27 | if (data.type.toString() === c.VORTEILSCARD.toString()) { 28 | return { 29 | art: 'A-VORTEILSCARD', 30 | klasse: 'KLASSENLOS', 31 | }; 32 | } 33 | if (data.type.toString() === c.HALBTAXABO.toString()) { 34 | return { 35 | art: 'CH-HALBTAXABO_OHNE_RAILPLUS', 36 | klasse: 'KLASSENLOS', 37 | }; 38 | } 39 | if (data.type.toString() === c.GENERALABONNEMENT.toString()) { 40 | return { 41 | art: 'CH-GENERAL-ABONNEMENT', 42 | klasse: cls, 43 | }; 44 | } 45 | if (data.type.toString() === c.NL_40.toString()) { 46 | return { 47 | art: 'NL-40_OHNE_RAILPLUS', 48 | klasse: 'KLASSENLOS', 49 | }; 50 | } 51 | if (data.type.toString() === c.AT_KLIMATICKET.toString()) { 52 | return { 53 | art: 'KLIMATICKET_OE', 54 | klasse: 'KLASSENLOS', 55 | }; 56 | } 57 | return { 58 | art: 'KEINE_ERMAESSIGUNG', 59 | klasse: 'KLASSENLOS', 60 | }; 61 | }; 62 | export { 63 | c as data, 64 | formatLoyaltyCard, 65 | }; 66 | -------------------------------------------------------------------------------- /format/poi.js: -------------------------------------------------------------------------------- 1 | import {formatLocationIdentifier} from './location-identifier.js'; 2 | import {formatCoord} from './coord.js'; 3 | 4 | const formatPoi = (p) => { 5 | // todo: use Number.isFinite()! 6 | if (p.type !== 'location' || !p.latitude || !p.longitude || !p.id || !p.name) { 7 | throw new TypeError('invalid POI'); 8 | } 9 | 10 | return { 11 | type: 'P', // POI 12 | name: p.name, 13 | lid: formatLocationIdentifier({ 14 | A: '4', // POI? 15 | O: p.name, 16 | L: p.id, 17 | X: formatCoord(p.longitude), 18 | Y: formatCoord(p.latitude), 19 | }), 20 | }; 21 | }; 22 | 23 | export { 24 | formatPoi, 25 | }; 26 | -------------------------------------------------------------------------------- /format/products-filter.js: -------------------------------------------------------------------------------- 1 | const isObj = element => element !== null && 'object' === typeof element && !Array.isArray(element); 2 | 3 | const hasProp = (o, k) => Object.prototype.hasOwnProperty.call(o, k); 4 | 5 | const formatProductsFilter = (ctx, filter, key = 'vendo') => { 6 | if (!isObj(filter)) { 7 | throw new TypeError('products filter must be an object'); 8 | } 9 | const {profile} = ctx; 10 | 11 | const byProduct = {}; 12 | const defaultProducts = {}; 13 | for (let product of profile.products) { 14 | byProduct[product.id] = product; 15 | defaultProducts[product.id] = product.default; 16 | } 17 | filter = Object.assign({}, defaultProducts, filter); 18 | 19 | let products = []; 20 | let foundDeselected = false; 21 | for (let product in filter) { 22 | if (!hasProp(filter, product) || filter[product] !== true) { 23 | foundDeselected = true; 24 | continue; 25 | } 26 | if (!byProduct[product]) { 27 | throw new TypeError('unknown product ' + product); 28 | } 29 | products.push(byProduct[product][key]); 30 | } 31 | if (products.length === 0) { 32 | throw new Error('no products used'); 33 | } 34 | if (!foundDeselected && key == 'ris') { 35 | return undefined; 36 | } 37 | if (!foundDeselected && key == 'dbnav') { 38 | return ['ALL']; 39 | } 40 | 41 | return products; 42 | }; 43 | 44 | export { 45 | formatProductsFilter, 46 | }; 47 | -------------------------------------------------------------------------------- /format/station.js: -------------------------------------------------------------------------------- 1 | import {formatLocationIdentifier} from './location-identifier.js'; 2 | 3 | const isIBNR = /^\d{6,}$/; 4 | 5 | const formatStation = (id) => { 6 | if (!isIBNR.test(id)) { 7 | throw new Error('station ID must be an IBNR.'); 8 | } 9 | return { 10 | type: 'S', // station 11 | // todo: name necessary? 12 | lid: formatLocationIdentifier({ 13 | A: '1', // station? 14 | L: id, 15 | // todo: `p` – timestamp of when the ID was obtained 16 | }), 17 | }; 18 | }; 19 | 20 | export { 21 | formatStation, 22 | }; 23 | -------------------------------------------------------------------------------- /format/time.js: -------------------------------------------------------------------------------- 1 | import {DateTime, IANAZone} from 'luxon'; 2 | import {luxonIANAZonesByProfile as timezones} from '../lib/luxon-timezones.js'; 3 | 4 | const getTimezone = (profile) => { 5 | let timezone; 6 | if (timezones.has(profile)) { 7 | timezone = timezones.get(profile); 8 | } else { 9 | timezone = new IANAZone(profile.timezone); 10 | timezones.set(profile, timezone); 11 | } 12 | return timezone; 13 | }; 14 | 15 | const formatTime = (profile, when, includeOffset = false) => { 16 | const timezone = getTimezone(profile); 17 | 18 | return DateTime 19 | .fromMillis(Number(when), { 20 | locale: profile.locale, 21 | zone: timezone, 22 | }) 23 | .startOf('second') 24 | .toISO({includeOffset: includeOffset, suppressMilliseconds: true}); 25 | }; 26 | 27 | const formatTimeOfDay = (profile, when) => { 28 | const timezone = getTimezone(profile); 29 | 30 | return DateTime 31 | .fromMillis(Number(when), { 32 | locale: profile.locale, 33 | zone: timezone, 34 | }) 35 | .toFormat('HH:mm'); 36 | }; 37 | 38 | export { 39 | formatTime, 40 | formatTimeOfDay, 41 | }; 42 | 43 | 44 | -------------------------------------------------------------------------------- /format/transfers.js: -------------------------------------------------------------------------------- 1 | const formatTransfers = (transfers) => { 2 | if (transfers === -1) { // profiles may not accept -1: https://github.com/public-transport/db-vendo-client/issues/5 3 | return undefined; 4 | } 5 | return transfers; 6 | }; 7 | 8 | export { 9 | formatTransfers, 10 | }; 11 | -------------------------------------------------------------------------------- /format/travellers.js: -------------------------------------------------------------------------------- 1 | const formatTraveller = ({profile}, ageGroup, age, loyaltyCard) => { 2 | const tvlrAgeGroup = age 3 | ? profile.ageGroupFromAge(age) 4 | : ageGroup; 5 | let r = { 6 | typ: profile.ageGroupLabel[tvlrAgeGroup || profile.ageGroup.ADULT], 7 | anzahl: 1, 8 | alter: age 9 | ? [String(age)] 10 | : [], 11 | ermaessigungen: [profile.formatLoyaltyCard(loyaltyCard)], 12 | }; 13 | return r; 14 | }; 15 | 16 | const validateArr = (field, length) => { 17 | return !field || Array.isArray(field) && field.length == length; 18 | }; 19 | 20 | const formatTravellers = ({profile, opt}) => { 21 | if ('age' in opt && 'ageGroup' in opt) { 22 | throw new TypeError(`\ 23 | opt.age and opt.ageGroup are mutually exclusive. 24 | Pass in just opt.age, and the age group will calculated automatically.`); 25 | } 26 | let travellers = []; 27 | if (Array.isArray(opt.loyaltyCard) || Array.isArray(opt.age) || Array.isArray(opt.ageGroup)) { 28 | const len = opt.loyaltyCard?.length || opt.age?.length || opt.ageGroup?.length; 29 | if (!validateArr(opt.loyaltyCard, len) || !validateArr(opt.age, len) || !validateArr(opt.ageGroup, len)) { 30 | throw new TypeError('If any of loyaltyCard, age or ageGroup are an array, all given must be an array of the same length.'); 31 | } 32 | for (let i = 0; i < len; i++) { 33 | travellers.push(formatTraveller({profile}, opt.ageGroup && opt.ageGroup[i], opt.age && opt.age[i], opt.loyaltyCard && opt.loyaltyCard[i])); 34 | } 35 | } else { 36 | travellers.push(formatTraveller({profile}, opt.ageGroup, opt.age, opt.loyaltyCard)); 37 | } 38 | 39 | const basicCtrfReq = { 40 | klasse: opt.firstClass === true ? 'KLASSE_1' : 'KLASSE_2', 41 | reisende: travellers, 42 | }; 43 | return basicCtrfReq; 44 | }; 45 | 46 | export { 47 | formatTravellers, 48 | }; 49 | -------------------------------------------------------------------------------- /lib/age-group.js: -------------------------------------------------------------------------------- 1 | const ageGroup = { 2 | BABY: 'B', 3 | CHILD: 'K', 4 | YOUNG: 'Y', 5 | ADULT: 'E', 6 | SENIOR: 'S', 7 | upperBoundOf: { 8 | BABY: 6, 9 | CHILD: 15, 10 | YOUNG: 27, 11 | ADULT: 65, 12 | SENIOR: Infinity, 13 | }, 14 | }; 15 | 16 | const ageGroupLabel = { 17 | B: 'KLEINKIND', 18 | K: 'FAMILIENKIND', 19 | Y: 'JUGENDLICHER', 20 | E: 'ERWACHSENER', 21 | S: 'SENIOR', 22 | }; 23 | 24 | const ageGroupFromAge = (age) => { 25 | const {upperBoundOf} = ageGroup; 26 | if (age < upperBoundOf.BABY) { 27 | return ageGroup.BABY; 28 | } 29 | if (age < upperBoundOf.CHILD) { 30 | return ageGroup.CHILD; 31 | } 32 | if (age < upperBoundOf.YOUNG) { 33 | return ageGroup.YOUNG; 34 | } 35 | if (age < upperBoundOf.ADULT) { 36 | return ageGroup.ADULT; 37 | } 38 | if (age < upperBoundOf.SENIOR) { 39 | return ageGroup.SENIOR; 40 | } 41 | throw new TypeError(`Invalid age '${age}'`); 42 | }; 43 | 44 | export { 45 | ageGroup, 46 | ageGroupLabel, 47 | ageGroupFromAge, 48 | }; 49 | -------------------------------------------------------------------------------- /lib/api-parsers.js: -------------------------------------------------------------------------------- 1 | import {data as cards} from '../format/loyalty-cards.js'; 2 | import {parseBoolean, parseInteger, parseArrayOfStrings} from 'hafas-rest-api/lib/parse.js'; 3 | 4 | const typesByName = new Map([ 5 | ['bahncard-1st-25', {type: cards.BAHNCARD, discount: 25, class: 1}], 6 | ['bahncard-2nd-25', {type: cards.BAHNCARD, discount: 25, class: 2}], 7 | ['bahncard-1st-50', {type: cards.BAHNCARD, discount: 50, class: 1}], 8 | ['bahncard-2nd-50', {type: cards.BAHNCARD, discount: 50, class: 2}], 9 | ['bahncard-1st-100', {type: cards.BAHNCARD, discount: 100, class: 1}], 10 | ['bahncard-2nd-100', {type: cards.BAHNCARD, discount: 100, class: 2}], 11 | ['vorteilscard', {type: cards.VORTEILSCARD}], 12 | ['halbtaxabo-railplus', {type: cards.HALBTAXABO}], 13 | ['halbtaxabo', {type: cards.HALBTAXABO}], 14 | ['voordeelurenabo-railplus', {type: cards.VOORDEELURENABO}], 15 | ['voordeelurenabo', {type: cards.VOORDEELURENABO}], 16 | ['shcard', {type: cards.SHCARD}], 17 | ['generalabonnement-1st', {type: cards.GENERALABONNEMENT, class: 1}], 18 | ['generalabonnement-2nd', {type: cards.GENERALABONNEMENT, class: 2}], 19 | ['generalabonnement', {type: cards.GENERALABONNEMENT}], 20 | ['nl-40', {type: cards.NL_40}], 21 | ['at-klimaticket', {type: cards.AT_KLIMATICKET}], 22 | ]); 23 | const types = Array.from(typesByName.keys()); 24 | 25 | const parseLoyaltyCard = (key, val) => { 26 | if (typesByName.has(val)) { 27 | return typesByName.get(val); 28 | } 29 | if (!val) { 30 | return null; 31 | } 32 | throw new Error(key + ' must be one of ' + types.join(', ')); 33 | }; 34 | 35 | const parseArrayOr = (parseEntry) => { 36 | return (key, val) => { 37 | if (Array.isArray(val)) { 38 | return val.map(e => parseEntry(key, e)); 39 | } 40 | return parseEntry(key, val); 41 | }; 42 | }; 43 | 44 | const mapRouteParsers = (route, parsers) => { 45 | if (route.includes('journey')) { 46 | return { 47 | ...parsers, 48 | firstClass: { 49 | description: 'Search for first-class options?', 50 | type: 'boolean', 51 | default: false, 52 | parse: parseBoolean, 53 | }, 54 | loyaltyCard: { 55 | description: 'Type of loyalty card in use.', 56 | type: 'string', 57 | enum: types, 58 | defaultStr: '*none*', 59 | parse: parseArrayOr(parseLoyaltyCard), 60 | }, 61 | age: { 62 | description: 'Age of traveller', 63 | type: 'integer', 64 | defaultStr: '*adult*', 65 | parse: parseArrayOr(parseInteger), 66 | }, 67 | notOnlyFastRoutes: { 68 | description: 'If true, also show routes that are mathematically non-optimal', 69 | type: 'boolean', 70 | default: false, 71 | parse: parseBoolean, 72 | }, 73 | bestprice: { 74 | description: 'Search for lowest prices across the entire day', 75 | type: 'boolean', 76 | default: false, 77 | parse: parseBoolean, 78 | }, 79 | deutschlandTicketDiscount: { 80 | description: 'Calculate ticket prices assuming Deutschlandticket is present', 81 | type: 'boolean', 82 | default: false, 83 | parse: parseBoolean, 84 | }, 85 | deutschlandTicketConnectionsOnly: { 86 | description: 'Only return journeys that can be used with the Deutschlandticket', 87 | type: 'boolean', 88 | default: false, 89 | parse: parseBoolean, 90 | }, 91 | }; 92 | } 93 | if (route.includes('departures') || route.includes('arrivals')) { 94 | return { 95 | ...parsers, 96 | moreStops: { 97 | description: 'Also include departures/arrivals for up to nine comma-separated station evaNumbers (not supported with dbnav and dbweb)', 98 | type: 'string', 99 | default: '', 100 | parse: parseArrayOfStrings, 101 | }, 102 | }; 103 | } 104 | return parsers; 105 | }; 106 | 107 | export { 108 | mapRouteParsers, 109 | }; 110 | -------------------------------------------------------------------------------- /lib/luxon-timezones.js: -------------------------------------------------------------------------------- 1 | // hafas-client profile -> luxon.IANAZone 2 | const luxonIANAZonesByProfile = new WeakMap(); 3 | 4 | export { 5 | luxonIANAZonesByProfile, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/products.js: -------------------------------------------------------------------------------- 1 | const products = [ 2 | { 3 | id: 'nationalExpress', 4 | mode: 'train', 5 | bitmasks: [1], 6 | name: 'InterCityExpress', 7 | short: 'ICE', 8 | vendo: 'ICE', 9 | ris: 'HIGH_SPEED_TRAIN', 10 | ris_alt: 'HIGH_SPEED_TRAIN', 11 | dbnav: 'HOCHGESCHWINDIGKEITSZUEGE', 12 | dbnav_short: 'ICE', 13 | default: true, 14 | }, 15 | { 16 | id: 'national', 17 | mode: 'train', 18 | bitmasks: [2], 19 | name: 'InterCity & EuroCity', 20 | short: 'IC/EC', 21 | vendo: 'EC_IC', 22 | ris: 'INTERCITY_TRAIN', 23 | ris_alt: 'INTERCITY_TRAIN', 24 | dbnav: 'INTERCITYUNDEUROCITYZUEGE', 25 | dbnav_short: 'IC_EC', 26 | default: true, 27 | }, 28 | { 29 | id: 'regionalExpress', 30 | mode: 'train', 31 | bitmasks: [4], 32 | name: 'RegionalExpress & InterRegio', // FlixTrain?? 33 | short: 'RE/IR', 34 | vendo: 'IR', 35 | ris: 'INTER_REGIONAL_TRAIN', 36 | ris_alt: 'INTER_REGIONAL_TRAIN', 37 | dbnav: 'INTERREGIOUNDSCHNELLZUEGE', 38 | dbnav_short: 'IR', 39 | default: true, 40 | }, 41 | { 42 | id: 'regional', 43 | mode: 'train', 44 | bitmasks: [8], 45 | name: 'Regio', 46 | short: 'RB', 47 | vendo: 'REGIONAL', 48 | ris: 'REGIONAL_TRAIN', 49 | ris_alt: 'REGIONAL_TRAIN', 50 | dbnav: 'NAHVERKEHRSONSTIGEZUEGE', 51 | dbnav_short: 'RB', 52 | default: true, 53 | }, 54 | { 55 | id: 'suburban', 56 | mode: 'train', 57 | bitmasks: [16], 58 | name: 'S-Bahn', 59 | short: 'S', 60 | vendo: 'SBAHN', 61 | ris: 'CITY_TRAIN', 62 | ris_alt: 'CITY_TRAIN', 63 | dbnav: 'SBAHNEN', 64 | dbnav_short: 'SBAHN', 65 | default: true, 66 | }, 67 | { 68 | id: 'bus', 69 | mode: 'bus', 70 | bitmasks: [32], 71 | name: 'Bus', 72 | short: 'B', 73 | vendo: 'BUS', 74 | ris: 'BUS', 75 | ris_alt: 'BUS', 76 | dbnav: 'BUSSE', 77 | dbnav_short: 'BUS', 78 | default: true, 79 | }, 80 | { 81 | id: 'ferry', 82 | mode: 'watercraft', 83 | bitmasks: [64], 84 | name: 'Ferry', 85 | short: 'F', 86 | vendo: 'SCHIFF', 87 | ris: 'FERRY', 88 | ris_alt: 'FERRY', 89 | dbnav: 'SCHIFFE', 90 | dbnav_short: 'SCHIFF', 91 | default: true, 92 | }, 93 | { 94 | id: 'subway', 95 | mode: 'train', 96 | bitmasks: [128], 97 | name: 'U-Bahn', 98 | short: 'U', 99 | vendo: 'UBAHN', 100 | ris: 'SUBWAY', 101 | ris_alt: 'SUBWAY', 102 | dbnav: 'UBAHN', 103 | dbnav_short: 'UBAHN', 104 | default: true, 105 | }, 106 | { 107 | id: 'tram', 108 | mode: 'train', 109 | bitmasks: [256], 110 | name: 'Tram', 111 | short: 'T', 112 | vendo: 'TRAM', 113 | ris: 'TRAM', 114 | ris_alt: 'TRAM', 115 | dbnav: 'STRASSENBAHN', 116 | dbnav_short: 'STR', 117 | default: true, 118 | }, 119 | { 120 | id: 'taxi', 121 | mode: 'taxi', 122 | bitmasks: [512], 123 | name: 'Group Taxi', 124 | short: 'Taxi', 125 | vendo: 'ANRUFPFLICHTIG', 126 | ris: 'TAXI', 127 | ris_alt: 'SHUTTLE', 128 | dbnav: 'ANRUFPFLICHTIGEVERKEHRE', 129 | dbnav_short: 'ANRUFPFLICHTIGEVERKEHRE', 130 | default: true, 131 | }, 132 | ]; 133 | 134 | export { 135 | products, 136 | }; 137 | -------------------------------------------------------------------------------- /lib/validate-profile.js: -------------------------------------------------------------------------------- 1 | const types = { 2 | locale: 'string', 3 | timezone: 'string', 4 | 5 | request: 'function', 6 | transformReq: 'function', 7 | transformReqBody: 'function', 8 | 9 | formatStationBoardReq: 'function', 10 | formatLocationsReq: 'function', 11 | formatStopReq: 'function', 12 | formatTripReq: 'function', 13 | formatRefreshJourneyReq: 'function', 14 | formatJourneysReq: 'function', 15 | transformJourneysQuery: 'function', 16 | 17 | products: 'array', 18 | 19 | parseDateTime: 'function', 20 | parseDeparture: 'function', 21 | parseArrival: 'function', 22 | parseJourneyLeg: 'function', 23 | parseJourney: 'function', 24 | parseLine: 'function', 25 | parseStationName: 'function', 26 | parseLocation: 'function', 27 | parsePolyline: 'function', 28 | parseOperator: 'function', 29 | parseRemarks: 'function', 30 | parseStopover: 'function', 31 | 32 | formatAddress: 'function', 33 | formatCoord: 'function', 34 | formatDate: 'function', 35 | formatLocationFilter: 'function', 36 | formatProductsFilter: 'function', 37 | formatPoi: 'function', 38 | formatStation: 'function', 39 | formatTime: 'function', 40 | formatLocation: 'function', 41 | formatRectangle: 'function', 42 | }; 43 | 44 | const validateProfile = (profile) => { 45 | for (let key of Object.keys(types)) { 46 | const type = types[key]; 47 | if (type === 'array') { 48 | if (!Array.isArray(profile[key])) { 49 | throw new TypeError(`profile.${key} must be an array.`); 50 | } 51 | } else if (type !== typeof profile[key]) { 52 | throw new TypeError(`profile.${key} must be a ${type}.`); 53 | } 54 | if (type === 'object' && profile[key] === null) { 55 | throw new TypeError(`profile.${key} must not be null.`); 56 | } 57 | } 58 | 59 | if (!Array.isArray(profile.products)) { 60 | throw new TypeError('profile.products must be an array.'); 61 | } 62 | if (profile.products.length === 0) { 63 | throw new Error('profile.products is empty.'); 64 | } 65 | for (let product of profile.products) { 66 | if ('string' !== typeof product.id) { 67 | throw new TypeError('profile.products[].id must be a string.'); 68 | } 69 | if ('boolean' !== typeof product.default) { 70 | throw new TypeError('profile.products[].default must be a boolean.'); 71 | } 72 | if (!Array.isArray(product.bitmasks)) { 73 | throw new TypeError(product.id + '.bitmasks must be an array.'); 74 | } 75 | for (let bitmask of product.bitmasks) { 76 | if ('number' !== typeof bitmask) { 77 | throw new TypeError(product.id + '.bitmasks[] must be a number.'); 78 | } 79 | } 80 | } 81 | 82 | if ('trip' in profile && 'boolean' !== typeof profile.trip) { 83 | throw new Error('profile.trip must be a boolean.'); 84 | } 85 | if ('journeyLeg' in profile) { 86 | throw new Error('profile.journeyLeg has been removed. Use profile.trip.'); 87 | } 88 | }; 89 | 90 | export { 91 | validateProfile, 92 | }; 93 | -------------------------------------------------------------------------------- /p/db/index.js: -------------------------------------------------------------------------------- 1 | import dbnavBase from '../dbnav/base.json' with { type: 'json' }; 2 | import dbregioguideBase from '../dbregioguide/base.json' with { type: 'json' }; 3 | import {products} from '../../lib/products.js'; 4 | 5 | // journeys() 6 | import {formatJourneysReq} from '../dbnav/journeys-req.js'; 7 | const {journeysEndpoint} = dbnavBase; 8 | 9 | // refreshJourneys() 10 | import {formatRefreshJourneyReq} from '../dbnav/journeys-req.js'; 11 | const {refreshJourneysEndpointTickets, refreshJourneysEndpointPolyline} = dbnavBase; 12 | 13 | // locations() 14 | import {formatLocationsReq} from '../dbnav/locations-req.js'; 15 | import {formatLocationFilter} from '../dbnav/location-filter.js'; 16 | const {locationsEndpoint} = dbnavBase; 17 | 18 | // stop() 19 | import {formatStopReq} from '../dbnav/stop-req.js'; 20 | import {parseStop} from '../dbnav/parse-stop.js'; 21 | const {stopEndpoint} = dbnavBase; 22 | 23 | // nearby() 24 | import {formatNearbyReq} from '../dbnav/nearby-req.js'; 25 | const {nearbyEndpoint} = dbnavBase; 26 | 27 | // trip() 28 | import {formatTripReq} from './trip-req.js'; 29 | const tripEndpoint_dbnav = dbnavBase.tripEndpoint; 30 | const tripEndpoint_dbregioguide = dbregioguideBase.tripEndpoint; 31 | 32 | // arrivals(), departures() 33 | import {formatStationBoardReq} from '../dbregioguide/station-board-req.js'; 34 | const {boardEndpoint} = dbregioguideBase; 35 | 36 | const profile = { 37 | locale: 'de-DE', 38 | timezone: 'Europe/Berlin', 39 | 40 | products, 41 | 42 | formatJourneysReq, 43 | journeysEndpoint, 44 | 45 | formatRefreshJourneyReq, 46 | refreshJourneysEndpointTickets, refreshJourneysEndpointPolyline, 47 | 48 | formatLocationsReq, formatLocationFilter, 49 | locationsEndpoint, 50 | 51 | formatStopReq, parseStop, 52 | stopEndpoint, 53 | 54 | formatNearbyReq, 55 | nearbyEndpoint, 56 | 57 | formatTripReq, 58 | tripEndpoint_dbnav, tripEndpoint_dbregioguide, 59 | 60 | formatStationBoardReq, 61 | boardEndpoint, 62 | }; 63 | 64 | 65 | export { 66 | profile, 67 | }; 68 | -------------------------------------------------------------------------------- /p/db/trip-req.js: -------------------------------------------------------------------------------- 1 | import {formatTripReq as hafasFormatTripReq} from '../dbnav/trip-req.js'; 2 | import {formatTripReq as risTripReq} from '../dbregioguide/trip-req.js'; 3 | 4 | 5 | const formatTripReq = ({profile, opt}, id) => { 6 | const _profile = {...profile}; 7 | if (id.includes('#')) { 8 | _profile['tripEndpoint'] = profile.tripEndpoint_dbnav; 9 | return hafasFormatTripReq({profile: _profile, opt}, id); 10 | } 11 | 12 | _profile['tripEndpoint'] = profile.tripEndpoint_dbregioguide; 13 | return risTripReq({profile: _profile, opt}, id); 14 | }; 15 | 16 | export { 17 | formatTripReq, 18 | }; 19 | -------------------------------------------------------------------------------- /p/dbbahnhof/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "boardEndpoint": "https://www.bahnhof.de/api/boards/", 3 | "defaultLanguage": "en" 4 | } 5 | -------------------------------------------------------------------------------- /p/dbbahnhof/index.js: -------------------------------------------------------------------------------- 1 | import baseProfile from './base.json' with { type: 'json' }; 2 | import {products} from '../../lib/products.js'; 3 | import {formatStationBoardReq} from './station-board-req.js'; 4 | 5 | const profile = { 6 | ...baseProfile, 7 | locale: 'de-DE', 8 | timezone: 'Europe/Berlin', 9 | 10 | products, 11 | 12 | formatStationBoardReq, 13 | 14 | journeysOutFrwd: false, 15 | departuresGetPasslist: true, 16 | departuresStbFltrEquiv: true, 17 | trip: false, 18 | radar: false, 19 | refreshJourney: false, 20 | journeysFromTrip: false, 21 | refreshJourneyUseOutReconL: false, 22 | tripsByName: false, 23 | remarks: false, 24 | remarksGetPolyline: false, 25 | reachableFrom: false, 26 | lines: false, 27 | }; 28 | 29 | export { 30 | profile, 31 | }; 32 | -------------------------------------------------------------------------------- /p/dbbahnhof/station-board-req.js: -------------------------------------------------------------------------------- 1 | import {stringify} from 'qs'; 2 | 3 | const formatStationBoardReq = (ctx, station, type) => { 4 | const {profile, opt} = ctx; 5 | 6 | if (opt.departure || opt.arrival) { 7 | throw new Error('opt.departure/opt.arrival is not supported for profile dbbahnhof, can only query for current time.'); 8 | } 9 | const evaNumbers = [station]; 10 | if (opt.moreStops) { 11 | evaNumbers.push(...opt.moreStops); 12 | } 13 | const query = { 14 | filterTransports: profile.formatProductsFilter(ctx, opt.products || {}, 'ris_alt'), 15 | evaNumbers: evaNumbers, 16 | duration: opt.duration, 17 | sortBy: 'TIME_SCHEDULE', 18 | locale: opt.language, 19 | }; 20 | 21 | return { 22 | endpoint: profile.boardEndpoint, 23 | path: type + '?' + stringify(query, {arrayFormat: 'repeat', encodeValuesOnly: true}), 24 | method: 'get', 25 | }; 26 | }; 27 | 28 | export { 29 | formatStationBoardReq, 30 | }; 31 | -------------------------------------------------------------------------------- /p/dbnav/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "journeysEndpoint": "https://app.vendo.noncd.db.de/mob/angebote/fahrplan", 3 | "bestpriceEndpoint": "https://app.vendo.noncd.db.de/mob/angebote/tagesbestpreis", 4 | "refreshJourneysEndpointTickets": "https://app.vendo.noncd.db.de/mob/angebote/recon", 5 | "refreshJourneysEndpointPolyline": "https://app.vendo.noncd.db.de/mob/trip/recon", 6 | "locationsEndpoint": "https://app.vendo.noncd.db.de/mob/location/search", 7 | "stopEndpoint": "https://app.vendo.noncd.db.de/mob/location/details/", 8 | "nearbyEndpoint": "https://app.vendo.noncd.db.de/mob/location/nearby", 9 | "tripEndpoint": "https://app.vendo.noncd.db.de/mob/zuglauf/", 10 | "boardEndpoint": "https://app.vendo.noncd.db.de/mob/bahnhofstafel/", 11 | "defaultLanguage": "en" 12 | } 13 | -------------------------------------------------------------------------------- /p/dbnav/header.js: -------------------------------------------------------------------------------- 1 | import {v4 as uuidv4} from 'uuid'; 2 | 3 | const getHeaders = (contentType) => { 4 | return { 5 | 'X-Correlation-ID': uuidv4() + '_' + uuidv4(), 6 | 'Accept': contentType, 7 | 'Content-Type': contentType, 8 | }; 9 | }; 10 | 11 | export { 12 | getHeaders, 13 | }; 14 | -------------------------------------------------------------------------------- /p/dbnav/index.js: -------------------------------------------------------------------------------- 1 | import baseProfile from './base.json' with { type: 'json' }; 2 | import {products} from '../../lib/products.js'; 3 | import {formatJourneysReq, formatRefreshJourneyReq} from './journeys-req.js'; 4 | import {formatTripReq} from './trip-req.js'; 5 | import {formatLocationFilter} from './location-filter.js'; 6 | import {formatLocationsReq} from './locations-req.js'; 7 | import {formatStopReq} from './stop-req.js'; 8 | import {formatNearbyReq} from './nearby-req.js'; 9 | import {formatStationBoardReq} from './station-board-req.js'; 10 | import {parseStop} from './parse-stop.js'; 11 | import {parseJourney} from './parse-journey.js'; 12 | 13 | const profile = { 14 | ...baseProfile, 15 | locale: 'de-DE', 16 | timezone: 'Europe/Berlin', 17 | 18 | products, 19 | formatJourneysReq, 20 | formatRefreshJourneyReq, 21 | formatTripReq, 22 | formatNearbyReq, 23 | formatLocationsReq, 24 | formatStopReq, 25 | formatStationBoardReq, 26 | formatLocationFilter, 27 | 28 | parseJourney, 29 | parseStop, 30 | }; 31 | 32 | export { 33 | profile, 34 | }; 35 | -------------------------------------------------------------------------------- /p/dbnav/journeys-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatBaseJourneysReq = (ctx) => { 4 | // TODO opt.accessibility 5 | // TODO routingMode 6 | const travellers = ctx.profile.formatTravellers(ctx); 7 | return { 8 | autonomeReservierung: false, 9 | einstiegsTypList: [ 10 | 'STANDARD', 11 | ], 12 | fahrverguenstigungen: { 13 | deutschlandTicketVorhanden: ctx.opt.deutschlandTicketDiscount, 14 | nurDeutschlandTicketVerbindungen: ctx.opt.deutschlandTicketConnectionsOnly, 15 | }, 16 | klasse: travellers.klasse, 17 | reisendenProfil: { 18 | reisende: travellers.reisende.map(t => { 19 | return { 20 | ermaessigungen: [ 21 | t.ermaessigungen[0].art + ' ' + t.ermaessigungen[0].klasse, 22 | ], 23 | reisendenTyp: t.typ, 24 | alter: t.alter.length && parseInt(t.alter[0]) || undefined, 25 | }; 26 | }), 27 | }, 28 | reservierungsKontingenteVorhanden: false, 29 | }; 30 | }; 31 | 32 | const formatJourneysReq = (ctx, from, to, when, outFrwd, journeysRef) => { 33 | const {profile, opt} = ctx; 34 | 35 | from = profile.formatLocation(profile, from, 'from'); 36 | to = profile.formatLocation(profile, to, 'to'); 37 | const filters = profile.formatProductsFilter({profile}, opt.products || {}, 'dbnav'); 38 | const transfers = profile.formatTransfers(opt.transfers) ?? undefined; // `dbnav` does not allow `null` here 39 | // TODO opt.accessibility 40 | // TODO routingMode 41 | let query = formatBaseJourneysReq(ctx); 42 | query.reiseHin = { 43 | wunsch: { 44 | abgangsLocationId: from.lid, 45 | verkehrsmittel: filters, 46 | zeitWunsch: { 47 | reiseDatum: profile.formatTime(profile, when, true), 48 | zeitPunktArt: outFrwd ? 'ABFAHRT' : 'ANKUNFT', 49 | }, 50 | viaLocations: opt.via 51 | ? [{locationId: profile.formatLocation(profile, opt.via, 'opt.via').lid}] 52 | : undefined, 53 | zielLocationId: to.lid, 54 | maxUmstiege: transfers, 55 | minUmstiegsdauer: opt.transferTime || undefined, 56 | fahrradmitnahme: opt.bike, 57 | }, 58 | }; 59 | if (journeysRef) { 60 | query.reiseHin.wunsch.context = journeysRef; 61 | } 62 | if (opt.notOnlyFastRoutes) { 63 | query.reiseHin.wunsch.economic = true; 64 | } 65 | return { 66 | endpoint: opt.bestprice ? profile.bestpriceEndpoint : profile.journeysEndpoint, 67 | body: query, 68 | headers: getHeaders('application/x.db.vendo.mob.verbindungssuche.v8+json'), 69 | method: 'post', 70 | }; 71 | }; 72 | 73 | const formatRefreshJourneyReq = (ctx, refreshToken) => { 74 | const {profile, opt} = ctx; 75 | let query = { 76 | reconCtx: refreshToken, 77 | }; 78 | if (opt.tickets) { 79 | query = formatBaseJourneysReq(ctx); 80 | query.verbindungHin = {kontext: refreshToken}; 81 | } 82 | return { 83 | endpoint: opt.tickets ? profile.refreshJourneysEndpointTickets : profile.refreshJourneysEndpointPolyline, 84 | body: query, 85 | headers: getHeaders('application/x.db.vendo.mob.verbindungssuche.v8+json'), 86 | method: 'post', 87 | }; 88 | }; 89 | 90 | export { 91 | formatJourneysReq, 92 | formatRefreshJourneyReq, 93 | }; 94 | -------------------------------------------------------------------------------- /p/dbnav/location-filter.js: -------------------------------------------------------------------------------- 1 | const formatLocationFilter = (stops, addresses, poi) => { 2 | if (stops && addresses && poi) { 3 | return ['ALL']; 4 | } 5 | const types = []; 6 | if (stops) { 7 | types.push('ST'); 8 | } 9 | if (addresses) { 10 | types.push('ADR'); 11 | } 12 | if (poi) { 13 | types.push('POI'); 14 | } 15 | return types; 16 | }; 17 | 18 | export { 19 | formatLocationFilter, 20 | }; 21 | -------------------------------------------------------------------------------- /p/dbnav/locations-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatLocationsReq = (ctx, query) => { 4 | const {profile, opt} = ctx; 5 | 6 | return { 7 | endpoint: profile.locationsEndpoint, 8 | body: { 9 | locationTypes: profile.formatLocationFilter(opt.stops, opt.addresses, opt.poi), 10 | searchTerm: query, 11 | maxResults: opt.results, 12 | }, 13 | headers: getHeaders('application/x.db.vendo.mob.location.v3+json'), 14 | method: 'post', 15 | }; 16 | }; 17 | 18 | export { 19 | formatLocationsReq, 20 | }; 21 | -------------------------------------------------------------------------------- /p/dbnav/nearby-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatNearbyReq = (ctx, location) => { 4 | const {profile, opt} = ctx; 5 | if (opt.distance > 10000) { 6 | throw new Error('maximum supported distance by this endpoint is 10000'); 7 | } 8 | // TODO location types 9 | return { 10 | endpoint: profile.nearbyEndpoint, 11 | body: { 12 | area: { 13 | coordinates: { 14 | longitude: location.longitude, 15 | latitude: location.latitude, 16 | }, 17 | radius: opt.distance || 10000, 18 | }, 19 | maxResults: opt.results, 20 | products: profile.formatProductsFilter(ctx, opt.products || {}, 'dbnav'), 21 | }, 22 | headers: getHeaders('application/x.db.vendo.mob.location.v3+json'), 23 | method: 'post', 24 | }; 25 | }; 26 | 27 | export { 28 | formatNearbyReq, 29 | }; 30 | -------------------------------------------------------------------------------- /p/dbnav/parse-journey.js: -------------------------------------------------------------------------------- 1 | import {parseJourney as parseJourneyDefault} from '../../parse/journey.js'; 2 | 3 | const parseJourney = (ctx, jj) => { 4 | const legs = (jj.verbindung || jj).verbindungsAbschnitte; 5 | if (legs.length > 0) { 6 | legs[0] = preprocessJourneyLeg(legs[0]); 7 | } 8 | if (legs.length > 1) { 9 | legs[legs.length - 1] = preprocessJourneyLeg(legs.at(-1)); 10 | } 11 | 12 | return parseJourneyDefault(ctx, jj); 13 | }; 14 | 15 | const preprocessJourneyLeg = (pt) => { // fixes https://github.com/public-transport/db-vendo-client/issues/24 16 | if (pt.typ === 'FUSSWEG' || pt.typ === 'TRANSFER') { 17 | pt.ezAbgangsDatum = correctRealtimeTimeZone(pt.abgangsDatum, pt.ezAbgangsDatum); 18 | pt.ezAnkunftsDatum = correctRealtimeTimeZone(pt.ankunftsDatum, pt.ezAnkunftsDatum); 19 | } 20 | 21 | return pt; 22 | }; 23 | 24 | const correctRealtimeTimeZone = (planned, realtime) => { 25 | if (planned && realtime) { 26 | const timeZoneOffsetRegex = /([+-]\d\d:\d\d|Z)$/; 27 | const timeZoneOffsetPlanned = timeZoneOffsetRegex.exec(planned)[0]; 28 | return realtime.replace(timeZoneOffsetRegex, timeZoneOffsetPlanned); 29 | } 30 | 31 | return realtime; 32 | }; 33 | 34 | export {parseJourney}; 35 | -------------------------------------------------------------------------------- /p/dbnav/parse-stop.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const parseStop = (ctx, l, id) => { 4 | const {profile, common, opt} = ctx; 5 | 6 | if (!l) { 7 | return null; 8 | } 9 | let stop = { 10 | type: 'station', 11 | id: id, 12 | name: l.haltName, 13 | }; 14 | 15 | stop.products = profile.parseProducts(ctx, l.produktGattungen?.map(p => p.produktGattung)); 16 | if (opt.linesOfStops) { 17 | stop.lines = l.produktGattungen?.flatMap(p => { 18 | const foundProduct = profile.products.find(pp => pp.dbnav == p.produktGattung); 19 | return p.produkte?.map(l => { 20 | return { 21 | type: 'line', 22 | name: l.name, 23 | productName: l.name && l.name.split(' ')[0] || undefined, 24 | mode: foundProduct?.mode, 25 | product: foundProduct?.id, 26 | public: true, 27 | }; 28 | }); 29 | }); 30 | } 31 | 32 | if (common && common.locations && common.locations[stop.id]) { 33 | delete stop.type; 34 | stop = { 35 | ...common.locations[stop.id], 36 | ...stop, 37 | }; 38 | } 39 | 40 | // TODO isMeta 41 | // TODO entrances 42 | return stop; 43 | }; 44 | 45 | export { 46 | parseStop, 47 | }; 48 | -------------------------------------------------------------------------------- /p/dbnav/station-board-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatStationBoardReq = (ctx, station, type) => { 4 | const {profile, opt} = ctx; 5 | 6 | return { 7 | endpoint: profile.boardEndpoint, 8 | path: type == 'departures' ? 'abfahrt' : 'ankunft', 9 | body: {anfragezeit: profile.formatTimeOfDay(profile, opt.when), datum: profile.formatDate(profile, opt.when), ursprungsBahnhofId: profile.formatStation(station).lid, verkehrsmittel: profile.formatProductsFilter(ctx, opt.products || {}, 'dbnav')}, 10 | method: 'POST', 11 | headers: getHeaders('application/x.db.vendo.mob.bahnhofstafeln.v2+json'), 12 | }; 13 | }; 14 | 15 | export { 16 | formatStationBoardReq, 17 | }; 18 | -------------------------------------------------------------------------------- /p/dbnav/stop-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatStopReq = (ctx, stopRef) => { 4 | const {profile} = ctx; 5 | 6 | return { 7 | endpoint: profile.stopEndpoint, 8 | path: stopRef, 9 | headers: getHeaders('application/x.db.vendo.mob.location.v3+json'), 10 | method: 'get', 11 | }; 12 | }; 13 | 14 | export { 15 | formatStopReq, 16 | }; 17 | -------------------------------------------------------------------------------- /p/dbnav/trip-req.js: -------------------------------------------------------------------------------- 1 | import {getHeaders} from './header.js'; 2 | 3 | const formatTripReq = ({profile, opt}, id) => { 4 | return { 5 | endpoint: profile.tripEndpoint, 6 | path: encodeURIComponent(id), 7 | headers: getHeaders('application/x.db.vendo.mob.zuglauf.v2+json'), 8 | method: 'get', 9 | }; 10 | }; 11 | 12 | export { 13 | formatTripReq, 14 | }; 15 | -------------------------------------------------------------------------------- /p/dbregioguide/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "tripEndpoint": "https://regio-guide.de/@prd/zupo-travel-information/api/public/ri/journey/", 3 | "boardEndpoint": "https://regio-guide.de/@prd/zupo-travel-information/api/public/ri/board/", 4 | "defaultLanguage": "en" 5 | } 6 | -------------------------------------------------------------------------------- /p/dbregioguide/index.js: -------------------------------------------------------------------------------- 1 | import baseProfile from './base.json' with { type: 'json' }; 2 | import {products} from '../../lib/products.js'; 3 | import {formatTripReq} from './trip-req.js'; 4 | import {formatStationBoardReq} from './station-board-req.js'; 5 | 6 | const profile = { 7 | ...baseProfile, 8 | locale: 'de-DE', 9 | timezone: 'Europe/Berlin', 10 | 11 | products, 12 | formatTripReq, 13 | formatStationBoardReq, 14 | 15 | journeysOutFrwd: false, 16 | departuresGetPasslist: false, 17 | departuresStbFltrEquiv: true, 18 | trip: false, 19 | radar: false, 20 | refreshJourney: false, 21 | journeysFromTrip: false, 22 | refreshJourneyUseOutReconL: false, 23 | tripsByName: false, 24 | remarks: false, 25 | remarksGetPolyline: false, 26 | reachableFrom: false, 27 | lines: false, 28 | }; 29 | 30 | export { 31 | profile, 32 | }; 33 | -------------------------------------------------------------------------------- /p/dbregioguide/station-board-req.js: -------------------------------------------------------------------------------- 1 | const formatStationBoardReq = (ctx, station, type) => { 2 | const {profile, opt} = ctx; 3 | 4 | if (opt.moreStops) { 5 | station += ',' + opt.moreStops.join(','); 6 | } 7 | 8 | return { 9 | endpoint: profile.boardEndpoint, 10 | path: (type == 'departures' ? 'departure' : 'arrival') + '/' + station, 11 | query: { 12 | // TODO direction, fields below 13 | modeOfTransport: profile.formatProductsFilter(ctx, opt.products || {}, 'ris'), 14 | timeStart: profile.formatTime(profile, opt.when, true), 15 | timeEnd: profile.formatTime(profile, opt.when.getTime() + opt.duration * 60 * 1000, true), 16 | expandTimeFrame: 'TIME_END', // TODO impact? 17 | }, 18 | method: 'get', 19 | }; 20 | }; 21 | 22 | export { 23 | formatStationBoardReq, 24 | }; 25 | -------------------------------------------------------------------------------- /p/dbregioguide/trip-req.js: -------------------------------------------------------------------------------- 1 | const formatTripReq = ({profile, opt}, id) => { 2 | return { 3 | endpoint: profile.tripEndpoint, 4 | path: id, 5 | method: 'get', 6 | }; 7 | }; 8 | 9 | export { 10 | formatTripReq, 11 | }; 12 | -------------------------------------------------------------------------------- /p/dbris/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "boardEndpoint": "https://apis.deutschebahn.com/db/apis/ris-boards/v1/public/", 3 | "defaultLanguage": "en" 4 | } 5 | -------------------------------------------------------------------------------- /p/dbris/index.js: -------------------------------------------------------------------------------- 1 | import baseProfile from './base.json' with { type: 'json' }; 2 | import {products} from '../../lib/products.js'; 3 | import {formatStationBoardReq} from './station-board-req.js'; 4 | 5 | const profile = { 6 | ...baseProfile, 7 | locale: 'de-DE', 8 | timezone: 'Europe/Berlin', 9 | 10 | products, 11 | 12 | formatStationBoardReq, 13 | 14 | journeysOutFrwd: false, 15 | departuresGetPasslist: true, 16 | departuresStbFltrEquiv: true, 17 | trip: false, 18 | radar: false, 19 | refreshJourney: false, 20 | journeysFromTrip: false, 21 | refreshJourneyUseOutReconL: false, 22 | tripsByName: false, 23 | remarks: false, 24 | remarksGetPolyline: false, 25 | reachableFrom: false, 26 | lines: false, 27 | }; 28 | 29 | export { 30 | profile, 31 | }; 32 | -------------------------------------------------------------------------------- /p/dbris/station-board-req.js: -------------------------------------------------------------------------------- 1 | const formatStationBoardReq = (ctx, station, type) => { 2 | const {profile, opt} = ctx; 3 | 4 | const query = { 5 | filterTransports: profile.formatProductsFilter(ctx, opt.products || {}, 'ris_alt'), 6 | timeStart: profile.formatTime(profile, opt.when, true), 7 | timeEnd: profile.formatTime(profile, opt.when.getTime() + opt.duration * 60 * 1000, true), 8 | includeStationGroup: opt.includeRelatedStations, 9 | maxTransportsPerType: opt.results === Infinity ? undefined : opt.results, 10 | includeMessagesDisruptions: opt.remarks, 11 | sortBy: 'TIME_SCHEDULE', 12 | }; 13 | if (!opt.stopovers) { 14 | query.maxViaStops = 0; 15 | } 16 | if (opt.moreStops) { 17 | station += ',' + opt.moreStops.join(','); 18 | } 19 | return { 20 | endpoint: profile.boardEndpoint, 21 | path: type + '/' + station, 22 | query: query, 23 | method: 'get', 24 | headers: { 25 | 'Db-Client-Id': process.env.DB_CLIENT_ID, 26 | 'Db-Api-Key': process.env.DB_API_KEY, 27 | 'Accept': 'application/vnd.de.db.ris+json', 28 | }, 29 | }; 30 | }; 31 | 32 | export { 33 | formatStationBoardReq, 34 | }; 35 | -------------------------------------------------------------------------------- /p/dbweb/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "journeysEndpoint": "https://int.bahn.de/web/api/angebote/fahrplan", 3 | "bestpriceEndpoint": "https://int.bahn.de/web/api/angebote/tagesbestpreis", 4 | "refreshJourneysEndpointTickets": "https://int.bahn.de/web/api/angebote/recon", 5 | "refreshJourneysEndpointPolyline": "https://int.bahn.de/web/api/reiseloesung/verbindung", 6 | "locationsEndpoint": "https://int.bahn.de/web/api/reiseloesung/orte", 7 | "nearbyEndpoint": "https://int.bahn.de/web/api/reiseloesung/orte/nearby", 8 | "tripEndpoint": "https://int.bahn.de/web/api/reiseloesung/fahrt", 9 | "boardEndpoint": "https://int.bahn.de/web/api/reiseloesung/", 10 | "defaultLanguage": "en" 11 | } 12 | -------------------------------------------------------------------------------- /p/dbweb/example.js: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util' 2 | import {createClient} from '../../index.js' 3 | import {profile as dbProfile} from './index.js' 4 | 5 | // Pick a descriptive user agent! hafas-client won't work with this string. 6 | const client = createClient(dbProfile, 'hafas-client-example', {enrichStations: true}) 7 | 8 | const berlinJungfernheide = '8011167' 9 | const münchenHbf = '8000261' 10 | const regensburgHbf = '8000309' 11 | 12 | let data = await client.locations('Berlin Jungfernheide') 13 | // let data = await client.locations('Atze Musiktheater', { 14 | // poi: true, 15 | // addresses: false, 16 | // fuzzy: false, 17 | // }) 18 | // let data = await client.nearby({ 19 | // type: 'location', 20 | // latitude: 52.4751309, 21 | // longitude: 13.3656537 22 | // }, {results: 1}) 23 | // let data = await client.reachableFrom({ 24 | // type: 'location', 25 | // address: '13353 Berlin-Wedding, Torfstr. 17', 26 | // latitude: 52.541797, 27 | // longitude: 13.350042 28 | // }, { 29 | // when: new Date('2018-08-27T10:00:00+0200'), 30 | // maxDuration: 50 31 | // }) 32 | 33 | // let data = await client.stop(regensburgHbf) 34 | 35 | // let data = await client.departures(berlinJungfernheide, {duration: 1}) 36 | // let data = await client.arrivals(berlinJungfernheide, {duration: 10, linesOfStops: true}) 37 | 38 | // let data = await client.journeys(berlinJungfernheide, münchenHbf, { 39 | // results: 1, 40 | // tickets: true, 41 | // }) 42 | // { 43 | // const [journey] = data.journeys 44 | // const leg = journey.legs[0] 45 | // data = await client.trip(leg.tripId, {polyline: true}) 46 | // } 47 | // { 48 | // const [journey] = data.journeys 49 | // data = await client.refreshJourney(journey.refreshToken, { 50 | // stopovers: true, 51 | // remarks: true, 52 | // }) 53 | // } 54 | 55 | // let data = await client.radar({ 56 | // north: 52.52411, 57 | // west: 13.41002, 58 | // south: 52.51942, 59 | // east: 13.41709 60 | // }, {results: 10}) 61 | 62 | // let data = await client.journeys('8011113', '8000261', { 63 | // departure: Date.now() - 2 * 60 * 60 * 1000, 64 | // results: 1, stopovers: true, transfers: 1 65 | // }) 66 | // { 67 | // const leg = journeys[0].legs.find(l => l.line && l.line.product === 'nationalExpress') 68 | // const prevStopover = leg.stopovers.find((st) => { 69 | // return st.departure && Date.parse(st.departure) < Date.now() 70 | // }) 71 | // data = await client.journeysFromTrip(leg.tripId, prevStopover, '8000207') 72 | // } 73 | 74 | console.log(inspect(data, {depth: null, colors: true})) 75 | -------------------------------------------------------------------------------- /p/dbweb/index.js: -------------------------------------------------------------------------------- 1 | import baseProfile from './base.json' with { type: 'json' }; 2 | import {products} from '../../lib/products.js'; 3 | import {formatJourneysReq, formatRefreshJourneyReq} from './journeys-req.js'; 4 | import {formatLocationFilter} from './location-filter.js'; 5 | import {formatLocationsReq} from './locations-req.js'; 6 | import {formatStationBoardReq} from './station-board-req.js'; 7 | import {formatTripReq} from './trip-req.js'; 8 | import {formatNearbyReq} from './nearby-req.js'; 9 | 10 | const profile = { 11 | ...baseProfile, 12 | locale: 'de-DE', 13 | timezone: 'Europe/Berlin', 14 | 15 | products, 16 | 17 | formatJourneysReq, 18 | formatRefreshJourneyReq, 19 | formatTripReq, 20 | formatNearbyReq, 21 | formatLocationsReq, 22 | formatStationBoardReq, 23 | formatLocationFilter, 24 | 25 | departuresGetPasslist: true, 26 | }; 27 | 28 | export { 29 | profile, 30 | }; 31 | -------------------------------------------------------------------------------- /p/dbweb/journeys-req.js: -------------------------------------------------------------------------------- 1 | const formatJourneysReq = (ctx, from, to, when, outFrwd, journeysRef) => { 2 | const {profile, opt} = ctx; 3 | 4 | from = profile.formatLocation(profile, from, 'from'); 5 | to = profile.formatLocation(profile, to, 'to'); 6 | const filters = profile.formatProductsFilter({profile}, opt.products || {}); 7 | const transfers = profile.formatTransfers(opt.transfers); 8 | // TODO opt.accessibility 9 | // TODO routingMode 10 | let query = { 11 | maxUmstiege: transfers, 12 | minUmstiegszeit: opt.transferTime, 13 | deutschlandTicketVorhanden: opt.deutschlandTicketDiscount, 14 | nurDeutschlandTicketVerbindungen: opt.deutschlandTicketConnectionsOnly, 15 | reservierungsKontingenteVorhanden: false, 16 | schnelleVerbindungen: !opt.notOnlyFastRoutes, 17 | sitzplatzOnly: false, 18 | abfahrtsHalt: from.lid, 19 | zwischenhalte: opt.via 20 | ? [{id: profile.formatLocation(profile, opt.via, 'opt.via').lid}] 21 | : null, 22 | ankunftsHalt: to.lid, 23 | produktgattungen: filters, 24 | bikeCarriage: opt.bike, 25 | // TODO 26 | // todo: this is actually "take additional stations nearby the given start and destination station into account" 27 | // see rest.exe docs 28 | // ushrp: Boolean(opt.startWithWalking), 29 | }; 30 | query.anfrageZeitpunkt = profile.formatTime(profile, when); 31 | if (journeysRef) { 32 | query.pagingReference = journeysRef; 33 | } 34 | query.ankunftSuche = outFrwd ? 'ABFAHRT' : 'ANKUNFT'; 35 | if (opt.results !== null) { 36 | // TODO query.numF = opt.results; 37 | } 38 | query = Object.assign(query, profile.formatTravellers(ctx)); 39 | return { 40 | endpoint: opt.bestprice ? profile.bestpriceEndpoint : profile.journeysEndpoint, 41 | body: query, 42 | method: 'post', 43 | }; 44 | }; 45 | 46 | const formatRefreshJourneyReq = (ctx, refreshToken) => { 47 | const {profile, opt} = ctx; 48 | if (opt.tickets) { 49 | let query = { 50 | ctxRecon: refreshToken, 51 | deutschlandTicketVorhanden: false, 52 | nurDeutschlandTicketVerbindungen: false, 53 | reservierungsKontingenteVorhanden: false, 54 | }; 55 | query = Object.assign(query, profile.formatTravellers(ctx)); 56 | return { 57 | endpoint: profile.refreshJourneysEndpointTickets, 58 | body: query, 59 | method: 'post', 60 | }; 61 | } else { 62 | return { 63 | endpoint: profile.refreshJourneysEndpointPolyline, 64 | body: { 65 | ctxRecon: refreshToken, 66 | poly: true, 67 | }, 68 | method: 'post', 69 | }; 70 | } 71 | }; 72 | 73 | export { 74 | formatJourneysReq, 75 | formatRefreshJourneyReq, 76 | }; 77 | -------------------------------------------------------------------------------- /p/dbweb/location-filter.js: -------------------------------------------------------------------------------- 1 | const formatLocationFilter = (stops, addresses, poi) => { 2 | if (!addresses && !poi) { // TODO other combos? 3 | return 'HALTESTELLEN'; 4 | } 5 | return 'ALL'; 6 | }; 7 | 8 | export { 9 | formatLocationFilter, 10 | }; 11 | -------------------------------------------------------------------------------- /p/dbweb/locations-req.js: -------------------------------------------------------------------------------- 1 | const formatLocationsReq = (ctx, query) => { 2 | const {profile, opt} = ctx; 3 | 4 | return { 5 | endpoint: profile.locationsEndpoint, 6 | query: { 7 | typ: profile.formatLocationFilter(opt.stops, opt.addresses, opt.poi), 8 | suchbegriff: query, 9 | limit: opt.results, 10 | }, 11 | method: 'get', 12 | }; 13 | }; 14 | 15 | export { 16 | formatLocationsReq, 17 | }; 18 | -------------------------------------------------------------------------------- /p/dbweb/nearby-req.js: -------------------------------------------------------------------------------- 1 | const formatNearbyReq = (ctx, location) => { 2 | const {profile, opt} = ctx; 3 | 4 | return { 5 | endpoint: profile.nearbyEndpoint, 6 | query: { 7 | long: location.longitude, 8 | lat: location.latitude, 9 | radius: opt.distance || undefined, 10 | products: profile.formatProductsFilter(ctx, opt.products || {}), 11 | // TODO getPOIs: Boolean(opt.poi), 12 | // TODO getStops: Boolean(opt.stops), 13 | maxNo: opt.results, 14 | }, 15 | method: 'get', 16 | }; 17 | }; 18 | 19 | export { 20 | formatNearbyReq, 21 | }; 22 | -------------------------------------------------------------------------------- /p/dbweb/station-board-req.js: -------------------------------------------------------------------------------- 1 | const formatStationBoardReq = (ctx, station, type) => { 2 | const {profile, opt} = ctx; 3 | 4 | return { 5 | endpoint: profile.boardEndpoint, 6 | path: type === 'departures' ? 'abfahrten' : 'ankuenfte', 7 | query: { 8 | ortExtId: station, 9 | zeit: profile.formatTimeOfDay(profile, opt.when), 10 | datum: profile.formatDate(profile, opt.when), 11 | mitVias: opt.stopovers || Boolean(opt.direction) || undefined, 12 | verkehrsmittel: profile.formatProductsFilter(ctx, opt.products || {}), 13 | }, 14 | method: 'GET', 15 | }; 16 | }; 17 | 18 | export { 19 | formatStationBoardReq, 20 | }; 21 | -------------------------------------------------------------------------------- /p/dbweb/trip-req.js: -------------------------------------------------------------------------------- 1 | const formatTripReq = ({profile, opt}, id) => { 2 | return { 3 | endpoint: profile.tripEndpoint, 4 | path: '', 5 | query: { 6 | journeyId: id, 7 | poly: opt.polyline || opt.polylines, 8 | }, 9 | method: 'get', 10 | }; 11 | }; 12 | 13 | export { 14 | formatTripReq, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-vendo-client", 3 | "description": "Client for bahn.de public transport APIs.", 4 | "version": "6.8.2", 5 | "type": "module", 6 | "main": "index.js", 7 | "files": [ 8 | "index.js", 9 | "throttle.js", 10 | "retry.js", 11 | "api.js", 12 | "lib", 13 | "parse", 14 | "format", 15 | "p", 16 | "docs", 17 | "!docs/dumps/*" 18 | ], 19 | "author": "Traines ", 20 | "contributors": [ 21 | "Jannis R ", 22 | "Julius Tens ", 23 | "n0emis ", 24 | "em0lar ", 25 | "Adrian Böhme (https://github.com/Adwirawien)", 26 | "Yureka ", 27 | "Kristjan Esperanto (https://github.com/KristjanESPERANTO)", 28 | "Marcel Radzio ", 29 | "Benoit Deldicque (https://github.com/bddq)", 30 | "roehrt", 31 | "Sören Wegener (https://soerface.de/)", 32 | "Paul Sutter ", 33 | "1Maxnet1", 34 | "McToel ", 35 | "Daniel Bund (https://github.com/dabund24)" 36 | ], 37 | "homepage": "https://github.com/public-transport/db-vendo-client", 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/public-transport/db-vendo-client.git" 41 | }, 42 | "bugs": "https://github.com/public-transport/db-vendo-client/issues", 43 | "funding": { 44 | "url": "https://github.com/sponsors/derhuerst" 45 | }, 46 | "license": "ISC", 47 | "keywords": [ 48 | "bahn.de", 49 | "hafas", 50 | "public", 51 | "transport", 52 | "transit", 53 | "api", 54 | "http" 55 | ], 56 | "packageManager": "npm@10.9.0", 57 | "engines": { 58 | "node": ">=18" 59 | }, 60 | "dependencies": { 61 | "content-type": "^1.0.5", 62 | "cross-fetch": "^4.1.0", 63 | "db-hafas-stations": "2.0.0", 64 | "gps-distance": "0.0.4", 65 | "https-proxy-agent": "^7.0.6", 66 | "luxon": "^3.5.0", 67 | "qs": "^6.14.0", 68 | "slugg": "^1.2.1", 69 | "uuid": "^11.1.0" 70 | }, 71 | "devDependencies": { 72 | "@eslint/js": "^9.21.0", 73 | "@pollyjs/adapter-node-http": "^6.0.6", 74 | "@pollyjs/core": "^6.0.6", 75 | "@pollyjs/persister-fs": "^6.0.6", 76 | "@stylistic/eslint-plugin": "^4.1.0", 77 | "cspell": "^8.17.5", 78 | "db-rest": "github:derhuerst/db-rest", 79 | "eslint": "^9.21.0", 80 | "globals": "^16.0.0", 81 | "hafas-rest-api": "^5.1.3", 82 | "is-coordinates": "^2.0.2", 83 | "is-roughly-equal": "^0.1.0", 84 | "p-retry": "^6.2.1", 85 | "p-throttle": "^7.0.0", 86 | "tap": "^20.0.3", 87 | "validate-fptf": "^3.0.0" 88 | }, 89 | "scripts": { 90 | "lint": "eslint", 91 | "lint:fix": "eslint --fix", 92 | "test-unit": "tap test/lib/*.js test/*.js test/format/*.js test/parse/*.js", 93 | "test-integration": "VCR_MODE=playback tap test/e2e/*.js", 94 | "test-integration:record": "VCR_MODE=record tap -t60 -j1 test/e2e/*.js", 95 | "test-e2e": "VCR_OFF=true tap -t60 -j16 test/e2e/*.js", 96 | "test-spelling": "cspell .", 97 | "test": "npm run test-unit && npm run test-integration && npm run test-spelling", 98 | "prepublishOnly": "npm run lint && npm test", 99 | "api": "node api.js" 100 | }, 101 | "tap": { 102 | "disable-coverage": true, 103 | "allow-empty-coverage": true, 104 | "plugin": [ 105 | "!@tapjs/typescript", 106 | "!@tapjs/mock", 107 | "!@tapjs/snapshot", 108 | "!@tapjs/intercept", 109 | "!@tapjs/fixture" 110 | ] 111 | }, 112 | "publishConfig": { 113 | "registry": "https://registry.npmjs.org" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /parse/arrival-or-departure.js: -------------------------------------------------------------------------------- 1 | const ARRIVAL = 'a'; 2 | const DEPARTURE = 'd'; 3 | 4 | const createParseArrOrDep = (prefix) => { 5 | if (prefix !== ARRIVAL && prefix !== DEPARTURE) { 6 | throw new Error('invalid prefix'); 7 | } 8 | 9 | const parseArrOrDep = (ctx, d) => { // d = raw arrival/departure 10 | const {profile, opt} = ctx; 11 | const cancelled = profile.parseCancelled(d); 12 | const res = { 13 | tripId: d.journeyID || d.journeyId || d.train?.journeyId || d.zuglaufId, 14 | stop: profile.parseLocation(ctx, d.station || d.abfrageOrt || d.stopPlace || {bahnhofsId: d.bahnhofsId}), 15 | ...profile.parseWhen( 16 | ctx, 17 | null, 18 | d.timeSchedule || d.time || d.zeit || d.abgangsDatum || d.ankunftsDatum, 19 | d.timeType != 'SCHEDULE' ? d.timePredicted || d.timeDelayed || d.time || d.ezZeit || d.ezAbgangsDatum || d.ezAnkunftsDatum : null, 20 | cancelled), 21 | ...profile.parsePlatform(ctx, d.platformSchedule || d.platform || d.gleis, d.platformPredicted || d.platform || d.ezGleis, cancelled), 22 | // prognosisType: TODO 23 | direction: d.transport?.direction?.stopPlaces?.length > 0 && profile.parseStationName(ctx, d.transport?.direction?.stopPlaces[0].name) || profile.parseStationName(ctx, d.destination?.name || d.richtung || d.terminus) || null, 24 | provenance: profile.parseStationName(ctx, d.transport?.origin?.name || d.origin?.name || d.abgangsOrt?.name) || null, 25 | line: profile.parseLine(ctx, d) || null, 26 | remarks: [], 27 | origin: profile.parseLocation(ctx, d.transport?.origin || d.origin) || null, 28 | destination: profile.parseLocation(ctx, d.transport?.destination || d.destination) || null, 29 | // loadFactor: profile.parseArrOrDepWithLoadFactor(ctx, d) 30 | }; 31 | 32 | // TODO pos 33 | 34 | if (cancelled) { 35 | res.cancelled = true; 36 | Object.defineProperty(res, 'canceled', {value: true}); 37 | } 38 | 39 | if (opt.remarks) { 40 | res.remarks = profile.parseRemarks(ctx, d); 41 | } 42 | 43 | if (opt.stopovers || opt.direction) { 44 | let stopovers = undefined; 45 | if (Array.isArray(d.ueber)) { 46 | stopovers = d.ueber 47 | .map(viaName => profile.parseStopover(ctx, {name: viaName}, null)); 48 | } else if (Array.isArray(d.transport?.via) || Array.isArray(d.viaStops)) { 49 | stopovers = (d.transport?.via || d.viaStops) 50 | .map(via => profile.parseStopover(ctx, via, null)); 51 | } 52 | if (stopovers) { 53 | if (prefix === ARRIVAL) { 54 | res.previousStopovers = stopovers; 55 | } else if (prefix === DEPARTURE) { 56 | res.nextStopovers = stopovers; 57 | } 58 | } 59 | } 60 | 61 | return res; 62 | }; 63 | 64 | return parseArrOrDep; 65 | }; 66 | 67 | export { 68 | createParseArrOrDep, 69 | }; 70 | -------------------------------------------------------------------------------- /parse/arrival.js: -------------------------------------------------------------------------------- 1 | import {createParseArrOrDep} from './arrival-or-departure.js'; 2 | 3 | const ARRIVAL = 'a'; 4 | const parseArrival = createParseArrOrDep(ARRIVAL); 5 | 6 | export { 7 | parseArrival, 8 | }; 9 | -------------------------------------------------------------------------------- /parse/date-time.js: -------------------------------------------------------------------------------- 1 | import {DateTime, IANAZone} from 'luxon'; 2 | import {luxonIANAZonesByProfile as timezones} from '../lib/luxon-timezones.js'; 3 | 4 | const parseDateTime = (ctx, date, time, timestamp = false) => { 5 | const {profile} = ctx; 6 | 7 | let timezone; 8 | if (timezones.has(profile)) { 9 | timezone = timezones.get(profile); 10 | } else { 11 | timezone = new IANAZone(profile.timezone); 12 | timezones.set(profile, timezone); 13 | } 14 | 15 | let dt = DateTime.fromISO(time, { 16 | locale: profile.locale, 17 | zone: timezone, 18 | }); 19 | return timestamp 20 | ? dt.toMillis() 21 | : dt.toISO({suppressMilliseconds: true}); 22 | }; 23 | 24 | export { 25 | parseDateTime, 26 | }; 27 | -------------------------------------------------------------------------------- /parse/departure.js: -------------------------------------------------------------------------------- 1 | import {createParseArrOrDep} from './arrival-or-departure.js'; 2 | 3 | const DEPARTURE = 'd'; 4 | const parseDeparture = createParseArrOrDep(DEPARTURE); 5 | 6 | export { 7 | parseDeparture, 8 | }; 9 | -------------------------------------------------------------------------------- /parse/journey.js: -------------------------------------------------------------------------------- 1 | import {parseRemarks} from './remarks.js'; 2 | 3 | const createFakeWalkingLeg = (prevLeg, leg) => { 4 | const fakeWalkingLeg = { 5 | origin: prevLeg.destination, 6 | destination: leg.origin, 7 | }; 8 | fakeWalkingLeg.departure = prevLeg.arrival; 9 | fakeWalkingLeg.plannedDeparture = prevLeg.plannedArrival; 10 | fakeWalkingLeg.departureDelay = prevLeg.delay; 11 | fakeWalkingLeg.arrival = fakeWalkingLeg.departure; 12 | fakeWalkingLeg.plannedArrival = fakeWalkingLeg.plannedDeparture; 13 | fakeWalkingLeg.arrivalDelay = fakeWalkingLeg.departureDelay; 14 | fakeWalkingLeg.public = true; 15 | fakeWalkingLeg.walking = true; 16 | fakeWalkingLeg.distance = null; 17 | return fakeWalkingLeg; 18 | }; 19 | 20 | const parseLocationsFromCtxRecon = (ctx, j) => { 21 | return (j.ctxRecon || j.kontext) 22 | .split('$') 23 | .map(e => ctx.profile.parseLocation(ctx, {id: e})) 24 | .filter(e => e.latitude || e.location?.latitude) 25 | .reduce((map, e) => { 26 | map[e.id] = e; 27 | map[e.name] = e; 28 | return map; 29 | }, {}); 30 | }; 31 | 32 | const trimJourneyId = (journeyId) => { 33 | if (!journeyId) { 34 | return null; 35 | } 36 | const endOfHafasId = journeyId.lastIndexOf('$'); 37 | if (endOfHafasId != -1) { 38 | return journeyId.substring(0, endOfHafasId + 1); 39 | } 40 | return journeyId; 41 | }; 42 | 43 | const parseJourney = (ctx, jj) => { // j = raw journey 44 | const {profile, opt} = ctx; 45 | const j = jj.verbindung || jj; 46 | const fallbackLocations = parseLocationsFromCtxRecon(ctx, j); 47 | const legs = []; 48 | for (const l of j.verbindungsAbschnitte) { 49 | const leg = profile.parseJourneyLeg(ctx, l, null, fallbackLocations); 50 | if (legs.length > 0 && !legs[legs.length - 1].walking && !leg.walking) { 51 | const fakeWalkingLeg = createFakeWalkingLeg(legs[legs.length - 1], leg); 52 | legs.push(fakeWalkingLeg); 53 | } 54 | legs.push(leg); 55 | } 56 | 57 | const res = { 58 | type: 'journey', 59 | legs, 60 | refreshToken: trimJourneyId(j.ctxRecon || j.kontext), 61 | }; 62 | 63 | // TODO freq 64 | 65 | if (opt.remarks) { 66 | res.remarks = parseRemarks(ctx, j); 67 | } 68 | 69 | // TODO 70 | if (opt.scheduledDays && j.serviceDays) { 71 | // todo [breaking]: rename to scheduledDates 72 | // TODO parse scheduledDays as before 73 | res.serviceDays = j.serviceDays.map(d => ({ 74 | irregular: d.irregular, 75 | lastDateInPeriod: d.lastDateInPeriod || d.letztesDatumInZeitraum, 76 | planningPeriodBegin: d.planningPeriodBegin || d.planungsZeitraumAnfang, 77 | planningPeriodEnd: d.planningPeriodEnd || d.planungsZeitraumEnde, 78 | regular: d.regular, 79 | weekdays: d.weekdays || d.wochentage, 80 | })); 81 | } 82 | 83 | res.price = profile.parsePrice(ctx, jj); 84 | const tickets = profile.parseTickets(ctx, jj); 85 | if (tickets) { 86 | res.tickets = tickets; 87 | } 88 | 89 | return res; 90 | }; 91 | 92 | export { 93 | parseJourney, 94 | }; 95 | -------------------------------------------------------------------------------- /parse/line.js: -------------------------------------------------------------------------------- 1 | import slugg from 'slugg'; 2 | 3 | const parseLine = (ctx, p) => { 4 | const profile = ctx.profile; 5 | const fahrtNr = p.verkehrsmittel?.nummer || p.transport?.number || p.train?.no || p.no || ((p.risZuglaufId || '') + '_').split('_')[1] || p.verkehrsmittelNummer || (p.verkehrmittel?.langText || p.verkehrsmittel?.langText || p.mitteltext || p.zugName || p.lineName || '').replace(/\D/g, ''); 6 | const res = { 7 | type: 'line', 8 | id: slugg(p.verkehrsmittel?.langText || p.verkehrmittel?.langText || p.transport?.journeyDescription || p.risZuglaufId || p.train && p.train.category + ' ' + p.train.lineName + ' ' + p.train.no || p.no && p.name + ' ' + p.no || p.langtext || p.mitteltext || p.zugName || p.lineName), // TODO terrible 9 | fahrtNr: String(fahrtNr), 10 | name: p.verkehrsmittel?.name || p.verkehrsmittel?.langText || p.verkehrmittel?.name || p.verkehrmittel?.langText || p.zugName || p.transport && p.transport.category + ' ' + p.transport.line || p.train && p.train.category + ' ' + p.train.lineName || p.name || p.mitteltext || p.langtext || p.lineName, 11 | public: true, 12 | }; 13 | 14 | const adminCode = p.administrationID || p.administrationId || p.administration?.id || p.administration?.administrationID; 15 | if (adminCode) { 16 | res.adminCode = adminCode; 17 | } 18 | res.productName = p.verkehrsmittel?.kurzText || p.verkehrmittel?.kurzText || p.transport?.category || p.train?.category || p.category || p.kurztext || p.lineName?.replace(/\d/g, ''); 19 | const foundProduct = profile.products.find(pp => pp.vendo == p.verkehrsmittel?.produktGattung || pp.vendo == p.verkehrmittel?.produktGattung || pp.ris == p.transport?.type || pp.ris == p.train?.type || pp.ris == p.type || pp.ris_alt == p.train?.type || pp.ris_alt == p.type || pp.dbnav_short == p.produktGattung); 20 | res.mode = foundProduct?.mode; 21 | res.product = foundProduct?.id; 22 | 23 | res.operator = profile.parseOperator(ctx, p.verkehrsmittel?.zugattribute || p.zugattribute || p.attributNotizen || p.administration || p); 24 | return res; 25 | }; 26 | 27 | export { 28 | parseLine, 29 | }; 30 | -------------------------------------------------------------------------------- /parse/load-factor.js: -------------------------------------------------------------------------------- 1 | // https://www.bahn.de/p/view/service/buchung/auslastungsinformation.shtml 2 | const loadFactors = []; 3 | loadFactors[1] = 'low-to-medium'; 4 | loadFactors[2] = 'high'; 5 | loadFactors[3] = 'very-high'; 6 | loadFactors[4] = 'exceptionally-high'; 7 | 8 | const parseLoadFactor = (opt, auslastung) => { 9 | if (!auslastung) { 10 | return null; 11 | } 12 | const cls = opt.firstClass === true 13 | ? 'KLASSE_1' 14 | : 'KLASSE_2'; 15 | const load = auslastung.find(a => a.klasse === cls)?.stufe; 16 | return load && loadFactors[load] || null; 17 | }; 18 | 19 | const parseArrOrDepWithLoadFactor = (ctx, d) => { 20 | 21 | /* const load = parseLoadFactor(opt, d); 22 | if (load) { 23 | parsed.loadFactor = load; 24 | }*/ // TODO 25 | return undefined; 26 | }; 27 | 28 | export { 29 | parseArrOrDepWithLoadFactor, 30 | parseLoadFactor, 31 | }; 32 | -------------------------------------------------------------------------------- /parse/location.js: -------------------------------------------------------------------------------- 1 | import {parse} from 'qs'; 2 | 3 | const POI = 'POI'; 4 | const STATION = 'ST'; 5 | const ADDRESS = 'ADR'; 6 | 7 | const leadingZeros = /^0+/; 8 | 9 | const parseLocation = (ctx, l) => { 10 | const {profile} = ctx; 11 | 12 | if (!l) { 13 | return null; 14 | } 15 | 16 | const lid = parse(l.id || l.locationId, {delimiter: '@'}); 17 | let res = { 18 | type: 'location', 19 | id: (l.extId || l.evaNr || lid.L || l.evaNumber || l.evaNo || l.bahnhofsId || '').replace(leadingZeros, '') || null, 20 | }; 21 | const name = l.name || lid.O; 22 | 23 | if (l.lat && l.lon || l.coordinates || l.position) { 24 | res.latitude = l.lat || l.coordinates?.latitude || l.position?.latitude; 25 | res.longitude = l.lon || l.coordinates?.longitude || l.position?.longitude; 26 | } else if ('X' in lid && 'Y' in lid) { 27 | res.latitude = lid.Y / 1000000; 28 | res.longitude = lid.X / 1000000; 29 | } 30 | 31 | // addresses and POIs might also have fake evaNr sometimes! 32 | if (l.type === STATION || l.extId || l.evaNumber || l.evaNo || lid.A == '1' || l.bahnhofsId) { 33 | let stop = { 34 | type: 'station', 35 | id: res.id, 36 | }; 37 | if (name) { 38 | stop.name = name; 39 | } 40 | if ('number' === typeof res.latitude) { 41 | stop.location = res; // todo: remove `.id` 42 | } 43 | // TODO subStops 44 | 45 | if ('products' in l) { 46 | stop.products = profile.parseProducts(ctx, l.products); 47 | } 48 | 49 | stop = profile.enrichStation(ctx, stop); 50 | 51 | // TODO isMeta 52 | // TODO entrances, lines 53 | return stop; 54 | } 55 | 56 | res.name = name; 57 | res = profile.enrichStation(ctx, res); 58 | 59 | if (l.type === ADDRESS || lid.A == '2') { 60 | res.address = name; 61 | } 62 | if (l.type === POI || lid.A == '4') { 63 | res.poi = true; 64 | } 65 | 66 | return res; 67 | }; 68 | 69 | const enrichStation = (ctx, stop, locations) => { 70 | const {common} = ctx; 71 | const locs = locations || common?.locations; 72 | const rich = locs && (locs[stop.id] || locs[stop.name]); 73 | if (rich) { 74 | delete stop.type; 75 | delete stop.id; 76 | stop = { 77 | ...rich, 78 | ...stop, 79 | }; 80 | delete stop.lines; 81 | delete stop.facilities; 82 | delete stop.reisezentrumOpeningHours; 83 | if (stop.station) { 84 | stop.station = {...stop.station}; 85 | delete stop.station.lines; 86 | delete stop.station.facilities; 87 | delete stop.station.reisezentrumOpeningHours; 88 | } 89 | } 90 | return stop; 91 | }; 92 | 93 | export { 94 | parseLocation, 95 | enrichStation, 96 | }; 97 | -------------------------------------------------------------------------------- /parse/operator.js: -------------------------------------------------------------------------------- 1 | import slugg from 'slugg'; 2 | 3 | const parseOperator = (ctx, zugattrib) => { 4 | if (zugattrib?.operatorName) { 5 | return { 6 | type: 'operator', 7 | id: zugattrib.operatorCode, 8 | name: zugattrib.operatorName, 9 | }; 10 | } 11 | if (!zugattrib || !Array.isArray(zugattrib)) { 12 | return null; 13 | } 14 | const bef = zugattrib.find(z => z.key == 'BEF' || z.key == 'OP'); 15 | if (!bef) { 16 | return null; 17 | } 18 | const name = bef.value || bef.text; 19 | if (!name) { 20 | return null; 21 | } 22 | return { 23 | type: 'operator', 24 | id: slugg(name.trim()), // todo: find a more reliable way 25 | name, 26 | }; 27 | }; 28 | 29 | export { 30 | parseOperator, 31 | }; 32 | -------------------------------------------------------------------------------- /parse/platform.js: -------------------------------------------------------------------------------- 1 | const parsePlatform = (ctx, platfS, platfR, cncl = false) => { 2 | let planned = platfS || null; 3 | let prognosed = platfR || null; 4 | 5 | if (cncl) { 6 | return { 7 | platform: null, 8 | plannedPlatform: planned, 9 | prognosedPlatform: prognosed, 10 | }; 11 | } 12 | return { 13 | platform: prognosed || planned, 14 | plannedPlatform: planned, 15 | }; 16 | }; 17 | 18 | export { 19 | parsePlatform, 20 | }; 21 | -------------------------------------------------------------------------------- /parse/polyline.js: -------------------------------------------------------------------------------- 1 | const parsePolyline = (ctx, p) => { // p = raw polylineGroup 2 | const desc = p.polylineDescriptions || p.polylineDesc; 3 | if (desc.length < 1) { 4 | return null; 5 | } 6 | const points = desc.reduce((max, d) => (d.coordinates.length > max.coordinates.length ? d : max), 7 | ).coordinates; // TODO: initial and final poly? 8 | 9 | if (points.length === 0) { 10 | return null; 11 | } 12 | 13 | const res = points.map(ll => ({ 14 | type: 'Feature', 15 | properties: {}, 16 | geometry: { 17 | type: 'Point', 18 | coordinates: [ll.lng || ll.longitude, ll.lat || ll.latitude], 19 | }, 20 | })); 21 | 22 | // TODO initial and final descriptions?, match station info? 23 | 24 | return { 25 | type: 'FeatureCollection', 26 | features: res, 27 | }; 28 | }; 29 | 30 | export { 31 | parsePolyline, 32 | }; 33 | -------------------------------------------------------------------------------- /parse/products.js: -------------------------------------------------------------------------------- 1 | const parseProducts = ({profile}, products) => { 2 | const res = {}; 3 | for (let product of profile.products) { 4 | res[product.id] = Boolean(products.find(p => p == product.vendo || p == product.dbnav)); 5 | } 6 | return res; 7 | }; 8 | 9 | export { 10 | parseProducts, 11 | }; 12 | -------------------------------------------------------------------------------- /parse/stopover.js: -------------------------------------------------------------------------------- 1 | const parseStopover = (ctx, st, date) => { // st = raw stopover 2 | const {profile, opt} = ctx; 3 | 4 | const cancelled = profile.parseCancelled(st); 5 | const arr = profile.parseWhen(ctx, date, st.ankunftsZeitpunkt || st.ankunftsDatum || st.arrivalTime?.target, st.ezAnkunftsZeitpunkt || st.ezAnkunftsDatum || st.arrivalTime?.timeType != 'SCHEDULE' && st.arrivalTime?.predicted, cancelled, 6 | ); 7 | const arrPl = profile.parsePlatform(ctx, st.gleis || st.track?.target, st.ezGleis || st.track?.prediction); 8 | const dep = profile.parseWhen(ctx, date, st.abfahrtsZeitpunkt || st.abgangsDatum || st.departureTime?.target, st.ezAbfahrtsZeitpunkt || st.ezAbgangsDatum || st.departureTime?.timeType != 'SCHEDULE' && st.departureTime?.predicted, cancelled, 9 | ); 10 | const depPl = arrPl; 11 | 12 | const res = { 13 | stop: profile.parseLocation(ctx, st.ort || st.station || st) || null, 14 | arrival: arr.when, 15 | plannedArrival: arr.plannedWhen, 16 | arrivalDelay: arr.delay, 17 | arrivalPlatform: arrPl.platform, 18 | arrivalPrognosisType: null, // TODO 19 | plannedArrivalPlatform: arrPl.plannedPlatform, 20 | departure: dep.when, 21 | plannedDeparture: dep.plannedWhen, 22 | departureDelay: dep.delay, 23 | departurePlatform: depPl.platform, 24 | departurePrognosisType: null, // TODO 25 | plannedDeparturePlatform: depPl.plannedPlatform, 26 | }; 27 | 28 | if (arr.prognosedWhen) { 29 | res.prognosedArrival = arr.prognosedWhen; 30 | } 31 | if (arrPl.prognosedPlatform) { 32 | res.prognosedArrivalPlatform = arrPl.prognosedPlatform; 33 | } 34 | if (dep.prognosedWhen) { 35 | res.prognosedDeparture = dep.prognosedWhen; 36 | } 37 | if (depPl.prognosedPlatform) { 38 | res.prognosedDeparturePlatform = depPl.prognosedPlatform; 39 | } 40 | 41 | const load = profile.parseLoadFactor(opt, st.auslastungsmeldungen || st.auslastungsInfos); 42 | if (load) { 43 | res.loadFactor = load; 44 | } 45 | // mark stations the train passes without stopping 46 | // TODO risNotizen key text.realtime.stop.exit.disabled? 47 | 48 | if (cancelled) { 49 | res.cancelled = true; 50 | Object.defineProperty(res, 'canceled', {value: true}); 51 | } 52 | 53 | // TODO res.additional = true; 54 | 55 | if (opt.remarks) { 56 | res.remarks = profile.parseRemarks(ctx, st); 57 | } 58 | 59 | return res; 60 | }; 61 | 62 | export { 63 | parseStopover, 64 | }; 65 | -------------------------------------------------------------------------------- /parse/tickets.js: -------------------------------------------------------------------------------- 1 | const PARTIAL_FARE_HINT = 'Teilpreis / partial fare'; 2 | 3 | const parsePrice = (ctx, raw) => { 4 | const p = raw.angebotsPreis || raw.angebote?.preise?.gesamt?.ab || raw.abPreis; 5 | if (p?.betrag) { 6 | const partialFare = raw.hasTeilpreis ?? raw.angebote?.preise?.istTeilpreis ?? raw.teilpreis; 7 | return { 8 | amount: p.betrag, 9 | currency: p.waehrung, 10 | hint: partialFare ? PARTIAL_FARE_HINT : null, 11 | partialFare: partialFare, 12 | }; 13 | } 14 | return undefined; 15 | }; 16 | 17 | const parseTickets = (ctx, j) => { 18 | if (!ctx.opt.tickets) { 19 | return undefined; 20 | } 21 | let tickets = undefined; 22 | let price = parsePrice(ctx, j); 23 | let ang = j.reiseAngebote 24 | || j.angebote?.angebotsCluster?.flatMap(c => c.angebotsSubCluster 25 | .flatMap(c => c.angebotsPositionen 26 | .flatMap(p => [ 27 | p.einfacheFahrt?.standard?.reisePosition, 28 | p.einfacheFahrt?.upsellEntgelt?.einfacheFahrt?.reisePosition, 29 | ].filter(p => p) 30 | .map(p => { 31 | p.reisePosition.teilpreis = Boolean(p.teilpreisInformationen?.length); 32 | return p.reisePosition; 33 | })), 34 | ), 35 | ); 36 | if (ang && ang.length > 0) { // if refreshJourney() 37 | tickets = ang 38 | .filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList?.flatMap(b => b.referenzen) 39 | .find(r => r.referenzAngebotsoption == 'PFLICHT')) 40 | .map((s) => { 41 | const p = { 42 | name: s.name, 43 | priceObj: { 44 | amount: Math.round(s.preis?.betrag * 100), 45 | currency: s.preis?.waehrung, 46 | }, 47 | firstClass: s.klasse == 'KLASSE_1' || s.premium || Boolean(s.nutzungsInformationen?.find(i => i.klasse == 'KLASSE_1')), 48 | partialFare: s.teilpreis, 49 | }; 50 | if (s.teilpreis) { 51 | p.addData = PARTIAL_FARE_HINT; 52 | } 53 | const conds = s.konditionsAnzeigen || s.konditionen; 54 | if (conds) { 55 | p.addDataTicketInfo = conds.map(a => a.anzeigeUeberschrift || a.bezeichnung) 56 | .join('. '); 57 | p.addDataTicketDetails = conds.map(a => a.textLang || a.details) 58 | .join(' '); 59 | } 60 | if (s.leuchtturmInfo || s.leuchtturmText) { 61 | p.addDataTravelInfo = s.leuchtturmInfo?.text || s.leuchtturmText; 62 | } 63 | return p; 64 | }); 65 | if (ctx.opt.generateUnreliableTicketUrls) { 66 | // TODO 67 | } 68 | 69 | } else if (price) { // if journeys() 70 | tickets = [{ 71 | name: 'from', 72 | priceObj: { 73 | amount: Math.round(price.amount * 100), 74 | currency: price.currency, 75 | }, 76 | }]; 77 | } 78 | return tickets; 79 | }; 80 | 81 | export { 82 | parsePrice, 83 | parseTickets, 84 | }; 85 | -------------------------------------------------------------------------------- /parse/trip.js: -------------------------------------------------------------------------------- 1 | const parseTrip = (ctx, t, id) => { // t = raw trip 2 | const {profile} = ctx; 3 | 4 | // pretend the trip is a leg in a journey 5 | const trip = profile.parseJourneyLeg(ctx, t); 6 | trip.id = trip.tripId || id; 7 | delete trip.tripId; 8 | delete trip.reachable; 9 | trip.cancelled = Boolean(profile.parseCancelled(t)); 10 | 11 | // TODO opt.scheduledDays 12 | return trip; 13 | }; 14 | 15 | export { 16 | parseTrip, 17 | }; 18 | -------------------------------------------------------------------------------- /parse/when.js: -------------------------------------------------------------------------------- 1 | const parseWhen = (ctx, date, timeS, timeR, cncl = false) => { 2 | const parse = ctx.profile.parseDateTime; 3 | let planned = timeS 4 | ? parse(ctx, date, timeS, false) 5 | : null; 6 | let prognosed = timeR 7 | ? parse(ctx, date, timeR, false) 8 | : null; 9 | let delay = null; 10 | 11 | if (planned && prognosed) { 12 | const tPlanned = parse(ctx, date, timeS, true); 13 | const tPrognosed = parse(ctx, date, timeR, true); 14 | delay = Math.round((tPrognosed - tPlanned) / 1000); 15 | } 16 | 17 | if (cncl) { 18 | return { 19 | when: null, 20 | plannedWhen: planned, 21 | prognosedWhen: prognosed, 22 | delay, 23 | }; 24 | } 25 | return { 26 | when: prognosed || planned, 27 | plannedWhen: planned, 28 | delay, 29 | }; 30 | }; 31 | 32 | export { 33 | parseWhen, 34 | }; 35 | -------------------------------------------------------------------------------- /retry.js: -------------------------------------------------------------------------------- 1 | import retry from 'p-retry'; 2 | import {defaultProfile} from './lib/default-profile.js'; 3 | 4 | const retryDefaults = { 5 | retries: 3, 6 | factor: 3, 7 | minTimeout: 5 * 1000, 8 | }; 9 | 10 | const withRetrying = (profile, retryOpts = {}) => { 11 | retryOpts = Object.assign({}, retryDefaults, retryOpts); 12 | // https://github.com/public-transport/hafas-client/issues/76#issuecomment-574408717 13 | const {request} = {...defaultProfile, ...profile}; 14 | 15 | const retryingRequest = (...args) => { 16 | const attempt = () => { 17 | return request(...args) 18 | .catch((err) => { 19 | if (err.isHafasError) { 20 | throw err; 21 | } // continue 22 | if (err.code === 'ENOTFOUND') { // abort 23 | const abortErr = new retry.AbortError(err); 24 | Object.assign(abortErr, err); 25 | throw abortErr; 26 | } 27 | throw err; // continue 28 | }); 29 | }; 30 | return retry(attempt, retryOpts); 31 | }; 32 | 33 | return { 34 | ...profile, 35 | request: retryingRequest, 36 | }; 37 | }; 38 | 39 | export { 40 | withRetrying, 41 | }; 42 | -------------------------------------------------------------------------------- /test/dbbahnhof-departures.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbbahnhof/index.js'; 5 | import res from './fixtures/dbbahnhof-departures.json' with { type: 'json' }; 6 | import {dbDepartures as expected} from './fixtures/dbbahnhof-departures.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | direction: null, 13 | duration: 10, 14 | linesOfStops: true, 15 | remarks: true, 16 | stopovers: false, 17 | includeRelatedStations: true, 18 | when: '2019-08-19T20:30:00+02:00', 19 | products: {}, 20 | }; 21 | 22 | tap.test('parses a bahnhof.de departure correctly', (t) => { 23 | const ctx = {profile, opt, common: null, res}; 24 | const arrivals = res.entries.flat() 25 | .map(d => profile.parseArrival(ctx, d)); 26 | 27 | t.same(arrivals, expected); 28 | t.end(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/dbnav-departures.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbnav/index.js'; 5 | import res from './fixtures/dbnav-departures.json' with { type: 'json' }; 6 | import {dbnavDepartures as expected} from './fixtures/dbnav-departures.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | direction: null, 13 | duration: 10, 14 | linesOfStops: true, 15 | remarks: true, 16 | stopovers: true, 17 | includeRelatedStations: true, 18 | when: '2019-08-19T20:30:00+02:00', 19 | products: {}, 20 | }; 21 | 22 | tap.test('parses a dbnav departure correctly', (t) => { 23 | const ctx = {profile, opt, common: null, res}; 24 | const departures = res.bahnhofstafelAbfahrtPositionen.map(d => profile.parseDeparture(ctx, d)); 25 | t.same(departures, expected); 26 | t.end(); 27 | }); 28 | -------------------------------------------------------------------------------- /test/dbnav-refresh-journey.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbnav/index.js'; 5 | import res from './fixtures/dbnav-refresh-journey.json' with { type: 'json' }; 6 | import {dbNavJourney as expected} from './fixtures/dbnav-refresh-journey.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | results: null, 13 | via: null, 14 | stopovers: false, 15 | transfers: -1, 16 | transferTime: 0, 17 | accessibility: 'none', 18 | bike: false, 19 | tickets: true, 20 | polylines: true, 21 | remarks: true, 22 | walkingSpeed: 'normal', 23 | startWithWalking: true, 24 | scheduledDays: false, 25 | departure: '2020-04-10T20:33+02:00', 26 | products: {}, 27 | }; 28 | 29 | tap.test('parses a refresh journey correctly (DB)', (t) => { 30 | const ctx = {profile, opt, common: null, res}; 31 | const journey = profile.parseJourney(ctx, res); 32 | 33 | t.same(journey, expected.journey); 34 | t.end(); 35 | }); 36 | -------------------------------------------------------------------------------- /test/dbnav-stop.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbnav/index.js'; 5 | import res from './fixtures/dbnav-stop.json' with { type: 'json' }; 6 | import {dbnavDepartures as expected} from './fixtures/dbnav-stop.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | linesOfStops: true, 13 | }; 14 | 15 | tap.test('parses a dbnav stop correctly', (t) => { 16 | const ctx = {profile, opt, common: null, res}; 17 | const stop = profile.parseStop(ctx, res, '8000096'); 18 | t.same(stop, expected); 19 | t.end(); 20 | }); 21 | -------------------------------------------------------------------------------- /test/dbnav-trip.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbnav/index.js'; 5 | import res from './fixtures/dbnav-trip.json' with { type: 'json' }; 6 | import {dbTrip as expected} from './fixtures/dbnav-trip.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | stopovers: true, 13 | remarks: true, 14 | 15 | products: {}, 16 | }; 17 | 18 | tap.test('parses a trip correctly (DBnav)', (t) => { 19 | const ctx = {profile, opt, common: null, res}; 20 | const trip = profile.parseTrip(ctx, res, 'foo'); 21 | 22 | t.same(trip, expected.trip); 23 | // console.log(JSON.stringify(trip, function(k, v) { return v === undefined ? "undefined" : v; })); 24 | t.end(); 25 | }); 26 | -------------------------------------------------------------------------------- /test/dbregioguide-trip.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbregioguide/index.js'; 5 | import res from './fixtures/dbregioguide-trip.json' with { type: 'json' }; 6 | import {dbTrip as expected} from './fixtures/dbregioguide-trip.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | stopovers: true, 13 | remarks: true, 14 | products: {}, 15 | }; 16 | 17 | tap.test('parses a regio guide trip correctly (DB)', (t) => { 18 | const ctx = {profile, opt, common: null, res}; 19 | const trip = profile.parseTrip(ctx, res, 'foo'); 20 | 21 | t.same(trip, expected.trip); 22 | t.end(); 23 | }); 24 | -------------------------------------------------------------------------------- /test/dbris-arrivals.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbris/index.js'; 5 | import res from './fixtures/dbris-arrivals.json' with { type: 'json' }; 6 | import {dbArrivals as expected} from './fixtures/dbris-arrivals.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | direction: null, 13 | duration: 10, 14 | linesOfStops: true, 15 | remarks: true, 16 | stopovers: false, 17 | includeRelatedStations: true, 18 | when: '2019-08-19T20:30:00+02:00', 19 | products: {}, 20 | }; 21 | 22 | tap.test('parses a RIS::Boards arrival correctly', (t) => { 23 | const ctx = {profile, opt, common: null, res}; 24 | const arrivals = res.arrivals.map(d => profile.parseArrival(ctx, d)); 25 | 26 | t.same(arrivals, expected); 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/dbris-departures.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbris/index.js'; 5 | import res from './fixtures/dbris-departures.json' with { type: 'json' }; 6 | import {dbDepartures as expected} from './fixtures/dbris-departures.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | direction: null, 13 | duration: 10, 14 | linesOfStops: true, 15 | remarks: true, 16 | stopovers: false, 17 | includeRelatedStations: true, 18 | when: '2019-08-19T20:30:00+02:00', 19 | products: {}, 20 | }; 21 | 22 | tap.test('parses a RIS::Boards departure correctly', (t) => { 23 | const ctx = {profile, opt, common: null, res}; 24 | const arrivals = res.departures.map(d => profile.parseArrival(ctx, d)); 25 | 26 | t.same(arrivals, expected); 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/dbweb-departures.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbweb/index.js'; 5 | import res from './fixtures/dbweb-departures.json' with { type: 'json' }; 6 | import {dbwebDepartures as expected} from './fixtures/dbweb-departures.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | direction: null, 13 | duration: null, 14 | linesOfStops: true, 15 | remarks: true, 16 | stopovers: true, 17 | includeRelatedStations: true, 18 | when: '2025-02-08T15:37:00', 19 | products: {}, 20 | }; 21 | 22 | const osterburken = { 23 | type: 'station', 24 | id: '8000295', 25 | name: 'Osterburken', 26 | location: { 27 | type: 'location', 28 | id: '8000295', 29 | latitude: 49.42992, 30 | longitude: 9.422996, 31 | }, 32 | products: { 33 | nationalExpress: false, 34 | national: false, 35 | regionalExp: false, 36 | regional: true, 37 | suburban: true, 38 | bus: true, 39 | ferry: false, 40 | subway: false, 41 | tram: false, 42 | taxi: true, 43 | }, 44 | weight: 5.6, 45 | }; 46 | 47 | const moeckmuehl = { 48 | type: 'station', 49 | id: '8004050', 50 | name: 'Möckmühl', 51 | location: { 52 | type: 'location', 53 | id: '8004050', 54 | latitude: 49.321187, 55 | longitude: 9.357977, 56 | }, 57 | products: { 58 | nationalExpress: false, 59 | national: false, 60 | regionalExp: false, 61 | regional: true, 62 | suburban: false, 63 | bus: true, 64 | ferry: false, 65 | subway: false, 66 | tram: false, 67 | taxi: false, 68 | }, 69 | distance: 2114, 70 | weight: 6.45, 71 | }; 72 | 73 | const common = { 74 | locations: { 75 | Osterburken: osterburken, 76 | 8000295: osterburken, 77 | Möckmühl: moeckmuehl, 78 | }, 79 | }; 80 | 81 | tap.test('parses a dbweb departure correctly', (t) => { 82 | const ctx = {profile, opt, common, res}; 83 | const departures = res.entries.map(d => profile.parseDeparture(ctx, d)); 84 | 85 | t.same(departures, expected); 86 | t.end(); 87 | }); 88 | -------------------------------------------------------------------------------- /test/dbweb-journey.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbweb/index.js'; 5 | import res from './fixtures/dbweb-journey.json' with { type: 'json' }; 6 | import {dbwebJourney as expected} from './fixtures/dbweb-journey.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | results: null, 13 | via: null, 14 | stopovers: false, 15 | transfers: -1, 16 | transferTime: 0, 17 | accessibility: 'none', 18 | bike: false, 19 | tickets: true, 20 | polylines: true, 21 | remarks: true, 22 | walkingSpeed: 'normal', 23 | startWithWalking: true, 24 | scheduledDays: false, 25 | departure: '2020-04-10T20:33+02:00', 26 | products: {}, 27 | }; 28 | 29 | tap.test('parses a dbweb journey correctly', (t) => { // TODO DEVI leg 30 | const ctx = {profile, opt, common: null, res}; 31 | const journey = profile.parseJourney(ctx, res.verbindungen[0]); 32 | 33 | t.same(journey, expected); 34 | t.end(); 35 | }); 36 | -------------------------------------------------------------------------------- /test/dbweb-refresh-journey.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbweb/index.js'; 5 | import res from './fixtures/dbweb-refresh-journey.json' with { type: 'json' }; 6 | import {dbJourney as expected} from './fixtures/dbweb-refresh-journey.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | results: null, 13 | via: null, 14 | stopovers: false, 15 | transfers: -1, 16 | transferTime: 0, 17 | accessibility: 'none', 18 | bike: false, 19 | tickets: true, 20 | polylines: true, 21 | remarks: true, 22 | walkingSpeed: 'normal', 23 | startWithWalking: true, 24 | scheduledDays: false, 25 | departure: '2020-04-10T20:33+02:00', 26 | products: {}, 27 | }; 28 | 29 | tap.test('parses a refresh journey correctly (dbweb)', (t) => { 30 | const ctx = {profile, opt, common: null, res}; 31 | const journey = profile.parseJourney(ctx, res.verbindungen[0]); 32 | 33 | t.same(journey, expected.journey); 34 | t.end(); 35 | }); 36 | -------------------------------------------------------------------------------- /test/dbweb-trip.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../index.js'; 4 | import {profile as rawProfile} from '../p/dbweb/index.js'; 5 | import res from './fixtures/dbweb-trip.json' with { type: 'json' }; 6 | import {dbwebTrip as expected} from './fixtures/dbweb-trip.js'; 7 | 8 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 9 | const {profile} = client; 10 | 11 | const opt = { 12 | stopovers: true, 13 | remarks: true, 14 | products: {}, 15 | }; 16 | 17 | tap.test('parses a trip correctly (DB)', (t) => { 18 | const ctx = {profile, opt, common: null, res}; 19 | const trip = profile.parseTrip(ctx, res, 'foo'); 20 | 21 | t.same(trip, expected.trip); 22 | t.end(); 23 | }); 24 | -------------------------------------------------------------------------------- /test/e2e/common.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../../index.js'; 4 | import {profile as dbProfile} from '../../p/db/index.js'; 5 | 6 | const client = createClient(dbProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 7 | 8 | tap.test('exposes the profile', (t) => { 9 | t.ok(client.profile); 10 | t.equal(client.profile.endpoint, dbProfile.endpoint); 11 | t.end(); 12 | }); 13 | -------------------------------------------------------------------------------- /test/e2e/lib/arrivals.js: -------------------------------------------------------------------------------- 1 | const testArrivals = async (cfg) => { 2 | const {test: t, res, validate} = cfg; 3 | const ids = cfg.ids || (cfg.id 4 | ? [cfg.id] 5 | : []); 6 | const {arrivals: arrs} = res; 7 | 8 | validate(t, res, 'arrivalsResponse', 'res'); 9 | 10 | for (let i = 0; i < arrs.length; i++) { 11 | let stop = arrs[i].stop; 12 | let name = `res.arrivals[${i}].stop`; 13 | if (stop.station) { 14 | stop = stop.station; 15 | name += '.station'; 16 | } 17 | 18 | t.ok( 19 | ids.includes(stop.id) 20 | || stop.station && ids.includes(stop.station.id), 21 | name + '.id is invalid', 22 | ); 23 | } 24 | 25 | // todo: move into arrivals validator 26 | t.same(arrs, arrs.sort((a, b) => t.when > b.when), 'res.arrivals must be sorted by .when'); 27 | }; 28 | 29 | export { 30 | testArrivals, 31 | }; 32 | -------------------------------------------------------------------------------- /test/e2e/lib/departures.js: -------------------------------------------------------------------------------- 1 | const testDepartures = async (cfg) => { 2 | const {test: t, res, validate} = cfg; 3 | const ids = cfg.ids || (cfg.id 4 | ? [cfg.id] 5 | : []); 6 | const {departures: deps} = res; 7 | 8 | validate(t, res, 'departuresResponse', 'res'); 9 | 10 | for (let i = 0; i < deps.length; i++) { 11 | let stop = deps[i].stop; 12 | let name = `res.departures[${i}].stop`; 13 | if (stop.station) { 14 | stop = stop.station; 15 | name += '.station'; 16 | } 17 | 18 | t.ok( 19 | ids.includes(stop.id) 20 | || stop.station && ids.includes(stop.station.id), 21 | `${name}.id is invalid (${stop.id}), must be one of ${ids.join('/')}`, 22 | ); 23 | } 24 | 25 | // todo: move into deps validator 26 | t.same(deps, deps.sort((a, b) => t.when > b.when), 'res.departures must be sorted by .when'); 27 | }; 28 | 29 | export { 30 | testDepartures, 31 | }; 32 | -------------------------------------------------------------------------------- /test/e2e/lib/earlier-later-journeys.js: -------------------------------------------------------------------------------- 1 | const testEarlierLaterJourneys = async (cfg) => { 2 | const { 3 | test: t, 4 | fetchJourneys, 5 | fromId, 6 | toId, 7 | when, 8 | // todo: validate 9 | } = cfg; 10 | 11 | const model = await fetchJourneys(fromId, toId, { 12 | results: 3, departure: when, 13 | }); 14 | 15 | // todo: move to journeys validator? 16 | t.equal(typeof model.earlierRef, 'string'); 17 | t.ok(model.earlierRef); 18 | t.equal(typeof model.laterRef, 'string'); 19 | t.ok(model.laterRef); 20 | 21 | // departure/arrival and earlierThan/laterThan should be mutually exclusive 22 | await t.rejects(async () => { 23 | await fetchJourneys(fromId, toId, { 24 | departure: when, earlierThan: model.earlierRef, 25 | }); 26 | }); 27 | await t.rejects(async () => { 28 | await fetchJourneys(fromId, toId, { 29 | departure: when, laterThan: model.laterRef, 30 | }); 31 | }); 32 | await t.rejects(async () => { 33 | await fetchJourneys(fromId, toId, { 34 | arrival: when, earlierThan: model.earlierRef, 35 | }); 36 | }); 37 | await t.rejects(async () => { 38 | await fetchJourneys(fromId, toId, { 39 | arrival: when, laterThan: model.laterRef, 40 | }); 41 | }); 42 | 43 | let earliestDep = Infinity, latestDep = -Infinity; 44 | for (let j of model.journeys) { 45 | if (j.legs[0].departure === null) { 46 | continue; 47 | } 48 | const dep = Date.parse(j.legs[0].departure); 49 | if (dep < earliestDep) { 50 | earliestDep = dep; 51 | } else if (dep > latestDep) { 52 | latestDep = dep; 53 | } 54 | } 55 | 56 | const earlier = await fetchJourneys(fromId, toId, { 57 | results: 3, 58 | // todo: single journey ref? 59 | earlierThan: model.earlierRef, 60 | }); 61 | for (let j of earlier.journeys) { 62 | const firstLeg = j.legs[0]; 63 | const dep = Date.parse(firstLeg.departure || firstLeg.plannedDeparture); 64 | t.ok(dep < earliestDep); 65 | } 66 | 67 | const later = await fetchJourneys(fromId, toId, { 68 | results: 3, 69 | // todo: single journey ref? 70 | laterThan: model.laterRef, 71 | }); 72 | for (let j of later.journeys) { 73 | const firstLeg = j.legs[0]; 74 | const dep = Date.parse(firstLeg.departure || firstLeg.plannedDeparture); 75 | t.ok(dep > latestDep); 76 | } 77 | }; 78 | 79 | export { 80 | testEarlierLaterJourneys, 81 | }; 82 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-fails-with-no-product.js: -------------------------------------------------------------------------------- 1 | const journeysFailsWithNoProduct = async (cfg) => { 2 | const { 3 | test: t, 4 | fetchJourneys, 5 | fromId, 6 | toId, 7 | when, 8 | products, 9 | } = cfg; 10 | 11 | const noProducts = Object.create(null); 12 | for (let p of products) { 13 | noProducts[p.id] = false; 14 | } 15 | 16 | await t.rejects(async () => { 17 | await fetchJourneys(fromId, toId, {departure: when, products: noProducts}); 18 | }); 19 | }; 20 | 21 | export { 22 | journeysFailsWithNoProduct, 23 | }; 24 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-station-to-address.js: -------------------------------------------------------------------------------- 1 | import isRoughlyEqual from 'is-roughly-equal'; 2 | 3 | const testJourneysStationToAddress = async (cfg) => { 4 | const {test: t, res, validate, fromId} = cfg; 5 | const {address, latitude, longitude} = cfg.to; 6 | 7 | validate(t, res, 'journeysResult', 'res'); 8 | const {journeys} = res; 9 | 10 | t.ok(journeys.length >= 3, 'journeys must have >=3 items'); 11 | for (let i = 0; i < journeys.length; i++) { 12 | const j = journeys[i]; 13 | 14 | const firstLeg = j.legs[0]; 15 | const orig = firstLeg.origin.station || firstLeg.origin; 16 | t.ok(orig.id, fromId); 17 | 18 | const d = j.legs[j.legs.length - 1].destination; 19 | const n = `res.journeys[0].legs[${i}].destination`; 20 | 21 | t.equal(d.type, 'location', n + '.type is invalid'); 22 | t.equal(d.address, address, n + '.address is invalid'); 23 | t.ok(isRoughlyEqual(0.0001, d.latitude, latitude), n + '.latitude is invalid'); 24 | t.ok(isRoughlyEqual(0.0001, d.longitude, longitude), n + '.longitude is invalid'); 25 | } 26 | }; 27 | 28 | export { 29 | testJourneysStationToAddress, 30 | }; 31 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-station-to-poi.js: -------------------------------------------------------------------------------- 1 | import isRoughlyEqual from 'is-roughly-equal'; 2 | 3 | const testJourneysStationToPoi = async (cfg) => { 4 | const {test: t, res, validate} = cfg; 5 | const fromIds = cfg.fromIds || (cfg.fromId 6 | ? [cfg.fromId] 7 | : []); 8 | const {id, name, latitude, longitude} = cfg.to; 9 | 10 | validate(t, res, 'journeysResult', 'res'); 11 | const {journeys} = res; 12 | 13 | t.ok(journeys.length >= 3, 'journeys must have >=3 items'); 14 | for (let i = 0; i < journeys.length; i++) { 15 | const j = journeys[i]; 16 | 17 | let o = j.legs[0].origin; 18 | const oN = 'res.journeys[0].legs[0].destination'; 19 | t.ok( 20 | fromIds.includes(o.id) 21 | || o.station && fromIds.includes(o.station.id), 22 | `invalid ${oN}.legs[0].origin`, 23 | ); 24 | 25 | let d = j.legs[j.legs.length - 1].destination; 26 | let dN = `res.journeys[${i}].legs[${j.legs.length - 1}].destination`; 27 | if (d.station) { 28 | d = d.station; 29 | dN += '.station'; 30 | } 31 | 32 | t.equal(d.type, 'location', dN + '.type is invalid'); 33 | t.equal(d.id, id, dN + '.id is invalid'); 34 | t.equal(d.name, name, dN + '.name is invalid'); 35 | t.ok(isRoughlyEqual(0.0001, d.latitude, latitude), dN + '.latitude is invalid'); 36 | t.ok(isRoughlyEqual(0.0001, d.longitude, longitude), dN + '.longitude is invalid'); 37 | } 38 | }; 39 | 40 | export { 41 | testJourneysStationToPoi, 42 | }; 43 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-station-to-station.js: -------------------------------------------------------------------------------- 1 | const testJourneysStationToStation = async (cfg) => { 2 | const {test: t, res, validate} = cfg; 3 | const fromIds = cfg.fromIds || (cfg.fromId 4 | ? [cfg.fromId] 5 | : []); 6 | const toIds = cfg.toIds || (cfg.toId 7 | ? [cfg.toId] 8 | : []); 9 | 10 | validate(t, res, 'journeysResult', 'res'); 11 | const {journeys} = res; 12 | 13 | t.ok(journeys.length >= 4, 'journeys must have >=4 items'); 14 | for (let i = 0; i < journeys.length; i++) { 15 | const j = journeys[i]; 16 | const n = `res.journeys[${i}]`; 17 | 18 | const o = j.legs[0].origin; 19 | const d = j.legs[j.legs.length - 1].destination; 20 | t.ok( 21 | fromIds.includes(o.id) 22 | || o.station && fromIds.includes(o.station.id), 23 | `invalid ${n}.legs[0].origin`, 24 | ); 25 | t.ok( 26 | toIds.includes(d.id) 27 | || d.station && toIds.includes(d.station.id), 28 | `invalid ${n}.legs[${j.legs.length - 1}].destination`, 29 | ); 30 | } 31 | }; 32 | 33 | export { 34 | testJourneysStationToStation, 35 | }; 36 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-walking-speed.js: -------------------------------------------------------------------------------- 1 | import isRoughlyEqual from 'is-roughly-equal'; 2 | 3 | const testJourneysWalkingSpeed = async (cfg) => { 4 | const {test: t, journeys, validate, from, to, when, products, minTimeDifference} = cfg; 5 | 6 | const {journeys: [journeyWithFastWalking]} = await journeys(from, to, { 7 | departure: when, 8 | results: 1, products, walkingSpeed: 'fast', 9 | }); 10 | const legWithFastWalking = journeyWithFastWalking.legs.find(l => l.walking); 11 | t.ok(legWithFastWalking, 'no walking leg in journey with fast walking'); 12 | 13 | const {journeys: [journeyWithSlowWalking]} = await journeys(from, to, { 14 | departure: when, 15 | results: 1, products, walkingSpeed: 'slow', 16 | }); 17 | const legWithSlowWalking = journeyWithSlowWalking.legs.find(l => l.walking); 18 | t.ok(legWithSlowWalking, 'no walking leg in journey with slow walking'); 19 | 20 | const fastDist = legWithFastWalking.distance; 21 | const slowDist = legWithSlowWalking.distance; 22 | t.ok(isRoughlyEqual(100, fastDist, slowDist), 'precondition failed'); 23 | const fastDur = new Date(legWithFastWalking.arrival) - new Date(legWithFastWalking.departure); 24 | const slowDur = new Date(legWithSlowWalking.arrival) - new Date(legWithSlowWalking.departure); 25 | t.notOk(isRoughlyEqual(minTimeDifference, fastDur, slowDur), 'walkingSpeed not applied'); 26 | t.end(); 27 | }; 28 | 29 | export { 30 | testJourneysWalkingSpeed, 31 | }; 32 | -------------------------------------------------------------------------------- /test/e2e/lib/journeys-with-detour.js: -------------------------------------------------------------------------------- 1 | const testJourneysWithDetour = async (cfg) => { 2 | const {test: t, res, validate, detourIds} = cfg; 3 | 4 | // We assume that going from A to B via C *without* detour is currently 5 | // impossible. We check if the routing engine computes a detour. 6 | 7 | validate(t, res, 'journeysResult', 'res'); 8 | const {journeys} = res; 9 | 10 | const leg = journeys[0].legs.some((leg) => { 11 | return leg.stopovers && leg.stopovers.some((st) => st.stop.station && detourIds.includes(st.stop.station.id) 12 | || detourIds.includes(st.stop.id), 13 | ); 14 | }); 15 | t.ok(leg, detourIds.join('/') + ' is not being passed'); 16 | }; 17 | 18 | export { 19 | testJourneysWithDetour, 20 | }; 21 | -------------------------------------------------------------------------------- /test/e2e/lib/leg-cycle-alternatives.js: -------------------------------------------------------------------------------- 1 | import isRoughlyEqual from 'is-roughly-equal'; 2 | 3 | import {hour} from './util.js'; 4 | 5 | const testLegCycleAlternatives = async (cfg) => { 6 | const { 7 | test: t, 8 | fetchJourneys, 9 | fromId, 10 | toId, 11 | } = cfg; 12 | 13 | // Apparently HAFAS doesn't return the leg cycle or alternatives more 14 | // than ~2 hours in advance. This is why we don't pass `when` here. 15 | const journeys = await fetchJourneys(fromId, toId, {results: 3}); 16 | 17 | for (let i = 0; i < journeys.length; i++) { 18 | const journey = journeys[i]; 19 | for (let j = 0; j < journey.legs.length; j++) { 20 | const leg = journey.legs[j]; 21 | const name = `journeys[${i}].legs[${j}]`; 22 | 23 | if (!leg.line) { 24 | continue; 25 | } 26 | 27 | t.ok(leg.cycle, name + '.cycle is missing'); 28 | t.equal(typeof leg.cycle.min, 'number', name + '.cycle.min is not a number'); 29 | t.equal(typeof leg.cycle.max, 'number', name + '.cycle.max is not a number'); 30 | t.equal(typeof leg.cycle.nr, 'number', name + '.cycle.nr is not a number'); 31 | 32 | const lineWhen = Number(new Date(leg.departure)); 33 | t.ok(Array.isArray(leg.alternatives), name + '.alternatives must be an array'); 34 | for (let k = 0; k < leg.alternatives.length; k++) { 35 | const a = leg.alternatives[k]; 36 | const n = name + `.alternatives[${k}]`; 37 | 38 | let alternativeWhen = Number(new Date(a.when)); 39 | if ('number' === typeof a.delay) { 40 | alternativeWhen -= a.delay * 1000; 41 | } 42 | t.ok(isRoughlyEqual(2 * hour, alternativeWhen, lineWhen), n + '.when seems invalid'); 43 | } 44 | } 45 | } 46 | }; 47 | 48 | export { 49 | testLegCycleAlternatives, 50 | }; 51 | -------------------------------------------------------------------------------- /test/e2e/lib/lines.js: -------------------------------------------------------------------------------- 1 | const testLines = async (cfg) => { 2 | const { 3 | test: t, 4 | fetchLines, 5 | validate, 6 | query, 7 | } = cfg; 8 | 9 | const res = await fetchLines(query); 10 | const { 11 | lines, 12 | realtimeDataUpdatedAt, 13 | } = res; 14 | 15 | for (let i = 0; i < res.lines.length; i++) { 16 | const l = res.lines[i]; 17 | const name = `res.lines[${i}]`; 18 | validate(t, l, 'line', name); 19 | } 20 | 21 | validate(t, realtimeDataUpdatedAt, 'realtimeDataUpdatedAt', 'res.realtimeDataUpdatedAt'); 22 | }; 23 | 24 | export { 25 | testLines, 26 | }; 27 | -------------------------------------------------------------------------------- /test/e2e/lib/refresh-journey.js: -------------------------------------------------------------------------------- 1 | const simplify = j => j.legs.map(l => { 2 | return { 3 | origin: l.origin, 4 | destination: l.destination, 5 | departure: l.plannedDeparture || l.departure, 6 | arrival: l.plannedArrival || l.arrival, 7 | line: l.line, 8 | }; 9 | }); 10 | 11 | const testRefreshJourney = async (cfg) => { 12 | const { 13 | test: t, 14 | fetchJourneys, 15 | refreshJourney, 16 | validate, 17 | fromId, 18 | toId, 19 | when, 20 | } = cfg; 21 | 22 | const modelRes = await fetchJourneys(fromId, toId, { 23 | results: 1, departure: when, 24 | stopovers: false, 25 | }); 26 | validate(t, modelRes, 'journeysResult', 'modelRes'); 27 | const [model] = modelRes.journeys; 28 | 29 | // todo: move to journeys validator? 30 | t.equal(typeof model.refreshToken, 'string'); 31 | t.ok(model.refreshToken); 32 | 33 | const refreshedRes = await refreshJourney(model.refreshToken, { 34 | stopovers: false, 35 | }); 36 | validate(t, refreshedRes, 'refreshJourneyResult', 'refreshedRes'); 37 | const refreshed = refreshedRes.journey; 38 | 39 | t.same(simplify(refreshed), simplify(model)); 40 | }; 41 | 42 | export { 43 | testRefreshJourney, 44 | }; 45 | -------------------------------------------------------------------------------- /test/e2e/lib/remarks.js: -------------------------------------------------------------------------------- 1 | const WEEK = 7 * 24 * 60 * 60 * 1000; 2 | 3 | const testRemarks = async (cfg) => { 4 | const { 5 | test: t, 6 | fetchRemarks, 7 | validate, 8 | when, 9 | } = cfg; 10 | 11 | const res = await fetchRemarks({ 12 | results: 10, 13 | from: when, 14 | to: new Date(when + WEEK), 15 | }); 16 | const { 17 | remarks, 18 | realtimeDataUpdatedAt, 19 | } = res; 20 | 21 | for (let i = 0; i < res.remarks.length; i++) { 22 | const rem = res.remarks[i]; 23 | const name = `res.remarks[${i}]`; 24 | validate(t, rem, 'remark', name); 25 | } 26 | 27 | // most endpoints currently don't provide this info for remarks() 28 | if (realtimeDataUpdatedAt !== null) { 29 | validate(t, realtimeDataUpdatedAt, 'realtimeDataUpdatedAt', 'res.realtimeDataUpdatedAt'); 30 | } 31 | }; 32 | 33 | export { 34 | testRemarks, 35 | }; 36 | -------------------------------------------------------------------------------- /test/e2e/lib/validate-fptf-with.js: -------------------------------------------------------------------------------- 1 | import validateFptf from 'validate-fptf'; 2 | const {defaultValidators} = validateFptf; 3 | import anyOf from 'validate-fptf/lib/any-of.js'; 4 | 5 | import validators from './validators.js'; 6 | 7 | const createValidateFptfWith = (cfg, customValidators = {}) => { 8 | const val = Object.assign({}, defaultValidators); 9 | for (let key of Object.keys(validators)) { 10 | val[key] = validators[key](cfg); 11 | } 12 | Object.assign(val, customValidators); 13 | 14 | const validateFptfWith = (t, item, allowedTypes, name) => { 15 | if ('string' === typeof allowedTypes) { 16 | val[allowedTypes](val, item, name); 17 | } else { 18 | anyOf(allowedTypes, val, item, name); 19 | } 20 | t.pass(name + ' is valid'); 21 | }; 22 | return validateFptfWith; 23 | }; 24 | 25 | export { 26 | createValidateFptfWith, 27 | }; 28 | -------------------------------------------------------------------------------- /test/fixtures/dbnav-stop.json: -------------------------------------------------------------------------------- 1 | { 2 | "haltName": "Stuttgart Hbf", 3 | "produktGattungen": [ 4 | { 5 | "produktGattung": "HOCHGESCHWINDIGKEITSZUEGE", 6 | "produkte": [ 7 | { 8 | "name": "ICE" 9 | }, 10 | { 11 | "name": "RJ" 12 | }, 13 | { 14 | "name": "RJX" 15 | }, 16 | { 17 | "name": "TGV" 18 | } 19 | ] 20 | }, 21 | { 22 | "produktGattung": "INTERCITYUNDEUROCITYZUEGE", 23 | "produkte": [ 24 | { 25 | "name": "EC" 26 | }, 27 | { 28 | "name": "IC" 29 | }, 30 | { 31 | "name": "NJ" 32 | } 33 | ] 34 | }, 35 | { 36 | "produktGattung": "INTERREGIOUNDSCHNELLZUEGE", 37 | "produkte": [ 38 | { 39 | "name": "BUS" 40 | }, 41 | { 42 | "name": "Bus" 43 | }, 44 | { 45 | "name": "D" 46 | }, 47 | { 48 | "name": "DBK" 49 | }, 50 | { 51 | "name": "EN" 52 | }, 53 | { 54 | "name": "FLX" 55 | }, 56 | { 57 | "name": "WB" 58 | } 59 | ] 60 | }, 61 | { 62 | "produktGattung": "NAHVERKEHRSONSTIGEZUEGE", 63 | "produkte": [ 64 | { 65 | "name": "GA" 66 | }, 67 | { 68 | "name": "IRE" 69 | }, 70 | { 71 | "name": "MEX" 72 | }, 73 | { 74 | "name": "RB" 75 | }, 76 | { 77 | "name": "RE" 78 | }, 79 | { 80 | "name": "Bus EV" 81 | }, 82 | { 83 | "name": "BusMEX90" 84 | } 85 | ] 86 | }, 87 | { 88 | "produktGattung": "SBAHNEN", 89 | "produkte": [ 90 | { 91 | "name": "S 1" 92 | }, 93 | { 94 | "name": "S 2" 95 | } 96 | ] 97 | }, 98 | { 99 | "produktGattung": "BUSSE", 100 | "produkte": [ 101 | { 102 | "name": "Bus 40" 103 | }, 104 | { 105 | "name": "Bus 42" 106 | }, 107 | { 108 | "name": "BusMEX90" 109 | } 110 | ] 111 | }, 112 | { 113 | "produktGattung": "STRASSENBAHN", 114 | "produkte": [ 115 | { 116 | "name": "STB U1" 117 | }, 118 | { 119 | "name": "STB U5" 120 | } 121 | ] 122 | } 123 | ] 124 | } -------------------------------------------------------------------------------- /test/fixtures/dbregioguide-departures.js: -------------------------------------------------------------------------------- 1 | const dbDepartures = [ 2 | { 3 | tripId: '20241212-d1494ce6-1a01-38de-bf84-c0bceb12f503', 4 | stop: { 5 | type: 'station', 6 | id: '8000365', 7 | name: 'Dombühl', 8 | }, 9 | when: '2024-12-12T12:34:00+01:00', 10 | plannedWhen: '2024-12-12T12:34:00+01:00', 11 | delay: 0, 12 | platform: '3', 13 | plannedPlatform: '3', 14 | direction: 'Nürnberg Hbf', 15 | provenance: null, 16 | line: { 17 | type: 'line', 18 | id: 're-90-88617', 19 | fahrtNr: '88617', 20 | name: 'RE 90', 21 | public: true, 22 | productName: 'RE', 23 | mode: 'train', 24 | product: 'regional', 25 | adminCode: 'GARE', 26 | operator: { 27 | type: 'operator', 28 | id: 'RE', 29 | name: 'Arverio Baden-Württemberg (RE)', 30 | }, 31 | }, 32 | remarks: [], 33 | origin: null, 34 | destination: { 35 | type: 'station', 36 | id: '8000284', 37 | name: 'Nürnberg Hbf', 38 | }, 39 | }, 40 | { 41 | tripId: '20241212-abd01ce0-cca3-3759-aa4b-410ea4d0a720', 42 | stop: { 43 | type: 'station', 44 | id: '682943', 45 | name: 'Bahnhof, Dombühl', 46 | }, 47 | when: '2024-12-12T12:50:00+01:00', 48 | plannedWhen: '2024-12-12T12:50:00+01:00', 49 | delay: null, 50 | platform: null, 51 | plannedPlatform: null, 52 | direction: 'Gymnasium, Dinkelsbühl', 53 | provenance: null, 54 | line: { 55 | type: 'line', 56 | id: 'bus-813-2221', 57 | fahrtNr: '2221', 58 | name: 'Bus 813', 59 | public: true, 60 | productName: 'Bus', 61 | mode: 'bus', 62 | product: 'bus', 63 | adminCode: 'vgn063', 64 | operator: { 65 | type: 'operator', 66 | id: 'DPN', 67 | name: 'Nahreisezug', 68 | }, 69 | }, 70 | remarks: [], 71 | origin: null, 72 | destination: { 73 | type: 'station', 74 | id: '676542', 75 | name: 'Gymnasium, Dinkelsbühl', 76 | }, 77 | }, 78 | { 79 | tripId: '20241212-ab6272a5-4bf6-32c1-9344-b47e1fc49eeb', 80 | stop: { 81 | type: 'station', 82 | id: '682943', 83 | name: 'Bahnhof, Dombühl', 84 | }, 85 | when: '2024-12-12T12:50:00+01:00', 86 | plannedWhen: '2024-12-12T12:50:00+01:00', 87 | delay: null, 88 | platform: null, 89 | plannedPlatform: null, 90 | direction: 'Bahnhof, Rothenburg ob der Tauber', 91 | provenance: null, 92 | line: { 93 | type: 'line', 94 | id: 'bus-807-2177', 95 | fahrtNr: '2177', 96 | name: 'Bus 807', 97 | public: true, 98 | productName: 'Bus', 99 | mode: 'bus', 100 | product: 'bus', 101 | adminCode: 'vgn063', 102 | operator: { 103 | type: 'operator', 104 | id: 'DPN', 105 | name: 'Nahreisezug', 106 | }, 107 | }, 108 | remarks: [], 109 | origin: null, 110 | destination: { 111 | type: 'station', 112 | id: '683407', 113 | name: 'Bahnhof, Rothenburg ob der Tauber', 114 | }, 115 | }, 116 | ]; 117 | 118 | export { 119 | dbDepartures, 120 | }; 121 | -------------------------------------------------------------------------------- /test/fixtures/dbregioguide-departures.json: -------------------------------------------------------------------------------- 1 | { 2 | "isArrival": false, 3 | "evaNo": "8000365", 4 | "stationName": "Dombühl", 5 | "items": [ 6 | { 7 | "station": { 8 | "evaNo": "8000365", 9 | "name": "Dombühl", 10 | "canceled": false 11 | }, 12 | "train": { 13 | "journeyId": "20241212-d1494ce6-1a01-38de-bf84-c0bceb12f503", 14 | "category": "RE", 15 | "type": "REGIONAL_TRAIN", 16 | "no": 88617, 17 | "lineName": "90" 18 | }, 19 | "category": "REGIONAL", 20 | "time": "2024-12-12T11:34:00.000Z", 21 | "timePredicted": "2024-12-12T11:34:00.000Z", 22 | "diff": 0, 23 | "timeType": "PREVIEW", 24 | "platform": "3", 25 | "platformPredicted": "3", 26 | "administration": { 27 | "id": "GARE", 28 | "operatorCode": "RE", 29 | "operatorName": "Arverio Baden-Württemberg (RE)" 30 | }, 31 | "canceled": false, 32 | "departureId": "8000365_D_1", 33 | "destination": { 34 | "evaNo": "8000284", 35 | "name": "Nürnberg Hbf", 36 | "canceled": false 37 | } 38 | }, 39 | { 40 | "station": { 41 | "evaNo": "682943", 42 | "name": "Bahnhof, Dombühl", 43 | "canceled": false 44 | }, 45 | "train": { 46 | "journeyId": "20241212-abd01ce0-cca3-3759-aa4b-410ea4d0a720", 47 | "category": "Bus", 48 | "type": "BUS", 49 | "no": 2221, 50 | "lineName": "813" 51 | }, 52 | "category": "BUS", 53 | "time": "2024-12-12T11:50:00.000Z", 54 | "timePredicted": "2024-12-12T11:50:00.000Z", 55 | "diff": 0, 56 | "timeType": "SCHEDULE", 57 | "platform": "", 58 | "platformPredicted": "", 59 | "administration": { 60 | "id": "vgn063", 61 | "operatorCode": "DPN", 62 | "operatorName": "Nahreisezug" 63 | }, 64 | "canceled": false, 65 | "departureId": "682943_D_1", 66 | "destination": { 67 | "evaNo": "676542", 68 | "name": "Gymnasium, Dinkelsbühl", 69 | "canceled": false 70 | } 71 | }, 72 | { 73 | "station": { 74 | "evaNo": "682943", 75 | "name": "Bahnhof, Dombühl", 76 | "canceled": false 77 | }, 78 | "train": { 79 | "journeyId": "20241212-ab6272a5-4bf6-32c1-9344-b47e1fc49eeb", 80 | "category": "Bus", 81 | "type": "BUS", 82 | "no": 2177, 83 | "lineName": "807" 84 | }, 85 | "category": "BUS", 86 | "time": "2024-12-12T11:50:00.000Z", 87 | "timePredicted": "2024-12-12T11:50:00.000Z", 88 | "diff": 0, 89 | "timeType": "SCHEDULE", 90 | "platform": "", 91 | "platformPredicted": "", 92 | "administration": { 93 | "id": "vgn063", 94 | "operatorCode": "DPN", 95 | "operatorName": "Nahreisezug" 96 | }, 97 | "canceled": false, 98 | "departureId": "682943_D_1", 99 | "destination": { 100 | "evaNo": "683407", 101 | "name": "Bahnhof, Rothenburg ob der Tauber", 102 | "canceled": false 103 | } 104 | } 105 | ], 106 | "availableTransports": [ 107 | { 108 | "id": "REGIONAL", 109 | "text": "Regional" 110 | }, 111 | { 112 | "id": "BUS", 113 | "text": "Bus" 114 | } 115 | ], 116 | "timeStart": "2024-12-12T11:33:30.000Z", 117 | "timeEnd": "2024-12-12T12:03:30.000Z" 118 | } -------------------------------------------------------------------------------- /test/format/db-trip.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {profile as rawProfile} from '../../p/db/index.js'; 4 | import {createClient} from '../../index.js'; 5 | 6 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 7 | const {profile} = client; 8 | 9 | const opt = { 10 | stopovers: true, 11 | polyline: false, 12 | remarks: true, 13 | language: 'en', 14 | }; 15 | 16 | const tripIdHafas = '2|#VN#1#ST#1738783727#PI#0#ZI#222242#TA#0#DA#70225#1S#8000237#1T#1317#LS#8000261#LT#2002#PU#80#RT#1#CA#ICE#ZE#1007#ZB#ICE 1007#PC#0#FR#8000237#FT#1317#TO#8000261#TT#2002#'; 17 | const tripIdRis = '20250207-e6b2807e-bb48-39f9-89eb-8491ebc4b32c'; 18 | 19 | const reqDbNavExpected = { 20 | endpoint: 'https://app.vendo.noncd.db.de/mob/zuglauf/', 21 | path: '2%7C%23VN%231%23ST%231738783727%23PI%230%23ZI%23222242%23TA%230%23DA%2370225%231S%238000237%231T%231317%23LS%238000261%23LT%232002%23PU%2380%23RT%231%23CA%23ICE%23ZE%231007%23ZB%23ICE%201007%23PC%230%23FR%238000237%23FT%231317%23TO%238000261%23TT%232002%23', 22 | headers: { 23 | 'Accept': 'application/x.db.vendo.mob.zuglauf.v2+json', 24 | 'Content-Type': 'application/x.db.vendo.mob.zuglauf.v2+json', 25 | }, 26 | method: 'get', 27 | }; 28 | 29 | const reqDbRegioGuideExpected = { 30 | endpoint: 'https://regio-guide.de/@prd/zupo-travel-information/api/public/ri/journey/', 31 | path: '20250207-e6b2807e-bb48-39f9-89eb-8491ebc4b32c', 32 | method: 'get', 33 | }; 34 | 35 | tap.test('db trip(): dynamic request formatting', (t) => { 36 | const ctx = {profile, opt}; 37 | t.notHas(client.profile, 'tripEndpoint'); 38 | 39 | const reqDbNav = profile.formatTripReq(ctx, tripIdHafas); 40 | delete reqDbNav.headers['X-Correlation-ID']; 41 | const reqDbRegioGuide = profile.formatTripReq(ctx, tripIdRis); 42 | 43 | t.same(reqDbNav, reqDbNavExpected); 44 | t.same(reqDbRegioGuide, reqDbRegioGuideExpected); 45 | 46 | t.end(); 47 | }); 48 | -------------------------------------------------------------------------------- /test/format/dbweb-arrivals-query.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../../index.js'; 4 | import {profile as rawProfile} from '../../p/dbweb/index.js'; 5 | 6 | const client = createClient(rawProfile, 'public-transport/hafas-client:test'); 7 | const {profile} = client; 8 | 9 | const opt = { 10 | when: new Date('2025-02-09T23:55:00+01:00'), 11 | remarks: true, 12 | stopovers: true, 13 | language: 'en', 14 | }; 15 | 16 | const berlinArrivalsQuery = { 17 | endpoint: 'https://int.bahn.de/web/api/reiseloesung/', 18 | path: 'ankuenfte', 19 | query: { 20 | ortExtId: '8011160', 21 | zeit: '23:55', 22 | datum: '2025-02-09', 23 | mitVias: true, 24 | verkehrsmittel: [ 25 | 'ICE', 26 | 'EC_IC', 27 | 'IR', 28 | 'REGIONAL', 29 | 'SBAHN', 30 | 'BUS', 31 | 'SCHIFF', 32 | 'UBAHN', 33 | 'TRAM', 34 | 'ANRUFPFLICHTIG', 35 | ], 36 | }, 37 | method: 'GET', 38 | }; 39 | 40 | tap.test('formats an arrivals() request correctly', (t) => { 41 | const ctx = {profile, opt}; 42 | 43 | const req = profile.formatStationBoardReq(ctx, '8011160', 'arrivals'); 44 | 45 | t.same(req, berlinArrivalsQuery); 46 | t.end(); 47 | }); 48 | -------------------------------------------------------------------------------- /test/format/dbweb-journeys-query.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | 3 | import {createClient} from '../../index.js'; 4 | import {profile as rawProfile} from '../../p/dbweb/index.js'; 5 | import {data as loyaltyCards} from '../../format/loyalty-cards.js'; 6 | 7 | const client = createClient(rawProfile, 'public-transport/hafas-client:test', {enrichStations: false}); 8 | const {profile} = client; 9 | 10 | const opt = { 11 | results: null, 12 | via: null, 13 | stopovers: false, 14 | transfers: null, 15 | transferTime: 0, 16 | accessibility: 'none', 17 | bike: false, 18 | walkingSpeed: 'normal', 19 | startWithWalking: true, 20 | tickets: false, 21 | polylines: false, 22 | subStops: true, 23 | entrances: true, 24 | remarks: true, 25 | scheduledDays: false, 26 | departure: '2023-09-12T08:09:10+02:00', 27 | products: {}, 28 | 29 | firstClass: false, 30 | age: 24, 31 | loyaltyCard: { 32 | type: loyaltyCards.BAHNCARD, 33 | discount: 25, 34 | }, 35 | }; 36 | 37 | const berlinWienQuery0 = Object.freeze( 38 | { 39 | abfahrtsHalt: 'A=1@L=8098160@', 40 | anfrageZeitpunkt: '2024-12-07T23:50:12', 41 | ankunftsHalt: 'A=1@L=8000284@', 42 | ankunftSuche: 'ABFAHRT', 43 | klasse: 'KLASSE_2', 44 | produktgattungen: [ 45 | 'ICE', 46 | 'EC_IC', 47 | 'IR', 48 | 'REGIONAL', 49 | 'SBAHN', 50 | 'BUS', 51 | 'SCHIFF', 52 | 'UBAHN', 53 | 'TRAM', 54 | 'ANRUFPFLICHTIG', 55 | ], 56 | schnelleVerbindungen: true, 57 | sitzplatzOnly: false, 58 | bikeCarriage: false, 59 | reservierungsKontingenteVorhanden: false, 60 | nurDeutschlandTicketVerbindungen: undefined, 61 | deutschlandTicketVorhanden: undefined, 62 | maxUmstiege: null, 63 | zwischenhalte: null, 64 | minUmstiegszeit: 0, 65 | }); 66 | 67 | tap.test('formats a journeys() request correctly (DB)', (t) => { 68 | const _opt = {...opt}; 69 | delete _opt.loyaltyCard; 70 | delete _opt.age; 71 | const ctx = {profile, opt: _opt}; 72 | 73 | const req = profile.formatJourneysReq(ctx, '8098160', '8000284', new Date('2024-12-07T23:50:12+01:00'), true, null); 74 | t.same(req.body, { 75 | ...berlinWienQuery0, 76 | reisende: [ 77 | { 78 | typ: 'ERWACHSENER', 79 | ermaessigungen: [ 80 | { 81 | art: 'KEINE_ERMAESSIGUNG', 82 | klasse: 'KLASSENLOS', 83 | }, 84 | ], 85 | alter: [], 86 | anzahl: 1, 87 | }, 88 | ], 89 | }); 90 | t.end(); 91 | }); 92 | 93 | 94 | tap.test('formats a journeys() request with BC correctly (dbweb)', (t) => { 95 | const ctx = {profile, opt}; 96 | 97 | const req = profile.formatJourneysReq(ctx, '8098160', '8000284', new Date('2024-12-07T23:50:12+01:00'), true, null); 98 | 99 | t.same(req.body, { 100 | ...berlinWienQuery0, 101 | reisende: [ 102 | { 103 | typ: 'JUGENDLICHER', 104 | ermaessigungen: [ 105 | { 106 | art: 'BAHNCARD25', 107 | klasse: 'KLASSE_2', 108 | }, 109 | ], 110 | alter: ['24'], 111 | anzahl: 1, 112 | }, 113 | ], 114 | }); 115 | t.end(); 116 | }); 117 | 118 | tap.test('formats a journeys() request with unlimited transfers (dbweb)', (t) => { 119 | const _opt = {...opt}; 120 | const ctx = {profile, opt: _opt}; 121 | 122 | ctx.opt.transfers = 0; // no transfers 123 | const reqZeroTransfers = profile.formatJourneysReq(ctx, '8098160', '8000284', new Date('2024-12-07T23:50:12+01:00'), true, null); 124 | t.equal(reqZeroTransfers.body.maxUmstiege, 0); 125 | 126 | ctx.opt.transfers = undefined; // unconstrained transfers implicit 127 | const reqUnlimitedTransfersImplicit = profile.formatJourneysReq(ctx, '8098160', '8000284', new Date('2024-12-07T23:50:12+01:00'), true, null); 128 | t.equal(reqUnlimitedTransfersImplicit.body.maxUmstiege, undefined); 129 | 130 | ctx.opt.transfers = -1; // unconstrained transfers explicit 131 | const reqUnlimitedTransfersExplicit = profile.formatJourneysReq(ctx, '8098160', '8000284', new Date('2024-12-07T23:50:12+01:00'), true, null); 132 | t.equal(reqUnlimitedTransfersExplicit.body.maxUmstiege, undefined); 133 | 134 | t.end(); 135 | }); 136 | -------------------------------------------------------------------------------- /test/format/products-filter.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import {formatProductsFilter as format} from '../../format/products-filter.js'; 3 | 4 | const products = [ 5 | { 6 | id: 'train', 7 | bitmasks: [1, 2], 8 | vendo: 'REGIONAL', 9 | default: true, 10 | }, 11 | { 12 | id: 'bus', 13 | bitmasks: [4], 14 | vendo: 'BUS', 15 | default: true, 16 | }, 17 | { 18 | id: 'tram', 19 | bitmasks: [8, 32], 20 | vendo: 'TRAM', 21 | default: false, 22 | }, 23 | ]; 24 | 25 | const ctx = { 26 | common: {}, 27 | opt: {}, 28 | profile: {products}, 29 | }; 30 | 31 | tap.test('formatProductsFilter works without customisations', (t) => { 32 | const expected = ['REGIONAL', 'BUS']; 33 | const filter = {}; 34 | t.same(format(ctx, filter), expected); 35 | t.end(); 36 | }); 37 | -------------------------------------------------------------------------------- /test/lib/request.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import { 3 | checkIfResponseIsOk as checkIfResIsOk, 4 | } from '../../lib/request.js'; 5 | import { 6 | HafasError, 7 | } from '../../lib/errors.js'; 8 | 9 | const resNoMatch = {verbindungen: [], verbindungReference: {}, fehlerNachricht: {code: 'MDA-AK-MSG-1001', ueberschrift: 'Datum liegt außerhalb der Fahrplanperiode.', text: 'Das Datum liegt außerhalb der Fahrplanperiode.'}}; 10 | 11 | const secret = Symbol('secret'); 12 | 13 | tap.test('checkIfResponseIsOk properly throws HAFAS errors', (t) => { 14 | try { 15 | checkIfResIsOk({ 16 | body: resNoMatch, 17 | errProps: {secret}, 18 | }); 19 | } catch (err) { 20 | t.ok(err); 21 | 22 | t.ok(err instanceof HafasError); 23 | t.equal(err.isHafasError, true); 24 | t.ok(err instanceof HafasError); 25 | t.equal(err.isCausedByServer, false); 26 | t.equal(err.code, 'MDA-AK-MSG-1001'); 27 | 28 | t.equal(err.hafasMessage, 'Datum liegt außerhalb der Fahrplanperiode.'); 29 | t.equal(err.hafasDescription, 'Das Datum liegt außerhalb der Fahrplanperiode.'); 30 | 31 | t.end(); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /test/parse/date-time.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import {parseDateTime as parse} from '../../parse/date-time.js'; 3 | 4 | const ctx = { 5 | common: {}, 6 | opt: {}, 7 | profile: { 8 | timezone: 'Europe/Berlin', 9 | locale: 'de-DE', 10 | }, 11 | }; 12 | 13 | tap.test('date & time parsing returns a timestamp', (t) => { 14 | const iso = parse(ctx, null, '2019-08-19T20:30:00', false); 15 | const ts = parse(ctx, null, '2019-08-19T20:30:00', true); 16 | t.equal(ts, Number(new Date(iso))); 17 | t.equal(ts, 1566239400 * 1000); 18 | t.end(); 19 | }); 20 | 21 | tap.test('date & time parsing uses profile.timezone', (t) => { 22 | const iso = parse({ 23 | ...ctx, 24 | profile: {...ctx.profile, timezone: 'Europe/Moscow'}, 25 | }, null, '2019-08-19T20:30:00', false); 26 | t.equal(iso, '2019-08-19T20:30:00+03:00'); 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/parse/location.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import {parseLocation as parse} from '../../parse/location.js'; 3 | import {parseProducts} from '../../parse/products.js'; 4 | 5 | const profile = { 6 | parseLocation: parse, 7 | enrichStation: (ctx, stop) => stop, 8 | parseStationName: (_, name) => name.toLowerCase(), 9 | parseProducts, 10 | products: [{ 11 | id: 'nationalExpress', 12 | vendo: 'ICE', 13 | }, 14 | { 15 | id: 'national', 16 | vendo: 'IC', 17 | }, 18 | { 19 | id: 'regional', 20 | vendo: 'REGIONAL', 21 | }, 22 | { 23 | id: 'bus', 24 | vendo: 'BUS', 25 | }, 26 | { 27 | id: 'taxi', 28 | vendo: 'ANRUFPFLICHTIG', 29 | }], 30 | }; 31 | 32 | const ctx = { 33 | data: {}, 34 | opt: { 35 | linesOfStops: false, 36 | subStops: true, 37 | entrances: true, 38 | }, 39 | profile, 40 | }; 41 | 42 | tap.test('parses an address correctly', (t) => { 43 | const input = { 44 | id: 'A=2@O=Würzburg - Heuchelhof, Pergamonweg@X=9952209@Y=49736794@U=92@b=981423354@B=1@p=1706613073@', 45 | lat: 49.736794, 46 | lon: 9.952209, 47 | name: 'Würzburg - Heuchelhof, Pergamonweg', 48 | products: [], 49 | type: 'ADR', 50 | }; 51 | 52 | const address = parse(ctx, input); 53 | t.same(address, { 54 | type: 'location', 55 | id: null, 56 | name: 'Würzburg - Heuchelhof, Pergamonweg', 57 | address: 'Würzburg - Heuchelhof, Pergamonweg', 58 | latitude: 49.736794, 59 | longitude: 9.952209, 60 | }); 61 | 62 | t.end(); 63 | }); 64 | 65 | tap.test('parses a POI correctly', (t) => { 66 | const input = { 67 | id: 'A=4@O=Berlin, Pergamonkeller (Gastronomie)@X=13395473@Y=52520223@U=91@L=991526508@B=1@p=1732715706@', 68 | lat: 52.52022, 69 | lon: 13.395473, 70 | name: 'Berlin, Pergamonkeller (Gastronomie)', 71 | products: [], 72 | type: 'POI', 73 | }; 74 | 75 | const poi = parse(ctx, input); 76 | t.same(poi, { 77 | type: 'location', 78 | poi: true, 79 | id: '991526508', 80 | name: 'Berlin, Pergamonkeller (Gastronomie)', 81 | latitude: 52.52022, 82 | longitude: 13.395473, 83 | }); 84 | t.end(); 85 | }); 86 | 87 | tap.test('parses a stop correctly', (t) => { 88 | const input = { 89 | extId: '8012622', 90 | id: 'A=1@O=Perleberg@X=11852322@Y=53071252@U=81@L=8012622@B=1@p=1733173731@i=U×008027183@', 91 | lat: 53.07068, 92 | lon: 11.85039, 93 | name: 'Perleberg', 94 | products: [ 95 | 'REGIONAL', 96 | 'BUS', 97 | 'ANRUFPFLICHTIG', 98 | ], 99 | type: 'ST', 100 | }; 101 | 102 | const stop = parse(ctx, input); 103 | t.same(stop, { 104 | type: 'station', 105 | id: '8012622', 106 | name: 'Perleberg', 107 | location: { 108 | type: 'location', 109 | id: '8012622', 110 | latitude: 53.07068, 111 | longitude: 11.85039, 112 | }, 113 | products: { 114 | nationalExpress: false, 115 | national: false, 116 | regional: true, 117 | bus: true, 118 | taxi: true, 119 | }, 120 | }); 121 | t.end(); 122 | }); 123 | 124 | tap.test('falls back to coordinates from `lid', (t) => { 125 | const input = { 126 | id: 'A=1@O=Bahnhof, Rothenburg ob der Tauber@X=10190711@Y=49377180@U=80@L=683407@', 127 | name: 'Bahnhof, Rothenburg ob der Tauber', 128 | bahnhofsInfoId: '5393', 129 | extId: '683407', 130 | adminID: 'vgn063', 131 | kategorie: 'Bus', 132 | nummer: '2524', 133 | }; 134 | 135 | const stop = parse(ctx, input); 136 | t.same(stop, { 137 | type: 'station', 138 | id: '683407', 139 | name: 'Bahnhof, Rothenburg ob der Tauber', 140 | location: { 141 | type: 'location', 142 | id: '683407', 143 | latitude: 49.377180, 144 | longitude: 10.190711, 145 | }, 146 | }); 147 | t.end(); 148 | }); 149 | -------------------------------------------------------------------------------- /test/parse/operator.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import {parseOperator as parse} from '../../parse/operator.js'; 3 | 4 | const ctx = { 5 | data: {}, 6 | opt: {}, 7 | profile: {}, 8 | }; 9 | 10 | tap.test('parses an operator correctly', (t) => { 11 | const op = [{ 12 | kategorie: 'BEFÖRDERER', 13 | key: 'BEF', 14 | value: 'DB Fernverkehr AG', 15 | }, { 16 | kategorie: 'FAHRRADMITNAHME', 17 | key: 'FR', 18 | value: 'Bicycles conveyed - subject to reservation', 19 | }]; 20 | 21 | t.same(parse(ctx, op), { 22 | type: 'operator', 23 | id: 'db-fernverkehr-ag', 24 | name: 'DB Fernverkehr AG', 25 | }); 26 | t.end(); 27 | }); 28 | 29 | 30 | tap.test('parses nothing', (t) => { 31 | const op = [{ 32 | kategorie: 'INFORMATION', 33 | key: 'cB', 34 | value: 'Tel. 0981-9714925, Anmeldung bis 90 Min. vor Abfahrt (Mo-So: 9-15 Uhr)', 35 | }]; 36 | 37 | t.same(parse(ctx, op), null); 38 | t.end(); 39 | }); 40 | 41 | 42 | tap.test('parses dbnav operator correctly', (t) => { 43 | const op = [{text: 'DB Fernverkehr AG', key: 'OP'}]; 44 | 45 | t.same(parse(ctx, op), { 46 | type: 'operator', 47 | id: 'db-fernverkehr-ag', 48 | name: 'DB Fernverkehr AG', 49 | }); 50 | t.end(); 51 | }); 52 | 53 | 54 | tap.test('parses db regio guide trip operator correctly', (t) => { 55 | const op = { 56 | name: 'S 5', 57 | no: 36552, 58 | journeyId: '20250114-2080f6df-62d4-3c0f-8a89-0db06bc5c2c8', 59 | tenantId: 'hessen', 60 | administrationId: '800528', 61 | operatorName: 'DB Regio, S-Bahn Rhein-Main', 62 | operatorCode: 'DB', 63 | category: 'S', 64 | type: 'CITY_TRAIN', 65 | date: '2025-01-14T16:08:00+01:00', 66 | }; 67 | 68 | t.same(parse(ctx, op), { 69 | type: 'operator', 70 | id: 'DB', 71 | name: 'DB Regio, S-Bahn Rhein-Main', 72 | }); 73 | t.end(); 74 | }); 75 | -------------------------------------------------------------------------------- /test/parse/when.js: -------------------------------------------------------------------------------- 1 | import tap from 'tap'; 2 | import {parseWhen as parse} from '../../parse/when.js'; 3 | import {parseDateTime} from '../../parse/date-time.js'; 4 | 5 | const profile = { 6 | parseDateTime: parseDateTime, 7 | timezone: 'Europe/Berlin', 8 | locale: 'de-DE', 9 | }; 10 | const ctx = { 11 | data: {}, 12 | opt: {}, 13 | profile, 14 | }; 15 | 16 | tap.test('parseWhen works correctly', (t) => { 17 | const date = null; 18 | const timeS = '2019-06-06T16:30:00'; 19 | const timeR = '2019-06-06T16:31:00'; 20 | const expected = { 21 | when: '2019-06-06T16:31:00+02:00', 22 | plannedWhen: '2019-06-06T16:30:00+02:00', 23 | delay: 60, // seconds 24 | }; 25 | 26 | t.same(parse(ctx, date, timeS, timeR), expected); 27 | 28 | // no realtime data 29 | t.same(parse(ctx, date, timeS, null), { 30 | ...expected, when: expected.plannedWhen, delay: null, 31 | }); 32 | 33 | // cancelled 34 | t.same(parse(ctx, date, timeS, timeR, true), { 35 | ...expected, 36 | when: null, 37 | prognosedWhen: expected.when, 38 | }); 39 | t.end(); 40 | }); 41 | -------------------------------------------------------------------------------- /throttle.js: -------------------------------------------------------------------------------- 1 | import throttle from 'p-throttle'; 2 | import {defaultProfile} from './lib/default-profile.js'; 3 | 4 | const withThrottling = (profile, limit = 5, interval = 1000) => { 5 | // https://github.com/public-transport/hafas-client/issues/76#issuecomment-574408717 6 | const {request} = {...defaultProfile, ...profile}; 7 | 8 | return { 9 | ...profile, 10 | request: throttle({limit, interval})(request), 11 | }; 12 | }; 13 | 14 | export { 15 | withThrottling, 16 | }; 17 | -------------------------------------------------------------------------------- /tools/debug-cli/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {parseArgs} from 'node:util'; 4 | import {createClient} from '../../index.js'; 5 | 6 | const showError = (err) => { 7 | console.error(err); 8 | process.exit(1); 9 | }; 10 | 11 | const toString = val => String(val); 12 | const parseJsObject = val => { 13 | const res = eval(`(${val})`); 14 | return res && 'object' === typeof res 15 | ? res 16 | : {}; 17 | }; 18 | 19 | const methodsAndTheirArgs = [ 20 | ['departures', 0, toString], 21 | ['departures', 1, parseJsObject], 22 | ['arrivals', 0, toString], 23 | ['arrivals', 1, parseJsObject], 24 | ['journeys', 0, toString], 25 | ['journeys', 1, toString], 26 | ['journeys', 2, parseJsObject], 27 | ['refreshJourney', 0, toString], 28 | ['refreshJourney', 1, parseJsObject], 29 | ['journeysFromTrip', 0, toString], 30 | ['journeysFromTrip', 1, parseJsObject], 31 | ['journeysFromTrip', 2, toString], 32 | ['journeysFromTrip', 3, parseJsObject], 33 | ['locations', 0, toString], 34 | ['locations', 1, parseJsObject], 35 | ['stop', 0, toString], 36 | ['stop', 1, parseJsObject], 37 | ['nearby', 0, parseJsObject], 38 | ['nearby', 1, parseJsObject], 39 | ['trip', 0, toString], 40 | ['trip', 1, parseJsObject], 41 | ['tripsByName', 0, toString], 42 | ['tripsByName', 1, parseJsObject], 43 | ['radar', 0, parseJsObject], 44 | ['radar', 1, parseJsObject], 45 | ['reachableFrom', 0, parseJsObject], 46 | ['reachableFrom', 1, parseJsObject], 47 | ['remarks', 0, parseJsObject], 48 | ['lines', 0, toString], 49 | ['lines', 1, parseJsObject], 50 | ['serverInfo', 0, parseJsObject], 51 | ]; 52 | 53 | const { 54 | positionals: args, 55 | } = parseArgs({ 56 | strict: true, 57 | allowPositionals: true, 58 | }); 59 | 60 | const profileName = args[0]; 61 | const fnName = args[1]; 62 | 63 | const parsedArgs = args.slice(2) 64 | .map((arg, i) => { 65 | const parser = methodsAndTheirArgs.find(([_fnName, _i]) => _fnName === fnName && _i === i); 66 | return parser 67 | ? parser[2](arg) 68 | : arg; 69 | }); 70 | (async () => { 71 | const {profile} = await import(`../../p/${profileName}/index.js`); 72 | 73 | const client = createClient(profile, 'db-vendo-client debug CLI'); 74 | 75 | const fn = client[fnName]; 76 | 77 | const res = await fn(...parsedArgs); 78 | process.stdout.write(JSON.stringify(res) + '\n'); 79 | })() 80 | .catch(showError); 81 | -------------------------------------------------------------------------------- /tools/debug-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "debug-cli", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "bin": { 7 | "db-vendo-client-debug-cli": "./cli.js" 8 | }, 9 | "author": "Jannis R ", 10 | "license": "ISC", 11 | "engines": { 12 | "node": ">=16.17" 13 | } 14 | } 15 | --------------------------------------------------------------------------------