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, "'"); }