Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
data/
|
||||||
|
node_modules/
|
||||||
1005
package-lock.json
generated
Normal file
1005
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal 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
21
server.mjs
Normal 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
15
src/config.mjs
Normal 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
10
src/const.mjs
Normal 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
30
src/departures-router.mjs
Normal 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
34
src/runner.mjs
Normal 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
17
src/scraper/config.mjs
Normal 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
48
src/scraper/fetch.mjs
Normal 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;
|
||||||
|
}
|
||||||
21
src/scraper/get-tokens.mjs
Normal file
21
src/scraper/get-tokens.mjs
Normal 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 };
|
||||||
|
}
|
||||||
28
src/scraper/transform-bus-info.mjs
Normal file
28
src/scraper/transform-bus-info.mjs
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user