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 = `
少儿上新趋势雷达
一键发现少儿新节目,采集四平台数值,并判断增长趋势
${viewButton("new", "候选")}
${viewButton("platform", "全部")}
${viewButton("ignored", "已忽略")}
导出
${trendSummary()}
${state.message || `当前 ${state.programs.length} 个候选`}
内置来源 ${state.defaults.length || 0} 个
趋势需要至少两次成功采集才会更准确
${state.trendResults.length ? trendTable() : programTable()}
高级:手动补充来源 URL
${sourceForm()}
`;
bindEvents();
}
function trendSummary() {
if (!state.trendResults.length) {
return `
还没有趋势结论
点击“一键采集上新趋势”,系统会自动找少儿新节目、采集四平台数值,并给出建议。
`;
}
const counts = countBy(state.trendResults.map((item) => item.trend?.verdict || "no_data"));
return `
${summaryCard("强增长", counts.strong_growth || 0)}
${summaryCard("在增长", counts.rising || 0)}
${summaryCard("新有数值", counts.new_signal || 0)}
${summaryCard("暂无数值", counts.no_data || 0)}
`;
}
function summaryCard(label, value) {
return `${value}${label}
`;
}
function viewButton(id, label) {
return ``;
}
function trendTable() {
return `
| 节目 |
判断 |
腾讯 |
优酷 |
爱奇艺 |
芒果 |
增长 |
建议 |
操作 |
${state.trendResults.map(trendRow).join("")}
`;
}
function trendRow(item) {
const program = item.program || {};
const trend = item.trend || {};
const url = program.urls?.[0] || "";
const platform = program.platforms?.[0] || "";
return `
| ${escapeHtml(program.display_name)}${releaseDateNote(program)} |
${trendBadge(trend)} |
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
${growthText(trend)} |
${escapeHtml(trend.recommendation || "")} |
${url ? `开` : ""}
|
`;
}
function programTable() {
if (state.programs.length === 0) {
return `还没有筛出节目。可以直接点“一键采集上新趋势”。
`;
}
return `
| 节目 |
类型 |
腾讯 |
优酷 |
爱奇艺 |
芒果 |
来源 |
操作 |
${state.programs.map(programRow).join("")}
`;
}
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 `
| ${escapeHtml(program.display_name)}${releaseDateNote(program)} |
${escapeHtml(TYPE_LABELS[program.content_type] || "其他")} |
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
${escapeHtml(sources)} |
${url ? `开` : ""}
${program.ignored
? ``
: `
`}
|
`;
}
function releaseDateNote(program) {
const value = program.release_date || "";
const text = value ? formatReleaseDate(value) : "未知";
const title = value ? `上线时间:${text}` : "暂未从平台页面识别到上线时间";
return `上线:${escapeHtml(text)}`;
}
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 `${escapeHtml(text)} | `;
}
function trendBadge(trend) {
return `${escapeHtml(trend.label || "暂无数值")}`;
}
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 `
`;
}
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]) => ``).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, """);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, "'");
}