456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1";
|
||
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) {
|
||
window.addEventListener("hotness:auth-updated", () => init());
|
||
init();
|
||
}
|
||
|
||
async function init() {
|
||
render();
|
||
try {
|
||
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} 个节目`;
|
||
}
|
||
} catch (error) {
|
||
state.message = error.requiresAuth ? "请先输入访问密码" : error.message;
|
||
}
|
||
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, { headers: authHeaders() });
|
||
return parseApiResponse(response);
|
||
}
|
||
|
||
async function apiPost(path, payload) {
|
||
const response = await fetch(path, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", ...authHeaders() },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
return parseApiResponse(response);
|
||
}
|
||
|
||
async function parseApiResponse(response) {
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(data.error || "request failed");
|
||
error.requiresAuth = Boolean(data.requires_auth || response.status === 401);
|
||
throw error;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function authHeaders() {
|
||
const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || "";
|
||
return token ? { "x-hotness-auth-token": token } : {};
|
||
}
|
||
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHtml(value).replace(/'/g, "'");
|
||
}
|