const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1"; const form = document.querySelector("#collect-form"); const input = document.querySelector("#program-name"); const button = document.querySelector("#collect-button"); const exportLink = document.querySelector("#export-link"); const statusDot = document.querySelector("#status-dot"); const statusText = document.querySelector("#status-text"); const tableTitle = document.querySelector("#table-title"); const runCount = document.querySelector("#run-count"); const runBulkButton = document.querySelector("#run-bulk-button"); const runBulkBar = document.querySelector("#run-bulk-bar"); const runDeleteSelected = document.querySelector("#run-delete-selected"); const runCancelBulk = document.querySelector("#run-cancel-bulk"); const runCollapseToggle = document.querySelector("#run-collapse-toggle"); const runCollapseNote = document.querySelector("#run-collapse-note"); const table = document.querySelector("#hotness-table"); const programList = document.querySelector("#program-list"); const collectHistoryButton = document.querySelector("#collect-history-button"); const retryPendingButton = document.querySelector("#retry-pending-button"); const temporaryFileInput = document.querySelector("#temporary-file-input"); const temporaryQueryText = document.querySelector("#temporary-query-text"); const temporaryQueryButton = document.querySelector("#temporary-query-button"); const temporaryExportButton = document.querySelector("#temporary-export-button"); const temporarySaveLinks = document.querySelector("#temporary-save-links"); const temporaryQueryResult = document.querySelector("#temporary-query-result"); const historyBulkButton = document.querySelector("#history-bulk-button"); const historyBulkBar = document.querySelector("#history-bulk-bar"); const historyCollectSelected = document.querySelector("#history-collect-selected"); const historyDeleteSelected = document.querySelector("#history-delete-selected"); const historyCancelBulk = document.querySelector("#history-cancel-bulk"); const filterBar = document.querySelector(".platform-filters"); const collectPlatformBox = document.querySelector(".collect-platforms"); const collectPlatformAll = document.querySelector("#collect-platform-all"); const aliasInput = document.querySelector("#alias-input"); const saveLibraryButton = document.querySelector("#save-library-button"); const libraryStatus = document.querySelector("#library-status"); const linkCandidates = document.querySelector("#link-candidates"); const trendCharts = document.querySelector("#trend-charts"); const compareList = document.querySelector("#compare-list"); const comparePlatform = document.querySelector("#compare-platform"); const compareRange = document.querySelector("#compare-range"); const compareChart = document.querySelector("#compare-chart"); const compareTable = document.querySelector("#compare-table"); const detailDialog = document.querySelector("#detail-dialog"); const detailTitle = document.querySelector("#detail-title"); const detailBody = document.querySelector("#detail-body"); const dashboardProgramCount = document.querySelector("#dashboard-program-count"); const dashboardLastCapture = document.querySelector("#dashboard-last-capture"); const dashboardPendingCount = document.querySelector("#dashboard-pending-count"); const taskQueuePanel = document.querySelector("#task-queue-panel"); const taskCurrent = document.querySelector("#task-current"); const taskRatio = document.querySelector("#task-ratio"); const taskProgressFill = document.querySelector("#task-progress-fill"); const taskOkCount = document.querySelector("#task-ok-count"); const taskMissingCount = document.querySelector("#task-missing-count"); const taskErrorCount = document.querySelector("#task-error-count"); const mobileSyncList = document.querySelector("#mobile-sync-list"); const dutyAutoRetry = document.querySelector("#duty-auto-retry"); const dutyAutoCollect = document.querySelector("#duty-auto-collect"); const dutyAutoExport = document.querySelector("#duty-auto-export"); const dutyRunTime = document.querySelector("#duty-run-time"); const dutySaveSettings = document.querySelector("#duty-save-settings"); const dutyRunNow = document.querySelector("#duty-run-now"); const dutyStatus = document.querySelector("#duty-status"); const appStatusPort = document.querySelector("#app-status-port"); const appStatusPortDock = document.querySelector("#app-status-port-dock"); const appStatusText = document.querySelector("#app-status-text"); const APP_BUILD_LABEL = "桌面开发版"; const VISIBLE_RECENT_RUNS = 12; const urlInputs = { tencent: document.querySelector("#url-tencent"), youku: document.querySelector("#url-youku"), iqiyi: document.querySelector("#url-iqiyi"), mgtv: document.querySelector("#url-mgtv"), }; const platformOrder = ["tencent", "youku", "iqiyi", "mgtv"]; const platformLabels = { tencent: "腾讯视频", youku: "优酷", iqiyi: "爱奇艺", mgtv: "芒果TV", }; const metricLabels = { tencent: "热度值", youku: "热度值", iqiyi: "内容热度", mgtv: "播放次数", }; let activeName = ""; let activeHistory = null; let selectedPlatforms = new Set(platformOrder); let collectPlatforms = new Set(platformOrder); let dirtyUrlInputs = new Set(); let programsCache = []; let historyBulkMode = false; let historyDeleteMode = false; let selectedHistoryPrograms = new Set(); let runBulkMode = false; let selectedRuns = new Set(); let showAllRuns = false; let compareNames = new Set(); let compareHistories = new Map(); let resolveTimer = 0; let resolveRequestId = 0; let temporaryQueryItems = []; let appStarted = false; for (const [platform, element] of Object.entries(urlInputs)) { element.addEventListener("input", () => { dirtyUrlInputs.add(platform); }); } input.addEventListener("input", () => { const name = input.value.trim(); if (activeName && name !== activeName) { clearUrlInputs(); } scheduleResolveLinks(name); }); form.addEventListener("submit", async (event) => { event.preventDefault(); const name = input.value.trim(); if (!name) return; activeName = name; const platforms = readCollectPlatforms(); if (platforms.length === 0) { setStatus("error", "请至少选择一个采集平台"); return; } setBusy(true, `正在采集《${name}》`); const summary = { total: 1, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 }; updateTaskQueue({ active: true, current: `正在采集:${name}`, completed: 0, total: 1, summary }); try { const currentUrls = readUrlInputs(); const missingSelectedUrl = platforms.some((platform) => !currentUrls[platform]); if (missingSelectedUrl) await resolveLinks(name); const payload = await postJson("/api/collect", { name, urls: readUrlInputs(), platforms }); countCollection(summary, payload.collection); updateTaskQueue({ active: true, current: `已完成:${name}`, completed: 1, total: 1, summary }); renderHistory(payload.history); await refreshPrograms(); setStatus("ok", `已新增 ${formatTime(payload.collection.captured_at)} 这一列`); } catch (error) { summary.error += 1; updateTaskQueue({ active: true, current: `采集失败:${name}`, completed: 1, total: 1, summary }); setStatus("error", error.message); } finally { setBusy(false); updateTaskQueue({ active: false, current: "采集任务完成", completed: 1, total: 1, summary }); } }); collectPlatformBox.addEventListener("change", (event) => { if (!event.target.matches("input[type='checkbox']")) return; collectPlatforms = new Set(readCollectPlatforms()); updateCollectPlatformState(); }); collectPlatformAll.addEventListener("click", () => { const shouldSelectAll = readCollectPlatforms().length !== platformOrder.length; for (const checkbox of collectPlatformBox.querySelectorAll("input[type='checkbox']")) { checkbox.checked = shouldSelectAll; } collectPlatforms = new Set(readCollectPlatforms()); updateCollectPlatformState(); }); saveLibraryButton.addEventListener("click", async () => { const name = input.value.trim() || activeName; if (!name) { setStatus("error", "请先输入节目名"); return; } saveLibraryButton.disabled = true; libraryStatus.textContent = "正在保存"; try { const payload = await postJson("/api/link-library", { name, aliases: aliasInput.value, urls: readAllUrlInputs(), }); syncLibraryInputs(payload.entry); setStatus("ok", `已保存《${name}》链接库`); libraryStatus.textContent = "已保存"; } catch (error) { setStatus("error", error.message); libraryStatus.textContent = "保存失败"; } finally { saveLibraryButton.disabled = false; } }); collectHistoryButton.addEventListener("click", async () => { const names = programsCache.map((program) => program.name).filter(Boolean); if (names.length === 0) { setStatus("error", "暂无历史节目可采集"); return; } const ok = window.confirm(`确定按当前勾选平台采集全部 ${names.length} 个历史节目吗?`); if (!ok) return; await collectHistoryPrograms(names); }); retryPendingButton.addEventListener("click", async () => { const platforms = readCollectPlatforms(); if (platforms.length === 0) { setStatus("error", "请至少选择一个复查平台"); return; } const ok = window.confirm("确定复查历史里未匹配、无指标、风控或错误的平台吗?\n只会重试这些失败平台,已正常的平台不会重复采集。"); if (!ok) return; setBulkBusy(true); setStatus("busy", "正在复查无数据平台"); try { const payload = await postJson("/api/retry-pending", { platforms }); const summary = { total: payload.items.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 }; let lastHistory = null; for (const item of payload.items) { lastHistory = item.history || lastHistory; countCollection(summary, item.collection); } await refreshPrograms(); if (activeName) { await loadHistory(activeName); } else if (lastHistory) { renderHistory(lastHistory); } setStatus("ok", pendingRetrySummaryText(summary)); } catch (error) { setStatus("error", error.message); } finally { setBulkBusy(false); } }); historyCollectSelected.addEventListener("click", async () => { const names = [...selectedHistoryPrograms]; if (names.length === 0) { setStatus("error", "请先勾选要采集的历史节目"); return; } const ok = window.confirm(`确定按当前勾选平台采集选中的 ${names.length} 个历史节目吗?`); if (!ok) return; await collectHistoryPrograms(names); }); temporaryQueryButton.addEventListener("click", async () => { const names = temporaryNames(); const platforms = readCollectPlatforms(); if (names.length === 0) { setStatus("error", "请先粘贴要临时查询的节目名"); return; } if (platforms.length === 0) { setStatus("error", "请至少选择一个查询平台"); return; } temporaryQueryButton.disabled = true; temporaryExportButton.disabled = true; temporaryQueryResult.classList.add("empty"); temporaryQueryResult.textContent = `正在临时查询 ${names.length} 个节目`; setStatus("busy", `正在临时查询 ${names.length} 个节目`); try { await runTemporaryQueryProgressively(names, platforms); setStatus("ok", `临时查询完成:${temporaryQueryItems.length} 个节目`); } catch (error) { setStatus("error", error.message); temporaryQueryResult.textContent = error.message; } finally { temporaryQueryButton.disabled = false; temporaryExportButton.disabled = temporaryQueryItems.length === 0; } }); temporaryFileInput.addEventListener("change", async () => { const file = temporaryFileInput.files?.[0]; if (!file) return; try { temporaryQueryText.value = await loadTemporaryImportFile(file); setStatus("ok", `已导入 ${file.name}`); } catch (error) { setStatus("error", `导入失败:${error.message}`); } finally { temporaryFileInput.value = ""; } }); temporaryQueryText.addEventListener("dragover", (event) => { event.preventDefault(); temporaryQueryText.classList.add("drag-over"); }); temporaryQueryText.addEventListener("dragleave", () => { temporaryQueryText.classList.remove("drag-over"); }); temporaryQueryText.addEventListener("drop", async (event) => { event.preventDefault(); temporaryQueryText.classList.remove("drag-over"); const file = event.dataTransfer?.files?.[0]; const text = event.dataTransfer?.getData("text/plain") || ""; try { temporaryQueryText.value = file ? await loadTemporaryImportFile(file) : normalizeTemporaryListText(text); setStatus("ok", file ? `已导入 ${file.name}` : "已导入拖入文本"); } catch (error) { setStatus("error", `导入失败:${error.message}`); } }); temporaryExportButton.addEventListener("click", () => { downloadTemporaryCsv(temporaryQueryItems); }); historyBulkButton.addEventListener("click", () => { if (historyBulkMode || historyDeleteMode || selectedHistoryPrograms.size) { clearHistorySelection(); return; } historyBulkMode = true; selectedHistoryPrograms = new Set(programsCache.map((program) => program.name)); renderPrograms(programsCache); }); historyCancelBulk.addEventListener("click", () => { clearHistorySelection(); }); historyDeleteSelected.addEventListener("click", async () => { if (!historyDeleteMode) { historyDeleteMode = true; renderPrograms(programsCache); return; } const names = [...selectedHistoryPrograms]; if (names.length === 0) { setStatus("error", "请先勾选要删除的历史节目"); return; } const ok = window.confirm(`确定彻底删除选中的 ${names.length} 个历史节目及其全部采集记录吗?`); if (!ok) return; const deleteLibrary = window.confirm("是否同时删除这些节目的链接库?\n确定:历史和链接库都删除\n取消:只删除历史记录"); setStatus("busy", `正在删除 ${names.length} 个历史节目`); historyDeleteSelected.disabled = true; try { const payload = await postJson("/api/delete-programs", { names, deleteLibrary }); for (const name of names) { compareNames.delete(name); compareHistories.delete(name); } if (names.includes(activeName)) { activeName = ""; activeHistory = payload.history; clearUrlInputs(); renderHistory(payload.history); input.value = ""; } historyBulkMode = false; historyDeleteMode = false; selectedHistoryPrograms.clear(); programsCache = payload.programs || []; renderPrograms(programsCache); renderCompareList(programsCache); await renderCompare(); setStatus("ok", deleteLibrary ? `已删除 ${names.length} 个节目历史和链接库` : `已删除 ${names.length} 个节目历史`); } catch (error) { setStatus("error", error.message); } finally { historyDeleteSelected.disabled = false; } }); programList.addEventListener("click", async (event) => { const checkbox = event.target.closest("[data-select-program]"); if (checkbox) { const name = checkbox.dataset.selectProgram; if (checkbox.checked) selectedHistoryPrograms.add(name); else selectedHistoryPrograms.delete(name); updateHistoryBulkBar(); return; } const deleteButton = event.target.closest("[data-delete-program]"); if (deleteButton) { const name = deleteButton.dataset.deleteProgram; if (!name) return; const ok = window.confirm(`确定删除《${name}》的全部历史记录吗?`); if (!ok) return; const deleteLibrary = window.confirm("是否同时删除这个节目的链接库?\n确定:历史和链接库都删除\n取消:只删除历史记录"); setStatus("busy", `正在删除《${name}》`); try { const payload = await postJson("/api/delete-program", { name, deleteLibrary }); if (activeName === name) { activeName = ""; activeHistory = payload.history; clearUrlInputs(); renderHistory(payload.history); input.value = ""; } programsCache = payload.programs || []; renderPrograms(programsCache); renderCompareList(programsCache); compareNames.delete(name); compareHistories.delete(name); await renderCompare(); setStatus("ok", deleteLibrary ? `已删除《${name}》历史和链接库` : `已删除《${name}》历史`); } catch (error) { setStatus("error", error.message); } return; } const item = event.target.closest("[data-name]"); if (!item) return; activeName = item.dataset.name; input.value = activeName; await loadHistory(activeName); }); filterBar.addEventListener("click", (event) => { const button = event.target.closest("[data-platform-filter]"); if (!button) return; const platform = button.dataset.platformFilter; if (platform === "all") { selectedPlatforms = new Set(platformOrder); } else if (selectedPlatforms.has(platform)) { selectedPlatforms.delete(platform); } else { selectedPlatforms.add(platform); } if (selectedPlatforms.size === 0) selectedPlatforms = new Set(platformOrder); syncFilterButtons(); if (activeHistory) renderHistory(activeHistory); }); table.addEventListener("click", async (event) => { const expandButton = event.target.closest("[data-expand-runs]"); if (expandButton) { showAllRuns = true; if (activeHistory) renderHistory(activeHistory); return; } const detailButton = event.target.closest("[data-detail-run]"); if (detailButton) { showDetail(detailButton.dataset.detailPlatform, detailButton.dataset.detailRun); return; } const checkbox = event.target.closest("[data-select-run]"); if (checkbox) { const run = checkbox.dataset.selectRun; if (checkbox.checked) selectedRuns.add(run); else selectedRuns.delete(run); updateRunBulkBar(); return; } const button = event.target.closest("[data-delete-run]"); if (!button || !activeHistory?.name) return; const run = button.dataset.deleteRun; const shownTime = formatTime(run); const ok = window.confirm(`确定删除《${activeHistory.name}》在 ${shownTime} 的整列数据吗?`); if (!ok) return; setStatus("busy", `正在删除 ${shownTime} 这一列`); try { const payload = await postJson("/api/delete-run", { name: activeHistory.name, run }); renderHistory(payload.history); await refreshPrograms(); setStatus("ok", `已删除 ${shownTime} 这一列`); } catch (error) { setStatus("error", error.message); } }); runBulkButton.addEventListener("click", () => { if (!activeHistory?.name || (activeHistory.runs || []).length === 0) { setStatus("error", "当前节目暂无可删除的时间列"); return; } runBulkMode = true; selectedRuns.clear(); renderHistory(activeHistory); }); runCancelBulk.addEventListener("click", () => { runBulkMode = false; selectedRuns.clear(); renderHistory(activeHistory); }); runCollapseToggle.addEventListener("click", () => { showAllRuns = !showAllRuns; if (activeHistory) renderHistory(activeHistory); }); runDeleteSelected.addEventListener("click", async () => { const runs = [...selectedRuns]; if (!activeHistory?.name) return; if (runs.length === 0) { setStatus("error", "请先勾选要删除的时间列"); return; } const ok = window.confirm(`确定删除《${activeHistory.name}》选中的 ${runs.length} 个时间列吗?`); if (!ok) return; setStatus("busy", `正在删除 ${runs.length} 个时间列`); runDeleteSelected.disabled = true; try { const payload = await postJson("/api/delete-runs", { name: activeHistory.name, runs }); runBulkMode = false; selectedRuns.clear(); renderHistory(payload.history); await refreshPrograms(); setStatus("ok", `已删除 ${runs.length} 个时间列`); } catch (error) { setStatus("error", error.message); } finally { runDeleteSelected.disabled = false; } }); detailDialog.addEventListener("click", async (event) => { const button = event.target.closest("[data-use-candidate-url]"); if (!button) return; const platform = button.dataset.useCandidatePlatform; const url = button.dataset.useCandidateUrl; const input = urlInputs[platform]; if (!input || !url) return; input.value = url; dirtyUrlInputs.add(platform); detailDialog.close(); setStatus("ok", `已填入${platformLabels[platform] || platform}链接,可保存链接库或直接重新采集`); }); linkCandidates.addEventListener("click", (event) => { const button = event.target.closest("[data-fill-platform]"); if (!button) return; const platform = button.dataset.fillPlatform; const url = button.dataset.fillUrl; fillPlatformUrl(platform, url, { manual: true }); setStatus("ok", `已采用${platformLabels[platform] || platform}候选链接`); }); compareList.addEventListener("change", async (event) => { const checkbox = event.target.closest("[data-compare-name]"); if (!checkbox) return; if (checkbox.checked) compareNames.add(checkbox.dataset.compareName); else compareNames.delete(checkbox.dataset.compareName); await renderCompare(); }); comparePlatform.addEventListener("change", () => { renderCompare(); }); compareRange.addEventListener("change", () => { renderCompare(); }); mobileSyncList?.addEventListener("click", (event) => { const button = event.target.closest("[data-mobile-sync-name]"); if (!button) return; input.value = button.dataset.mobileSyncName || ""; activeName = input.value.trim(); fillMobileSyncUrls(button.dataset.mobileSyncUrls || "{}"); scheduleResolveLinks(activeName); setStatus("ok", `已填入手机同步节目:${activeName}`); window.location.hash = "collect-form"; input.focus(); }); dutySaveSettings?.addEventListener("click", () => { saveDutySettings(); }); dutyRunNow?.addEventListener("click", () => { runDutyNow(); }); consumeRedirectedAccessToken(); initializeApp(); document.addEventListener("hotness:programs-changed", refreshPrograms); async function initializeApp() { if (!(await ensureAccessAuth())) return; startApp(); } function consumeRedirectedAccessToken() { const params = new URLSearchParams(window.location.search); const token = params.get("access_token"); if (!token) return; localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, token); params.delete("access_token"); const search = params.toString(); const cleanUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`; history.replaceState(null, "", cleanUrl || "/"); } function startApp() { if (appStarted) return; appStarted = true; updateCollectPlatformState(); renderAppStatusDock(); refreshPrograms(); loadMobileSyncDrafts(); loadDutySettings(); } async function loadHistory(name) { setStatus("busy", `正在读取《${name}》历史`); try { const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`); renderHistory(payload.history); await loadLinkLibrary(name); setStatus("ok", `已载入《${name}》`); } catch (error) { setStatus("error", error.message); } } async function refreshPrograms() { try { const payload = await getJson("/api/programs"); programsCache = payload.programs || []; renderPrograms(programsCache); renderCompareList(programsCache); renderDesktopDashboard(); } catch { programsCache = []; renderPrograms([]); renderCompareList([]); renderDesktopDashboard(); } } async function loadMobileSyncDrafts() { if (!mobileSyncList) return; try { const payload = await getJson("/api/mobile-sync"); renderMobileSyncDrafts(payload.items || []); } catch { renderMobileSyncDrafts([]); } } function renderMobileSyncDrafts(items) { if (!mobileSyncList) return; const pendingItems = (items || []).filter((item) => item.status !== "done"); if (pendingItems.length === 0) { mobileSyncList.classList.add("empty"); mobileSyncList.textContent = "暂无手机同步记录"; return; } mobileSyncList.classList.remove("empty"); mobileSyncList.innerHTML = pendingItems.slice(0, 30).map((item) => { const urls = item.urls || {}; const urlCount = Object.values(urls).filter(Boolean).length; const platformText = (item.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台"; const note = item.note ? `
${escapeHtml(item.note)}
` : ""; return `
${escapeHtml(item.name)} ${escapeHtml(item.device_name || "mobile")} · ${formatTime(item.received_at || item.created_at)}
${urlCount} 个链接 · ${escapeHtml(platformText)}
${note}
`; }).join(""); } function fillMobileSyncUrls(serializedUrls) { let urls = {}; try { urls = JSON.parse(serializedUrls); } catch { urls = {}; } dirtyUrlInputs.clear(); for (const platform of platformOrder) { const value = String(urls?.[platform] || "").trim(); urlInputs[platform].value = value; if (value) dirtyUrlInputs.add(platform); } } async function loadDutySettings() { if (!dutyStatus) return; try { const payload = await getJson("/api/duty-settings"); applyDutySettings(payload.settings || {}); renderDutyStatus(payload.status || null); } catch { dutyStatus.textContent = "值班设置读取失败"; } } async function saveDutySettings() { const settings = readDutySettingsForm(); dutySaveSettings.disabled = true; try { const payload = await postJson("/api/duty-settings", { settings }); applyDutySettings(payload.settings || settings); renderDutyStatus(payload.status || null); setStatus("ok", "已保存值班设置"); } catch (error) { setStatus("error", error.message); } finally { dutySaveSettings.disabled = false; } } async function runDutyNow() { dutyRunNow.disabled = true; dutyStatus.textContent = "正在执行值班任务"; try { const payload = await postJson("/api/duty-run", { settings: readDutySettingsForm() }); renderDutyStatus(payload.status || null); await refreshPrograms(); if (activeName) await loadHistory(activeName); setStatus("ok", "值班任务执行完成"); } catch (error) { dutyStatus.textContent = `值班执行失败:${error.message}`; setStatus("error", error.message); } finally { dutyRunNow.disabled = false; } } function readDutySettingsForm() { return { autoRetry: Boolean(dutyAutoRetry?.checked), autoCollect: Boolean(dutyAutoCollect?.checked), autoExport: Boolean(dutyAutoExport?.checked), runTime: dutyRunTime?.value || "09:30", }; } function applyDutySettings(settings) { dutyAutoRetry.checked = Boolean(settings.autoRetry); dutyAutoCollect.checked = Boolean(settings.autoCollect); dutyAutoExport.checked = Boolean(settings.autoExport); dutyRunTime.value = settings.runTime || "09:30"; } function renderDutyStatus(status) { if (!dutyStatus) return; if (!status?.last_run_at) { dutyStatus.textContent = "尚未执行值班任务"; return; } const exportText = status.export_path ? ` · 已导出 ${status.export_path}` : ""; dutyStatus.textContent = `上次执行 ${formatTime(status.last_run_at)} · 复查 ${status.retry_count || 0} · 采集 ${status.collect_count || 0}${exportText}`; } async function loadLinkLibrary(name) { try { const payload = await getJson(`/api/link-library?name=${encodeURIComponent(name)}`); syncLibraryInputs(payload.entry); } catch { syncLibraryInputs(null); } } function scheduleResolveLinks(name) { window.clearTimeout(resolveTimer); const cleanName = String(name || "").trim(); if (!cleanName) { renderLinkCandidates(null); return; } resolveTimer = window.setTimeout(() => { resolveLinks(cleanName); }, 650); } async function resolveLinks(name) { const requestId = ++resolveRequestId; libraryStatus.textContent = "正在自动搜索链接"; renderLinkCandidates({ loading: true }); try { const payload = await getJson(`/api/resolve-links?name=${encodeURIComponent(name)}`); if (requestId !== resolveRequestId || input.value.trim() !== name) return; for (const platform of platformOrder) { const result = payload.results?.[platform]; if (!result?.url) continue; fillPlatformUrl(platform, result.url, { manual: false }); } renderLinkCandidates(payload.results || {}); libraryStatus.textContent = "已自动匹配链接"; } catch (error) { if (requestId !== resolveRequestId) return; renderLinkCandidates(null); libraryStatus.textContent = "自动搜索失败"; setStatus("error", error.message); } } function fillPlatformUrl(platform, url, { manual }) { const input = urlInputs[platform]; if (!input || !url) return; if (!manual && dirtyUrlInputs.has(platform)) return; input.value = url; if (manual) dirtyUrlInputs.add(platform); } function renderLinkCandidates(results) { if (!linkCandidates) return; if (!results) { linkCandidates.innerHTML = ""; return; } if (results.loading) { linkCandidates.innerHTML = ""; return; } const matchedPlatforms = platformOrder.filter((platform) => (results[platform]?.candidates || []).length); if (matchedPlatforms.length === 0) { linkCandidates.innerHTML = ""; return; } linkCandidates.innerHTML = matchedPlatforms.map((platform) => { const result = results[platform]; const candidates = result?.candidates || []; return ` `; }).join(""); } function candidateSourceLabel(result) { if (!result?.url) return "未匹配"; return { builtin: "内置", library: "链接库", history: "历史", search: "搜索", }[result.source] || "已匹配"; } function renderPrograms(programs) { if (programs.length === 0) { programList.innerHTML = `
暂无历史
`; updateHistoryBulkBar(); return; } programList.innerHTML = programs.map((program) => `
${historyDeleteMode ? `` : ""}
`).join(""); updateHistoryBulkBar(); } function renderDesktopDashboard() { if (!dashboardProgramCount) return; dashboardProgramCount.textContent = String(programsCache.length || 0); const latest = programsCache.find((program) => program.updated_at)?.updated_at || ""; dashboardLastCapture.textContent = latest ? formatTime(latest) : "--"; dashboardPendingCount.textContent = activeHistory ? String(countPendingResults(activeHistory)) : "--"; } function countPendingResults(history) { let count = 0; for (const row of Object.values(history?.platforms || {})) { for (const value of Object.values(row?.values || {})) { if (["blocked", "no_match", "no_metric", "error"].includes(value?.status)) count += 1; } } return count; } function updateTaskQueue({ active = false, current = "", completed = 0, total = 0, summary = null } = {}) { if (!taskQueuePanel) return; const safeTotal = Math.max(0, Number(total) || 0); const safeCompleted = Math.min(Math.max(0, Number(completed) || 0), safeTotal); const percent = safeTotal ? Math.round((safeCompleted / safeTotal) * 100) : 0; taskQueuePanel.classList.toggle("idle", !active && safeCompleted === 0); taskCurrent.textContent = current || (active ? "正在准备采集任务" : "暂无运行中的采集任务"); taskRatio.textContent = `${safeCompleted}/${safeTotal}`; taskProgressFill.style.width = `${percent}%`; taskOkCount.textContent = String(summary?.ok || 0); taskMissingCount.textContent = String((summary?.no_match || 0) + (summary?.no_metric || 0)); taskErrorCount.textContent = String((summary?.blocked || 0) + (summary?.error || 0)); } async function collectHistoryPrograms(names) { const platforms = readCollectPlatforms(); if (platforms.length === 0) { setStatus("error", "请至少选择一个采集平台"); return; } setBulkBusy(true); const summary = { total: names.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 }; let lastHistory = null; updateTaskQueue({ active: true, current: "正在准备历史节目采集", completed: 0, total: names.length, summary }); try { for (const [index, name] of names.entries()) { updateTaskQueue({ active: true, current: `正在采集:${name}`, completed: index, total: names.length, summary }); setStatus("busy", `正在采集 ${index + 1}/${names.length}:《${name}》`); try { const payload = await postJson("/api/collect", { name, urls: {}, platforms }); lastHistory = payload.history; countCollection(summary, payload.collection); if (activeName === name) renderHistory(payload.history); } catch { summary.error += 1; } updateTaskQueue({ active: true, current: `已完成:${name}`, completed: index + 1, total: names.length, summary }); } await refreshPrograms(); if (activeName) { await loadHistory(activeName); } else if (lastHistory) { renderHistory(lastHistory); } setStatus("ok", bulkSummaryText(summary)); } finally { setBulkBusy(false); updateTaskQueue({ active: false, current: "采集任务完成", completed: names.length, total: names.length, summary }); } } function setBulkBusy(isBusy) { collectHistoryButton.disabled = isBusy; retryPendingButton.disabled = isBusy; historyBulkButton.disabled = isBusy; historyCollectSelected.disabled = isBusy; historyDeleteSelected.disabled = isBusy; runBulkButton.disabled = isBusy; button.disabled = isBusy; } function updateRunBulkBar() { if (!runBulkBar) return; const hasRuns = Boolean(activeHistory?.name && (activeHistory.runs || []).length); runBulkButton.hidden = runBulkMode || !hasRuns; runBulkBar.hidden = !runBulkMode; runDeleteSelected.textContent = selectedRuns.size ? `删除选中列(${selectedRuns.size})` : "删除选中列"; } function updateHistoryBulkBar() { if (!historyBulkBar) return; historyBulkMode = selectedHistoryPrograms.size > 0 || historyDeleteMode; historyBulkBar.hidden = !historyBulkMode; historyBulkButton.hidden = false; historyBulkButton.textContent = historyBulkMode ? "取消选择" : "批量选择"; historyCollectSelected.textContent = selectedHistoryPrograms.size ? `采集选中(${selectedHistoryPrograms.size})` : "采集选中"; historyDeleteSelected.textContent = historyDeleteMode ? (selectedHistoryPrograms.size ? `确认删除(${selectedHistoryPrograms.size})` : "确认删除") : (selectedHistoryPrograms.size ? `删除选中(${selectedHistoryPrograms.size})` : "删除选中"); } function clearHistorySelection() { historyBulkMode = false; historyDeleteMode = false; selectedHistoryPrograms.clear(); renderPrograms(programsCache); } function countCollection(summary, collection) { const results = collection?.results || []; if (results.some((result) => result.status === "ok")) summary.ok += 1; for (const result of results) { if (result.status === "blocked") summary.blocked += 1; else if (result.status === "no_match") summary.no_match += 1; else if (result.status === "no_metric") summary.no_metric += 1; else if (result.status === "error") summary.error += 1; } } function bulkSummaryText(summary) { const issues = [ summary.blocked ? `风控 ${summary.blocked}` : "", summary.no_match ? `未匹配 ${summary.no_match}` : "", summary.no_metric ? `无指标 ${summary.no_metric}` : "", summary.error ? `错误 ${summary.error}` : "", ].filter(Boolean).join(","); return `历史节目采集完成:${summary.ok}/${summary.total} 个节目有有效数据${issues ? `;${issues}` : ""}`; } function pendingRetrySummaryText(summary) { if (summary.total === 0) return "没有需要复查的无数据平台"; const issues = [ summary.blocked ? `仍被风控 ${summary.blocked}` : "", summary.no_match ? `仍未匹配 ${summary.no_match}` : "", summary.no_metric ? `仍无指标 ${summary.no_metric}` : "", summary.error ? `错误 ${summary.error}` : "", ].filter(Boolean).join(","); return `复查完成:${summary.ok}/${summary.total} 个节目恢复有效数据${issues ? `;${issues}` : ""}`; } function temporaryNames() { return [...new Set(normalizeTemporaryListText(temporaryQueryText.value) .split(/[\r\n]+/) .map((name) => name.trim()) .filter(Boolean))]; } async function runTemporaryQueryProgressively(names, platforms) { temporaryQueryItems = names.map((name) => ({ name, collection: { name, captured_at: new Date().toISOString(), results: [], }, })); renderTemporaryResults(temporaryQueryItems); const tasks = temporaryQueryTasks(names, platforms); const summary = { total: tasks.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 }; let completed = 0; updateTaskQueue({ active: true, current: "正在准备临时查询", completed, total: tasks.length, summary }); await clientMapLimit(tasks, 6, async (task) => { let payload = null; updateTaskQueue({ active: true, current: `临时查询:${task.name} / ${platformLabels[task.platform] || task.platform}`, completed, total: tasks.length, summary }); try { payload = await postJson("/api/query-once", { names: [task.name], platforms: [task.platform], saveLinks: temporarySaveLinks.checked, }); mergeTemporaryQueryResult(task.name, payload.items?.[0], task.platform); } catch (error) { mergeTemporaryQueryResult(task.name, { collection: { captured_at: new Date().toISOString(), results: [temporaryPlatformError(task, error)], }, }, task.platform); } finally { completed += 1; Object.assign(summary, summarizeTemporaryTaskQueue()); renderTemporaryResults(temporaryQueryItems); temporaryExportButton.disabled = temporaryRows(temporaryQueryItems).length === 0; updateTaskQueue({ active: true, current: `临时查询进度 ${completed}/${tasks.length}`, completed, total: tasks.length, summary }); setStatus("busy", `临时查询进度 ${completed}/${tasks.length}`); } }); updateTaskQueue({ active: false, current: "临时查询完成", completed, total: tasks.length, summary }); } function temporaryQueryTasks(names, platforms) { return names.flatMap((name) => platforms.map((platform) => ({ name, platform }))); } function summarizeTemporaryTaskQueue() { const summary = { ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 }; for (const item of temporaryQueryItems) { for (const result of item.collection?.results || []) { if (result.status === "ok") summary.ok += 1; else if (result.status === "blocked") summary.blocked += 1; else if (result.status === "no_match") summary.no_match += 1; else if (result.status === "no_metric") summary.no_metric += 1; else if (result.status === "error") summary.error += 1; } } return summary; } function mergeTemporaryQueryResult(name, item, platform) { const target = temporaryQueryItems.find((entry) => entry.name === name); if (!target) return; const incoming = item?.collection?.results?.[0] || temporaryPlatformError({ name, platform }, new Error("没有返回结果")); const results = (target.collection.results || []).filter((result) => result.platform !== platform); target.collection = { ...target.collection, captured_at: item?.collection?.captured_at || target.collection.captured_at, results: [...results, incoming].sort((left, right) => platformOrder.indexOf(left.platform) - platformOrder.indexOf(right.platform), ), }; } function temporaryPlatformError(task, error) { return { platform: task.platform, platform_label: platformLabels[task.platform] || task.platform, metric_label: metricLabels[task.platform] || "", name: task.name, url: "", page_title: "", hotness_raw: "", hotness_number: "", unit: "", confidence: "", evidence: "", status: "error", fetched_at: new Date().toISOString(), error: error?.message || "query failed", }; } async function clientMapLimit(items, limit, worker) { let nextIndex = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; await worker(items[index], index); } }); await Promise.all(workers); } async function loadTemporaryImportFile(file) { if (!file) return ""; const name = String(file.name || "").toLowerCase(); const type = String(file.type || "").toLowerCase(); if (type.startsWith("image/")) { const payload = await postJson("/api/temporary-ocr", { filename: file.name, type: file.type, data: await readFileAsDataUrl(file), }); return normalizeTemporaryListText(payload.text || ""); } if (name.endsWith(".xlsx") || type.includes("spreadsheetml")) { return normalizeTemporaryListText(await extractXlsxText(await file.arrayBuffer())); } return normalizeTemporaryListText(await file.text()); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error("读取截图失败")); reader.readAsDataURL(file); }); } function normalizeTemporaryListText(text) { const seen = new Set(); const names = []; for (const line of String(text || "").split(/[\r\n]+/)) { const cells = splitTemporaryListLine(line); const value = (cells.find((cell) => cell.trim()) || "").trim(); if (!value || isTemporaryListHeader(value) || seen.has(value)) continue; seen.add(value); names.push(value); } return names.join("\n"); } function splitTemporaryListLine(line) { const text = String(line || "").trim(); if (text.includes("\t")) return text.split("\t"); if (!text.includes(",")) return [text]; const cells = []; let current = ""; let quoted = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (char === "\"") { if (quoted && text[index + 1] === "\"") { current += "\""; index += 1; } else { quoted = !quoted; } } else if (char === "," && !quoted) { cells.push(current); current = ""; } else { current += char; } } cells.push(current); return cells; } function isTemporaryListHeader(value) { return new Set(["节目", "节目名", "节目名称", "片名", "名称", "name", "program", "title"]).has( String(value || "").trim().toLowerCase(), ); } async function extractXlsxText(arrayBuffer) { const entries = await unzipXlsxEntries(arrayBuffer); const sharedStrings = parseSharedStrings(entries.get("xl/sharedStrings.xml") || ""); const worksheetName = [...entries.keys()].find((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name)); if (!worksheetName) throw new Error("没有在 Excel 中找到工作表"); const names = parseWorksheetNames(entries.get(worksheetName), sharedStrings); if (names.length === 0) throw new Error("没有在 Excel 第一列找到节目名"); return names.join("\n"); } async function unzipXlsxEntries(arrayBuffer) { const bytes = new Uint8Array(arrayBuffer); const view = new DataView(arrayBuffer); const decoder = new TextDecoder("utf-8"); const eocdOffset = findZipEndOfCentralDirectory(view); if (eocdOffset < 0) throw new Error("无法识别 Excel 文件"); const entryCount = view.getUint16(eocdOffset + 10, true); let offset = view.getUint32(eocdOffset + 16, true); const entries = new Map(); for (let index = 0; index < entryCount; index += 1) { if (view.getUint32(offset, true) !== 0x02014b50) throw new Error("Excel 文件结构异常"); const method = view.getUint16(offset + 10, true); const compressedSize = view.getUint32(offset + 20, true); const nameLength = view.getUint16(offset + 28, true); const extraLength = view.getUint16(offset + 30, true); const commentLength = view.getUint16(offset + 32, true); const localOffset = view.getUint32(offset + 42, true); const path = decoder.decode(bytes.slice(offset + 46, offset + 46 + nameLength)); const content = await readZipEntry(bytes, view, localOffset, compressedSize, method, decoder); entries.set(path, content); offset += 46 + nameLength + extraLength + commentLength; } return entries; } function findZipEndOfCentralDirectory(view) { for (let offset = view.byteLength - 22; offset >= 0; offset -= 1) { if (view.getUint32(offset, true) === 0x06054b50) return offset; } return -1; } async function readZipEntry(bytes, view, offset, compressedSize, method, decoder) { if (view.getUint32(offset, true) !== 0x04034b50) throw new Error("Excel 文件内容异常"); const nameLength = view.getUint16(offset + 26, true); const extraLength = view.getUint16(offset + 28, true); const start = offset + 30 + nameLength + extraLength; const compressed = bytes.slice(start, start + compressedSize); if (method === 0) return decoder.decode(compressed); if (method !== 8) throw new Error("暂不支持这个 Excel 压缩格式"); if (typeof DecompressionStream !== "function") { throw new Error("当前浏览器不支持直接读取 xlsx,请另存为 CSV 后导入。"); } const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream("deflate-raw")); return decoder.decode(await new Response(stream).arrayBuffer()); } function parseSharedStrings(xml) { if (!xml) return []; const doc = new DOMParser().parseFromString(xml, "application/xml"); return [...doc.getElementsByTagName("si")].map((item) => [...item.getElementsByTagName("t")].map((node) => node.textContent || "").join("").trim(), ); } function parseWorksheetNames(xml, sharedStrings) { const doc = new DOMParser().parseFromString(xml || "", "application/xml"); const names = []; for (const row of doc.getElementsByTagName("row")) { const cells = [...row.getElementsByTagName("c")].map((cell) => worksheetCellText(cell, sharedStrings)); const value = (cells.find(Boolean) || "").trim(); if (value && !isTemporaryListHeader(value)) names.push(value); } return [...new Set(names)]; } function worksheetCellText(cell, sharedStrings) { const type = cell.getAttribute("t"); if (type === "inlineStr") { return [...cell.getElementsByTagName("t")].map((node) => node.textContent || "").join("").trim(); } const value = cell.getElementsByTagName("v")[0]?.textContent?.trim() || ""; if (type === "s") return sharedStrings[Number(value)] || ""; return value; } function renderTemporaryResults(items) { const rows = temporaryRows(items); if (rows.length === 0) { temporaryQueryResult.classList.add("empty"); temporaryQueryResult.textContent = "暂无临时查询结果"; return; } temporaryQueryResult.classList.remove("empty"); temporaryQueryResult.innerHTML = `
${rows.map((row) => ` `).join("")}
节目 平台 指标 数值 状态 节目页
${escapeHtml(row.name)} ${escapeHtml(row.platform_label)} ${escapeHtml(row.metric_label)} ${escapeHtml(row.value)} ${escapeHtml(row.status_label)} ${row.url ? `打开` : ""}
`; } function temporaryRows(items) { return (items || []).flatMap((item) => (item.collection?.results || []).map((result) => ({ name: item.name || result.name || "", platform: result.platform || "", platform_label: result.platform_label || platformLabels[result.platform] || result.platform || "", metric_label: result.metric_label || metricLabels[result.platform] || "", value: result.status === "ok" ? (result.hotness_raw || result.hotness_number || "") : "", hotness_raw: result.hotness_raw || "", hotness_number: result.hotness_number || "", unit: result.unit || "", status: result.status || "", status_label: statusLabel(result.status), confidence: result.confidence || "", credibility: result.credibility?.label || "", page_title: result.page_title || "", evidence: result.evidence || "", error: result.error || "", url: result.url || "", fetched_at: result.fetched_at || item.collection?.captured_at || "", }))); } function downloadTemporaryCsv(items) { const rows = temporaryRows(items); if (rows.length === 0) return; const headers = [ "节目", "平台", "指标", "数值", "状态", "可信度", "节目页", "页面标题", "采集时间", "错误", "证据", ]; const csv = [ headers, ...rows.map((row) => [ row.name, row.platform_label, row.metric_label, row.value, row.status_label, row.credibility, row.url, row.page_title, row.fetched_at, row.error, row.evidence, ]), ].map((row) => row.map(csvEscape).join(",")).join("\n"); const blob = new Blob([`\ufeff${csv}\n`], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `临时查询-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function csvEscape(value) { const text = String(value ?? ""); if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, "\"\"")}"`; return text; } function renderHistory(history) { activeHistory = history; renderDesktopDashboard(); const runs = history.runs || []; const visibleRuns = visibleRunsForTable(runs); const collapsedCount = hiddenRunCount(runs); tableTitle.textContent = history.name ? `《${history.name}》` : "还没有采集结果"; runCount.textContent = `${runs.length} 次`; exportLink.href = history.name ? `/api/export?name=${encodeURIComponent(history.name)}` : "#"; exportLink.setAttribute("aria-disabled", history.name && runs.length > 0 ? "false" : "true"); syncUrlInputs(history); updateRunCollapseControls(runs); updateRunBulkBar(); if (runs.length === 0) { runBulkMode = false; selectedRuns.clear(); updateRunBulkBar(); table.querySelector("thead").innerHTML = ""; table.querySelector("tbody").innerHTML = `暂无采集结果`; renderTrendCharts(history); return; } table.querySelector("thead").innerHTML = ` 平台 指标口径 节目页 ${collapsedCount > 0 ? `${collapsedCount} 个旧列已隐藏` : ""} ${visibleRuns.map((run) => ` ${runBulkMode ? `` : ""} ${formatTime(run)} ${runBulkMode ? "" : ``} `).join("")} `; table.querySelector("tbody").innerHTML = platformOrder.filter((platform) => selectedPlatforms.has(platform)).map((platform) => { const row = history.platforms?.[platform] || { values: {} }; return ` ${escapeHtml(row.platform_label || platformLabels[platform] || platform)} ${renderMetricCell(row, platform)} ${renderUrl(row.url)} ${collapsedCount > 0 ? `` : ""} ${visibleRuns.map((run) => renderValueCell(row.values?.[run], platform, run)).join("")} `; }).join(""); renderTrendCharts(history); } function visibleRunsForTable(runs) { if (showAllRuns || runBulkMode) return runs; if (runs.length <= VISIBLE_RECENT_RUNS) return runs; return runs.slice(-VISIBLE_RECENT_RUNS); } function hiddenRunCount(runs) { if (showAllRuns || runBulkMode) return 0; return Math.max(0, runs.length - VISIBLE_RECENT_RUNS); } function updateRunCollapseControls(runs) { const hiddenCount = Math.max(0, runs.length - VISIBLE_RECENT_RUNS); if (hiddenCount === 0 || runBulkMode) { runCollapseToggle.hidden = true; runCollapseNote.textContent = ""; return; } runCollapseToggle.hidden = false; runCollapseToggle.textContent = showAllRuns ? "收起旧列" : "展开旧列"; runCollapseNote.textContent = showAllRuns ? `已展开全部 ${runs.length} 次` : `默认显示最近 ${VISIBLE_RECENT_RUNS} 次,隐藏 ${hiddenCount} 个旧列`; } function syncFilterButtons() { for (const button of filterBar.querySelectorAll("[data-platform-filter]")) { const platform = button.dataset.platformFilter; const active = platform === "all" ? selectedPlatforms.size === platformOrder.length : selectedPlatforms.has(platform); button.classList.toggle("active", active); } } function syncUrlInputs(history) { for (const platform of platformOrder) { const input = urlInputs[platform]; if (!input) continue; input.value = history.platforms?.[platform]?.url || ""; } dirtyUrlInputs.clear(); } function syncLibraryInputs(entry) { aliasInput.value = (entry?.aliases || []).join(","); libraryStatus.textContent = entry?.source === "library" ? "已载入链接库" : (entry?.source === "builtin" ? "已载入内置链接" : ""); for (const platform of platformOrder) { const input = urlInputs[platform]; if (!input || input.value.trim()) continue; input.value = entry?.urls?.[platform] || ""; } dirtyUrlInputs.clear(); } function readUrlInputs() { return Object.fromEntries(platformOrder .map((platform) => [ platform, urlInputs[platform]?.value.trim() || "", ]) .filter(([, url]) => url)); } function readAllUrlInputs() { return Object.fromEntries(platformOrder.map((platform) => [ platform, urlInputs[platform]?.value.trim() || "", ])); } function readCollectPlatforms() { return [...collectPlatformBox.querySelectorAll("input[type='checkbox']:checked")] .map((checkbox) => checkbox.value) .filter((platform) => platformOrder.includes(platform)); } function updateCollectPlatformState() { const selected = readCollectPlatforms(); collectPlatformAll.textContent = selected.length === platformOrder.length ? "取消全选" : "全选"; collectPlatformAll.classList.toggle("warn", selected.length === 0); for (const label of collectPlatformBox.querySelectorAll("label")) { const checkbox = label.querySelector("input"); label.classList.toggle("active", checkbox.checked); } } function clearUrlInputs() { for (const input of Object.values(urlInputs)) { input.value = ""; } aliasInput.value = ""; libraryStatus.textContent = ""; renderLinkCandidates(null); dirtyUrlInputs.clear(); } function renderMetricCell(row, platform) { const label = row.metric_label || metricLabels[platform] || "指标值"; const description = row.metric_description || ""; return ` ${escapeHtml(shortMetricLabel(label))} `; } function shortMetricLabel(label) { if (label.includes("播放")) return "播放数"; if (label.includes("内容")) return "内容热"; if (label.includes("热度")) return "热度值"; return label.length > 5 ? `${label.slice(0, 5)}...` : label; } function renderValueCellLegacy(value, platform, run) { if (!value) return `未采集`; const detailTitle = [value.page_title, value.evidence].filter(Boolean).join("\n"); const anomalyBadge = value.anomaly ? `异常` : ""; const credibilityBadge = renderCredibilityBadge(value.credibility); const detailButton = ``; if (value.status === "ok") { const shown = value.raw || value.number || ""; const meta = value.number && String(value.number) !== String(value.raw) ? value.number : "ok"; return ` ${escapeHtml(shown)} ${anomalyBadge} ${credibilityBadge} ${detailButton} `; } const tone = value.status === "blocked" ? "status-warn" : "status-bad"; const statusText = { no_match: "未找到", no_metric: "无指标", blocked: "被拦截", error: "抓取错", }[value.status] || value.status || "error"; const fullReason = [ value.page_title ? `页面标题:${value.page_title}` : "", value.credibility?.reason ? `可信度:${value.credibility.reason}` : "", value.error ? `错误:${value.error}` : "", ].filter(Boolean).join("\n"); return ` ${escapeHtml(statusText)} ${credibilityBadge} ${detailButton} `; } function renderValueCell(value, platform, run) { const display = compactValueDisplay(value); return ` ${escapeHtml(display.text)} `; } function compactValueDisplay(value) { if (!value) { return { text: "无", className: "short-status muted", reason: "本次未选择该平台,或历史中没有这一列数据", }; } const reason = valueTooltip(value); if (value.status === "ok") { const shown = value.raw || value.number || ""; return { text: shown ? String(shown) : "无", className: shown ? "heat-value status-ok" : "short-status muted", reason, }; } return { text: "无", className: `short-status ${value.status === "blocked" ? "status-warn" : "status-bad"}`, reason, }; } function valueTooltip(value) { return [ value.status ? `状态:${statusLabel(value.status)}` : "", value.page_title ? `页面标题:${value.page_title}` : "", value.credibility?.label ? `可信度:${value.credibility.label}` : "", value.credibility?.reason ? `说明:${value.credibility.reason}` : "", value.error ? `错误:${value.error}` : "", value.anomaly?.message ? `异常:${value.anomaly.message}` : "", value.evidence ? `证据:${value.evidence}` : "", ].filter(Boolean).join("\n") || "无可用数据"; } function statusLabel(status) { return { ok: "已抓取", no_match: "未找到", no_metric: "无指标", blocked: "被拒绝", error: "抓取错误", }[status] || status || "未知"; } function renderUrl(url) { if (!url) return `未匹配`; return `打开节目页`; } function showDetail(platform, run) { const row = activeHistory?.platforms?.[platform]; const value = row?.values?.[run]; if (!value) return; detailTitle.textContent = `${row.platform_label || platformLabels[platform] || platform} · ${formatTime(run)}`; const candidates = value.search_candidates || []; detailBody.innerHTML = ` ${detailLine("状态", value.status || "")} ${detailLine("指标", row.metric_label || metricLabels[platform] || "")} ${detailLine("指标口径", row.metric_description || "")} ${detailLine("可信度", value.credibility?.label || "")} ${detailLine("可信度说明", value.credibility?.reason || "")} ${detailLine("数值", value.raw || value.number || "")} ${detailLine("页面标题", value.page_title || "")} ${detailLine("节目页", value.url || row.url || "", true)} ${detailLine("搜索页", value.search_url || "", true)} ${detailLine("证据", value.evidence || "")} ${value.anomaly ? detailLine("异常提示", value.anomaly.message || "") : ""} ${detailLine("错误", value.error || "")} ${candidates.length ? `
搜索候选
    ${candidates.slice(0, 5).map((candidate) => `
  1. ${escapeHtml(candidate.evidence || "")}
  2. `).join("")}
` : ""} `; detailDialog.showModal(); } function renderCredibilityBadge(credibility) { if (!credibility?.label) return ""; return `${escapeHtml(shortCredibilityLabel(credibility.label))}`; } function shortCredibilityLabel(label) { return { "高可信": "高信", "中可信": "中信", "低可信": "低信", "已确认节目页": "确认", "拒绝": "拒绝", }[label] || label; } function detailLine(label, value, asLink = false) { if (!value) return ""; const content = asLink ? `${escapeHtml(value)}` : escapeHtml(value); return `
${escapeHtml(label)}
${content}
`; } function renderTrendCharts(history) { const runs = history?.runs || []; if (!history?.name || runs.length === 0) { trendCharts.innerHTML = `
暂无趋势
`; return; } trendCharts.innerHTML = platformOrder .filter((platform) => selectedPlatforms.has(platform)) .map((platform) => renderPlatformTrend(platform, history.platforms?.[platform], runs)) .join(""); } function renderPlatformTrend(platform, row, runs) { const points = runs.map((run, index) => { const value = row?.values?.[run]; const number = value?.status === "ok" ? Number(value.number) : NaN; return Number.isFinite(number) ? { index, run, number, raw: value.raw || String(number) } : null; }).filter(Boolean); if (points.length === 0) { return `
${escapeHtml(row?.platform_label || platformLabels[platform] || platform)}
暂无有效数据
`; } return `
${escapeHtml(row?.platform_label || platformLabels[platform] || platform)}
${lineSvg(points)}
最新:${escapeHtml(points[points.length - 1].raw)}
`; } function lineSvg(points) { const width = 320; const height = 120; const pad = 18; const min = Math.min(...points.map((point) => point.number)); const max = Math.max(...points.map((point) => point.number)); const span = max - min || 1; const lastIndex = Math.max(...points.map((point) => point.index), 1); const coordinates = points.map((point) => { const x = pad + (point.index / lastIndex) * (width - pad * 2); const y = height - pad - ((point.number - min) / span) * (height - pad * 2); return `${round(x)},${round(y)}`; }).join(" "); return ` ${points.map((point) => { const [x, y] = coordinates.split(" ")[points.indexOf(point)].split(","); return `${escapeHtml(formatTime(point.run))} ${escapeHtml(point.raw)}`; }).join("")} `; } function renderCompareList(programs) { if (!compareList) return; compareList.innerHTML = programs.length ? programs.map((program) => ` `).join("") : `
暂无历史节目
`; } async function renderCompare() { try { const names = [...compareNames]; if (names.length === 0) { compareChart.className = "compare-chart empty"; compareChart.textContent = "选择节目后显示对比"; compareTable.innerHTML = ""; return; } for (const name of names) { if (compareHistories.has(name)) continue; const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`); compareHistories.set(name, payload.history); } const platform = comparePlatform.value; const range = compareRange.value; const histories = names.map((name) => compareHistories.get(name)).filter(Boolean); const sourceSeries = histories.map((history) => comparePlatformSeries(history, platform)).filter((item) => item.points.length); const series = filterCompareSeriesByRange(sourceSeries, range).filter((item) => item.points.length); compareChart.className = "compare-chart compare-line-chart"; compareChart.innerHTML = renderCompareLineChart(series); compareTable.innerHTML = ""; } catch (error) { setStatus("error", error.message); } } function latestPlatformValue(history, platform) { const row = history?.platforms?.[platform]; const runs = [...(history?.runs || [])].reverse(); const latestRun = runs.find((run) => row?.values?.[run]); const value = latestRun ? row.values[latestRun] : null; return { name: history?.name || "", run: latestRun || "", status: value?.status || "未采集", raw: value?.status === "ok" ? (value.raw || value.number || "") : "", number: value?.status === "ok" ? Number(value.number) : NaN, }; } function filterCompareSeriesByRange(series, range) { if (range === "all") return series; const allPoints = series.flatMap((item) => item.points).filter((point) => Number.isFinite(point.time)); if (allPoints.length === 0) return series; const latestTime = Math.max(...allPoints.map((point) => point.time)); const cutoff = compareRangeCutoff(latestTime, range); return series.map((item) => ({ ...item, points: item.points.filter((point) => point.time >= cutoff && point.time <= latestTime), })); } function compareRangeCutoff(latestTime, range) { if (range === "today") { const start = new Date(latestTime); start.setHours(0, 0, 0, 0); return start.getTime(); } const days = range === "3d" ? 3 : 7; return latestTime - days * 24 * 60 * 60 * 1000; } function comparePlatformSeries(history, platform) { const row = history?.platforms?.[platform]; const points = (history?.runs || []) .map((run) => { const value = row?.values?.[run]; const number = value?.status === "ok" ? Number(value.number) : NaN; if (!Number.isFinite(number)) return null; return { run, time: new Date(run).getTime(), number, raw: value.raw || String(value.number || ""), }; }) .filter(Boolean) .sort((a, b) => a.time - b.time); return { name: history?.name || "", points, }; } function renderCompareLineChart(series) { const validSeries = series.filter((item) => item.points.length); const allPoints = validSeries.flatMap((item) => item.points); if (allPoints.length === 0) return `
所选节目在该平台暂无有效数据
`; const width = 920; const height = 188; const pad = { left: 44, right: 18, top: 22, bottom: 30 }; const times = allPoints.map((point) => point.time).filter(Number.isFinite); const minTime = Math.min(...times); const maxTime = Math.max(...times); const { minValue, maxValue } = buildCompareValueDomain(allPoints); const colors = ["#0f766e", "#2563eb", "#b45309", "#7c3aed", "#dc2626", "#0891b2", "#4d7c0f", "#be185d"]; const x = (time) => { if (maxTime === minTime) return (pad.left + width - pad.right) / 2; return pad.left + ((time - minTime) / (maxTime - minTime)) * (width - pad.left - pad.right); }; const y = (value) => height - pad.bottom - ((value - minValue) / (maxValue - minValue)) * (height - pad.top - pad.bottom); const yTicks = [minValue, (minValue + maxValue) / 2, maxValue].map(round); const timeTicks = buildCompareTimeTicks(times, 10); const timeLabelsUseTimeOnly = compareTimesOnSameDay(timeTicks); const grid = yTicks.map((tick) => { const yy = y(tick); return `${escapeHtml(formatCompactNumber(tick))}`; }).join(""); const lines = validSeries.map((item, index) => { const color = colors[index % colors.length]; const path = item.points.map((point) => `${round(x(point.time))},${round(y(point.number))}`).join(" "); const labeledPointIndexes = buildCompareLabelIndexes(item.points, 13); const circles = item.points.map((point, pointIndex) => { const cx = round(x(point.time)); const cy = round(y(point.number)); const labelY = Math.max(7, Math.min(height - pad.bottom - 3, cy + (index % 2 === 0 ? -4 : 8))); const labelAnchor = pointIndex === item.points.length - 1 ? "end" : "middle"; const label = labeledPointIndexes.has(pointIndex) ? `${escapeHtml(formatCompactNumber(point.number))}` : ""; return ` ${escapeHtml(`${item.name} ${formatShortDate(point.time)} ${point.raw}`)} ${label} `; }).join(""); return ` ${item.points.length > 1 ? `` : ""} ${circles} `; }).join(""); const legend = validSeries.map((item, index) => ` ${escapeHtml(item.name)} `).join(""); const timeAxis = timeTicks.map((time, index) => { const xx = round(x(time)); const anchor = index === 0 ? "start" : (index === timeTicks.length - 1 ? "end" : "middle"); const label = formatCompareTimeLabel(time, timeLabelsUseTimeOnly); return ` ${escapeHtml(label.primary)} ${label.secondary ? `${escapeHtml(label.secondary)}` : ""} `; }).join(""); return ` ${grid} ${timeAxis} ${lines}
${legend}
`; } function buildCompareLabelIndexes(points, maxLabels = 13) { if (points.length <= maxLabels) return new Set(points.map((_, index) => index)); const indexes = new Set(); const lastIndex = points.length - 1; for (let i = 0; i < maxLabels; i += 1) { indexes.add(Math.round((i / (maxLabels - 1)) * lastIndex)); } return indexes; } function buildCompareValueDomain(points) { const values = points.map((point) => point.number).filter(Number.isFinite); const min = Math.min(...values); const max = Math.max(...values); const span = max - min || Math.max(Math.abs(max) * 0.02, 1); const padding = span * 0.12; return { minValue: Math.max(0, min - padding), maxValue: max + padding, }; } function buildCompareTimeTicks(times, maxTicks = 10) { const uniqueTimes = [...new Set(times.filter(Number.isFinite))].sort((a, b) => a - b); if (uniqueTimes.length <= 2) return uniqueTimes; const minTime = uniqueTimes[0]; const maxTime = uniqueTimes[uniqueTimes.length - 1]; const minimumGap = 92; const minGapMs = ((maxTime - minTime) * minimumGap) / 920; let ticks = [minTime]; for (const time of uniqueTimes.slice(1, -1)) { const last = ticks[ticks.length - 1]; if (time - last >= minGapMs && maxTime - time >= minGapMs * 0.65) ticks.push(time); } ticks.push(maxTime); if (ticks.length <= maxTicks) return ticks; const sampled = []; const lastIndex = ticks.length - 1; for (let i = 0; i < maxTicks; i += 1) { sampled.push(ticks[Math.round((i / (maxTicks - 1)) * lastIndex)]); } return [...new Set(sampled)].sort((a, b) => a - b); } function compareTimesOnSameDay(times) { if (!times.length) return false; const first = new Date(times[0]); return times.every((time) => { const date = new Date(time); return date.getFullYear() === first.getFullYear() && date.getMonth() === first.getMonth() && date.getDate() === first.getDate(); }); } function formatCompareTimeLabel(value, timeOnly = false) { const date = new Date(value); if (Number.isNaN(date.getTime())) return { primary: "", secondary: "" }; const monthDay = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; const hourMinute = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; return timeOnly ? { primary: hourMinute, secondary: "" } : { primary: monthDay, secondary: hourMinute }; } function formatShortDate(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; } function formatCompactNumber(value) { const number = Number(value); if (!Number.isFinite(number)) return ""; if (Math.abs(number) >= 100_000_000) return `${round(number / 100_000_000)}亿`; if (Math.abs(number) >= 10_000) return `${round(number / 10_000)}万`; return String(round(number)); } async function getJson(url) { const response = await fetch(url, { headers: authHeaders() }); const payload = await response.json(); if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码"); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); return payload; } async function postJson(url, body) { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...authHeaders() }, body: JSON.stringify(body), }); const payload = await response.json(); if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码"); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); return payload; } async function ensureAccessAuth() { try { const response = await fetch("/api/auth/status", { headers: authHeaders() }); const payload = await response.json(); if (!payload.enabled || payload.authorized) { return true; } } catch {} redirectToLogin(); return false; } function authHeaders() { const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || ""; return token ? { "x-hotness-auth-token": token } : {}; } function handleAuthFailure(response, payload) { if (response.status !== 401 || !payload?.requires_auth) return false; localStorage.removeItem(HOTNESS_AUTH_TOKEN_KEY); redirectToLogin(); return true; } function redirectToLogin() { window.location.href = "/"; } function setBusy(isBusy, text = "") { button.disabled = isBusy; if (isBusy) setStatus("busy", text); } function setStatus(type, text) { statusDot.className = `dot ${type}`; statusText.textContent = text; if (appStatusText) appStatusText.textContent = text; } function renderAppStatusDock() { const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); if (appStatusPort) appStatusPort.textContent = port; if (appStatusPortDock) appStatusPortDock.textContent = port; } 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 round(value) { return Math.round(value * 100) / 100; } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escapeAttribute(value) { return escapeHtml(value).replace(/`/g, "`"); }