const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1";
const authGate = document.querySelector("#auth-gate");
const authPassword = document.querySelector("#auth-password");
const authMessage = document.querySelector("#auth-message");
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();
});
initializeApp();
document.addEventListener("hotness:programs-changed", refreshPrograms);
async function initializeApp() {
if (!(await ensureAccessAuth())) return;
startApp();
}
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 `
${escapeHtml(platformLabels[platform] || platform)}
${escapeHtml(candidateSourceLabel(result))}
${candidates.slice(0, 4).map((candidate, index) => `
`).join("")}
`;
}).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) => `
| ${escapeHtml(row.name)} |
${escapeHtml(row.platform_label)} |
${escapeHtml(row.metric_label)} |
${escapeHtml(row.value)} |
${escapeHtml(row.status_label)} |
${row.url ? `打开` : ""} |
`).join("")}
`;
}
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) => `
-
${escapeHtml(candidate.evidence || "")}
`).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 `
`;
}
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 `
${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) {
hideAuthGate();
return true;
}
} catch {}
showAuthGate("");
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);
showAuthGate(payload.error || "需要输入访问密码");
return true;
}
function showAuthGate(message = "") {
if (!authGate) return;
authGate.hidden = false;
setAuthMessage(message);
requestAnimationFrame(() => authPassword?.focus());
}
function hideAuthGate() {
if (authGate) authGate.hidden = true;
setAuthMessage("");
}
function setAuthMessage(message) {
if (authMessage) authMessage.textContent = message || "";
}
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, "`");
}