Initial commit

This commit is contained in:
Georgi Gardev
2025-05-14 12:45:23 +03:00
commit 6a64e2939e
12 changed files with 1247 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
data/
node_modules/

1005
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "sofiatraffic-api",
"version": "1.0.0",
"author": "Georgi Gardev <georgi@gar.dev>",
"description": "Scrapes Sofia Traffic API and serves it via a local server.",
"type": "module",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"axios": "1.9.0",
"axios-cookiejar-support": "6.0.2",
"express": "5.1.0",
"tough-cookie": "5.1.2"
}
}

21
server.mjs Normal file
View File

@@ -0,0 +1,21 @@
import express from 'express';
import { departuresRouter } from './src/departures-router.mjs';
import { startScraper } from './src/runner.mjs';
import { getStops, setStops } from './src/config.mjs';
const PORT = process.env.PORT || 3000;
if (getStops().length === 0) {
console.error('No stops configured. Please configure stops in config.json. Defaulting to [7].');
setStops([7]);
}
startScraper();
const app = express();
app.use(departuresRouter);
app.listen(PORT, () => {
console.log(`🚀 Server running at http://localhost:${PORT}`);
});

15
src/config.mjs Normal file
View File

@@ -0,0 +1,15 @@
import { readFileSync, writeFileSync } from 'fs';
import { CONFIG_FILE } from './const.mjs';
export function setStops(stops) {
writeFileSync(CONFIG_FILE, JSON.stringify({ stops }, null, 2), 'utf-8');
}
export function getStops() {
try {
const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
return data.stops;
} catch (error) {
return [];
}
}

10
src/const.mjs Normal file
View File

@@ -0,0 +1,10 @@
export const DATA_FILE = './data/departures.json';
export const CONFIG_FILE = './data/config.json';
export const SCRAPE_TIMEOUT = 60 * 1000; // 1 minute
export const STOP = {
plAviacia: 1259,
plAviacia2: 1257,
iztok: 1696,
};

30
src/departures-router.mjs Normal file
View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { readFileSync } from 'fs';
import { DATA_FILE } from './const.mjs';
export const departuresRouter = new Router();
departuresRouter.get('/departures', (req, res) => {
const { line, stop } = req.query;
try {
const departuresJson = readFileSync(DATA_FILE, 'utf8');
let departures = JSON.parse(departuresJson);
if (stop) {
departures = departures.filter((d) => d.stop === Number(stop));
}
if (line) {
departures = departures.map((d) => ({
...d,
departures: d.departures.filter((dep) => dep.name === line),
}));
}
return res.json(departures);
} catch (error) {
console.error('Error reading file:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});

34
src/runner.mjs Normal file
View File

@@ -0,0 +1,34 @@
import { writeFileSync } from 'fs';
import { DATA_FILE, SCRAPE_TIMEOUT } from './const.mjs';
import { getDepartures } from './scraper/fetch.mjs';
import { getStops, setStops } from './config.mjs';
async function fetchAndSave() {
const stops = getStops();
if (!stops || !Array.isArray(stops) || stops.length === 0 || !stops.every(Number.isInteger)) {
throw new Error('Invalid stops configuration');
}
const departures = await getDepartures(stops);
if (!departures) {
console.error({ error: 'No data received or error fetching data' });
return;
}
console.log(`${new Date().toLocaleString()} · writing departures.json...`);
writeFileSync(DATA_FILE, JSON.stringify(departures, null, 2), 'utf-8');
}
export async function startScraper() {
await fetchAndSave();
console.log('🟡 Starting scraper...');
setInterval(async () => {
fetchAndSave().catch((error) => {
console.error('Error during scraping:', error);
});
}, SCRAPE_TIMEOUT);
}

17
src/scraper/config.mjs Normal file
View File

@@ -0,0 +1,17 @@
export const ENV_URL = 'https://sofiatraffic.bg/bg/get-envs';
export const VIRTUAL_TABLE_URL = 'https://sofiatraffic.bg/bg/trip/getVirtualTable';
export const TYPES = {
1: 'bus',
2: 'tram',
3: 'metro',
4: 'trolleybus',
5: 'night',
};
export const COMMON_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
Referer: 'https://sofiatraffic.bg/bg/public-transport',
Origin: 'https://sofiatraffic.bg',
'Content-Type': 'application/json',
};

48
src/scraper/fetch.mjs Normal file
View File

@@ -0,0 +1,48 @@
import axios from 'axios';
import { COMMON_HEADERS, VIRTUAL_TABLE_URL } from './config.mjs';
import { getTokens } from './get-tokens.mjs';
import { transformBusInfo } from './transform-bus-info.mjs';
async function fetch(stop, tokens) {
const { xsrfToken, session } = tokens || (await getTokens());
const body = { stop };
const headers = {
...COMMON_HEADERS,
'x-xsrf-token': decodeURIComponent(xsrfToken),
Cookie: `XSRF-TOKEN=${xsrfToken}; sofia_traffic_session=${session}`,
};
try {
const response = await axios.post(VIRTUAL_TABLE_URL, body, { headers });
return response.data;
} catch (error) {
return null;
}
}
async function getStopDepartures(stop, tokens) {
const data = await fetch(stop, tokens);
if (!data) {
return [];
}
return Object.keys(data).reduce((acc, key) => {
acc.push(transformBusInfo(data[key]));
return acc;
}, []);
}
export async function getDepartures(stops) {
const tokens = await getTokens();
const stopPromises = stops.map(async (stop) => ({
stop,
departures: await getStopDepartures(stop, tokens),
}));
const stopsData = await Promise.all(stopPromises);
return stopsData;
}

View File

@@ -0,0 +1,21 @@
import axios from 'axios';
import { wrapper } from 'axios-cookiejar-support';
import { CookieJar } from 'tough-cookie';
import { COMMON_HEADERS, ENV_URL } from './config.mjs';
export async function getTokens() {
const jar = new CookieJar();
const client = wrapper(axios.create({ jar }));
await client.get(ENV_URL, { headers: COMMON_HEADERS });
const allCookies = await jar.getCookies(ENV_URL);
const xsrfCookie = allCookies.find((c) => c.key === 'XSRF-TOKEN');
const sessionCookie = allCookies.find((c) => c.key === 'sofia_traffic_session');
if (!xsrfCookie || !sessionCookie) {
throw new Error('Missing XSRF or session cookie');
}
return { xsrfToken: xsrfCookie.value, session: sessionCookie.value };
}

View File

@@ -0,0 +1,28 @@
import { TYPES } from './config.mjs';
// Example API response data structure
// A2454_A99: {
// id: 143,
// name: '84',
// ext_id: 'A99',
// type: 1,
// color: '#BD202E',
// icon: '/images/transport_types/bus.png',
// route_name: 'УЛ. ГЕН. ГУРКО - ЛЕТИЩЕ СОФИЯ ТЕРМИНАЛ 2',
// route_id: 207,
// st_name: 'ЛЕТИЩЕ СОФИЯ ТЕРМИНАЛ 2',
// st_name_en: 'LETISHTE SOFIYA TERMINAL 2',
// st_code: '2454',
// "details":[{"t":376,"ac":true,"wheelchairs":true,"bikes":false},{"t":436,"ac":true,"wheelchairs":true,"bikes":false},{"t":496,"ac":true,"wheelchairs":true,"bikes":false}],
// last_stop: 'A2454'
// },
export function transformBusInfo(data) {
return {
type: TYPES[data.type],
name: data.name,
destination: data.st_name,
destination_stop: data.st_code,
next: data.details.map((d) => d.t),
};
}