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