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.",
"type": "module",
"scripts": {
"start": "node server.mjs"
"start": "node server.mjs",
"test": "node --test"
},
"dependencies": {
"axios": "1.18.1",
+11 -8
View File
@@ -26,16 +26,19 @@ departuresRouter.get('/departures', (req, res) => {
}
if (stop && line) {
const minutes = departures
.flatMap((stop) => {
return stop.departures.flatMap((d) => d.next);
})
.map(Number)
.toSorted((a, b) => a - b);
const pairs = departures.flatMap((stop) => {
return stop.departures.flatMap((d) =>
d.next.map((minutes, i) => ({
minutes: Number(minutes),
time: d.nextTimes?.[i] ?? null,
}))
);
});
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);
+2
View File
@@ -1,6 +1,8 @@
export const ENV_URL = 'https://sofiatraffic.bg/bg/get-envs';
export const VIRTUAL_TABLE_URL = 'https://sofiatraffic.bg/bg/trip/getVirtualTable';
export const TIMEZONE = 'Europe/Sofia';
export const TYPES = {
1: 'bus',
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);
if (!data) {
@@ -30,17 +30,19 @@ async function getStopDepartures(stop, tokens) {
}
return Object.keys(data).reduce((acc, key) => {
acc.push(transformBusInfo(data[key]));
acc.push(transformBusInfo(data[key], scrapeTime));
return acc;
}, []);
}
export async function getDepartures(stops) {
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) => ({
stop,
departures: await getStopDepartures(stop, tokens),
departures: await getStopDepartures(stop, tokens, scrapeTime),
}));
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
// A2454_A99: {
@@ -17,12 +33,17 @@ import { TYPES } from './config.mjs';
// 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 {
type: TYPES[data.type],
name: data.name,
destination: data.st_name,
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);
});