const http = require("http"); const fs = require("fs"); const path = require("path"); const PORT = process.env.PORT || 3000; const CACHE_FILE = path.join(__dirname, ".league-cache.json"); const CACHE_TTL = 15 * 60 * 1000; // football-data.org: лиги и бесплатный ключ из .env (https://www.football-data.org/client/register) const LEAGUES = { premier: "PL", laliga: "PD", bundesliga: "BL1", seriea: "SA", }; const FDB_API_KEY = process.env.FOOTBALL_DATA_KEY || ""; function readCache() { try { return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch (_) { return null; } } function writeCache(d) { try { fs.writeFileSync(CACHE_FILE, JSON.stringify(d)); } catch (_) {} } async function getLeagueTable(key) { if (!FDB_API_KEY) return null; const url = "https://api.football-data.org/v4/competitions/" + LEAGUES[key] + "/standings"; const res = await fetch(url, { headers: { "X-Auth-Token": FDB_API_KEY } }); if (!res.ok) throw new Error("football-data status " + res.status); const j = await res.json(); const standing = (j.standings || []).find((s) => s.type === "TOTAL") || (j.standings || [])[0]; if (!standing) throw new Error("нет таблицы в ответе"); const rows = standing.table.map((t) => ({ name: t.team.name, played: t.playedGames, win: t.win, draw: t.draw, lost: t.lost, gf: t.goalsFor, ga: t.goalsAgainst, })); const name = j.competition && j.competition.name ? j.competition.name : "лига"; return { name, rows }; } let refreshing = null; async function refresh() { if (refreshing) return refreshing; refreshing = (async () => { const prev = readCache(); const out = { ts: Date.now(), source: "TheSportsDB", data: {} }; for (const key of Object.keys(LEAGUE_IDS)) { try { const t = await getLeagueTable(LEAGUE_IDS[key]); if (t) { out.data[key] = { key, name: t.name, rows: t.rows }; console.log("[standings] " + key + ": " + t.rows.length + " команд"); } else { throw new Error("исчерпаны источники"); } } catch (e) { out.data[key] = (prev && prev.data && prev.data[key]) || { error: "данные временно недоступны" }; console.log("[standings] " + key + ": ошибка " + String(e && e.message || e).slice(0, 80)); } } writeCache(out); return out; })(); try { return await refreshing; } finally { refreshing = null; } } async function getStandings() { const cache = readCache(); if (cache && Date.now() - cache.ts < CACHE_TTL) return cache; return refresh(); } http.createServer(async (req, res) => { const url = req.url.split("?")[0]; if (url === "/api/standings") { try { const j = await getStandings(); if (!j || !j.data) { res.writeHead(503, { "Content-Type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ ok: false })); } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); return res.end(JSON.stringify({ ok: true, updatedAt: j.ts, source: j.source, data: j.data })); } catch (e) { const cache = readCache(); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); return res.end(JSON.stringify(cache && cache.data ? cache : { ok: false })); } } let file = url === "/" ? "/index.html" : decodeURIComponent(url); const full = path.normalize(path.join(__dirname, file)); if (!full.startsWith(__dirname)) { res.writeHead(403); return res.end(); } if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end("Not found"); } const ext = path.extname(full).toLowerCase(); const type = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", }[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); res.end(fs.readFileSync(full)); }).listen(PORT, () => { console.log("Сервер на порту " + PORT); setTimeout(() => { refresh().catch(() => {}); }, 1500); setInterval(() => { refresh().catch(() => {}); }, CACHE_TTL); });