git.zapret.moe в России блокируют по имени; ссылки на исходники в шапке и подвале страниц ведут на рабочее имя. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
594 lines
29 KiB
HTML
594 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<!-- data-theme НЕ задан: иначе правило prefers-color-scheme
|
||
никогда не срабатывало и системная светлая тема игнорировалась -->
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Статистика — yt.zapret.moe</title>
|
||
<meta name="description" content="Обезличенные счётчики нагрузки сервиса.">
|
||
<meta name="robots" content="noindex">
|
||
<!-- Шрифт размещён локально: внешний запрос к Google блокировал отрисовку
|
||
и попутно отдавал им IP каждого посетителя. -->
|
||
<link rel="preload" as="font" type="font/woff2" crossorigin
|
||
href="{{ url_for('static', filename='fonts/inter-400-cyrillic.woff2') }}">
|
||
<link rel="preload" as="font" type="font/woff2" crossorigin
|
||
href="{{ url_for('static', filename='fonts/inter-700-cyrillic.woff2') }}">
|
||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topbar">
|
||
<div class="topbar__in">
|
||
<a class="brand" href="/">
|
||
<span class="brand__mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11M7 10l5 5 5-5M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg></span>
|
||
<span>yt-grab <span class="brand__sub">статистика</span></span>
|
||
</a>
|
||
<nav class="topnav">
|
||
<a class="topnav__keep" href="/stats" aria-current="page"><span>Статистика</span></a>
|
||
<span class="topnav__sep" aria-hidden="true"></span>
|
||
<a href="https://t.me/bypassblock" target="_blank" rel="noopener noreferrer" title="Наш канал в Telegram"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11.5 20 5l-2.5 14-5-4-2.5 3-.5-4.5L18 7"/></svg><span>Канал</span></a>
|
||
<a href="https://github.com/zapret-discordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer" title="Зеркало на GitHub"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg><span>GitHub</span></a>
|
||
<a href="https://git2.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer" title="Исходный код проекта"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="5" r="2.5"/><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="9" r="2.5"/><path d="M6 7.5v9M8.5 5H14a4 4 0 0 1 4 4v0"/></svg><span>Исходники</span></a>
|
||
<a class="topnav__donate" href="https://t.me/zapretvpns_bot?start=ytdlp_stats_topnav" target="_blank" rel="noopener noreferrer" title="Поддержать проект"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.3-7-9.2A4 4 0 0 1 12 8a4 4 0 0 1 7 2.8C19 15.7 12 20 12 20z"/></svg><span>Поддержать</span></a>
|
||
<button class="icon-btn" id="theme-toggle" aria-label="Сменить тему" title="Сменить тему"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="wrap">
|
||
|
||
<section class="hero" style="padding-bottom:var(--s-4)">
|
||
<h1 style="font-size:28px">Как пользуются сервисом</h1>
|
||
<p>Счётчики нагрузки и лента скачанного. Без IP и без привязки к тому, кто скачивал.</p>
|
||
</section>
|
||
|
||
<!-- Переключатель периода -->
|
||
<div style="display:flex; justify-content:center; margin-bottom:var(--s-6)">
|
||
<div class="seg" id="period-seg" role="radiogroup" aria-label="Период">
|
||
<button type="button" role="radio" data-period="today" aria-checked="true">Сегодня</button>
|
||
<button type="button" role="radio" data-period="week" aria-checked="false">Неделя</button>
|
||
<button type="button" role="radio" data-period="month" aria-checked="false">Месяц</button>
|
||
<button type="button" role="radio" data-period="all" aria-checked="false">Всё время</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="banner hidden" id="load-error" role="alert" style="margin-bottom:var(--s-4)">
|
||
<span>⚠</span><span id="load-error-text"></span>
|
||
</div>
|
||
|
||
<!-- Плитки -->
|
||
<div class="tiles" id="tiles">
|
||
<div class="tile">
|
||
<div class="tile__label">Отдано данных</div>
|
||
<div class="tile__value" id="v-bytes">—</div>
|
||
<div class="tile__sub" id="v-files">—</div>
|
||
</div>
|
||
<div class="tile">
|
||
<div class="tile__label">Скачиваний</div>
|
||
<div class="tile__value" id="v-done">—</div>
|
||
<div class="tile__sub" id="v-started">—</div>
|
||
</div>
|
||
<div class="tile">
|
||
<div class="tile__label">Запросов к API</div>
|
||
<div class="tile__value" id="v-api">—</div>
|
||
<div class="tile__sub">разборов ссылок</div>
|
||
</div>
|
||
<div class="tile">
|
||
<div class="tile__label">Доля успешных</div>
|
||
<div class="tile__value" id="v-rate">—</div>
|
||
<div class="tile__sub" id="v-failed">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- График -->
|
||
<section class="section">
|
||
<div class="section__head">
|
||
<h2>За последние 30 дней</h2>
|
||
<div class="seg" id="metric-seg" role="radiogroup" aria-label="Показатель" style="padding:2px">
|
||
<button type="button" role="radio" data-metric="bytes_served" aria-checked="true"
|
||
style="padding:6px 10px; font-size:12px">Байты</button>
|
||
<button type="button" role="radio" data-metric="downloads_done" aria-checked="false"
|
||
style="padding:6px 10px; font-size:12px">Скачивания</button>
|
||
<button type="button" role="radio" data-metric="api_info" aria-checked="false"
|
||
style="padding:6px 10px; font-size:12px">Запросы</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="chart" id="chart" role="img" aria-label="Активность за 30 дней"></div>
|
||
<div class="chart__axis">
|
||
<span id="axis-from"></span>
|
||
<span id="axis-to"></span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Лента: что скачивают -->
|
||
<section class="section">
|
||
<div class="section__head">
|
||
<h2>Что скачивают <span class="pill" id="feed-total"></span></h2>
|
||
<div class="seg" id="feed-seg" role="radiogroup" aria-label="Сортировка" style="padding:2px">
|
||
<button type="button" role="radio" data-order="recent" aria-checked="true"
|
||
style="padding:6px 10px; font-size:12px">Недавние</button>
|
||
<button type="button" role="radio" data-order="popular" aria-checked="false"
|
||
style="padding:6px 10px; font-size:12px">Популярные</button>
|
||
</div>
|
||
</div>
|
||
<div class="banner hidden" id="feed-error" role="alert" style="margin-bottom:var(--s-3)">
|
||
<span>⚠</span><span id="feed-error-text"></span>
|
||
</div>
|
||
<div class="stack" id="feed"></div>
|
||
<div class="empty" id="feed-empty">
|
||
<div class="empty__icon" aria-hidden="true">🎬</div>
|
||
<strong>Пока пусто</strong>
|
||
Здесь появятся ролики, которые скачивали через сервис.
|
||
</div>
|
||
<div style="display:flex; justify-content:center; margin-top:var(--s-4)">
|
||
<button class="btn btn--ghost hidden" id="feed-more">Показать ещё</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="section">
|
||
<div class="note">
|
||
<span aria-hidden="true">🔒</span>
|
||
<span>О ленте выше: сервер хранит <strong>адрес ролика, время каждого скачивания и
|
||
размер самого ролика</strong> (в лучшем качестве — а не того файла, что скачали вы)
|
||
— и больше ничего. Ни названий, ни обложек, ни выбранных форматов, ни того, кто скачивал: ни IP,
|
||
ни сессии, ни учётных записей. Названия и картинки ваш браузер забирает напрямую с
|
||
YouTube, сервер их не видит и не хранит. Сами видеофайлы не сохраняются — удаляются
|
||
вскоре после выдачи. Времена скачиваний хранятся год, затем стираются.
|
||
<span id="since-note"></span></span>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<footer class="footer">
|
||
<div class="footer__in">
|
||
<p><a href="/">yt-grab</a> — открытая обёртка над
|
||
<a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer" target="_blank">yt-dlp</a>.
|
||
<a href="https://git2.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" rel="noopener noreferrer" target="_blank">Исходный код</a>.</p>
|
||
<nav class="footer__sites" data-zapret-sites="Другие сайты Zapret:"></nav>
|
||
<script src="/_sites/sites.js" defer></script>
|
||
<p class="footer__donate">
|
||
Сервис бесплатный и без рекламы —
|
||
<a href="https://t.me/zapretvpns_bot?start=ytdlp_stats_footer" rel="noopener noreferrer" target="_blank">поддержать проект</a>,
|
||
<a href="https://t.me/bypassblock" rel="noopener noreferrer" target="_blank">канал</a>.
|
||
</p>
|
||
</div>
|
||
</footer>
|
||
|
||
<script>
|
||
"use strict";
|
||
const $ = (s) => document.querySelector(s);
|
||
|
||
/* тема — та же настройка, что и на главной */
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem("ytgrab.settings.v1") || "{}");
|
||
if (s.theme) document.documentElement.setAttribute("data-theme", s.theme);
|
||
} catch { /* приватный режим */ }
|
||
$("#theme-toggle").addEventListener("click", () => {
|
||
const cur = document.documentElement.getAttribute("data-theme")
|
||
|| (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||
const next = cur === "dark" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", next);
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem("ytgrab.settings.v1") || "{}");
|
||
s.theme = next;
|
||
localStorage.setItem("ytgrab.settings.v1", JSON.stringify(s));
|
||
} catch { /* приватный режим */ }
|
||
});
|
||
|
||
const fmtBytes = (b) => {
|
||
if (!b) return "0 B";
|
||
const u = ["B","KB","MB","GB","TB"]; let i = 0, n = b;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${n.toFixed(n >= 100 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||
};
|
||
const fmtNum = (n) => (n || 0).toLocaleString("ru-RU");
|
||
const plural = (n, one, few, many) => {
|
||
const m10 = n % 10, m100 = n % 100;
|
||
if (m10 === 1 && m100 !== 11) return one;
|
||
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
|
||
return many;
|
||
};
|
||
|
||
/* ---------- Данные ----------
|
||
Страница приходит с сервера уже с цифрами (INITIAL), поэтому прочерки
|
||
не мигают и первый запрос к API не нужен вовсе. Дальше главный источник
|
||
— кэш в localStorage: к серверу идём, только когда кэш протух. */
|
||
const INITIAL = {{ initial|tojson }};
|
||
const LS_CACHE = "ytgrab.cache.v1";
|
||
const CACHE_TTL_MS = 60000;
|
||
|
||
const cache = {
|
||
read() {
|
||
try { return JSON.parse(localStorage.getItem(LS_CACHE) || "null"); }
|
||
catch { return null; }
|
||
},
|
||
write(patch) {
|
||
try {
|
||
const cur = cache.read() || {};
|
||
localStorage.setItem(LS_CACHE, JSON.stringify({ ...cur, ...patch, at: Date.now() }));
|
||
} catch { /* приватный режим — просто живём без кэша */ }
|
||
},
|
||
fresh() {
|
||
const c = cache.read();
|
||
return c && c.at && (Date.now() - c.at) < CACHE_TTL_MS;
|
||
}
|
||
};
|
||
|
||
let period = "today", metric = "bytes_served";
|
||
let DATA = INITIAL.stats || cache.read()?.stats || null;
|
||
|
||
/* Суммы за период считаем в браузере из того же ряда по дням, который уже
|
||
получен. Сервер их больше не агрегирует — это три лишних запроса к БД. */
|
||
const COUNTERS = ["api_info","downloads_started","downloads_done",
|
||
"downloads_failed","files_served","bytes_served"];
|
||
|
||
function periodTotals(p) {
|
||
if (!DATA) return {};
|
||
if (p === "all") return DATA.all || {};
|
||
const days = p === "today" ? 1 : p === "week" ? 7 : 30;
|
||
const series = (DATA.series || []).slice(-days);
|
||
const out = {};
|
||
COUNTERS.forEach(c => { out[c] = series.reduce((s, d) => s + (d[c] || 0), 0); });
|
||
return out;
|
||
}
|
||
|
||
async function load(force) {
|
||
// к серверу идём, только если кэш протух или нас попросили явно
|
||
if (!force && cache.fresh()) return;
|
||
try {
|
||
const r = await fetch("/api/stats");
|
||
if (!r.ok) throw new Error(httpMessage(r.status));
|
||
DATA = await readJson(r);
|
||
cache.write({ stats: DATA });
|
||
} catch (e) {
|
||
// Показываем ошибку, только если показывать вообще нечего.
|
||
// Если данные уже на экране — молча живём со старыми.
|
||
if (!DATA) {
|
||
$("#load-error-text").textContent = e.message;
|
||
$("#load-error").classList.remove("hidden");
|
||
}
|
||
return;
|
||
}
|
||
$("#load-error").classList.add("hidden");
|
||
renderAll();
|
||
}
|
||
|
||
function renderAll() {
|
||
if (!DATA) return;
|
||
render();
|
||
renderChart();
|
||
if (DATA.since) {
|
||
const d = new Date(DATA.since + "T00:00:00");
|
||
// локаль уже добавляет «г.», своя точка дала бы «2026 г..»
|
||
$("#since-note").textContent = "Счёт идёт с " +
|
||
d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
const d = periodTotals(period);
|
||
$("#v-bytes").textContent = fmtBytes(d.bytes_served);
|
||
$("#v-files").textContent = fmtNum(d.files_served) + " " +
|
||
plural(d.files_served || 0, "файл отдан", "файла отдано", "файлов отдано");
|
||
$("#v-done").textContent = fmtNum(d.downloads_done);
|
||
$("#v-started").textContent = "запущено " + fmtNum(d.downloads_started);
|
||
$("#v-api").textContent = fmtNum(d.api_info);
|
||
|
||
const done = d.downloads_done || 0, failed = d.downloads_failed || 0;
|
||
const total = done + failed;
|
||
$("#v-rate").textContent = total ? Math.round(done / total * 100) + "%" : "—";
|
||
$("#v-failed").textContent = failed
|
||
? fmtNum(failed) + " " + plural(failed, "ошибка", "ошибки", "ошибок")
|
||
: "без ошибок";
|
||
}
|
||
|
||
function renderChart() {
|
||
const series = DATA.series || [];
|
||
const box = $("#chart");
|
||
box.innerHTML = "";
|
||
if (!series.length) { box.innerHTML = '<div class="empty">Пока нет данных</div>'; return; }
|
||
const max = Math.max(...series.map(s => s[metric] || 0), 1);
|
||
series.forEach(s => {
|
||
const v = s[metric] || 0;
|
||
const bar = document.createElement("div");
|
||
bar.className = "bar" + (v === 0 ? " bar--empty" : "");
|
||
// минимальная видимая высота у ненулевых значений, чтобы их было заметно
|
||
bar.style.height = v === 0 ? "2px" : Math.max(4, v / max * 100) + "%";
|
||
const shown = metric === "bytes_served" ? fmtBytes(v) : fmtNum(v);
|
||
const day = new Date(s.day + "T00:00:00")
|
||
.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||
bar.title = `${day}: ${shown}`;
|
||
box.appendChild(bar);
|
||
});
|
||
const fmtDay = (iso) => new Date(iso + "T00:00:00")
|
||
.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||
$("#axis-from").textContent = fmtDay(series[0].day);
|
||
$("#axis-to").textContent = fmtDay(series[series.length - 1].day);
|
||
}
|
||
|
||
document.querySelectorAll("#period-seg button").forEach(b =>
|
||
b.addEventListener("click", () => {
|
||
period = b.dataset.period;
|
||
document.querySelectorAll("#period-seg button").forEach(x =>
|
||
x.setAttribute("aria-checked", String(x === b)));
|
||
render();
|
||
}));
|
||
|
||
document.querySelectorAll("#metric-seg button").forEach(b =>
|
||
b.addEventListener("click", () => {
|
||
metric = b.dataset.metric;
|
||
document.querySelectorAll("#metric-seg button").forEach(x =>
|
||
x.setAttribute("aria-checked", String(x === b)));
|
||
renderChart();
|
||
}));
|
||
|
||
/* ---------- Лента «что скачивают» ---------- */
|
||
|
||
/* nginx отдаёт свои ошибки HTML-страницей, поэтому разбор JSON падал
|
||
сырым английским исключением прямо в интерфейс. */
|
||
const httpMessage = (code) => ({
|
||
429: "Слишком много запросов — подождите немного",
|
||
502: "Сервис недоступен, попробуйте позже",
|
||
503: "Сервис перегружен, попробуйте позже",
|
||
504: "Сервер не ответил вовремя",
|
||
}[code] || `Сервер ответил ${code}`);
|
||
|
||
async function readJson(r) {
|
||
try { return await r.json(); }
|
||
catch { throw new Error("Сервер вернул неожиданный ответ"); }
|
||
}
|
||
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
|
||
({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
||
|
||
/* Адреса приходят из БД и попадают в href — пропускаем только http(s),
|
||
чтобы исключить javascript:-ссылки. */
|
||
const safeUrl = (u) => {
|
||
try {
|
||
const p = new URL(u);
|
||
return (p.protocol === "http:" || p.protocol === "https:") ? u : null;
|
||
} catch { return null; }
|
||
};
|
||
|
||
/* ID ролика -> обложка с CDN YouTube. Никаких запросов к нашему серверу. */
|
||
function youtubeId(url) {
|
||
try {
|
||
const u = new URL(url);
|
||
const h = u.hostname.replace(/^www\./, "");
|
||
if (h === "youtu.be") return u.pathname.slice(1).split("/")[0] || null;
|
||
if (h === "youtube.com" || h.endsWith(".youtube.com")) {
|
||
if (u.pathname === "/watch") return u.searchParams.get("v");
|
||
const m = u.pathname.match(/^\/(shorts|embed|live)\/([^/]+)/);
|
||
if (m) return m[2];
|
||
}
|
||
} catch { /* не ссылка */ }
|
||
return null;
|
||
}
|
||
|
||
const titleCache = new Map();
|
||
async function fetchTitle(url) {
|
||
if (titleCache.has(url)) return titleCache.get(url);
|
||
try {
|
||
const r = await fetch("https://www.youtube.com/oembed?format=json&url=" +
|
||
encodeURIComponent(url));
|
||
if (!r.ok) throw new Error("no oembed");
|
||
const d = await r.json();
|
||
titleCache.set(url, d.title);
|
||
return d.title;
|
||
} catch {
|
||
titleCache.set(url, null); // не долбим источник повторно
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const FEED_PAGE = (INITIAL.feed && INITIAL.feed.page_size) || 50;
|
||
// Листаем по курсору, а не по смещению: last_seen меняется при каждом
|
||
// скачивании, поэтому при OFFSET записи прыгали между страницами.
|
||
let feedOrder = "recent", feedCursor = null, feedPages = 0;
|
||
|
||
const relTime = (iso) => {
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return ""; // пустая или битая метка
|
||
const diff = (Date.now() - d) / 1000;
|
||
if (diff < 60) return "только что";
|
||
if (diff < 3600) {
|
||
const m = Math.floor(diff / 60);
|
||
return `${m} ${plural(m, "минуту", "минуты", "минут")} назад`;
|
||
}
|
||
if (diff < 86400) {
|
||
const h = Math.floor(diff / 3600);
|
||
return `${h} ${plural(h, "час", "часа", "часов")} назад`;
|
||
}
|
||
const dd = Math.floor(diff / 86400);
|
||
if (diff < 2592000) return `${dd} ${plural(dd, "день", "дня", "дней")} назад`;
|
||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" });
|
||
};
|
||
|
||
/* Точное время: в ленте видно, когда именно скачивали, а не только
|
||
«сколько назад». Сегодняшнее показываем часами, остальное — с датой. */
|
||
const exactTime = (iso) => {
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return "";
|
||
const today = new Date().toDateString() === d.toDateString();
|
||
return d.toLocaleString("ru-RU", today
|
||
? { hour: "2-digit", minute: "2-digit" }
|
||
: { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||
};
|
||
|
||
/* Полная метка для подсказки при наведении. */
|
||
const fullTime = (iso) => {
|
||
const d = new Date(iso);
|
||
return isNaN(d) ? "" : d.toLocaleString("ru-RU", {
|
||
day: "2-digit", month: "long", year: "numeric",
|
||
hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||
};
|
||
|
||
let feedGen = 0;
|
||
|
||
async function loadFeed(reset) {
|
||
// Простая блокировка «уже гружу» отбрасывала смену сортировки: кнопка
|
||
// выглядела нажатой, а лента оставалась прежней и дальше дописывала
|
||
// популярные к недавним. Считаем поколения — побеждает последний
|
||
// запрос, устаревший ответ отбрасывается.
|
||
const gen = ++feedGen;
|
||
const more = $("#feed-more");
|
||
more.disabled = true; // защита от двойного клика
|
||
try {
|
||
await _loadFeed(reset, gen);
|
||
} finally {
|
||
if (gen === feedGen) more.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function _loadFeed(reset, gen) {
|
||
if (reset) { feedCursor = null; feedPages = 0; $("#feed").innerHTML = ""; }
|
||
const order = feedOrder, cursor = feedCursor;
|
||
let d;
|
||
try {
|
||
const qs = `order=${order}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
|
||
const r = await fetch(`/api/feed?${qs}`);
|
||
if (!r.ok) throw new Error(httpMessage(r.status));
|
||
d = await readJson(r);
|
||
if (gen !== feedGen) return; // ответ на устаревший запрос
|
||
if (order === "recent" && !cursor) cache.write({ feed: d });
|
||
} catch (e) {
|
||
if (gen !== feedGen) return;
|
||
// Есть что показать из кэша — показываем его, а не ошибку.
|
||
const c = cache.read();
|
||
if (reset && feedOrder === "recent" && c && c.feed) {
|
||
applyFeed(c.feed);
|
||
return;
|
||
}
|
||
// «Пока пусто» здесь было бы ложью — данные есть, их просто не отдали
|
||
$("#feed-empty").classList.add("hidden");
|
||
$("#feed-error-text").textContent = e.message;
|
||
$("#feed-error").classList.remove("hidden");
|
||
return;
|
||
}
|
||
applyFeed(d);
|
||
}
|
||
|
||
function applyFeed(d) {
|
||
$("#feed-error").classList.add("hidden");
|
||
const items = d.items || [];
|
||
|
||
$("#feed-empty").classList.toggle("hidden", !!($("#feed").children.length || items.length));
|
||
if (d.totals && d.totals.unique) {
|
||
$("#feed-total").textContent = fmtNum(d.totals.unique) + " " +
|
||
plural(d.totals.unique, "ролик", "ролика", "роликов");
|
||
$("#feed-total").classList.remove("hidden");
|
||
} else {
|
||
$("#feed-total").classList.add("hidden");
|
||
}
|
||
|
||
items.forEach(it => {
|
||
const url = safeUrl(it.url);
|
||
if (!url) return;
|
||
const vid = youtubeId(url);
|
||
const host = (() => { try { return new URL(url).hostname.replace(/^www\./, ""); }
|
||
catch { return ""; } })();
|
||
const el = document.createElement("div");
|
||
el.className = "feed-item";
|
||
el.innerHTML = `
|
||
<div class="feed-thumb">${vid
|
||
? `<img src="https://i.ytimg.com/vi/${esc(vid)}/mqdefault.jpg" alt=""
|
||
loading="lazy" referrerpolicy="no-referrer">`
|
||
: `<span aria-hidden="true">🔗</span>`}</div>
|
||
<div class="feed-body">
|
||
<a class="feed-title" href="${esc(url)}" target="_blank"
|
||
rel="noopener noreferrer nofollow">${esc(url)}</a>
|
||
<div class="meta" title="Последний раз: ${esc(fullTime(it.last_seen))} Впервые: ${esc(fullTime(it.first_seen))}${
|
||
it.size ? " Размер ролика в лучшем качестве" : ""}">${esc(host)}${
|
||
it.size > 0 ? ` · ${esc(fmtBytes(it.size))}` : ""}${
|
||
exactTime(it.last_seen) ? ` · <span class="meta__time">${esc(exactTime(it.last_seen))}</span>` : ""
|
||
}${relTime(it.last_seen) ? ` · ${esc(relTime(it.last_seen))}` : ""}</div>
|
||
</div>
|
||
<button class="feed-count" type="button" data-times="${esc(url)}"
|
||
aria-expanded="false" title="Показать время каждого скачивания">
|
||
<strong>${fmtNum(it.count)}</strong><span>раз</span>
|
||
</button>
|
||
<div class="feed-times hidden" hidden></div>`;
|
||
$("#feed").appendChild(el);
|
||
|
||
// по клику на счётчик показываем время каждого скачивания
|
||
el.querySelector("[data-times]").addEventListener("click", async (e) => {
|
||
const btn = e.currentTarget;
|
||
const box = el.querySelector(".feed-times");
|
||
const open = btn.getAttribute("aria-expanded") === "true";
|
||
if (open) {
|
||
btn.setAttribute("aria-expanded", "false");
|
||
box.hidden = true; box.classList.add("hidden");
|
||
return;
|
||
}
|
||
btn.setAttribute("aria-expanded", "true");
|
||
box.hidden = false; box.classList.remove("hidden");
|
||
if (box.dataset.loaded) return;
|
||
box.textContent = "Загружаем…";
|
||
try {
|
||
const r = await fetch("/api/feed/events?url=" + encodeURIComponent(url));
|
||
if (!r.ok) throw new Error(`Сервер ответил ${r.status}`);
|
||
const d = await r.json();
|
||
const times = d.times || [];
|
||
if (!times.length) {
|
||
box.textContent = "Времена не записаны: их начали сохранять недавно.";
|
||
} else {
|
||
box.innerHTML = times.map(t =>
|
||
`<span class="feed-time">${esc(fullTime(t))}</span>`).join("");
|
||
if (times.length < it.count) {
|
||
box.innerHTML += `<span class="feed-time feed-time--note">` +
|
||
`…и ещё ${fmtNum(it.count - times.length)} до начала записи ` +
|
||
`(хранится ${d.retention_days} дней)</span>`;
|
||
}
|
||
}
|
||
box.dataset.loaded = "1";
|
||
} catch (err) {
|
||
box.textContent = "Не удалось загрузить: " + err.message;
|
||
}
|
||
});
|
||
|
||
// название подтягиваем с YouTube — наш сервер его не хранит
|
||
if (vid) {
|
||
const a = el.querySelector(".feed-title");
|
||
fetchTitle(url).then(t => { if (t) a.textContent = t; });
|
||
}
|
||
});
|
||
|
||
feedCursor = d.next_cursor || null;
|
||
feedPages += 1;
|
||
$("#feed-more").classList.toggle("hidden", !feedCursor);
|
||
}
|
||
|
||
document.querySelectorAll("#feed-seg button").forEach(b =>
|
||
b.addEventListener("click", () => {
|
||
feedOrder = b.dataset.order;
|
||
document.querySelectorAll("#feed-seg button").forEach(x =>
|
||
x.setAttribute("aria-checked", String(x === b)));
|
||
loadFeed(true);
|
||
}));
|
||
$("#feed-more").addEventListener("click", () => loadFeed(false));
|
||
|
||
/* ---------- Старт ----------
|
||
Рисуем синхронно из данных, пришедших вместе со страницей: никакого
|
||
мигания прочерков и ни одного запроса к API на обычной загрузке. */
|
||
renderAll();
|
||
cache.write({ stats: DATA, feed: INITIAL.feed });
|
||
if (INITIAL.feed) applyFeed(INITIAL.feed);
|
||
|
||
/* Обновляем только когда вкладка на виду и кэш протух. */
|
||
function refreshIfStale() {
|
||
if (document.visibilityState !== "visible" || cache.fresh()) return;
|
||
load();
|
||
// Ленту перечитываем только если пользователь не листал дальше первой
|
||
// страницы: иначе фоновое обновление схлопывало подгруженные карточки
|
||
// и сбрасывало прокрутку прямо во время чтения.
|
||
if (feedPages <= 1) loadFeed(true);
|
||
}
|
||
setInterval(refreshIfStale, 30000);
|
||
document.addEventListener("visibilitychange", refreshIfStale);
|
||
</script>
|
||
</body>
|
||
</html>
|