реальные таблицы: football-data.org + фолбэк на демо
This commit is contained in:
parent
181440b81d
commit
38ea559b5f
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
.vibe42-run.log
|
||||
.vibe42-run.pid
|
||||
node_modules/
|
||||
.env
|
||||
.league-cache.json
|
||||
|
||||
@ -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: ошибка исчерпаны источники
|
||||
|
||||
@ -1 +1 @@
|
||||
1345
|
||||
8240
|
||||
|
||||
17
index.html
17
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 @@
|
||||
<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>
|
||||
|
||||
@ -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
105
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
|
||||
? `<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
106
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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user