kaikai_test/public/rankings.js
2026-05-14 18:53:53 +08:00

439 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const PLATFORM_LABELS = { tencent: "腾讯视频", youku: "优酷", iqiyi: "爱奇艺", mgtv: "芒果TV" };
const TYPE_LABELS = { animation: "动画", education: "早教", song: "儿歌", toy: "玩具", movie: "电影", other: "其他" };
const SOURCE_LABELS = { new: "新片", recommend: "推荐", rank: "榜单", hot: "热播", channel: "频道" };
const METRIC_PLATFORMS = ["tencent", "youku", "iqiyi", "mgtv"];
const state = {
view: "new",
programs: [],
trendResults: [],
defaults: [],
loading: false,
message: "",
filters: {
q: "",
exclude: "预告 片段 花絮 解说",
platform: "",
content_type: "animation",
status: "",
min_platforms: "",
},
};
const root = document.querySelector("#ranking-radar");
if (root) init();
async function init() {
render();
const [defaults, latest] = await Promise.all([
apiGet("/api/rankings/default-sources"),
apiGet("/api/kids-trends/latest"),
refreshPrograms(),
]);
state.defaults = defaults.sources || [];
if (latest.trend?.results?.length) {
state.trendResults = latest.trend.results || [];
state.message = `已恢复上次上新趋势:${formatTime(latest.trend.captured_at)},采集 ${latest.trend.collected_count || state.trendResults.length} 个节目`;
}
render();
}
async function refreshPrograms() {
const params = new URLSearchParams({ category: "kids", view: state.view });
for (const [key, value] of Object.entries(state.filters)) {
if (value) params.set(key, value);
}
const data = await apiGet(`/api/rankings/programs?${params.toString()}`);
state.programs = data.programs || [];
}
function render() {
root.innerHTML = `
<div class="ranking-head">
<div>
<div class="panel-title">少儿上新趋势雷达</div>
<div class="ranking-subtitle">一键发现少儿新节目,采集四平台数值,并判断增长趋势</div>
</div>
<div class="ranking-actions">
<button class="button ghost primary-action" type="button" data-action="run-trend">${state.loading ? "采集中" : "一键采集上新趋势"}</button>
${viewButton("new", "候选")}
${viewButton("platform", "全部")}
${viewButton("ignored", "已忽略")}
<a class="button ghost" href="/api/rankings/export?category=kids&view=${state.view}">导出</a>
</div>
</div>
<div class="kids-discovery">
${trendSummary()}
<form class="kids-filter-form" data-role="filters">
<input name="q" type="search" value="${escapeAttr(state.filters.q)}" placeholder="关键词,可留空">
<input name="exclude" type="text" value="${escapeAttr(state.filters.exclude)}" placeholder="排除词,用空格分隔">
<select name="content_type">
<option value="">全部类型</option>
${options(TYPE_LABELS, state.filters.content_type)}
</select>
<select name="platform">
<option value="">全部平台</option>
${options(PLATFORM_LABELS, state.filters.platform)}
</select>
<select name="status">
<option value="">全部状态</option>
<option value="untracked" ${state.filters.status === "untracked" ? "selected" : ""}>未追踪</option>
<option value="tracked" ${state.filters.status === "tracked" ? "selected" : ""}>已追踪</option>
<option value="uncollected" ${state.filters.status === "uncollected" ? "selected" : ""}>未采集</option>
<option value="collected" ${state.filters.status === "collected" ? "selected" : ""}>已采集</option>
</select>
<select name="min_platforms">
<option value="">不限平台数</option>
<option value="2" ${state.filters.min_platforms === "2" ? "selected" : ""}>至少2个平台</option>
<option value="3" ${state.filters.min_platforms === "3" ? "selected" : ""}>至少3个平台</option>
</select>
<button type="submit">筛选</button>
</form>
<div class="kids-summary">
<span title="${escapeAttr(state.message)}">${state.message || `当前 ${state.programs.length} 个候选`}</span>
<span title="${escapeAttr(sourceSummary())}">内置来源 ${state.defaults.length || 0} 个</span>
<span>趋势需要至少两次成功采集才会更准确</span>
</div>
${state.trendResults.length ? trendTable() : programTable()}
<details class="ranking-advanced">
<summary>高级:手动补充来源 URL</summary>
${sourceForm()}
</details>
</div>
`;
bindEvents();
}
function trendSummary() {
if (!state.trendResults.length) {
return `
<div class="trend-summary empty">
<strong>还没有趋势结论</strong>
<span>点击“一键采集上新趋势”,系统会自动找少儿新节目、采集四平台数值,并给出建议。</span>
</div>
`;
}
const counts = countBy(state.trendResults.map((item) => item.trend?.verdict || "no_data"));
return `
<div class="trend-summary">
${summaryCard("强增长", counts.strong_growth || 0)}
${summaryCard("在增长", counts.rising || 0)}
${summaryCard("新有数值", counts.new_signal || 0)}
${summaryCard("暂无数值", counts.no_data || 0)}
</div>
`;
}
function summaryCard(label, value) {
return `<div class="trend-card"><strong>${value}</strong><span>${label}</span></div>`;
}
function viewButton(id, label) {
return `<button class="ranking-chip ${state.view === id ? "active" : ""}" type="button" data-view="${id}">${label}</button>`;
}
function trendTable() {
return `
<div class="ranking-table-wrap">
<table class="ranking-table kids-table trend-table">
<thead>
<tr>
<th>节目</th>
<th>判断</th>
<th>腾讯</th>
<th>优酷</th>
<th>爱奇艺</th>
<th>芒果</th>
<th>增长</th>
<th>建议</th>
<th>操作</th>
</tr>
</thead>
<tbody>${state.trendResults.map(trendRow).join("")}</tbody>
</table>
</div>
`;
}
function trendRow(item) {
const program = item.program || {};
const trend = item.trend || {};
const url = program.urls?.[0] || "";
const platform = program.platforms?.[0] || "";
return `
<tr>
<td><strong title="${escapeAttr(program.display_name)}">${escapeHtml(program.display_name)}</strong>${releaseDateNote(program)}</td>
<td>${trendBadge(trend)}</td>
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
<td title="${escapeAttr(growthTitle(trend))}">${growthText(trend)}</td>
<td>${escapeHtml(trend.recommendation || "")}</td>
<td class="ranking-row-actions">
${url ? `<a class="mini-button" href="${escapeAttr(url)}" target="_blank" rel="noreferrer">开</a>` : ""}
<button class="mini-button" type="button" data-track-program="${escapeAttr(program.display_name)}" data-url="${escapeAttr(url)}" data-platform="${escapeAttr(platform)}">追踪</button>
</td>
</tr>
`;
}
function programTable() {
if (state.programs.length === 0) {
return `<div class="ranking-empty">还没有筛出节目。可以直接点“一键采集上新趋势”。</div>`;
}
return `
<div class="ranking-table-wrap">
<table class="ranking-table kids-table">
<thead>
<tr>
<th>节目</th>
<th>类型</th>
<th>腾讯</th>
<th>优酷</th>
<th>爱奇艺</th>
<th>芒果</th>
<th>来源</th>
<th>操作</th>
</tr>
</thead>
<tbody>${state.programs.map(programRow).join("")}</tbody>
</table>
</div>
`;
}
function programRow(program) {
const url = program.urls?.[0] || "";
const platform = program.platforms?.[0] || "";
const sources = (program.source_types || []).map((id) => SOURCE_LABELS[id] || id).join("、");
return `
<tr>
<td><strong title="${escapeAttr(program.display_name)}">${escapeHtml(program.display_name)}</strong>${releaseDateNote(program)}</td>
<td>${escapeHtml(TYPE_LABELS[program.content_type] || "其他")}</td>
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
<td title="${escapeAttr(program.first_seen_source || sources)}">${escapeHtml(sources)}</td>
<td class="ranking-row-actions">
${url ? `<a class="mini-button" href="${escapeAttr(url)}" target="_blank" rel="noreferrer">开</a>` : ""}
${program.ignored
? `<button class="mini-button" type="button" data-restore-program="${escapeAttr(program.display_name)}">恢复</button>`
: `<button class="mini-button" type="button" data-track-program="${escapeAttr(program.display_name)}" data-url="${escapeAttr(url)}" data-platform="${escapeAttr(platform)}">追踪</button>
<button class="mini-button" type="button" data-collect-program="${escapeAttr(program.display_name)}">采集</button>
<button class="mini-button warn" type="button" data-ignore-program="${escapeAttr(program.display_name)}">忽略</button>`}
</td>
</tr>
`;
}
function releaseDateNote(program) {
const value = program.release_date || "";
const text = value ? formatReleaseDate(value) : "未知";
const title = value ? `上线时间:${text}` : "暂未从平台页面识别到上线时间";
return `<small class="release-date-note ${value ? "" : "missing"}" title="${escapeAttr(title)}">上线:${escapeHtml(text)}</small>`;
}
function metricCell(program, platform) {
const metric = program.latest_metrics?.[platform];
const ok = metric?.status === "ok";
const text = ok ? metric.short : "未采";
const title = ok
? `${metric.platform_label || PLATFORM_LABELS[platform]} ${metric.metric_label || ""}${metric.raw || metric.number || ""},采集于 ${formatTime(metric.run)}`
: `${PLATFORM_LABELS[platform]} 暂无成功采集数值`;
return `<td class="${ok ? "metric-ok" : "metric-missing"}" title="${escapeAttr(title)}">${escapeHtml(text)}</td>`;
}
function trendBadge(trend) {
return `<span class="trend-badge ${escapeAttr(trend.verdict || "no_data")}">${escapeHtml(trend.label || "暂无数值")}</span>`;
}
function growthText(trend) {
if (!trend || !trend.growing_platforms) return "-";
const delta = Number(trend.best_delta || 0);
const rate = Number(trend.best_growth_rate || 0);
return `+${delta}${rate ? ` / ${Math.round(rate * 100)}%` : ""}`;
}
function growthTitle(trend) {
if (!trend?.platform_trends) return "";
return Object.values(trend.platform_trends)
.filter((item) => item.latest_status === "ok")
.map((item) => `${PLATFORM_LABELS[item.platform] || item.platform}: ${item.previous_raw || "无上次"} -> ${item.latest_raw || "无本次"}`)
.join("\n");
}
function sourceForm() {
return `
<form class="ranking-source-form" data-role="source-form">
<select name="platform" aria-label="平台">${options(PLATFORM_LABELS)}</select>
<select name="source_type" aria-label="来源类型">${options(SOURCE_LABELS, "channel")}</select>
<input name="label" type="text" placeholder="来源名,例如 腾讯少儿新片">
<input name="url" type="url" placeholder="频道/榜单 URL">
<label class="ranking-check"><input name="enabled" type="checkbox" checked>启用</label>
<button type="submit">保存补充来源</button>
</form>
`;
}
function bindEvents() {
root.querySelector("[data-action='run-trend']")?.addEventListener("click", runTrend);
root.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", async () => {
state.view = button.dataset.view;
state.trendResults = [];
await refreshPrograms();
render();
});
});
root.querySelector("[data-role='filters']")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
for (const key of Object.keys(state.filters)) {
state.filters[key] = String(form.get(key) || "").trim();
}
state.trendResults = [];
await refreshPrograms();
state.message = `筛出 ${state.programs.length}`;
render();
});
root.querySelector("[data-role='source-form']")?.addEventListener("submit", saveSource);
root.querySelectorAll("[data-ignore-program]").forEach((button) => button.addEventListener("click", () => ignoreProgram(button.dataset.ignoreProgram, true)));
root.querySelectorAll("[data-restore-program]").forEach((button) => button.addEventListener("click", () => ignoreProgram(button.dataset.restoreProgram, false)));
root.querySelectorAll("[data-track-program]").forEach((button) => button.addEventListener("click", () => trackProgram(button.dataset.trackProgram, button.dataset.platform, button.dataset.url)));
root.querySelectorAll("[data-collect-program]").forEach((button) => button.addEventListener("click", () => collectPrograms([button.dataset.collectProgram])));
}
async function runTrend() {
state.loading = true;
state.message = "正在发现并采集少儿上新趋势";
state.trendResults = [];
render();
try {
const data = await apiPost("/api/kids-trends/run", {
limit: 8,
platforms: ["tencent", "youku", "iqiyi", "mgtv"],
});
state.trendResults = data.results || [];
state.message = `发现 ${data.discovered_count || 0} 条,采集 ${data.collected_count || 0} 个节目`;
await refreshPrograms();
} finally {
state.loading = false;
render();
}
}
async function saveSource(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await apiPost("/api/ranking-sources", {
category: "kids",
platform: form.get("platform"),
source_type: form.get("source_type"),
label: form.get("label"),
url: form.get("url"),
enabled: form.get("enabled") === "on",
});
state.message = "补充来源已保存";
render();
}
async function ignoreProgram(name, ignored) {
const data = await apiPost("/api/rankings/ignore", { category: "kids", name, ignored });
state.programs = data.programs || [];
state.message = ignored ? "已忽略" : "已恢复";
render();
}
async function trackProgram(name, platform, url) {
await apiPost("/api/rankings/track", { category: "kids", name, platform, url });
state.message = "已加入历史节目";
await refreshPrograms();
render();
document.dispatchEvent(new CustomEvent("hotness:programs-changed"));
}
async function collectPrograms(names) {
const cleanNames = [...new Set(names.filter(Boolean))].slice(0, 20);
if (cleanNames.length === 0) return;
state.loading = true;
state.message = `正在采集 ${cleanNames.length} 个节目`;
render();
try {
const data = await apiPost("/api/rankings/collect", {
category: "kids",
names: cleanNames,
platforms: ["tencent", "youku", "iqiyi", "mgtv"],
});
state.programs = data.programs || [];
state.message = `已采集 ${data.items?.length || 0}`;
} finally {
state.loading = false;
render();
}
}
function sourceSummary() {
return state.defaults.map((source) => `${PLATFORM_LABELS[source.platform] || source.platform}${source.label}`).join("\n");
}
function countBy(values) {
const counts = {};
for (const value of values) counts[value] = (counts[value] || 0) + 1;
return counts;
}
function options(map, selected = "") {
return Object.entries(map).map(([value, label]) => `<option value="${value}" ${value === selected ? "selected" : ""}>${label}</option>`).join("");
}
async function apiGet(path) {
const response = await fetch(path);
return parseApiResponse(response);
}
async function apiPost(path, payload) {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
return parseApiResponse(response);
}
async function parseApiResponse(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "request failed");
return data;
}
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatReleaseDate(value) {
const text = String(value || "").trim();
if (!text) return "";
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(text)) return text;
if (/^[0-9]{2}-[0-9]{2}$/.test(text)) return text;
return text;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, "&#39;");
}