diff --git a/.gitignore b/.gitignore index bbded04..5046369 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .vibe42-run.log .vibe42-run.pid node_modules/ +.env +.league-cache.json diff --git a/.vibe42-run.log b/.vibe42-run.log index 07c594f..291c1f3 100644 --- a/.vibe42-run.log +++ b/.vibe42-run.log @@ -10,3 +10,99 @@ up to date in 355ms up to date in 326ms Сервер на порту 3000 +--- run Wed Sep 23 10:20:53 AM UTC 2026 :: npm start --silent + +up to date in 343ms +Сервер на порту 3000 +--- run Wed Sep 23 10:21:30 AM UTC 2026 :: npm start --silent + +up to date in 334ms +Сервер на порту 3000 +--- run Wed Sep 23 10:22:20 AM UTC 2026 :: npm start --silent + +up to date in 346ms +Сервер на порту 3000 +--- run Wed Sep 23 10:23:01 AM UTC 2026 :: npm start --silent + +up to date in 388ms +Сервер на порту 3000 +--- run Wed Sep 23 10:23:39 AM UTC 2026 :: npm start --silent + +up to date in 373ms +Сервер на порту 3000 +--- run Wed Sep 23 10:24:40 AM UTC 2026 :: npm start --silent + +up to date in 360ms +Сервер на порту 3000 +--- run Wed Sep 23 10:25:25 AM UTC 2026 :: npm start --silent + +up to date in 322ms +Сервер на порту 3000 +--- run Wed Sep 23 10:28:35 AM UTC 2026 :: npm start --silent + +up to date in 356ms +Сервер на порту 3000 +--- run Wed Sep 23 10:29:19 AM UTC 2026 :: npm start --silent + +up to date in 328ms +Сервер на порту 3000 +--- run Wed Sep 23 10:30:51 AM UTC 2026 :: npm start --silent + +up to date in 369ms +Сервер на порту 3000 +--- run Wed Sep 23 10:31:35 AM UTC 2026 :: npm start --silent + +up to date in 328ms +Сервер на порту 3000 +--- run Wed Sep 23 10:32:17 AM UTC 2026 :: npm start --silent + +up to date in 366ms +Сервер на порту 3000 +--- run Wed Sep 23 10:33:20 AM UTC 2026 :: npm start --silent + +up to date in 343ms +Сервер на порту 3000 +[standings] premier: ошибка Unexpected end of JSON input +[standings] laliga: ошибка Unexpected end of JSON input +[standings] bundesliga: ошибка Unexpected end of JSON input +[standings] seriea: ошибка Unexpected end of JSON input +--- run Wed Sep 23 10:34:33 AM UTC 2026 :: npm start --silent + +up to date in 339ms +Сервер на порту 3000 +[standings] premier: ошибка TheSportsDB: не-JSON ответ, status=200 len=0 +[standings] laliga: ошибка TheSportsDB: не-JSON ответ, status=200 len=0 +[standings] bundesliga: ошибка TheSportsDB: не-JSON ответ, status=200 len=0 +[standings] seriea: ошибка TheSportsDB: не-JSON ответ, status=200 len=0 +--- run Wed Sep 23 10:35:56 AM UTC 2026 :: npm start --silent + +up to date in 321ms +Сервер на порту 3000 +[standings] premier: ошибка не найдена актуальная таблица для premier +[standings] laliga: ошибка не найдена актуальная таблица для laliga +[standings] bundesliga: ошибка не найдена актуальная таблица для bundesliga +[standings] seriea: ошибка не найдена актуальная таблица для seriea +--- run Wed Sep 23 10:37:05 AM UTC 2026 :: npm start --silent + +up to date in 314ms +Сервер на порту 3000 +[standings] premier: ошибка не найдена актуальная таблица для premier +[standings] laliga: ошибка не найдена актуальная таблица для laliga +[standings] bundesliga: ошибка не найдена актуальная таблица для bundesliga +[standings] seriea: ошибка не найдена актуальная таблица для seriea +--- run Wed Sep 23 10:39:07 AM UTC 2026 :: npm start --silent + +up to date in 312ms +Сервер на порту 3000 +[standings] premier: ошибка исчерпаны источники +[standings] laliga: ошибка исчерпаны источники +[standings] bundesliga: ошибка исчерпаны источники +[standings] seriea: ошибка исчерпаны источники +--- run Wed Sep 23 10:40:16 AM UTC 2026 :: npm start --silent + +up to date in 321ms +Сервер на порту 3000 +[standings] premier: ошибка исчерпаны источники +[standings] laliga: ошибка исчерпаны источники +[standings] bundesliga: ошибка исчерпаны источники +[standings] seriea: ошибка исчерпаны источники diff --git a/.vibe42-run.pid b/.vibe42-run.pid index 07aeb9b..e3db4d2 100644 --- a/.vibe42-run.pid +++ b/.vibe42-run.pid @@ -1 +1 @@ -1345 +8240 diff --git a/index.html b/index.html index 675ae07..9684dce 100644 --- a/index.html +++ b/index.html @@ -24,8 +24,19 @@ .kt-ai-table td { padding: 10px; border-bottom: 1px solid var(--kt-ai-border); } .kt-ai-table tr:last-child td { border-bottom: none; } .kt-ai-table tr.top td { font-weight: 500; } + .kt-ai-table tr.zone-1 td:first-child { box-shadow: inset 3px 0 0 var(--kt-ai-fg); } + .kt-ai-table tr.zone-1 td, .kt-ai-table tr.zone-2 td { background: var(--kt-ai-bg-soft); } + .kt-ai-table tr.zone-2 td:first-child { box-shadow: inset 3px 0 0 var(--kt-ai-fg-muted); } + .kt-ai-table tr.zone-4 td:first-child { box-shadow: inset 3px 0 0 var(--kt-ai-fg-faint); } .pos { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; background: var(--kt-ai-bg); border: 1px solid var(--kt-ai-border); font-size: 12px; color: var(--kt-ai-fg-muted); } - tr.top .pos { background: var(--kt-ai-primary-subtle); border-color: transparent; color: var(--kt-ai-fg); } + tr .pos.lead { background: var(--kt-ai-primary); border-color: transparent; color: var(--kt-ai-bg); } + .zone-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; font-size: 12px; color: var(--kt-ai-fg-muted); } + .zone-legend span::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 2px; vertical-align: 1px; } + .leg-white::before { background: var(--kt-ai-fg); } + .leg-mut::before { background: var(--kt-ai-fg-muted); } + .leg-dim::before { background: var(--kt-ai-fg-faint); } + .table-status { font-size: 12px; color: var(--kt-ai-fg-muted); margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .table-status .kt-ai-chip { font-size: 11px; } .nums td { text-align: center; font-variant-numeric: tabular-nums; } .nums th { text-align: center; } .table-scroll { overflow-x: auto; } @@ -87,12 +98,14 @@

Таблицы чемпионатов

-

Текущее положение команд в главных лигах. Зелёные строки — лидеры.

+

Реальные данные из футбольных лиг — обновляются каждые 15 минут. Подсвечены лидеры.

+
+

Ближайшие матчи

diff --git a/package.json b/package.json index 810b897..81de267 100644 --- a/package.json +++ b/package.json @@ -2,5 +2,5 @@ "name": "sports-portal", "private": true, "type": "commonjs", - "scripts": { "start": "node server.js" } + "scripts": { "start": "node --env-file=.env server.js" } } diff --git a/script.js b/script.js index 9eac1bf..d6337e4 100644 --- a/script.js +++ b/script.js @@ -27,9 +27,10 @@ const SPORTS = [ }, ]; +// Демо-данные: работают только если API не доступно (например, в Предпросмотре без сервера) const LEAGUES = { premier: { - label: "Премьер-лига", + label: "АПЛ, Англия", cols: ["И", "В", "Н", "П", "ГЗ", "ГП"], rows: [ ["Арсенал", 38, 24, 10, 4, 78, 32], @@ -41,7 +42,7 @@ const LEAGUES = { ], }, laliga: { - label: "Ла Лига", + label: "Ла Лига, Испания", cols: ["И", "В", "Н", "П", "ГЗ", "ГП"], rows: [ ["Реал Мадрид", 38, 27, 4, 7, 78, 33], @@ -149,10 +150,72 @@ function renderSports() { `).join(""); } +const TABLE_COLS = ["И", "В", "Н", "П", "ГЗ", "ГП"]; +const CURRENT = { + premier: { label: "АПЛ, Англия", cols: TABLE_COLS, rows: [], live: true }, + laliga: { label: "Ла Лига, Испания", cols: TABLE_COLS, rows: [], live: true }, + bundesliga: { label: "Бундеслига, Германия", cols: TABLE_COLS, rows: [], live: true }, + seriea: { label: "Серия А, Италия", cols: TABLE_COLS, rows: [], live: true }, +}; +const ZONES = { + premier: { ucl: 4, uel: 1, releg: 3 }, + laliga: { ucl: 4, uel: 1, releg: 3 }, + bundesliga: { ucl: 4, uel: 1, releg: 3 }, + seriea: { ucl: 4, uel: 0, releg: 3 }, +}; + +function applyLive(key, payload) { + if (!payload || !Array.isArray(payload.rows)) return; + CURRENT[key] = { + label: payload.name || CURRENT[key].label, + cols: CURRENT[key].cols, + rows: payload.rows.map((r) => [r.name, r.played, r.win, r.draw, r.lost, r.gf, r.ga]), + live: true, + }; +} + +function liveError(key) { + CURRENT[key].rows = []; + CURRENT[key].live = false; +} + +async function loadStandings() { + try { + const res = await fetch("api/standings"); + const j = await res.json(); + if (!j.ok || !j.data) throw new Error("bad payload"); + for (const key of Object.keys(CURRENT)) { + const d = j.data[key]; + if (!d || d.error) { liveError(key); continue; } + applyLive(key, d); + } + const anyLive = Object.values(CURRENT).some((l) => l.live); + const status = document.getElementById("table-status"); + if (status) { + const when = new Date(j.updatedAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + status.innerHTML = anyLive + ? `обновлено в ${when}источник: TheSportsDB · повтор через 15 минут` + : `нет связи с источником — показаны демо-данные`; + } + for (const key of Object.keys(CURRENT)) { + if (!CURRENT[key].live) CURRENT[key].rows = (LEAGUES[key] && LEAGUES[key].rows) || []; + } + return anyLive; + } catch (_) { + for (const key of Object.keys(CURRENT)) { + CURRENT[key].rows = (LEAGUES[key] && LEAGUES[key].rows) || []; + CURRENT[key].live = false; + } + const status = document.getElementById("table-status"); + if (status) status.innerHTML = `нет связи с источником — показаны демо-данные`; + return false; + } +} + function renderLeagueTabs() { const tabs = document.getElementById("league-tabs"); if (!tabs) return; - tabs.innerHTML = Object.entries(LEAGUES).map(([key, l], i) => ` + tabs.innerHTML = Object.entries(CURRENT).map(([key, l], i) => ` `).join(""); tabs.addEventListener("click", (e) => { @@ -164,22 +227,38 @@ function renderLeagueTabs() { }); } +function zoneClass(key, i, total) { + const z = ZONES[key] || {}; + if (i < (z.ucl || 0)) return "zone-1"; + if (i < (z.ucl || 0) + (z.uel || 0)) return "zone-2"; + if (i >= total - (z.releg || 0)) return "zone-4"; + return ""; +} + function renderTable(key) { - const league = LEAGUES[key] || LEAGUES.premier; + const league = CURRENT[key] || CURRENT.premier; const table = document.getElementById("league-table"); if (!table) return; + if (!league.rows.length) { + table.innerHTML = `Данные по этой лиге временно недоступны.`; + return; + } + const total = league.rows.length; const head = `#Команда${league.cols.map((c) => `${c}`).join("")}`; const body = league.rows.map((r, i) => { - const pts = r[1] * 3 + r[2]; + const pts = r[2] * 3 + r[3]; + const zone = zoneClass(key, i, total); return ` - - ${i + 1} + + ${i + 1} ${r[0]} - ${pts} + ${pts} ${r.slice(1).map((v) => `${v}`).join("")} `; }).join(""); table.innerHTML = head + `${body}`; + const legend = document.querySelector(".zone-legend"); + if (legend) legend.innerHTML = `Лига чемпионовЕврокубкиВылет`; } function renderMatches() { @@ -210,10 +289,16 @@ function renderArticles() { `).join(""); } -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { renderSports(); renderLeagueTabs(); - renderTable("premier"); renderMatches(); renderArticles(); + await loadStandings(); + renderTable("premier"); + setInterval(async () => { + await loadStandings(); + const activeBtn = document.querySelector("#league-tabs [data-variant='primary']"); + renderTable(activeBtn ? activeBtn.dataset.league : "premier"); + }, 15 * 60 * 1000); }); diff --git a/server.js b/server.js index f2e59c6..204ed9d 100644 --- a/server.js +++ b/server.js @@ -3,11 +3,103 @@ 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; -http.createServer((req, res) => { - let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; - const decoded = decodeURIComponent(file); - const full = path.normalize(path.join(__dirname, decoded)); +// 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(); @@ -21,4 +113,8 @@ http.createServer((req, res) => { }[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); res.end(fs.readFileSync(full)); -}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +}).listen(PORT, () => { + console.log("Сервер на порту " + PORT); + setTimeout(() => { refresh().catch(() => {}); }, 1500); + setInterval(() => { refresh().catch(() => {}); }, CACHE_TTL); +});