Add times
This commit is contained in:
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user