commit 4e04c6f86410043e44bd1659799cf2423fb0b319 Author: Codex Date: Thu May 14 18:53:53 2026 +0800 Initial video hotness app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d98420 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.hotness-server.json +.hotness-webview-server.json +server.out.log +*.log +node_modules/ +dist/ +release/ +data/ +runtime/ +vendor/ diff --git a/Microsoft.Web.WebView2.Core.dll b/Microsoft.Web.WebView2.Core.dll new file mode 100644 index 0000000..4a27877 Binary files /dev/null and b/Microsoft.Web.WebView2.Core.dll differ diff --git a/Microsoft.Web.WebView2.WinForms.dll b/Microsoft.Web.WebView2.WinForms.dll new file mode 100644 index 0000000..21caf04 Binary files /dev/null and b/Microsoft.Web.WebView2.WinForms.dll differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c62d4c --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# 节目热度采集工具 + +这是独立窗口版桌面 App。团队日常只需要打开一个 exe,不需要命令行、npm、VBS 或浏览器启动脚本。 + +## 日常使用 + +打开: + +```text +节目热度采集工具-独立窗口版.exe +``` + +关闭: + +在 App 窗口菜单里点 `工具` -> `退出后台`,或在右下角托盘图标里点 `退出后台`。 + +注意:直接点窗口右上角关闭时,工具会隐藏到后台,方便半自动值班继续运行。 + +## 桌面快捷方式 + +双击: + +```text +安装桌面App到桌面(只需一次).vbs +``` + +它会在桌面生成 `节目热度采集工具` 快捷方式,指向 `节目热度采集工具-独立窗口版.exe`。 + +## 开机自启动 + +值班电脑需要自动运行时,双击: + +```text +开启节目热度采集工具开机自启动.exe +``` + +取消自动启动时,双击: + +```text +取消节目热度采集工具开机自启动.exe +``` + +## 手机访问 + +电脑端启动后,在独立窗口菜单里点 `工具` -> `打开手机页`。 + +手机和电脑在同一局域网时,也可以访问电脑端显示的手机地址。 + +## 必要文件 + +这些文件和文件夹不要删除: + +- `节目热度采集工具-独立窗口版.exe` +- `Microsoft.Web.WebView2.Core.dll` +- `Microsoft.Web.WebView2.WinForms.dll` +- `WebView2Loader.dll` +- `runtime/` +- `src/` +- `public/` +- `data/` + +## 重新生成 exe + +如需重新生成独立窗口版和自启动 helper,双击: + +```text +生成独立启动器exe(无npm版).cmd +``` + +## 验证 + +```powershell +.\runtime\node.exe --test +.\runtime\node.exe --check public\app.js +.\runtime\node.exe --check src\server.js +``` diff --git a/WebView2Loader.dll b/WebView2Loader.dll new file mode 100644 index 0000000..983ee32 Binary files /dev/null and b/WebView2Loader.dll differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..920b5fa --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "video-hotness-scraper", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Desktop app for collecting public hotness values from Chinese video program pages.", + "scripts": { + "scrape": "node src/index.js", + "serve": "node src/server.js", + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..963a23c --- /dev/null +++ b/public/app.js @@ -0,0 +1,2236 @@ +const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1"; +const authGate = document.querySelector("#auth-gate"); +const authForm = document.querySelector("#auth-form"); +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(); +}); + +authForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + await submitAccessPassword(); +}); + +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 ` + + `; + }).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) { + hideAuthGate(); + return true; + } + } catch {} + showAuthGate(""); + return false; +} + +async function submitAccessPassword() { + const password = authPassword?.value || ""; + if (!password.trim()) { + showAuthGate("请输入访问密码"); + return; + } + setAuthMessage("正在验证..."); + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "访问密码不正确"); + if (payload.token) localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, payload.token); + if (authPassword) authPassword.value = ""; + hideAuthGate(); + startApp(); + } catch (error) { + showAuthGate(error.message || "访问密码不正确"); + } +} + +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, "`"); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9c8ad46 --- /dev/null +++ b/public/index.html @@ -0,0 +1,277 @@ + + + + + + 节目热度采集 + + + + + + +
+
+
+
+

节目热度采集

+

腾讯视频、优酷、爱奇艺、芒果TV

+
+
+ + +
+
+ +
+ +
+ + 等待输入节目名 +
+ +
+
+
+
任务队列
+
暂无运行中的采集任务
+
+
0/0
+
+ +
+ 有效 0 + 未找到/无指标 0 + 风控/错误 0 +
+
+ +
+
+
+
手机同步待处理
+
手机端同步过来的节目先放这里,不会自动写入历史数据。
+
+
+
暂无手机同步记录
+
+ +
+
+
+
半自动值班
+
减少每天重复操作:复查无数据、采集历史、导出 CSV、备份数据。
+
+ +
+
+ + + + + +
+
尚未执行值班任务
+
+ +
+ + +
+
+
还没有采集结果
+
+ +
0 次
+
+
+ +
+ + + + + +
+
+ + +
+
+ + + +
+
+
+
+
趋势图
+
每个平台独立刻度
+
+
+
+ +
+
+
历史节目
+
0
+
已建立采集档案
+
+
+
最近采集
+
--
+
来自历史库更新时间
+
+
+
当前节目待复查
+
--
+
未匹配、无指标、风控或错误
+
+
+
快捷入口
+ +
+
+ +
+
+
节目对比
+
+ + +
+
+
+
选择节目后显示对比
+
+
+
+
+
+ +
+
+
+
临时查询
+
只查这一次,不写入历史;可单独导出 CSV
+
+
+ + + + +
+
+ +
暂无临时查询结果
+
+
+ + +
+
+ 采集详情 + +
+
+
+
+ + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..78386b9 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "节目热度采集", + "short_name": "热度采集", + "start_url": "/mobile.html", + "display": "standalone", + "background_color": "#f5f7f8", + "theme_color": "#0f766e", + "icons": [] +} diff --git a/public/mobile-sw.js b/public/mobile-sw.js new file mode 100644 index 0000000..292db07 --- /dev/null +++ b/public/mobile-sw.js @@ -0,0 +1,52 @@ +const CACHE_NAME = "video-hotness-mobile-offline-v1"; +const APP_SHELL = [ + "/mobile.html", + "/mobile.css", + "/mobile.js", + "/manifest.webmanifest", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)), + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)), + )), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname === "/" || APP_SHELL.includes(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + + if (url.pathname.startsWith("/api/")) { + event.respondWith(fetch(request)); + } +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + return response; + } catch { + return caches.match("/mobile.html"); + } +} diff --git a/public/mobile.css b/public/mobile.css new file mode 100644 index 0000000..74476fb --- /dev/null +++ b/public/mobile.css @@ -0,0 +1,728 @@ +:root { + color-scheme: light; + --bg: #f5f7f8; + --panel: #ffffff; + --text: #17202a; + --muted: #687586; + --line: #d9e1e8; + --accent: #0f766e; + --accent-soft: #e5f4f2; + --ok: #16794c; + --warn: #9a640f; + --bad: #b42318; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; + font-size: 15px; + line-height: 1.45; +} + +.auth-gate { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 18px; + background: rgba(245, 247, 248, 0.97); +} + +.auth-card { + width: min(420px, 100%); + display: grid; + gap: 12px; + padding: 20px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 12px 32px rgba(30, 41, 59, 0.12); +} + +.auth-title { + font-size: 20px; + font-weight: 900; +} + +.auth-card p { + margin: 0; + color: var(--muted); +} + +.auth-card input, +.auth-card button { + width: 100%; + min-height: 44px; + border-radius: 6px; +} + +.auth-card button { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-weight: 800; +} + +.auth-message { + min-height: 20px; + color: var(--bad); + font-weight: 700; +} + +.mobile-shell { + min-height: 100vh; + padding: max(14px, env(safe-area-inset-top)) 14px max(18px, env(safe-area-inset-bottom)); +} + +.mobile-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +h1 { + margin: 0; + font-size: 22px; + letter-spacing: 0; +} + +.mobile-header p { + margin: 2px 0 0; + color: var(--muted); +} + +.desktop-link, +.secondary { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; + background: var(--panel); + color: var(--accent); + font-weight: 700; + text-decoration: none; + white-space: nowrap; +} + +.collect-panel, +.notice, +.network, +.offline-panel, +.device-panel, +.app-settings-panel, +.batch-panel, +.history-strip, +.results { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + margin-bottom: 10px; +} + +.device-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.batch-panel { + display: grid; + gap: 10px; +} + +.app-settings-panel { + display: grid; + gap: 10px; +} + +.settings-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; +} + +.settings-head p { + margin: 2px 0 0; + color: var(--muted); + font-size: 13px; +} + +.app-state { + min-width: 54px; + border: 1px solid rgba(22, 121, 76, 0.24); + border-radius: 999px; + padding: 3px 9px; + background: #ecfdf3; + color: var(--ok); + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.app-state.offline { + border-color: rgba(154, 100, 15, 0.32); + background: #fff8eb; + color: var(--warn); +} + +.setting-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.binding-summary { + border-radius: 8px; + padding: 8px 10px; + background: #f8fafc; + color: var(--muted); + font-size: 13px; +} + +#mobile-batch-text { + width: 100%; + min-height: 116px; + resize: vertical; +} + +.field { + display: grid; + gap: 6px; +} + +.field span, +.section-title, +.network-title { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 11px; + color: var(--text); + font: inherit; + outline: none; +} + +input { + height: 42px; +} + +textarea { + min-height: 72px; + padding-top: 9px; + resize: vertical; +} + +input:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16); +} + +.note-field { + margin-top: 10px; +} + +.url-box { + margin-top: 10px; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.url-box summary { + color: var(--accent); + font-weight: 700; + cursor: pointer; +} + +.url-fields { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.collect-platforms { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.collect-platforms label { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 32px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 10px; + background: #fff; + color: var(--muted); +} + +.collect-platforms label.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent); +} + +.collect-platforms input { + width: auto; + height: auto; +} + +.actions { + display: grid; + grid-template-columns: 1fr 118px 92px; + gap: 10px; + margin-top: 12px; +} + +button { + height: 44px; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 800; +} + +.secondary-button { + border-color: var(--line); + background: #fff; + color: var(--accent); +} + +button:disabled, +.secondary[aria-disabled="true"] { + opacity: 0.55; + pointer-events: none; +} + +.notice { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.offline-status, +.install-hint { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 10px; + color: var(--muted); + font-size: 13px; +} + +.install-hint { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.install-hint strong, +.install-hint span { + display: block; +} + +.install-hint strong { + color: var(--accent); + font-size: 14px; +} + +.install-hint.install-ready { + border-color: rgba(15, 118, 110, 0.34); + background: #f2fbf8; +} + +#install-app-button { + min-width: 74px; +} + +.offline-status { + display: grid; + gap: 3px; + border-color: rgba(15, 118, 110, 0.28); + background: #f2fbf8; +} + +.offline-status strong { + color: var(--accent); + font-size: 14px; +} + +.offline-status.offline { + border-color: rgba(154, 100, 15, 0.32); + background: #fff8eb; +} + +.offline-status.offline strong { + color: var(--warn); +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #94a3b8; + flex: 0 0 auto; +} + +.dot.busy { + background: var(--warn); +} + +.dot.ok { + background: var(--ok); +} + +.dot.error { + background: var(--bad); +} + +.network { + display: grid; + gap: 8px; +} + +.network a { + color: var(--accent); + word-break: break-all; +} + +.network-help { + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.network-help summary { + color: var(--accent); + font-weight: 800; +} + +.network-help p { + margin: 8px 0 0; + color: var(--muted); + font-size: 13px; +} + +.offline-panel { + display: grid; + gap: 10px; +} + +.offline-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.offline-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +#offline-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 28px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); +} + +.offline-list { + display: grid; + gap: 8px; +} + +.offline-item { + display: grid; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + background: #fff; +} + +.offline-item strong { + font-size: 15px; +} + +.offline-item.synced { + background: #f8fafc; +} + +.offline-title { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.sync-status { + display: inline-flex; + align-items: center; + min-height: 22px; + border-radius: 999px; + padding: 0 8px; + background: #fff7ed; + color: var(--warn); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.sync-status.synced { + background: #e7f6ec; + color: var(--ok); +} + +.offline-meta { + color: var(--muted); + font-size: 12px; + word-break: break-word; +} + +.offline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.offline-actions button { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 10px; + background: #fff; + color: var(--accent); + font: inherit; + font-size: 13px; + font-weight: 800; +} + +.draft-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.program-list { + display: flex; + gap: 8px; + overflow-x: auto; + padding-top: 8px; +} + +.program-item { + min-height: 36px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 12px; + background: #fff; + color: var(--text); + font: inherit; + white-space: nowrap; +} + +.program-item.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); + font-weight: 800; +} + +.result-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + margin-bottom: 10px; +} + +.result-title { + min-width: 0; + font-size: 18px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-count { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.cards { + display: grid; + gap: 10px; +} + +.platform-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + background: #fff; +} + +.platform-row { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.platform-name { + font-weight: 800; +} + +.metric { + color: var(--muted); + font-size: 13px; +} + +.metric-help { + margin-top: 2px; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.latest-value { + margin-top: 8px; + font-size: 28px; + font-weight: 900; + color: var(--ok); + letter-spacing: 0; +} + +.latest-value.warn { + color: var(--warn); + font-size: 18px; +} + +.latest-value.bad { + color: var(--bad); + font-size: 18px; +} + +.meta { + margin-top: 2px; + color: var(--muted); + font-size: 12px; + word-break: break-word; +} + +.anomaly-badge { + display: inline-flex; + margin-left: 6px; + border-radius: 5px; + padding: 0 5px; + background: #fff3dc; + color: var(--warn); + font-size: 12px; + vertical-align: middle; +} + +.credibility-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + margin-top: 6px; + border-radius: 5px; + padding: 0 6px; + background: #edf6ff; + color: #175cd3; + font-size: 12px; + font-weight: 800; +} + +.credibility-badge.high { + background: #e7f6ec; + color: var(--ok); +} + +.credibility-badge.medium { + background: #edf6ff; + color: #175cd3; +} + +.credibility-badge.low { + background: #fff3dc; + color: var(--warn); +} + +.credibility-badge.rejected { + background: #fff1f0; + color: var(--bad); +} + +.mini-history { + display: grid; + gap: 6px; + margin-top: 10px; + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.mini-row { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.mini-row strong { + color: var(--text); +} + +.open-link { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.empty { + color: var(--muted); + padding: 22px 4px; + text-align: center; +} diff --git a/public/mobile.html b/public/mobile.html new file mode 100644 index 0000000..17eb887 --- /dev/null +++ b/public/mobile.html @@ -0,0 +1,156 @@ + + + + + + + 热度采集手机版 + + + + + +
+
+
+

节目热度采集

+

移动录入版

+
+ 桌面版 +
+ +
+ + +
+ +
+
+
+
手机 App 设置
+

绑定电脑或 NAS 地址后,离开局域网再回来也能快速同步。

+
+ 检测中 +
+ +
+ + +
+
尚未绑定固定地址,默认使用当前打开页面。
+
+ +
+ + +
+ 节目页 URL(可选,自动找不到时填写) +
+ + + + +
+
+ +
+ 本次采集 + + + + +
+ + + +
+ + + 导出 CSV +
+
+ +
+ + 等待输入节目名 +
+ +
+ 离线录入 + 首次在局域网打开后,会缓存手机版;之后可离线打开并保存待同步。 +
+ +
+
+ 安装到手机桌面 + 可在手机浏览器菜单中选择“添加到主屏幕”,下次不在局域网也能直接打开录入。 +
+ +
+ +
+
手机访问地址
+ +
+ 不在同一 WiFi 怎么办 +

手机连电脑热点最简单;长期使用可以电脑和手机都装 Tailscale;临时外网访问可以用内网穿透转发 3000 端口。

+
+
+ +
+
+
+
手机待同步
+

手机用流量时先存这里,回到局域网后再同步到电脑。

+
+ 0 +
+
+
+ + +
+
+ +
+
批量离线录入
+ + +
+ +
+
历史节目
+
+
+ +
+
+
还没有采集结果
+
0 次
+
+
+
+
+ + + diff --git a/public/mobile.js b/public/mobile.js new file mode 100644 index 0000000..e45f689 --- /dev/null +++ b/public/mobile.js @@ -0,0 +1,829 @@ +const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1"; +const authGate = document.querySelector("#auth-gate"); +const authForm = document.querySelector("#auth-form"); +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 cards = document.querySelector("#cards"); +const programList = document.querySelector("#program-list"); +const networkLinks = document.querySelector("#network-links"); +const collectPlatformBox = document.querySelector(".collect-platforms"); +const mobileNote = document.querySelector("#mobile-note"); +const mobileDeviceNameInput = document.querySelector("#mobile-device-name"); +const saveDeviceNameButton = document.querySelector("#save-device-name-button"); +const saveOfflineButton = document.querySelector("#save-offline-button"); +const offlineCount = document.querySelector("#offline-count"); +const offlineList = document.querySelector("#offline-list"); +const clearOfflineButton = document.querySelector("#clear-offline-button"); +const syncOfflineButton = document.querySelector("#sync-offline-button"); +const mobileBatchText = document.querySelector("#mobile-batch-text"); +const saveBatchOfflineButton = document.querySelector("#save-batch-offline-button"); +const offlineStatus = document.querySelector("#offline-status"); +const installHint = document.querySelector("#install-hint"); +const installStatus = document.querySelector("#install-status"); +const installAppButton = document.querySelector("#install-app-button"); +const mobileServerUrlInput = document.querySelector("#mobile-server-url"); +const saveMobileServerButton = document.querySelector("#save-mobile-server-button"); +const testMobileServerButton = document.querySelector("#test-mobile-server-button"); +const mobileBindingSummary = document.querySelector("#mobile-binding-summary"); +const mobileAppState = document.querySelector("#mobile-app-state"); + +const MOBILE_DRAFTS_KEY = "video-hotness-mobile-drafts-v1"; +const MOBILE_DEVICE_KEY = "video-hotness-mobile-device-v1"; +const MOBILE_SERVER_KEY = "video-hotness-mobile-server-v1"; +const platformOrder = ["tencent", "youku", "iqiyi", "mgtv"]; +const platformLabels = { + tencent: "腾讯视频", + youku: "优酷", + iqiyi: "爱奇艺", + mgtv: "芒果TV", +}; +const metricLabels = { + tencent: "热度值", + youku: "热度值", + iqiyi: "内容热度", + mgtv: "播放次数", +}; +const urlInputs = { + tencent: document.querySelector("#url-tencent"), + youku: document.querySelector("#url-youku"), + iqiyi: document.querySelector("#url-iqiyi"), + mgtv: document.querySelector("#url-mgtv"), +}; + +let activeName = ""; +let dirtyUrlInputs = new Set(); +let deferredInstallPrompt = null; +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(); + } +}); + +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}》`); + + try { + const payload = await postJson("/api/collect", { name, urls: readUrlInputs(), platforms }); + renderHistory(payload.history); + await refreshPrograms(); + setStatus("ok", `已新增 ${formatTime(payload.collection.captured_at)} 这一列`); + } catch (error) { + setStatus("error", error.message); + } finally { + setBusy(false); + } +}); + +collectPlatformBox.addEventListener("change", (event) => { + if (!event.target.matches("input[type='checkbox']")) return; + updateCollectPlatformState(); +}); + +saveOfflineButton.addEventListener("click", () => { + saveOfflineDraft(); +}); + +saveDeviceNameButton.addEventListener("click", () => { + saveMobileDeviceName(); +}); + +saveMobileServerButton.addEventListener("click", () => { + saveMobileServerUrl(); +}); + +testMobileServerButton.addEventListener("click", () => { + testMobileServerConnection(); +}); + +installAppButton.addEventListener("click", () => { + installMobileApp(); +}); + +saveBatchOfflineButton.addEventListener("click", () => { + saveBatchOfflineDrafts(); +}); + +syncOfflineButton.addEventListener("click", () => { + syncOfflineDrafts(); +}); + +clearOfflineButton.addEventListener("click", () => { + const drafts = readOfflineDrafts(); + if (drafts.length === 0) return; + if (!window.confirm(`确定清空 ${drafts.length} 条手机待同步记录吗?`)) return; + localStorage.setItem(MOBILE_DRAFTS_KEY, "[]"); + renderOfflineDrafts(); + setStatus("ok", "已清空手机待同步列表"); +}); + +offlineList.addEventListener("click", (event) => { + const editButton = event.target.closest("[data-edit-draft]"); + if (editButton) { + editOfflineDraft(editButton.dataset.editDraft); + return; + } + + const deleteButton = event.target.closest("[data-delete-draft]"); + if (deleteButton) { + deleteOfflineDraft(deleteButton.dataset.deleteDraft); + } +}); + +programList.addEventListener("click", async (event) => { + const item = event.target.closest("[data-name]"); + if (!item) return; + activeName = item.dataset.name; + input.value = activeName; + await loadHistory(activeName); +}); + +window.addEventListener("online", updateOfflineStatus); +window.addEventListener("offline", updateOfflineStatus); +window.addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + deferredInstallPrompt = event; + updateInstallPrompt("ready"); +}); +window.addEventListener("appinstalled", () => { + deferredInstallPrompt = null; + updateInstallPrompt("installed"); +}); + +authForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + await submitAccessPassword(); +}); + +initializeApp(); + +async function initializeApp() { + if (!(await ensureAccessAuth())) return; + startApp(); +} + +async function startApp() { + if (appStarted) return; + appStarted = true; + updateCollectPlatformState(); + mobileDeviceNameInput.value = mobileDeviceName(); + mobileServerUrlInput.value = mobileServerBaseUrl(); + registerMobileServiceWorker(); + updateInstallPrompt(isStandaloneDisplay() ? "installed" : "manual"); + updateOfflineStatus(); + updateMobileBindingSummary(); + renderOfflineDrafts(); + await Promise.all([refreshPrograms(), loadNetworkLinks()]); +} + +async function registerMobileServiceWorker() { + if (!("serviceWorker" in navigator)) { + if (installStatus) installStatus.textContent = "当前浏览器不支持离线缓存,可继续使用手机待同步列表。"; + return; + } + try { + await navigator.serviceWorker.register("/mobile-sw.js"); + } catch { + if (installStatus) installStatus.textContent = "离线缓存注册失败,可刷新后重试。"; + } +} + +async function installMobileApp() { + if (!deferredInstallPrompt) { + updateInstallPrompt("manual"); + return; + } + installAppButton.disabled = true; + deferredInstallPrompt.prompt(); + const choice = await deferredInstallPrompt.userChoice.catch(() => ({ outcome: "dismissed" })); + deferredInstallPrompt = null; + installAppButton.disabled = false; + updateInstallPrompt(choice.outcome === "accepted" ? "installed" : "manual"); +} + +function updateInstallPrompt(state) { + if (!installStatus || !installAppButton) return; + installHint.classList.toggle("install-ready", state === "ready"); + if (state === "ready") { + installStatus.textContent = "当前浏览器支持直接安装,点击按钮后会添加到手机桌面。"; + installAppButton.hidden = false; + return; + } + installAppButton.hidden = true; + installStatus.textContent = state === "installed" + ? "已用 App 模式打开;离线录入和待同步列表可继续使用。" + : "如果没有安装按钮,请在手机浏览器菜单选择“添加到主屏幕”。"; +} + +function isStandaloneDisplay() { + return window.matchMedia?.("(display-mode: standalone)").matches || window.navigator.standalone === true; +} + +function updateOfflineStatus() { + if (!offlineStatus) return; + const online = navigator.onLine; + const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced"); + if (mobileAppState) { + mobileAppState.textContent = online ? "在线" : "离线"; + mobileAppState.classList.toggle("offline", !online); + } + offlineStatus.classList.toggle("offline", !online); + offlineStatus.innerHTML = online && pendingDrafts.length + ? `有 ${pendingDrafts.length} 条可同步电脑可访问时点击“同步到电脑”,同步后会显示电脑已收到。` + : online + ? `离线录入已准备首次打开后会缓存手机版;离开局域网时仍可保存待同步。` + : `当前离线可以继续录入并保存待同步;回到局域网后再同步到电脑。`; +} + +async function loadHistory(name) { + setStatus("busy", `正在读取《${name}》历史`); + try { + const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`); + renderHistory(payload.history); + setStatus("ok", `已载入《${name}》`); + } catch (error) { + setStatus("error", error.message); + } +} + +async function refreshPrograms() { + try { + const payload = await getJson("/api/programs"); + renderPrograms(payload.programs || []); + } catch { + renderPrograms([]); + } +} + +async function loadNetworkLinks() { + try { + const payload = await getJson("/api/network"); + const urls = payload.urls || []; + networkLinks.innerHTML = urls.length + ? urls.map((url) => `${escapeHtml(url)}`).join("
") + : "没有读取到局域网地址,可先用本机浏览器访问。"; + } catch { + networkLinks.textContent = "局域网地址读取失败。"; + } +} + +function mobileServerBaseUrl() { + const saved = (localStorage.getItem(MOBILE_SERVER_KEY) || "").trim(); + return saved || window.location.origin; +} + +function normalizeServerUrl(value) { + const text = String(value || "").trim().replace(/\/+$/, ""); + if (!text) return ""; + try { + const parsed = new URL(text); + return parsed.origin; + } catch { + return ""; + } +} + +function apiUrl(path) { + return `${mobileServerBaseUrl()}${path}`; +} + +function saveMobileServerUrl() { + const normalized = normalizeServerUrl(mobileServerUrlInput.value); + if (!normalized) { + setStatus("error", "请输入正确的电脑或 NAS 地址,例如 http://192.168.18.120:3001"); + return; + } + localStorage.setItem(MOBILE_SERVER_KEY, normalized); + mobileServerUrlInput.value = normalized; + updateMobileBindingSummary(); + setStatus("ok", `已绑定地址:${normalized}`); +} + +async function testMobileServerConnection() { + const normalized = normalizeServerUrl(mobileServerUrlInput.value) || mobileServerBaseUrl(); + if (normalized !== mobileServerBaseUrl()) { + localStorage.setItem(MOBILE_SERVER_KEY, normalized); + mobileServerUrlInput.value = normalized; + } + testMobileServerButton.disabled = true; + setStatus("busy", "正在测试电脑端连接"); + try { + const payload = await getJson("/api/network"); + updateMobileBindingSummary({ ok: true }); + setStatus("ok", `连接正常,读取到 ${(payload.urls || []).length} 个手机访问地址`); + } catch (error) { + updateMobileBindingSummary({ ok: false, error: error.message }); + setStatus("error", `连接失败:${error.message}`); + } finally { + testMobileServerButton.disabled = false; + } +} + +function updateMobileBindingSummary(result = null) { + const base = mobileServerBaseUrl(); + const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced").length; + const resultText = result?.ok ? " · 连接正常" : result?.error ? ` · 连接失败:${result.error}` : ""; + mobileBindingSummary.textContent = `当前绑定:${base} · 待同步 ${pendingDrafts} 条${resultText}`; +} + +function renderPrograms(programs) { + if (programs.length === 0) { + programList.innerHTML = `
暂无历史
`; + return; + } + + programList.innerHTML = programs.map((program) => ` + + `).join(""); +} + +function saveOfflineDraft() { + const name = input.value.trim(); + if (!name) { + setStatus("error", "请先输入节目名"); + return; + } + + const draft = createOfflineDraft({ + name, + note: mobileNote.value.trim(), + urls: readAllUrlInputs(), + platforms: readCollectPlatforms(), + }); + + const drafts = readOfflineDrafts(); + drafts.unshift(draft); + localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.slice(0, 200))); + renderOfflineDrafts(); + updateOfflineStatus(); + updateMobileBindingSummary(); + setStatus("ok", `已保存《${name}》到手机待同步`); +} + +function saveBatchOfflineDrafts() { + const names = parseMobileBatchNames(mobileBatchText.value); + if (names.length === 0) { + setStatus("error", "请先粘贴节目名单"); + return; + } + + const drafts = readOfflineDrafts(); + const newDrafts = names.map((name) => createOfflineDraft({ + name, + note: "批量离线录入", + urls: {}, + platforms: readCollectPlatforms(), + })); + localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify([...newDrafts, ...drafts].slice(0, 200))); + mobileBatchText.value = ""; + renderOfflineDrafts(); + updateOfflineStatus(); + updateMobileBindingSummary(); + setStatus("ok", `已批量保存 ${newDrafts.length} 条待同步`); +} + +function parseMobileBatchNames(text) { + const seen = new Set(); + const names = []; + for (const line of String(text || "").split(/\r?\n/)) { + const name = line.split(/[,,\t]/)[0].trim(); + if (!name || seen.has(name) || /节目|名称|片名/.test(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function createOfflineDraft({ name, note = "", urls = {}, platforms = [] }) { + return { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + name, + note, + urls, + platforms, + device_name: mobileDeviceName(), + created_at: new Date().toISOString(), + sync_status: "pending", + }; +} + +function readOfflineDrafts() { + try { + const value = JSON.parse(localStorage.getItem(MOBILE_DRAFTS_KEY) || "[]"); + return Array.isArray(value) ? value.filter((draft) => draft?.name) : []; + } catch { + return []; + } +} + +function renderOfflineDrafts() { + const drafts = readOfflineDrafts(); + const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced"); + offlineCount.textContent = String(drafts.length); + if (drafts.length === 0) { + offlineList.innerHTML = `
暂无手机待同步记录
`; + clearOfflineButton.disabled = true; + syncOfflineButton.disabled = true; + return; + } + + clearOfflineButton.disabled = false; + syncOfflineButton.disabled = pendingDrafts.length === 0; + offlineList.innerHTML = drafts.slice(0, 20).map((draft) => { + const urlCount = Object.values(draft.urls || {}).filter(Boolean).length; + const note = draft.note ? `
${escapeHtml(draft.note)}
` : ""; + const isSynced = draft.sync_status === "synced"; + const syncLabel = isSynced ? "已同步" : "待同步"; + return ` +
+
+ ${escapeHtml(draft.name)} + ${syncLabel} +
+
${formatTime(draft.created_at)} · ${urlCount} 个链接 · ${escapeHtml((draft.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台")}
+ ${note} +
+ + +
+
+ `; + }).join(""); +} + +function editOfflineDraft(id) { + const drafts = readOfflineDrafts(); + const draft = drafts.find((item) => item.id === id); + if (!draft) return; + const nextName = window.prompt("修改节目名", draft.name); + if (nextName === null) return; + const cleanName = nextName.trim(); + if (!cleanName) { + setStatus("error", "节目名不能为空"); + return; + } + const nextNote = window.prompt("修改备注", draft.note || ""); + if (nextNote === null) return; + const updated = drafts.map((item) => item.id === id + ? { ...item, name: cleanName, note: nextNote.trim(), sync_status: "pending", edited_at: new Date().toISOString() } + : item); + localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updated)); + renderOfflineDrafts(); + updateOfflineStatus(); + updateMobileBindingSummary(); + setStatus("ok", `已更新《${cleanName}》`); +} + +function deleteOfflineDraft(id) { + const drafts = readOfflineDrafts(); + const draft = drafts.find((item) => item.id === id); + if (!draft) return; + if (!window.confirm(`删除《${draft.name}》这条手机记录吗?`)) return; + localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.filter((item) => item.id !== id))); + renderOfflineDrafts(); + updateOfflineStatus(); + updateMobileBindingSummary(); + setStatus("ok", `已删除《${draft.name}》`); +} + +async function syncOfflineDrafts() { + const drafts = readOfflineDrafts(); + const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced"); + if (pendingDrafts.length === 0) { + setStatus("ok", "没有待同步的手机记录"); + renderOfflineDrafts(); + return; + } + + syncOfflineButton.disabled = true; + setStatus("busy", `正在同步 ${pendingDrafts.length} 条到电脑`); + + try { + const payload = await postJson("/api/mobile-sync", { + deviceName: mobileDeviceName(), + drafts: pendingDrafts, + }); + const acceptedIds = new Set((payload.accepted || []).map((item) => item.id)); + const syncedAt = new Date().toISOString(); + const updatedDrafts = drafts.map((draft) => acceptedIds.has(draft.id) + ? { ...draft, sync_status: "synced", synced_at: syncedAt } + : draft); + localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updatedDrafts)); + renderOfflineDrafts(); + updateOfflineStatus(); + updateMobileBindingSummary(); + setStatus("ok", `电脑已收到 ${acceptedIds.size} 条,已进入待处理`); + } catch (error) { + setStatus("error", `同步失败:${error.message}`); + } finally { + syncOfflineButton.disabled = false; + renderOfflineDrafts(); + updateMobileBindingSummary(); + } +} + +function mobileDeviceName() { + let deviceName = (mobileDeviceNameInput?.value || localStorage.getItem(MOBILE_DEVICE_KEY) || "").trim(); + if (!deviceName) { + deviceName = `mobile-${Math.random().toString(16).slice(2, 8)}`; + localStorage.setItem(MOBILE_DEVICE_KEY, deviceName); + } + return deviceName; +} + +function saveMobileDeviceName() { + const deviceName = mobileDeviceNameInput.value.trim(); + if (!deviceName) { + setStatus("error", "请输入这台手机或录入人的名称"); + return; + } + localStorage.setItem(MOBILE_DEVICE_KEY, deviceName); + renderOfflineDrafts(); + setStatus("ok", `已保存手机名称:${deviceName}`); +} + +function renderHistory(history) { + const runs = history.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); + + if (runs.length === 0) { + cards.innerHTML = `
暂无采集结果
`; + return; + } + + cards.innerHTML = platformOrder.map((platform) => { + const row = history.platforms?.[platform] || { values: {} }; + const latestRun = runs[runs.length - 1]; + const latest = row.values?.[latestRun]; + return renderPlatformCard(platform, row, latest, runs); + }).join(""); +} + +function renderPlatformCard(platform, row, latest, runs) { + const label = row.platform_label || platformLabels[platform] || platform; + const metric = row.metric_label || metricLabels[platform] || "指标值"; + const url = row.url || latest?.url || ""; + const latestHtml = renderLatest(latest); + const historyRows = runs.slice(-5).reverse().map((run) => { + const value = row.values?.[run]; + const shown = value?.status === "ok" ? (value.raw || value.number || "") : statusLabel(value?.status); + return ` +
+ ${formatTime(run)} + ${escapeHtml(shown || "未采集")} +
+ `; + }).join(""); + + return ` +
+
+
+
${escapeHtml(label)}
+
${escapeHtml(metric)}
+ ${row.metric_description ? `
${escapeHtml(row.metric_description)}
` : ""} +
+ ${url ? `打开` : ""} +
+ ${latestHtml} +
${historyRows}
+
+ `; +} + +function renderLatest(value) { + if (!value) return `
未采集
`; + + if (value.status === "ok") { + const shown = value.raw || value.number || ""; + const meta = value.number && String(value.number) !== String(value.raw) ? `标准化:${value.number}` : ""; + const anomaly = value.anomaly ? `异常` : ""; + const credibility = renderCredibilityBadge(value.credibility); + return ` +
${escapeHtml(shown)}${anomaly}
+ ${credibility} + ${meta ? `
${escapeHtml(meta)}
` : ""} + ${value.credibility?.reason ? `
${escapeHtml(value.credibility.reason)}
` : ""} + ${value.anomaly ? `
${escapeHtml(value.anomaly.message || "")}
` : ""} + `; + } + + const tone = value.status === "blocked" ? "warn" : "bad"; + return ` +
${escapeHtml(statusLabel(value.status))}
+ ${renderCredibilityBadge(value.credibility)} +
${escapeHtml(value.error || "")}
+ `; +} + +function renderCredibilityBadge(credibility) { + if (!credibility?.label) return ""; + return `${escapeHtml(credibility.label)}`; +} + +function syncUrlInputs(history) { + for (const platform of platformOrder) { + const input = urlInputs[platform]; + if (!input) continue; + input.value = history.platforms?.[platform]?.url || ""; + } + dirtyUrlInputs.clear(); +} + +function readUrlInputs() { + return Object.fromEntries(platformOrder + .filter((platform) => dirtyUrlInputs.has(platform)) + .map((platform) => [ + platform, + urlInputs[platform]?.value.trim() || "", + ])); +} + +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() { + 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 = ""; + } + dirtyUrlInputs.clear(); +} + +async function getJson(url) { + const response = await fetch(apiUrl(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(apiUrl(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(apiUrl("/api/auth/status"), { headers: authHeaders() }); + const payload = await response.json(); + if (!payload.enabled || payload.authorized) { + hideAuthGate(); + return true; + } + } catch { + return true; + } + showAuthGate(""); + return false; +} + +async function submitAccessPassword() { + const password = authPassword?.value || ""; + if (!password.trim()) { + showAuthGate("请输入访问密码"); + return; + } + setAuthMessage("正在验证..."); + try { + const response = await fetch(apiUrl("/api/auth/login"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "访问密码不正确"); + if (payload.token) localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, payload.token); + if (authPassword) authPassword.value = ""; + hideAuthGate(); + await startApp(); + } catch (error) { + showAuthGate(error.message || "访问密码不正确"); + } +} + +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; +} + +function statusLabel(status) { + return { + no_match: "未找到", + blocked: "被拦截", + error: "错误", + }[status] || status || ""; +} + +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 escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeAttribute(value) { + return escapeHtml(value).replace(/`/g, "`"); +} diff --git a/public/rankings.css b/public/rankings.css new file mode 100644 index 0000000..beb91d2 --- /dev/null +++ b/public/rankings.css @@ -0,0 +1,360 @@ +.ranking-panel { + margin-top: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: hidden; +} + +.ranking-head, +.ranking-section-head, +.ranking-bulk { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ranking-head { + padding: 14px 16px; + border-bottom: 1px solid var(--line); +} + +.ranking-subtitle, +.ranking-section-head span, +.ranking-bulk span { + color: var(--muted); + font-size: 12px; +} + +.ranking-actions, +.ranking-tabs, +.ranking-row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.primary-action { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.primary-action:hover { + background: var(--accent-strong); + color: #fff; +} + +.ranking-chip { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 10px; + background: #fff; + color: var(--muted); + font-weight: 700; + cursor: pointer; +} + +.ranking-chip.active { + border-color: var(--accent); + background: #e5f4f2; + color: var(--accent-strong); +} + +.ranking-body { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + gap: 0; +} + +.kids-discovery { + padding: 14px 16px; +} + +.kids-filter-form { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(180px, 1.4fr) repeat(4, minmax(120px, 0.8fr)) 76px; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.kids-filter-form input, +.kids-filter-form select, +.kids-filter-form button { + min-height: 34px; +} + +.kids-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 10px; + color: var(--muted); + font-size: 12px; +} + +.trend-summary { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.trend-summary.empty { + display: block; +} + +.trend-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + background: #fbfdff; +} + +.trend-card strong { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-size: 20px; +} + +.trend-card span { + color: var(--muted); + font-size: 12px; +} + +.ranking-advanced { + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.ranking-advanced summary { + color: var(--accent-strong); + cursor: pointer; + font-weight: 700; +} + +.ranking-sources, +.ranking-programs { + padding: 14px 16px; +} + +.ranking-sources { + border-right: 1px solid var(--line); +} + +.ranking-section-head { + margin-bottom: 10px; +} + +.ranking-source-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; +} + +.ranking-source-form input, +.ranking-source-form select, +.ranking-source-form button, +.ranking-bulk button { + min-height: 32px; +} + +.ranking-source-form input[name="label"], +.ranking-source-form input[name="url"] { + grid-column: 1 / -1; +} + +.ranking-check { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); +} + +.ranking-source-list { + display: grid; + gap: 6px; +} + +.ranking-source-row { + display: grid; + grid-template-columns: 64px 44px minmax(0, 1fr) 36px 36px; + gap: 6px; + align-items: center; + padding: 6px; + border: 1px solid var(--line); + border-radius: 6px; + font-size: 12px; +} + +.ranking-source-row a, +.ranking-table strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.release-date-note { + display: block; + margin-top: 4px; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; + line-height: 1.2; +} + +.release-date-note.missing { + color: var(--muted); + font-weight: 600; +} + +.ranking-empty { + padding: 16px; + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + text-align: center; +} + +.ranking-table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; +} + +.ranking-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.ranking-table th, +.ranking-table td { + padding: 10px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +.ranking-table th { + background: var(--panel-soft); + font-weight: 700; +} + +.kids-table th:nth-child(1), +.kids-table td:nth-child(1) { + width: 24%; +} + +.kids-table th:nth-child(2), +.kids-table td:nth-child(2) { + width: 64px; +} + +.kids-table th:nth-child(n+3):nth-child(-n+6), +.kids-table td:nth-child(n+3):nth-child(-n+6) { + width: 86px; + text-align: right; +} + +.kids-table th:nth-child(8), +.kids-table td:nth-child(8) { + width: 190px; +} + +.trend-table th:nth-child(1), +.trend-table td:nth-child(1) { + width: 22%; +} + +.trend-table th:nth-child(n+3):nth-child(-n+6), +.trend-table td:nth-child(n+3):nth-child(-n+6) { + width: 86px; + text-align: right; +} + +.trend-table th:nth-child(9), +.trend-table td:nth-child(9) { + width: 150px; +} + +.trend-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + border-radius: 999px; + padding: 0 8px; + background: #eef2f7; + color: #475569; + font-weight: 700; + white-space: nowrap; +} + +.trend-badge.strong_growth { + background: #dcfce7; + color: #166534; +} + +.trend-badge.rising { + background: #dbeafe; + color: #1d4ed8; +} + +.trend-badge.multi_platform { + background: #fef3c7; + color: #92400e; +} + +.trend-badge.new_signal { + background: #e0f2fe; + color: #0369a1; +} + +.trend-badge.no_data { + background: #fee2e2; + color: #991b1b; +} + +.metric-ok { + color: var(--accent-strong); + font-weight: 700; +} + +.metric-missing { + color: var(--muted); +} + +.ranking-bulk { + margin-top: 10px; +} + +@media (max-width: 900px) { + .ranking-body { + grid-template-columns: 1fr; + } + + .ranking-sources { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .ranking-head, + .ranking-section-head, + .ranking-bulk { + align-items: flex-start; + flex-direction: column; + } + + .kids-filter-form { + grid-template-columns: 1fr; + } +} diff --git a/public/rankings.js b/public/rankings.js new file mode 100644 index 0000000..a57d433 --- /dev/null +++ b/public/rankings.js @@ -0,0 +1,438 @@ +const PLATFORM_LABELS = { tencent: "腾讯视频", youku: "优酷", iqiyi: "爱奇艺", mgtv: "芒果TV" }; +const TYPE_LABELS = { animation: "动画", education: "早教", song: "儿歌", toy: "玩具", movie: "电影", other: "其他" }; +const SOURCE_LABELS = { new: "新片", recommend: "推荐", rank: "榜单", hot: "热播", channel: "频道" }; +const METRIC_PLATFORMS = ["tencent", "youku", "iqiyi", "mgtv"]; + +const state = { + view: "new", + programs: [], + trendResults: [], + defaults: [], + loading: false, + message: "", + filters: { + q: "", + exclude: "预告 片段 花絮 解说", + platform: "", + content_type: "animation", + status: "", + min_platforms: "", + }, +}; + +const root = document.querySelector("#ranking-radar"); +if (root) init(); + +async function init() { + render(); + const [defaults, latest] = await Promise.all([ + apiGet("/api/rankings/default-sources"), + apiGet("/api/kids-trends/latest"), + refreshPrograms(), + ]); + state.defaults = defaults.sources || []; + if (latest.trend?.results?.length) { + state.trendResults = latest.trend.results || []; + state.message = `已恢复上次上新趋势:${formatTime(latest.trend.captured_at)},采集 ${latest.trend.collected_count || state.trendResults.length} 个节目`; + } + render(); +} + +async function refreshPrograms() { + const params = new URLSearchParams({ category: "kids", view: state.view }); + for (const [key, value] of Object.entries(state.filters)) { + if (value) params.set(key, value); + } + const data = await apiGet(`/api/rankings/programs?${params.toString()}`); + state.programs = data.programs || []; +} + +function render() { + root.innerHTML = ` +
+
+
少儿上新趋势雷达
+
一键发现少儿新节目,采集四平台数值,并判断增长趋势
+
+
+ + ${viewButton("new", "候选")} + ${viewButton("platform", "全部")} + ${viewButton("ignored", "已忽略")} + 导出 +
+
+
+ ${trendSummary()} +
+ + + + + + + +
+
+ ${state.message || `当前 ${state.programs.length} 个候选`} + 内置来源 ${state.defaults.length || 0} 个 + 趋势需要至少两次成功采集才会更准确 +
+ ${state.trendResults.length ? trendTable() : programTable()} +
+ 高级:手动补充来源 URL + ${sourceForm()} +
+
+ `; + bindEvents(); +} + +function trendSummary() { + if (!state.trendResults.length) { + return ` +
+ 还没有趋势结论 + 点击“一键采集上新趋势”,系统会自动找少儿新节目、采集四平台数值,并给出建议。 +
+ `; + } + const counts = countBy(state.trendResults.map((item) => item.trend?.verdict || "no_data")); + return ` +
+ ${summaryCard("强增长", counts.strong_growth || 0)} + ${summaryCard("在增长", counts.rising || 0)} + ${summaryCard("新有数值", counts.new_signal || 0)} + ${summaryCard("暂无数值", counts.no_data || 0)} +
+ `; +} + +function summaryCard(label, value) { + return `
${value}${label}
`; +} + +function viewButton(id, label) { + return ``; +} + +function trendTable() { + return ` +
+ + + + + + + + + + + + + + + ${state.trendResults.map(trendRow).join("")} +
节目判断腾讯优酷爱奇艺芒果增长建议操作
+
+ `; +} + +function trendRow(item) { + const program = item.program || {}; + const trend = item.trend || {}; + const url = program.urls?.[0] || ""; + const platform = program.platforms?.[0] || ""; + return ` + + ${escapeHtml(program.display_name)}${releaseDateNote(program)} + ${trendBadge(trend)} + ${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")} + ${growthText(trend)} + ${escapeHtml(trend.recommendation || "")} + + ${url ? `` : ""} + + + + `; +} + +function programTable() { + if (state.programs.length === 0) { + return `
还没有筛出节目。可以直接点“一键采集上新趋势”。
`; + } + + return ` +
+ + + + + + + + + + + + + + ${state.programs.map(programRow).join("")} +
节目类型腾讯优酷爱奇艺芒果来源操作
+
+ `; +} + +function programRow(program) { + const url = program.urls?.[0] || ""; + const platform = program.platforms?.[0] || ""; + const sources = (program.source_types || []).map((id) => SOURCE_LABELS[id] || id).join("、"); + return ` + + ${escapeHtml(program.display_name)}${releaseDateNote(program)} + ${escapeHtml(TYPE_LABELS[program.content_type] || "其他")} + ${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")} + ${escapeHtml(sources)} + + ${url ? `` : ""} + ${program.ignored + ? `` + : ` + + `} + + + `; +} + +function releaseDateNote(program) { + const value = program.release_date || ""; + const text = value ? formatReleaseDate(value) : "未知"; + const title = value ? `上线时间:${text}` : "暂未从平台页面识别到上线时间"; + return `上线:${escapeHtml(text)}`; +} + +function metricCell(program, platform) { + const metric = program.latest_metrics?.[platform]; + const ok = metric?.status === "ok"; + const text = ok ? metric.short : "未采"; + const title = ok + ? `${metric.platform_label || PLATFORM_LABELS[platform]} ${metric.metric_label || ""}:${metric.raw || metric.number || ""},采集于 ${formatTime(metric.run)}` + : `${PLATFORM_LABELS[platform]} 暂无成功采集数值`; + return `${escapeHtml(text)}`; +} + +function trendBadge(trend) { + return `${escapeHtml(trend.label || "暂无数值")}`; +} + +function growthText(trend) { + if (!trend || !trend.growing_platforms) return "-"; + const delta = Number(trend.best_delta || 0); + const rate = Number(trend.best_growth_rate || 0); + return `+${delta}${rate ? ` / ${Math.round(rate * 100)}%` : ""}`; +} + +function growthTitle(trend) { + if (!trend?.platform_trends) return ""; + return Object.values(trend.platform_trends) + .filter((item) => item.latest_status === "ok") + .map((item) => `${PLATFORM_LABELS[item.platform] || item.platform}: ${item.previous_raw || "无上次"} -> ${item.latest_raw || "无本次"}`) + .join("\n"); +} + +function sourceForm() { + return ` +
+ + + + + + +
+ `; +} + +function bindEvents() { + root.querySelector("[data-action='run-trend']")?.addEventListener("click", runTrend); + root.querySelectorAll("[data-view]").forEach((button) => { + button.addEventListener("click", async () => { + state.view = button.dataset.view; + state.trendResults = []; + await refreshPrograms(); + render(); + }); + }); + + root.querySelector("[data-role='filters']")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + for (const key of Object.keys(state.filters)) { + state.filters[key] = String(form.get(key) || "").trim(); + } + state.trendResults = []; + await refreshPrograms(); + state.message = `筛出 ${state.programs.length} 个`; + render(); + }); + + root.querySelector("[data-role='source-form']")?.addEventListener("submit", saveSource); + root.querySelectorAll("[data-ignore-program]").forEach((button) => button.addEventListener("click", () => ignoreProgram(button.dataset.ignoreProgram, true))); + root.querySelectorAll("[data-restore-program]").forEach((button) => button.addEventListener("click", () => ignoreProgram(button.dataset.restoreProgram, false))); + root.querySelectorAll("[data-track-program]").forEach((button) => button.addEventListener("click", () => trackProgram(button.dataset.trackProgram, button.dataset.platform, button.dataset.url))); + root.querySelectorAll("[data-collect-program]").forEach((button) => button.addEventListener("click", () => collectPrograms([button.dataset.collectProgram]))); +} + +async function runTrend() { + state.loading = true; + state.message = "正在发现并采集少儿上新趋势"; + state.trendResults = []; + render(); + try { + const data = await apiPost("/api/kids-trends/run", { + limit: 8, + platforms: ["tencent", "youku", "iqiyi", "mgtv"], + }); + state.trendResults = data.results || []; + state.message = `发现 ${data.discovered_count || 0} 条,采集 ${data.collected_count || 0} 个节目`; + await refreshPrograms(); + } finally { + state.loading = false; + render(); + } +} + +async function saveSource(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await apiPost("/api/ranking-sources", { + category: "kids", + platform: form.get("platform"), + source_type: form.get("source_type"), + label: form.get("label"), + url: form.get("url"), + enabled: form.get("enabled") === "on", + }); + state.message = "补充来源已保存"; + render(); +} + +async function ignoreProgram(name, ignored) { + const data = await apiPost("/api/rankings/ignore", { category: "kids", name, ignored }); + state.programs = data.programs || []; + state.message = ignored ? "已忽略" : "已恢复"; + render(); +} + +async function trackProgram(name, platform, url) { + await apiPost("/api/rankings/track", { category: "kids", name, platform, url }); + state.message = "已加入历史节目"; + await refreshPrograms(); + render(); + document.dispatchEvent(new CustomEvent("hotness:programs-changed")); +} + +async function collectPrograms(names) { + const cleanNames = [...new Set(names.filter(Boolean))].slice(0, 20); + if (cleanNames.length === 0) return; + state.loading = true; + state.message = `正在采集 ${cleanNames.length} 个节目`; + render(); + try { + const data = await apiPost("/api/rankings/collect", { + category: "kids", + names: cleanNames, + platforms: ["tencent", "youku", "iqiyi", "mgtv"], + }); + state.programs = data.programs || []; + state.message = `已采集 ${data.items?.length || 0} 个`; + } finally { + state.loading = false; + render(); + } +} + +function sourceSummary() { + return state.defaults.map((source) => `${PLATFORM_LABELS[source.platform] || source.platform}:${source.label}`).join("\n"); +} + +function countBy(values) { + const counts = {}; + for (const value of values) counts[value] = (counts[value] || 0) + 1; + return counts; +} + +function options(map, selected = "") { + return Object.entries(map).map(([value, label]) => ``).join(""); +} + +async function apiGet(path) { + const response = await fetch(path); + return parseApiResponse(response); +} + +async function apiPost(path, payload) { + const response = await fetch(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + return parseApiResponse(response); +} + +async function parseApiResponse(response) { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "request failed"); + return data; +} + +function formatTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function formatReleaseDate(value) { + const text = String(value || "").trim(); + if (!text) return ""; + if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(text)) return text; + if (/^[0-9]{2}-[0-9]{2}$/.test(text)) return text; + return text; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/'/g, "'"); +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..72e311d --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1671 @@ +:root { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --panel-soft: #eef3f7; + --text: #18202a; + --muted: #637083; + --line: #d9e0e7; + --accent: #0f766e; + --accent-strong: #0b5f59; + --warn: #ad6817; + --bad: #b42318; + --ok: #16794c; + --shadow: 0 8px 24px rgba(30, 41, 59, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +html { + scroll-behavior: smooth; +} + +.auth-gate { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(246, 247, 249, 0.96); +} + +.auth-card { + width: min(420px, 100%); + display: grid; + gap: 12px; + padding: 24px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.auth-title { + font-size: 20px; + font-weight: 900; +} + +.auth-card p { + margin: 0; + color: var(--muted); +} + +.auth-card input, +.auth-card button { + min-height: 42px; + border-radius: 6px; +} + +.auth-card button { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-weight: 800; + cursor: pointer; +} + +.auth-message { + min-height: 20px; + color: var(--bad); + font-weight: 700; +} + +.app-nav { + position: sticky; + top: 0; + z-index: 30; + display: grid; + grid-template-columns: auto auto minmax(360px, 1fr) auto; + gap: 16px; + align-items: center; + min-height: 52px; + padding: 8px 24px; + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--line); + box-shadow: 0 4px 18px rgba(30, 41, 59, 0.06); + backdrop-filter: blur(8px); +} + +.app-nav-brand { + color: var(--text); + font-size: 16px; + font-weight: 900; + text-decoration: none; +} + +.app-version-badge { + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; + padding: 0 10px; + border-radius: 6px; + background: #e8f5f2; + color: var(--accent); + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.app-nav-links { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.app-nav-links a { + display: inline-flex; + align-items: center; + justify-content: center; + height: 34px; + padding: 0 12px; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + font-weight: 800; +} + +.app-nav-links a:hover, +.app-nav-links a:focus-visible { + color: var(--accent); + background: #edf7f5; +} + +.app-nav-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.app-nav-meta strong { + color: var(--accent); + font-size: 13px; +} + +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; + padding-bottom: 42px; +} + +.topbar { + display: grid; + grid-template-columns: minmax(360px, 480px) minmax(320px, 1fr); + gap: 28px; + align-items: center; + padding: 28px 24px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} + +.app-status-dock { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 32; + display: flex; + align-items: center; + gap: 10px; + min-height: 36px; + padding: 7px 24px; + background: #ffffff; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + box-shadow: 0 -4px 18px rgba(30, 41, 59, 0.06); +} + +.dock-label { + color: var(--accent); + font-weight: 900; +} + +.dock-separator { + width: 1px; + height: 14px; + background: var(--line); +} + +.brand-block { + display: grid; + gap: 14px; + align-content: center; + min-width: 0; + max-width: 360px; +} + +.brand-copy { + min-width: 0; +} + +h1 { + margin: 0; + font-size: 30px; + font-weight: 800; + letter-spacing: 0; + line-height: 1.12; +} + +#subtitle { + margin: 8px 0 0; + color: var(--muted); + font-size: 16px; + line-height: 1.35; +} + +.top-collect-all { + width: 100%; + min-width: 0; + height: 54px; + padding: 0 20px; + font-size: 16px; + font-weight: 800; +} + +.top-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.top-collect-all.secondary { + background: #fff; + color: var(--teal); + border: 1px solid var(--teal); +} + +.searchbar { + display: grid; + grid-template-columns: minmax(220px, 360px) 108px 108px 108px minmax(0, 1fr); + gap: 10px; + align-items: center; +} + +.url-grid { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 8px; +} + +.url-grid input { + height: 34px; + font-size: 13px; +} + +.temporary-query-panel { + margin: 16px 24px 0; + padding: 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} + +.desktop-dashboard { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr 1.3fr; + gap: 12px; + margin: 12px 0 0; +} + +.dashboard-card { + min-width: 0; + padding: 14px 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.dashboard-card-main { + border-color: rgba(15, 118, 110, 0.28); +} + +.dashboard-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.dashboard-value { + margin-top: 4px; + font-size: 30px; + line-height: 1.15; + font-weight: 800; + color: var(--text); +} + +.dashboard-value.compact { + font-size: 22px; +} + +.dashboard-note { + margin-top: 6px; + color: var(--muted); + font-size: 12px; +} + +.dashboard-actions-card { + display: grid; + align-content: center; + gap: 10px; +} + +.dashboard-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.dashboard-action { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid var(--accent); + color: var(--accent); + text-decoration: none; + font-weight: 800; +} + +.task-queue-panel { + margin: 12px 24px 0; + padding: 14px 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.task-queue-panel.idle { + box-shadow: none; +} + +.task-queue-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.task-current { + margin-top: 2px; + color: var(--muted); + font-size: 13px; +} + +.task-ratio { + font-weight: 800; + color: var(--accent); +} + +.task-progress-track { + height: 8px; + margin-top: 12px; + overflow: hidden; + border-radius: 999px; + background: var(--panel-soft); +} + +.task-progress-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 160ms ease; +} + +.task-counters { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 10px; + color: var(--muted); + font-size: 12px; +} + +.task-counters strong { + color: var(--text); +} + +.mobile-sync-panel { + margin: 12px 24px 0; + padding: 14px 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.mobile-sync-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.mobile-sync-list.empty { + display: block; + color: var(--muted); +} + +.mobile-sync-item { + display: grid; + gap: 8px; + align-content: start; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; +} + +.mobile-sync-main { + display: grid; + gap: 2px; +} + +.mobile-sync-main span, +.mobile-sync-meta, +.mobile-sync-note { + color: var(--muted); + font-size: 12px; +} + +.mobile-sync-note { + word-break: break-word; +} + +.duty-panel { + margin: 12px 24px 0; + padding: 14px 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.duty-grid { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 12px; +} + +.duty-grid label { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + color: var(--text); + font-size: 13px; + font-weight: 800; +} + +.duty-grid input[type="checkbox"] { + width: auto; + height: auto; +} + +.duty-time input { + width: 120px; + height: 32px; +} + +.duty-status { + margin-top: 10px; + color: var(--muted); + font-size: 13px; +} + +.temporary-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.file-button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 12px; + border: 1px solid var(--teal); + border-radius: 6px; + color: var(--teal); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.file-button input { + display: none; +} + +#temporary-query-text { + width: 100%; + margin-top: 12px; + min-height: 92px; + resize: vertical; +} + +#temporary-query-text.drag-over { + border-color: var(--accent); + background: #f0fdfa; + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.14); +} + +.temporary-result { + margin-top: 12px; +} + +.temporary-result-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; +} + +.temporary-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.temporary-table th, +.temporary-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.link-candidates { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.link-candidate-card { + border: 1px solid var(--border); + border-radius: 8px; + background: #fbfdff; + padding: 8px; + min-width: 0; +} + +.link-candidate-head { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.link-candidate-head strong { + color: var(--text); +} + +.link-candidate-list { + display: grid; + gap: 6px; +} + +.link-candidate-list button { + display: grid; + gap: 2px; + text-align: left; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + padding: 6px; + cursor: pointer; +} + +.link-candidate-list button:hover { + border-color: var(--accent); + background: #f0fdfa; +} + +.link-candidate-list span { + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.link-candidate-list small { + color: var(--muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.link-candidate-empty { + color: var(--muted); + font-size: 12px; +} + +.collect-platforms { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.collect-platforms label { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 10px; + background: #fff; + color: var(--muted); +} + +.collect-platforms label.active { + border-color: var(--accent); + background: #e5f4f2; + color: var(--accent-strong); +} + +.collect-platforms input { + width: auto; + height: auto; +} + +.mini-button { + height: 30px; + border-color: var(--line); + border-radius: 999px; + padding: 0 10px; + background: #fff; + color: var(--accent-strong); + font-size: 13px; +} + +.mini-button.warn { + color: var(--bad); +} + +.library-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(180px, 1fr) 116px minmax(90px, auto); + gap: 8px; + align-items: center; +} + +.library-row input { + height: 34px; + font-size: 13px; +} + +.library-row .button { + height: 34px; + font-size: 13px; +} + +.inline-status { + color: var(--muted); + font-size: 12px; +} + +.network-help { + grid-column: 1 / -1; + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.network-help summary { + color: var(--accent-strong); + cursor: pointer; + font-weight: 700; +} + +.help-grid { + display: grid; + gap: 4px; + margin-top: 8px; + color: var(--muted); + font-size: 13px; +} + +input { + width: 100%; + height: 40px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 12px; + background: #fff; + color: var(--text); + font: inherit; + outline: none; +} + +select, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + background: #fff; + color: var(--text); + font: inherit; + outline: none; +} + +textarea { + resize: vertical; + min-height: 92px; +} + +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.16); +} + +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + border: 1px solid var(--accent); + border-radius: 6px; + padding: 0 14px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +button:disabled, +.button[aria-disabled="true"] { + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; +} + +.button.ghost { + background: #fff; + color: var(--accent-strong); +} + +.statusline { + display: flex; + gap: 8px; + align-items: center; + min-height: 38px; + padding: 0 24px; + border-bottom: 1px solid var(--line); + color: var(--muted); + background: #fbfcfd; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #94a3b8; +} + +.dot.busy { + background: var(--warn); +} + +.dot.ok { + background: var(--ok); +} + +.dot.error { + background: var(--bad); +} + +.workspace { + flex: 1; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + min-height: 0; +} + +.side { + border-right: 1px solid var(--line); + background: #fbfcfd; + padding: 16px; + overflow: auto; +} + +.side-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.side-title > span { + flex: 0 0 auto; +} + +.collect-history-button { + height: 28px; + border-color: var(--accent); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.history-actions, +.history-bulk-bar { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.history-actions { + min-width: 0; +} + +.history-bulk-bar { + margin-bottom: 10px; +} + +.history-bulk-bar button { + min-height: 28px; + border-color: var(--line); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.history-bulk-bar #history-collect-selected { + border-color: var(--accent); + background: #f0fffb; + color: var(--accent-strong); +} + +.history-bulk-bar #history-delete-selected { + border-color: #f2c0bd; + background: #fff7f6; + color: var(--bad); +} + +.program-list { + display: grid; + gap: 6px; +} + +.program-item-row { + display: grid; + grid-template-columns: 24px minmax(130px, 1fr) 44px; + gap: 6px; + align-items: center; + min-width: 0; +} + +.program-item-row.bulk { + grid-template-columns: 24px minmax(170px, 1fr); +} + +.program-select { + display: grid; + place-items: center; + min-height: 36px; + padding: 0; +} + +.program-select input { + width: 18px; + height: 18px; + cursor: pointer; +} + +.program-item-row.active .program-item { + border-color: var(--line); + background: var(--panel); +} + +.program-item { + min-height: 36px; + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: 6px; + padding: 8px 10px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.program-name-text { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + word-break: break-word; + line-height: 1.35; +} + +.program-item:hover, +.program-item.active { + border-color: var(--line); + background: var(--panel); +} + +.delete-program { + width: 44px; + min-width: 44px; + height: 36px; + border-color: #f2c0bd; + border-radius: 6px; + padding: 0; + background: #fff7f6; + color: var(--bad); + font-size: 12px; + font-weight: 700; +} + +.delete-program:hover { + background: #ffeceb; +} + +.table-panel { + min-width: 0; + padding: 18px 24px 24px; +} + +.table-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.table-actions, +.run-bulk-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.run-collapse-tools { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-start; + gap: 10px; + margin: -2px 0 12px; + padding: 10px 12px; + border: 1px solid #b8ddd9; + border-radius: 8px; + background: #f2fbfa; +} + +.run-collapse-note { + color: #24515b; + font-size: 13px; + font-weight: 800; +} + +#run-collapse-toggle { + min-height: 32px; + border-color: var(--accent); + background: var(--accent); + color: #fff; + box-shadow: 0 8px 16px rgba(15, 118, 110, 0.14); +} + +#run-collapse-toggle:hover { + background: var(--accent-strong); +} + +.run-bulk-bar { + margin-bottom: 10px; +} + +.run-bulk-bar button { + min-height: 28px; + border-color: var(--line); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.run-bulk-bar #run-delete-selected { + border-color: #f2c0bd; + background: #fff7f6; + color: var(--bad); +} + +.platform-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.filter-chip { + height: 32px; + border-color: var(--line); + border-radius: 999px; + padding: 0 12px; + background: #fff; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.filter-chip.active { + border-color: var(--accent); + background: #e5f4f2; + color: var(--accent-strong); +} + +.filter-chip.reset { + border-style: dashed; +} + +.table-title { + font-size: 18px; + font-weight: 700; +} + +.run-count { + color: var(--muted); + font-weight: 600; +} + +.table-wrap { + width: 100%; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +table { + width: 100%; + min-width: 780px; + border-collapse: collapse; + table-layout: fixed; +} + +th, +td { + border-bottom: 1px solid var(--line); + padding: 8px 10px; + text-align: left; + vertical-align: middle; +} + +th { + position: sticky; + top: 0; + z-index: 1; + background: var(--panel-soft); + color: #314154; + font-size: 13px; + font-weight: 700; +} + +.run-head { + display: grid; + gap: 4px; +} + +.run-collapse-cell { + min-width: 108px; + background: #f8fafc; + color: var(--muted); + text-align: center; + font-size: 12px; + font-weight: 700; +} + +.run-collapse-cell button { + min-height: 26px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.run-collapse-cell button:hover { + background: #e5f4f2; +} + +.run-select { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.run-select input { + width: 18px; + height: 18px; + cursor: pointer; +} + +.delete-run { + justify-self: start; + height: 24px; + border-color: #f2c0bd; + border-radius: 5px; + padding: 0 8px; + background: #fff7f6; + color: var(--bad); + font-size: 12px; + font-weight: 700; +} + +th:first-child, +td:first-child { + width: 118px; + position: sticky; + left: 0; + z-index: 2; + background: inherit; +} + +thead th:first-child { + z-index: 3; +} + +tbody tr { + background: var(--panel); +} + +tbody tr:hover { + background: #f9fbfb; +} + +.url-cell { + width: 210px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.url-cell a { + color: var(--accent-strong); + text-decoration: none; +} + +.heat-cell { + min-width: 72px; + text-align: center; +} + +.heat-value { + display: block; + font-weight: 700; +} + +.heat-meta { + display: block; + color: var(--muted); + font-size: 12px; +} + +.metric-help { + display: none; +} + +.compact-label { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 5px; + padding: 0 6px; + background: #f5f8fb; + color: #314154; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.short-status { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 5px; + padding: 0 6px; + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.short-status.muted { + background: #f5f8fb; + color: var(--muted); +} + +.detail-link { + height: 24px; + margin-top: 4px; + border-color: var(--line); + border-radius: 5px; + padding: 0 8px; + background: #fff; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.anomaly-badge { + display: inline-flex; + align-items: center; + min-height: 18px; + border-radius: 5px; + padding: 0 5px; + background: #fff3dc; + color: var(--warn); + font-size: 12px; +} + +.credibility-badge { + display: inline-flex; + align-items: center; + min-height: 18px; + margin-top: 4px; + border-radius: 5px; + padding: 0 5px; + background: #edf6ff; + color: #175cd3; + font-size: 12px; + font-weight: 800; +} + +.credibility-badge.high { + background: #e7f6ec; + color: var(--ok); +} + +.credibility-badge.medium { + background: #edf6ff; + color: #175cd3; +} + +.credibility-badge.low { + background: #fff3dc; + color: var(--warn); +} + +.credibility-badge.rejected { + background: #fff1f0; + color: var(--bad); +} + +.status-ok { + color: var(--ok); +} + +.status-warn { + color: var(--warn); +} + +.status-bad { + color: var(--bad); +} + +.empty { + color: var(--muted); + text-align: center; + padding: 36px 12px; +} + +.chart-panel, +.compare-panel { + margin-top: 16px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + box-shadow: var(--shadow); +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.panel-title { + font-weight: 800; +} + +.panel-note { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.compare-controls { + display: grid; + grid-template-columns: 180px 108px; + gap: 8px; + align-items: center; +} + +.compare-controls select { + height: 34px; + padding: 0 10px; + font-size: 13px; +} + +.trend-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.trend-card { + border: 1px solid var(--line); + border-radius: 7px; + padding: 10px; +} + +.trend-title { + font-weight: 800; +} + +.trend-meta { + color: var(--muted); + font-size: 12px; +} + +.line-chart { + width: 100%; + height: 120px; + margin-top: 6px; +} + +.line-chart line { + stroke: var(--line); +} + +.line-chart polyline { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; +} + +.line-chart circle { + fill: var(--accent); +} + +.batch-form { + display: grid; + grid-template-columns: minmax(240px, 1fr) 128px; + gap: 10px; + align-items: start; +} + +.compare-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.compare-check { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 10px; + background: #fff; +} + +.compare-check input { + width: auto; + height: auto; +} + +.compare-chart { + display: grid; + gap: 8px; +} + +.compare-line-chart { + min-height: 204px; +} + +.compare-line-svg { + width: 100%; + min-height: 188px; + display: block; + overflow: visible; +} + +.compare-plot-bg { + fill: #fbfdff; +} + +.compare-grid-line line { + stroke: var(--line); + stroke-width: 1; +} + +.compare-grid-line text, +.compare-axis-label, +.compare-time-label { + fill: var(--muted); + font-size: 6px; +} + +.compare-time-tick line { + stroke: var(--muted); + stroke-width: 1; +} + +.compare-point-value { + font-size: 5px; + font-weight: 700; + paint-order: stroke; + stroke: #ffffff; + stroke-width: 1.2px; + stroke-linejoin: round; +} + +.compare-axis { + stroke: var(--muted); + stroke-width: 1; +} + +.compare-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin-top: 4px; + color: #314154; + font-size: 16px; + font-weight: 700; +} + +.compare-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.compare-legend i { + width: 16px; + height: 16px; + border-radius: 999px; +} + +.bar-row, +.compare-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(120px, 2fr) minmax(70px, auto); + gap: 10px; + align-items: center; +} + +.compare-row { + grid-template-columns: minmax(120px, 1fr) minmax(70px, auto); + margin-top: 6px; + color: var(--muted); +} + +.bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: var(--panel-soft); +} + +.bar-track i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent); +} + +.detail-dialog { + width: min(720px, calc(100vw - 32px)); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0; + color: var(--text); +} + +.detail-dialog::backdrop { + background: rgba(15, 23, 42, 0.35); +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + padding: 12px 14px; +} + +.close-button { + width: 30px; + height: 30px; + border-color: var(--line); + border-radius: 6px; + padding: 0; + background: #fff; + color: var(--text); + font-size: 20px; +} + +.detail-body { + display: grid; + gap: 10px; + max-height: min(70vh, 620px); + overflow: auto; + padding: 14px; +} + +.detail-label { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.detail-value { + word-break: break-word; +} + +.candidate-list { + margin: 4px 0 0; + padding-left: 20px; +} + +.candidate-list li { + margin-bottom: 8px; +} + +.candidate-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.candidate-actions button { + border: 1px solid var(--border-strong); + background: #f8ffff; + color: var(--accent-strong); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.candidate-actions button:hover { + background: #e8fbfa; +} + +.candidate-list span { + display: block; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 820px) { + .topbar { + grid-template-columns: 1fr; + gap: 12px; + padding: 16px; + } + + .searchbar { + grid-template-columns: 1fr; + } + + .top-actions { + grid-template-columns: 1fr; + } + + .url-grid { + grid-template-columns: 1fr; + } + + .link-candidates { + grid-template-columns: 1fr; + } + + .library-row, + .bar-row { + grid-template-columns: 1fr; + } + + .workspace { + grid-template-columns: 1fr; + } + + .side { + border-right: 0; + border-bottom: 1px solid var(--line); + max-height: 180px; + } + + .table-panel { + padding: 16px; + } +} diff --git a/src/anomaly.js b/src/anomaly.js new file mode 100644 index 0000000..1e61ced --- /dev/null +++ b/src/anomaly.js @@ -0,0 +1,72 @@ +const DEFAULT_RULE = { + dropRatio: 0.6, + spikeRatio: 1.8, + minDelta: 50, +}; + +const PLATFORM_RULES = { + mgtv: { + dropRatio: 0.8, + spikeRatio: 3, + minDelta: 100_000, + }, +}; + +export function annotateCollectionAnomalies(collection, history) { + for (const result of collection.results || []) { + if (result.status !== "ok") continue; + + const current = Number(result.hotness_number); + if (!Number.isFinite(current) || current <= 0) continue; + + const previous = findPreviousValue(history, result.platform); + if (!previous) continue; + + const rule = PLATFORM_RULES[result.platform] || DEFAULT_RULE; + const delta = current - previous.number; + const ratio = current / previous.number; + if (Math.abs(delta) < rule.minDelta) continue; + + if (ratio < rule.dropRatio) { + result.anomaly = makeAnomaly("drop", result, previous, ratio); + } else if (ratio > rule.spikeRatio) { + result.anomaly = makeAnomaly("spike", result, previous, ratio); + } + } + + return collection; +} + +function findPreviousValue(history, platform) { + const row = history?.platforms?.[platform]; + if (!row?.values) return null; + + const runs = [...(history.runs || [])].reverse(); + for (const run of runs) { + const value = row.values[run]; + if (value?.status !== "ok") continue; + + const number = Number(value.number); + if (!Number.isFinite(number) || number <= 0) continue; + return { + run, + number, + raw: value.raw || String(number), + }; + } + + return null; +} + +function makeAnomaly(type, result, previous, ratio) { + const direction = type === "drop" ? "明显下降" : "明显上升"; + return { + type, + level: "warning", + previous_run: previous.run, + previous_number: previous.number, + previous_raw: previous.raw, + ratio: Math.round(ratio * 100) / 100, + message: `与上次 ${previous.raw} 相比${direction},请核对页面和证据`, + }; +} diff --git a/src/collector.js b/src/collector.js new file mode 100644 index 0000000..6980181 --- /dev/null +++ b/src/collector.js @@ -0,0 +1,377 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { PLATFORMS } from "./sites.js"; +import { findProgramPage, findProgramPageQuick } from "./search.js"; +import { scrapeUrl } from "./scraper.js"; +import { getKnownProgramUrls } from "./linkLibrary.js"; +import { textMatchesProgram } from "./identity.js"; +import { assessCredibility } from "./credibility.js"; + +export async function collectProgramHotness(programName, options = {}) { + const capturedAt = options.capturedAt || new Date().toISOString(); + const delayMs = Number.isFinite(options.delayMs) ? options.delayMs : 1_200; + const knownProgramUrls = await getKnownProgramUrls(programName); + const freshSearchPlatforms = new Set(options.freshSearchPlatforms || []); + const urlOverrides = { + ...knownProgramUrls, + ...compactUrls(options.urls || {}), + }; + const selectedPlatforms = selectedPlatformConfigs(options.platforms); + const results = []; + + if (options.parallelPlatforms) { + results.push(...await Promise.all(selectedPlatforms.map((platform) => collectPlatformHotness({ + platform, + programName, + capturedAt, + knownProgramUrls, + freshSearchPlatforms, + urlOverrides, + all: options.all, + quickSearch: options.quickSearch, + })))); + } else { + for (const [index, platform] of selectedPlatforms.entries()) { + if (index > 0 && delayMs > 0) await sleep(delayMs); + results.push(await collectPlatformHotness({ + platform, + programName, + capturedAt, + knownProgramUrls, + freshSearchPlatforms, + urlOverrides, + all: options.all, + quickSearch: options.quickSearch, + })); + } + } + + return { + name: programName, + captured_at: capturedAt, + results, + }; +} + +async function collectPlatformHotness({ platform, programName, capturedAt, knownProgramUrls, freshSearchPlatforms, urlOverrides, all, quickSearch }) { + const knownUrl = freshSearchPlatforms.has(platform.id) ? "" : (urlOverrides[platform.id] || ""); + let rejectedKnownUrl = ""; + if (knownUrl) { + const scraped = await scrapeUrl({ + platform: platform.id, + name: programName, + url: knownUrl, + }, { + fetchedAt: capturedAt, + all, + }); + + if (shouldKeepKnownScrape(scraped, programName)) { + const credible = addCredibility(scraped, programName); + return { + ...credible, + platform_label: platform.label, + metric_label: credible.metric_label || platform.metricLabel, + search_url: "", + search_candidates: [], + }; + } + + if (pageBelongsToProgram(scraped, programName)) { + return noMetricResult({ + platform, + programName, + scraped, + capturedAt, + }); + } + + rejectedKnownUrl = knownUrl; + } + + const builtInUrl = freshSearchPlatforms.has(platform.id) ? "" : (knownProgramUrls[platform.id] || ""); + if (builtInUrl && builtInUrl !== knownUrl) { + const scraped = await scrapeUrl({ + platform: platform.id, + name: programName, + url: builtInUrl, + }, { + fetchedAt: capturedAt, + all, + }); + + if (shouldKeepKnownScrape(scraped, programName)) { + const credible = addCredibility(scraped, programName); + return { + ...credible, + platform_label: platform.label, + metric_label: credible.metric_label || platform.metricLabel, + search_url: "", + search_candidates: [], + }; + } + + if (pageBelongsToProgram(scraped, programName)) { + return noMetricResult({ + platform, + programName, + scraped, + capturedAt, + }); + } + } + + const found = await (quickSearch ? findProgramPageQuick : findProgramPage)(platform.id, programName); + if (!found.url) { + const searchMetric = await scrapeSearchResultMetric({ + platform, + programName, + found, + capturedAt, + all, + }); + if (searchMetric) return searchMetric; + + return { + platform: platform.id, + platform_label: platform.label, + metric_label: platform.metricLabel, + name: programName, + url: "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + evidence: "", + status: found.status, + fetched_at: capturedAt, + error: rejectedKnownUrl + ? `stored URL did not match program title: ${rejectedKnownUrl}` + : found.error, + credibility: { + level: "rejected", + label: "拒绝", + reason: rejectedKnownUrl ? "已保存 URL 与当前节目不匹配" : "未找到可确认的节目页", + }, + search_url: found.searchUrl || "", + clear_url: Boolean(rejectedKnownUrl), + }; + } + + const scrapedMatch = await scrapeFirstMatchingCandidate({ + platform, + programName, + found, + capturedAt, + all, + }); + + if (scrapedMatch.noMetric) { + return noMetricResult({ + platform, + programName, + scraped: scrapedMatch.noMetric, + candidate: scrapedMatch.candidate, + capturedAt, + searchUrl: found.searchUrl || "", + searchCandidates: found.candidates, + }); + } + + if (!scrapedMatch.result) { + const rejected = scrapedMatch.rejected[0] || {}; + if (pageBelongsToProgram(rejected, programName, scrapedMatch.rejectedCandidate)) { + return noMetricResult({ + platform, + programName, + scraped: rejected, + capturedAt, + searchUrl: found.searchUrl || "", + searchCandidates: found.candidates, + }); + } + const status = rejected.status && rejected.status !== "ok" && !hasIdentityEvidence(rejected) + ? rejected.status + : "no_match"; + const error = status === "no_match" + ? `matched page did not belong to requested program: ${rejected.url || found.url}` + : (rejected.error || found.error || "candidate page fetch failed"); + return { + platform: platform.id, + platform_label: platform.label, + metric_label: rejected.metric_label || platform.metricLabel, + name: programName, + url: "", + page_title: rejected.page_title || "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + evidence: rejected.evidence || "", + status, + fetched_at: capturedAt, + error, + credibility: { + level: status === "no_match" ? "rejected" : "", + label: status === "no_match" ? "拒绝" : "", + reason: status === "no_match" ? "搜索候选页面与当前节目不匹配" : "", + }, + search_url: found.searchUrl || "", + search_candidates: found.candidates, + }; + } + + const credibleResult = addCredibility(scrapedMatch.result, programName, scrapedMatch.candidate); + return { + ...credibleResult, + platform_label: platform.label, + metric_label: credibleResult.metric_label || platform.metricLabel, + search_url: found.searchUrl || "", + search_candidates: found.candidates, + }; +} + +async function scrapeSearchResultMetric({ platform, programName, found, capturedAt, all }) { + if (platform.id !== "iqiyi" || !found.searchUrl) return null; + + const scraped = await scrapeUrl({ + platform: platform.id, + name: programName, + url: found.searchUrl, + }, { + fetchedAt: capturedAt, + all, + }); + + if (scraped.status !== "ok" || !scrapedResultMatchesProgram(scraped, programName)) return null; + + const credible = addCredibility({ + ...scraped, + url: "", + page_title: scraped.page_title || "爱奇艺搜索结果页", + error: "", + }, programName); + + return { + ...credible, + platform_label: platform.label, + metric_label: credible.metric_label || platform.metricLabel, + search_url: found.searchUrl || "", + search_candidates: found.candidates || [], + }; +} + +function compactUrls(urls) { + return Object.fromEntries(Object.entries(urls) + .filter(([platform, url]) => String(url || "").trim() && !isSearchPageUrl(url, platform))); +} + +function isSearchPageUrl(url, platformId) { + try { + const parsed = new URL(url); + if (platformId === "tencent") return /\/x\/search\//.test(parsed.pathname); + if (platformId === "youku") return /\/search/.test(parsed.pathname) || parsed.hostname === "so.youku.com"; + if (platformId === "iqiyi") return /\/so(?:\/|$)/.test(parsed.pathname) || parsed.hostname === "so.iqiyi.com"; + if (platformId === "mgtv") return /\/so/.test(parsed.pathname) || parsed.hostname === "so.mgtv.com"; + } catch {} + return false; +} + +function selectedPlatformConfigs(platforms) { + if (!Array.isArray(platforms) || platforms.length === 0) return PLATFORMS; + const selected = new Set(platforms.map((platform) => String(platform || "").trim())); + const matched = PLATFORMS.filter((platform) => selected.has(platform.id)); + return matched.length ? matched : PLATFORMS; +} + +async function scrapeFirstMatchingCandidate({ platform, programName, found, capturedAt, all }) { + const candidates = uniqueCandidateUrls(found); + const rejected = []; + let rejectedCandidate = null; + + for (const candidate of candidates.slice(0, 4)) { + const scraped = await scrapeUrl({ + platform: platform.id, + name: programName, + url: candidate.url, + }, { + fetchedAt: capturedAt, + all, + }); + + if (scraped.status === "ok" && scrapedResultMatchesProgram(scraped, programName, candidate)) { + return { result: scraped, candidate, rejected }; + } + if (pageBelongsToProgram(scraped, programName, candidate)) { + return { result: null, noMetric: scraped, candidate, rejected }; + } + rejected.push(scraped); + if (!rejectedCandidate) rejectedCandidate = candidate; + } + + return { result: null, rejected, rejectedCandidate }; +} + +function uniqueCandidateUrls(found) { + const candidates = (found.candidates?.length ? found.candidates : [{ url: found.url }]) + .filter((candidate) => candidate?.url); + const seen = new Set(); + return candidates.filter((candidate) => { + if (seen.has(candidate.url)) return false; + seen.add(candidate.url); + return true; + }); +} + +function addCredibility(result, programName, candidate = null) { + return { + ...result, + credibility: assessCredibility(result, programName, candidate), + }; +} + +function noMetricResult({ platform, programName, scraped, candidate = null, capturedAt, searchUrl = "", searchCandidates = [] }) { + return { + platform: platform.id, + platform_label: platform.label, + metric_label: scraped.metric_label || platform.metricLabel, + name: programName, + url: scraped.url || "", + page_title: scraped.page_title || "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + evidence: scraped.evidence || candidate?.evidence || "", + status: "no_metric", + fetched_at: capturedAt, + error: scraped.error || "program page found, but no visible metric was detected", + credibility: { + level: "medium", + label: "已确认节目页", + reason: "页面标题匹配当前节目,但页面中未识别到可采集指标", + }, + search_url: searchUrl, + search_candidates: searchCandidates, + }; +} + +function shouldKeepKnownScrape(result, programName) { + if (result.status === "ok" && scrapedResultMatchesProgram(result, programName)) return true; + return result.status !== "ok" && !hasIdentityEvidence(result); +} + +function scrapedResultMatchesProgram(result, programName, candidate = null) { + return textMatchesProgram(result.page_title, programName) + || textMatchesProgram(result.evidence, programName); +} + +function pageBelongsToProgram(result, programName, candidate = null) { + return textMatchesProgram(result.page_title, programName) + || textMatchesProgram(candidate?.pageTitle, programName) + || textMatchesProgram(result.evidence, programName) + || textMatchesProgram(candidate?.evidence, programName); +} + +function hasIdentityEvidence(result) { + return Boolean(result.page_title || result.evidence); +} diff --git a/src/credibility.js b/src/credibility.js new file mode 100644 index 0000000..dad76f4 --- /dev/null +++ b/src/credibility.js @@ -0,0 +1,54 @@ +import { textMatchesProgram } from "./identity.js"; + +export function assessCredibility(result, programName, candidate = null) { + if (!result || result.status !== "ok") { + return { + level: result?.status === "no_match" ? "rejected" : "", + label: result?.status === "no_match" ? "拒绝" : "", + reason: result?.status === "no_match" ? "页面标题和证据不足以确认属于当前节目" : "", + }; + } + + const titleMatch = textMatchesProgram(result.page_title, programName); + const evidenceMatch = textMatchesProgram(result.evidence, programName); + const candidateMatch = textMatchesProgram(candidate?.pageTitle, programName) + || textMatchesProgram(candidate?.evidence, programName); + + if (titleMatch && evidenceMatch) { + return { + level: "high", + label: "高可信", + reason: "页面标题和提取证据均匹配当前节目", + }; + } + + if (evidenceMatch) { + return { + level: "medium", + label: "中可信", + reason: "提取证据匹配当前节目,页面标题可能是合集或同系列入口", + }; + } + + if (titleMatch) { + return { + level: "medium", + label: "中可信", + reason: "页面标题匹配当前节目,但提取证据未包含节目名", + }; + } + + if (candidateMatch) { + return { + level: "low", + label: "低可信", + reason: "仅搜索候选匹配当前节目,页面证据不足", + }; + } + + return { + level: "rejected", + label: "拒绝", + reason: "页面标题和证据均未匹配当前节目", + }; +} diff --git a/src/csv.js b/src/csv.js new file mode 100644 index 0000000..730c774 --- /dev/null +++ b/src/csv.js @@ -0,0 +1,95 @@ +export function parseCsv(content) { + const rows = parseRows(content); + if (rows.length === 0) return []; + + const headers = rows[0].map((header) => header.trim()); + return rows.slice(1) + .filter((row) => row.some((cell) => cell.trim() !== "")) + .map((row) => { + const record = {}; + headers.forEach((header, index) => { + record[header] = row[index] ?? ""; + }); + return record; + }); +} + +export function stringifyCsv(records) { + if (records.length === 0) return ""; + + const headers = [ + "platform", + "metric_label", + "name", + "url", + "hotness_raw", + "hotness_number", + "unit", + "confidence", + "evidence", + "status", + "fetched_at", + "error", + ]; + + const lines = [headers.join(",")]; + for (const record of records) { + lines.push(headers.map((header) => csvEscape(record[header] ?? "")).join(",")); + } + return `${lines.join("\n")}\n`; +} + +function parseRows(content) { + const rows = []; + let row = []; + let cell = ""; + let inQuotes = false; + + for (let i = 0; i < content.length; i += 1) { + const char = content[i]; + const next = content[i + 1]; + + if (char === "\"" && inQuotes && next === "\"") { + cell += "\""; + i += 1; + continue; + } + + if (char === "\"") { + inQuotes = !inQuotes; + continue; + } + + if (char === "," && !inQuotes) { + row.push(cell); + cell = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") i += 1; + row.push(cell); + rows.push(row); + row = []; + cell = ""; + continue; + } + + cell += char; + } + + if (cell.length > 0 || row.length > 0) { + row.push(cell); + rows.push(row); + } + + return rows; +} + +function csvEscape(value) { + const text = String(value); + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, "\"\"")}"`; + } + return text; +} diff --git a/src/extract.js b/src/extract.js new file mode 100644 index 0000000..f67f1e0 --- /dev/null +++ b/src/extract.js @@ -0,0 +1,394 @@ +const BLOCK_PATTERNS = [ + /验证码/, + /安全验证/, + /访问过于频繁/, + /请求过于频繁/, + /人机验证/, + /_____tmd_____/, + /x5secdata/, + /\/punish\?/, + /captcha/i, +]; + +const HOTNESS_LABELS = [ + "热度值", + "热度指数", + "站内热度", + "播放热度", + "当前热度", + "最高热度", + "历史最高热度", + "腾讯视频热度", + "优酷热度", + "爱奇艺热度", + "芒果热度", + "热度", +]; + +const NUMBER = String.raw`([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)`; + +const LABEL_BEFORE_RE = new RegExp( + `(${HOTNESS_LABELS.join("|")})[^0-9]{0,24}${NUMBER}`, + "g", +); + +const VALUE_BEFORE_RE = new RegExp( + `${NUMBER}[^\\S\\r\\n]{0,8}(?:${HOTNESS_LABELS.join("|")})`, + "g", +); + +const JSON_KEY_RE = /["']?(?:heat|hot|hotness|hotNum|popularity|heatValue|hotValue|heat_value|hot_value|heatScore|hotScore|hotIndex|heatIndex|hot_index|heat_index)["']?\s*[:=]\s*["']?([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)/gi; + +const YOUKU_TITLE_HEAT_RE = /class=["'][^"']*new-title-heat[^"']*["'][^>]*>\s*([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)/gi; + +const PLAY_COUNT_RE = new RegExp( + `${NUMBER}\\s*(?:次)?\\s*(?:播放|观看|浏览)`, + "g", +); + +const PLAY_COUNT_LABEL_BEFORE_RE = new RegExp( + `(播放量|播放次数|累计播放|总播放|播放)[^0-9]{0,24}${NUMBER}\\s*(?:次)?`, + "g", +); + +const MGTV_ALBUM_COUNT_RE = new RegExp( + `${NUMBER}\\s*共\\s*[0-9]+\\s*集`, + "g", +); + +export function extractHotness(html, options = {}) { + const text = htmlToSearchableText(html); + const candidates = []; + + if (BLOCK_PATTERNS.some((pattern) => pattern.test(text))) { + return { + blocked: true, + candidates: [], + best: null, + }; + } + + collectPlatformSpecific(html, text, candidates, options); + collectLabelBefore(text, candidates); + collectValueBefore(text, candidates); + collectJsonKeys(html, candidates); + + const deduped = dedupeCandidates(candidates) + .sort((a, b) => b.confidence - a.confidence || a.index - b.index); + + return { + blocked: false, + candidates: options.all ? deduped : deduped.slice(0, 1), + best: deduped[0] || null, + }; +} + +function collectPlatformSpecific(html, text, candidates, options) { + const platformCollectors = { + tencent: collectTencentCandidates, + youku: collectYoukuCandidates, + iqiyi: collectIqiyiCandidates, + mgtv: collectMgtvCandidates, + }; + platformCollectors[options.platform]?.(html, text, candidates, options); +} + +function collectTencentCandidates(_html, text, candidates) { + collectTencentHeatJson(text, candidates); +} + +function collectYoukuCandidates(html, _text, candidates) { + collectYoukuTitleHeat(html, candidates); +} + +function collectIqiyiCandidates(html, _text, candidates, options) { + if (options.programName) collectIqiyiProgramBlock(html, candidates, options.programName); +} + +function collectMgtvCandidates(_html, text, candidates) { + collectPlaybackCounts(text, candidates); + collectMgtvAlbumCounts(text, candidates); +} + +function collectTencentHeatJson(text, candidates) { + for (const match of text.matchAll(/(?:腾讯视频)?热度值[^0-9]{0,24}([0-9][0-9,\s]*(?:\.[0-9]+)?)/g)) { + const [, raw] = match; + candidates.push(makeCandidate({ + raw, + label: "tencent-heat", + evidence: snippet(text, match.index, match[0].length), + source: "tencent-heat", + metricLabel: "热度值", + index: match.index, + confidence: 0.93, + })); + } +} + +function collectIqiyiProgramBlock(html, candidates, programName) { + const decoded = decodeHtmlEntities(html).replace(/\\\//g, "/"); + const keyword = normalizeSearchText(programName); + if (!keyword) return; + + for (const block of decoded.matchAll(//gi)) { + const rawBlock = block[0]; + const text = rawBlock.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + if (!normalizeSearchText(text).includes(keyword)) continue; + + const heat = rawBlock.match(/class=["'][^"']*heat-num[^"']*["'][\s\S]*?<\/i>\s*([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)/i); + if (!heat) continue; + + candidates.push(makeCandidate({ + raw: heat[1], + label: "iqiyi-related-heat", + evidence: text, + source: "iqiyi-related-heat", + metricLabel: "内容热度", + index: block.index || 0, + confidence: 0.97, + })); + } + + collectIqiyiSearchTextHeat(decoded, candidates, keyword); +} + +function collectIqiyiSearchTextHeat(decoded, candidates, keyword) { + const text = decoded.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + const heatPatterns = [ + /热度\s*([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)/g, + /([0-9][0-9,\s]*(?:\.[0-9]+)?\s*(?:万|亿|k|K|w|W)?)\s*前往[^。;;]{0,30}热度/g, + ]; + + for (const pattern of heatPatterns) { + for (const match of text.matchAll(pattern)) { + const raw = match[1]; + const evidence = snippet(text, match.index, match[0].length, 180); + if (!normalizeSearchText(evidence).includes(keyword)) continue; + + candidates.push(makeCandidate({ + raw, + label: "iqiyi-search-result-heat", + evidence, + source: "iqiyi-search-result-heat", + metricLabel: "内容热度", + index: match.index || 0, + confidence: 0.9, + })); + } + } +} + +export function normalizeHotness(raw) { + if (!raw) { + return { + raw: "", + number: null, + unit: "", + }; + } + + const rawText = String(raw).trim(); + const compact = rawText + .replace(/\s+/g, "") + .replace(/,/g, "") + .replace(/次播放$/, "次") + .replace(/播放$/, ""); + const match = compact.match(/^([0-9]+(?:\.[0-9]+)?)(万|亿|k|K|w|W)?(次)?$/); + if (!match) { + return { + raw: String(raw).trim(), + number: null, + unit: "", + }; + } + + const value = Number(match[1]); + const numericUnit = match[2] || ""; + const countUnit = match[3] || ""; + const unit = `${numericUnit}${countUnit}`; + const multiplier = { + "万": 10_000, + "亿": 100_000_000, + k: 1_000, + K: 1_000, + w: 10_000, + W: 10_000, + }[numericUnit] || 1; + + return { + raw: countUnit ? compact : rawText, + number: Math.round(value * multiplier * 100) / 100, + unit, + }; +} + +function collectLabelBefore(text, candidates) { + for (const match of text.matchAll(LABEL_BEFORE_RE)) { + const [, label, raw] = match; + candidates.push(makeCandidate({ + raw, + label, + evidence: snippet(text, match.index, match[0].length), + source: "label-before", + metricLabel: label, + index: match.index, + confidence: label.includes("热度值") || label.includes("热度指数") ? 0.92 : 0.86, + })); + } +} + +function collectValueBefore(text, candidates) { + for (const match of text.matchAll(VALUE_BEFORE_RE)) { + const [, raw] = match; + candidates.push(makeCandidate({ + raw, + label: "热度", + evidence: snippet(text, match.index, match[0].length), + source: "value-before", + metricLabel: "热度值", + index: match.index, + confidence: 0.8, + })); + } +} + +function collectJsonKeys(html, candidates) { + const scriptText = decodeHtmlEntities(html); + for (const match of scriptText.matchAll(JSON_KEY_RE)) { + const [, raw] = match; + candidates.push(makeCandidate({ + raw, + label: "json-hotness-key", + evidence: snippet(scriptText, match.index, match[0].length), + source: "json-key", + metricLabel: "热度值", + index: match.index, + confidence: 0.76, + })); + } +} + +function collectYoukuTitleHeat(html, candidates) { + const scriptText = decodeHtmlEntities(html); + for (const match of scriptText.matchAll(YOUKU_TITLE_HEAT_RE)) { + const [, raw] = match; + candidates.push(makeCandidate({ + raw, + label: "youku-title-heat", + evidence: snippet(scriptText, match.index, match[0].length), + source: "youku-title-heat", + metricLabel: "热度值", + index: match.index, + confidence: 0.88, + })); + } +} + +function collectPlaybackCounts(text, candidates) { + for (const match of text.matchAll(PLAY_COUNT_RE)) { + candidates.push(makeCandidate({ + raw: match[0].replace(/(播放|观看|浏览)$/g, ""), + label: "播放次数", + evidence: snippet(text, match.index, match[0].length), + source: "play-count", + metricLabel: "播放次数", + index: match.index, + confidence: 0.9, + })); + } + + for (const match of text.matchAll(PLAY_COUNT_LABEL_BEFORE_RE)) { + const [, label, raw] = match; + candidates.push(makeCandidate({ + raw, + label, + evidence: snippet(text, match.index, match[0].length), + source: "play-count-label", + metricLabel: "播放次数", + index: match.index, + confidence: 0.88, + })); + } +} + +function collectMgtvAlbumCounts(text, candidates) { + for (const match of text.matchAll(MGTV_ALBUM_COUNT_RE)) { + candidates.push(makeCandidate({ + raw: match[1], + label: "播放次数", + evidence: snippet(text, match.index, match[0].length), + source: "mgtv-album-count", + metricLabel: "播放次数", + index: match.index, + confidence: 0.82, + })); + } +} + +function makeCandidate({ raw, label, evidence, source, metricLabel, index = 0, confidence }) { + const normalized = normalizeHotness(raw); + return { + label, + metricLabel, + index, + hotnessRaw: normalized.raw, + hotnessNumber: normalized.number, + unit: normalized.unit, + evidence: evidence.trim(), + source, + confidence, + }; +} + +function dedupeCandidates(candidates) { + const seen = new Set(); + const results = []; + + for (const candidate of candidates) { + if (candidate.hotnessNumber == null) continue; + if (candidate.hotnessNumber <= 0) continue; + + const key = `${candidate.source}:${candidate.hotnessRaw}:${candidate.evidence}`; + if (seen.has(key)) continue; + seen.add(key); + results.push(candidate); + } + + return results; +} + +function htmlToSearchableText(html) { + return decodeHtmlEntities(html) + .replace(/]*>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function decodeHtmlEntities(value) { + return String(value) + .replace(/ /g, " ") + .replace(/"/g, "\"") + .replace(/"/g, "\"") + .replace(/"/gi, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/gi, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function snippet(text, index = 0, length = 0, padding = 40) { + const start = Math.max(0, index - padding); + const end = Math.min(text.length, index + length + padding); + return text.slice(start, end).replace(/\s+/g, " "); +} + +function normalizeSearchText(value) { + return String(value || "") + .toLowerCase() + .replace(/[《》【】[\]()()::\s\-_/]+/g, ""); +} diff --git a/src/identity.js b/src/identity.js new file mode 100644 index 0000000..422deb3 --- /dev/null +++ b/src/identity.js @@ -0,0 +1,92 @@ +export function textMatchesProgram(text, programName) { + if (!text) return false; + const haystack = normalizeProgramText(text); + const tokens = programTokens(programName); + if (tokens.length === 0) return false; + if (tokens.every((token) => haystack.includes(token))) return true; + + const needle = normalizeProgramText(programName); + return nearContains(haystack, needle); +} + +export function programTokens(value) { + const normalized = String(value || "").split(/[\s::\-_/]+/) + .map(normalizeProgramText) + .filter((token) => token.length >= 2); + return [...new Set(normalized)]; +} + +export function normalizeProgramText(value) { + return normalizeSeasonNumber(String(value || "")) + .toLowerCase() + .replace(/[《》【】[\]()()::\s\-_/]+/g, ""); +} + +function normalizeSeasonNumber(value) { + return value.replace(/第([一二三四五六七八九十0-9]+)(季|部|辑)/g, (_, raw) => chineseNumber(raw)); +} + +function chineseNumber(value) { + if (/^[0-9]+$/.test(value)) return value; + const digits = { + 一: 1, + 二: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, + }; + if (value === "十") return "10"; + if (value.startsWith("十")) return String(10 + (digits[value[1]] || 0)); + if (value.includes("十")) { + const [tens, ones] = value.split("十"); + return String((digits[tens] || 1) * 10 + (digits[ones] || 0)); + } + return String(digits[value] || value); +} + +function nearContains(haystack, needle) { + if (!haystack || !needle || needle.length < 6) return false; + if (haystack.includes(needle)) return true; + + const maxDistance = needle.length >= 10 ? 2 : 1; + const minLength = Math.max(4, needle.length - maxDistance); + const maxLength = needle.length + maxDistance; + const searchable = haystack.slice(0, 5000); + + for (let length = minLength; length <= maxLength; length += 1) { + if (length > searchable.length) continue; + for (let index = 0; index <= searchable.length - length; index += 1) { + const candidate = searchable.slice(index, index + length); + if (editDistanceWithin(candidate, needle, maxDistance)) return true; + } + } + return false; +} + +function editDistanceWithin(left, right, maxDistance) { + if (Math.abs(left.length - right.length) > maxDistance) return false; + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let i = 1; i <= left.length; i += 1) { + const current = [i]; + let rowMin = current[0]; + for (let j = 1; j <= right.length; j += 1) { + const cost = left[i - 1] === right[j - 1] ? 0 : 1; + const value = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + cost, + ); + current[j] = value; + rowMin = Math.min(rowMin, value); + } + if (rowMin > maxDistance) return false; + previous = current; + } + + return previous[right.length] <= maxDistance; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4270b5e --- /dev/null +++ b/src/index.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; +import { parseCsv, stringifyCsv } from "./csv.js"; +import { scrapeUrl } from "./scraper.js"; + +const DEFAULT_DELAY_MS = 2_000; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help || (!args.url && !args.input)) { + printHelp(); + return; + } + + const records = await loadInput(args); + const delayMs = toInteger(args.delay, DEFAULT_DELAY_MS); + const results = []; + + for (let index = 0; index < records.length; index += 1) { + const item = records[index]; + if (index > 0 && delayMs > 0) await sleep(delayMs); + results.push(await scrapeUrl(item, { + all: args.all, + debugHtmlPath: args.debugHtml, + })); + } + + await writeOutput(results, args); +} + +async function loadInput(args) { + if (args.input) { + const content = await readFile(args.input, "utf8"); + return parseCsv(content).map((row) => ({ + platform: row.platform || "", + name: row.name || "", + url: row.url || "", + })); + } + + return [{ + platform: args.platform || "", + name: args.name || "", + url: args.url, + }]; +} + +async function writeOutput(results, args) { + const format = args.format || "csv"; + const content = format === "json" + ? `${JSON.stringify(results, null, 2)}\n` + : stringifyCsv(results); + + if (args.out) { + await writeFile(args.out, content, "utf8"); + console.error(`Wrote ${results.length} result(s) to ${args.out}`); + return; + } + + process.stdout.write(content); +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + + const key = toCamelCase(arg.slice(2)); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + continue; + } + + args[key] = next; + index += 1; + } + + return args; +} + +function toCamelCase(value) { + return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} + +function toInteger(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function printHelp() { + process.stdout.write(`视频节目热度抓取工具 + +Usage: + node src/index.js --url [--platform tencent|youku|iqiyi|mgtv] + node src/index.js --input programs.csv [--out hotness.csv] + +Options: + --url 抓取单个节目页面 + --input 从 CSV 批量读取,字段为 platform,name,url + --platform 手动指定平台 + --name 单 URL 模式下的节目名 + --out 写入输出文件,默认打印到终端 + --format csv|json 输出格式,默认 csv + --delay 每条之间的等待时间,默认 2000 + --all JSON 输出时包含所有候选热度 + --debug-html 保存最后一次请求到的 HTML,便于调规则 + --help 显示帮助 +`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/kidsTrend.js b/src/kidsTrend.js new file mode 100644 index 0000000..00ececb --- /dev/null +++ b/src/kidsTrend.js @@ -0,0 +1,85 @@ +import { PLATFORMS } from "./sites.js"; + +export function analyzeKidsTrend(history) { + const platformTrends = PLATFORMS.map((platform) => platformTrend(history, platform.id)); + const valued = platformTrends.filter((item) => item.latest_status === "ok"); + const growing = valued.filter((item) => Number.isFinite(item.delta) && item.delta > 0); + const bestDelta = growing.length ? Math.max(...growing.map((item) => item.delta)) : 0; + const bestGrowthRate = growing.length ? Math.max(...growing.map((item) => item.growth_rate || 0)) : 0; + + let verdict = "no_data"; + let label = "暂无数值"; + let recommendation = "先不关注"; + + if (valued.length > 0 && growing.length === 0) { + verdict = "new_signal"; + label = "新有数值"; + recommendation = "再采一次"; + } + if (growing.length > 0) { + verdict = "rising"; + label = "在增长"; + recommendation = "继续观察"; + } + if (growing.length >= 2 || bestGrowthRate >= 0.3 || bestDelta >= 300) { + verdict = "strong_growth"; + label = "强增长"; + recommendation = "重点跟踪"; + } + if (valued.length >= 2 && verdict === "new_signal") { + verdict = "multi_platform"; + label = "多平台有数"; + recommendation = "值得观察"; + } + + return { + name: history?.name || "", + verdict, + label, + recommendation, + platforms_with_value: valued.length, + growing_platforms: growing.length, + best_delta: bestDelta, + best_growth_rate: bestGrowthRate, + platform_trends: Object.fromEntries(platformTrends.map((item) => [item.platform, item])), + }; +} + +function platformTrend(history, platformId) { + const row = history?.platforms?.[platformId] || {}; + const runs = [...(history?.runs || [])].sort().reverse(); + const values = []; + + for (const run of runs) { + const value = row.values?.[run]; + if (value?.status !== "ok") continue; + const number = Number(value.number); + if (!Number.isFinite(number)) continue; + values.push({ + run, + raw: value.raw || String(value.number || ""), + number, + }); + if (values.length >= 2) break; + } + + const latest = values[0] || null; + const previous = values[1] || null; + const delta = latest && previous ? latest.number - previous.number : null; + const growthRate = Number.isFinite(delta) && previous?.number + ? delta / previous.number + : null; + + return { + platform: platformId, + latest_status: latest ? "ok" : "missing", + latest_raw: latest?.raw || "", + latest_number: latest?.number || "", + latest_run: latest?.run || "", + previous_raw: previous?.raw || "", + previous_number: previous?.number || "", + previous_run: previous?.run || "", + delta, + growth_rate: growthRate, + }; +} diff --git a/src/known.js b/src/known.js new file mode 100644 index 0000000..0999896 --- /dev/null +++ b/src/known.js @@ -0,0 +1,85 @@ +export const KNOWN_PROGRAM_URLS = [ + { + aliases: [ + "星愿甜心生肖奇遇记", + "星愿甜心:生肖奇遇记", + "星愿甜心 生肖奇遇记", + ], + urls: { + youku: "http://www.youku.com/show_page/id_zacbcc72e4dbf44e7a960.html", + iqiyi: "https://www.iqiyi.com/a_1mq7qanyl7p.html", + }, + }, + { + aliases: [ + "星愿甜心织梦大作战", + "星愿甜心:织梦大作战", + "星愿甜心 织梦大作战", + ], + urls: { + tencent: "https://v.qq.com/x/cover/mzc00200vr6nagn.html", + youku: "https://v.youku.com/video?s=cfbe56bb481d4b0380e3", + iqiyi: "https://www.iqiyi.com/a_1mq7qanyl7p.html", + }, + }, + { + aliases: [ + "星愿少女契约之约", + "星愿少女:契约之约", + "星愿少女 契约之约", + ], + urls: { + tencent: "https://v.qq.com/x/cover/mzc00200cwl7bzq.html", + youku: "https://v.youku.com/video?s=cfaa440439104059a1ac", + iqiyi: "https://www.iqiyi.com/a_283hcshsqm5.html", + }, + }, + { + aliases: [ + "魔法少女莎莎", + ], + urls: { + tencent: "https://v.qq.com/x/cover/mzc002006584inx/n4102ctgbe6.html", + iqiyi: "https://www.iqiyi.com/a_1pza8jvovcp.html", + mgtv: "https://www.mgtv.com/h/822848.html", + }, + }, + { + aliases: [ + "海底小纵队 中国之旅3", + "海底小纵队 中国之旅 第三季", + "海底小纵队中国之旅3", + "海底小纵队中国之旅 第3季", + "海底小纵队中国之旅 第三季", + ], + urls: { + tencent: "https://v.qq.com/x/cover/mzc002002r88ch5.html", + youku: "https://v.youku.com/v_show/id_XNjUxNTI3NDQwMA==.html?s=bdfec875773d41008b39", + iqiyi: "https://www.iqiyi.com/a_kiaj6mgyeh.html", + mgtv: "https://www.mgtv.com/h/841564.html", + }, + }, + { + aliases: [ + "咖宝车神之超能救援", + "咖宝车神之超能救援队", + ], + urls: { + youku: "https://v.youku.com/video?s=eecf25b4def245c4a9c0", + iqiyi: "https://www.iqiyi.com/a_153iooump3p.html", + mgtv: "https://www.mgtv.com/h/854311.html", + }, + }, +]; + +export function getKnownProgramUrls(programName) { + const key = normalizeProgramName(programName); + const matched = KNOWN_PROGRAM_URLS.find((item) => item.aliases.some((alias) => normalizeProgramName(alias) === key)); + return matched ? { ...matched.urls } : {}; +} + +export function normalizeProgramName(value) { + return String(value || "") + .toLowerCase() + .replace(/[《》【】[\]()()::\s\-_/]+/g, ""); +} diff --git a/src/linkLibrary.js b/src/linkLibrary.js new file mode 100644 index 0000000..63fefea --- /dev/null +++ b/src/linkLibrary.js @@ -0,0 +1,185 @@ +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { KNOWN_PROGRAM_URLS, normalizeProgramName } from "./known.js"; +import { detectPlatform, normalizePlatformUrl, PLATFORMS } from "./sites.js"; + +const DATA_DIR = path.resolve(process.env.HOTNESS_DATA_DIR || path.join(process.cwd(), "data")); +const LIBRARY_FILE = path.join(DATA_DIR, "link-library.json"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); +const PLATFORM_IDS = new Set(PLATFORMS.map((platform) => platform.id)); + +export async function getKnownProgramUrls(programName) { + return (await getProgramLinkEntry(programName)).urls; +} + +export async function getProgramLinkEntry(programName) { + const key = normalizeProgramName(programName); + const staticEntry = getStaticEntry(key); + const library = await readLinkLibrary(); + const saved = library.programs[key] || {}; + + return { + name: saved.name || programName || staticEntry.name || "", + aliases: uniqueStrings([ + ...(staticEntry.aliases || []), + ...(saved.aliases || []), + ]), + urls: compactUrls({ + ...(staticEntry.urls || {}), + ...(saved.urls || {}), + }), + updated_at: saved.updated_at || "", + source: saved.name ? "library" : (staticEntry.name ? "builtin" : ""), + }; +} + +export async function saveProgramLinkEntry({ name, aliases = [], urls = {} }) { + const cleanName = String(name || "").trim(); + if (!cleanName) throw new Error("节目名不能为空"); + + const sanitizedUrls = validatePlatformUrls(urls); + const library = await readLinkLibrary(); + const key = normalizeProgramName(cleanName); + const existing = library.programs[key] || {}; + const mergedUrls = { ...(existing.urls || {}) }; + for (const platform of PLATFORMS) { + if (!Object.hasOwn(urls || {}, platform.id)) continue; + if (String(urls[platform.id] || "").trim()) { + mergedUrls[platform.id] = sanitizedUrls[platform.id]; + } else { + delete mergedUrls[platform.id]; + } + } + + const entry = { + name: cleanName, + aliases: uniqueStrings([ + cleanName, + ...splitAliases(aliases), + ]), + urls: compactUrls(mergedUrls), + updated_at: new Date().toISOString(), + }; + + library.programs[key] = entry; + await writeLinkLibrary(library); + return getProgramLinkEntry(cleanName); +} + +export async function deleteProgramLinkEntry(programName) { + const key = normalizeProgramName(programName); + const library = await readLinkLibrary(); + delete library.programs[key]; + await writeLinkLibrary(library); + return getProgramLinkEntry(programName); +} + +export function validatePlatformUrls(urls) { + const result = {}; + for (const platform of PLATFORMS) { + const raw = normalizePlatformUrl(urls?.[platform.id] || "", platform.id); + if (!raw) continue; + + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error(`${platform.label} URL 格式不正确`); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${platform.label} URL 只能是 http 或 https`); + } + + const detected = detectPlatform(parsed.toString()); + if (detected !== platform.id) { + throw new Error(`${platform.label} URL 不是对应平台的节目页`); + } + + if (isSearchPageUrl(parsed.toString(), platform.id)) { + throw new Error(`${platform.label} URL 不能是搜索结果页`); + } + + result[platform.id] = parsed.toString(); + } + return result; +} + +function isSearchPageUrl(url, platformId) { + try { + const parsed = new URL(url); + if (platformId === "tencent") return /\/x\/search\//.test(parsed.pathname); + if (platformId === "youku") return /\/search/.test(parsed.pathname) || parsed.hostname === "so.youku.com"; + if (platformId === "iqiyi") return /\/so(?:\/|$)/.test(parsed.pathname) || parsed.hostname === "so.iqiyi.com"; + if (platformId === "mgtv") return /\/so/.test(parsed.pathname) || parsed.hostname === "so.mgtv.com"; + } catch {} + return false; +} + +async function readLinkLibrary() { + try { + const content = await readFile(LIBRARY_FILE, "utf8"); + return normalizeLibrary(JSON.parse(content)); + } catch (error) { + if (error.code === "ENOENT") return normalizeLibrary({}); + throw error; + } +} + +async function writeLinkLibrary(library) { + await mkdir(DATA_DIR, { recursive: true }); + await backupLinkLibraryFile(); + await writeFile(LIBRARY_FILE, `${JSON.stringify(normalizeLibrary(library), null, 2)}\n`, "utf8"); +} + +async function backupLinkLibraryFile() { + try { + await mkdir(BACKUP_DIR, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await copyFile(LIBRARY_FILE, path.join(BACKUP_DIR, `link-library-${stamp}.json`)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +function normalizeLibrary(library) { + const normalized = { + version: 1, + programs: library.programs || {}, + }; + + for (const [key, entry] of Object.entries(normalized.programs)) { + entry.name = String(entry.name || key).trim(); + entry.aliases = uniqueStrings(entry.aliases || []); + entry.urls = compactUrls(entry.urls || {}); + entry.updated_at = entry.updated_at || ""; + } + + return normalized; +} + +function getStaticEntry(key) { + const item = KNOWN_PROGRAM_URLS.find((entry) => entry.aliases.some((alias) => normalizeProgramName(alias) === key)); + if (!item) return { name: "", aliases: [], urls: {} }; + return { + name: item.aliases[0] || "", + aliases: item.aliases || [], + urls: item.urls || {}, + }; +} + +function compactUrls(urls) { + return Object.fromEntries(Object.entries(urls) + .filter(([platform, url]) => PLATFORM_IDS.has(platform) && String(url || "").trim())); +} + +function splitAliases(value) { + if (Array.isArray(value)) return value; + return String(value || "").split(/[,,\n]/); +} + +function uniqueStrings(values) { + return [...new Set(values + .map((value) => String(value || "").trim()) + .filter(Boolean))]; +} diff --git a/src/native-launcher/HotnessDisableStartup.cs b/src/native-launcher/HotnessDisableStartup.cs new file mode 100644 index 0000000..9a04eee --- /dev/null +++ b/src/native-launcher/HotnessDisableStartup.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Windows.Forms; + +namespace VideoHotnessDesktop +{ + internal static class HotnessDisableStartup + { + [STAThread] + private static void Main() + { + try + { + string startupDir = Environment.GetFolderPath(Environment.SpecialFolder.Startup); + string startupFile = Path.Combine(startupDir, "节目热度采集工具-开机启动.cmd"); + if (File.Exists(startupFile)) + { + File.Delete(startupFile); + MessageBox.Show("已取消开机自启动。", "开机自启动已取消", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + MessageBox.Show("当前没有设置开机自启动。", "无需取消", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + catch (Exception error) + { + MessageBox.Show(error.Message, "取消开机自启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } +} diff --git a/src/native-launcher/HotnessEnableStartup.cs b/src/native-launcher/HotnessEnableStartup.cs new file mode 100644 index 0000000..4db2d8b --- /dev/null +++ b/src/native-launcher/HotnessEnableStartup.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Text; +using System.Windows.Forms; + +namespace VideoHotnessDesktop +{ + internal static class HotnessEnableStartup + { + [STAThread] + private static void Main() + { + string root = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); + string launcher = Path.Combine(root, "节目热度采集工具-独立窗口版.exe"); + + if (!File.Exists(launcher)) + { + MessageBox.Show("找不到 节目热度采集工具-独立窗口版.exe,请确认程序放在项目根目录。", "开机自启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + try + { + string startupDir = Environment.GetFolderPath(Environment.SpecialFolder.Startup); + string startupFile = Path.Combine(startupDir, "节目热度采集工具-开机启动.cmd"); + string script = "@echo off\r\n" + + "cd /d \"" + root + "\"\r\n" + + "start \"\" \"" + launcher + "\"\r\n"; + File.WriteAllText(startupFile, script, Encoding.Default); + MessageBox.Show("已开启开机自启动。\r\n\r\n下次这台电脑登录 Windows 后,会自动启动节目热度采集工具。", "开机自启动已开启", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception error) + { + MessageBox.Show(error.Message, "开机自启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } +} diff --git a/src/native-launcher/HotnessWebViewApp.cs b/src/native-launcher/HotnessWebViewApp.cs new file mode 100644 index 0000000..5fc9889 --- /dev/null +++ b/src/native-launcher/HotnessWebViewApp.cs @@ -0,0 +1,358 @@ +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.WinForms; +using System; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace VideoHotnessDesktop +{ + internal static class HotnessWebViewApp + { + private const string AppMutexName = "Global\\VideoHotnessDesktopWebViewApp"; + + [STAThread] + private static void Main() + { + bool createdNew; + using (var mutex = new Mutex(true, AppMutexName, out createdNew)) + { + if (!createdNew) + { + MessageBox.Show("节目热度采集工具已经在运行。\r\n\r\n请从任务栏或右下角托盘打开现有窗口。", "已经在运行", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new MainForm()); + } + } + } + + internal sealed class MainForm : Form + { + private readonly string root; + private readonly string dataDir; + private readonly string token; + private readonly int port; + private readonly string appUrl; + private readonly string mobileUrl; + private readonly string statePath; + private readonly string logPath; + private readonly WebView2 webView; + private readonly NotifyIcon tray; + private readonly ToolStripStatusLabel statusLabel; + private Process serverProcess; + private bool isQuitting; + + public MainForm() + { + root = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); + dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + token = Guid.NewGuid().ToString("D"); + statePath = Path.Combine(root, ".hotness-webview-server.json"); + logPath = Path.Combine(dataDir, "webview-app.log"); + CleanupPreviousWebViewServer(); + port = FindFreePort(); + appUrl = "http://127.0.0.1:" + port + "/"; + mobileUrl = appUrl + "mobile.html"; + + Text = "节目热度采集工具"; + Width = 1480; + Height = 920; + MinimumSize = new Size(1080, 720); + StartPosition = FormStartPosition.CenterScreen; + + var menu = BuildMenu(); + MainMenuStrip = menu; + Controls.Add(menu); + + var status = new StatusStrip(); + statusLabel = new ToolStripStatusLabel("正在启动本地服务..."); + status.Items.Add(statusLabel); + Controls.Add(status); + + webView = new WebView2 + { + Dock = DockStyle.Fill + }; + Controls.Add(webView); + webView.BringToFront(); + + tray = new NotifyIcon + { + Icon = SystemIcons.Application, + Text = "节目热度采集工具", + Visible = true, + ContextMenuStrip = BuildTrayMenu() + }; + tray.DoubleClick += delegate { ShowMainWindow(); }; + + Load += async delegate { await StartAndLoadAsync(); }; + FormClosing += OnFormClosing; + } + + private MenuStrip BuildMenu() + { + var menu = new MenuStrip(); + var file = new ToolStripMenuItem("工具"); + file.DropDownItems.Add("重新加载", null, delegate { webView.Reload(); }); + file.DropDownItems.Add("打开手机页", null, delegate { OpenExternal(mobileUrl); }); + file.DropDownItems.Add("打开数据目录", null, delegate { OpenExternal(dataDir); }); + file.DropDownItems.Add(new ToolStripSeparator()); + file.DropDownItems.Add("退出后台", null, delegate { QuitApp(); }); + menu.Items.Add(file); + return menu; + } + + private ContextMenuStrip BuildTrayMenu() + { + var menu = new ContextMenuStrip(); + menu.Items.Add("打开主界面", null, delegate { ShowMainWindow(); }); + menu.Items.Add("打开手机页", null, delegate { OpenExternal(mobileUrl); }); + menu.Items.Add("打开数据目录", null, delegate { OpenExternal(dataDir); }); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出后台", null, delegate { QuitApp(); }); + return menu; + } + + private async Task StartAndLoadAsync() + { + try + { + StartServer(); + await WaitForServerAsync(); + await WriteStateAsync(); + Text = "节目热度采集工具 - " + appUrl; + tray.Text = "节目热度采集工具 " + appUrl; + statusLabel.Text = "已连接:" + appUrl + " 数据目录:" + dataDir; + + string userDataFolder = Path.Combine(dataDir, "webview2-profile"); + var environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder); + await webView.EnsureCoreWebView2Async(environment); + webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; + webView.CoreWebView2.Settings.AreDevToolsEnabled = true; + webView.CoreWebView2.DocumentTitleChanged += delegate + { + Text = "节目热度采集工具 - " + appUrl; + }; + webView.Source = new Uri(appUrl); + } + catch (Exception error) + { + Log(error.ToString()); + statusLabel.Text = "启动失败:" + error.Message; + MessageBox.Show(error.Message, "节目热度采集工具启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void StartServer() + { + string node = Path.Combine(root, "runtime", "node.exe"); + string server = Path.Combine(root, "src", "server.js"); + if (!File.Exists(node)) throw new FileNotFoundException("找不到 runtime\\node.exe", node); + if (!File.Exists(server)) throw new FileNotFoundException("找不到 src\\server.js", server); + + var info = new ProcessStartInfo + { + FileName = node, + Arguments = "\"src\\server.js\"", + WorkingDirectory = root, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + info.EnvironmentVariables["PORT"] = port.ToString(); + info.EnvironmentVariables["HOST"] = "::"; + info.EnvironmentVariables["HOTNESS_DESKTOP_ROOT"] = root; + info.EnvironmentVariables["HOTNESS_DESKTOP_TOKEN"] = token; + info.EnvironmentVariables["HOTNESS_DATA_DIR"] = dataDir; + info.EnvironmentVariables["HOTNESS_SERVER_LOG"] = Path.Combine(dataDir, "server.out.log"); + + serverProcess = Process.Start(info); + Log("started node pid=" + (serverProcess == null ? "" : serverProcess.Id.ToString()) + " url=" + appUrl); + } + + private void CleanupPreviousWebViewServer() + { + try + { + if (!File.Exists(statePath)) return; + string text = File.ReadAllText(statePath, Encoding.UTF8); + if (!text.Contains("\"mode\": \"webview-app\"")) return; + + int pid = ReadJsonInt(text, "pid"); + if (pid > 0 && pid != Process.GetCurrentProcess().Id) + { + try + { + Process previous = Process.GetProcessById(pid); + previous.Kill(); + previous.WaitForExit(3000); + Log("cleaned previous webview server pid=" + pid); + } + catch + { + } + } + File.Delete(statePath); + } + catch (Exception error) + { + Log("cleanup previous server failed: " + error.Message); + } + } + + private async Task WaitForServerAsync() + { + var started = DateTime.UtcNow; + while ((DateTime.UtcNow - started).TotalSeconds < 15) + { + if (await IsServerReadyAsync()) return; + await Task.Delay(250); + } + throw new Exception("本地服务启动超时:" + appUrl); + } + + private async Task IsServerReadyAsync() + { + try + { + using (var client = new WebClient()) + { + string json = await client.DownloadStringTaskAsync(appUrl + "api/desktop-instance"); + return json.Contains(token); + } + } + catch + { + return false; + } + } + + private async Task WriteStateAsync() + { + string json = "{\r\n" + + " \"pid\": " + (serverProcess == null ? "0" : serverProcess.Id.ToString()) + ",\r\n" + + " \"port\": " + port + ",\r\n" + + " \"url\": \"" + appUrl + "\",\r\n" + + " \"mode\": \"webview-app\",\r\n" + + " \"root\": \"" + EscapeJson(root) + "\",\r\n" + + " \"dataDir\": \"" + EscapeJson(dataDir) + "\",\r\n" + + " \"token\": \"" + token + "\",\r\n" + + " \"updated_at\": \"" + DateTime.UtcNow.ToString("o") + "\"\r\n" + + "}\r\n"; + using (var writer = new StreamWriter(statePath, false, Encoding.UTF8)) + { + await writer.WriteAsync(json); + } + } + + private static int FindFreePort() + { + for (int candidate = 3000; candidate <= 3099; candidate++) + { + TcpListener listener = null; + try + { + listener = new TcpListener(IPAddress.IPv6Any, candidate); + listener.Server.DualMode = true; + listener.Start(); + return candidate; + } + catch + { + } + finally + { + if (listener != null) listener.Stop(); + } + } + throw new Exception("3000-3099 没有可用端口。"); + } + + private void OnFormClosing(object sender, FormClosingEventArgs e) + { + if (isQuitting) return; + e.Cancel = true; + Hide(); + tray.ShowBalloonTip(2000, "节目热度采集工具仍在后台运行", "右下角托盘可重新打开或退出后台。", ToolTipIcon.Info); + } + + private void ShowMainWindow() + { + Show(); + if (WindowState == FormWindowState.Minimized) WindowState = FormWindowState.Normal; + Activate(); + } + + private void QuitApp() + { + isQuitting = true; + tray.Visible = false; + try + { + if (serverProcess != null && !serverProcess.HasExited) serverProcess.Kill(); + } + catch + { + } + try + { + if (File.Exists(statePath)) File.Delete(statePath); + } + catch + { + } + Application.Exit(); + } + + private static void OpenExternal(string value) + { + Process.Start(new ProcessStartInfo + { + FileName = value, + UseShellExecute = true + }); + } + + private static string EscapeJson(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static int ReadJsonInt(string json, string key) + { + string marker = "\"" + key + "\""; + int keyIndex = json.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (keyIndex < 0) return 0; + int colonIndex = json.IndexOf(":", keyIndex, StringComparison.Ordinal); + if (colonIndex < 0) return 0; + int start = colonIndex + 1; + while (start < json.Length && Char.IsWhiteSpace(json[start])) start++; + int end = start; + while (end < json.Length && Char.IsDigit(json[end])) end++; + int result; + return Int32.TryParse(json.Substring(start, end - start), out result) ? result : 0; + } + + private void Log(string message) + { + try + { + File.AppendAllText(logPath, DateTime.Now.ToString("s") + " " + message + "\r\n", Encoding.UTF8); + } + catch + { + } + } + } +} diff --git a/src/ocr.js b/src/ocr.js new file mode 100644 index 0000000..cd4ff18 --- /dev/null +++ b/src/ocr.js @@ -0,0 +1,83 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WINDOWS_OCR_SCRIPT = path.join(__dirname, "windows-ocr.ps1"); +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + +export async function recognizeImageText({ buffer, extension = ".png" }) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw new Error("没有收到截图图片"); + } + if (buffer.length > MAX_IMAGE_BYTES) { + throw new Error("截图太大,请裁剪后再导入"); + } + + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hotness-ocr-")); + const imagePath = path.join(tempDir, `screenshot${safeImageExtension(extension)}`); + try { + await writeFile(imagePath, buffer); + const text = await runWindowsOcr(imagePath); + if (!text.trim()) throw new Error("没有从截图中识别到文字"); + return text; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +function safeImageExtension(extension) { + const ext = String(extension || "").toLowerCase(); + return [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"].includes(ext) ? ext : ".png"; +} + +function runWindowsOcr(imagePath) { + return new Promise((resolve, reject) => { + const child = spawn("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + WINDOWS_OCR_SCRIPT, + "-ImagePath", + imagePath, + ], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill(); + reject(new Error("截图 OCR 超时,请裁剪后重试")); + }, 30000); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(new Error(`无法启动截图 OCR:${error.message}`)); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) return resolve(stdout.trim()); + reject(new Error(cleanOcrError(stderr) || "截图 OCR 失败")); + }); + }); +} + +function cleanOcrError(error) { + return String(error || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-2) + .join(" "); +} diff --git a/src/rankingDiscovery.js b/src/rankingDiscovery.js new file mode 100644 index 0000000..aad10bb --- /dev/null +++ b/src/rankingDiscovery.js @@ -0,0 +1,209 @@ +import { normalizeRankingProgramName } from "./rankingStorage.js"; +import { classifyKidsContent, cleanKidsProgramName, isUsefulKidsProgram } from "./rankingKids.js"; +import { getRequestHeaders, normalizePlatformUrl } from "./sites.js"; + +const DATE_FIELD_RE = /(?:releaseDate|release_date|publishTime|publish_time|onlineTime|online_time|firstOnlineTime|data-release-date|data-publish-time)["'\s:=\\-]{1,12}([0-9]{4}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}(?:日)?|[0-9]{10,13})/i; +const LABEL_DATE_RE = /(?:上线|首播|开播|发布时间|播出时间|上线时间|首播时间)[^0-9]{0,16}([0-9]{4}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}(?:日)?|[0-9]{1,2}月[0-9]{1,2}日)/; +const PLAIN_DATE_RE = /([0-9]{4}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}(?:日)?)/; + +const URL_PATTERNS = { + tencent: [/\/x\/cover\//, /\/x\/page\//], + youku: [/\/v_show\//, /^\/video$/, /\/show_page\//], + iqiyi: [/\/a_/, /\/v_/], + mgtv: [/\/h\//, /\/b\//, /\/l\//], +}; + +const BAD_NAME_RE = /^(更多|全部|登录|注册|会员|立即播放|播放|详情|查看全部|换一换|排行榜|热播|推荐|动漫|少儿|儿童|综艺|电影|电视剧)$/; +const BAD_TEXT_RE = /(预告|花絮|片段|短视频|资讯|新闻|海报|剧照|主题曲|片头|片尾)/; + +export async function discoverRankingItems(source) { + const response = await fetch(source.url, { + headers: getRequestHeaders(source.platform), + redirect: "follow", + signal: AbortSignal.timeout(12_000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const html = await response.text(); + return discoverRankingItemsFromHtml(html, source, response.url || source.url); +} + +export function discoverRankingItemsFromHtml(html, source, baseUrl = source.url) { + const decoded = decodeEscapedText(html); + const candidates = [ + ...anchorCandidates(decoded, source, baseUrl), + ...jsonCandidates(decoded, source, baseUrl), + ]; + + const seen = new Set(); + const results = []; + for (const candidate of candidates) { + const name = finalProgramName(candidate.name, source.category); + if (!isGoodProgramName(name, source.category)) continue; + const normalized = normalizeRankingProgramName(name); + if (!normalized || normalized.length < 2) continue; + const key = `${candidate.platform}:${normalized}`; + if (seen.has(key)) continue; + seen.add(key); + results.push({ + ...candidate, + name, + normalized_name: normalized, + content_type: source.category === "kids" ? classifyKidsContent(name) : "other", + rank: results.length + 1, + }); + if (results.length >= 30) break; + } + return results; +} + +function anchorCandidates(html, source, baseUrl) { + const results = []; + const linkRe = /]*)>([\s\S]*?)<\/a>/gi; + for (const match of html.matchAll(linkRe)) { + const attrs = match[1] || ""; + const href = attrs.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] || ""; + const url = normalizeCandidateUrl(href, baseUrl, source.platform); + if (!url) continue; + const text = cleanText(match[2]); + const title = attrs.match(/\btitle\s*=\s*["']([^"']+)["']/i)?.[1] || ""; + const name = cleanProgramName(title || text); + results.push(makeItem({ source, name, url, evidence: text || title, releaseDate: extractReleaseDate(`${attrs} ${text} ${title}`) })); + } + return results; +} + +function jsonCandidates(html, source, baseUrl) { + const results = []; + const titleRe = /["'](?:title|name|albumName|videoTitle|displayName)["']\s*:\s*["']([^"']{2,80})["'][\s\S]{0,260}?["'](?:url|playUrl|pageUrl|jumpUrl|href)["']\s*:\s*["']([^"']+)["']/gi; + for (const match of html.matchAll(titleRe)) { + const name = cleanProgramName(match[1]); + const url = normalizeCandidateUrl(match[2], baseUrl, source.platform); + if (!url) continue; + results.push(makeItem({ source, name, url, evidence: cleanText(match[0]), releaseDate: extractReleaseDate(match[0]) })); + } + + const urlFirstRe = /["'](?:url|playUrl|pageUrl|jumpUrl|href)["']\s*:\s*["']([^"']+)["'][\s\S]{0,260}?["'](?:title|name|albumName|videoTitle|displayName)["']\s*:\s*["']([^"']{2,80})["']/gi; + for (const match of html.matchAll(urlFirstRe)) { + const url = normalizeCandidateUrl(match[1], baseUrl, source.platform); + const name = cleanProgramName(match[2]); + if (!url) continue; + results.push(makeItem({ source, name, url, evidence: cleanText(match[0]), releaseDate: extractReleaseDate(match[0]) })); + } + return results; +} + +function makeItem({ source, name, url, evidence, releaseDate = "" }) { + return { + name, + platform: source.platform, + category: source.category, + source_id: source.id, + source_label: source.label, + source_type: source.source_type, + url, + evidence: evidence || name, + release_date: releaseDate || extractReleaseDate(evidence || ""), + }; +} + +function extractReleaseDate(value) { + const text = decodeEscapedText(value); + const raw = text.match(DATE_FIELD_RE)?.[1] + || text.match(LABEL_DATE_RE)?.[1] + || text.match(PLAIN_DATE_RE)?.[1] + || ""; + return normalizeReleaseDate(raw); +} + +function normalizeReleaseDate(raw) { + const value = String(raw || "").trim(); + if (!value) return ""; + + if (/^[0-9]{10,13}$/.test(value)) { + const timestamp = Number(value.length === 13 ? value : `${value}000`); + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); + } + + const full = value.match(/^([0-9]{4})[-/.年]([0-9]{1,2})[-/.月]([0-9]{1,2})(?:日)?$/); + if (full) return `${full[1]}-${full[2].padStart(2, "0")}-${full[3].padStart(2, "0")}`; + + const partial = value.match(/^([0-9]{1,2})月([0-9]{1,2})日$/); + if (partial) return `${partial[1].padStart(2, "0")}-${partial[2].padStart(2, "0")}`; + + return ""; +} + +function normalizeCandidateUrl(rawUrl, baseUrl, platform) { + if (!rawUrl) return ""; + try { + const parsed = new URL(decodeEscapedText(rawUrl), baseUrl); + parsed.hash = ""; + const url = normalizePlatformUrl(parsed.toString(), platform); + if (!matchesPlatformUrl(url, platform)) return ""; + return url; + } catch { + return ""; + } +} + +function matchesPlatformUrl(url, platform) { + let parsed; + try { + parsed = new URL(url); + } catch { + return false; + } + const path = parsed.pathname; + if (/search|so\//i.test(path)) return false; + if (/\.(jpg|jpeg|png|gif|webp|css|js|svg|ico)$/i.test(path)) return false; + return (URL_PATTERNS[platform] || []).some((pattern) => pattern.test(path)); +} + +function finalProgramName(name, category) { + if (category === "kids") return cleanKidsProgramName(name); + return name; +} + +function isGoodProgramName(name, category) { + const value = String(name || "").trim(); + if (category === "kids" && !isUsefulKidsProgram(value)) return false; + if (value.length < 2 || value.length > 40) return false; + if (BAD_NAME_RE.test(value)) return false; + if (BAD_TEXT_RE.test(value)) return false; + if (/^[0-9\s.,::-]+$/.test(value)) return false; + return true; +} + +function cleanProgramName(value) { + return decodeHtmlEntities(String(value || "")) + .replace(/<[^>]+>/g, " ") + .replace(/[《》]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function cleanText(value) { + return cleanProgramName(value).slice(0, 180); +} + +function decodeEscapedText(value) { + return decodeHtmlEntities(String(value || "") + .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\\//g, "/")); +} + +function decodeHtmlEntities(value) { + return String(value) + .replace(/ /g, " ") + .replace(/"/g, "\"") + .replace(/"/g, "\"") + .replace(/"/gi, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/gi, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} diff --git a/src/rankingKids.js b/src/rankingKids.js new file mode 100644 index 0000000..b670359 --- /dev/null +++ b/src/rankingKids.js @@ -0,0 +1,165 @@ +const KIDS_DEFAULT_SOURCES = [ + { + id: "default-kids-tencent-channel", + platform: "tencent", + category: "kids", + source_type: "channel", + label: "腾讯少儿频道", + url: "https://v.qq.com/channel/child", + enabled: true, + builtin: true, + }, + { + id: "default-kids-youku-channel", + platform: "youku", + category: "kids", + source_type: "channel", + label: "优酷少儿频道", + url: "https://www.youku.com/channel/webkid", + enabled: true, + builtin: true, + }, + { + id: "default-kids-iqiyi-channel", + platform: "iqiyi", + category: "kids", + source_type: "channel", + label: "爱奇艺儿童频道", + url: "https://www.iqiyi.com/cartoon/", + enabled: true, + builtin: true, + }, + { + id: "default-kids-mgtv-channel", + platform: "mgtv", + category: "kids", + source_type: "channel", + label: "芒果TV少儿频道", + url: "https://www.mgtv.com/c/3.html", + enabled: true, + builtin: true, + }, + { + id: "default-kids-youku-rank", + platform: "youku", + category: "kids", + source_type: "rank", + label: "优酷少儿热度", + url: "https://www.youku.com/category/show/c_100_s_1_d_1.html", + enabled: true, + builtin: true, + }, +]; + +const PREFIX_RE = /^(?:独播|自制|全网独播|热播|推荐|少儿|儿童|动画|动漫|VIP|会员|免费|高清|蓝光|热度榜|TOP|NEW|有更新|更新至|已完结|连载中)\s*/i; +const NOISE_ONLY_RE = /^(?:VIP|会员|NEW|TOP|热度榜|有更新|更新至|已完结|连载中|独播|推荐|少儿|儿童|动画|动漫|\d+集全|\d+集|第\d+集|本月|今日|全部)$/i; +const BAD_FRAGMENT_RE = /(预告|花絮|片段|短视频|资讯|新闻|海报|剧照|主题曲|片头|片尾|合集|解说|盘点|看点|精彩|幕后|花絮)/; +const NON_KIDS_RE = /^(?:综|剧|影|纪录片|综艺)[・·\s-]|(无限超越班|这个少侠有点冷|文脉赓续|何以湖南|脱口秀|真人秀|短剧|电视剧|综艺|晚会|新闻|访谈|纪录片)/; +const PLATFORM_NAME_RE = /^(?:腾讯视频|优酷|爱奇艺|芒果TV|芒果tv|芒果|Tencent Video|Youku|iQIYI|MGTV)$/i; + +export function defaultKidsSources() { + return KIDS_DEFAULT_SOURCES.map((source) => ({ ...source })); +} + +export function cleanKidsProgramName(value) { + let text = String(value || "") + .replace(/<[^>]+>/g, " ") + .replace(/[《》【】「」]/g, "") + .replace(/[·•]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + text = cleanReadableKidsNoise(text); + + text = text + .replace(/^(?:独播|自制|全网独播)\s+/, "") + .replace(/^(?:乐学)?VIP\s*\d+\s*(?:集|期)全\s*/i, "") + .replace(/^(?:VIP|会员)?\s*(?:有更新|NEW|更新至)\s*(?:\d+|本月|今日)?\s*(?:集|期)?(?:全)?$/i, "") + .replace(/^热度榜\s*TOP\s*(?:更新至)?\s*(?:本月|今日)?$/i, ""); + + let previous = ""; + while (text && text !== previous) { + previous = text; + text = text.replace(PREFIX_RE, "").trim(); + } + + text = text + .replace(/^\d+\s*(?:集|期)全\s*/, "") + .replace(/^(?:独播|自制|全网独播)\s+\d+\s*(?:集|期)全\s*/, "") + .replace(/^更新至\s*\d+\s*(?:集|期)\s*/, "") + .replace(/\s+/g, " ") + .trim(); + + if (NOISE_ONLY_RE.test(text)) return ""; + if (PLATFORM_NAME_RE.test(text)) return ""; + if (!hasChineseOrLetters(text)) return ""; + if (text.length < 2 || text.length > 32) return ""; + return text; +} + +export function isUsefulKidsProgram(value) { + const raw = String(value || "").trim(); + if (NON_KIDS_RE.test(raw)) return false; + const name = cleanKidsProgramName(value); + if (!name) return false; + if (BAD_FRAGMENT_RE.test(name)) return false; + if (NON_KIDS_RE.test(name)) return false; + if (/^第?[0-9一二三四五六七八九十百]+[集期]/.test(name)) return false; + return true; +} + +export function classifyKidsContent(value) { + const name = cleanKidsProgramName(value) || String(value || ""); + if (/(儿歌|童谣|歌曲|音乐|唱跳)/.test(name)) return "song"; + if (/(玩具|积木|工程车|挖掘机|汽车玩具|拆箱)/.test(name)) return "toy"; + if (/(早教|启蒙|认知|识字|拼音|英语|数学|物理|化学|科学|口算|百科|科普|习惯|安全教育)/.test(name)) return "education"; + if (/(电影|大电影|剧场版)/.test(name)) return "movie"; + if (/(动画|历险|冒险|奇遇|大功|车神|萌可|精灵|魔法|宝贝|小队|小纵队|帮帮龙|熊|兔|猪|猫|狗|龙|队|侠|战士|卫士|第.{1,4}季)/.test(name)) return "animation"; + return "other"; +} + +export function filterKidsPrograms(programs, filters = {}) { + const q = String(filters.q || "").trim(); + const excludeTerms = splitTerms(filters.exclude); + const platform = String(filters.platform || "").trim(); + const sourceType = String(filters.source_type || "").trim(); + const contentType = String(filters.content_type || "").trim(); + const status = String(filters.status || "").trim(); + const minPlatforms = Number(filters.min_platforms || 0); + + return (programs || []).filter((program) => { + const name = program.display_name || program.name || ""; + if (!isUsefulKidsProgram(name)) return false; + const effectiveContentType = classifyKidsContent(name); + if (q && !name.includes(q)) return false; + if (excludeTerms.some((term) => name.includes(term))) return false; + if (platform && !(program.platforms || []).includes(platform)) return false; + if (sourceType && !(program.source_types || []).includes(sourceType)) return false; + if (contentType && effectiveContentType !== contentType) return false; + if (minPlatforms && (program.platforms || []).length < minPlatforms) return false; + if (status === "untracked" && program.tracked) return false; + if (status === "tracked" && !program.tracked) return false; + if (status === "uncollected" && program.collected) return false; + if (status === "collected" && !program.collected) return false; + return true; + }); +} + +function splitTerms(value) { + return String(value || "") + .split(/[,\s,、]+/) + .map((term) => term.trim()) + .filter(Boolean); +} + +function cleanReadableKidsNoise(value) { + return String(value || "") + .replace(/^(?:新上线\s*)?NEW\s*\S*?\s*\d+\s*(?:期|集|季)?全?\s*/i, "") + .replace(/^(?:新上线|新片|上线|更新至)\s+/i, "") + .replace(/^\d+\s*(?:期|集|季)?全?\s+/, "") + .trim(); +} + +function hasChineseOrLetters(value) { + return /[\u4e00-\u9fa5a-z]/i.test(value); +} diff --git a/src/rankingMetrics.js b/src/rankingMetrics.js new file mode 100644 index 0000000..c1bf9ad --- /dev/null +++ b/src/rankingMetrics.js @@ -0,0 +1,139 @@ +import { PLATFORMS } from "./sites.js"; + +export function latestProgramMetrics(history) { + const runs = [...(history?.runs || [])].sort().reverse(); + const metrics = {}; + + for (const platform of PLATFORMS) { + const row = history?.platforms?.[platform.id] || {}; + let latest = null; + let latestRun = ""; + + for (const run of runs) { + const value = row.values?.[run]; + if (value?.status === "ok") { + latest = value; + latestRun = run; + break; + } + } + + if (latest) { + metrics[platform.id] = { + platform: platform.id, + platform_label: row.platform_label || platform.label, + metric_label: latest.metric_label || row.metric_label || platform.metricLabel || "", + status: latest.status, + raw: latest.raw || String(latest.number || ""), + number: latest.number || "", + unit: latest.unit || "", + run: latestRun, + short: latest.raw || String(latest.number || ""), + credibility: latest.credibility || null, + }; + } else { + metrics[platform.id] = { + platform: platform.id, + platform_label: row.platform_label || platform.label, + metric_label: row.metric_label || platform.metricLabel || "", + status: "missing", + raw: "", + number: "", + unit: "", + run: "", + short: "未采", + credibility: null, + }; + } + } + + return metrics; +} + +export function collectionMetrics(collection) { + const metrics = missingMetrics(); + const capturedAt = collection?.captured_at || ""; + + for (const result of collection?.results || []) { + const platform = PLATFORMS.find((item) => item.id === result.platform); + if (!platform) continue; + if (result.status !== "ok") continue; + metrics[platform.id] = { + platform: platform.id, + platform_label: result.platform_label || platform.label, + metric_label: result.metric_label || platform.metricLabel || "", + status: result.status, + raw: result.hotness_raw || String(result.hotness_number || ""), + number: result.hotness_number || "", + unit: result.unit || "", + run: capturedAt, + short: result.hotness_raw || String(result.hotness_number || ""), + credibility: result.credibility || null, + }; + } + + return metrics; +} + +export function collectionHistory(collection) { + const capturedAt = collection?.captured_at || new Date().toISOString(); + const platforms = {}; + for (const platform of PLATFORMS) { + platforms[platform.id] = { + platform: platform.id, + platform_label: platform.label, + metric_label: platform.metricLabel || "", + values: {}, + }; + } + + for (const result of collection?.results || []) { + const platform = PLATFORMS.find((item) => item.id === result.platform); + if (!platform) continue; + const row = platforms[platform.id]; + row.platform_label = result.platform_label || row.platform_label; + row.metric_label = result.metric_label || row.metric_label; + row.values[capturedAt] = { + raw: result.hotness_raw || "", + number: result.hotness_number || "", + unit: result.unit || "", + metric_label: result.metric_label || row.metric_label || "", + status: result.status || "", + confidence: result.confidence || "", + credibility: result.credibility || null, + }; + } + + return { + name: collection?.name || "", + runs: [capturedAt], + platforms, + }; +} + +export function trendCollectionPlatforms(program, requestedPlatforms = []) { + const requested = sanitizePlatforms(requestedPlatforms); + const sourcePlatforms = sanitizePlatforms(program?.platforms || []); + const base = requested.length ? requested : PLATFORMS.map((platform) => platform.id); + return [...sourcePlatforms, ...base.filter((platform) => !sourcePlatforms.includes(platform))]; +} + +function missingMetrics() { + return Object.fromEntries(PLATFORMS.map((platform) => [platform.id, { + platform: platform.id, + platform_label: platform.label, + metric_label: platform.metricLabel || "", + status: "missing", + raw: "", + number: "", + unit: "", + run: "", + short: "鏈噰", + credibility: null, + }])); +} + +function sanitizePlatforms(platforms) { + const known = new Set(PLATFORMS.map((platform) => platform.id)); + return [...new Set((platforms || []).map((platform) => String(platform || "").trim()).filter((platform) => known.has(platform)))]; +} diff --git a/src/rankingScoring.js b/src/rankingScoring.js new file mode 100644 index 0000000..0747ff5 --- /dev/null +++ b/src/rankingScoring.js @@ -0,0 +1,64 @@ +const SOURCE_SCORES = { + new: 40, + recommend: 20, + rank: 15, + hot: 10, + channel: 5, +}; + +const CONTENT_TYPE_SCORES = { + animation: 12, + movie: 8, + education: 4, + song: -4, + toy: -6, + other: -10, +}; + +export function newStage(firstSeenAt, now = new Date()) { + const first = new Date(firstSeenAt); + if (Number.isNaN(first.getTime())) return "new"; + const days = Math.floor((now.getTime() - first.getTime()) / 86_400_000); + if (days <= 7) return "new"; + if (days <= 30) return "recent"; + return "regular"; +} + +export function newStageLabel(stage) { + return { + new: "新", + recent: "近期", + regular: "常规", + }[stage] || stage || ""; +} + +export function scoreProgram(program) { + let score = 0; + if (program.new_type === "first_seen") score += 30; + if (program.new_type === "platform_new") score += 40; + if (program.new_type === "suspected_new") score += 20; + + for (const type of program.source_types || []) { + score += SOURCE_SCORES[type] || 0; + } + + score += (program.platforms?.length || 0) * 15; + score += CONTENT_TYPE_SCORES[program.content_type] || 0; + if (Number.isFinite(Number(program.best_rank))) { + score += Math.max(0, 16 - Math.min(Number(program.best_rank), 16)); + } + if ((program.seen_count || 0) >= 2) score += 10; + if (program.collected) score += 5; + return score; +} + +export function programView(program, now = new Date()) { + const stage = newStage(program.first_seen_at, now); + return { + ...program, + new_stage: stage, + new_stage_label: newStageLabel(stage), + new_score: scoreProgram(program), + platform_count: program.platforms?.length || 0, + }; +} diff --git a/src/rankingStorage.js b/src/rankingStorage.js new file mode 100644 index 0000000..41e02d5 --- /dev/null +++ b/src/rankingStorage.js @@ -0,0 +1,392 @@ +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { normalizeProgramName } from "./known.js"; +import { classifyKidsContent, cleanKidsProgramName, filterKidsPrograms } from "./rankingKids.js"; +import { PLATFORMS } from "./sites.js"; +import { programView } from "./rankingScoring.js"; + +const DATA_DIR = path.resolve(process.env.HOTNESS_DATA_DIR || path.join(process.cwd(), "data")); +const RANKINGS_FILE = path.join(DATA_DIR, "rankings.json"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); +const CATEGORIES = new Set(["kids", "anime"]); +const SOURCE_TYPES = new Set(["new", "recommend", "rank", "hot", "channel"]); +const PLATFORM_IDS = new Set(PLATFORMS.map((platform) => platform.id)); + +export function assertCategory(category) { + const value = String(category || "").trim(); + if (!CATEGORIES.has(value)) throw new Error("榜单类别必须是 kids 或 anime"); + return value; +} + +export function normalizeRankingProgramName(value) { + return normalizeProgramName(value); +} + +export function programKey(category, name) { + return `${assertCategory(category)}:${normalizeRankingProgramName(name)}`; +} + +export async function readRankingData() { + try { + const content = await readFile(RANKINGS_FILE, "utf8"); + return normalizeRankingData(JSON.parse(content)); + } catch (error) { + if (error.code === "ENOENT") return normalizeRankingData({}); + throw error; + } +} + +export async function writeRankingData(data) { + await mkdir(DATA_DIR, { recursive: true }); + await backupRankingFile(); + await writeFile(RANKINGS_FILE, `${JSON.stringify(normalizeRankingData(data), null, 2)}\n`, "utf8"); +} + +export async function listRankingSources(category) { + const data = await readRankingData(); + const cleanCategory = assertCategory(category); + return Object.values(data.sources) + .filter((source) => source.category === cleanCategory) + .sort((a, b) => String(a.platform).localeCompare(String(b.platform)) || String(a.label).localeCompare(String(b.label))); +} + +export async function saveRankingSource(input) { + const data = await readRankingData(); + const now = new Date().toISOString(); + const source = sanitizeSource(input, now); + const previous = data.sources[source.id] || {}; + data.sources[source.id] = { + ...source, + created_at: previous.created_at || now, + updated_at: now, + }; + await writeRankingData(data); + return data.sources[source.id]; +} + +export async function deleteRankingSource(id) { + const data = await readRankingData(); + delete data.sources[String(id || "").trim()]; + await writeRankingData(data); + return true; +} + +export async function refreshRankingSnapshot({ category, items, sourceIds, capturedAt = new Date().toISOString() }) { + const cleanCategory = assertCategory(category); + const data = await readRankingData(); + const snapshot = { + id: `${cleanCategory}-${capturedAt.replace(/[-:.TZ]/g, "").slice(0, 14)}`, + category: cleanCategory, + captured_at: capturedAt, + source_ids: sourceIds || [], + items: sanitizeItems(items, cleanCategory, capturedAt), + }; + + data.snapshots.push(snapshot); + data.snapshots = data.snapshots + .filter((item) => item.category === cleanCategory) + .slice(-30) + .concat(data.snapshots.filter((item) => item.category !== cleanCategory)); + + updateProgramIndex(data, snapshot); + await writeRankingData(data); + return { + snapshot, + programs: rankingProgramsView(data, cleanCategory, "new"), + }; +} + +export async function latestRankingSnapshot(category) { + const cleanCategory = assertCategory(category); + const data = await readRankingData(); + return [...data.snapshots].reverse().find((snapshot) => snapshot.category === cleanCategory) || { + category: cleanCategory, + captured_at: "", + items: [], + }; +} + +export async function rankingPrograms(category, view = "new", filters = {}) { + const data = await readRankingData(); + return rankingProgramsView(data, assertCategory(category), view, filters); +} + +export async function setRankingIgnored({ category, name, ignored = true, reason = "" }) { + const data = await readRankingData(); + const key = programKey(category, name); + if (data.programIndex[key]) data.programIndex[key].ignored = Boolean(ignored); + if (ignored) { + data.ignoredPrograms[key] = { + category: assertCategory(category), + normalized_name: normalizeRankingProgramName(name), + ignored_at: new Date().toISOString(), + reason: String(reason || "").trim() || "不关注", + }; + } else { + delete data.ignoredPrograms[key]; + } + await writeRankingData(data); + return rankingProgramsView(data, assertCategory(category), ignored ? "new" : "ignored"); +} + +export async function markRankingTracked({ category, name }) { + const data = await readRankingData(); + const key = programKey(category, name); + if (data.programIndex[key]) data.programIndex[key].tracked = true; + await writeRankingData(data); + return data.programIndex[key] || null; +} + +export async function markRankingCollected({ category, names }) { + const data = await readRankingData(); + const cleanCategory = assertCategory(category); + for (const name of names || []) { + const key = programKey(cleanCategory, name); + if (data.programIndex[key]) data.programIndex[key].collected = true; + } + await writeRankingData(data); + return rankingProgramsView(data, cleanCategory, "new"); +} + +export async function rankingCsv(category, view = "new") { + const cleanCategory = assertCategory(category); + const programs = await rankingPrograms(cleanCategory, view); + const rows = [[ + "category", + "view", + "score", + "stage", + "platform_count", + "platforms", + "source_types", + "name", + "release_date", + "first_seen_at", + "first_seen_platform", + "tracked", + "collected", + "url", + ]]; + + for (const program of programs) { + rows.push([ + cleanCategory, + view, + program.new_score, + program.new_stage, + program.platform_count, + (program.platforms || []).join("|"), + (program.source_types || []).join("|"), + program.display_name, + program.release_date || "", + program.first_seen_at, + program.first_seen_platform, + program.tracked ? "yes" : "no", + program.collected ? "yes" : "no", + program.urls?.[0] || "", + ]); + } + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"; +} + +export async function saveLatestKidsTrendRun(trend) { + const data = await readRankingData(); + data.latestKidsTrendRun = sanitizeLatestTrendRun(trend); + await writeRankingData(data); + return data.latestKidsTrendRun; +} + +export async function latestKidsTrendRun() { + const data = await readRankingData(); + return data.latestKidsTrendRun || null; +} + +function rankingProgramsView(data, category, view, filters = {}) { + const programs = Object.values(data.programIndex) + .filter((program) => program.category === category) + .map((program) => { + const viewed = programView(program); + const displayName = category === "kids" ? (cleanKidsProgramName(viewed.display_name) || viewed.display_name) : viewed.display_name; + return { + ...viewed, + display_name: displayName, + content_type: category === "kids" ? classifyKidsContent(displayName) : viewed.content_type, + ignored_reason: data.ignoredPrograms[programKey(category, viewed.display_name)]?.reason || "", + }; + }); + + const filtered = programs + .filter((program) => { + if (view === "ignored") return program.ignored; + if (view === "platform") return !program.ignored; + return !program.ignored && program.new_stage !== "regular"; + }) + .sort((a, b) => (b.new_score || 0) - (a.new_score || 0) || String(b.first_seen_at).localeCompare(String(a.first_seen_at))); + return category === "kids" ? filterKidsPrograms(filtered, filters) : filtered; +} + +function updateProgramIndex(data, snapshot) { + for (const item of snapshot.items) { + const key = programKey(item.category, item.normalized_name); + const existing = data.programIndex[key] || {}; + const platforms = unique([...(existing.platforms || []), item.platform]); + const sourceTypes = unique([...(existing.source_types || []), item.source_type]); + const urls = unique([...(existing.urls || []), item.url].filter(Boolean)); + const urlByPlatform = { ...(existing.url_by_platform || {}) }; + if (item.platform && item.url) urlByPlatform[item.platform] = item.url; + const firstSeenAt = existing.first_seen_at || snapshot.captured_at; + data.programIndex[key] = { + category: item.category, + normalized_name: item.normalized_name, + display_name: existing.display_name || item.name, + first_seen_at: firstSeenAt, + release_date: existing.release_date || item.release_date || "", + first_seen_platform: existing.first_seen_platform || item.platform, + first_seen_source: existing.first_seen_source || item.source_label, + platforms, + source_types: sourceTypes, + urls, + url_by_platform: urlByPlatform, + content_type: existing.content_type || item.content_type || (item.category === "kids" ? classifyKidsContent(item.name) : "other"), + best_rank: Math.min(Number(existing.best_rank || item.rank || 9999), Number(item.rank || 9999)), + last_seen_at: snapshot.captured_at, + seen_count: (existing.seen_count || 0) + 1, + tracked: Boolean(existing.tracked), + collected: Boolean(existing.collected), + ignored: Boolean(existing.ignored || data.ignoredPrograms[key]), + new_type: existing.new_type || (item.source_type === "new" ? "platform_new" : "first_seen"), + }; + } +} + +function sanitizeItems(items, category, capturedAt) { + return (items || []).map((item, index) => { + const name = String(item.name || "").trim(); + const normalized = normalizeRankingProgramName(name); + return { + name, + normalized_name: normalized, + platform: sanitizePlatform(item.platform), + category, + source_id: String(item.source_id || "").trim(), + source_label: String(item.source_label || "").trim(), + source_type: sanitizeSourceType(item.source_type), + url: String(item.url || "").trim(), + rank: Number.isFinite(Number(item.rank)) ? Number(item.rank) : index + 1, + evidence: String(item.evidence || "").trim(), + release_date: String(item.release_date || "").trim(), + content_type: item.content_type || (category === "kids" ? classifyKidsContent(name) : "other"), + discovered_at: capturedAt, + }; + }).filter((item) => item.name && item.normalized_name && item.url); +} + +function sanitizeSource(input, now) { + const category = assertCategory(input.category); + const platform = sanitizePlatform(input.platform); + const sourceType = sanitizeSourceType(input.source_type); + const label = String(input.label || "").trim(); + const url = String(input.url || "").trim(); + if (!label) throw new Error("来源名称不能为空"); + if (!url) throw new Error("来源 URL 不能为空"); + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(); + } catch { + throw new Error("来源 URL 格式不正确"); + } + const id = String(input.id || `${platform}-${category}-${sourceType}-${Date.now()}`).trim(); + return { + id, + platform, + category, + source_type: sourceType, + label, + url, + enabled: input.enabled !== false, + created_at: input.created_at || now, + updated_at: now, + }; +} + +function sanitizePlatform(platform) { + const value = String(platform || "").trim(); + if (!PLATFORM_IDS.has(value)) throw new Error("平台不正确"); + return value; +} + +function sanitizeSourceType(type) { + const value = String(type || "").trim() || "channel"; + if (!SOURCE_TYPES.has(value)) throw new Error("来源类型不正确"); + return value; +} + +function normalizeRankingData(data) { + const normalized = { + version: 1, + sources: data.sources || {}, + snapshots: Array.isArray(data.snapshots) ? data.snapshots : [], + programIndex: data.programIndex || {}, + ignoredPrograms: data.ignoredPrograms || {}, + latestKidsTrendRun: data.latestKidsTrendRun || null, + }; + hydrateProgramIndexFromSnapshots(normalized); + return normalized; +} + +function sanitizeLatestTrendRun(trend) { + const capturedAt = String(trend?.captured_at || new Date().toISOString()); + const results = Array.isArray(trend?.results) ? trend.results.slice(0, 50) : []; + return { + captured_at: capturedAt, + discovered_count: Number.isFinite(Number(trend?.discovered_count)) ? Number(trend.discovered_count) : 0, + collected_count: Number.isFinite(Number(trend?.collected_count)) ? Number(trend.collected_count) : results.length, + errors: Array.isArray(trend?.errors) ? trend.errors.slice(0, 20) : [], + results, + }; +} + +function hydrateProgramIndexFromSnapshots(data) { + for (const snapshot of data.snapshots || []) { + for (const item of snapshot.items || []) { + let key = ""; + try { + key = programKey(item.category, item.normalized_name || item.name); + } catch { + continue; + } + const program = data.programIndex[key]; + if (!program) continue; + const rank = Number(item.rank || 9999); + if (Number.isFinite(rank)) { + program.best_rank = Math.min(Number(program.best_rank || 9999), rank); + } + if (!program.content_type && item.category === "kids") { + program.content_type = item.content_type || classifyKidsContent(item.name); + } + if (!program.release_date && item.release_date) { + program.release_date = item.release_date; + } + } + } +} + +async function backupRankingFile() { + try { + await mkdir(BACKUP_DIR, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await copyFile(RANKINGS_FILE, path.join(BACKUP_DIR, `rankings-${stamp}.json`)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +function csvEscape(value) { + const text = String(value ?? ""); + if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, "\"\"")}"`; + return text; +} diff --git a/src/retryQueue.js b/src/retryQueue.js new file mode 100644 index 0000000..b229b02 --- /dev/null +++ b/src/retryQueue.js @@ -0,0 +1,39 @@ +import { PLATFORMS } from "./sites.js"; + +export const retryableStatuses = new Set(["no_match", "no_metric", "blocked", "error"]); + +export function pendingRetryItems(history) { + const items = []; + for (const program of Object.values(history?.programs || {})) { + const platforms = []; + const reasons = []; + + for (const platform of PLATFORMS) { + const latest = latestPlatformValue(program, platform.id); + if (!latest || !retryableStatuses.has(String(latest.status || ""))) continue; + platforms.push(platform.id); + reasons.push(`${platform.label}:${latest.status}`); + } + + if (platforms.length > 0) { + items.push({ + name: program.name || "", + platforms, + reason: reasons.join(";"), + }); + } + } + + return items + .filter((item) => item.name) + .sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN")); +} + +function latestPlatformValue(program, platformId) { + const values = program?.platforms?.[platformId]?.values || {}; + const runs = [...(program?.runs || [])].reverse(); + for (const run of runs) { + if (values[run]) return values[run]; + } + return null; +} diff --git a/src/scraper.js b/src/scraper.js new file mode 100644 index 0000000..1039db5 --- /dev/null +++ b/src/scraper.js @@ -0,0 +1,119 @@ +import { writeFile } from "node:fs/promises"; +import { extractHotness } from "./extract.js"; +import { detectPlatform, getMetricLabel, getRequestHeaders, normalizePlatformUrl } from "./sites.js"; + +export async function scrapeUrl(item, options = {}) { + const fetchedAt = options.fetchedAt || new Date().toISOString(); + const url = normalizePlatformUrl(item.url || "", item.platform); + const platform = detectPlatform(url, item.platform); + const base = { + platform, + metric_label: getMetricLabel(platform), + name: item.name || "", + url, + page_title: "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + evidence: "", + status: "error", + fetched_at: fetchedAt, + error: "", + }; + + if (!url) { + return { + ...base, + error: "missing url", + }; + } + + try { + const response = await fetch(url, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: AbortSignal.timeout(options.timeoutMs || 10_000), + }); + + const html = await response.text(); + const pageTitle = extractPageTitle(html); + if (options.debugHtmlPath) await writeFile(options.debugHtmlPath, html, "utf8"); + + if (response.status === 403 || response.status === 429) { + return { + ...base, + page_title: pageTitle, + status: "blocked", + error: `HTTP ${response.status}`, + }; + } + + if (!response.ok) { + return { + ...base, + page_title: pageTitle, + status: "error", + error: `HTTP ${response.status}`, + }; + } + + const extracted = extractHotness(html, { all: options.all, platform, programName: item.name || "" }); + if (extracted.blocked) { + return { + ...base, + page_title: pageTitle, + status: "blocked", + error: "captcha or anti-bot page detected", + }; + } + + if (!extracted.best) { + return { + ...base, + page_title: pageTitle, + status: "no_match", + }; + } + + const best = extracted.best; + return { + ...base, + page_title: pageTitle, + hotness_raw: best.hotnessRaw, + hotness_number: best.hotnessNumber, + unit: best.unit, + metric_label: getMetricLabel(platform), + confidence: best.confidence, + evidence: best.evidence, + status: "ok", + candidates: options.all ? extracted.candidates : undefined, + }; + } catch (error) { + return { + ...base, + status: "error", + error: error.message, + }; + } +} + +function extractPageTitle(html) { + return decodeHtmlEntities(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] || "") + .replace(/\s+/g, " ") + .trim(); +} + +function decodeHtmlEntities(value) { + return String(value) + .replace(/ /g, " ") + .replace(/"/g, "\"") + .replace(/"/g, "\"") + .replace(/"/gi, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/gi, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} diff --git a/src/search.js b/src/search.js new file mode 100644 index 0000000..bb0ca4b --- /dev/null +++ b/src/search.js @@ -0,0 +1,1141 @@ +import crypto from "node:crypto"; +import { getRequestHeaders } from "./sites.js"; + +const SEARCH_TIMEOUT_MS = 6_000; +const QUICK_SEARCH_TIMEOUT_MS = 6_000; + +const SEARCH_CONFIGS = { + tencent: { + searchUrl: (keyword) => `https://v.qq.com/x/search/?q=${encodeURIComponent(keyword)}`, + siteSearchUrls: [ + (keyword) => `https://v.qq.com/x/search/?q=${encodeURIComponent(keyword)}`, + ], + allowHosts: ["v.qq.com"], + includePaths: [/\/x\/cover\//, /\/x\/page\//], + excludePaths: [/\/x\/search\//, /\/search/], + fallbackQueries: [ + (keyword) => `site:v.qq.com/x/cover ${keyword} 腾讯视频`, + (keyword) => `site:v.qq.com/x/page ${keyword} 腾讯视频`, + (keyword) => `${keyword} 腾讯视频`, + ], + }, + youku: { + searchUrl: (keyword) => `https://so.youku.com/search_video/q_${encodeURIComponent(keyword)}`, + preferFallback: true, + siteSearchUrls: [ + (keyword) => `https://so.youku.com/search_video/q_${encodeURIComponent(keyword)}`, + (keyword) => `https://www.youku.com/search_video?keyword=${encodeURIComponent(keyword)}`, + ], + allowHosts: ["v.youku.com", "www.youku.com", "youku.com"], + includePaths: [/\/v_show\//, /^\/video$/, /\/show_page\//], + excludePaths: [/\/search/], + fallbackQueries: [ + (keyword) => `site:v.youku.com/v_show ${keyword}`, + (keyword) => `site:v.youku.com/video ${keyword}`, + (keyword) => `site:www.youku.com/show_page ${keyword}`, + (keyword) => `site:youku.com ${keyword} youku`, + (keyword) => `site:v.youku.com ${keyword} 优酷`, + (keyword) => `site:youku.com/show_page ${keyword} 优酷`, + (keyword) => `${keyword} 优酷`, + ], + }, + iqiyi: { + searchUrl: (keyword) => `https://so.iqiyi.com/so/q_${encodeURIComponent(keyword)}`, + siteSearchUrls: [ + (keyword) => `https://so.iqiyi.com/so/q_${encodeURIComponent(keyword)}`, + (keyword) => `https://www.iqiyi.com/search?keyword=${encodeURIComponent(keyword)}`, + ], + allowHosts: ["www.iqiyi.com"], + includePaths: [/\/v_/, /\/a_/], + excludePaths: [/\/so\//], + fallbackQueries: [ + (keyword) => `site:www.iqiyi.com/a_ ${keyword} 爱奇艺 热度`, + (keyword) => `site:www.iqiyi.com/v_ ${keyword} 爱奇艺`, + (keyword) => `${keyword} 爱奇艺`, + ], + }, + mgtv: { + searchUrl: (keyword) => `https://so.mgtv.com/so?k=${encodeURIComponent(keyword)}`, + siteSearchUrls: [ + (keyword) => `https://so.mgtv.com/so?k=${encodeURIComponent(keyword)}`, + ], + allowHosts: ["www.mgtv.com", "mgtv.com"], + includePaths: [/\/b\//, /\/h\//, /\/l\//], + excludePaths: [/\/so/], + fallbackQueries: [ + (keyword) => `site:www.mgtv.com/h ${keyword} 芒果TV`, + (keyword) => `site:www.mgtv.com/b ${keyword} 芒果TV`, + (keyword) => `${keyword} 芒果TV`, + ], + }, +}; + +SEARCH_CONFIGS.tencent.fallbackQueries = [ + (keyword) => `site:v.qq.com/x/cover ${keyword} 腾讯视频`, + (keyword) => `site:v.qq.com/x/page ${keyword} 腾讯视频`, + (keyword) => `${keyword} 腾讯视频 少儿`, + (keyword) => `${keyword} 小企鹅乐园`, + (keyword) => `${keyword} 腾讯视频`, +]; +SEARCH_CONFIGS.youku.fallbackQueries = [ + (keyword) => `site:v.youku.com/v_show ${keyword}`, + (keyword) => `site:v.youku.com/video ${keyword}`, + (keyword) => `site:www.youku.com/show_page ${keyword}`, + (keyword) => `site:youku.com ${keyword} youku`, + (keyword) => `site:v.youku.com ${keyword} 优酷`, + (keyword) => `site:youku.com/show_page ${keyword} 优酷`, + (keyword) => `${keyword} 优酷`, +]; +SEARCH_CONFIGS.iqiyi.excludePaths = [/\/so(?:\/|$)/, /\/search/]; +SEARCH_CONFIGS.iqiyi.fallbackQueries = [ + (keyword) => `${keyword} 爱奇艺`, + (keyword) => `site:www.iqiyi.com/a_ ${keyword} 爱奇艺 热度`, + (keyword) => `site:www.iqiyi.com/v_ ${keyword} 爱奇艺`, +]; +SEARCH_CONFIGS.mgtv.fallbackQueries = [ + (keyword) => `site:www.mgtv.com/h ${keyword} 芒果TV`, + (keyword) => `site:www.mgtv.com/b ${keyword} 芒果TV`, + (keyword) => `${keyword} 芒果TV`, +]; + +export async function findProgramPage(platform, keyword, options = {}) { + const config = SEARCH_CONFIGS[platform]; + if (!config) { + return { + platform, + keyword, + url: "", + status: "error", + error: `unsupported platform: ${platform}`, + candidates: [], + }; + } + + try { + const keywordAliases = platform === "youku" + ? await youkuHomeSearchKeywords(keyword, options.signal) + : platform === "iqiyi" + ? iqiyiSearchKeywords(keyword) + : [keyword]; + const searchUrl = config.searchUrl(keyword); + let html = ""; + let blockedSearch = Boolean(config.preferFallback); + let responseOk = true; + + if (!config.preferFallback) { + const response = await fetch(searchUrl, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: fetchSignal(options.signal, SEARCH_TIMEOUT_MS), + }); + html = await response.text(); + blockedSearch = response.status === 403 || response.status === 429 || isBlockedSearchPage(html); + responseOk = response.ok; + } + + if (!responseOk && !blockedSearch) { + return { + platform, + keyword, + url: "", + status: "error", + error: "search HTTP error", + candidates: [], + }; + } + + let candidates = blockedSearch + ? [] + : await rankCandidates(platform, await candidateUrlsFromHtml(platform, html, searchUrl, config, keyword, options.signal), keyword, options.signal); + let matchedSearchUrl = searchUrl; + + if (!hasStrongCandidate(candidates) && config.siteSearchUrls?.length) { + const siteSearch = await findFromSiteSearches(platform, config, keywordAliases, options.signal); + candidates = mergeCandidates(candidates, siteSearch.candidates); + matchedSearchUrl = siteSearch.searchUrl || matchedSearchUrl; + } + + if (platform === "tencent" && !hasStrongCandidate(candidates)) { + const stationSearch = await findFromTencentStationSearch(config, keywordAliases, options.signal); + candidates = mergeCandidates(candidates, stationSearch.candidates); + matchedSearchUrl = stationSearch.searchUrl || matchedSearchUrl; + } + + if (platform === "iqiyi" && !hasStrongCandidate(candidates)) { + const iqiyiFallback = await findIqiyiFromDuckDuckGo(config, keywordAliases, options.signal); + candidates = mergeCandidates(candidates, iqiyiFallback.candidates); + matchedSearchUrl = iqiyiFallback.searchUrl || matchedSearchUrl; + } + + if (!hasStrongCandidate(candidates)) { + const fallback = await findFromFallbackSearch(platform, config, keywordAliases, options.signal); + candidates = mergeCandidates(candidates, fallback.candidates); + matchedSearchUrl = fallback.searchUrl || matchedSearchUrl; + } + + const best = candidates[0]; + return { + platform, + keyword, + url: best?.url || "", + status: best ? "ok" : "no_match", + error: best ? "" : (blockedSearch ? "search page requires verification" : "no program page found from search page"), + candidates, + searchUrl: matchedSearchUrl, + }; + } catch (error) { + return { + platform, + keyword, + url: "", + status: "error", + error: error.message, + candidates: [], + }; + } +} + +export async function findProgramPageQuick(platform, keyword) { + const controller = new AbortController(); + let timer; + try { + timer = setTimeout(() => controller.abort(), QUICK_SEARCH_TIMEOUT_MS); + return await findProgramPage(platform, keyword, { signal: controller.signal }); + } catch (error) { + return { + platform, + keyword, + url: "", + status: "error", + error: controller.signal.aborted ? `quick search timeout ${QUICK_SEARCH_TIMEOUT_MS}ms` : error.message, + candidates: [], + searchUrl: "", + }; + } finally { + clearTimeout(timer); + } +} + +function fetchSignal(parentSignal, timeoutMs) { + return parentSignal ? AbortSignal.any([parentSignal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs); +} + +async function findFromSiteSearches(platform, config, keywords, signal) { + let bestCandidates = []; + let bestSearchUrl = ""; + + for (const keyword of uniqueKeywords(keywords)) { + for (const searchBuilder of config.siteSearchUrls || []) { + const searchUrl = searchBuilder(keyword); + try { + const response = await fetch(searchUrl, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + if (!response.ok) continue; + const html = await response.text(); + if (isBlockedSearchPage(html)) continue; + + const candidates = await rankCandidates(platform, await candidateUrlsFromHtml(platform, html, searchUrl, config, keyword, signal), keyword, signal); + if (hasStrongCandidate(candidates)) return { candidates, searchUrl }; + if (candidates.length > bestCandidates.length) { + bestCandidates = candidates; + bestSearchUrl = searchUrl; + } + } catch { + continue; + } + } + } + + return { candidates: bestCandidates, searchUrl: bestSearchUrl }; +} + +const TENCENT_SEARCH_API_URLS = [ + "https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.MultiTerminalSearch/MbSearch?vversion_platform=2", + "https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.HttpMobileRecall/MbSearchHttp", +]; + +async function findFromTencentStationSearch(config, keywords, signal) { + let bestCandidates = []; + let bestSearchUrl = ""; + + for (const keyword of uniqueKeywords(keywords)) { + for (const searchUrl of TENCENT_SEARCH_API_URLS) { + try { + const response = await fetch(searchUrl, { + method: "POST", + headers: getTencentSearchApiHeaders(keyword), + body: JSON.stringify(buildTencentSearchPayload(keyword)), + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + if (!response.ok) continue; + + const json = await response.json(); + const candidates = await rankCandidates( + "tencent", + extractTencentSearchCandidates(json, keyword, config), + keyword, + signal, + ); + if (hasStrongCandidate(candidates)) return { candidates, searchUrl }; + if (candidates.length > bestCandidates.length) { + bestCandidates = candidates; + bestSearchUrl = searchUrl; + } + } catch { + continue; + } + } + } + + return { candidates: bestCandidates, searchUrl: bestSearchUrl }; +} + +function getTencentSearchApiHeaders(keyword) { + return { + ...getRequestHeaders("tencent"), + accept: "application/json, text/plain, */*", + "content-type": "application/json", + origin: "https://v.qq.com", + referer: SEARCH_CONFIGS.tencent.searchUrl(keyword), + }; +} + +function buildTencentSearchPayload(keyword) { + return { + query: keyword, + pagenum: 0, + pagesize: 20, + queryFrom: 0, + filterValue: "", + sceneId: 21, + searchDatakey: "", + transInfo: "", + isneedQc: true, + preQid: "", + adClientInfo: "", + extraInfo: { + isNewMarkLabel: "0", + multi_terminal_pc: "1", + themeType: "0", + sugRelatedIds: "{}", + appVersion: "", + frontVersion: "26041606", + }, + version: "26022601", + clientType: 1, + uuid: crypto.randomUUID(), + retry: 0, + featureList: [ + "DEFAULT_FEFEATURE", + "PC_SHORT_VIDEOS_WATERFALL", + "PC_WANT_EPISODE_V2", + "PC_WANT_EPISODE", + ], + }; +} + +async function findFromFallbackSearch(platform, config, keywords, signal) { + let bestCandidates = []; + let bestSearchUrl = ""; + for (const keyword of uniqueKeywords(keywords)) { + for (const queryBuilder of config.fallbackQueries || []) { + const query = queryBuilder(keyword); + for (const engine of fallbackSearchUrls(query)) { + try { + const response = await fetch(engine.url, { + headers: { + ...getRequestHeaders(""), + referer: engine.referer, + }, + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + + if (!response.ok) continue; + const html = await response.text(); + const candidates = await rankCandidates(platform, await candidateUrlsFromHtml(platform, html, engine.url, config, keyword, signal), keyword, signal); + if (hasStrongCandidate(candidates)) return { candidates, searchUrl: engine.url }; + if (candidates.length > bestCandidates.length) { + bestCandidates = candidates; + bestSearchUrl = engine.url; + } + } catch { + continue; + } + } + } + } + + return { candidates: bestCandidates, searchUrl: bestSearchUrl }; +} + +async function findIqiyiFromDuckDuckGo(config, keywords, signal) { + for (const keyword of uniqueKeywords(keywords)) { + const query = `${keyword} 爱奇艺`; + const searchUrl = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + try { + const response = await fetch(searchUrl, { + headers: { + ...getRequestHeaders(""), + referer: "https://duckduckgo.com/", + }, + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + if (!response.ok) continue; + const html = await response.text(); + const candidates = extractCandidateUrls(html, searchUrl, config, keyword) + .map((candidate) => ({ + ...candidate, + keywordScore: keywordMatchScore(candidate.evidence, keyword), + score: candidate.score + keywordMatchScore(candidate.evidence, keyword), + })) + .filter((candidate) => candidate.keywordScore > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + if (hasStrongCandidate(candidates)) return { candidates, searchUrl }; + } catch { + continue; + } + } + + return { candidates: [], searchUrl: "" }; +} + +async function youkuHomeSearchKeywords(keyword, signal) { + const keywords = [keyword]; + try { + const json = await fetchYoukuMtopSearch({ + pg: "1", + pz: "12", + searchFrom: "home", + utdId: "XlQcF5xQrCcCAWoLKdGqIOhS", + ykPid: "", + sdkver: 314, + pcKuFlixMode: 1, + appScene: "kubox", + appCaller: "pc", + s: "pc", + device: "pc", + platform: "pc", + keyword, + }, signal); + + for (const value of extractYoukuSuggestionTexts(json)) { + keywords.push(value); + } + } catch {} + + return uniqueKeywords(keywords).slice(0, 5); +} + +async function fetchYoukuMtopSearch(dataObject, signal) { + const appKey = "23774304"; + const api = "mtop.youku.soku.yksearch"; + const data = JSON.stringify(dataObject); + const headers = { + ...getRequestHeaders("youku"), + referer: "https://www.youku.com/", + }; + + const first = await fetch(buildYoukuMtopUrl({ api, appKey, data, token: "" }), { + headers, + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + await first.text(); + const cookieHeader = first.headers.get("set-cookie") || ""; + const token = extractMtopToken(cookieHeader); + if (!token) return {}; + + const response = await fetch(buildYoukuMtopUrl({ api, appKey, data, token }), { + headers: { + ...headers, + cookie: compactMtopCookie(cookieHeader), + }, + redirect: "follow", + signal: fetchSignal(signal, 8_000), + }); + return response.json(); +} + +function buildYoukuMtopUrl({ api, appKey, data, token }) { + const timestamp = Date.now().toString(); + const sign = crypto + .createHash("md5") + .update(`${token}&${timestamp}&${appKey}&${data}`) + .digest("hex"); + const params = new URLSearchParams({ + jsv: "2.7.2", + appKey, + t: timestamp, + sign, + api, + v: "2.0", + type: "GET", + dataType: "json", + ecode: "1", + data, + }); + return `https://acs.youku.com/h5/${api}/2.0/?${params.toString()}`; +} + +function extractMtopToken(cookieHeader) { + return (cookieHeader.match(/_m_h5_tk=([^_;]+)/)?.[1] || "").split("_")[0] || ""; +} + +function compactMtopCookie(cookieHeader) { + return [...cookieHeader.matchAll(/(?:^|, )([^=;, ]+=[^;]+)/g)] + .map((match) => match[1]) + .filter((cookie) => cookie.startsWith("_m_h5") || cookie.startsWith("mtop")) + .join("; "); +} + +function extractYoukuSuggestionTexts(json) { + const values = []; + walkJson(json, (key, value) => { + if (typeof value !== "string") return; + if (!["w", "show_w", "keyword"].includes(key)) return; + const text = stripHtml(value).trim(); + if (text) values.push(text); + }); + return values; +} + +function walkJson(value, visit) { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + visit(key, child); + walkJson(child, visit); + } +} + +function stripHtml(value) { + return String(value || "").replace(/<[^>]+>/g, ""); +} + +function uniqueKeywords(keywords) { + const seen = new Set(); + const result = []; + for (const keyword of keywords) { + const value = String(keyword || "").trim(); + const key = normalizeSearchText(value); + if (!value || seen.has(key)) continue; + seen.add(key); + result.push(value); + } + return result; +} + +export function iqiyiSearchKeywords(keyword) { + const value = String(keyword || "").trim(); + const keywords = [value]; + const seasonMatch = value.match(/^(.+?)(\d{1,2})之(.+)$/); + if (seasonMatch) { + const [, prefix, season, title] = seasonMatch; + keywords.push(`${prefix} 第${season}季 ${title}`); + keywords.push(`${prefix}第${season}季${title}`); + keywords.push(`${prefix} ${title}`); + } + return uniqueKeywords(keywords).slice(0, 5); +} + +function fallbackSearchUrls(query) { + const encoded = encodeURIComponent(query); + return [ + { + url: `https://www.bing.com/search?format=rss&q=${encoded}`, + referer: "https://www.bing.com/", + }, + { + url: `https://www.bing.com/search?q=${encoded}`, + referer: "https://www.bing.com/", + }, + { + url: `https://duckduckgo.com/html/?q=${encoded}`, + referer: "https://duckduckgo.com/", + }, + { + url: `https://www.baidu.com/s?wd=${encoded}`, + referer: "https://www.baidu.com/", + }, + { + url: `https://www.sogou.com/web?query=${encoded}`, + referer: "https://www.sogou.com/", + }, + ]; +} + +async function candidateUrlsFromHtml(platform, html, baseUrl, config, keyword, signal) { + const direct = extractCandidateUrls(html, baseUrl, config, keyword); + const expanded = await expandShortLinkCandidates(platform, html, config, keyword, signal); + const bridge = direct.length >= 2 ? [] : await expandBridgePageCandidates(platform, html, baseUrl, config, keyword, signal); + return mergeCandidates(direct, expanded, bridge); +} + +export function extractCandidateUrls(html, baseUrl, config, keyword) { + const decoded = decodeEscapedText(html); + const candidates = new Map(); + + for (const candidate of extractStructuredSearchCandidates(decoded, baseUrl, config, keyword)) { + const previous = candidates.get(candidate.url); + if (!previous || candidate.score > previous.score) { + candidates.set(candidate.url, candidate); + } + } + + const linkMatches = [ + ...decoded.matchAll(/\bhref\s*=\s*["']([^"']+)["']/gi), + ...decoded.matchAll(/\b(?:url|playUrl|pageUrl|coverUrl|jumpUrl|target)\s*[:=]\s*["']([^"']+)["']/gi), + ...decoded.matchAll(/\s*([^<\s]+)\s*<\/link>/gi), + ...decoded.matchAll(/["']((?:https?:)?\/\/[^"']+)["']/gi), + ...decoded.matchAll(/\b((?:https?:)?\/\/(?:v\.qq\.com|(?:v\.|www\.)?youku\.com|www\.iqiyi\.com|(?:www\.)?mgtv\.com)\/[^"'<>\s]+)/gi), + ...decoded.matchAll(/\b((?:v\.qq\.com|(?:v\.|www\.)?youku\.com|www\.iqiyi\.com|(?:www\.)?mgtv\.com)\/[^"'<>\s]+)/gi), + ]; + + for (const match of linkMatches) { + const rawUrl = match[1]; + const url = normalizeUrl(rawUrl, baseUrl); + if (!url) continue; + + const score = scoreUrl(url, config, keyword); + if (score <= 0) continue; + + const previous = candidates.get(url); + if (!previous || score > previous.score) { + candidates.set(url, { + url, + score, + evidence: cleanSnippet(decoded, match.index ?? 0, 700), + }); + } + } + + return [...candidates.values()].sort((a, b) => b.score - a.score).slice(0, 10); +} + +export function extractTencentSearchCandidates(json, keyword, config = SEARCH_CONFIGS.tencent) { + const candidates = new Map(); + + for (const { item, boxShowName } of tencentSearchItems(json)) { + const evidence = tencentItemEvidence(item, boxShowName); + if (keywordMatchScore(evidence, keyword) <= 0) continue; + + for (const url of tencentItemProgramUrls(item)) { + const score = scoreUrl(url, config, keyword); + if (score <= 0) continue; + const candidate = { + url, + score: score + 140, + evidence, + }; + const previous = candidates.get(url); + if (!previous || candidate.score > previous.score) { + candidates.set(url, candidate); + } + } + } + + return [...candidates.values()] + .sort((a, b) => b.score - a.score) + .slice(0, 10); +} + +function tencentSearchItems(json) { + const lists = [ + json?.data?.normalList, + ...(json?.data?.areaBoxList || []), + ].filter(Boolean); + const items = []; + const seen = new Set(); + + for (const list of lists) { + for (const item of list.itemList || []) { + if (!item || typeof item !== "object" || seen.has(item)) continue; + seen.add(item); + items.push({ item, boxShowName: list.boxShowName || "" }); + } + } + + return items; +} + +function tencentItemProgramUrls(item) { + const urls = []; + const nodes = [item, item?.videoInfo, item?.doc].filter(Boolean); + + for (const node of nodes) { + if (Number(node.dataType) === 2) { + const cid = tencentNodeId(node); + const coverUrl = tencentCoverUrlFromCid(cid); + if (coverUrl) urls.push(coverUrl); + } + + for (const key of ["url", "playUrl", "pageUrl", "coverUrl", "jumpUrl", "target"]) { + const url = canonicalTencentProgramUrl(node[key]); + if (url) urls.push(url); + } + } + + return [...new Set(urls)]; +} + +function tencentNodeId(node) { + return String(node?.cid || node?.coverId || node?.cover_id || node?.id || "").trim(); +} + +function tencentCoverUrlFromCid(cid) { + const value = String(cid || "").trim(); + if (!/^[a-z0-9]{8,40}$/i.test(value)) return ""; + return `https://v.qq.com/x/cover/${value}.html`; +} + +function canonicalTencentProgramUrl(rawUrl) { + const url = normalizeUrl(rawUrl, "https://v.qq.com/"); + if (!url) return ""; + + try { + const parsed = new URL(url); + const path = safeDecodeURIComponent(parsed.pathname); + const coverMatch = path.match(/^\/x\/cover\/([^/]+)(?:\/[^/]+)?\.html$/); + if (parsed.hostname === "v.qq.com" && coverMatch) { + return `https://v.qq.com/x/cover/${coverMatch[1]}.html`; + } + } catch {} + + return url; +} + +function tencentItemEvidence(item, boxShowName = "") { + const values = [boxShowName]; + collectTencentEvidenceStrings(item, values); + return [...new Set(values.map(stripHtml).map((value) => value.trim()).filter(Boolean))] + .join(" "); +} + +function collectTencentEvidenceStrings(value, results, depth = 0) { + if (!value || typeof value !== "object" || depth > 3 || results.length > 80) return; + + for (const [key, child] of Object.entries(value)) { + if (typeof child === "string") { + if (/title|name|subtitle|desc|keyword|text/i.test(key)) results.push(child); + continue; + } + if (child && typeof child === "object") { + collectTencentEvidenceStrings(child, results, depth + 1); + } + } +} + +async function expandShortLinkCandidates(platform, html, config, keyword, signal) { + const decoded = decodeEscapedText(html); + const results = []; + const seen = new Set(); + const shortLinks = extractShortLinks(decoded, keyword, platform); + + for (const item of shortLinks.slice(0, 5)) { + if (seen.has(item.url)) continue; + seen.add(item.url); + + try { + const response = await fetch(item.url, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: fetchSignal(signal, 5_000), + }); + const target = response.url || ""; + const score = scoreUrl(target, config, keyword); + if (score <= 0) continue; + results.push({ + url: target, + score: score + 120, + evidence: item.evidence, + }); + } catch {} + } + + return results; +} + +export function extractShortLinks(text, keyword = "", platform = "") { + const decoded = decodeEscapedText(text); + const results = []; + const shortLinkPattern = /\bhttps?:\/\/(?:t\.cn|url\.cn|m\.weibo\.cn\/status|weibo\.com\/ttarticle\/x\/m\/show)[^\s"'<>),。;]+/gi; + for (const match of decoded.matchAll(shortLinkPattern)) { + const evidence = cleanSnippet(decoded, match.index ?? 0, 500); + if (keyword && keywordMatchScore(evidence, keyword) <= 0) continue; + if (platform && !platformEvidenceMatches(evidence, platform)) continue; + results.push({ + url: match[0], + evidence, + }); + } + return results; +} + +async function expandBridgePageCandidates(platform, html, baseUrl, config, keyword, signal) { + const bridgePages = extractBridgePageUrls(html, baseUrl, keyword, platform); + const results = []; + + for (const bridge of bridgePages.slice(0, 3)) { + try { + const response = await fetch(bridge.url, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: fetchSignal(signal, 6_000), + }); + if (!response.ok) continue; + const pageHtml = await response.text(); + results.push( + ...extractCandidateUrls(pageHtml, response.url || bridge.url, config, keyword) + .map((candidate) => ({ + ...candidate, + score: candidate.score + 60, + evidence: `${bridge.evidence} ${candidate.evidence}`.trim(), + })), + ); + results.push(...await expandShortLinkCandidates(platform, pageHtml, config, keyword, signal)); + } catch {} + } + + return results; +} + +export function extractBridgePageUrls(html, baseUrl, keyword = "", platform = "") { + const decoded = decodeEscapedText(html); + const results = []; + const seen = new Set(); + const blocks = [ + ...decoded.matchAll(//gi), + ...decoded.matchAll(/]*(?:class|id)=["'][^"']*(?:result|b_algo|vrwrap|news-box|result-op)[^"']*["'][\s\S]*?<\/div>/gi), + ]; + + for (const block of blocks) { + const rawBlock = block[0]; + const blockText = decodePercentText(rawBlock) + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (keyword && keywordMatchScore(blockText, keyword) <= 0) continue; + if (platform && !platformEvidenceMatches(blockText, platform)) continue; + + for (const match of [ + ...rawBlock.matchAll(/\s*([^<\s]+)\s*<\/link>/gi), + ...rawBlock.matchAll(/\bhref\s*=\s*["']([^"']+)["']/gi), + ]) { + const url = normalizeUrl(match[1], baseUrl); + if (!url || seen.has(url)) continue; + if (scoreUrl(url, configForBridge(platform), keyword) > 0) continue; + if (isSearchOrEngineUrl(url)) continue; + seen.add(url); + results.push({ url, evidence: blockText }); + } + } + + return results; +} + +function configForBridge(platform) { + return SEARCH_CONFIGS[platform] || { + allowHosts: [], + includePaths: [], + excludePaths: [], + }; +} + +function isSearchOrEngineUrl(url) { + try { + const host = new URL(url).hostname.toLowerCase(); + return /(?:bing|baidu|sogou|google)\./.test(host); + } catch { + return true; + } +} + +function platformEvidenceMatches(text, platform) { + const normalized = normalizeSearchText(text); + const terms = { + tencent: ["腾讯视频", "腾讯", "小企鹅乐园", "企鹅乐园", "vqq"], + youku: ["优酷", "youku"], + iqiyi: ["爱奇艺", "iqiyi"], + mgtv: ["芒果tv", "芒果", "mgtv"], + }[platform] || []; + return terms.length === 0 || terms.some((term) => normalized.includes(normalizeSearchText(term))); +} + +function extractStructuredSearchCandidates(decoded, baseUrl, config, keyword) { + const results = []; + const blocks = [ + ...decoded.matchAll(//gi), + ...decoded.matchAll(/]*(?:class|id)=["'][^"']*(?:result|b_algo|vrwrap|news-box|result-op)[^"']*["'][\s\S]*?<\/div>/gi), + ]; + + for (const block of blocks) { + const rawBlock = block[0]; + const blockText = decodePercentText(rawBlock) + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (keywordMatchScore(blockText, keyword) <= 0) continue; + + const urls = [ + ...rawBlock.matchAll(/\s*([^<\s]+)\s*<\/link>/gi), + ...rawBlock.matchAll(/\bhref\s*=\s*["']([^"']+)["']/gi), + ...rawBlock.matchAll(/["']((?:https?:)?\/\/[^"']+)["']/gi), + ...rawBlock.matchAll(/\b((?:https?:)?\/\/(?:v\.qq\.com|(?:v\.|www\.)?youku\.com|www\.iqiyi\.com|(?:www\.)?mgtv\.com)\/[^"'<>\s]+)/gi), + ]; + + for (const match of urls) { + const url = normalizeUrl(match[1], baseUrl); + if (!url) continue; + + const score = scoreUrl(url, config, keyword); + if (score <= 0) continue; + + results.push({ + url, + score: score + 80, + evidence: blockText, + }); + } + } + + return results; +} + +async function rankCandidates(platform, candidates, keyword, signal) { + const ranked = []; + for (const candidate of candidates.slice(0, 8)) { + const pageTitle = await fetchPageTitle(platform, candidate.url, signal); + if (titleConflictsWithKeyword(pageTitle, keyword)) continue; + const keywordScore = keywordMatchScore(`${candidate.evidence} ${pageTitle}`, keyword); + ranked.push({ + ...candidate, + pageTitle, + keywordScore, + score: candidate.score + keywordScore, + }); + } + + return ranked + .filter((candidate) => candidate.keywordScore > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 10); +} + +async function fetchPageTitle(platform, url, signal) { + try { + const response = await fetch(url, { + headers: getRequestHeaders(platform), + redirect: "follow", + signal: fetchSignal(signal, 6_000), + }); + const html = await response.text(); + return decodeEscapedText(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] || "") + .replace(/\s+/g, " ") + .trim(); + } catch { + return ""; + } +} + +function hasStrongCandidate(candidates) { + return candidates.some((candidate) => candidate.score >= 180); +} + +export function titleConflictsWithKeyword(pageTitle, keyword) { + const title = String(pageTitle || "").trim(); + if (!title) return false; + return keywordMatchScore(title, keyword) === 0; +} + +function isBlockedSearchPage(html) { + return /_____tmd_____|x5secdata|captcha|验证码|安全验证|人机验证|访问过于频繁|请求过于频繁/i.test(html); +} + +function keywordMatchScore(text, keyword) { + const haystack = normalizeSearchText(text); + const tokens = keywordTokens(keyword); + if (!haystack || tokens.length === 0) return 0; + + const full = normalizeSearchText(keyword); + let score = haystack.includes(full) ? 220 : 0; + const matched = tokens.filter((token) => haystack.includes(token)).length; + if (matched === tokens.length) score += 180; + score += matched * 45; + return score; +} + +function keywordTokens(keyword) { + const tokens = String(keyword) + .split(/[\s::\-_/]+/) + .map(normalizeSearchText) + .filter((token) => token.length >= 2); + return [...new Set(tokens)]; +} + +function normalizeSearchText(value) { + return decodePercentText(String(value || "")) + .toLowerCase() + .replace(/[《》【】[\]()()::\s\-_/]+/g, ""); +} + +function scoreUrl(url, config, keyword) { + let parsed; + try { + parsed = new URL(url); + } catch { + return 0; + } + + const host = parsed.hostname.toLowerCase(); + const path = safeDecodeURIComponent(parsed.pathname); + if (!config.allowHosts.some((allowedHost) => host === allowedHost || host.endsWith(`.${allowedHost}`))) { + return 0; + } + + if (config.excludePaths.some((pattern) => pattern.test(path))) { + return 0; + } + + if (/^\/(?:a_|v_)\/?$/.test(path)) return 0; + if (host.includes("youku.com") && path === "/video" && !parsed.searchParams.get("s")) return 0; + if (!config.includePaths.some((pattern) => pattern.test(path))) return 0; + + let score = 80; + if (/\/a_/.test(path) || /\/show_page\//.test(path) || /\/x\/cover\//.test(path) || /\/b\//.test(path)) score += 20; + if (/\/v_/.test(path) || /\/v_show\//.test(path) || /\/x\/page\//.test(path)) score += 5; + if (url.includes(encodeURIComponent(keyword)) || url.includes(keyword)) score += 5; + if (url.includes("...") || url.includes("%E2%80%A6")) score = 0; + if (/\.(jpg|jpeg|png|gif|webp|css|js|ico|svg)$/i.test(path)) score = 0; + return score; +} + +function mergeCandidates(...groups) { + const merged = new Map(); + for (const candidate of groups.flat()) { + if (!candidate?.url) continue; + const previous = merged.get(candidate.url); + if (!previous || candidate.score > previous.score) { + merged.set(candidate.url, candidate); + } + } + return [...merged.values()] + .sort((a, b) => b.score - a.score) + .slice(0, 10); +} + +function normalizeUrl(rawUrl, baseUrl) { + if (!rawUrl) return ""; + const trimmed = decodeEscapedText(rawUrl.trim()); + if (trimmed.startsWith("javascript:") || trimmed.startsWith("#")) return ""; + try { + const absolute = /^(?:https?:)?\/\//i.test(trimmed) + ? trimmed + : /^(?:v\.qq\.com|(?:v\.|www\.)?youku\.com|www\.iqiyi\.com|(?:www\.)?mgtv\.com)\//i.test(trimmed) + ? `https://${trimmed}` + : trimmed; + const parsed = new URL(absolute, baseUrl); + const unwrapped = decodeWrappedTarget(parsed); + if (unwrapped) return new URL(unwrapped).toString(); + normalizeTencentPath(parsed); + return cleanupUrl(parsed); + } catch { + return ""; + } +} + +function normalizeTencentPath(parsed) { + if (parsed.hostname !== "v.qq.com") return; + if (!/^\/x\/cover\//.test(parsed.pathname)) return; + if (pathExtension(parsed.pathname)) return; + parsed.pathname = `${parsed.pathname}.html`; +} + +function pathExtension(pathname) { + return /\.[a-z0-9]+$/i.test(pathname); +} + +function decodeWrappedTarget(parsed) { + if (parsed.hostname.endsWith("bing.com")) { + const encoded = parsed.searchParams.get("u"); + if (encoded) { + try { + const value = encoded.startsWith("a1") ? encoded.slice(2) : encoded; + return Buffer.from(value, "base64url").toString("utf8"); + } catch {} + } + } + + for (const key of ["url", "u", "target", "to", "redirect", "jump"]) { + const value = parsed.searchParams.get(key); + if (!value) continue; + const decoded = decodePercentText(value); + if (/^https?:\/\//i.test(decoded) || /^\/\//.test(decoded)) return decoded; + } + + return ""; +} + +function cleanupUrl(parsed) { + parsed.hash = ""; + for (const key of [...parsed.searchParams.keys()]) { + if (/^(ptag|from|fromvsogou|query|wd|q|src|source|utm_|spm|cxid)/i.test(key)) { + parsed.searchParams.delete(key); + } + } + return parsed.toString(); +} + +function decodeEscapedText(value) { + return decodeHtmlEntities(String(value) + .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\\//g, "/")); +} + +function decodeHtmlEntities(value) { + return String(value) + .replace(/ /g, " ") + .replace(/"/g, "\"") + .replace(/"/g, "\"") + .replace(/"/gi, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/gi, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function cleanSnippet(text, index, padding = 160) { + const start = Math.max(0, index - padding); + const end = Math.min(text.length, index + padding); + return decodePercentText(text.slice(start, end)) + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function decodePercentText(value) { + return String(value).replace(/%[0-9a-f]{2}(?:%[0-9a-f]{2})*/gi, (match) => { + try { + return decodeURIComponent(match); + } catch { + return match; + } + }); +} + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch { + return String(value || ""); + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..b3af4ef --- /dev/null +++ b/src/server.js @@ -0,0 +1,1095 @@ +import http from "node:http"; +import crypto from "node:crypto"; +import { appendFile, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { annotateCollectionAnomalies } from "./anomaly.js"; +import { collectProgramHotness } from "./collector.js"; +import { analyzeKidsTrend } from "./kidsTrend.js"; +import { deleteProgramLinkEntry, getProgramLinkEntry, saveProgramLinkEntry, validatePlatformUrls } from "./linkLibrary.js"; +import { findProgramPage } from "./search.js"; +import { pendingRetryItems } from "./retryQueue.js"; +import { recognizeImageText } from "./ocr.js"; +import { discoverRankingItems } from "./rankingDiscovery.js"; +import { defaultKidsSources } from "./rankingKids.js"; +import { collectionHistory, collectionMetrics, latestProgramMetrics, trendCollectionPlatforms } from "./rankingMetrics.js"; +import { + deleteRankingSource, + latestRankingSnapshot, + latestKidsTrendRun, + listRankingSources, + markRankingCollected, + markRankingTracked, + rankingCsv, + rankingPrograms, + refreshRankingSnapshot, + saveRankingSource, + saveLatestKidsTrendRun, + setRankingIgnored, +} from "./rankingStorage.js"; +import { allProgramsToCsv, appendCollection, deleteProgram, deleteProgramRun, deleteProgramRuns, deletePrograms, ensureProgramHistory, getProgramHistory, listMobileSyncDrafts, listPrograms, programToCsv, readHistory, saveMobileSyncDrafts } from "./storage.js"; +import { normalizePlatformUrl, PLATFORMS } from "./sites.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = path.resolve(__dirname, "../public"); +const PORT = Number.parseInt(process.env.PORT || "3000", 10); +const HOST = process.env.HOST || "::"; +const desktopRoot = process.env.HOTNESS_DESKTOP_ROOT || ""; +const desktopToken = process.env.HOTNESS_DESKTOP_TOKEN || ""; +const ACCESS_PASSWORD = String(process.env.HOTNESS_ACCESS_PASSWORD || "").trim(); +const ACCESS_TOKEN = ACCESS_PASSWORD + ? crypto.createHash("sha256").update(`hotness-access-v1:${ACCESS_PASSWORD}`).digest("hex") + : ""; +const DATA_DIR = path.resolve(process.env.HOTNESS_DATA_DIR || path.join(process.cwd(), "data")); +const DUTY_FILE = path.join(DATA_DIR, "duty-settings.json"); +const DUTY_EXPORT_DIR = path.join(DATA_DIR, "exports"); +let dutyStatus = { + last_run_at: "", + retry_count: 0, + collect_count: 0, + export_path: "", + error: "", +}; +let lastDutyAutoDate = ""; +let dutyRunning = false; + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host}`); + + if (url.pathname === "/api/auth/status" && request.method === "GET") { + return sendJson(response, 200, { + enabled: Boolean(ACCESS_PASSWORD), + authorized: isAuthorizedRequest(request, url), + }); + } + + if (url.pathname === "/api/auth/login" && request.method === "POST") { + const body = await readJsonBody(request); + if (!ACCESS_PASSWORD) return sendJson(response, 200, { enabled: false, token: "" }); + const password = String(body.password || ""); + if (!timingSafeEqualText(password, ACCESS_PASSWORD)) { + return sendAuthRequired(response, "访问密码不正确"); + } + response.setHeader("set-cookie", cookieHeader(ACCESS_TOKEN)); + return sendJson(response, 200, { enabled: true, token: ACCESS_TOKEN }); + } + + if (url.pathname.startsWith("/api/") && !isAuthorizedRequest(request, url)) { + return sendAuthRequired(response); + } + + if (url.pathname === "/api/desktop-instance" && request.method === "GET") { + return sendJson(response, 200, { + desktopRoot, + desktopToken, + }); + } + + if (url.pathname === "/api/collect" && request.method === "POST") { + const body = await readJsonBody(request); + const name = String(body.name || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + const platforms = sanitizePlatforms(body.platforms); + if (platforms.length === 0) return sendJson(response, 400, { error: "请至少选择一个采集平台" }); + + const existing = await getProgramHistory(name); + const urls = { + ...Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + existing.platforms?.[platform.id]?.url || "", + ])), + ...sanitizeUrls(body.urls || {}), + }; + const collection = await collectProgramHotness(name, { + urls, + platforms, + delayMs: 0, + quickSearch: body.quickSearch !== false, + parallelPlatforms: true, + }); + annotateCollectionAnomalies(collection, existing); + const history = await appendCollection(collection); + return sendJson(response, 200, { collection, history }); + } + + if (url.pathname === "/api/collect-batch" && request.method === "POST") { + const body = await readJsonBody(request); + const names = uniqueNames([ + ...(Array.isArray(body.names) ? body.names : []), + ...String(body.text || "").split(/\r?\n/), + ]); + const platforms = sanitizePlatforms(body.platforms); + if (names.length === 0) return sendJson(response, 400, { error: "节目名不能为空" }); + if (names.length > 30) return sendJson(response, 400, { error: "一次最多批量采集 30 个节目" }); + if (platforms.length === 0) return sendJson(response, 400, { error: "请至少选择一个采集平台" }); + + const items = []; + for (const name of names) { + const existing = await getProgramHistory(name); + const urls = Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + existing.platforms?.[platform.id]?.url || "", + ])); + const collection = await collectProgramHotness(name, { urls, platforms }); + annotateCollectionAnomalies(collection, existing); + const history = await appendCollection(collection); + items.push({ name, collection, history }); + } + + return sendJson(response, 200, { items }); + } + + if (url.pathname === "/api/retry-pending" && request.method === "POST") { + const body = await readJsonBody(request); + const platforms = sanitizePlatforms(body.platforms); + const limit = Math.min(Math.max(Number(body.limit || 30), 1), 100); + const retryItems = pendingRetryItems(await readHistory()) + .map((item) => ({ + ...item, + platforms: item.platforms.filter((platform) => platforms.includes(platform)), + })) + .filter((item) => item.platforms.length > 0) + .slice(0, limit); + + const items = []; + for (const item of retryItems) { + const existing = await getProgramHistory(item.name); + const urls = Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + item.platforms.includes(platform.id) ? "" : (existing.platforms?.[platform.id]?.url || ""), + ])); + const collection = await collectProgramHotness(item.name, { + urls, + platforms: item.platforms, + freshSearchPlatforms: item.platforms, + }); + annotateCollectionAnomalies(collection, existing); + const history = await appendCollection(collection); + await saveSuccessfulRetryLinks(item.name, collection); + items.push({ ...item, collection, history }); + } + + return sendJson(response, 200, { items, pending_count: retryItems.length }); + } + + if (url.pathname === "/api/query-once" && request.method === "POST") { + const body = await readJsonBody(request); + const names = uniqueNames([ + ...(Array.isArray(body.names) ? body.names : []), + ...String(body.text || "").split(/\r?\n/), + ]); + const platforms = sanitizePlatforms(body.platforms); + if (names.length === 0) return sendJson(response, 400, { error: "节目名不能为空" }); + if (names.length > 50) return sendJson(response, 400, { error: "一次最多临时查询 50 个节目" }); + if (platforms.length === 0) return sendJson(response, 400, { error: "请至少选择一个查询平台" }); + + const items = await collectTemporaryQueryItems({ names, platforms, body }); + + return sendJson(response, 200, { items }); + } + + if (url.pathname === "/api/temporary-ocr" && request.method === "POST") { + const image = await readImageUploadBody(request); + const text = await recognizeImageText(image); + return sendJson(response, 200, { text }); + } + + if (url.pathname === "/api/history" && request.method === "GET") { + const name = String(url.searchParams.get("name") || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + + const history = await getProgramHistory(name); + return sendJson(response, 200, { history }); + } + + if (url.pathname === "/api/programs" && request.method === "GET") { + return sendJson(response, 200, { programs: await listPrograms() }); + } + + if (url.pathname === "/api/mobile-sync" && request.method === "GET") { + return sendJson(response, 200, await listMobileSyncDrafts()); + } + + if (url.pathname === "/api/mobile-sync" && request.method === "POST") { + const body = await readJsonBody(request); + const drafts = Array.isArray(body.drafts) ? body.drafts : []; + if (drafts.length === 0) return sendJson(response, 400, { error: "no mobile drafts to sync" }); + + const result = await saveMobileSyncDrafts({ + deviceName: body.deviceName, + drafts, + }); + return sendJson(response, 200, { + accepted: result.accepted, + accepted_count: result.accepted.length, + items: result.items, + }); + } + + if (url.pathname === "/api/duty-settings" && request.method === "GET") { + return sendJson(response, 200, { + settings: await readDutySettings(), + status: dutyStatus, + }); + } + + if (url.pathname === "/api/duty-settings" && request.method === "POST") { + const body = await readJsonBody(request); + const settings = await writeDutySettings(body.settings || {}); + return sendJson(response, 200, { settings, status: dutyStatus }); + } + + if (url.pathname === "/api/duty-status" && request.method === "GET") { + return sendJson(response, 200, { status: dutyStatus }); + } + + if (url.pathname === "/api/duty-run" && request.method === "POST") { + const body = await readJsonBody(request); + const settings = normalizeDutySettings({ + ...(await readDutySettings()), + ...(body.settings || {}), + }); + const status = await runDutyJob(settings); + return sendJson(response, 200, { settings, status }); + } + + if (url.pathname === "/api/link-library" && request.method === "GET") { + const name = String(url.searchParams.get("name") || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + return sendJson(response, 200, { entry: await getProgramLinkEntry(name) }); + } + + if (url.pathname === "/api/resolve-links" && request.method === "GET") { + const name = String(url.searchParams.get("name") || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + return sendJson(response, 200, await resolveProgramLinks(name)); + } + + if (url.pathname === "/api/link-library" && request.method === "POST") { + const body = await readJsonBody(request); + const entry = await saveProgramLinkEntry({ + name: body.name, + aliases: body.aliases, + urls: body.urls || {}, + }); + return sendJson(response, 200, { entry }); + } + + if (url.pathname === "/api/delete-run" && request.method === "POST") { + const body = await readJsonBody(request); + const name = String(body.name || "").trim(); + const run = String(body.run || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + if (!run) return sendJson(response, 400, { error: "时间列不能为空" }); + + const history = await deleteProgramRun(name, run); + return sendJson(response, 200, { history }); + } + + if (url.pathname === "/api/delete-runs" && request.method === "POST") { + const body = await readJsonBody(request); + const name = String(body.name || "").trim(); + const runs = [...new Set((body.runs || []).map((run) => String(run || "").trim()).filter(Boolean))]; + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + if (runs.length === 0) return sendJson(response, 400, { error: "请至少选择一个时间列" }); + + const history = await deleteProgramRuns(name, runs); + return sendJson(response, 200, { history }); + } + + if (url.pathname === "/api/delete-program" && request.method === "POST") { + const body = await readJsonBody(request); + const name = String(body.name || "").trim(); + if (!name) return sendJson(response, 400, { error: "节目名不能为空" }); + + const history = await deleteProgram(name); + if (body.deleteLibrary) await deleteProgramLinkEntry(name); + return sendJson(response, 200, { history, programs: await listPrograms() }); + } + + if (url.pathname === "/api/delete-programs" && request.method === "POST") { + const body = await readJsonBody(request); + const names = uniqueNames(body.names || []); + if (names.length === 0) return sendJson(response, 400, { error: "请至少选择一个节目" }); + + const deleted = await deletePrograms(names); + if (body.deleteLibrary) { + for (const name of deleted.names) await deleteProgramLinkEntry(name); + } + return sendJson(response, 200, { deleted, history: { name: "", runs: [], platforms: {} }, programs: await listPrograms() }); + } + + if (url.pathname === "/api/network" && request.method === "GET") { + return sendJson(response, 200, { + port: PORT, + urls: getLanUrls(PORT), + }); + } + + if (url.pathname === "/api/export" && request.method === "GET") { + const name = String(url.searchParams.get("name") || "").trim(); + if (!name) return sendText(response, 400, "节目名不能为空\n", "text/plain; charset=utf-8"); + + const history = await getProgramHistory(name); + response.writeHead(200, { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="${encodeURIComponent(name)}-hotness.csv"`, + }); + response.end(`\ufeff${programToCsv(history)}`); + return; + } + + if (url.pathname === "/api/export-all" && request.method === "GET") { + response.writeHead(200, { + "content-type": "text/csv; charset=utf-8", + "content-disposition": "attachment; filename=\"all-programs-hotness.csv\"", + }); + response.end(`\ufeff${await allProgramsToCsv()}`); + return; + } + + if (url.pathname === "/api/ranking-sources" && request.method === "GET") { + const category = String(url.searchParams.get("category") || "kids").trim(); + return sendJson(response, 200, { sources: await listRankingSources(category) }); + } + + if (url.pathname === "/api/ranking-sources" && request.method === "POST") { + const body = await readJsonBody(request); + const source = await saveRankingSource(body); + return sendJson(response, 200, { source, sources: await listRankingSources(source.category) }); + } + + if (url.pathname === "/api/ranking-sources/delete" && request.method === "POST") { + const body = await readJsonBody(request); + await deleteRankingSource(body.id); + return sendJson(response, 200, { ok: true }); + } + + if (url.pathname === "/api/rankings/latest" && request.method === "GET") { + const category = String(url.searchParams.get("category") || "kids").trim(); + return sendJson(response, 200, { snapshot: await latestRankingSnapshot(category) }); + } + + if (url.pathname === "/api/rankings/programs" && request.method === "GET") { + const category = String(url.searchParams.get("category") || "kids").trim(); + const view = String(url.searchParams.get("view") || "new").trim(); + const programs = await rankingPrograms(category, view, rankingFilters(url)); + return sendJson(response, 200, { programs: await enrichRankingPrograms(programs) }); + } + + if (url.pathname === "/api/rankings/refresh" && request.method === "POST") { + const body = await readJsonBody(request); + const category = String(body.category || "kids").trim(); + const sources = uniqueRankingSources([ + ...(category === "kids" && body.auto !== false ? defaultKidsSources() : []), + ...await listRankingSources(category), + ]).filter((source) => source.enabled !== false); + const items = []; + const errors = []; + + for (const source of sources) { + try { + items.push(...await discoverRankingItems(source)); + } catch (error) { + errors.push({ id: source.id, label: source.label, error: error.message }); + } + } + + const result = await refreshRankingSnapshot({ + category, + items, + sourceIds: sources.map((source) => source.id), + }); + return sendJson(response, 200, { ...result, errors }); + } + + if (url.pathname === "/api/rankings/default-sources" && request.method === "GET") { + return sendJson(response, 200, { sources: defaultKidsSources() }); + } + + if (url.pathname === "/api/rankings/ignore" && request.method === "POST") { + const body = await readJsonBody(request); + const programs = await setRankingIgnored({ + category: body.category, + name: body.name, + ignored: body.ignored !== false, + reason: body.reason || "", + }); + return sendJson(response, 200, { programs }); + } + + if (url.pathname === "/api/rankings/track" && request.method === "POST") { + const body = await readJsonBody(request); + const name = String(body.name || "").trim(); + const platform = String(body.platform || "").trim(); + const urlValue = String(body.url || "").trim(); + if (!name) return sendJson(response, 400, { error: "program name required" }); + + if (platform && urlValue) { + await saveProgramLinkEntry({ + name, + urls: { [platform]: urlValue }, + }); + } + await ensureProgramHistory(name); + const tracked = await markRankingTracked({ category: body.category, name }); + return sendJson(response, 200, { tracked, programs: await listPrograms() }); + } + + if (url.pathname === "/api/rankings/collect" && request.method === "POST") { + const body = await readJsonBody(request); + const category = String(body.category || "kids").trim(); + const names = uniqueNames(Array.isArray(body.names) ? body.names : []); + const platforms = sanitizePlatforms(body.platforms); + if (names.length === 0) return sendJson(response, 400, { error: "program names required" }); + + const items = []; + for (const name of names.slice(0, 20)) { + const existing = await getProgramHistory(name); + const urls = Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + existing.platforms?.[platform.id]?.url || "", + ])); + const collection = await collectProgramHotness(name, { urls, platforms }); + annotateCollectionAnomalies(collection, existing); + const history = await appendCollection(collection); + items.push({ name, collection, history }); + } + const programs = await markRankingCollected({ category, names }); + return sendJson(response, 200, { items, programs }); + } + + if (url.pathname === "/api/rankings/export" && request.method === "GET") { + const category = String(url.searchParams.get("category") || "kids").trim(); + const view = String(url.searchParams.get("view") || "new").trim(); + response.writeHead(200, { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="ranking-${encodeURIComponent(category)}-${encodeURIComponent(view)}.csv"`, + }); + response.end(`\ufeff${await rankingCsv(category, view)}`); + return; + } + + if (url.pathname === "/api/kids-trends/run" && request.method === "POST") { + const body = await readJsonBody(request); + const limit = Math.min(Math.max(Number(body.limit || 8), 1), 20); + const platforms = sanitizePlatforms(body.platforms); + const trend = await runKidsTrendCollection({ limit, platforms }); + return sendJson(response, 200, trend); + } + + if (url.pathname === "/api/kids-trends/latest" && request.method === "GET") { + return sendJson(response, 200, { trend: await latestKidsTrendRun() }); + } + + if (request.method !== "GET") { + return sendText(response, 405, "Method Not Allowed\n", "text/plain; charset=utf-8"); + } + + return serveStatic(url.pathname, response); + } catch (error) { + return sendJson(response, 500, { error: error.message }); + } +}); + +server.listen(PORT, HOST, () => { + if (process.env.HOTNESS_SERVER_LOG) { + appendFile(process.env.HOTNESS_SERVER_LOG, `Video hotness app is running at http://127.0.0.1:${PORT}\n`).catch(() => {}); + } +}); +startDutyScheduler(); + +async function enrichRankingPrograms(programs) { + return Promise.all((programs || []).map(async (program) => { + const history = await getProgramHistory(program.display_name || program.name || ""); + return { + ...program, + latest_metrics: latestProgramMetrics(history), + trend: analyzeKidsTrend(history), + }; + })); +} + +async function runKidsTrendCollection({ limit, platforms }) { + const sources = uniqueRankingSources([ + ...defaultKidsSources(), + ...await listRankingSources("kids"), + ]).filter((source) => source.enabled !== false); + const items = []; + const errors = []; + + for (const source of sources) { + try { + items.push(...await discoverRankingItems(source)); + } catch (error) { + errors.push({ id: source.id, label: source.label, error: error.message }); + } + } + + await refreshRankingSnapshot({ + category: "kids", + items, + sourceIds: sources.map((source) => source.id), + }); + + const candidates = await rankingPrograms("kids", "new", { + content_type: "animation", + }); + const selected = candidates.slice(0, limit); + const results = []; + + for (const program of selected) { + const name = program.display_name; + const urls = { + ...(program.url_by_platform || {}), + ...Object.fromEntries((program.platforms || []).map((platform, index) => [platform, program.urls?.[index] || ""])), + }; + const collection = await collectProgramHotness(name, { + urls, + platforms: trendCollectionPlatforms(program, platforms), + delayMs: 350, + }); + const history = collectionHistory(collection); + await markRankingCollected({ category: "kids", names: [name] }); + results.push({ + program: { + ...program, + latest_metrics: collectionMetrics(collection), + }, + collection, + trend: analyzeKidsTrend(history), + }); + } + + const trend = { + captured_at: new Date().toISOString(), + discovered_count: items.length, + collected_count: results.length, + errors, + results: results.sort((a, b) => trendOrder(a.trend) - trendOrder(b.trend) || (b.trend.best_delta || 0) - (a.trend.best_delta || 0)), + }; + await saveLatestKidsTrendRun(trend); + return trend; +} + +async function saveSuccessfulRetryLinks(name, collection) { + const urls = {}; + for (const result of collection.results || []) { + if (result.status !== "ok" || !result.url) continue; + urls[result.platform] = result.url; + } + if (Object.keys(urls).length === 0) return; + await saveProgramLinkEntry({ name, urls }); +} + +async function readDutySettings() { + try { + const content = await readFile(DUTY_FILE, "utf8"); + return normalizeDutySettings(JSON.parse(content)); + } catch (error) { + if (error.code === "ENOENT") return normalizeDutySettings({}); + throw error; + } +} + +async function writeDutySettings(settings) { + const normalized = normalizeDutySettings(settings); + await mkdir(DATA_DIR, { recursive: true }); + await writeFile(DUTY_FILE, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); + return normalized; +} + +function normalizeDutySettings(settings) { + return { + autoRetry: Boolean(settings.autoRetry), + autoCollect: Boolean(settings.autoCollect), + autoExport: settings.autoExport !== false, + runTime: /^\d{2}:\d{2}$/.test(String(settings.runTime || "")) ? settings.runTime : "09:30", + }; +} + +async function runDutyJob(settings) { + if (dutyRunning) return dutyStatus; + dutyRunning = true; + const startedAt = new Date().toISOString(); + const status = { + last_run_at: startedAt, + retry_count: 0, + collect_count: 0, + export_path: "", + error: "", + }; + + try { + if (settings.autoRetry) { + const history = await readHistory(); + const retryItems = pendingRetryItems(history).slice(0, 50); + for (const item of retryItems) { + const existing = await getProgramHistory(item.name); + const urls = Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + item.platforms.includes(platform.id) ? "" : (existing.platforms?.[platform.id]?.url || ""), + ])); + const collection = await collectProgramHotness(item.name, { + urls, + platforms: item.platforms, + freshSearchPlatforms: item.platforms, + }); + annotateCollectionAnomalies(collection, existing); + await appendCollection(collection); + await saveSuccessfulRetryLinks(item.name, collection); + status.retry_count += 1; + } + } + + if (settings.autoCollect) { + const programs = await listPrograms(); + const platforms = PLATFORMS.map((platform) => platform.id); + for (const program of programs.slice(0, 100)) { + const existing = await getProgramHistory(program.name); + const urls = Object.fromEntries(PLATFORMS.map((platform) => [ + platform.id, + existing.platforms?.[platform.id]?.url || "", + ])); + const collection = await collectProgramHotness(program.name, { urls, platforms }); + annotateCollectionAnomalies(collection, existing); + await appendCollection(collection); + status.collect_count += 1; + } + } + + if (settings.autoExport) { + status.export_path = await writeDutyExport(startedAt); + } + } catch (error) { + status.error = error.message || "duty job failed"; + } + + dutyStatus = status; + dutyRunning = false; + return dutyStatus; +} + +function startDutyScheduler() { + setInterval(() => { + checkDutySchedule().catch((error) => { + dutyStatus = { + ...dutyStatus, + last_run_at: new Date().toISOString(), + error: error.message || "duty scheduler failed", + }; + }); + }, 60_000).unref?.(); +} + +async function checkDutySchedule() { + if (dutyRunning) return; + const settings = await readDutySettings(); + if (!settings.autoRetry && !settings.autoCollect && !settings.autoExport) return; + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + if (lastDutyAutoDate === today) return; + const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + if (currentTime < settings.runTime) return; + lastDutyAutoDate = today; + await runDutyJob(settings); +} + +async function writeDutyExport(startedAt) { + await mkdir(DUTY_EXPORT_DIR, { recursive: true }); + const stamp = startedAt.replace(/[:.]/g, "-"); + const exportPath = path.join(DUTY_EXPORT_DIR, `all-programs-hotness-${stamp}.csv`); + await writeFile(exportPath, `\ufeff${await allProgramsToCsv()}`, "utf8"); + await backupDutyHistory(stamp); + return exportPath; +} + +async function backupDutyHistory(stamp) { + try { + const backupDir = path.join(DATA_DIR, "backups"); + await mkdir(backupDir, { recursive: true }); + await copyFile(path.join(DATA_DIR, "history.json"), path.join(backupDir, `duty-history-${stamp}.json`)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +async function collectTemporaryQueryItems({ names, platforms, body }) { + const history = await readHistory(); + const manualUrls = sanitizeUrls(body.urls || {}); + const concurrency = Math.min(Math.max(Number(body.concurrency || 3), 1), 5); + return mapLimit(names, concurrency, async (name) => { + try { + const collection = await collectProgramHotness(name, { + urls: { ...historyProgramUrls(history, name), ...manualUrls }, + platforms, + freshSearchPlatforms: body.freshSearch ? platforms.filter((platform) => !manualUrls[platform]) : [], + delayMs: 0, + quickSearch: body.quickSearch !== false, + parallelPlatforms: true, + }); + if (body.saveLinks) await saveSuccessfulRetryLinks(name, collection); + return { name, collection }; + } catch (error) { + return { name, collection: temporaryErrorCollection(name, platforms, error) }; + } + }); +} + +function historyProgramUrls(history, name) { + const key = String(name || "").trim().toLowerCase(); + const program = Object.values(history?.programs || {}) + .find((item) => String(item.name || "").trim().toLowerCase() === key); + if (!program) return {}; + + const urls = {}; + for (const platform of PLATFORMS) { + const row = program.platforms?.[platform.id]; + const latest = latestPlatformValue(program, platform.id); + const url = row?.url || latest?.url || ""; + if (url) urls[platform.id] = url; + } + return urls; +} + +function latestPlatformValue(program, platformId) { + const values = program?.platforms?.[platformId]?.values || {}; + const runs = [...(program?.runs || [])].reverse(); + for (const run of runs) { + if (values[run]) return values[run]; + } + return null; +} + +function temporaryErrorCollection(name, platforms, error) { + const capturedAt = new Date().toISOString(); + return { + name, + captured_at: capturedAt, + results: platforms.map((platformId) => { + const platform = PLATFORMS.find((item) => item.id === platformId); + return { + platform: platformId, + platform_label: platform?.label || platformId, + metric_label: platform?.metricLabel || "", + name, + url: "", + page_title: "", + hotness_raw: "", + hotness_number: null, + unit: "", + confidence: "", + evidence: "", + status: "error", + fetched_at: capturedAt, + error: error?.message || "temporary query failed", + }; + }), + }; +} + +async function mapLimit(items, limit, worker) { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await worker(items[index], index); + } + }); + await Promise.all(workers); + return results; +} + +function trendOrder(trend) { + return { + strong_growth: 0, + rising: 1, + multi_platform: 2, + new_signal: 3, + no_data: 4, + }[trend?.verdict] ?? 9; +} + +async function serveStatic(pathname, response) { + const safePath = pathname === "/" ? "/index.html" : pathname; + const filePath = path.resolve(PUBLIC_DIR, `.${safePath}`); + if (!filePath.startsWith(PUBLIC_DIR)) { + return sendText(response, 403, "Forbidden\n", "text/plain; charset=utf-8"); + } + + try { + const content = await readFile(filePath); + response.writeHead(200, { "content-type": contentType(filePath) }); + response.end(content); + } catch (error) { + if (error.code === "ENOENT") { + return sendText(response, 404, "Not Found\n", "text/plain; charset=utf-8"); + } + throw error; + } +} + +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const content = Buffer.concat(chunks).toString("utf8"); + if (!content) return {}; + return JSON.parse(content); +} + +async function readImageUploadBody(request) { + const body = await readJsonBody(request); + const type = String(body.type || "").toLowerCase(); + const filename = String(body.filename || ""); + const data = String(body.data || ""); + if (!type.startsWith("image/")) throw new Error("请导入截图图片"); + + const match = data.match(/^data:image\/[a-z0-9.+-]+;base64,(.+)$/i); + if (!match) throw new Error("截图数据格式不正确"); + + const buffer = Buffer.from(match[1], "base64"); + return { + buffer, + extension: imageExtension(filename, type), + }; +} + +function imageExtension(filename, type) { + const ext = path.extname(filename || "").toLowerCase(); + if (ext) return ext; + return { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/tiff": ".tif", + }[type] || ".png"; +} + +function isAuthorizedRequest(request, url = null) { + if (!ACCESS_PASSWORD) return true; + const headerToken = String(request.headers["x-hotness-auth-token"] || ""); + const cookies = parseCookies(request.headers.cookie || ""); + const queryToken = url?.searchParams.get("access_token") || ""; + return [headerToken, cookies.hotness_auth, queryToken].some((token) => timingSafeEqualText(token || "", ACCESS_TOKEN)); +} + +function parseCookies(cookieHeaderValue) { + const cookies = {}; + for (const item of String(cookieHeaderValue || "").split(";")) { + const [rawKey, ...rawValue] = item.trim().split("="); + if (!rawKey) continue; + cookies[rawKey] = decodeURIComponent(rawValue.join("=") || ""); + } + return cookies; +} + +function cookieHeader(token) { + return `hotness_auth=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}`; +} + +function timingSafeEqualText(left, right) { + const leftBuffer = Buffer.from(String(left || "")); + const rightBuffer = Buffer.from(String(right || "")); + if (leftBuffer.length !== rightBuffer.length) return false; + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function sendAuthRequired(response, message = "需要输入访问密码") { + return sendJson(response, 401, { + error: message, + requires_auth: true, + }); +} + +function sendJson(response, status, payload) { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(payload)); +} + +function sendText(response, status, text, type) { + response.writeHead(status, { "content-type": type }); + response.end(text); +} + +function contentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + }[ext] || "application/octet-stream"; +} + +function sanitizeUrls(urls) { + const clean = {}; + for (const platform of PLATFORMS) { + const value = String(urls?.[platform.id] || "").trim(); + if (!value) continue; + try { + clean[platform.id] = validatePlatformUrls({ [platform.id]: value })[platform.id]; + } catch { + // Ignore invalid manual URLs during collection; the link-library save API reports them. + } + } + return clean; +} + +async function resolveProgramLinks(name) { + const [entry, history] = await Promise.all([ + getProgramLinkEntry(name), + getProgramHistory(name), + ]); + const results = {}; + + await Promise.all(PLATFORMS.map(async (platform) => { + const knownUrl = usableProgramUrl(entry.urls?.[platform.id], platform.id); + const historyUrl = usableProgramUrl(history.platforms?.[platform.id]?.url, platform.id); + let found = null; + + if (!knownUrl && !historyUrl) { + try { + found = await findProgramPage(platform.id, name); + } catch (error) { + found = { + platform: platform.id, + url: "", + status: "error", + error: error.message, + candidates: [], + }; + } + } + + const candidates = uniqueCandidateLinks([ + knownUrl ? { + url: knownUrl, + pageTitle: `${platform.label}:已保存/内置链接`, + source: entry.source || "library", + score: 1000, + } : null, + historyUrl && historyUrl !== knownUrl ? { + url: historyUrl, + pageTitle: `${platform.label}:历史成功链接`, + source: "history", + score: 900, + } : null, + ...(found?.candidates || []).map((candidate) => ({ + ...candidate, + source: "search", + })), + found?.url ? { + url: found.url, + pageTitle: found.candidates?.[0]?.pageTitle || `${platform.label}:自动搜索结果`, + source: "search", + score: found.candidates?.[0]?.score || 500, + } : null, + ]).filter((candidate) => !isSearchPageUrl(candidate.url, platform.id)); + + results[platform.id] = { + platform: platform.id, + label: platform.label, + url: candidates[0]?.url || "", + source: candidates[0]?.source || "", + status: candidates[0]?.url ? "ok" : (found?.status || "no_match"), + error: candidates[0]?.url ? "" : (found?.error || "no program page found"), + search_url: found?.searchUrl || "", + candidates, + }; + })); + + return { + name, + entry, + results, + }; +} + +function isSearchPageUrl(url, platformId) { + try { + const parsed = new URL(url); + if (platformId === "tencent") return /\/x\/search\//.test(parsed.pathname); + if (platformId === "youku") return /\/search/.test(parsed.pathname) || parsed.hostname === "so.youku.com"; + if (platformId === "iqiyi") return /\/so(?:\/|$)/.test(parsed.pathname) || parsed.hostname === "so.iqiyi.com"; + if (platformId === "mgtv") return /\/so/.test(parsed.pathname) || parsed.hostname === "so.mgtv.com"; + } catch {} + return false; +} + +function usableProgramUrl(url, platformId) { + const value = String(url || "").trim(); + if (!value) return ""; + return isSearchPageUrl(value, platformId) ? "" : value; +} + +function uniqueCandidateLinks(items) { + const seen = new Set(); + return items + .filter((item) => item?.url) + .map((item) => ({ + ...item, + url: normalizePlatformUrl(item.url, item.platform || ""), + })) + .filter((item) => { + if (seen.has(item.url)) return false; + seen.add(item.url); + return true; + }) + .sort((a, b) => (b.score || 0) - (a.score || 0)) + .slice(0, 6); +} + +function getLanUrls(port) { + const urls = []; + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries || []) { + if (entry.internal || entry.family !== "IPv4") continue; + urls.push(`http://${entry.address}:${port}/mobile.html`); + } + } + return urls; +} + +function uniqueNames(values) { + return [...new Set(values + .map((value) => String(value || "").trim()) + .filter(Boolean))]; +} + +function rankingFilters(url) { + return { + q: url.searchParams.get("q") || "", + exclude: url.searchParams.get("exclude") || "", + platform: url.searchParams.get("platform") || "", + source_type: url.searchParams.get("source_type") || "", + content_type: url.searchParams.get("content_type") || "", + status: url.searchParams.get("status") || "", + min_platforms: url.searchParams.get("min_platforms") || "", + }; +} + +function uniqueRankingSources(sources) { + const seen = new Set(); + const result = []; + for (const source of sources) { + const key = `${source.platform}:${source.url}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(source); + } + return result; +} + +function sanitizePlatforms(platforms) { + const allowed = new Set(PLATFORMS.map((platform) => platform.id)); + if (!Array.isArray(platforms) || platforms.length === 0) return [...allowed]; + return [...new Set(platforms + .map((platform) => String(platform || "").trim()) + .filter((platform) => allowed.has(platform)))]; +} diff --git a/src/sites.js b/src/sites.js new file mode 100644 index 0000000..5714fe3 --- /dev/null +++ b/src/sites.js @@ -0,0 +1,153 @@ +export const PLATFORMS = [ + { + id: "tencent", + label: "腾讯视频", + metricLabel: "热度值", + metricDescription: "腾讯视频页面公开展示的热度值,适合看平台内趋势,不建议跨平台直接比较。", + }, + { + id: "youku", + label: "优酷", + metricLabel: "热度值", + metricDescription: "优酷页面公开展示的热度值,当前优先识别节目页标题热度字段。", + }, + { + id: "iqiyi", + label: "爱奇艺", + metricLabel: "内容热度", + metricDescription: "爱奇艺页面公开展示的内容热度;同系列页面会按节目名定位相关节目条目。", + }, + { + id: "mgtv", + label: "芒果TV", + metricLabel: "播放次数", + metricDescription: "芒果TV页面公开展示的播放次数,和其他平台热度值不是同一指标。", + }, +]; + +const SITE_CONFIGS = { + tencent: { + label: "腾讯视频", + metricLabel: "热度值", + metricDescription: PLATFORMS.find((platform) => platform.id === "tencent").metricDescription, + hosts: ["v.qq.com", "video.qq.com"], + referer: "https://v.qq.com/", + }, + youku: { + label: "优酷", + metricLabel: "热度值", + metricDescription: PLATFORMS.find((platform) => platform.id === "youku").metricDescription, + hosts: ["youku.com", "v.youku.com"], + referer: "https://www.youku.com/", + }, + iqiyi: { + label: "爱奇艺", + metricLabel: "内容热度", + metricDescription: PLATFORMS.find((platform) => platform.id === "iqiyi").metricDescription, + hosts: ["iqiyi.com", "www.iqiyi.com"], + referer: "https://www.iqiyi.com/", + }, + mgtv: { + label: "芒果TV", + metricLabel: "播放次数", + metricDescription: PLATFORMS.find((platform) => platform.id === "mgtv").metricDescription, + hosts: ["mgtv.com", "www.mgtv.com"], + referer: "https://www.mgtv.com/", + }, +}; + +export function normalizePlatform(value) { + if (!value) return ""; + const text = String(value).trim().toLowerCase(); + const aliases = { + qq: "tencent", + tx: "tencent", + tengxun: "tencent", + "腾讯": "tencent", + "腾讯视频": "tencent", + "优酷": "youku", + "爱奇艺": "iqiyi", + iqy: "iqiyi", + mango: "mgtv", + hunan: "mgtv", + "芒果": "mgtv", + "芒果tv": "mgtv", + }; + return aliases[text] || text; +} + +export function detectPlatform(url, explicitPlatform = "") { + const normalized = normalizePlatform(explicitPlatform); + if (normalized && SITE_CONFIGS[normalized]) return normalized; + + let host = ""; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + return normalized || "unknown"; + } + + for (const [platform, config] of Object.entries(SITE_CONFIGS)) { + if (config.hosts.some((knownHost) => host === knownHost || host.endsWith(`.${knownHost}`))) { + return platform; + } + } + return normalized || "unknown"; +} + +export function normalizePlatformUrl(url, explicitPlatform = "") { + let parsed; + try { + parsed = new URL(String(url || "").trim()); + } catch { + return String(url || "").trim(); + } + + const platform = detectPlatform(parsed.toString(), explicitPlatform); + if (platform === "tencent" && parsed.hostname === "v.qq.com" && /^\/x\/cover\//.test(parsed.pathname) && !/\.[a-z0-9]+$/i.test(parsed.pathname)) { + parsed.pathname = `${parsed.pathname}.html`; + } + if (platform === "mgtv" && /^\/b\/(\d+)(?:\/\d+)?\.html$/.test(parsed.pathname)) { + const [, albumId] = parsed.pathname.match(/^\/b\/(\d+)/); + parsed.pathname = `/h/${albumId}.html`; + } + + parsed.hash = ""; + for (const key of [...parsed.searchParams.keys()]) { + if (/^(ptag|from|fromvsogou|query|wd|q|src|source|utm_|spm|cxid)/i.test(key)) { + parsed.searchParams.delete(key); + } + } + + return parsed.toString(); +} + +export function getSiteConfig(platform) { + return SITE_CONFIGS[normalizePlatform(platform)] || { + label: platform || "unknown", + hosts: [], + referer: "", + }; +} + +export function getMetricLabel(platform) { + return getSiteConfig(platform).metricLabel || "指标值"; +} + +export function getMetricDescription(platform) { + return getSiteConfig(platform).metricDescription || ""; +} + +export function getRequestHeaders(platform) { + const config = getSiteConfig(platform); + const headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.6", + "cache-control": "no-cache", + "pragma": "no-cache", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + }; + + if (config.referer) headers.referer = config.referer; + return headers; +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..49d2ee4 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,455 @@ +import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { textMatchesProgram } from "./identity.js"; +import { PLATFORMS } from "./sites.js"; + +const DATA_DIR = path.resolve(process.env.HOTNESS_DATA_DIR || path.join(process.cwd(), "data")); +const HISTORY_FILE = path.join(DATA_DIR, "history.json"); +const MOBILE_SYNC_FILE = path.join(DATA_DIR, "mobile-sync.json"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); +let historyWriteQueue = Promise.resolve(); +let mobileSyncWriteQueue = Promise.resolve(); + +export async function readHistory() { + try { + const content = await readFile(HISTORY_FILE, "utf8"); + return normalizeHistory(JSON.parse(content)); + } catch (error) { + if (error.code === "ENOENT") return normalizeHistory({}); + throw error; + } +} + +export async function writeHistory(history) { + historyWriteQueue = historyWriteQueue.then(() => writeHistoryNow(history)); + return historyWriteQueue; +} + +export async function appendCollection(collection) { + const history = await readHistory(); + const key = programKey(collection.name); + const program = history.programs[key] || createProgram(collection.name); + + if (!program.runs.includes(collection.captured_at)) { + program.runs.push(collection.captured_at); + } + + for (const rawResult of collection.results) { + const result = normalizeResultForStorage(rawResult, collection.name); + const platform = result.platform; + const row = program.platforms[platform] || createPlatformRow(platform); + row.url = result.clear_url ? "" : (result.url || row.url || ""); + row.platform_label = result.platform_label || row.platform_label; + row.metric_label = result.metric_label || row.metric_label; + row.metric_description = result.metric_description || row.metric_description || platformInfo(platform)?.metricDescription || ""; + row.values[collection.captured_at] = { + raw: result.hotness_raw || "", + number: result.hotness_number || "", + unit: result.unit || "", + metric_label: result.metric_label || row.metric_label || "", + status: result.status || "", + confidence: result.confidence || "", + credibility: result.credibility || null, + evidence: result.evidence || "", + page_title: result.page_title || "", + anomaly: result.anomaly || null, + error: result.error || "", + url: result.url || "", + search_url: result.search_url || "", + search_candidates: result.search_candidates || [], + }; + program.platforms[platform] = row; + } + + program.updated_at = new Date().toISOString(); + history.programs[key] = program; + await writeHistory(history); + return program; +} + +export async function getProgramHistory(programName) { + const history = await readHistory(); + return history.programs[programKey(programName)] || createProgram(programName); +} + +export async function ensureProgramHistory(programName) { + const name = String(programName || "").trim(); + if (!name) throw new Error("节目名不能为空"); + const history = await readHistory(); + const key = programKey(name); + if (!history.programs[key]) { + history.programs[key] = { + ...createProgram(name), + updated_at: new Date().toISOString(), + }; + await writeHistory(history); + } + return history.programs[key]; +} + +export async function deleteProgramRun(programName, run) { + const history = await readHistory(); + const key = programKey(programName); + const program = history.programs[key]; + if (!program) return createProgram(programName); + + program.runs = (program.runs || []).filter((item) => item !== run); + for (const row of Object.values(program.platforms || {})) { + if (row.values) delete row.values[run]; + } + program.updated_at = new Date().toISOString(); + history.programs[key] = program; + await writeHistory(history); + return normalizeHistory(history).programs[key] || createProgram(programName); +} + +export async function deleteProgramRuns(programName, runs) { + const history = await readHistory(); + const key = programKey(programName); + const program = history.programs[key]; + if (!program) return createProgram(programName); + + const deleteSet = new Set((runs || []).map((run) => String(run || "").trim()).filter(Boolean)); + program.runs = (program.runs || []).filter((item) => !deleteSet.has(item)); + for (const row of Object.values(program.platforms || {})) { + for (const run of deleteSet) { + if (row.values) delete row.values[run]; + } + } + program.updated_at = new Date().toISOString(); + history.programs[key] = program; + await writeHistory(history); + return normalizeHistory(history).programs[key] || createProgram(programName); +} + +export async function deleteProgram(programName) { + const history = await readHistory(); + const key = programKey(programName); + delete history.programs[key]; + await writeHistory(history); + return createProgram(programName); +} + +export async function deletePrograms(programNames) { + const names = [...new Set((programNames || []).map((name) => String(name || "").trim()).filter(Boolean))]; + const history = await readHistory(); + for (const name of names) { + delete history.programs[programKey(name)]; + } + await writeHistory(history); + return { names }; +} + +export async function listPrograms() { + const history = await readHistory(); + return Object.values(history.programs) + .map((program) => ({ + name: program.name, + updated_at: program.updated_at || "", + runs: program.runs.length, + })) + .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))); +} + +export async function allProgramsToCsv() { + const history = await readHistory(); + const rows = []; + rows.push(["program", "platform", "metric", "url", "run", "value", "number", "unit", "status", "credibility", "note"]); + + for (const program of Object.values(history.programs)) { + for (const platform of PLATFORMS) { + const row = program.platforms?.[platform.id] || createPlatformRow(platform.id); + for (const run of program.runs || []) { + const value = row.values?.[run]; + rows.push([ + program.name || "", + row.platform_label || platform.label, + row.metric_label || platform.metricLabel || "", + value?.url || row.url || "", + run, + value?.status === "ok" ? (value.raw || value.number || "") : "", + value?.number || "", + value?.unit || "", + value?.status || "未采集", + value?.credibility?.label || "", + csvNotes(value), + ]); + } + } + } + + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"; +} + +export function programToCsv(program) { + const rows = []; + const headers = ["platform", "metric", "url", ...program.runs, ...program.runs.map((run) => `${run}_note`)]; + rows.push(headers); + + for (const platform of PLATFORMS) { + const row = program.platforms[platform.id] || createPlatformRow(platform.id); + rows.push([ + row.platform_label || platform.label, + row.metric_label || platform.metricLabel || "", + row.url || "", + ...program.runs.map((run) => { + const value = row.values[run]; + if (!value) return ""; + if (value.status !== "ok") return value.status || ""; + return value.raw || value.number || ""; + }), + ...program.runs.map((run) => { + const value = row.values[run]; + if (!value) return ""; + const notes = [ + value.credibility?.label ? `可信度:${value.credibility.label}` : "", + value.credibility?.reason || "", + value.anomaly?.message || "", + value.page_title || "", + value.error || "", + ].filter(Boolean); + return notes.join(" | "); + }), + ]); + } + + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"; +} + +export async function listMobileSyncDrafts() { + return readMobileSyncFile(); +} + +export async function saveMobileSyncDrafts({ deviceName = "", drafts = [] } = {}) { + const mobileSync = await readMobileSyncFile(); + const accepted = []; + const now = new Date().toISOString(); + const knownKeys = new Set(mobileSync.items.map((item) => mobileSyncKey(item))); + + for (const draft of Array.isArray(drafts) ? drafts : []) { + const name = String(draft?.name || "").trim(); + if (!name) continue; + + const item = normalizeMobileSyncItem({ + ...draft, + name, + device_name: deviceName || draft.device_name || draft.deviceName || "mobile", + received_at: now, + status: "pending", + }); + const key = mobileSyncKey(item); + if (knownKeys.has(key)) continue; + knownKeys.add(key); + mobileSync.items.unshift(item); + accepted.push(item); + } + + mobileSync.updated_at = now; + await writeMobileSyncFile(mobileSync); + return { accepted, items: mobileSync.items }; +} + +function csvNotes(value) { + if (!value) return ""; + return [ + value.credibility?.reason || "", + value.anomaly?.message || "", + value.page_title || "", + value.error || "", + ].filter(Boolean).join(" | "); +} + +async function readMobileSyncFile() { + try { + const content = await readFile(MOBILE_SYNC_FILE, "utf8"); + return normalizeMobileSync(JSON.parse(content)); + } catch (error) { + if (error.code === "ENOENT") return normalizeMobileSync({}); + throw error; + } +} + +async function writeMobileSyncFile(mobileSync) { + mobileSyncWriteQueue = mobileSyncWriteQueue.then(async () => { + await mkdir(DATA_DIR, { recursive: true }); + await writeFile(MOBILE_SYNC_FILE, `${JSON.stringify(normalizeMobileSync(mobileSync), null, 2)}\n`, "utf8"); + }); + return mobileSyncWriteQueue; +} + +function normalizeMobileSync(mobileSync) { + return { + version: 1, + updated_at: mobileSync.updated_at || "", + items: (Array.isArray(mobileSync.items) ? mobileSync.items : []) + .map(normalizeMobileSyncItem) + .filter((item) => item.name), + }; +} + +function normalizeMobileSyncItem(item) { + const now = new Date().toISOString(); + return { + id: String(item?.id || `${Date.now()}-${Math.random().toString(16).slice(2)}`), + name: String(item?.name || "").trim(), + note: String(item?.note || "").trim(), + urls: sanitizeMobileSyncUrls(item?.urls || {}), + platforms: sanitizeMobileSyncPlatforms(item?.platforms || []), + device_name: String(item?.device_name || item?.deviceName || "mobile").trim() || "mobile", + created_at: String(item?.created_at || item?.createdAt || now), + received_at: String(item?.received_at || now), + status: String(item?.status || "pending"), + }; +} + +function sanitizeMobileSyncUrls(urls) { + const cleaned = {}; + for (const platform of PLATFORMS) { + cleaned[platform.id] = String(urls?.[platform.id] || "").trim(); + } + return cleaned; +} + +function sanitizeMobileSyncPlatforms(platforms) { + const allowed = new Set(PLATFORMS.map((platform) => platform.id)); + return [...new Set((Array.isArray(platforms) ? platforms : []) + .map((platform) => String(platform || "").trim()) + .filter((platform) => allowed.has(platform)))]; +} + +function mobileSyncKey(item) { + return `${item.device_name}:${item.id}`; +} + +function normalizeHistory(history) { + const normalized = { + version: 1, + programs: history.programs || {}, + }; + + for (const program of Object.values(normalized.programs)) { + program.runs = [...new Set(program.runs || [])].sort(); + program.platforms = program.platforms || {}; + for (const platform of PLATFORMS) { + program.platforms[platform.id] = { + ...createPlatformRow(platform.id), + ...(program.platforms[platform.id] || {}), + }; + if (isSearchPageUrl(program.platforms[platform.id].url, platform.id)) { + program.platforms[platform.id].url = ""; + } + } + } + + return normalized; +} + +function isSearchPageUrl(url, platformId) { + try { + const parsed = new URL(url); + if (platformId === "tencent") return /\/x\/search\//.test(parsed.pathname); + if (platformId === "youku") return /\/search/.test(parsed.pathname) || parsed.hostname === "so.youku.com"; + if (platformId === "iqiyi") return /\/so(?:\/|$)/.test(parsed.pathname) || parsed.hostname === "so.iqiyi.com"; + if (platformId === "mgtv") return /\/so/.test(parsed.pathname) || parsed.hostname === "so.mgtv.com"; + } catch {} + return false; +} + +function createProgram(name) { + const platforms = {}; + for (const platform of PLATFORMS) { + platforms[platform.id] = createPlatformRow(platform.id); + } + return { + name, + runs: [], + platforms, + updated_at: "", + }; +} + +function createPlatformRow(platformId) { + const platform = PLATFORMS.find((item) => item.id === platformId); + return { + platform: platformId, + platform_label: platform?.label || platformId, + metric_label: platform?.metricLabel || "指标值", + metric_description: platform?.metricDescription || "", + url: "", + values: {}, + }; +} + +function normalizeResultForStorage(result, programName = "") { + if (result?.status === "no_metric" && result?.url && !resultMatchesProgram(result, programName)) { + return { + ...result, + url: "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + status: "no_match", + error: result.error || "candidate page did not match requested program", + credibility: { + level: "rejected", + label: "拒绝", + reason: "候选页面标题和页面证据与当前节目不匹配,未保存链接", + }, + }; + } + + if (result?.status !== "ok" || result?.credibility?.level !== "low") return result; + return { + ...result, + url: "", + hotness_raw: "", + hotness_number: "", + unit: "", + confidence: "", + status: "no_match", + error: result.error || "low credibility result was not saved because only the search candidate matched the requested program", + credibility: { + level: "rejected", + label: "拒绝", + reason: "仅搜索候选匹配当前节目,页面标题和页面证据不足,未保存数值", + }, + }; +} + +function resultMatchesProgram(result, programName) { + return textMatchesProgram(result?.page_title, programName) + || textMatchesProgram(result?.evidence, programName); +} + +async function backupHistoryFile() { + try { + await mkdir(BACKUP_DIR, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await copyFile(HISTORY_FILE, path.join(BACKUP_DIR, `history-${stamp}.json`)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} + +async function writeHistoryNow(history) { + await mkdir(DATA_DIR, { recursive: true }); + await backupHistoryFile(); + const tempFile = `${HISTORY_FILE}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tempFile, `${JSON.stringify(normalizeHistory(history), null, 2)}\n`, "utf8"); + await rename(tempFile, HISTORY_FILE); +} + +function platformInfo(platformId) { + return PLATFORMS.find((item) => item.id === platformId); +} + +function programKey(name) { + return String(name || "").trim().toLowerCase(); +} + +function csvEscape(value) { + const text = String(value ?? ""); + if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, "\"\"")}"`; + return text; +} diff --git a/src/windows-ocr.ps1 b/src/windows-ocr.ps1 new file mode 100644 index 0000000..fec7b1e --- /dev/null +++ b/src/windows-ocr.ps1 @@ -0,0 +1,52 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ImagePath +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.Runtime.WindowsRuntime +[Windows.Storage.StorageFile,Windows.Storage,ContentType=WindowsRuntime] | Out-Null +[Windows.Storage.FileAccessMode,Windows.Storage,ContentType=WindowsRuntime] | Out-Null +[Windows.Storage.Streams.IRandomAccessStream,Windows.Storage.Streams,ContentType=WindowsRuntime] | Out-Null +[Windows.Graphics.Imaging.BitmapDecoder,Windows.Graphics.Imaging,ContentType=WindowsRuntime] | Out-Null +[Windows.Graphics.Imaging.SoftwareBitmap,Windows.Graphics.Imaging,ContentType=WindowsRuntime] | Out-Null +[Windows.Media.Ocr.OcrEngine,Windows.Media.Ocr,ContentType=WindowsRuntime] | Out-Null + +function Await-Operation { + param( + [Parameter(Mandatory = $true)] + [object]$Operation, + [Parameter(Mandatory = $true)] + [type]$ResultType + ) + + $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | + Where-Object { + $_.Name -eq "AsTask" -and + $_.IsGenericMethodDefinition -and + $_.GetParameters().Count -eq 1 + } | + Select-Object -First 1 + + $task = $method.MakeGenericMethod($ResultType).Invoke($null, @($Operation)) + return $task.GetAwaiter().GetResult() +} + +if (-not (Test-Path -LiteralPath $ImagePath)) { + throw "Image file does not exist: $ImagePath" +} + +$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages() +if ($null -eq $engine) { + throw "Windows OCR is not available for the current user language. Install OCR language support or import Excel/CSV." +} + +$file = Await-Operation ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile]) +$stream = Await-Operation ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream]) +$decoder = Await-Operation ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder]) +$bitmap = Await-Operation ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap]) +$result = Await-Operation ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult]) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +Write-Output $result.Text diff --git a/test/access-password.test.js b/test/access-password.test.js new file mode 100644 index 0000000..f15bd6b --- /dev/null +++ b/test/access-password.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const server = await readFile(new URL("../src/server.js", import.meta.url), "utf8"); +const desktopHtml = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +const desktopJs = await readFile(new URL("../public/app.js", import.meta.url), "utf8"); +const desktopCss = await readFile(new URL("../public/styles.css", import.meta.url), "utf8"); +const mobileHtml = await readFile(new URL("../public/mobile.html", import.meta.url), "utf8"); +const mobileJs = await readFile(new URL("../public/mobile.js", import.meta.url), "utf8"); +const mobileCss = await readFile(new URL("../public/mobile.css", import.meta.url), "utf8"); + +test("server supports optional shared access password authentication", () => { + assert.match(server, /HOTNESS_ACCESS_PASSWORD/); + assert.match(server, /\/api\/auth\/status/); + assert.match(server, /\/api\/auth\/login/); + assert.match(server, /isAuthorizedRequest/); + assert.match(server, /sendAuthRequired/); + assert.match(server, /x-hotness-auth-token/i); +}); + +test("desktop page has a password gate and sends auth token with API calls", () => { + assert.match(desktopHtml, /id="auth-gate"/); + assert.match(desktopHtml, /id="auth-password"/); + assert.match(desktopJs, /HOTNESS_AUTH_TOKEN_KEY/); + assert.match(desktopJs, /ensureAccessAuth/); + assert.match(desktopJs, /authHeaders/); + assert.match(desktopJs, /x-hotness-auth-token/i); + assert.match(desktopCss, /\.auth-gate/); +}); + +test("mobile page has the same password gate for cloud use", () => { + assert.match(mobileHtml, /id="auth-gate"/); + assert.match(mobileHtml, /id="auth-password"/); + assert.match(mobileJs, /HOTNESS_AUTH_TOKEN_KEY/); + assert.match(mobileJs, /ensureAccessAuth/); + assert.match(mobileJs, /authHeaders/); + assert.match(mobileJs, /x-hotness-auth-token/i); + assert.match(mobileCss, /\.auth-gate/); +}); diff --git a/test/desktop-dashboard.test.js b/test/desktop-dashboard.test.js new file mode 100644 index 0000000..00d2fa9 --- /dev/null +++ b/test/desktop-dashboard.test.js @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const html = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +const css = await readFile(new URL("../public/styles.css", import.meta.url), "utf8"); +const app = await readFile(new URL("../public/app.js", import.meta.url), "utf8"); + +test("home screen exposes a desktop workbench summary", () => { + assert.match(html, /id="desktop-dashboard"/); + assert.match(html, /id="dashboard-program-count"/); + assert.match(html, /id="dashboard-last-capture"/); + assert.match(html, /id="dashboard-pending-count"/); + assert.match(html, /href="#temporary-query-panel"/); + assert.match(css, /\.desktop-dashboard/); + assert.match(app, /renderDesktopDashboard/); +}); + +test("desktop workbench summary sits below the trend charts", () => { + assert.ok(html.indexOf('id="trend-charts"') < html.indexOf('id="desktop-dashboard"')); +}); + +test("collection progress has a visible task queue panel", () => { + assert.match(html, /id="task-queue-panel"/); + assert.match(html, /id="task-current"/); + assert.match(html, /id="task-progress-fill"/); + assert.match(html, /id="task-ok-count"/); + assert.match(html, /id="task-missing-count"/); + assert.match(css, /\.task-queue-panel/); + assert.match(app, /updateTaskQueue/); +}); + +test("desktop shell has app-style navigation and persistent status", () => { + assert.match(html, /class="app-nav"/); + assert.match(html, /href="#desktop-dashboard"/); + assert.match(html, /href="#collect-form"/); + assert.match(html, /href="#temporary-query-panel"/); + assert.match(html, /href="#program-list"/); + assert.match(html, /id="app-status-port"/); + assert.match(css, /\.app-nav/); + assert.match(css, /\.app-status-dock/); + assert.match(app, /renderAppStatusDock/); +}); + +test("desktop build is visibly identified in the app chrome", () => { + assert.match(html, /id="app-version-badge"/); + assert.match(html, /桌面开发版/); + assert.match(html, /id="app-build-label"/); + assert.match(css, /\.app-version-badge/); + assert.match(app, /APP_BUILD_LABEL/); +}); diff --git a/test/desktop-instance.test.js b/test/desktop-instance.test.js new file mode 100644 index 0000000..a14eb36 --- /dev/null +++ b/test/desktop-instance.test.js @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const server = await readFile(new URL("../src/server.js", import.meta.url), "utf8"); + +test("server exposes desktop instance identity for launcher reuse checks", () => { + assert.match(server, /HOTNESS_DESKTOP_ROOT/); + assert.match(server, /HOTNESS_DESKTOP_TOKEN/); + assert.match(server, /\/api\/desktop-instance/); + assert.match(server, /desktopRoot/); + assert.match(server, /desktopToken/); +}); diff --git a/test/duty-tool.test.js b/test/duty-tool.test.js new file mode 100644 index 0000000..baff173 --- /dev/null +++ b/test/duty-tool.test.js @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const html = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +const app = await readFile(new URL("../public/app.js", import.meta.url), "utf8"); +const css = await readFile(new URL("../public/styles.css", import.meta.url), "utf8"); +const server = await readFile(new URL("../src/server.js", import.meta.url), "utf8"); + +test("desktop exposes a semi-automatic duty panel", () => { + assert.match(html, /id="duty-panel"/); + assert.match(html, /id="duty-run-now"/); + assert.match(html, /id="duty-auto-retry"/); + assert.match(html, /id="duty-auto-collect"/); + assert.match(html, /id="duty-auto-export"/); + assert.match(css, /\.duty-panel/); +}); + +test("desktop duty panel loads and saves settings", () => { + assert.match(app, /loadDutySettings/); + assert.match(app, /saveDutySettings/); + assert.match(app, /runDutyNow/); + assert.match(app, /getJson\("\/api\/duty-settings"\)/); + assert.match(app, /postJson\("\/api\/duty-settings"/); + assert.match(app, /postJson\("\/api\/duty-run"/); +}); + +test("server exposes duty settings and manual run APIs", () => { + assert.match(server, /\/api\/duty-settings/); + assert.match(server, /\/api\/duty-status/); + assert.match(server, /\/api\/duty-run/); + assert.match(server, /readDutySettings/); + assert.match(server, /writeDutySettings/); + assert.match(server, /runDutyJob/); + assert.match(server, /startDutyScheduler/); + assert.match(server, /setInterval/); +}); diff --git a/test/history-collect-selected.test.js b/test/history-collect-selected.test.js new file mode 100644 index 0000000..fae4ec3 --- /dev/null +++ b/test/history-collect-selected.test.js @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const html = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +const app = await readFile(new URL("../public/app.js", import.meta.url), "utf8"); +const css = await readFile(new URL("../public/styles.css", import.meta.url), "utf8"); + +test("history toolbar exposes collect selected action", () => { + assert.match(html, /id="history-collect-selected"/); + assert.match(html, />采集选中 { + assert.match(app, /historyCollectSelected\s*=\s*document\.querySelector\("#history-collect-selected"\)/); + assert.match(app, /selectedHistoryPrograms\.size \? `采集选中\(\$\{selectedHistoryPrograms\.size\}\)` : "采集选中"/); + assert.match(app, /collectHistoryPrograms\(names/); +}); +test("history selection mode has a neutral entry point", () => { + assert.match(html, /id="history-bulk-button"[^>]*>批量选择<\/button>/); + assert.doesNotMatch(html, /id="history-bulk-button"[^>]*>批量删除<\/button>/); +}); +test("history bulk button toggles select all and cancel selection", () => { + assert.match(app, /selectedHistoryPrograms = new Set\(programsCache\.map\(\(program\) => program\.name\)\)/); + assert.match(app, /function clearHistorySelection\(\)/); + assert.match(app, /historyBulkButton\.textContent = historyBulkMode \? "取消选择" : "批量选择"/); + assert.match(app, /historyCancelBulk\.addEventListener\("click", \(\) => \{\s*clearHistorySelection\(\);/); + assert.match(app, /historyBulkButton\.hidden = false;/); +}); +test("history delete options appear after pressing delete selected", () => { + assert.match(app, /let historyDeleteMode = false;/); + assert.match(app, /historyDeleteMode = true;[\s\S]*renderPrograms\(programsCache\);/); + assert.match(app, /\$\{historyDeleteMode \? `