From 4e04c6f86410043e44bd1659799cf2423fb0b319 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 18:53:53 +0800 Subject: [PATCH] Initial video hotness app --- .gitignore | 10 + Microsoft.Web.WebView2.Core.dll | Bin 0 -> 603712 bytes Microsoft.Web.WebView2.WinForms.dll | Bin 0 -> 38488 bytes README.md | 76 + WebView2Loader.dll | Bin 0 -> 165968 bytes package.json | 15 + public/app.js | 2236 +++++++++++++++++ public/index.html | 277 ++ public/manifest.webmanifest | 9 + public/mobile-sw.js | 52 + public/mobile.css | 728 ++++++ public/mobile.html | 156 ++ public/mobile.js | 829 ++++++ public/rankings.css | 360 +++ public/rankings.js | 438 ++++ public/styles.css | 1671 ++++++++++++ src/anomaly.js | 72 + src/collector.js | 377 +++ src/credibility.js | 54 + src/csv.js | 95 + src/extract.js | 394 +++ src/identity.js | 92 + src/index.js | 120 + src/kidsTrend.js | 85 + src/known.js | 85 + src/linkLibrary.js | 185 ++ src/native-launcher/HotnessDisableStartup.cs | 32 + src/native-launcher/HotnessEnableStartup.cs | 38 + src/native-launcher/HotnessWebViewApp.cs | 358 +++ src/ocr.js | 83 + src/rankingDiscovery.js | 209 ++ src/rankingKids.js | 165 ++ src/rankingMetrics.js | 139 + src/rankingScoring.js | 64 + src/rankingStorage.js | 392 +++ src/retryQueue.js | 39 + src/scraper.js | 119 + src/search.js | 1141 +++++++++ src/server.js | 1095 ++++++++ src/sites.js | 153 ++ src/storage.js | 455 ++++ src/windows-ocr.ps1 | 52 + test/access-password.test.js | 40 + test/desktop-dashboard.test.js | 51 + test/desktop-instance.test.js | 13 + test/duty-tool.test.js | 37 + test/history-collect-selected.test.js | 125 + test/history-run-collapse.test.js | 34 + test/mobile-capture-system.test.js | 49 + test/mobile-offline-drafts.test.js | 29 + test/mobile-pwa-offline.test.js | 50 + test/mobile-sync.test.js | 46 + test/native-launcher.test.js | 49 + test/retry-queue.test.js | 74 + test/search-timeout.test.js | 19 + test/single-collect-speed.test.js | 12 + test/temporary-query.test.js | 71 + 云服务器部署说明(带访问密码).md | 106 + 取消节目热度采集工具开机自启动.exe | Bin 0 -> 4096 bytes 团队操作指引(从打开到手机同步,先看这个).md | 532 ++++ 安装桌面App到桌面(只需一次).vbs | 29 + 开启节目热度采集工具开机自启动.exe | Bin 0 -> 5120 bytes 生成独立启动器exe(无npm版).cmd | 25 + 节目热度采集工具-独立窗口版.exe | Bin 0 -> 18944 bytes 64 files changed, 14341 insertions(+) create mode 100644 .gitignore create mode 100644 Microsoft.Web.WebView2.Core.dll create mode 100644 Microsoft.Web.WebView2.WinForms.dll create mode 100644 README.md create mode 100644 WebView2Loader.dll create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/manifest.webmanifest create mode 100644 public/mobile-sw.js create mode 100644 public/mobile.css create mode 100644 public/mobile.html create mode 100644 public/mobile.js create mode 100644 public/rankings.css create mode 100644 public/rankings.js create mode 100644 public/styles.css create mode 100644 src/anomaly.js create mode 100644 src/collector.js create mode 100644 src/credibility.js create mode 100644 src/csv.js create mode 100644 src/extract.js create mode 100644 src/identity.js create mode 100644 src/index.js create mode 100644 src/kidsTrend.js create mode 100644 src/known.js create mode 100644 src/linkLibrary.js create mode 100644 src/native-launcher/HotnessDisableStartup.cs create mode 100644 src/native-launcher/HotnessEnableStartup.cs create mode 100644 src/native-launcher/HotnessWebViewApp.cs create mode 100644 src/ocr.js create mode 100644 src/rankingDiscovery.js create mode 100644 src/rankingKids.js create mode 100644 src/rankingMetrics.js create mode 100644 src/rankingScoring.js create mode 100644 src/rankingStorage.js create mode 100644 src/retryQueue.js create mode 100644 src/scraper.js create mode 100644 src/search.js create mode 100644 src/server.js create mode 100644 src/sites.js create mode 100644 src/storage.js create mode 100644 src/windows-ocr.ps1 create mode 100644 test/access-password.test.js create mode 100644 test/desktop-dashboard.test.js create mode 100644 test/desktop-instance.test.js create mode 100644 test/duty-tool.test.js create mode 100644 test/history-collect-selected.test.js create mode 100644 test/history-run-collapse.test.js create mode 100644 test/mobile-capture-system.test.js create mode 100644 test/mobile-offline-drafts.test.js create mode 100644 test/mobile-pwa-offline.test.js create mode 100644 test/mobile-sync.test.js create mode 100644 test/native-launcher.test.js create mode 100644 test/retry-queue.test.js create mode 100644 test/search-timeout.test.js create mode 100644 test/single-collect-speed.test.js create mode 100644 test/temporary-query.test.js create mode 100644 云服务器部署说明(带访问密码).md create mode 100644 取消节目热度采集工具开机自启动.exe create mode 100644 团队操作指引(从打开到手机同步,先看这个).md create mode 100644 安装桌面App到桌面(只需一次).vbs create mode 100644 开启节目热度采集工具开机自启动.exe create mode 100644 生成独立启动器exe(无npm版).cmd create mode 100644 节目热度采集工具-独立窗口版.exe 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 0000000000000000000000000000000000000000..4a278779bfb1d7f8d51cd76c249ff5a95c8a9b22 GIT binary patch literal 603712 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�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

n{ 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 XIUQ3bHhPD=vMwQ#qaHWAjUVz4IP9i; literal 0 HcmV?d00001 diff --git a/Microsoft.Web.WebView2.WinForms.dll b/Microsoft.Web.WebView2.WinForms.dll new file mode 100644 index 0000000000000000000000000000000000000000..21caf04f77e1baa303345c6a88aeeef0e96f3db4 GIT binary patch literal 38488 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..983ee321121a1a7c8e007b4aa46c3351d525dc0e GIT binary patch literal 165968 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 `QjE6o7ZY>Oa}bdYWD6s@9;fe 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

>>Sc-H&z*s5XO1oO`yvLW5&$Bl~CtrbN*cBxtY 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" + } +} 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 \? `