Initial video hotness app

This commit is contained in:
Codex 2026-05-14 18:53:53 +08:00
commit 4e04c6f864
64 changed files with 14341 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.hotness-server.json
.hotness-webview-server.json
server.out.log
*.log
node_modules/
dist/
release/
data/
runtime/
vendor/

Binary file not shown.

Binary file not shown.

76
README.md Normal file
View File

@ -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
```

BIN
WebView2Loader.dll Normal file

Binary file not shown.

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "video-hotness-scraper",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Desktop app for collecting public hotness values from Chinese video program pages.",
"scripts": {
"scrape": "node src/index.js",
"serve": "node src/server.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}

2236
public/app.js Normal file

File diff suppressed because it is too large Load Diff

277
public/index.html Normal file
View File

@ -0,0 +1,277 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>节目热度采集</title>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/rankings.css">
</head>
<body>
<section id="auth-gate" class="auth-gate" hidden>
<form id="auth-form" class="auth-card">
<div class="auth-title">输入访问密码</div>
<p>云端部署时需要先验证,验证后这台设备会记住登录状态。</p>
<input id="auth-password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<button id="auth-submit" type="submit">进入系统</button>
<div id="auth-message" class="auth-message" aria-live="polite"></div>
</form>
</section>
<nav class="app-nav" aria-label="桌面 App 导航">
<a class="app-nav-brand" href="#desktop-dashboard">节目热度采集</a>
<span id="app-version-badge" class="app-version-badge">桌面开发版</span>
<div class="app-nav-links">
<a href="#desktop-dashboard">工作台</a>
<a href="#collect-form">采集</a>
<a href="#temporary-query-panel">临时查询</a>
<a href="#program-list">历史库</a>
</div>
<div class="app-nav-meta">
<span>本地端口</span>
<strong id="app-status-port">--</strong>
</div>
</nav>
<main class="shell">
<header class="topbar">
<div class="brand-block">
<div class="brand-copy">
<h1>节目热度采集</h1>
<p id="subtitle">腾讯视频、优酷、爱奇艺、芒果TV</p>
</div>
<div class="top-actions">
<button id="collect-history-button" class="top-collect-all" type="button">采集全部历史节目</button>
<button id="retry-pending-button" class="top-collect-all secondary" type="button">复查无数据</button>
</div>
</div>
<form id="collect-form" class="searchbar">
<input id="program-name" name="name" type="search" placeholder="输入节目名" autocomplete="off" required>
<button id="collect-button" type="submit">采集一次</button>
<a id="export-link" class="button ghost" href="#" aria-disabled="true">导出 CSV</a>
<a id="export-all-link" class="button ghost" href="/api/export-all">导出全部</a>
<div class="url-grid" aria-label="节目页地址">
<input id="url-tencent" name="tencent" type="url" placeholder="腾讯视频 URL">
<input id="url-youku" name="youku" type="url" placeholder="优酷 URL">
<input id="url-iqiyi" name="iqiyi" type="url" placeholder="爱奇艺 URL">
<input id="url-mgtv" name="mgtv" type="url" placeholder="芒果TV URL">
</div>
<div id="link-candidates" class="link-candidates" aria-live="polite"></div>
<div class="collect-platforms" aria-label="本次采集平台">
<span>本次采集</span>
<label><input type="checkbox" value="tencent" checked>腾讯视频</label>
<label><input type="checkbox" value="youku" checked>优酷</label>
<label><input type="checkbox" value="iqiyi" checked>爱奇艺</label>
<label><input type="checkbox" value="mgtv" checked>芒果TV</label>
<button id="collect-platform-all" class="mini-button" type="button">全选</button>
</div>
<div class="library-row">
<input id="alias-input" type="text" placeholder="别名,多个用逗号分隔">
<button id="save-library-button" class="button ghost" type="button">保存链接库</button>
<span id="library-status" class="inline-status"></span>
</div>
<details class="network-help">
<summary>手机不在同一 WiFi 怎么用</summary>
<div class="help-grid">
<span>最快:让手机连接电脑开的热点,再打开手机访问地址。</span>
<span>稳定:电脑和手机都装 Tailscale用 Tailscale 分配的电脑地址访问。</span>
<span>临时外网:用内网穿透工具转发 3000 端口,只发给可信的人。</span>
</div>
</details>
</form>
</header>
<section class="statusline" aria-live="polite">
<span id="status-dot" class="dot idle"></span>
<span id="status-text">等待输入节目名</span>
</section>
<section id="task-queue-panel" class="task-queue-panel idle" aria-label="采集任务队列">
<div class="task-queue-head">
<div>
<div class="panel-title">任务队列</div>
<div id="task-current" class="task-current">暂无运行中的采集任务</div>
</div>
<div id="task-ratio" class="task-ratio">0/0</div>
</div>
<div class="task-progress-track" aria-hidden="true">
<div id="task-progress-fill" class="task-progress-fill"></div>
</div>
<div class="task-counters">
<span>有效 <strong id="task-ok-count">0</strong></span>
<span>未找到/无指标 <strong id="task-missing-count">0</strong></span>
<span>风控/错误 <strong id="task-error-count">0</strong></span>
</div>
</section>
<section id="mobile-sync-panel" class="mobile-sync-panel">
<div class="panel-head">
<div>
<div class="panel-title">手机同步待处理</div>
<div class="panel-note">手机端同步过来的节目先放这里,不会自动写入历史数据。</div>
</div>
</div>
<div id="mobile-sync-list" class="mobile-sync-list empty">暂无手机同步记录</div>
</section>
<section id="duty-panel" class="duty-panel">
<div class="panel-head">
<div>
<div class="panel-title">半自动值班</div>
<div class="panel-note">减少每天重复操作:复查无数据、采集历史、导出 CSV、备份数据。</div>
</div>
<button id="duty-run-now" class="mini-button" type="button">立即执行一次</button>
</div>
<div class="duty-grid">
<label><input id="duty-auto-retry" type="checkbox"> 每天复查无数据</label>
<label><input id="duty-auto-collect" type="checkbox"> 每天采集历史节目</label>
<label><input id="duty-auto-export" type="checkbox"> 完成后导出 CSV 并备份</label>
<label class="duty-time">执行时间 <input id="duty-run-time" type="time" value="09:30"></label>
<button id="duty-save-settings" class="mini-button" type="button">保存值班设置</button>
</div>
<div id="duty-status" class="duty-status">尚未执行值班任务</div>
</section>
<section class="workspace">
<aside class="side">
<div class="side-title">
<span>历史节目</span>
<div class="history-actions">
<button id="history-collect-selected" class="collect-history-button" type="button">采集选中</button>
<button id="history-bulk-button" class="collect-history-button" type="button">批量选择</button>
</div>
</div>
<div id="history-bulk-bar" class="history-bulk-bar" hidden>
<button id="history-delete-selected" type="button">删除选中</button>
<button id="history-cancel-bulk" type="button">取消</button>
</div>
<div id="program-list" class="program-list"></div>
</aside>
<section class="table-panel">
<div class="table-tools">
<div id="table-title" class="table-title">还没有采集结果</div>
<div class="table-actions">
<button id="run-bulk-button" class="mini-button" type="button">批量删除列</button>
<div id="run-count" class="run-count">0 次</div>
</div>
</div>
<div id="run-bulk-bar" class="run-bulk-bar" hidden>
<button id="run-delete-selected" type="button">删除选中列</button>
<button id="run-cancel-bulk" type="button">取消</button>
</div>
<div class="platform-filters" aria-label="筛选视频网站">
<button class="filter-chip active" type="button" data-platform-filter="tencent">腾讯视频</button>
<button class="filter-chip active" type="button" data-platform-filter="youku">优酷</button>
<button class="filter-chip active" type="button" data-platform-filter="iqiyi">爱奇艺</button>
<button class="filter-chip active" type="button" data-platform-filter="mgtv">芒果TV</button>
<button class="filter-chip reset" type="button" data-platform-filter="all">全部</button>
</div>
<div class="run-collapse-tools">
<span id="run-collapse-note" class="run-collapse-note"></span>
<button id="run-collapse-toggle" class="mini-button" type="button" hidden>展开旧列</button>
</div>
<div class="table-wrap">
<table id="hotness-table">
<thead></thead>
<tbody></tbody>
</table>
</div>
<section class="chart-panel">
<div class="panel-head">
<div class="panel-title">趋势图</div>
<div class="panel-note">每个平台独立刻度</div>
</div>
<div id="trend-charts" class="trend-grid"></div>
</section>
<section id="desktop-dashboard" class="desktop-dashboard" aria-label="今日工作台">
<div class="dashboard-card dashboard-card-main">
<div class="dashboard-label">历史节目</div>
<div id="dashboard-program-count" class="dashboard-value">0</div>
<div class="dashboard-note">已建立采集档案</div>
</div>
<div class="dashboard-card">
<div class="dashboard-label">最近采集</div>
<div id="dashboard-last-capture" class="dashboard-value compact">--</div>
<div class="dashboard-note">来自历史库更新时间</div>
</div>
<div class="dashboard-card">
<div class="dashboard-label">当前节目待复查</div>
<div id="dashboard-pending-count" class="dashboard-value">--</div>
<div class="dashboard-note">未匹配、无指标、风控或错误</div>
</div>
<div class="dashboard-card dashboard-actions-card">
<div class="dashboard-label">快捷入口</div>
<div class="dashboard-actions">
<a class="dashboard-action" href="#temporary-query-panel">临时查询</a>
<a class="dashboard-action" href="#program-list">历史节目</a>
</div>
</div>
</section>
<section class="compare-panel">
<div class="panel-head">
<div class="panel-title">节目对比</div>
<div class="compare-controls">
<select id="compare-platform" aria-label="对比平台">
<option value="tencent">腾讯视频</option>
<option value="youku">优酷</option>
<option value="iqiyi">爱奇艺</option>
<option value="mgtv">芒果TV</option>
</select>
<select id="compare-range" aria-label="对比日期范围">
<option value="today">当天</option>
<option value="3d">近三天</option>
<option value="7d">近七天</option>
<option value="all" selected>全部</option>
</select>
</div>
</div>
<div id="compare-list" class="compare-list"></div>
<div id="compare-chart" class="compare-chart empty">选择节目后显示对比</div>
<div id="compare-table" class="compare-table"></div>
</section>
<section id="ranking-radar" class="ranking-panel"></section>
</section>
</section>
<section id="temporary-query-panel" class="temporary-query-panel">
<div class="panel-head">
<div>
<div class="panel-title">临时查询</div>
<div class="panel-note">只查这一次,不写入历史;可单独导出 CSV</div>
</div>
<div class="temporary-actions">
<label class="file-button">
导入列表
<input id="temporary-file-input" type="file" accept=".txt,.csv,.xlsx,text/plain,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/*">
</label>
<label><input id="temporary-save-links" type="checkbox">保存成功链接</label>
<button id="temporary-query-button" class="mini-button" type="button">一键查询</button>
<button id="temporary-export-button" class="mini-button" type="button" disabled>导出临时 CSV</button>
</div>
</div>
<textarea id="temporary-query-text" rows="4" placeholder="粘贴节目列表,每行一个节目名"></textarea>
<div id="temporary-query-result" class="temporary-result empty">暂无临时查询结果</div>
</section>
</main>
<footer class="app-status-dock" aria-live="polite">
<span class="dock-label">状态</span>
<span id="app-status-text">等待操作</span>
<span class="dock-separator"></span>
<span id="app-build-label">桌面开发版</span>
<span class="dock-separator"></span>
<span>端口 <strong id="app-status-port-dock">--</strong></span>
</footer>
<dialog id="detail-dialog" class="detail-dialog">
<form method="dialog">
<div class="dialog-head">
<strong id="detail-title">采集详情</strong>
<button class="close-button" value="close" aria-label="关闭">×</button>
</div>
<div id="detail-body" class="detail-body"></div>
</form>
</dialog>
<script src="/app.js" type="module"></script>
<script src="/rankings.js" type="module"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
{
"name": "节目热度采集",
"short_name": "热度采集",
"start_url": "/mobile.html",
"display": "standalone",
"background_color": "#f5f7f8",
"theme_color": "#0f766e",
"icons": []
}

52
public/mobile-sw.js Normal file
View File

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

728
public/mobile.css Normal file
View File

@ -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;
}

156
public/mobile.html Normal file
View File

@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f766e">
<title>热度采集手机版</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/mobile.css">
</head>
<body>
<section id="auth-gate" class="auth-gate" hidden>
<form id="auth-form" class="auth-card">
<div class="auth-title">输入访问密码</div>
<p>云端使用时需要先验证,验证后这台手机会记住登录状态。</p>
<input id="auth-password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<button id="auth-submit" type="submit">进入手机版</button>
<div id="auth-message" class="auth-message" aria-live="polite"></div>
</form>
</section>
<main class="mobile-shell">
<header class="mobile-header">
<div>
<h1>节目热度采集</h1>
<p>移动录入版</p>
</div>
<a class="desktop-link" href="/">桌面版</a>
</header>
<section class="device-panel">
<label class="field">
<span>这台手机/录入人</span>
<input id="mobile-device-name" type="text" placeholder="例如:张三手机、商务部手机">
</label>
<button id="save-device-name-button" class="secondary-button" type="button">保存名称</button>
</section>
<section class="app-settings-panel">
<div class="settings-head">
<div>
<div class="section-title">手机 App 设置</div>
<p>绑定电脑或 NAS 地址后,离开局域网再回来也能快速同步。</p>
</div>
<span id="mobile-app-state" class="app-state">检测中</span>
</div>
<label class="field">
<span>电脑 / NAS 地址</span>
<input id="mobile-server-url" type="url" placeholder="例如http://192.168.18.120:3001">
</label>
<div class="setting-actions">
<button id="save-mobile-server-button" class="secondary-button" type="button">保存地址</button>
<button id="test-mobile-server-button" class="secondary-button" type="button">测试连接</button>
</div>
<div id="mobile-binding-summary" class="binding-summary">尚未绑定固定地址,默认使用当前打开页面。</div>
</section>
<form id="collect-form" class="collect-panel">
<label class="field">
<span>节目名</span>
<input id="program-name" name="name" type="search" placeholder="例如:星愿甜心 生肖奇遇记" autocomplete="off" required>
</label>
<details class="url-box">
<summary>节目页 URL可选自动找不到时填写</summary>
<div class="url-fields">
<input id="url-tencent" name="tencent" type="url" placeholder="腾讯视频 URL">
<input id="url-youku" name="youku" type="url" placeholder="优酷 URL">
<input id="url-iqiyi" name="iqiyi" type="url" placeholder="爱奇艺 URL">
<input id="url-mgtv" name="mgtv" type="url" placeholder="芒果TV URL">
</div>
</details>
<div class="collect-platforms" aria-label="本次采集平台">
<span>本次采集</span>
<label><input type="checkbox" value="tencent" checked>腾讯</label>
<label><input type="checkbox" value="youku" checked>优酷</label>
<label><input type="checkbox" value="iqiyi" checked>爱奇艺</label>
<label><input type="checkbox" value="mgtv" checked>芒果</label>
</div>
<label class="field note-field">
<span>备注</span>
<textarea id="mobile-note" rows="3" placeholder="例如:待查上线、朋友推荐、先录入链接"></textarea>
</label>
<div class="actions">
<button id="collect-button" type="submit">采集一次</button>
<button id="save-offline-button" class="secondary-button" type="button">保存待同步</button>
<a id="export-link" class="secondary" href="#" aria-disabled="true">导出 CSV</a>
</div>
</form>
<section class="notice" aria-live="polite">
<span id="status-dot" class="dot idle"></span>
<span id="status-text">等待输入节目名</span>
</section>
<section id="offline-status" class="offline-status">
<strong>离线录入</strong>
<span>首次在局域网打开后,会缓存手机版;之后可离线打开并保存待同步。</span>
</section>
<section id="install-hint" class="install-hint">
<div>
<strong>安装到手机桌面</strong>
<span id="install-status">可在手机浏览器菜单中选择“添加到主屏幕”,下次不在局域网也能直接打开录入。</span>
</div>
<button id="install-app-button" class="secondary-button" type="button" hidden>安装</button>
</section>
<section class="network">
<div class="network-title">手机访问地址</div>
<div id="network-links">正在读取局域网地址...</div>
<details class="network-help">
<summary>不在同一 WiFi 怎么办</summary>
<p>手机连电脑热点最简单;长期使用可以电脑和手机都装 Tailscale临时外网访问可以用内网穿透转发 3000 端口。</p>
</details>
</section>
<section class="offline-panel">
<div class="offline-head">
<div>
<div class="section-title">手机待同步</div>
<p>手机用流量时先存这里,回到局域网后再同步到电脑。</p>
</div>
<strong id="offline-count">0</strong>
</div>
<div id="offline-list" class="offline-list"></div>
<div class="draft-actions">
<button id="sync-offline-button" class="secondary-button" type="button">同步到电脑</button>
<button id="clear-offline-button" class="secondary-button" type="button">清空待同步</button>
</div>
</section>
<section class="batch-panel">
<div class="section-title">批量离线录入</div>
<textarea id="mobile-batch-text" rows="5" placeholder="每行一个节目名,可直接粘贴会议名单"></textarea>
<button id="save-batch-offline-button" class="secondary-button" type="button">批量保存待同步</button>
</section>
<section class="history-strip">
<div class="section-title">历史节目</div>
<div id="program-list" class="program-list"></div>
</section>
<section class="results">
<div class="result-head">
<div id="table-title" class="result-title">还没有采集结果</div>
<div id="run-count" class="run-count">0 次</div>
</div>
<div id="cards" class="cards"></div>
</section>
</main>
<script src="/mobile.js" type="module"></script>
</body>
</html>

829
public/mobile.js Normal file
View File

@ -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
? `<strong>有 ${pendingDrafts.length} 条可同步</strong><span>电脑可访问时点击“同步到电脑”,同步后会显示电脑已收到。</span>`
: online
? `<strong>离线录入已准备</strong><span>首次打开后会缓存手机版;离开局域网时仍可保存待同步。</span>`
: `<strong>当前离线</strong><span>可以继续录入并保存待同步;回到局域网后再同步到电脑。</span>`;
}
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) => `<a href="${escapeAttribute(url)}">${escapeHtml(url)}</a>`).join("<br>")
: "没有读取到局域网地址,可先用本机浏览器访问。";
} 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 = `<div class="empty">暂无历史</div>`;
return;
}
programList.innerHTML = programs.map((program) => `
<button class="program-item ${program.name === activeName ? "active" : ""}" data-name="${escapeHtml(program.name)}">
${escapeHtml(program.name)}
</button>
`).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 = `<div class="empty">暂无手机待同步记录</div>`;
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 ? `<div class="offline-meta">${escapeHtml(draft.note)}</div>` : "";
const isSynced = draft.sync_status === "synced";
const syncLabel = isSynced ? "已同步" : "待同步";
return `
<article class="offline-item ${isSynced ? "synced" : ""}">
<div class="offline-title">
<strong>${escapeHtml(draft.name)}</strong>
<span class="sync-status ${isSynced ? "synced" : "pending"}">${syncLabel}</span>
</div>
<div class="offline-meta">${formatTime(draft.created_at)} · ${urlCount} 个链接 · ${escapeHtml((draft.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台")}</div>
${note}
<div class="offline-actions">
<button type="button" data-edit-draft="${escapeAttribute(draft.id)}">编辑</button>
<button type="button" data-delete-draft="${escapeAttribute(draft.id)}">删除</button>
</div>
</article>
`;
}).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 = `<div class="empty">暂无采集结果</div>`;
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 `
<div class="mini-row">
<span>${formatTime(run)}</span>
<strong>${escapeHtml(shown || "未采集")}</strong>
</div>
`;
}).join("");
return `
<article class="platform-card">
<div class="platform-row">
<div>
<div class="platform-name">${escapeHtml(label)}</div>
<div class="metric">${escapeHtml(metric)}</div>
${row.metric_description ? `<div class="metric-help">${escapeHtml(row.metric_description)}</div>` : ""}
</div>
${url ? `<a class="open-link" href="${escapeAttribute(url)}" target="_blank" rel="noreferrer">打开</a>` : ""}
</div>
${latestHtml}
<div class="mini-history">${historyRows}</div>
</article>
`;
}
function renderLatest(value) {
if (!value) return `<div class="latest-value warn">未采集</div>`;
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 ? `<span class="anomaly-badge">异常</span>` : "";
const credibility = renderCredibilityBadge(value.credibility);
return `
<div class="latest-value">${escapeHtml(shown)}${anomaly}</div>
${credibility}
${meta ? `<div class="meta">${escapeHtml(meta)}</div>` : ""}
${value.credibility?.reason ? `<div class="meta">${escapeHtml(value.credibility.reason)}</div>` : ""}
${value.anomaly ? `<div class="meta">${escapeHtml(value.anomaly.message || "")}</div>` : ""}
`;
}
const tone = value.status === "blocked" ? "warn" : "bad";
return `
<div class="latest-value ${tone}">${escapeHtml(statusLabel(value.status))}</div>
${renderCredibilityBadge(value.credibility)}
<div class="meta">${escapeHtml(value.error || "")}</div>
`;
}
function renderCredibilityBadge(credibility) {
if (!credibility?.label) return "";
return `<span class="credibility-badge ${escapeAttribute(credibility.level || "")}">${escapeHtml(credibility.label)}</span>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}

360
public/rankings.css Normal file
View File

@ -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;
}
}

438
public/rankings.js Normal file
View File

@ -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 = `
<div class="ranking-head">
<div>
<div class="panel-title">少儿上新趋势雷达</div>
<div class="ranking-subtitle">一键发现少儿新节目采集四平台数值并判断增长趋势</div>
</div>
<div class="ranking-actions">
<button class="button ghost primary-action" type="button" data-action="run-trend">${state.loading ? "采集中" : "一键采集上新趋势"}</button>
${viewButton("new", "候选")}
${viewButton("platform", "全部")}
${viewButton("ignored", "已忽略")}
<a class="button ghost" href="/api/rankings/export?category=kids&view=${state.view}">导出</a>
</div>
</div>
<div class="kids-discovery">
${trendSummary()}
<form class="kids-filter-form" data-role="filters">
<input name="q" type="search" value="${escapeAttr(state.filters.q)}" placeholder="关键词,可留空">
<input name="exclude" type="text" value="${escapeAttr(state.filters.exclude)}" placeholder="排除词,用空格分隔">
<select name="content_type">
<option value="">全部类型</option>
${options(TYPE_LABELS, state.filters.content_type)}
</select>
<select name="platform">
<option value="">全部平台</option>
${options(PLATFORM_LABELS, state.filters.platform)}
</select>
<select name="status">
<option value="">全部状态</option>
<option value="untracked" ${state.filters.status === "untracked" ? "selected" : ""}>未追踪</option>
<option value="tracked" ${state.filters.status === "tracked" ? "selected" : ""}>已追踪</option>
<option value="uncollected" ${state.filters.status === "uncollected" ? "selected" : ""}>未采集</option>
<option value="collected" ${state.filters.status === "collected" ? "selected" : ""}>已采集</option>
</select>
<select name="min_platforms">
<option value="">不限平台数</option>
<option value="2" ${state.filters.min_platforms === "2" ? "selected" : ""}>至少2个平台</option>
<option value="3" ${state.filters.min_platforms === "3" ? "selected" : ""}>至少3个平台</option>
</select>
<button type="submit">筛选</button>
</form>
<div class="kids-summary">
<span title="${escapeAttr(state.message)}">${state.message || `当前 ${state.programs.length} 个候选`}</span>
<span title="${escapeAttr(sourceSummary())}">内置来源 ${state.defaults.length || 0} </span>
<span>趋势需要至少两次成功采集才会更准确</span>
</div>
${state.trendResults.length ? trendTable() : programTable()}
<details class="ranking-advanced">
<summary>高级手动补充来源 URL</summary>
${sourceForm()}
</details>
</div>
`;
bindEvents();
}
function trendSummary() {
if (!state.trendResults.length) {
return `
<div class="trend-summary empty">
<strong>还没有趋势结论</strong>
<span>点击一键采集上新趋势系统会自动找少儿新节目采集四平台数值并给出建议</span>
</div>
`;
}
const counts = countBy(state.trendResults.map((item) => item.trend?.verdict || "no_data"));
return `
<div class="trend-summary">
${summaryCard("强增长", counts.strong_growth || 0)}
${summaryCard("在增长", counts.rising || 0)}
${summaryCard("新有数值", counts.new_signal || 0)}
${summaryCard("暂无数值", counts.no_data || 0)}
</div>
`;
}
function summaryCard(label, value) {
return `<div class="trend-card"><strong>${value}</strong><span>${label}</span></div>`;
}
function viewButton(id, label) {
return `<button class="ranking-chip ${state.view === id ? "active" : ""}" type="button" data-view="${id}">${label}</button>`;
}
function trendTable() {
return `
<div class="ranking-table-wrap">
<table class="ranking-table kids-table trend-table">
<thead>
<tr>
<th>节目</th>
<th>判断</th>
<th>腾讯</th>
<th>优酷</th>
<th>爱奇艺</th>
<th>芒果</th>
<th>增长</th>
<th>建议</th>
<th>操作</th>
</tr>
</thead>
<tbody>${state.trendResults.map(trendRow).join("")}</tbody>
</table>
</div>
`;
}
function trendRow(item) {
const program = item.program || {};
const trend = item.trend || {};
const url = program.urls?.[0] || "";
const platform = program.platforms?.[0] || "";
return `
<tr>
<td><strong title="${escapeAttr(program.display_name)}">${escapeHtml(program.display_name)}</strong>${releaseDateNote(program)}</td>
<td>${trendBadge(trend)}</td>
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
<td title="${escapeAttr(growthTitle(trend))}">${growthText(trend)}</td>
<td>${escapeHtml(trend.recommendation || "")}</td>
<td class="ranking-row-actions">
${url ? `<a class="mini-button" href="${escapeAttr(url)}" target="_blank" rel="noreferrer">开</a>` : ""}
<button class="mini-button" type="button" data-track-program="${escapeAttr(program.display_name)}" data-url="${escapeAttr(url)}" data-platform="${escapeAttr(platform)}">追踪</button>
</td>
</tr>
`;
}
function programTable() {
if (state.programs.length === 0) {
return `<div class="ranking-empty">还没有筛出节目。可以直接点“一键采集上新趋势”。</div>`;
}
return `
<div class="ranking-table-wrap">
<table class="ranking-table kids-table">
<thead>
<tr>
<th>节目</th>
<th>类型</th>
<th>腾讯</th>
<th>优酷</th>
<th>爱奇艺</th>
<th>芒果</th>
<th>来源</th>
<th>操作</th>
</tr>
</thead>
<tbody>${state.programs.map(programRow).join("")}</tbody>
</table>
</div>
`;
}
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 `
<tr>
<td><strong title="${escapeAttr(program.display_name)}">${escapeHtml(program.display_name)}</strong>${releaseDateNote(program)}</td>
<td>${escapeHtml(TYPE_LABELS[program.content_type] || "其他")}</td>
${METRIC_PLATFORMS.map((id) => metricCell(program, id)).join("")}
<td title="${escapeAttr(program.first_seen_source || sources)}">${escapeHtml(sources)}</td>
<td class="ranking-row-actions">
${url ? `<a class="mini-button" href="${escapeAttr(url)}" target="_blank" rel="noreferrer">开</a>` : ""}
${program.ignored
? `<button class="mini-button" type="button" data-restore-program="${escapeAttr(program.display_name)}">恢复</button>`
: `<button class="mini-button" type="button" data-track-program="${escapeAttr(program.display_name)}" data-url="${escapeAttr(url)}" data-platform="${escapeAttr(platform)}">追踪</button>
<button class="mini-button" type="button" data-collect-program="${escapeAttr(program.display_name)}">采集</button>
<button class="mini-button warn" type="button" data-ignore-program="${escapeAttr(program.display_name)}">忽略</button>`}
</td>
</tr>
`;
}
function releaseDateNote(program) {
const value = program.release_date || "";
const text = value ? formatReleaseDate(value) : "未知";
const title = value ? `上线时间:${text}` : "暂未从平台页面识别到上线时间";
return `<small class="release-date-note ${value ? "" : "missing"}" title="${escapeAttr(title)}">上线:${escapeHtml(text)}</small>`;
}
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 `<td class="${ok ? "metric-ok" : "metric-missing"}" title="${escapeAttr(title)}">${escapeHtml(text)}</td>`;
}
function trendBadge(trend) {
return `<span class="trend-badge ${escapeAttr(trend.verdict || "no_data")}">${escapeHtml(trend.label || "暂无数值")}</span>`;
}
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 `
<form class="ranking-source-form" data-role="source-form">
<select name="platform" aria-label="平台">${options(PLATFORM_LABELS)}</select>
<select name="source_type" aria-label="来源类型">${options(SOURCE_LABELS, "channel")}</select>
<input name="label" type="text" placeholder="来源名,例如 腾讯少儿新片">
<input name="url" type="url" placeholder="频道/榜单 URL">
<label class="ranking-check"><input name="enabled" type="checkbox" checked>启用</label>
<button type="submit">保存补充来源</button>
</form>
`;
}
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]) => `<option value="${value}" ${value === selected ? "selected" : ""}>${label}</option>`).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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, "&#39;");
}

1671
public/styles.css Normal file

File diff suppressed because it is too large Load Diff

72
src/anomaly.js Normal file
View File

@ -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},请核对页面和证据`,
};
}

377
src/collector.js Normal file
View File

@ -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);
}

54
src/credibility.js Normal file
View File

@ -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: "页面标题和证据均未匹配当前节目",
};
}

95
src/csv.js Normal file
View File

@ -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;
}

394
src/extract.js Normal file
View File

@ -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(/<li\b[\s\S]*?<\/li>/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(/<script\b[^>]*>/gi, " <script> ")
.replace(/<\/script>/gi, " </script> ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function decodeHtmlEntities(value) {
return String(value)
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, "\"")
.replace(/&#34;/g, "\"")
.replace(/&#x22;/gi, "\"")
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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, "");
}

92
src/identity.js Normal file
View File

@ -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;
}

120
src/index.js Normal file
View File

@ -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 <url> [--platform tencent|youku|iqiyi|mgtv]
node src/index.js --input programs.csv [--out hotness.csv]
Options:
--url <url> 抓取单个节目页面
--input <csv> CSV 批量读取字段为 platform,name,url
--platform <platform> 手动指定平台
--name <name> URL 模式下的节目名
--out <file> 写入输出文件默认打印到终端
--format csv|json 输出格式默认 csv
--delay <ms> 每条之间的等待时间默认 2000
--all JSON 输出时包含所有候选热度
--debug-html <file> 保存最后一次请求到的 HTML便于调规则
--help 显示帮助
`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

85
src/kidsTrend.js Normal file
View File

@ -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,
};
}

85
src/known.js Normal file
View File

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

185
src/linkLibrary.js Normal file
View File

@ -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))];
}

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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<bool> 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
{
}
}
}
}

83
src/ocr.js Normal file
View File

@ -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(" ");
}

209
src/rankingDiscovery.js Normal file
View File

@ -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 = /<a\b([^>]*)>([\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(/&nbsp;/g, " ")
.replace(/&quot;/g, "\"")
.replace(/&#34;/g, "\"")
.replace(/&#x22;/gi, "\"")
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}

165
src/rankingKids.js Normal file
View File

@ -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);
}

139
src/rankingMetrics.js Normal file
View File

@ -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)))];
}

64
src/rankingScoring.js Normal file
View File

@ -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,
};
}

392
src/rankingStorage.js Normal file
View File

@ -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;
}

39
src/retryQueue.js Normal file
View File

@ -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;
}

119
src/scraper.js Normal file
View File

@ -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(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || "")
.replace(/\s+/g, " ")
.trim();
}
function decodeHtmlEntities(value) {
return String(value)
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, "\"")
.replace(/&#34;/g, "\"")
.replace(/&#x22;/gi, "\"")
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}

1141
src/search.js Normal file

File diff suppressed because it is too large Load Diff

1095
src/server.js Normal file

File diff suppressed because it is too large Load Diff

153
src/sites.js Normal file
View File

@ -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;
}

455
src/storage.js Normal file
View File

@ -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;
}

52
src/windows-ocr.ps1 Normal file
View File

@ -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

View File

@ -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/);
});

View File

@ -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/);
});

View File

@ -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/);
});

37
test/duty-tool.test.js Normal file
View File

@ -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/);
});

View File

@ -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, />采集选中</);
});
test("history collect selected action is wired to selection state", () => {
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 \? `<button class="delete-program"/);
assert.match(app, /historyBulkMode && !historyDeleteMode \? "bulk" : ""/);
assert.match(app, /historyDeleteSelected\.textContent = historyDeleteMode/);
});
test("history rows always render selection checkboxes", () => {
assert.match(app, /<label class="program-select">/);
assert.doesNotMatch(app, /\$\{historyBulkMode \? `<label class="program-select">/);
});
test("history sidebar is wide enough for program names", () => {
assert.match(css, /\.workspace \{[\s\S]*grid-template-columns: 280px minmax\(0, 1fr\);/);
assert.match(css, /\.program-item-row \{[\s\S]*grid-template-columns: 24px minmax\(130px, 1fr\) 44px;/);
assert.match(css, /\.program-item-row\.bulk \{[\s\S]*grid-template-columns: 24px minmax\(170px, 1fr\);/);
});
test("top collection name input stays compact", () => {
assert.match(css, /\.searchbar \{[\s\S]*grid-template-columns: minmax\(220px, 360px\) 108px 108px 108px minmax\(0, 1fr\);/);
});
test("collect all history action is prominent in the title area", () => {
assert.match(html, /<div class="brand-block">[\s\S]*<button id="collect-history-button" class="top-collect-all" type="button">采集全部历史节目<\/button>[\s\S]*<form id="collect-form"/);
assert.doesNotMatch(html, /id="collect-button"[^>]*>采集一次<\/button>\s*<button id="collect-history-button"/);
assert.match(css, /\.topbar \{[\s\S]*grid-template-columns: minmax\(360px, 480px\) minmax\(320px, 1fr\);/);
assert.match(css, /h1 \{[\s\S]*font-size: 30px;/);
assert.match(css, /#subtitle \{[\s\S]*font-size: 16px;/);
assert.match(css, /\.top-collect-all \{[\s\S]*width: 100%;[\s\S]*height: 54px;[\s\S]*font-size: 16px;/);
assert.doesNotMatch(html, /<div class="history-actions">[\s\S]*id="collect-history-button"/);
});
test("history toolbar exposes retry pending platforms action", () => {
assert.match(html, /id="retry-pending-button"[^>]*>复查无数据<\/button>/);
assert.match(app, /retryPendingButton\s*=\s*document\.querySelector\("#retry-pending-button"\)/);
assert.match(app, /postJson\("\/api\/retry-pending"/);
assert.match(app, /retryPendingButton\.disabled = isBusy;/);
});
test("compare chart renders trend lines instead of latest-value bars", () => {
assert.match(app, /renderCompareLineChart\(/);
assert.match(app, /function comparePlatformSeries\(/);
assert.doesNotMatch(app, /renderCompareBars\(rows\)/);
});
test("compare line chart is very compact and limits point labels", () => {
assert.match(app, /const height = 188;/);
assert.match(app, /buildCompareLabelIndexes\(item\.points, 13\)/);
assert.match(app, /function buildCompareLabelIndexes\(points, maxLabels = 13\)/);
assert.match(app, /class="compare-point-value"/);
assert.match(app, /formatCompactNumber\(point\.number\)/);
assert.match(html, /id="compare-chart" class="compare-chart empty"/);
});
test("compare line chart magnifies small value differences", () => {
assert.match(app, /function buildCompareValueDomain\(/);
assert.match(app, /const \{ minValue, maxValue \} = buildCompareValueDomain\(allPoints\);/);
assert.match(app, /\(value - minValue\) \/ \(maxValue - minValue\)/);
assert.doesNotMatch(app, /value \/ maxValue/);
});
test("compare line chart shows multiple time ticks", () => {
assert.match(app, /function buildCompareTimeTicks\(/);
assert.match(app, /class="compare-time-tick"/);
assert.match(app, /class="compare-time-label"/);
assert.match(app, /buildCompareTimeTicks\(times, 10\)/);
assert.match(app, /minimumGap = 92/);
assert.match(app, /formatCompareTimeLabel\(time, timeLabelsUseTimeOnly\)/);
assert.match(app, /function compareTimesOnSameDay\(times\)/);
assert.match(app, /function formatCompareTimeLabel\(value, timeOnly = false\)/);
assert.match(app, /<tspan x="\$\{xx\}">\$\{escapeHtml\(label\.primary\)\}<\/tspan>/);
assert.doesNotMatch(app, /const startLabel = formatShortDate\(minTime\);/);
assert.doesNotMatch(app, /const endLabel = formatShortDate\(maxTime\);/);
});
test("compare chart can filter by date range", () => {
assert.match(html, /id="compare-range"[\s\S]*value="today"[\s\S]*当天/);
assert.match(html, /value="3d"[\s\S]*近三天/);
assert.match(html, /value="7d"[\s\S]*近七天/);
assert.match(html, /value="all" selected[\s\S]*全部/);
assert.match(app, /const compareRange = document\.querySelector\("#compare-range"\)/);
assert.match(app, /compareRange\.addEventListener\("change"/);
assert.match(app, /filterCompareSeriesByRange\(sourceSeries, range\)/);
assert.match(app, /function compareRangeCutoff\(latestTime, range\)/);
assert.match(css, /\.compare-controls \{[\s\S]*grid-template-columns: 180px 108px;/);
});
test("compare chart omits duplicated latest-value rows", () => {
assert.match(app, /compareTable\.innerHTML = "";/);
assert.doesNotMatch(app, /compareTable\.innerHTML = rows\.map/);
});
test("manual batch collection panel is removed", () => {
assert.doesNotMatch(html, /id="batch-form"/);
assert.doesNotMatch(html, /批量采集/);
assert.doesNotMatch(app, /batchForm\.addEventListener/);
});
test("compare line chart css keeps the visual short", () => {
assert.match(css, /\.compare-line-chart \{\s*min-height: 204px;/);
assert.match(css, /\.compare-line-svg \{[\s\S]*min-height: 188px;/);
assert.match(css, /\.compare-time-label \{[\s\S]*font-size: 6px;/);
assert.match(css, /\.compare-point-value \{\s*font-size: 5px;/);
assert.match(css, /\.compare-legend \{[\s\S]*font-size: 16px;/);
assert.match(css, /\.compare-legend i \{[\s\S]*width: 16px;[\s\S]*height: 16px;/);
});

View File

@ -0,0 +1,34 @@
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 table exposes a control for showing or hiding old run columns", () => {
assert.match(html, /id="run-collapse-toggle"/);
assert.match(html, /id="run-collapse-note"/);
assert.match(app, /runCollapseToggle\s*=\s*document\.querySelector\("#run-collapse-toggle"\)/);
assert.match(app, /runCollapseNote\s*=\s*document\.querySelector\("#run-collapse-note"\)/);
});
test("old run controls are near the platform filters instead of the table corner", () => {
assert.ok(html.indexOf('class="platform-filters"') < html.indexOf('class="run-collapse-tools"'));
assert.ok(html.indexOf('class="run-collapse-tools"') < html.indexOf('class="table-wrap"'));
});
test("history table only renders recent runs until the user expands old columns", () => {
assert.match(app, /VISIBLE_RECENT_RUNS\s*=\s*12/);
assert.match(app, /showAllRuns/);
assert.match(app, /visibleRunsForTable\(runs\)/);
assert.match(app, /hiddenRunCount\(runs\)/);
assert.match(app, /run-collapse-cell/);
});
test("old-column collapse controls have compact table styling", () => {
assert.match(css, /\.run-collapse-tools/);
assert.match(css, /\.run-collapse-cell/);
assert.match(css, /\.run-collapse-note/);
assert.match(css, /#run-collapse-toggle/);
});

View File

@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const html = await readFile(new URL("../public/mobile.html", import.meta.url), "utf8");
const js = await readFile(new URL("../public/mobile.js", import.meta.url), "utf8");
const css = await readFile(new URL("../public/mobile.css", import.meta.url), "utf8");
test("mobile capture page lets each person name their device", () => {
assert.match(html, /id="mobile-device-name"/);
assert.match(js, /saveMobileDeviceName/);
assert.match(js, /mobileDeviceNameInput/);
});
test("mobile capture page supports batch offline entry", () => {
assert.match(html, /id="mobile-batch-text"/);
assert.match(html, /id="save-batch-offline-button"/);
assert.match(js, /saveBatchOfflineDrafts/);
assert.match(js, /parseMobileBatchNames/);
});
test("mobile drafts can be edited and deleted one by one", () => {
assert.match(js, /editOfflineDraft/);
assert.match(js, /deleteOfflineDraft/);
assert.match(js, /data-edit-draft/);
assert.match(js, /data-delete-draft/);
assert.match(css, /\.offline-actions/);
});
test("mobile page tells users when there are pending records to sync", () => {
assert.match(js, /pendingDrafts\.length/);
assert.match(js, /电脑已收到/);
assert.match(js, /有 \$\{pendingDrafts\.length\} 条可同步/);
});
test("mobile app exposes server binding settings for app-like use", () => {
assert.match(html, /id="mobile-server-url"/);
assert.match(html, /id="save-mobile-server-button"/);
assert.match(html, /id="test-mobile-server-button"/);
assert.match(html, /id="mobile-binding-summary"/);
assert.match(html, /id="mobile-app-state"/);
assert.match(js, /MOBILE_SERVER_KEY/);
assert.match(js, /mobileServerBaseUrl/);
assert.match(js, /saveMobileServerUrl/);
assert.match(js, /testMobileServerConnection/);
assert.match(js, /apiUrl\(url\)/);
assert.match(css, /\.app-settings-panel/);
assert.match(css, /\.binding-summary/);
});

View File

@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const html = await readFile(new URL("../public/mobile.html", import.meta.url), "utf8");
const css = await readFile(new URL("../public/mobile.css", import.meta.url), "utf8");
const js = await readFile(new URL("../public/mobile.js", import.meta.url), "utf8");
test("mobile page exposes offline draft fields and list", () => {
assert.match(html, /id="mobile-note"/);
assert.match(html, /id="save-offline-button"/);
assert.match(html, /id="offline-count"/);
assert.match(html, /id="offline-list"/);
assert.match(html, /id="clear-offline-button"/);
});
test("mobile app stores offline drafts locally", () => {
assert.match(js, /MOBILE_DRAFTS_KEY/);
assert.match(js, /localStorage/);
assert.match(js, /saveOfflineDraft/);
assert.match(js, /renderOfflineDrafts/);
assert.match(js, /readOfflineDrafts/);
});
test("mobile offline drafts have distinct compact styling", () => {
assert.match(css, /\.offline-panel/);
assert.match(css, /\.offline-item/);
assert.match(css, /\.draft-actions/);
});

View File

@ -0,0 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const html = await readFile(new URL("../public/mobile.html", import.meta.url), "utf8");
const js = await readFile(new URL("../public/mobile.js", import.meta.url), "utf8");
const css = await readFile(new URL("../public/mobile.css", import.meta.url), "utf8");
const manifest = await readFile(new URL("../public/manifest.webmanifest", import.meta.url), "utf8");
const sw = await readFile(new URL("../public/mobile-sw.js", import.meta.url), "utf8");
test("mobile page exposes offline availability status", () => {
assert.match(html, /id="offline-status"/);
assert.match(html, /id="install-hint"/);
assert.match(html, /id="install-status"/);
assert.match(html, /id="install-app-button"/);
assert.match(css, /\.offline-status/);
assert.match(css, /\.install-hint/);
});
test("mobile app registers its service worker", () => {
assert.match(js, /serviceWorker/);
assert.match(js, /mobile-sw\.js/);
assert.match(js, /updateOfflineStatus/);
});
test("mobile app can prompt installation when the browser supports PWA install", () => {
assert.match(js, /beforeinstallprompt/);
assert.match(js, /deferredInstallPrompt/);
assert.match(js, /installMobileApp/);
assert.match(js, /updateInstallPrompt/);
assert.match(js, /appinstalled/);
assert.match(js, /display-mode: standalone/);
assert.match(css, /\.install-hint\.install-ready/);
});
test("mobile service worker caches app shell for offline use", () => {
assert.match(sw, /CACHE_NAME/);
assert.match(sw, /\/mobile\.html/);
assert.match(sw, /\/mobile\.css/);
assert.match(sw, /\/mobile\.js/);
assert.match(sw, /\/manifest\.webmanifest/);
assert.match(sw, /caches\.open/);
assert.match(sw, /fetch/);
});
test("manifest starts at the mobile page in standalone display", () => {
const parsed = JSON.parse(manifest);
assert.equal(parsed.start_url, "/mobile.html");
assert.equal(parsed.display, "standalone");
});

46
test/mobile-sync.test.js Normal file
View File

@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const html = await readFile(new URL("../public/mobile.html", import.meta.url), "utf8");
const js = await readFile(new URL("../public/mobile.js", import.meta.url), "utf8");
const css = await readFile(new URL("../public/mobile.css", 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 server = await readFile(new URL("../src/server.js", import.meta.url), "utf8");
const storage = await readFile(new URL("../src/storage.js", import.meta.url), "utf8");
test("mobile page exposes a sync-to-desktop action", () => {
assert.match(html, /id="sync-offline-button"/);
assert.match(js, /syncOfflineDrafts/);
assert.match(js, /postJson\("\/api\/mobile-sync"/);
assert.match(js, /MOBILE_DEVICE_KEY/);
});
test("mobile drafts record synced state after upload", () => {
assert.match(js, /sync_status/);
assert.match(js, /synced_at/);
assert.match(js, /acceptedIds/);
assert.match(css, /\.sync-status/);
});
test("server stores mobile sync drafts outside history", () => {
assert.match(server, /\/api\/mobile-sync/);
assert.match(server, /saveMobileSyncDrafts/);
assert.match(server, /listMobileSyncDrafts/);
assert.match(storage, /MOBILE_SYNC_FILE/);
assert.match(storage, /mobile-sync\.json/);
assert.match(storage, /export async function saveMobileSyncDrafts/);
assert.match(storage, /export async function listMobileSyncDrafts/);
});
test("desktop page shows mobile sync drafts as a pending queue", () => {
assert.match(desktopHtml, /id="mobile-sync-panel"/);
assert.match(desktopHtml, /id="mobile-sync-list"/);
assert.match(desktopJs, /mobileSyncList\s*=\s*document\.querySelector\("#mobile-sync-list"\)/);
assert.match(desktopJs, /loadMobileSyncDrafts/);
assert.match(desktopJs, /getJson\("\/api\/mobile-sync"\)/);
assert.match(desktopCss, /\.mobile-sync-panel/);
assert.match(desktopCss, /\.mobile-sync-item/);
});

View File

@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const enableStartup = await readFile(new URL("../src/native-launcher/HotnessEnableStartup.cs", import.meta.url), "utf8");
const disableStartup = await readFile(new URL("../src/native-launcher/HotnessDisableStartup.cs", import.meta.url), "utf8");
const webviewApp = await readFile(new URL("../src/native-launcher/HotnessWebViewApp.cs", import.meta.url), "utf8");
const buildCmd = await readFile(new URL("../生成独立启动器exe无npm版.cmd", import.meta.url), "utf8");
test("native build script creates only the independent window app helpers without npm", () => {
assert.match(buildCmd, /csc\.exe/);
assert.match(buildCmd, /开启节目热度采集工具开机自启动\.exe/);
assert.match(buildCmd, /取消节目热度采集工具开机自启动\.exe/);
assert.match(buildCmd, /节目热度采集工具-独立窗口版\.exe/);
assert.match(buildCmd, /HotnessEnableStartup\.cs/);
assert.match(buildCmd, /HotnessDisableStartup\.cs/);
assert.match(buildCmd, /HotnessWebViewApp\.cs/);
});
test("native startup helpers add and remove a Windows startup command", () => {
assert.match(enableStartup, /SpecialFolder\.Startup/);
assert.match(enableStartup, /节目热度采集工具-开机启动\.cmd/);
assert.match(enableStartup, /start \\"\\"/);
assert.match(enableStartup, /节目热度采集工具-独立窗口版\.exe/);
assert.match(disableStartup, /SpecialFolder\.Startup/);
assert.match(disableStartup, /File\.Delete\(startupFile\)/);
});
test("native WebView2 app embeds the local tool in an independent window", () => {
assert.match(webviewApp, /using Microsoft\.Web\.WebView2\.WinForms/);
assert.match(webviewApp, /new WebView2/);
assert.match(webviewApp, /CoreWebView2Environment\.CreateAsync/);
assert.match(webviewApp, /webView\.Source = new Uri\(appUrl\)/);
assert.match(webviewApp, /AppMutexName/);
assert.match(webviewApp, /new Mutex\(true, AppMutexName, out createdNew\)/);
assert.match(webviewApp, /已经在运行/);
assert.match(webviewApp, /CleanupPreviousWebViewServer\(\)/);
assert.match(webviewApp, /Process\.GetProcessById\(pid\)/);
assert.match(webviewApp, /previous\.Kill\(\)/);
assert.match(webviewApp, /ReadJsonInt\(text, "pid"\)/);
assert.match(webviewApp, /ProcessStartInfo/);
assert.match(webviewApp, /runtime", "node\.exe"/);
assert.match(webviewApp, /src", "server\.js"/);
assert.match(webviewApp, /json\.Contains\(token\)/);
assert.match(webviewApp, /Text = "节目热度采集工具 - " \+ appUrl/);
assert.match(webviewApp, /statusLabel\.Text = "已连接:"/);
assert.match(webviewApp, /NotifyIcon/);
assert.match(webviewApp, /退出后台/);
});

74
test/retry-queue.test.js Normal file
View File

@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { retryableStatuses, pendingRetryItems } from "../src/retryQueue.js";
test("retry queue includes platforms whose latest result is not final", () => {
const history = {
programs: {
"百变职喵": {
name: "百变职喵",
runs: ["2026-05-12T00:00:00.000Z"],
platforms: {
tencent: {
values: {
"2026-05-12T00:00:00.000Z": { status: "ok", number: 123 },
},
},
iqiyi: {
values: {
"2026-05-12T00:00:00.000Z": { status: "no_match", error: "not online yet" },
},
},
mgtv: {
values: {
"2026-05-12T00:00:00.000Z": { status: "no_metric", url: "https://www.mgtv.com/h/1.html" },
},
},
},
},
},
};
assert.deepEqual(pendingRetryItems(history), [
{
name: "百变职喵",
platforms: ["iqiyi", "mgtv"],
reason: "爱奇艺:no_match芒果TV:no_metric",
},
]);
});
test("retry queue treats blocked and error as retryable but skips healthy latest values", () => {
assert.deepEqual([...retryableStatuses], ["no_match", "no_metric", "blocked", "error"]);
const history = {
programs: {
"星愿甜心": {
name: "星愿甜心",
runs: ["old", "new"],
platforms: {
youku: {
values: {
old: { status: "error" },
new: { status: "ok", number: 500 },
},
},
iqiyi: {
values: {
old: { status: "ok", number: 100 },
new: { status: "blocked", error: "captcha" },
},
},
},
},
},
};
assert.deepEqual(pendingRetryItems(history), [
{
name: "星愿甜心",
platforms: ["iqiyi"],
reason: "爱奇艺:blocked",
},
]);
});

View File

@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const search = await readFile(new URL("../src/search.js", import.meta.url), "utf8");
test("primary platform search page has a timeout", () => {
const block = search.match(/if \(!config\.preferFallback\) \{[\s\S]*?html = await response\.text\(\);/)?.[0] || "";
assert.match(block, /signal: fetchSignal\(options\.signal, SEARCH_TIMEOUT_MS\)/);
});
test("search module exposes a bounded quick search timeout", () => {
assert.match(search, /const SEARCH_TIMEOUT_MS = 6_000/);
assert.match(search, /const QUICK_SEARCH_TIMEOUT_MS = 6_000/);
assert.match(search, /export async function findProgramPageQuick/);
assert.match(search, /controller\.abort\(\)/);
assert.match(search, /findProgramPage\(platform, keyword, \{ signal: controller\.signal \}\)/);
assert.match(search, /signal: fetchSignal\(options\.signal/);
});

View File

@ -0,0 +1,12 @@
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("single program collection runs selected platforms in parallel with quick search", () => {
assert.match(server, /url\.pathname === "\/api\/collect"/);
assert.match(server, /delayMs: 0/);
assert.match(server, /quickSearch: body\.quickSearch !== false/);
assert.match(server, /parallelPlatforms: true/);
});

View File

@ -0,0 +1,71 @@
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 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 ocr = await readFile(new URL("../src/ocr.js", import.meta.url), "utf8");
test("temporary query API collects without appending to history", () => {
assert.match(server, /url\.pathname === "\/api\/query-once"/);
const block = server.match(/if \(url\.pathname === "\/api\/query-once"[\s\S]*?return sendJson\(response, 200, \{ items \}\);\s*\}/)?.[0] || "";
assert.match(block, /collectTemporaryQueryItems\(/);
assert.doesNotMatch(block, /appendCollection\(/);
});
test("temporary query UI has isolated input, action, and CSV export", () => {
assert.match(html, /id="temporary-query-panel"/);
assert.match(html, /id="temporary-file-input"[^>]*type="file"/);
assert.match(html, /id="temporary-query-text"/);
assert.match(html, /id="temporary-query-button"[^>]*>一键查询<\/button>/);
assert.match(html, /id="temporary-export-button"[^>]*>导出临时 CSV<\/button>/);
assert.match(app, /temporaryQueryButton\s*=\s*document\.querySelector\("#temporary-query-button"\)/);
assert.match(app, /temporaryFileInput\.addEventListener\("change"/);
assert.match(app, /postJson\("\/api\/query-once"/);
assert.match(app, /downloadTemporaryCsv\(/);
});
test("temporary query renders platform results as each request finishes", () => {
assert.match(app, /runTemporaryQueryProgressively\(names, platforms\)/);
assert.match(app, /temporaryQueryTasks\(names, platforms\)/);
assert.match(app, /postJson\("\/api\/query-once", \{\s*names: \[task\.name\],\s*platforms: \[task\.platform\]/);
assert.match(app, /mergeTemporaryQueryResult\(task\.name, payload\.items\?.\[0\]/);
assert.match(app, /renderTemporaryResults\(temporaryQueryItems\)/);
assert.match(app, /clientMapLimit\(tasks, 6/);
});
test("temporary query supports dropping Excel-style files into the list box", () => {
assert.match(html, /accept="\.txt,\.csv,\.xlsx,text\/plain,text\/csv/);
assert.match(app, /temporaryQueryText\.addEventListener\("drop"/);
assert.match(app, /loadTemporaryImportFile\(/);
assert.match(app, /extractXlsxText\(/);
assert.match(app, /normalizeTemporaryListText\(/);
});
test("temporary query supports OCR import for screenshot images", () => {
assert.match(server, /recognizeImageText/);
assert.match(server, /url\.pathname === "\/api\/temporary-ocr"/);
assert.match(server, /readImageUploadBody\(request\)/);
assert.match(app, /postJson\("\/api\/temporary-ocr"/);
assert.match(app, /readFileAsDataUrl\(/);
assert.doesNotMatch(app, /截图需要 OCR/);
assert.match(ocr, /windows-ocr\.ps1/);
assert.match(ocr, /mkdtemp/);
});
test("temporary query runs with bounded concurrency and per-program failures", () => {
assert.match(server, /async function collectTemporaryQueryItems/);
assert.match(server, /const history = await readHistory\(\)/);
assert.match(server, /mapLimit\(names, concurrency/);
assert.match(server, /const concurrency = Math\.min\(Math\.max\(Number\(body\.concurrency \|\| 3\), 1\), 5\)/);
assert.match(server, /const manualUrls = sanitizeUrls\(body\.urls \|\| \{\}\)/);
assert.match(server, /urls: \{ \.\.\.historyProgramUrls\(history, name\), \.\.\.manualUrls \}/);
assert.match(server, /freshSearchPlatforms: body\.freshSearch \? platforms\.filter\(\(platform\) => !manualUrls\[platform\]\) : \[\]/);
assert.match(server, /delayMs: 0/);
assert.match(server, /quickSearch: body\.quickSearch !== false/);
assert.match(server, /parallelPlatforms: true/);
assert.match(server, /temporaryErrorCollection/);
assert.match(server, /async function mapLimit/);
assert.match(server, /function historyProgramUrls/);
});

View File

@ -0,0 +1,106 @@
# 云服务器部署说明(带访问密码)
服务器公网地址:
```text
118.196.84.249
```
手机和外部电脑访问时使用公网地址,不使用私网地址 `192.168.0.145`
## 1. 上传项目
云服务器只需要上传这些内容:
```text
src
public
data
package.json
```
可以放到服务器目录:
```text
/www/video-hotness
```
桌面窗口 exe、WebView2 DLL、VBS、CMD、runtime 文件夹不用上传到云服务器。
## 2. 安装 Node.js
服务器需要 Node.js 20 或更高版本。
检查命令:
```bash
node -v
```
## 3. 启动带密码的服务
进入项目目录:
```bash
cd /www/video-hotness
npm install
```
启动服务时设置访问密码:
```bash
HOTNESS_ACCESS_PASSWORD='你的访问密码' PORT=3000 HOST=0.0.0.0 npm run serve
```
示例:
```bash
HOTNESS_ACCESS_PASSWORD='Kaikai2026' PORT=3000 HOST=0.0.0.0 npm run serve
```
## 4. 放行端口
云服务器控制台安全组放行:
```text
TCP 3000
```
如果服务器系统里还有防火墙,也要放行 3000。
## 5. 访问地址
电脑端:
```text
http://118.196.84.249:3000/
```
手机端:
```text
http://118.196.84.249:3000/mobile.html
```
打开后输入启动时设置的访问密码。
## 6. 手机端服务器地址
手机版里如果需要填写服务器地址,填写:
```text
http://118.196.84.249:3000
```
不要带 `/mobile.html`
## 7. 正式使用建议
长期使用建议绑定域名并开启 HTTPS例如
```text
https://hotness.example.com/
https://hotness.example.com/mobile.html
```
HTTPS 对手机安装到桌面、离线缓存、云端同步都更稳定。

Binary file not shown.

View File

@ -0,0 +1,532 @@
# 节目热度采集工具团队操作指引
这份文档给团队日常使用人员看。按步骤操作即可,不需要懂代码。
## 1. 先确认打开的是这个项目包
项目文件夹名称是:
`video-hotness-desktop-app`
所在位置一般是:
`X:\!!!!媒资小工具\抓取视频网站数据-桌面App开发-20260513\video-hotness-desktop-app`
进入这个文件夹后,日常只需要关注下面几个文件:
- `节目热度采集工具-独立窗口版.exe`
- `安装桌面App到桌面只需一次.vbs`
- `开启节目热度采集工具开机自启动.exe`
- `取消节目热度采集工具开机自启动.exe`
- `团队操作指引(从打开到手机同步,先看这个).md`
不要手动删除或移动这些文件夹:
- `data`
- `public`
- `runtime`
- `src`
- `test`
其中 `data` 里保存历史数据和同步数据,最重要。
## 2. 第一次使用:安装桌面快捷方式
如果你希望以后直接从桌面打开:
1. 打开项目文件夹。
2. 双击 `安装桌面App到桌面只需一次.vbs`
3. 桌面上会出现一个快捷方式。
4. 以后可以直接双击桌面快捷方式打开。
如果不想安装桌面快捷方式,也可以每次直接双击项目包里的 `节目热度采集工具-独立窗口版.exe`
## 3. 每天打开电脑端
1. 打开项目文件夹。
2. 双击 `节目热度采集工具-独立窗口版.exe`
3. 等待独立 App 窗口自动打开。
4. 页面标题是 `节目热度采集`
5. 页面里会看到 `桌面开发版` 标识。
正常情况下会打开类似这个地址:
`http://127.0.0.1:3001/`
端口不一定永远是 `3001`。如果电脑上有旧版本占用了端口,工具会自动换一个可用端口,例如 `3000``3001``3002`
## 4. 每天关闭电脑端
不要只点窗口右上角关闭。点右上角关闭后,工具会隐藏到后台继续运行。
正确关闭方式:
1. 在 App 顶部菜单点击 `工具`
2. 点击 `退出后台`
3. 等几秒即可。
如果只是临时隐藏独立 App 窗口,可以从右下角托盘重新打开。
## 5. 电脑端采集单个节目
适合只查一个节目。
1. 打开电脑端页面。
2. 在顶部输入框输入节目名。
3. 确认平台勾选:
- 腾讯视频
- 优酷
- 爱奇艺
- 芒果TV
4. 如果你已经有某个平台的节目页链接,可以填到对应 URL 输入框。
5. 如果没有链接,可以不填,工具会自动搜索。
6. 点击 `采集一次`
7. 等待结果出现。
结果说明:
- 有数字:说明抓到了该平台指标。
- 未找到:说明这次没有匹配到节目页。
- 无指标:说明找到了页面,但页面里没有抓到可用指标。
- 风控/错误:说明平台限制、网络、页面变化或程序请求失败。
## 6. 保存节目链接库
如果某个节目自动搜索不稳定,建议手动保存链接。
操作方法:
1. 输入节目名。
2. 填入对应平台 URL。
3. 如果节目还有别名,填在 `别名` 输入框。
4. 点击 `保存链接库`
保存后,下次采集这个节目时,工具会优先使用已保存的链接。
## 7. 采集全部历史节目
适合每天统一更新一遍已有节目。
1. 打开电脑端页面。
2. 确认顶部平台勾选是否正确。
3. 点击左上方 `采集全部历史节目`
4. 弹出确认窗口后,确认数量没问题。
5. 点击确认。
6. 等任务队列跑完。
注意:
- 这个操作会给每个历史节目新增一次采集记录。
- 如果节目很多,会比较久。
- 中途不要反复刷新页面。
### 历史时间列太多时怎么看
当某个节目已经采集了很多次,表格会默认只显示最近 12 次采集结果。
这样最新数据会靠前显示,不需要一直横向拖动找最新日期。
如果需要查看更早的数据:
1. 打开某个历史节目。
2. 看表格右上角提示,例如 `默认显示最近 12 次,隐藏 31 个旧列`
3. 点击 `展开旧列`
4. 表格会恢复显示全部历史时间列。
5. 看完后点击 `收起旧列`,会重新回到只看最近 12 次的状态。
注意:
- 旧列只是临时隐藏,没有删除数据。
- 导出 CSV 仍然会包含历史数据。
- 进入 `批量删除列` 时会显示全部列,方便确认要删除哪一次采集。
## 8. 复查无数据
适合处理之前没有搜到、没有指标、风控或错误的节目。
这个功能可以减少“节目后来上线了,但是我们不再抓”的风险。
操作方法:
1. 打开电脑端页面。
2. 确认顶部平台勾选。
3. 点击 `复查无数据`
4. 弹出确认窗口后点击确认。
5. 等待任务完成。
它只会重点重试历史里失败的平台,不会把已经正常的数据全部重复采一遍。
## 9. 临时查询:只查一次,不写入历史
适合临时拿一批节目查数据,但不想加入历史库。
位置:
电脑端页面下方的 `临时查询` 区域。
操作方法:
1. 找到 `临时查询`
2. 把节目列表粘贴进去。
3. 每行一个节目名。
4. 确认平台勾选。
5. 点击 `一键查询`
6. 查询结果会在临时查询区域显示。
7. 如需导出,点击 `导出临时 CSV`
注意:
- 临时查询不会写入历史数据。
- 临时查询结果可以单独导出。
- 如果勾选 `保存成功链接`,成功找到的链接可以写入链接库,方便以后正式采集。
## 10. 临时查询导入 Excel、CSV、TXT 或截图
`临时查询` 区域可以导入列表。
方法一:点按钮导入
1. 点击 `导入列表`
2. 选择 Excel、CSV、TXT 或图片文件。
3. 导入后检查文本框里的节目名是否正确。
4. 再点击 `一键查询`
方法二:直接拖进去
1. 把 Excel、CSV、TXT 或截图文件拖到临时查询文本框。
2. 松开鼠标。
3. 检查识别出来的节目名。
4. 再点击 `一键查询`
截图识别注意:
- 截图里的节目名尽量清晰。
- 不要把太多无关文字截进去。
- 识别后一定人工检查一遍,避免错字。
## 11. 导出数据
导出当前节目:
1. 先在历史节目中打开一个节目。
2. 点击顶部 `导出 CSV`
导出全部节目:
1. 点击顶部 `导出全部`
2. 浏览器会下载全部历史数据 CSV。
临时查询导出:
1. 在 `临时查询` 区域完成查询。
2. 点击 `导出临时 CSV`
## 12. 手机版入口
手机版用于手机快速录入和简单采集。
电脑端必须先打开。然后在电脑端或手机端找到手机访问地址。
常见地址格式:
`http://电脑局域网IP:端口/mobile.html`
例如:
`http://192.168.1.23:3001/mobile.html`
手机打开方法:
1. 确认电脑端已经打开。
2. 手机和电脑连接同一个 WiFi。
3. 在手机浏览器输入手机访问地址。
4. 打开后看到 `节目热度采集` 手机页面。
安装到手机桌面:
1. 如果页面出现 `安装` 按钮,直接点击 `安装`
2. 如果没有出现安装按钮,打开手机浏览器菜单。
3. 选择 `添加到主屏幕` 或类似选项。
4. 以后可以从手机桌面图标打开。
第一次打开后,建议先绑定电脑地址:
1. 找到 `手机 App 设置`
2. 在 `电脑 / NAS 地址` 里填电脑端地址,例如 `http://192.168.1.23:3001`
3. 点击 `保存地址`
4. 点击 `测试连接`
5. 如果提示连接正常,之后手机会优先用这个地址同步。
如果以后改成 NAS 或另一台值班电脑,只需要在这里重新保存新地址。
如果手机访问不了:
- 先确认手机和电脑是不是同一个 WiFi。
- 电脑不要连公司访客 WiFi访客 WiFi 有时会隔离设备。
- 可以让手机连接电脑开的热点。
- 也可以电脑和手机都使用同一个组网工具,例如 Tailscale。
## 13. 手机版在线采集一次
手机和电脑在同一网络时,可以直接用手机触发采集。
1. 手机打开 `/mobile.html`
2. 先在 `这台手机/录入人` 里填写来源名称,例如 `张三手机``商务部手机`
3. 点击 `保存名称`
4. 输入节目名。
5. 可选:填写平台 URL。
6. 可选:填写备注。
7. 勾选平台。
8. 点击 `采集一次`
9. 等待结果。
这个操作会通过电脑端服务采集数据。
## 14. 手机离线录入:没连电脑时先保存
适合外出、手机用流量、暂时连不上电脑时使用。
前提:
手机至少曾经在局域网里打开过一次手机版页面。这样浏览器才会缓存手机版页面。
操作方法:
1. 手机打开之前保存过的手机版页面。
2. 确认 `这台手机/录入人` 已经填好。
3. 输入节目名。
4. 可选:填写 URL。
5. 可选:填写备注。
6. 点击 `保存待同步`
7. 记录会出现在 `手机待同步` 列表里。
注意:
- 这一步只保存在手机本机浏览器里。
- 不会马上进入电脑。
- 不会马上写入历史数据。
- 不要随便清理手机浏览器数据,否则待同步记录可能丢失。
## 15. 手机批量离线录入
适合开会时一次拿到很多节目名。
1. 打开手机版页面。
2. 找到 `批量离线录入`
3. 把节目名单粘贴进去。
4. 每行一个节目名。
5. 点击 `批量保存待同步`
6. 保存后,记录会进入 `手机待同步` 列表。
注意:
- 批量保存只存到手机本机。
- 回到能访问电脑端的网络后,还需要点击 `同步到电脑`
## 16. 编辑或删除手机待同步记录
`手机待同步` 列表里,每条记录下面有操作按钮。
编辑:
1. 点击某条记录的 `编辑`
2. 按提示修改节目名。
3. 按提示修改备注。
4. 保存后,该条记录会重新变成待同步状态。
删除:
1. 点击某条记录的 `删除`
2. 确认删除。
3. 删除后不会同步到电脑。
## 17. 手机同步到电脑
当手机回到可以访问电脑端的网络后:
1. 确认电脑端已经打开。
2. 手机重新打开手机版页面。
3. 确认手机能访问电脑端地址。
4. 在 `手机待同步` 区域点击 `同步到电脑`
5. 成功后,手机会提示 `电脑已收到`
6. 手机记录会显示 `已同步`
同步后的数据不会直接写入历史采集表。
它会进入电脑端的 `手机同步待处理` 区域。
## 18. 电脑端处理手机同步记录
手机同步成功后,回到电脑端。
1. 打开电脑端页面。
2. 找到 `手机同步待处理` 区域。
3. 查看手机同步来的节目。
4. 如果要正式采集,点击该记录里的 `填入采集栏`
5. 节目名和已有 URL 会填到顶部采集栏。
6. 检查节目名、平台、URL。
7. 点击 `采集一次`
这样做的好处:
- 手机同步来的内容先进入待处理队列。
- 团队成员可以人工确认。
- 不会把误录入的节目直接写进历史数据。
## 19. 半自动值班工具
电脑端有 `半自动值班` 区域,用来减少每天重复操作。
可以设置:
- `每天复查无数据`
- `每天采集历史节目`
- `完成后导出 CSV 并备份`
- `执行时间`
保存设置:
1. 打开电脑端。
2. 找到 `半自动值班`
3. 勾选需要的任务。
4. 设置执行时间。
5. 点击 `保存值班设置`
立即执行一次:
1. 找到 `半自动值班`
2. 确认勾选项。
3. 点击 `立即执行一次`
4. 等待状态显示完成。
自动执行说明:
- 电脑端服务必须开着,定时值班才会执行。
- 如果电脑关机、工具没打开、后台服务没运行,到点不会自动执行。
- 每天同一设置只会自动跑一次。
- 导出的 CSV 会保存到 `data/exports`
- 备份会保存到 `data/backups`
建议:
- 第一次不要直接勾选所有项目。
- 可以先只勾选 `完成后导出 CSV 并备份`,点 `立即执行一次` 测试。
- 确认没问题后,再开启每天复查或每天采集。
## 20. 什么情况会导致数据不准
常见原因:
- 节目刚上线,平台搜索还没同步。
- 平台页面能打开,但指标还没有展示。
- 平台改版,页面结构变化。
- 搜索结果匹配到了同名或相似节目。
- 平台限制访问,出现风控。
- 手动填的 URL 不是节目主页,而是搜索页或短链中转页。
建议处理方式:
- 新节目第一次没数据,不要马上判定没有上线。
- 过一两天用 `复查无数据` 再查。
- 如果自动搜索不稳定,手动找到节目页 URL 后保存链接库。
- 临时名单先用 `临时查询`,确认有价值后再加入历史。
## 21. 不要做的事情
不要删除:
- `data`
- `runtime`
- `src`
- `public`
不要直接修改:
- `data/history.json`
- `data/link-library.json`
- `data/mobile-sync.json`
不要把项目文件夹只复制一半给别人。要复制就复制完整 `video-hotness-desktop-app` 文件夹。
不要同时开多个不同版本反复采集同一批节目,容易导致团队不知道哪份数据是准的。
## 22. 常见问题
### 双击打开没反应
先等 10 秒。如果还是没反应:
1. 再双击 `节目热度采集工具-独立窗口版.exe`
2. 如果还是不行,双击 `节目热度采集工具-独立窗口版.exe`
3. 看黑色窗口里有没有报错。
4. 把报错截图发给维护人员。
### 手机打不开
按顺序检查:
1. 电脑端是否已经打开。
2. 手机和电脑是否同一个 WiFi。
3. 手机访问地址里的 IP 和端口是否正确。
4. 电脑是否连了 VPN 或访客网络。
5. Windows 防火墙是否拦截。
### 手机离线记录不见了
可能原因:
- 清理了手机浏览器缓存。
- 换了浏览器。
- 换了手机。
- 使用了无痕模式。
建议固定使用同一个手机浏览器,不要用无痕模式。
### 同步到电脑后历史里没看到
这是正常的。
手机同步后先进入电脑端 `手机同步待处理`,需要人工点击 `填入采集栏`,再点击 `采集一次`,才会写入历史。
### 值班工具到点没有自动执行
按顺序检查:
1. 电脑端是否打开。
2. 是否保存了值班设置。
3. 是否勾选了至少一个值班任务。
4. 电脑是否睡眠或关机。
5. 后台服务是否被关闭。
### 临时查询结果为什么历史里没有
这是正常的。
临时查询的设计就是只查一次,不写入历史。需要保留到历史时,请用正式采集。
## 23. 推荐日常流程
每天统一采集:
1. 打开电脑端。
2. 如果已设置 `半自动值班`,先看值班状态。
3. 如需手动,点击 `复查无数据`
4. 点击 `采集全部历史节目`
5. 导出需要的 CSV。
6. 关闭电脑端。
临时节目名单:
1. 用 `临时查询` 导入名单。
2. 先看哪些节目有数据。
3. 有价值的节目再正式录入历史。
手机外出录入:
1. 手机上输入节目。
2. 点 `保存待同步`
3. 多节目时使用 `批量离线录入`
4. 回到电脑网络后点 `同步到电脑`
5. 电脑端在 `手机同步待处理` 人工确认。
6. 需要正式采集的再点 `填入采集栏``采集一次`

View File

@ -0,0 +1,29 @@
Function U(hexList)
Dim parts, part
parts = Split(hexList, ",")
For Each part In parts
U = U & ChrW(CLng("&H" & part))
Next
End Function
Set fso = CreateObject("Scripting.FileSystemObject")
root = fso.GetParentFolderName(WScript.ScriptFullName)
Set shell = CreateObject("WScript.Shell")
desktop = shell.SpecialFolders("Desktop")
shortcutName = U("8282,76EE,70ED,5EA6,91C7,96C6,5DE5,5177") & ".lnk"
launcherExe = U("8282,76EE,70ED,5EA6,91C7,96C6,5DE5,5177") & "-" & U("72EC,7ACB,7A97,53E3,7248") & ".exe"
target = root & "\" & launcherExe
If Not fso.FileExists(target) Then
MsgBox U("672A,627E,5230,542F,52A8,6587,4EF6") & vbCrLf & target, vbExclamation, U("5B89,88C5,5931,8D25")
WScript.Quit 1
End If
Set shortcut = shell.CreateShortcut(desktop & "\" & shortcutName)
shortcut.TargetPath = target
shortcut.WorkingDirectory = root
shortcut.Description = U("8282,76EE,70ED,5EA6,91C7,96C6,5DE5,5177")
shortcut.Save
MsgBox U("5DF2,5B89,88C5,5230,684C,9762") & vbCrLf & desktop & "\" & shortcutName, vbInformation, U("5B89,88C5,5B8C,6210")

Binary file not shown.

View File

@ -0,0 +1,25 @@
@echo off
setlocal
cd /d "%~dp0"
set "CSC=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if not exist "%CSC%" set "CSC=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe"
if not exist "%CSC%" (
echo Cannot find csc.exe
pause
exit /b 1
)
"%CSC%" /nologo /target:winexe /platform:anycpu /reference:System.Windows.Forms.dll /out:"开启节目热度采集工具开机自启动.exe" "src\native-launcher\HotnessEnableStartup.cs"
if errorlevel 1 pause & exit /b 1
"%CSC%" /nologo /target:winexe /platform:anycpu /reference:System.Windows.Forms.dll /out:"取消节目热度采集工具开机自启动.exe" "src\native-launcher\HotnessDisableStartup.cs"
if errorlevel 1 pause & exit /b 1
if exist "vendor\webview2\pkg\lib\net462\Microsoft.Web.WebView2.Core.dll" (
copy /y "vendor\webview2\pkg\lib\net462\Microsoft.Web.WebView2.Core.dll" "Microsoft.Web.WebView2.Core.dll" >nul
copy /y "vendor\webview2\pkg\lib\net462\Microsoft.Web.WebView2.WinForms.dll" "Microsoft.Web.WebView2.WinForms.dll" >nul
copy /y "vendor\webview2\pkg\runtimes\win-x64\native\WebView2Loader.dll" "WebView2Loader.dll" >nul
"%CSC%" /nologo /target:winexe /platform:x64 /reference:System.dll /reference:System.Core.dll /reference:System.Drawing.dll /reference:System.Windows.Forms.dll /reference:"Microsoft.Web.WebView2.Core.dll" /reference:"Microsoft.Web.WebView2.WinForms.dll" /out:"节目热度采集工具-独立窗口版.exe" "src\native-launcher\HotnessWebViewApp.cs"
if errorlevel 1 pause & exit /b 1
) else (
echo WebView2 SDK not found. Skip independent window app.
)
echo Done.
pause

Binary file not shown.