├── .eslintrc.json
├── .github
├── matchers
│ ├── eslint.json
│ └── tap.json
└── workflows
│ └── quality.yaml
├── .gitignore
├── README.md
├── docs
└── index.html
├── package-lock.json
├── package.json
└── src
├── data
├── crypto.js
├── crypto.test.js
├── date.js
├── date.test.js
├── main.js
├── main.test.js
├── routeStation.js
├── stations.js
├── stations.test.js
├── trains.js
├── trains.test.data.js
└── trains.test.js
└── site
├── build.js
└── templates
├── index.handlebars
├── partials
├── end.handlebars
└── start.handlebars
└── train.handlebars
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb-base", "prettier"],
3 | "rules": {
4 | // Node.js requires that import statements include a file extension in order
5 | // to properly resolve modules, similarly to how it works in browser
6 | // environments. In fact, the only places it doesn't work that way is with
7 | // precompilers like Babel, etc.
8 | "import/extensions": ["error", "always"],
9 |
10 | // This rule says that a file with a single export should only export
11 | // default, a rule I generally disagree with being in the linter because
12 | // what to export is contextual. There's not a good hard rule.
13 | "import/prefer-default-export": [0],
14 |
15 | // In general, I agree with this rule, but treating parameters as immutable
16 | // is a bridge too far for me.
17 | "no-param-reassign": ["error", { "props":false }],
18 |
19 | // This rule is copied from the Airbnb config, but we remove the prohibition
20 | // on ForOf statements because they are natively supported in Node.js. The
21 | // remaining prohibitions are still good, though, so we don't want to just
22 | // completely disable the rule.
23 | "no-restricted-syntax": [
24 | "error",
25 | {
26 | "selector": "ForInStatement",
27 | "message":
28 | "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array."
29 | },
30 | {
31 | "selector": "LabeledStatement",
32 | "message":
33 | "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand."
34 | },
35 | {
36 | "selector": "WithStatement",
37 | "message":
38 | "`with` is disallowed in strict mode because it makes code impossible to predict and optimize."
39 | }
40 | ]
41 | },
42 | "env": {
43 | "es2023": true,
44 | "node": true
45 | },
46 | "parserOptions": { "ecmaVersion": 2023 }
47 | }
48 |
--------------------------------------------------------------------------------
/.github/matchers/eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "problemMatcher": [
3 | {
4 | "owner": "eslint-stylish",
5 | "pattern": [
6 | {
7 | "regexp": "^([^\\s].*)$",
8 | "file": 1
9 | },
10 | {
11 | "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$",
12 | "line": 1,
13 | "column": 2,
14 | "severity": 3,
15 | "message": 4,
16 | "code": 5,
17 | "loop": true
18 | }
19 | ]
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/.github/matchers/tap.json:
--------------------------------------------------------------------------------
1 | {
2 | "problemMatcher": [
3 | {
4 | "owner": "tap",
5 | "pattern": [
6 | {
7 | "regexp": "^\\s*not ok \\d+ - (.*)",
8 | "message": 1
9 | },
10 | {
11 | "regexp": "^\\s*---"
12 | },
13 | {
14 | "regexp": "^\\s*at:"
15 | },
16 | {
17 | "regexp": "^\\s*line:\\s*(\\d+)",
18 | "line": 1
19 | },
20 | {
21 | "regexp": "^\\s*column:\\s*(\\d+)",
22 | "column": 1
23 | },
24 | {
25 | "regexp": "^\\s*file:\\s*(.*)",
26 | "file": 1
27 | }
28 | ]
29 | }
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/quality.yaml:
--------------------------------------------------------------------------------
1 | name: code quality
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | lint:
7 | name: lint
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v3
11 |
12 | - uses: actions/setup-node@v3
13 | with:
14 | node-version: 18
15 | cache: npm
16 |
17 | - name: install dependencies
18 | run: npm ci
19 |
20 | - name: add eslint output matcher
21 | run: echo "::add-matcher::${{ github.workspace }}/.github/matchers/eslint.json"
22 |
23 | - name: lint
24 | run: npm run test:lint
25 |
26 | test:
27 | name: unit test
28 | runs-on: ubuntu-latest
29 | steps:
30 | - uses: actions/checkout@v3
31 |
32 | - uses: actions/setup-node@v3
33 | with:
34 | node-version: 18
35 | cache: npm
36 |
37 | - name: install dependencies
38 | run: npm ci
39 |
40 | - name: add eslint output matcher
41 | run: echo "::add-matcher::${{ github.workspace }}/.github/matchers/tap.json"
42 |
43 | - name: run tests
44 | run: npm run test:test
45 |
46 | - name: store coverage output
47 | uses: actions/upload-artifact@v3
48 | with:
49 | name: coverage
50 | path: .tap
51 | retention-days: 1
52 |
53 | # She just gon' disable coverage reporting for right now because I don't want
54 | # to look at any more lcov reporters.
55 | # coverage:
56 | # name: code coverage
57 | # runs-on: ubuntu-latest
58 | # needs: [test]
59 | # steps:
60 | # - name: install lcov
61 | # uses: hrishikesh-kadam/setup-lcov@v1
62 |
63 | # - name: get coverage output
64 | # uses: actions/download-artifact@v3
65 | # with:
66 | # name: coverage
67 |
68 | # - name: Report code coverage
69 | # uses: zgosalvez/github-actions-report-lcov@v3
70 | # with:
71 | # coverage-files: ${{ github.workspace }}/.tap/report/lcov.info
72 | # minimum-coverage: 90
73 | # github-token: ${{ secrets.GITHUB_TOKEN }}
74 | # update-comment: true
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | _site
3 | .env
4 | .tap
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tools for getting Amtrak data
2 |
3 | Amtrak currently doesn't have an easily-accessible API. This tool will use the
4 | same APIs as
5 | [Amtrak's Track Your Train Map](https://www.amtrak.com/track-your-train.html) to
6 | extract information about trains that are currently active as well as those that
7 | are only planned. The result is a set of JSON files representing the current
8 | state of the map.
9 |
10 | The tool can also build a static website from the data. A version of that site
11 | is live at [mgwalker.github.io/amtrak-api/](https://mgwalker.github.io/amtrak-api/).
12 |
13 | ## Usage
14 |
15 | To get the latest data and build the site, run:
16 |
17 | ```
18 | npm run site
19 | ```
20 |
21 | If you only want to fetch the latest data, run:
22 |
23 | ```
24 | npm run update
25 | ```
26 |
27 | ## The data
28 |
29 | ### Stations
30 |
31 | Path: `_site/stations.json`
32 |
33 | An array of station objects, listing all of the stations identified on the
34 | Amtrak map. The station objects look like this:
35 |
36 | ```
37 | {
38 | code: . . string - three-letter station identifier
39 | name: . . string - the name of the station
40 | address1: string - street address of the station
41 | address2: string
42 | city: . string
43 | state: . string
44 | zip: . . string
45 | lat: . . float - geographic coordinates of the station
46 | lon: . . float
47 | _raw: . . object - the raw data from Amtrak
48 | }
49 | ```
50 |
51 | ### Routes
52 |
53 | Path: `_site/routes.json`
54 |
55 | An array of route objects, each one representing a whole Amtrak route. The
56 | route object includes an array of trains, each representing one of the trains
57 | that's currently running on that route. Then, each train has a list of stations
58 | representing each station along the route. Here's what those look like:
59 |
60 | #### Route:
61 |
62 | ```
63 | {
64 | route: string - name of the route
65 | trains: [ ] - list of trains currently running on this route (see below)
66 | }
67 |
68 | ```
69 |
70 | #### Train:
71 |
72 | ```
73 | {
74 | id: int - the train's internal Amtrak ID (not useful)
75 | heading: char(2) - train's current cardinal or ordinal direction
76 | number: int - the train number
77 | route: string - the name of the route
78 | stations: [ ] - list of stations this train stops at, sorted from
79 | starting station to ending station (see below)
80 | _raw: object - the raw data from Amtrak
81 | }
82 | ```
83 |
84 | #### Station:
85 |
86 | ```
87 | {
88 | code: . . string - the station's 3-letter code
89 | bus: . . bool - whether this station also has bus service, I think
90 | status: . string - the train's status relative to this station
91 | timezone: string - IANA timezone where the train currently is
92 |
93 | arrivalActual: . . Arrival and departure times for this train at this
94 | arrivalEstimated: . station. All arrivals are null if this is the first
95 | arrivalScheduled: . station. Likewise departure for the last station.
96 | departureActual: . Estimated times sometimes go away after a train
97 | departureEstimated: actually arrives or departs. Values are ISO 8601
98 | departureScheduled: date/time strings in UTC.
99 |
100 | station: station object, as described in the Stations part of the README.
101 | This has the station name, address, etc.
102 | }
103 | ```
104 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
15 |
21 |
76 |
77 |
78 |
79 |
83 |
This site is archived
84 |
85 | This website previously built itself automatically from Amtrak data,
86 | updated about every 20 minutes. However, the Amtrak data source is
87 | remarkably flaky and for a hobby project, it isn't worth the amount of
88 | naggy emails I get from the automatic build process whenver Amtrak
89 | delivers bad data.
90 |
91 |
92 |
93 | The source code used to generate this site – and the data files that
94 | it hosted – is
95 | available for free on GitHub. Please feel free to grab it and do with it as you please! If you're
98 | curious why I built this thing in the first place, I wrote
99 | a blog post about it in 2023.
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mgwalker/amtrak-api",
3 | "license": "ISC",
4 | "type": "module",
5 | "devDependencies": {
6 | "eslint": "^8.53.0",
7 | "eslint-config-airbnb-base": "^15.0.0",
8 | "eslint-config-prettier": "^9.0.0",
9 | "eslint-plugin-import": "^2.29.0",
10 | "prettier": "^3.0.3",
11 | "sinon": "^17.0.1",
12 | "tap": "^18.5.7"
13 | },
14 | "prettier": {},
15 | "dependencies": {
16 | "dayjs": "^1.11.10",
17 | "dotenv": "^16.3.1",
18 | "handlebars": "^4.7.8"
19 | },
20 | "scripts": {
21 | "site": "npm run update && node src/site/build.js",
22 | "update": "rm -rf _site && node src/data/main.js",
23 | "test:lint": "eslint 'src/**/*.js'",
24 | "test:test": "tap 'src/**/*.test.js' --coverage-report lcov --allow-incomplete-coverage",
25 | "test": "npm run test:lint && npm run test:test"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/data/crypto.js:
--------------------------------------------------------------------------------
1 | import { createDecipheriv, pbkdf2Sync } from "node:crypto";
2 |
3 | let cryptoInitializers = false;
4 |
5 | // The public key (used to encrypt private key passwords), key derivation salt,
6 | // and AES initialization vectors are all provided by the API. Fetch them.
7 | export const getCryptoInitializers = async (fetch = global.fetch) => {
8 | if (cryptoInitializers === false) {
9 | // First, the index of the public key is the sum of all zoom levels for all
10 | // routes, so let's get that real quick.
11 | const masterZoom = await fetch(
12 | "https://maps.amtrak.com/rttl/js/RoutesList.json",
13 | )
14 | .then((r) => r.json())
15 | .then((list) =>
16 | list.reduce((sum, { ZoomLevel }) => sum + (ZoomLevel ?? 0), 0),
17 | );
18 |
19 | // Then fetch the data containing our values.
20 | const cryptoData = await fetch(
21 | "https://maps.amtrak.com/rttl/js/RoutesList.v.json",
22 | ).then((r) => r.json());
23 |
24 | // And pull them out.
25 | cryptoInitializers = {
26 | PUBLIC_KEY: cryptoData.arr[masterZoom],
27 | // The salt and IV indices are equal to the length of any given value in the
28 | // array. So if salt[0] is 8 bytes long, then our value is at salt[8]. Etc.
29 | CRYPTO_SALT: Buffer.from(cryptoData.s[cryptoData.s[0].length], "hex"),
30 | CRYPTO_IV: Buffer.from(cryptoData.v[cryptoData.v[0].length], "hex"),
31 | };
32 | }
33 | return cryptoInitializers;
34 | };
35 |
36 | // The "private key" embedded in each response is really more of a password used
37 | // to derive a key. Anyway, it's 64 bytes long. Base64 decoded and padded, it
38 | // comes out to 88 bytes. And that's where this number comes from.
39 | const MASTER_SEGMENT = 88;
40 |
41 | export const decrypt = async (data, keyDerivationPassword) => {
42 | const { PUBLIC_KEY, CRYPTO_SALT, CRYPTO_IV } = await getCryptoInitializers();
43 |
44 | // The content is base64 encoded, so decode that to binary first.
45 | const ciphertext = Buffer.from(data, "base64");
46 |
47 | // The actual key is derived from the derivation password using the salt from
48 | // the API and PBKDF2 with SHA1, with 1,000 iterations and a 16-byte output.
49 | const key = pbkdf2Sync(
50 | keyDerivationPassword ?? PUBLIC_KEY,
51 | CRYPTO_SALT,
52 | 1_000,
53 | 16,
54 | "sha1",
55 | );
56 |
57 | // It's encrypted with AES-128-CBC using the generated key above and the
58 | // hardcoded initialization vector.
59 | const decipher = createDecipheriv("aes-128-cbc", key, CRYPTO_IV);
60 |
61 | // The Node library works in chunks, so we'll get some stuff out as soon as we
62 | // update the decipher, and we have to get the rest out by calling .final().
63 | // The result is a string either way, so just join the array of results at the
64 | // end and be happy.
65 | const text = [decipher.update(ciphertext, "binary", "utf-8")];
66 | text.push(decipher.final("utf-8"));
67 | return text.join("");
68 | };
69 |
70 | export const parse = async (data) => {
71 | // The encrypted data is at the beginning. The last 88 bytes are the base64
72 | // encoded private key password. Slice those two out.
73 | const ciphertext = data.slice(0, -MASTER_SEGMENT);
74 | const privateKeyCipher = data.slice(-MASTER_SEGMENT);
75 |
76 | // The private key password is encrypted with the public key provided by the
77 | // API. It's a pipe-delimited string, but only the first segment is useful.
78 | // We can toss out the rest.
79 | const [privateKey] = await decrypt(privateKeyCipher).then((keyFragments) =>
80 | keyFragments.split("|"),
81 | );
82 |
83 | // The actual data is encrypted with the private key. The result is always
84 | // JSON (for our purposes), so go ahead and parse that.
85 | const plaintext = await decrypt(ciphertext, privateKey);
86 | try {
87 | return JSON.parse(plaintext);
88 | } catch (e) {
89 | return null;
90 | }
91 | };
92 |
--------------------------------------------------------------------------------
/src/data/crypto.test.js:
--------------------------------------------------------------------------------
1 | import sinon from "sinon";
2 | import tap from "tap";
3 |
4 | import { getCryptoInitializers, parse } from "./crypto.js";
5 |
6 | tap.test("crypto utilities", async (cryptoTests) => {
7 | const fetch = sinon.stub();
8 |
9 | cryptoTests.beforeEach(() => {
10 | fetch.reset();
11 |
12 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({
13 | json: async () => [],
14 | });
15 |
16 | const s = "12345678";
17 | const v = "12345678901234567890123456789012";
18 |
19 | fetch
20 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json")
21 | .resolves({
22 | json: async () => ({
23 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"],
24 | s: [s, s, s, s, s, s, s, s, "deadbeef"],
25 | // prettier-ignore
26 | v: [
27 | v, v, v, v, v, v, v, v, v, v,
28 | v, v, v, v, v, v, v, v, v, v,
29 | v, v, v, v, v, v, v, v, v, v,
30 | v, v,
31 | "7e117a1e7e117a1e7e117a1e7e117a1e",
32 | ],
33 | }),
34 | });
35 | });
36 |
37 | cryptoTests.test("fetching the keys and stuff", async (test) => {
38 | const {
39 | PUBLIC_KEY: key,
40 | CRYPTO_SALT: salt,
41 | CRYPTO_IV: iv,
42 | } = await getCryptoInitializers(fetch);
43 |
44 | test.same(key, "0b1d2897-640a-4c64-a1d8-b54f453a7ad7");
45 | test.same(salt.toString("hex"), "deadbeef");
46 | test.same(iv.toString("hex"), "7e117a1e7e117a1e7e117a1e7e117a1e");
47 | });
48 |
49 | cryptoTests.test("parses an encrypted string", async (test) => {
50 | // This is an encrypted string of the type we'd get from Amtrak. The private
51 | // key is "private_key" and the payload is a JSON string that (see the test)
52 | // is a message for the emperor.
53 | //
54 | // See the bottom of this test file for commented code where I built up the
55 | // test ciphertext.
56 | const parsedMessage = await parse(
57 | "9REEYi/JXW52zpVxlbzDP/zQ2NxJE8ykzOdkuiiLn8U0PskEpazNwyQIpmOBlJthSQeY8NhCd9gldfh7C/CscgnbFUD7IHkKaK4fnwB6tyY1C5vh4yZ8rUj5NmPMHCM9G2d/zqKvBZw3iXZFjg18Jw==",
58 | );
59 |
60 | test.matchOnly(parsedMessage, { a: "message", for: "the emperor" });
61 | });
62 | });
63 |
64 | /*
65 | import { createDecipheriv, createCipheriv, pbkdf2Sync } from "node:crypto";
66 |
67 | const PUBLIC_KEY = "0b1d2897-640a-4c64-a1d8-b54f453a7ad7";
68 | const CRYPTO_SALT = Buffer.from("deadbeef", "hex");
69 | const CRYPTO_IV = Buffer.from("7e117a1e7e117a1e7e117a1e7e117a1e", "hex");
70 |
71 | // Encryption method. It's basically a reverse of the decrypt method in
72 | // the crypto.js utility module
73 | const enc = async (str, PK = PUBLIC_KEY) =>
74 | new Promise((resolve) => {
75 | const key = pbkdf2Sync(PK, CRYPTO_SALT, 1_000, 16, "sha1");
76 | const cipher = createCipheriv("aes-128-cbc", key, CRYPTO_IV);
77 | cipher.setEncoding("base64");
78 |
79 | const ciphertext = [];
80 | cipher.on("data", (chunk) => {
81 | if (chunk) {
82 | ciphertext.push(chunk);
83 | }
84 | });
85 | cipher.on("end", () => {
86 | resolve(ciphertext.join(""));
87 | });
88 |
89 | cipher.write(str);
90 | cipher.end();
91 | });
92 |
93 | const makeCipherBlob = async () => {
94 | // The actual private key is derived from the password, which is the first
95 | // part of this string, "private_key". But this extra stuff is tacked on to
96 | // private key when it's encrypted, ostensibly to make it harder to decrypt.
97 | const privateKey =
98 | "private_key|date string or something|time sneaky sneaky haha";
99 |
100 | // Encrypt the private key with the global public key
101 | const cipherkey = await enc(privateKey);
102 |
103 | // The binary length needs to be 64 and the base64-encoded length needs to be
104 | // 88. I output these here so I could fiddle with the padding stuff above to
105 | // get the right output lengths.
106 | console.log(cipherkey.length, Buffer.from(cipherkey, "base64").length);
107 |
108 | // Here's the actual message.
109 | const plaintext = JSON.stringify({ a: "message", for: "the emperor" });
110 |
111 | // Encrypt it with "private_key," not with the encrypted or derived keys.
112 | const ciphertext = await enc(plaintext, "private_key");
113 |
114 | // Smoosh them together and display them. This is what we can use for tests.
115 | console.log(`${ciphertext}${cipherkey}`);
116 | };
117 |
118 | await makeCipherBlob();
119 | */
120 |
--------------------------------------------------------------------------------
/src/data/date.js:
--------------------------------------------------------------------------------
1 | import customParseFormat from "dayjs/plugin/customParseFormat.js";
2 | import dayjs from "dayjs";
3 | import timezone from "dayjs/plugin/timezone.js";
4 | import utc from "dayjs/plugin/utc.js";
5 |
6 | dayjs.extend(customParseFormat);
7 | dayjs.extend(utc);
8 | dayjs.extend(timezone);
9 |
10 | export const getTimezoneFromCharacter = (tzIn) => {
11 | // The API returns timezones as single characters. I've only encountered the
12 | // four primary continental US timezones. The continental part makes sense
13 | // because these are trains, after all, but this doesn't account for all the
14 | // quirks with states and/or municipalities opting out of daylight savings
15 | // time.
16 | switch (tzIn.toUpperCase()) {
17 | case "P":
18 | return "America/Los_Angeles";
19 | case "M":
20 | return "America/Denver";
21 | case "C":
22 | return "America/Chicago";
23 | case "E":
24 | default:
25 | return "America/New_York";
26 | }
27 | };
28 |
29 | export const parseDate = (uglyDate, tz = "America/New_York") => {
30 | if (!uglyDate) {
31 | return null;
32 | }
33 |
34 | // DayJS can parse the ugly dates if we tell it what the format is. Give it a
35 | // timezone and then turn that puppy into an ISO8601 UTC string.
36 | return dayjs.tz(uglyDate, "MM/DD/YYYY hh:mm:ss", tz).toISOString();
37 | };
38 |
--------------------------------------------------------------------------------
/src/data/date.test.js:
--------------------------------------------------------------------------------
1 | import tap from "tap";
2 |
3 | import { getTimezoneFromCharacter, parseDate } from "./date.js";
4 |
5 | tap.test("date utilities", async (dateTests) => {
6 | dateTests.test("get timezone from a character", async (tzCharTests) => {
7 | tzCharTests.test("east coast", async (test) => {
8 | const actual = getTimezoneFromCharacter("e");
9 | test.same(actual, "America/New_York");
10 | });
11 |
12 | tzCharTests.test("center coast", async (test) => {
13 | const actual = getTimezoneFromCharacter("c");
14 | test.same(actual, "America/Chicago");
15 | });
16 |
17 | tzCharTests.test("mountain coast", async (test) => {
18 | const actual = getTimezoneFromCharacter("m");
19 | test.same(actual, "America/Denver");
20 | });
21 |
22 | tzCharTests.test("left coast", async (test) => {
23 | const actual = getTimezoneFromCharacter("p");
24 | test.same(actual, "America/Los_Angeles");
25 | });
26 |
27 | tzCharTests.test("unknown coast", async (test) => {
28 | const actual = getTimezoneFromCharacter("q");
29 | test.same(actual, "America/New_York");
30 | });
31 | });
32 |
33 | dateTests.test(
34 | "parse ugly Amtrak date into ISO8601 strings",
35 | async (parseTests) => {
36 | parseTests.test("handles falsey dates", async (test) => {
37 | const actual = parseDate(false);
38 | test.same(actual, null);
39 | });
40 |
41 | parseTests.test("defaults to NY timezone", async (test) => {
42 | const actual = parseDate("02/03/2017 3:47:00");
43 | test.same(actual, "2017-02-03T08:47:00.000Z");
44 | });
45 |
46 | parseTests.test("uses the optionally provided timezone", async (test) => {
47 | const actual = parseDate("02/03/2017 3:47:00", "America/Denver");
48 | test.same(actual, "2017-02-03T10:47:00.000Z");
49 | });
50 | },
51 | );
52 | });
53 |
--------------------------------------------------------------------------------
/src/data/main.js:
--------------------------------------------------------------------------------
1 | import defaultFs from "node:fs/promises";
2 | import { getStations as defaultGetStations } from "./stations.js";
3 | import { getTrains as defaultGetTrains } from "./trains.js";
4 |
5 | const slugify = (str) =>
6 | str
7 | .toLowerCase()
8 | .trim()
9 | .replace(/[\s_]/g, "-")
10 | .replace(/[^a-z0-9-_]/g, "")
11 | .replace(/-{2,}/g, "-");
12 |
13 | export const main = async ({
14 | fs = defaultFs,
15 | getStations = defaultGetStations,
16 | getTrains = defaultGetTrains,
17 | } = {}) => {
18 | const stations = await getStations();
19 | const trains = await getTrains(stations ?? []);
20 |
21 | await fs.mkdir(`_site/routes`, { recursive: true });
22 |
23 | if (stations) {
24 | await fs.writeFile("_site/stations.json", JSON.stringify(stations));
25 | }
26 | if (trains) {
27 | await fs.writeFile("_site/trains.json", JSON.stringify(trains));
28 |
29 | const routes = Array.from(new Set(trains.map(({ route }) => route)))
30 | .map((routeName) => ({
31 | route: routeName,
32 | trains: trains.filter(({ route }) => route === routeName),
33 | }))
34 | .sort(({ route: a }, { route: b }) => {
35 | if (a > b) {
36 | return 1;
37 | }
38 | if (a < b) {
39 | return -1;
40 | }
41 | return 0;
42 | });
43 |
44 | await fs.writeFile("_site/routes.json", JSON.stringify(routes));
45 |
46 | for await (const route of routes) {
47 | await fs.writeFile(
48 | `_site/routes/${slugify(route.route)}.json`,
49 | JSON.stringify(route),
50 | );
51 | }
52 | }
53 | };
54 |
55 | const isMainModule = import.meta.url.endsWith(process.argv[1]);
56 | if (isMainModule) {
57 | main();
58 | }
59 |
--------------------------------------------------------------------------------
/src/data/main.test.js:
--------------------------------------------------------------------------------
1 | import sinon from "sinon";
2 | import tap from "tap";
3 |
4 | import { main } from "./main.js";
5 |
6 | const sandbox = sinon.createSandbox();
7 |
8 | tap.test("main module", async (t) => {
9 | const fs = { mkdir: sandbox.spy(), writeFile: sandbox.spy() };
10 | const getStations = sandbox.stub();
11 | const getTrains = sandbox.stub();
12 |
13 | t.beforeEach(async () => {});
14 |
15 | t.afterEach(() => {
16 | sandbox.restore();
17 | });
18 |
19 | t.test("main works", async () => {
20 | const stations = [
21 | { code: "station 1", name: "Moopville Station" },
22 | { code: "station 2", name: "Mooptropolis" },
23 | ];
24 | getStations.resolves(stations);
25 |
26 | const trains = [
27 | { id: 1, number: 1, route: "Route 1" },
28 | { id: 2, number: 2, route: "Route 1" },
29 | { id: 4, number: 3, route: "Route 1" },
30 | { id: 5, number: 20, route: "Route 3" },
31 | { id: 8, number: 30, route: "Route 4" },
32 | { id: 6, number: 10, route: "Route 2" },
33 | { id: 7, number: 11, route: "Route 2" },
34 | ];
35 | getTrains.resolves(trains);
36 |
37 | const routes = [
38 | {
39 | route: "Route 1",
40 | trains: [
41 | { id: 1, number: 1, route: "Route 1" },
42 | { id: 2, number: 2, route: "Route 1" },
43 | { id: 4, number: 3, route: "Route 1" },
44 | ],
45 | },
46 | {
47 | route: "Route 2",
48 | trains: [
49 | { id: 6, number: 10, route: "Route 2" },
50 | { id: 7, number: 11, route: "Route 2" },
51 | ],
52 | },
53 | { route: "Route 3", trains: [{ id: 5, number: 20, route: "Route 3" }] },
54 | { route: "Route 4", trains: [{ id: 8, number: 30, route: "Route 4" }] },
55 | ];
56 |
57 | await main({ fs, getStations, getTrains });
58 |
59 | t.ok(fs.mkdir.calledWith("_site/routes", { recursive: true }));
60 | t.ok(
61 | fs.writeFile.calledWith("_site/stations.json", JSON.stringify(stations)),
62 | );
63 | t.ok(fs.writeFile.calledWith("_site/trains.json", JSON.stringify(trains)));
64 | t.ok(fs.writeFile.calledWith("_site/routes.json", JSON.stringify(routes)));
65 |
66 | t.ok(
67 | fs.writeFile.calledWith(
68 | "_site/routes/route-1.json",
69 | JSON.stringify(routes[0]),
70 | ),
71 | );
72 |
73 | t.ok(
74 | fs.writeFile.calledWith(
75 | "_site/routes/route-2.json",
76 | JSON.stringify(routes[1]),
77 | ),
78 | );
79 |
80 | t.ok(
81 | fs.writeFile.calledWith(
82 | "_site/routes/route-3.json",
83 | JSON.stringify(routes[2]),
84 | ),
85 | );
86 |
87 | t.ok(
88 | fs.writeFile.calledWith(
89 | "_site/routes/route-4.json",
90 | JSON.stringify(routes[3]),
91 | ),
92 | );
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/data/routeStation.js:
--------------------------------------------------------------------------------
1 | import { getTimezoneFromCharacter, parseDate } from "./date.js";
2 |
3 | export const parseRouteStation = (routeStation, first = false) => {
4 | const timezone = getTimezoneFromCharacter(routeStation.tz);
5 |
6 | const station = {
7 | code: routeStation.code,
8 | bus: routeStation.bus,
9 | arrivalActual: null,
10 | arrivalEstimated: null,
11 | arrivalScheduled: null,
12 | departureActual: null,
13 | departureEstimated: null,
14 | departureScheduled: null,
15 | status: null,
16 | timezone,
17 | };
18 |
19 | if (first && !routeStation.postdep) {
20 | // If this is the first station and the train has not yet departed, then
21 | // this station is scheduled. We only have scheduled and estimated departure
22 | // times available to us.
23 | station.status = "scheduled";
24 | station.departureEstimated = parseDate(routeStation.estdep, timezone);
25 | station.departureScheduled = parseDate(routeStation.schdep, timezone);
26 | } else if (routeStation.postdep) {
27 | // If the train has departed this station...
28 | station.status = "departed";
29 |
30 | if (first) {
31 | // ...and this is the first station, we only have scheduled and actual
32 | // departure times available, as the train kinda didn't arrive. It was
33 | // just... there. Spooky train.
34 | station.departureActual = parseDate(routeStation.postdep, timezone);
35 | station.departureScheduled = parseDate(routeStation.schdep, timezone);
36 | } else {
37 | // ...otherwise we can capture the scheduled and actual arrival and
38 | // depature times for this station.
39 | station.arrivalActual = parseDate(routeStation.postarr, timezone);
40 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone);
41 | station.departureActual = parseDate(routeStation.postdep, timezone);
42 | station.departureScheduled = parseDate(routeStation.schdep, timezone);
43 | }
44 | } else if (routeStation.postarr) {
45 | // If the has not departed but HAS arrived, then the train is currently
46 | // sitting at the station. We can know when it was supposed to arrive, when
47 | // it did, when it is scheduled to leave, and when they think it'll actually
48 | // depart.
49 | station.status = "arrived";
50 | station.arrivalActual = parseDate(routeStation.postarr, timezone);
51 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone);
52 | station.departureEstimated = parseDate(routeStation.estdep, timezone);
53 | station.departureScheduled = parseDate(routeStation.schdep, timezone);
54 | } else {
55 | // If the train has neither departed nor arrived, then it must be on its
56 | // way. We only have scheduled and estimated times to work with.
57 | station.status = "enroute";
58 | station.arrivalEstimated = parseDate(routeStation.estarr, timezone);
59 | station.arrivalScheduled = parseDate(routeStation.scharr, timezone);
60 | station.departureEstimated = parseDate(routeStation.estdep, timezone);
61 | station.departureScheduled = parseDate(routeStation.schdep, timezone);
62 | }
63 |
64 | return station;
65 | };
66 |
--------------------------------------------------------------------------------
/src/data/stations.js:
--------------------------------------------------------------------------------
1 | import { parse } from "./crypto.js";
2 |
3 | export const getStations = async ({
4 | cryptoParse = parse,
5 | fetch = global.fetch,
6 | } = {}) => {
7 | // Fetch the raw data.
8 | const rawData = await fetch(
9 | "https://maps.amtrak.com/services/MapDataService/stations/trainStations",
10 | ).then((response) => response.text());
11 |
12 | // Decrypt it.
13 | const stations = await cryptoParse(rawData);
14 |
15 | // Sometimes Amtrak tells the GitHub action that it's not allowed to access
16 | // the site. Perhaps they're shutting this API down because they're not happy
17 | // that someone has made their data accessible since they're not willing to
18 | // do it, despite being publicly funded.
19 | if (stations?.StationsDataResponse?.error) {
20 | console.log(stations.StationsDataResponse.error.message);
21 | return [];
22 | }
23 |
24 | // Map it into a little bit cleaner structure, and keep the original raw data
25 | return stations?.StationsDataResponse?.features?.map(
26 | ({ properties: station }) => ({
27 | code: station.Code,
28 | name: station.StationName,
29 | address1: station.Address1,
30 | address2: station.Address2,
31 | city: station.City,
32 | state: station.State,
33 | lat: station.lat,
34 | lon: station.lon,
35 | zip: station.Zipcode,
36 | _raw: station,
37 | }),
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/data/stations.test.js:
--------------------------------------------------------------------------------
1 | import sinon from "sinon";
2 | import tap from "tap";
3 |
4 | import { getStations } from "./stations.js";
5 |
6 | tap.test("stations fetcher", async (stationTests) => {
7 | const fetch = sinon.stub();
8 |
9 | stationTests.beforeEach(() => {
10 | fetch.reset();
11 |
12 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({
13 | json: async () => [],
14 | });
15 |
16 | const s = "12345678";
17 | const v = "12345678901234567890123456789012";
18 |
19 | fetch
20 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json")
21 | .resolves({
22 | json: async () => ({
23 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"],
24 | s: [s, s, s, s, s, s, s, s, "deadbeef"],
25 | // prettier-ignore
26 | v: [
27 | v, v, v, v, v, v, v, v, v, v,
28 | v, v, v, v, v, v, v, v, v, v,
29 | v, v, v, v, v, v, v, v, v, v,
30 | v, v,
31 | "7e117a1e7e117a1e7e117a1e7e117a1e",
32 | ],
33 | }),
34 | });
35 | });
36 |
37 | stationTests.test("maps stations into our lingo", async (test) => {
38 | fetch
39 | .withArgs(
40 | "https://maps.amtrak.com/services/MapDataService/stations/trainStations",
41 | )
42 | .resolves({
43 | text: async () => "this is some text",
44 | });
45 |
46 | const cryptoParse = sinon.stub();
47 | cryptoParse.resolves({
48 | StationsDataResponse: {
49 | features: [
50 | {
51 | properties: {
52 | Code: "STA1",
53 | StationName: "Station 1",
54 | Address1: "Street 1",
55 | Address2: "Unit 1",
56 | City: "Cityville",
57 | State: "FR",
58 | lat: 31,
59 | lon: -31,
60 | Zipcode: 11111,
61 | },
62 | },
63 | {
64 | properties: {
65 | Code: "STA2",
66 | StationName: "Station Two",
67 | Address1: "Street 2",
68 | Address2: "Apt 2",
69 | City: "Metropolis",
70 | State: "BQ",
71 | lat: 32,
72 | lon: -32,
73 | Zipcode: 22222,
74 | },
75 | },
76 | {
77 | properties: {
78 | Code: "STA3",
79 | StationName: "Third Station",
80 | Address1: "Street 3",
81 | Address2: "Loft #3",
82 | City: "Gotham City",
83 | State: "OP",
84 | lat: 33,
85 | lon: -33,
86 | Zipcode: 33333,
87 | },
88 | },
89 | ],
90 | },
91 | });
92 |
93 | const out = await getStations({ fetch, cryptoParse });
94 | test.same(out, [
95 | {
96 | code: "STA1",
97 | name: "Station 1",
98 | address1: "Street 1",
99 | address2: "Unit 1",
100 | city: "Cityville",
101 | state: "FR",
102 | lat: 31,
103 | lon: -31,
104 | zip: 11111,
105 | _raw: {
106 | Code: "STA1",
107 | StationName: "Station 1",
108 | Address1: "Street 1",
109 | Address2: "Unit 1",
110 | City: "Cityville",
111 | State: "FR",
112 | lat: 31,
113 | lon: -31,
114 | Zipcode: 11111,
115 | },
116 | },
117 | {
118 | code: "STA2",
119 | name: "Station Two",
120 | address1: "Street 2",
121 | address2: "Apt 2",
122 | city: "Metropolis",
123 | state: "BQ",
124 | lat: 32,
125 | lon: -32,
126 | zip: 22222,
127 | _raw: {
128 | Code: "STA2",
129 | StationName: "Station Two",
130 | Address1: "Street 2",
131 | Address2: "Apt 2",
132 | City: "Metropolis",
133 | State: "BQ",
134 | lat: 32,
135 | lon: -32,
136 | Zipcode: 22222,
137 | },
138 | },
139 | {
140 | code: "STA3",
141 | name: "Third Station",
142 | address1: "Street 3",
143 | address2: "Loft #3",
144 | city: "Gotham City",
145 | state: "OP",
146 | lat: 33,
147 | lon: -33,
148 | zip: 33333,
149 | _raw: {
150 | Code: "STA3",
151 | StationName: "Third Station",
152 | Address1: "Street 3",
153 | Address2: "Loft #3",
154 | City: "Gotham City",
155 | State: "OP",
156 | lat: 33,
157 | lon: -33,
158 | Zipcode: 33333,
159 | },
160 | },
161 | ]);
162 | });
163 | });
164 |
--------------------------------------------------------------------------------
/src/data/trains.js:
--------------------------------------------------------------------------------
1 | import { parse } from "./crypto.js";
2 | import { parseRouteStation } from "./routeStation.js";
3 |
4 | export const getTrains = async (
5 | allStationMetadata,
6 | { fetch = global.fetch, cryptoParse = parse } = {},
7 | ) => {
8 | // Fetch the train data. This is an encrypted blob.
9 | const rawData = await fetch(
10 | "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData",
11 | ).then((response) => response.text());
12 |
13 | // Decrypt all in one swoop.
14 | const trains = await cryptoParse(rawData);
15 |
16 | // Now clean up the train data.
17 | return (
18 | trains.features
19 | .map(({ properties: train }) => {
20 | // The train has a bunch of properties Station1 through Station41. If a
21 | // train visits fewer stations, the remainder are just null. It seems
22 | // fragile to just assume there will always be the same number of these
23 | // properties, so instead, find all property keys that match, filter out the
24 | // ones with no data, and sort by number. Now they're in visit order.
25 | // Good work, team!
26 | const stations = Object.keys(train)
27 | .filter((key) => /^Station\d{1,2}$/.test(key))
28 | .filter((key) => !!train[key])
29 | .sort((a, b) => {
30 | const numA = +a.replace(/\D/g, "");
31 | const numB = +b.replace(/\D/g, "");
32 | return numA - numB;
33 | })
34 | // Now we can clean up the information for each station, too.
35 | .map((stationKey, stationNumber) => {
36 | // So the station metadata is string-encoded JSON. Which I guess means
37 | // it was double encoded, since the containing object was also string-
38 | // encoded JSON. Shrugging-person-made-of-symbols.
39 | const stationData = JSON.parse(train[stationKey]);
40 | return {
41 | // This function takes care of figuring out whether the train is
42 | // scheduled, arrived, enroute, or departed. It also turns all the
43 | // timestamps into proper ISO8601 UTC timestamps, from what the API
44 | // provides (local timestamps, but not quite ISO8601, and with the
45 | // timezone provided as a single letter).
46 | //
47 | // This function needs to know if this is the first station in the
48 | // list because the first station is handled a little differently from
49 | // the rest. So that's the second argument.
50 | ...parseRouteStation(stationData, stationNumber === 0),
51 |
52 | // From the list of all stations provided, we can find this station
53 | // and add its metadata too.
54 | station: allStationMetadata.find(
55 | (value) => value.code === stationData.code,
56 | ),
57 |
58 | // And finally, keep the raw data.
59 | _raw: stationData,
60 | };
61 | })
62 | // And clear out this CBN station. No idea what it is, but there's no
63 | // associated CBN station metadata, and I believe I saw this somewhere on
64 | // the Amtrak site as well.
65 | .filter(({ code }) => code !== "CBN");
66 |
67 | // If the first station is scheduled, all stations are scheduled, regardless
68 | // of the prior logic. So go ahead and fix that up.
69 | if (stations.length > 0 && stations[0].status === "scheduled") {
70 | stations.forEach((station) => {
71 | station.status = "scheduled";
72 | });
73 | }
74 |
75 | // If any station is in "arrived" status, then there can't be any enroute
76 | // stations because the train isn't enroute anywhere.
77 | if (stations.some(({ status }) => status === "arrived")) {
78 | stations
79 | .filter(({ status }) => status === "enroute")
80 | .forEach((station) => {
81 | station.status = "scheduled";
82 | });
83 | }
84 |
85 | // Also, there is only one enroute station. Any stations downtrack from that
86 | // are just scheduled.
87 | const enrouteIndex = stations.findIndex(
88 | ({ status }) => status === "enroute",
89 | );
90 | if (enrouteIndex >= 0) {
91 | stations.slice(enrouteIndex + 1).forEach((station) => {
92 | station.status = "scheduled";
93 | });
94 | }
95 |
96 | // Now that we've turned all those weird StationDD properties into a single
97 | // sorted parsed array, we can delete them.
98 | Object.keys(train)
99 | .filter((key) => /^Station\d{1,2}$/.test(key))
100 | .forEach((key) => delete train[key]);
101 |
102 | // And build our cleaned up train object. Also keep the raw data.
103 | const newTrain = {
104 | id: train.ID,
105 | heading: train.Heading,
106 | number: +train.TrainNum,
107 | route: train.RouteName,
108 | stations,
109 | _raw: train,
110 | };
111 |
112 | return newTrain;
113 | })
114 | // Sometimes there are trains with no stations. This seems to be common when
115 | // one of the listed trains is predeparture.
116 | .filter(({ stations }) => stations.length > 0)
117 | );
118 | };
119 |
--------------------------------------------------------------------------------
/src/data/trains.test.data.js:
--------------------------------------------------------------------------------
1 | export const scheduled = {
2 | features: [
3 | {
4 | properties: {
5 | ID: "train 1",
6 | Heading: "nw",
7 | TrainNum: "37",
8 | RouteName: "Big Route",
9 | Station3: JSON.stringify({
10 | code: "STA3",
11 | bus: false,
12 | tz: "P",
13 | scharr: "10/10/2020 08:00:00",
14 | schdep: "10/10/2020 08:10:00",
15 | }),
16 | Station1: JSON.stringify({
17 | code: "STA1",
18 | bus: false,
19 | tz: "E",
20 | estdep: "10/10/2020 07:00:00",
21 | schdep: "10/10/2020 06:30:00",
22 | }),
23 | Station2: JSON.stringify({
24 | code: "STA2",
25 | bus: false,
26 | tz: "M",
27 | scharr: "10/10/2020 07:15:00",
28 | schdep: "10/10/2020 07:20:00",
29 | }),
30 | },
31 | },
32 | ],
33 | };
34 |
35 | export const enrouteArrived = {
36 | features: [
37 | {
38 | properties: {
39 | ID: "train 2",
40 | Heading: "ns",
41 | TrainNum: "42",
42 | RouteName: "Choo Choo Route",
43 | Station2: JSON.stringify({
44 | code: "STA2",
45 | bus: false,
46 | tz: "M",
47 | postarr: "10/10/2020 13:45:00",
48 | postdep: "10/10/2020 13:48:00",
49 | }),
50 | Station4: JSON.stringify({
51 | code: "STA4",
52 | bus: false,
53 | tz: "M",
54 | scharr: "10/10/2020 18:15:00",
55 | }),
56 | Station3: JSON.stringify({
57 | code: "STA3",
58 | bus: false,
59 | tz: "E",
60 | postarr: "10/10/2020 14:19:00",
61 | }),
62 | Station1: JSON.stringify({
63 | code: "STA1",
64 | bus: false,
65 | tz: "C",
66 | postdep: "10/10/2020 13:25:00",
67 | }),
68 | },
69 | },
70 | ],
71 | };
72 |
73 | export const enroute = {
74 | features: [
75 | {
76 | properties: {
77 | ID: "train 2",
78 | Heading: "ns",
79 | TrainNum: "42",
80 | RouteName: "Choo Choo Route",
81 | Station2: JSON.stringify({
82 | code: "STA2",
83 | bus: false,
84 | tz: "M",
85 | postarr: "10/10/2020 13:45:00",
86 | postdep: "10/10/2020 13:48:00",
87 | }),
88 | Station4: JSON.stringify({
89 | code: "STA4",
90 | bus: false,
91 | tz: "M",
92 | scharr: "10/10/2020 18:15:00",
93 | }),
94 | Station3: JSON.stringify({
95 | code: "STA3",
96 | bus: false,
97 | tz: "E",
98 | postarr: "10/10/2020 14:19:00",
99 | postdep: "10/10/2020 14:23:00",
100 | }),
101 | Station1: JSON.stringify({
102 | code: "STA1",
103 | bus: false,
104 | tz: "C",
105 | postdep: "10/10/2020 13:25:00",
106 | }),
107 | Station5: JSON.stringify({
108 | code: "STA5",
109 | bus: false,
110 | tz: "M",
111 | scharr: "10/10/2020 18:15:00",
112 | }),
113 | },
114 | },
115 | ],
116 | };
117 |
--------------------------------------------------------------------------------
/src/data/trains.test.js:
--------------------------------------------------------------------------------
1 | import sinon from "sinon";
2 | import tap from "tap";
3 | import { enroute, enrouteArrived, scheduled } from "./trains.test.data.js";
4 |
5 | import { getTrains } from "./trains.js";
6 |
7 | tap.test("trains fetcher", async (trainTests) => {
8 | const fetch = sinon.stub();
9 | const cryptoParse = sinon.stub();
10 |
11 | trainTests.beforeEach(() => {
12 | fetch.reset();
13 | cryptoParse.reset();
14 |
15 | fetch.withArgs("https://maps.amtrak.com/rttl/js/RoutesList.json").resolves({
16 | json: async () => [],
17 | });
18 |
19 | const s = "12345678";
20 | const v = "12345678901234567890123456789012";
21 |
22 | fetch
23 | .withArgs("https://maps.amtrak.com/rttl/js/RoutesList.v.json")
24 | .resolves({
25 | json: async () => ({
26 | arr: ["0b1d2897-640a-4c64-a1d8-b54f453a7ad7"],
27 | s: [s, s, s, s, s, s, s, s, "deadbeef"],
28 | // prettier-ignore
29 | v: [
30 | v, v, v, v, v, v, v, v, v, v,
31 | v, v, v, v, v, v, v, v, v, v,
32 | v, v, v, v, v, v, v, v, v, v,
33 | v, v,
34 | "7e117a1e7e117a1e7e117a1e7e117a1e",
35 | ],
36 | }),
37 | });
38 |
39 | fetch
40 | .withArgs(
41 | "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData",
42 | )
43 | .resolves({
44 | text: async () => "this is some text",
45 | });
46 | });
47 |
48 | trainTests.test("a predeparture train", async (test) => {
49 | cryptoParse.resolves({
50 | features: [
51 | {
52 | properties: {
53 | ID: "bad train",
54 | TrainNum: "333",
55 | Station4: null,
56 | Station2: null,
57 | Station1: null,
58 | Station3: null,
59 | },
60 | },
61 | ],
62 | });
63 |
64 | const out = await getTrains([], { fetch, cryptoParse });
65 | test.same(out, []);
66 | });
67 |
68 | trainTests.test("a scheduled train", async (test) => {
69 | cryptoParse.resolves(scheduled);
70 |
71 | const out = await getTrains(
72 | [
73 | { code: "STA1", station: "station 1 metadata" },
74 | { code: "STA2", station: "station 2 metadata" },
75 | { code: "STA3", station: "station 3 metadata" },
76 | ],
77 | { fetch, cryptoParse },
78 | );
79 |
80 | test.same(out, [
81 | {
82 | id: "train 1",
83 | heading: "nw",
84 | number: 37,
85 | route: "Big Route",
86 | stations: [
87 | {
88 | code: "STA1",
89 | bus: false,
90 | arrivalActual: null,
91 | arrivalEstimated: null,
92 | arrivalScheduled: null,
93 | departureActual: null,
94 | departureEstimated: "2020-10-10T11:00:00.000Z",
95 | departureScheduled: "2020-10-10T10:30:00.000Z",
96 | status: "scheduled",
97 | timezone: "America/New_York",
98 | station: {
99 | code: "STA1",
100 | station: "station 1 metadata",
101 | },
102 | _raw: {
103 | code: "STA1",
104 | bus: false,
105 | tz: "E",
106 | estdep: "10/10/2020 07:00:00",
107 | schdep: "10/10/2020 06:30:00",
108 | },
109 | },
110 | {
111 | code: "STA2",
112 | bus: false,
113 | arrivalActual: null,
114 | arrivalEstimated: null,
115 | arrivalScheduled: "2020-10-10T13:15:00.000Z",
116 | departureActual: null,
117 | departureEstimated: null,
118 | departureScheduled: "2020-10-10T13:20:00.000Z",
119 | status: "scheduled",
120 | timezone: "America/Denver",
121 | station: {
122 | code: "STA2",
123 | station: "station 2 metadata",
124 | },
125 | _raw: {
126 | code: "STA2",
127 | bus: false,
128 | tz: "M",
129 | scharr: "10/10/2020 07:15:00",
130 | schdep: "10/10/2020 07:20:00",
131 | },
132 | },
133 | {
134 | code: "STA3",
135 | bus: false,
136 | arrivalActual: null,
137 | arrivalEstimated: null,
138 | arrivalScheduled: "2020-10-10T15:00:00.000Z",
139 | departureActual: null,
140 | departureEstimated: null,
141 | departureScheduled: "2020-10-10T15:10:00.000Z",
142 | status: "scheduled",
143 | timezone: "America/Los_Angeles",
144 | station: {
145 | code: "STA3",
146 | station: "station 3 metadata",
147 | },
148 | _raw: {
149 | code: "STA3",
150 | bus: false,
151 | tz: "P",
152 | scharr: "10/10/2020 08:00:00",
153 | schdep: "10/10/2020 08:10:00",
154 | },
155 | },
156 | ],
157 | _raw: {
158 | ID: "train 1",
159 | Heading: "nw",
160 | TrainNum: "37",
161 | RouteName: "Big Route",
162 | },
163 | },
164 | ]);
165 | });
166 |
167 | trainTests.test(
168 | "a train that has begun its route but is currently sitting at a station",
169 | async (test) => {
170 | cryptoParse.resolves(enrouteArrived);
171 |
172 | const out = await getTrains(
173 | [
174 | { code: "STA1", station: "station 1 metadata" },
175 | { code: "STA2", station: "station 2 metadata" },
176 | { code: "STA3", station: "station 3 metadata" },
177 | { code: "STA4", station: "station 4 metadata" },
178 | ],
179 | { fetch, cryptoParse },
180 | );
181 |
182 | test.same(out, [
183 | {
184 | id: "train 2",
185 | heading: "ns",
186 | number: 42,
187 | route: "Choo Choo Route",
188 | stations: [
189 | {
190 | code: "STA1",
191 | bus: false,
192 | arrivalActual: null,
193 | arrivalEstimated: null,
194 | arrivalScheduled: null,
195 | departureActual: "2020-10-10T18:25:00.000Z",
196 | departureEstimated: null,
197 | departureScheduled: null,
198 | status: "departed",
199 | timezone: "America/Chicago",
200 | station: {
201 | code: "STA1",
202 | station: "station 1 metadata",
203 | },
204 | _raw: {
205 | code: "STA1",
206 | bus: false,
207 | tz: "C",
208 | postdep: "10/10/2020 13:25:00",
209 | },
210 | },
211 | {
212 | code: "STA2",
213 | bus: false,
214 | arrivalActual: "2020-10-10T19:45:00.000Z",
215 | arrivalEstimated: null,
216 | arrivalScheduled: null,
217 | departureActual: "2020-10-10T19:48:00.000Z",
218 | departureEstimated: null,
219 | departureScheduled: null,
220 | status: "departed",
221 | timezone: "America/Denver",
222 | station: {
223 | code: "STA2",
224 | station: "station 2 metadata",
225 | },
226 | _raw: {
227 | code: "STA2",
228 | bus: false,
229 | tz: "M",
230 | postarr: "10/10/2020 13:45:00",
231 | postdep: "10/10/2020 13:48:00",
232 | },
233 | },
234 | {
235 | code: "STA3",
236 | bus: false,
237 | arrivalActual: "2020-10-10T18:19:00.000Z",
238 | arrivalEstimated: null,
239 | arrivalScheduled: null,
240 | departureActual: null,
241 | departureEstimated: null,
242 | departureScheduled: null,
243 | status: "arrived",
244 | timezone: "America/New_York",
245 | station: {
246 | code: "STA3",
247 | station: "station 3 metadata",
248 | },
249 | _raw: {
250 | code: "STA3",
251 | bus: false,
252 | tz: "E",
253 | postarr: "10/10/2020 14:19:00",
254 | },
255 | },
256 | {
257 | code: "STA4",
258 | bus: false,
259 | arrivalActual: null,
260 | arrivalEstimated: null,
261 | arrivalScheduled: "2020-10-11T00:15:00.000Z",
262 | departureActual: null,
263 | departureEstimated: null,
264 | departureScheduled: null,
265 | status: "scheduled",
266 | timezone: "America/Denver",
267 | station: {
268 | code: "STA4",
269 | station: "station 4 metadata",
270 | },
271 | _raw: {
272 | code: "STA4",
273 | bus: false,
274 | tz: "M",
275 | scharr: "10/10/2020 18:15:00",
276 | },
277 | },
278 | ],
279 | _raw: {
280 | ID: "train 2",
281 | Heading: "ns",
282 | TrainNum: "42",
283 | RouteName: "Choo Choo Route",
284 | },
285 | },
286 | ]);
287 | },
288 | );
289 |
290 | trainTests.test(
291 | "a train that has begun its route and is currently between stations",
292 | async (test) => {
293 | cryptoParse.resolves(enroute);
294 |
295 | const out = await getTrains(
296 | [
297 | { code: "STA1", station: "station 1 metadata" },
298 | { code: "STA2", station: "station 2 metadata" },
299 | { code: "STA3", station: "station 3 metadata" },
300 | { code: "STA4", station: "station 4 metadata" },
301 | { code: "STA5", station: "station 5 metadata" },
302 | ],
303 | { fetch, cryptoParse },
304 | );
305 |
306 | test.same(out, [
307 | {
308 | id: "train 2",
309 | heading: "ns",
310 | number: 42,
311 | route: "Choo Choo Route",
312 | stations: [
313 | {
314 | code: "STA1",
315 | bus: false,
316 | arrivalActual: null,
317 | arrivalEstimated: null,
318 | arrivalScheduled: null,
319 | departureActual: "2020-10-10T18:25:00.000Z",
320 | departureEstimated: null,
321 | departureScheduled: null,
322 | status: "departed",
323 | timezone: "America/Chicago",
324 | station: {
325 | code: "STA1",
326 | station: "station 1 metadata",
327 | },
328 | _raw: {
329 | code: "STA1",
330 | bus: false,
331 | tz: "C",
332 | postdep: "10/10/2020 13:25:00",
333 | },
334 | },
335 | {
336 | code: "STA2",
337 | bus: false,
338 | arrivalActual: "2020-10-10T19:45:00.000Z",
339 | arrivalEstimated: null,
340 | arrivalScheduled: null,
341 | departureActual: "2020-10-10T19:48:00.000Z",
342 | departureEstimated: null,
343 | departureScheduled: null,
344 | status: "departed",
345 | timezone: "America/Denver",
346 | station: {
347 | code: "STA2",
348 | station: "station 2 metadata",
349 | },
350 | _raw: {
351 | code: "STA2",
352 | bus: false,
353 | tz: "M",
354 | postarr: "10/10/2020 13:45:00",
355 | postdep: "10/10/2020 13:48:00",
356 | },
357 | },
358 | {
359 | code: "STA3",
360 | bus: false,
361 | arrivalActual: "2020-10-10T18:19:00.000Z",
362 | arrivalEstimated: null,
363 | arrivalScheduled: null,
364 | departureActual: "2020-10-10T18:23:00.000Z",
365 | departureEstimated: null,
366 | departureScheduled: null,
367 | status: "departed",
368 | timezone: "America/New_York",
369 | station: {
370 | code: "STA3",
371 | station: "station 3 metadata",
372 | },
373 | _raw: {
374 | code: "STA3",
375 | bus: false,
376 | tz: "E",
377 | postarr: "10/10/2020 14:19:00",
378 | postdep: "10/10/2020 14:23:00",
379 | },
380 | },
381 | {
382 | code: "STA4",
383 | bus: false,
384 | arrivalActual: null,
385 | arrivalEstimated: null,
386 | arrivalScheduled: "2020-10-11T00:15:00.000Z",
387 | departureActual: null,
388 | departureEstimated: null,
389 | departureScheduled: null,
390 | status: "enroute",
391 | timezone: "America/Denver",
392 | station: {
393 | code: "STA4",
394 | station: "station 4 metadata",
395 | },
396 | _raw: {
397 | code: "STA4",
398 | bus: false,
399 | tz: "M",
400 | scharr: "10/10/2020 18:15:00",
401 | },
402 | },
403 | {
404 | code: "STA5",
405 | bus: false,
406 | arrivalActual: null,
407 | arrivalEstimated: null,
408 | arrivalScheduled: "2020-10-11T00:15:00.000Z",
409 | departureActual: null,
410 | departureEstimated: null,
411 | departureScheduled: null,
412 | status: "scheduled",
413 | timezone: "America/Denver",
414 | station: {
415 | code: "STA5",
416 | station: "station 5 metadata",
417 | },
418 | _raw: {
419 | code: "STA5",
420 | bus: false,
421 | tz: "M",
422 | scharr: "10/10/2020 18:15:00",
423 | },
424 | },
425 | ],
426 | _raw: {
427 | ID: "train 2",
428 | Heading: "ns",
429 | TrainNum: "42",
430 | RouteName: "Choo Choo Route",
431 | },
432 | },
433 | ]);
434 | },
435 | );
436 | });
437 |
--------------------------------------------------------------------------------
/src/site/build.js:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | import fs from "node:fs/promises";
3 | import path from "node:path";
4 | import handlebars from "handlebars";
5 | import dayjs from "dayjs";
6 | import duration from "dayjs/plugin/duration.js";
7 | import relativeTime from "dayjs/plugin/relativeTime.js";
8 | import timezone from "dayjs/plugin/timezone.js";
9 | import utc from "dayjs/plugin/utc.js";
10 |
11 | dayjs.extend(duration);
12 | dayjs.extend(relativeTime);
13 | dayjs.extend(utc);
14 | dayjs.extend(timezone);
15 |
16 | dotenv.config();
17 |
18 | const slugify = (str) =>
19 | str
20 | .toLowerCase()
21 | .trim()
22 | .replace(/[\s_]/g, "-")
23 | .replace(/[^a-z0-9-_]/g, "")
24 | .replace(/-{2,}/g, "-");
25 |
26 | const stationDelay = (station) => {
27 | const delay = { arrival: false, departure: false };
28 |
29 | if (
30 | station.arrivalScheduled &&
31 | (station.arrivalActual || station.arrivalEstimated)
32 | ) {
33 | // The train can't have a delayed arrival if there's not a scheduled arrival
34 | // or at least one of actual or estimated arrival.
35 | const scheduled = dayjs(station.arrivalScheduled).tz(station.timezone);
36 | const actual = dayjs(station.arrivalActual ?? station.arrivalEstimated).tz(
37 | station.timezone,
38 | );
39 |
40 | const delayTime = dayjs.duration(actual.diff(scheduled));
41 | if (delayTime.asMinutes() > 10) {
42 | delay.arrival = delayTime.humanize();
43 | }
44 | }
45 |
46 | if (
47 | station.departureScheduled &&
48 | (station.departureActual || station.departureEstimated)
49 | ) {
50 | // Same kinda logic for departure delays. :)
51 | const scheduled = dayjs(station.departureScheduled).tz(station.timezone);
52 | const actual = dayjs(
53 | station.departureActual ?? station.departureEstimated,
54 | ).tz(station.timezone);
55 |
56 | const delayTime = dayjs.duration(actual.diff(scheduled));
57 | if (delayTime.asMinutes() > 10) {
58 | delay.departure = delayTime.humanize();
59 | }
60 | }
61 |
62 | return delay;
63 | };
64 |
65 | const dayAndTime = (str, tz) => {
66 | const ts = dayjs(str).tz(tz);
67 | return `${ts.format("dddd")} at ${ts.format("h:mm A")}`;
68 | };
69 |
70 | const BASE_URL = process.env.BASE_URL ?? "/amtrak-api";
71 |
72 | const TAG_BG_COLORS = new Map([
73 | ["scheduled", "bg-base-lighter"],
74 | ["departed", "bg-primary-dark"],
75 | ["enroute", "bg-green"],
76 | ["on time", "bg-green"],
77 | ["arrived", "bg-gold"],
78 | ["delayed", "bg-secondary-dark"],
79 | ]);
80 |
81 | const TAG_TEXT_COLORS = new Map([
82 | ["scheduled", "text-black"],
83 | ["departed", "text-white"],
84 | ["enroute", "text-white"],
85 | ["on time", "text-white"],
86 | ["arrived", "text-black"],
87 | ["delayed", "text-white"],
88 | ]);
89 |
90 | const STATION_COLORS = new Map([
91 | ["arrived", "bg-gold"],
92 | ["enroute", "bg-green"],
93 | ["departed", "bg-primary-dark"],
94 | ["scheduled", "bg-base-light"],
95 | ]);
96 |
97 | // Get the route data
98 | const routes = JSON.parse(
99 | await fs.readFile("_site/routes.json", { encoding: "utf-8" }),
100 | ).map((routeInfo) => {
101 | const routeUrl = `${BASE_URL}/${slugify(routeInfo.route)}`;
102 | const routePath = `_site/${slugify(routeInfo.route)}`;
103 | const route = {
104 | name: routeInfo.route,
105 | filepath: routePath,
106 | url: routeUrl,
107 | };
108 |
109 | route.trains = routeInfo.trains.map((trainInfo) => {
110 | const train = {
111 | route: route.name,
112 | number: trainInfo.number,
113 | filepath: `${routePath}/${trainInfo.number}-${trainInfo.id}.html`,
114 | url: `${routeUrl}/${trainInfo.number}-${trainInfo.id}.html`,
115 | info: "",
116 | status: "scheduled",
117 | trackColor: "bg-base-light",
118 | };
119 |
120 | train.stations = trainInfo.stations.map((stationInfo) => {
121 | const station = {
122 | name: stationInfo.station?.name ?? stationInfo.code,
123 | code: stationInfo.code,
124 | status: stationInfo.status,
125 |
126 | // Are our arrival and departure times known or expected? We know the
127 | // arrival time if the train has arrived or departed, and we know the
128 | // departure time if the train has departed. In all other cases, we're
129 | // just hoping for the best.
130 | arrivalKnown:
131 | stationInfo.status === "arrived" || stationInfo.status === "departed",
132 | departureKnown: stationInfo.status === "departed",
133 |
134 | delay: stationDelay(stationInfo),
135 |
136 | tag: {
137 | text: stationInfo.status,
138 | bg: TAG_BG_COLORS.get(stationInfo.status),
139 | color: TAG_TEXT_COLORS.get(stationInfo.status),
140 | },
141 |
142 | dotColor: STATION_COLORS.get(stationInfo.status),
143 | spacerColors: { before: "", after: "" },
144 |
145 | arrival: (() => {
146 | const t =
147 | stationInfo.arrivalActual ??
148 | stationInfo.arrivalEstimated ??
149 | stationInfo.arrivalScheduled ??
150 | false;
151 | if (t) {
152 | return dayAndTime(t, stationInfo.timezone);
153 | }
154 | return false;
155 | })(),
156 | departure: (() => {
157 | const t =
158 | stationInfo.departureActual ??
159 | stationInfo.departureEstimated ??
160 | stationInfo.departureScheduled ??
161 | false;
162 | if (t) {
163 | return dayAndTime(t, stationInfo.timezone);
164 | }
165 | return false;
166 | })(),
167 | };
168 |
169 | if (station.status === "enroute" || station.status === "scheduled") {
170 | station.info = [];
171 | if (station.delay.arrival || station.delay.departure) {
172 | station.info.push("This train is running behind schedule.");
173 | if (station.delay.arrival) {
174 | station.info.push(
175 | `It will arrive about ${station.delay.arrival} late`,
176 | );
177 | }
178 |
179 | if (station.delay.arrival && station.delay.departure) {
180 | station.info.push(" and ");
181 | }
182 |
183 | if (station.delay.departure) {
184 | if (!station.delay.arrival) {
185 | station.info.push("It ");
186 | }
187 | station.info.push(
188 | ` will depart about ${station.delay.departure} late.`,
189 | );
190 | }
191 | }
192 | station.info = station.info.join(" ");
193 | }
194 |
195 | return station;
196 | });
197 |
198 | train.stations.forEach((station, i) => {
199 | if (station.status === "departed") {
200 | station.spacerColors.after = "bg-primary-dark";
201 | }
202 | if (i > 0) {
203 | station.spacerColors.before = train.stations[i - 1].spacerColors.after;
204 | }
205 | });
206 |
207 | if (train.stations[0].status !== "scheduled") {
208 | train.status = "on time";
209 | }
210 | if (train.stations.slice(-1).pop().status === "arrived") {
211 | train.status = "arrived";
212 | }
213 | train.tag = {
214 | text: train.status,
215 | bg: TAG_BG_COLORS.get(train.status),
216 | color: TAG_TEXT_COLORS.get(train.status),
217 | };
218 | if (train.status !== "scheduled") {
219 | train.trackColor = "bg-primary-light";
220 | }
221 |
222 | const first = train.stations[0];
223 | const final = train.stations.slice(-1).pop();
224 |
225 | if (train.status === "scheduled") {
226 | train.info = `Scheduled to depart ${first.name} on ${first.departure} and arrive at ${final.name} on ${final.arrival}.`;
227 | } else {
228 | const { previous, next } = (() => {
229 | const index = train.stations.findLastIndex(
230 | ({ status }) => status === "departed" || status === "arrived",
231 | );
232 | if (index > 0) {
233 | return {
234 | previous: train.stations[index],
235 | next: train.stations[index + 1],
236 | };
237 | }
238 | return {
239 | previous: first,
240 | next: final,
241 | };
242 | })();
243 |
244 | if (previous?.delay.departure || next?.delay.arrival) {
245 | train.tag.text = "delayed";
246 | train.tag.bg = TAG_BG_COLORS.get("delayed");
247 | train.tag.color = TAG_TEXT_COLORS.get("delayed");
248 | }
249 |
250 | const info = [
251 | previous.status === "arrived" ? "Arrived at " : "Departed from ",
252 | previous.name,
253 | ];
254 | if (previous.status === "arrived" && previous.delay.arrival) {
255 | info.push(" about ", previous.delay.arrival, " late");
256 | }
257 | if (previous.status === "departed" && previous.delay.departure) {
258 | info.push(" about ", previous.delay.departure, " late");
259 | }
260 |
261 | info.push(" on ", previous.arrival);
262 |
263 | if (previous.status === "departed" && next) {
264 | info.push(" and is scheduled to arrive at ", next.name);
265 | if (next.delay.arrival) {
266 | info.push(" about ", next.delay.arrival, " late");
267 | }
268 | info.push(" on ", next.arrival);
269 | } else if (next) {
270 | info.push(". Expected to depart");
271 | if (previous.delay.departure) {
272 | info.push(" about ", previous.delay.departure, " late");
273 | }
274 | info.push(" on ", previous.departure, " and arrive at ", next.name);
275 | if (next.delay.arrival) {
276 | info.push(" about ", next.delay.arrival, " late");
277 | }
278 | info.push(" on ", next.arrival);
279 | }
280 | info.push(".");
281 |
282 | train.info = info.join("");
283 | }
284 |
285 | train.from = train.stations[0].name;
286 | train.to = train.stations.slice(-1).pop().name;
287 |
288 | return train;
289 | });
290 |
291 | return route;
292 | });
293 |
294 | // Register all our handlebar partials so they're available in our templates.
295 | await fs
296 | .readdir("src/site/templates/partials")
297 | .then((files) =>
298 | files
299 | .filter((file) => file.endsWith(".handlebars"))
300 | .map(async (file) => {
301 | const name = path.basename(file, ".handlebars");
302 | const template = await fs.readFile(
303 | path.join("src/site/templates/partials", file),
304 | { encoding: "utf-8" },
305 | );
306 | handlebars.registerPartial(name, template);
307 | }),
308 | )
309 | .then((promises) => Promise.all(promises));
310 |
311 | // Load our two template files. One is for index pages (front page and route
312 | // pages), and the other is for a single train.
313 | const indexTemplate = await fs
314 | .readFile("src/site/templates/index.handlebars", {
315 | encoding: "utf-8",
316 | })
317 | .then((template) => handlebars.compile(template));
318 | const trainTemplate = await fs
319 | .readFile("src/site/templates/train.handlebars", {
320 | encoding: "utf-8",
321 | })
322 | .then((template) => handlebars.compile(template));
323 |
324 | // Build the front page
325 | const indexPage = indexTemplate({
326 | index: true,
327 | routes,
328 | });
329 | await fs.writeFile("_site/index.html", indexPage);
330 |
331 | // Build all the route pages.
332 | const routePages = routes.map(async (route) => {
333 | // Make a directory for the route
334 | await fs.mkdir(route.filepath, { recursive: true });
335 |
336 | // Build the route index page and write it out.
337 | const routePage = indexTemplate({ routes: [route] });
338 | await fs.writeFile(path.join(route.filepath, "index.html"), routePage);
339 |
340 | // Now create pages for each train on the route.
341 | const trainPages = route.trains.map(async (train) => {
342 | const trainPage = trainTemplate(train);
343 | await fs.writeFile(path.join(train.filepath), trainPage);
344 | });
345 | await Promise.all(trainPages);
346 | });
347 |
348 | await Promise.all(routePages);
349 |
--------------------------------------------------------------------------------
/src/site/templates/index.handlebars:
--------------------------------------------------------------------------------
1 | {{> start . }}
2 |
3 | {{#routes}}
4 |
5 |