реальные таблицы: football-data.org + фолбэк на демо

This commit is contained in:
Dias 2026-09-23 10:41:39 +00:00
parent 181440b81d
commit 38ea559b5f
7 changed files with 311 additions and 19 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.vibe42-run.log
.vibe42-run.pid
node_modules/
.env
.league-cache.json

View File

@ -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: ошибка исчерпаны источники

View File

@ -1 +1 @@
1345
8240

View File

@ -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 @@
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Таблицы чемпионатов</h2>
<p>Текущее положение команд в главных лигах. Зелёные строки — лидеры.</p>
<p>Реальные данные из футбольных лиг — обновляются каждые 15 минут. Подсвечены лидеры.</p>
</div>
<div class="league-tabs" id="league-tabs"></div>
<div id="table-status" class="table-status"></div>
<div class="kt-ai-card table-scroll" style="margin-top:16px; padding: 8px 12px;">
<table class="kt-ai-table" id="league-table"></table>
</div>
<div class="zone-legend" id="zone-legend"></div>
<div id="matches" style="margin-top: var(--kt-ai-space-8, 28px)">
<h3 style="margin-bottom: 12px; font-weight: 500;">Ближайшие матчи</h3>
<div class="match-strip" id="match-strip"></div>

View File

@ -2,5 +2,5 @@
"name": "sports-portal",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" }
"scripts": { "start": "node --env-file=.env server.js" }
}

105
script.js
View File

@ -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
? `<span class="kt-ai-chip">обновлено в ${when}</span><span>источник: TheSportsDB · повтор через 15 минут</span>`
: `<span>нет связи с источником — показаны демо-данные</span>`;
}
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 = `<span>нет связи с источником — показаны демо-данные</span>`;
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) => `
<button class="kt-ai-btn" data-league="${key}" data-variant="${i === 0 ? "primary" : "ghost"}">${l.label}</button>
`).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 = `<tr><td style="padding: 16px; color: var(--kt-ai-fg-muted)">Данные по этой лиге временно недоступны.</td></tr>`;
return;
}
const total = league.rows.length;
const head = `<tr><th>#</th><th>Команда</th>${league.cols.map((c) => `<th>${c}</th>`).join("")}</tr>`;
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 `
<tr class="${i < 3 ? "top" : ""}">
<td><span class="pos">${i + 1}</span></td>
<tr class="${zone}">
<td><span class="pos${i === 0 ? " lead" : ""}">${i + 1}</span></td>
<td>${r[0]}</td>
<td style="text-align:center; font-variant-numeric: tabular-nums;">${pts}</td>
<td style="text-align:center; font-variant-numeric: tabular-nums; font-weight:500">${pts}</td>
${r.slice(1).map((v) => `<td style="text-align:center; font-variant-numeric: tabular-nums;">${v}</td>`).join("")}
</tr>`;
}).join("");
table.innerHTML = head + `<tbody class="nums">${body}</tbody>`;
const legend = document.querySelector(".zone-legend");
if (legend) legend.innerHTML = `<span class="leg-white">Лига чемпионов</span><span class="leg-mut">Еврокубки</span><span class="leg-dim">Вылет</span>`;
}
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);
});

106
server.js
View File

@ -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);
});