Add times

This commit is contained in:
Georgi Gardev
2026-07-10 19:52:25 +03:00
parent 7c83487fec
commit 2295470baa
6 changed files with 107 additions and 15 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"description": "Scrapes Sofia Traffic API and serves it via a local server.", "description": "Scrapes Sofia Traffic API and serves it via a local server.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.mjs" "start": "node server.mjs",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"axios": "1.18.1", "axios": "1.18.1",
+11 -8
View File
@@ -26,16 +26,19 @@ departuresRouter.get('/departures', (req, res) => {
} }
if (stop && line) { if (stop && line) {
const minutes = departures const pairs = departures.flatMap((stop) => {
.flatMap((stop) => { return stop.departures.flatMap((d) =>
return stop.departures.flatMap((d) => d.next); d.next.map((minutes, i) => ({
}) minutes: Number(minutes),
.map(Number) time: d.nextTimes?.[i] ?? null,
.toSorted((a, b) => a - b); }))
);
});
const uniqueMinutes = [...new Set(minutes)]; const uniquePairs = [...new Map(pairs.map((p) => [p.minutes, p])).values()]
.toSorted((a, b) => a.minutes - b.minutes);
return res.json(uniqueMinutes); return res.json(uniquePairs);
} }
return res.json(departures); return res.json(departures);
+2
View File
@@ -1,6 +1,8 @@
export const ENV_URL = 'https://sofiatraffic.bg/bg/get-envs'; export const ENV_URL = 'https://sofiatraffic.bg/bg/get-envs';
export const VIRTUAL_TABLE_URL = 'https://sofiatraffic.bg/bg/trip/getVirtualTable'; export const VIRTUAL_TABLE_URL = 'https://sofiatraffic.bg/bg/trip/getVirtualTable';
export const TIMEZONE = 'Europe/Sofia';
export const TYPES = { export const TYPES = {
1: 'bus', 1: 'bus',
2: 'tram', 2: 'tram',
+5 -3
View File
@@ -22,7 +22,7 @@ async function fetch(stop, tokens) {
} }
} }
async function getStopDepartures(stop, tokens) { async function getStopDepartures(stop, tokens, scrapeTime) {
const data = await fetch(stop, tokens); const data = await fetch(stop, tokens);
if (!data) { if (!data) {
@@ -30,17 +30,19 @@ async function getStopDepartures(stop, tokens) {
} }
return Object.keys(data).reduce((acc, key) => { return Object.keys(data).reduce((acc, key) => {
acc.push(transformBusInfo(data[key])); acc.push(transformBusInfo(data[key], scrapeTime));
return acc; return acc;
}, []); }, []);
} }
export async function getDepartures(stops) { export async function getDepartures(stops) {
const tokens = await getTokens(); const tokens = await getTokens();
// One timestamp for the whole batch so concurrently fetched stops agree on "now".
const scrapeTime = Date.now();
const stopPromises = stops.map(async (stop) => ({ const stopPromises = stops.map(async (stop) => ({
stop, stop,
departures: await getStopDepartures(stop, tokens), departures: await getStopDepartures(stop, tokens, scrapeTime),
})); }));
const stopsData = await Promise.all(stopPromises); const stopsData = await Promise.all(stopPromises);
+24 -3
View File
@@ -1,4 +1,20 @@
import { TYPES } from './config.mjs'; import { TIMEZONE, TYPES } from './config.mjs';
// Explicit timeZone: the Docker container runs UTC, system time can't be trusted.
const timeFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function toClockTime(minutesFromNow, scrapeTime) {
if (!Number.isFinite(minutesFromNow)) {
return null;
}
return timeFormatter.format(new Date(scrapeTime + minutesFromNow * 60_000));
}
// Example API response data structure // Example API response data structure
// A2454_A99: { // A2454_A99: {
@@ -17,12 +33,17 @@ import { TYPES } from './config.mjs';
// last_stop: 'A2454' // last_stop: 'A2454'
// }, // },
export function transformBusInfo(data) { // next[i] and nextTimes[i] describe the same departure, both derived from details[i].t.
// Times are anchored to scrapeTime, not request time — the file may be read up to a minute later.
export function transformBusInfo(data, scrapeTime = Date.now()) {
const minutes = data.details.map((d) => d.t);
return { return {
type: TYPES[data.type], type: TYPES[data.type],
name: data.name, name: data.name,
destination: data.st_name, destination: data.st_name,
destination_stop: data.st_code, destination_stop: data.st_code,
next: data.details.map((d) => d.t), next: minutes,
nextTimes: minutes.map((m) => toClockTime(m, scrapeTime)),
}; };
} }
+63
View File
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { transformBusInfo } from '../src/scraper/transform-bus-info.mjs';
function busInfo(details) {
return {
type: 1,
name: '84',
st_name: 'ЛЕТИЩЕ СОФИЯ ТЕРМИНАЛ 2',
st_code: '2454',
details,
};
}
test('formats times in Europe/Sofia regardless of system timezone (summer, UTC+3)', () => {
const scrapeTime = Date.parse('2026-07-10T10:00:00Z'); // 13:00 in Sofia
const result = transformBusInfo(busInfo([{ t: 0 }, { t: 5 }]), scrapeTime);
assert.deepEqual(result.next, [0, 5]);
assert.deepEqual(result.nextTimes, ['13:00', '13:05']);
});
test('winter time uses UTC+2', () => {
const scrapeTime = Date.parse('2026-01-15T10:00:00Z'); // 12:00 in Sofia
const result = transformBusInfo(busInfo([{ t: 30 }]), scrapeTime);
assert.deepEqual(result.nextTimes, ['12:30']);
});
test('rolls over midnight via date arithmetic', () => {
const scrapeTime = Date.parse('2026-07-10T20:55:00Z'); // 23:55 in Sofia
const result = transformBusInfo(busInfo([{ t: 10 }]), scrapeTime);
assert.deepEqual(result.nextTimes, ['00:05']);
});
test('pads single-digit hours and minutes, no AM/PM', () => {
const scrapeTime = Date.parse('2026-07-10T06:03:00Z'); // 09:03 in Sofia
const result = transformBusInfo(busInfo([{ t: 0 }]), scrapeTime);
assert.deepEqual(result.nextTimes, ['09:03']);
});
test('empty details produce empty arrays', () => {
const result = transformBusInfo(busInfo([]), Date.now());
assert.deepEqual(result.next, []);
assert.deepEqual(result.nextTimes, []);
});
test('non-numeric t yields null time and keeps arrays index-aligned', () => {
const scrapeTime = Date.parse('2026-07-10T10:00:00Z');
const result = transformBusInfo(busInfo([{ t: 2 }, { t: null }, { t: 10 }]), scrapeTime);
assert.deepEqual(result.next, [2, null, 10]);
assert.deepEqual(result.nextTimes, ['13:02', null, '13:10']);
assert.equal(result.next.length, result.nextTimes.length);
});