Compare commits

...

No commits in common. "codex/feishu-daily-report" and "main" have entirely different histories.

102 changed files with 14739 additions and 3162 deletions

View File

@ -1,10 +0,0 @@
PORT=8787
BASE_URL=http://localhost:8787
DATABASE_PATH=data/daily-report.sqlite
EMPLOYEE_SEED_PATH=data/employees.json
WORKDAY_CALENDAR_PATH=data/workday-calendar.json
FEISHU_WEBHOOK_URL=
FEISHU_WEBHOOK_SECRET=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
SESSION_SECRET=

15
.gitignore vendored
View File

@ -1,7 +1,10 @@
.env
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
__pycache__/
*.pyc
.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.

174
README.md
View File

@ -1,146 +1,76 @@
# 飞书每日工作汇报系统
# 节目热度采集工具
这是一个轻量的日报提交和浏览工具。员工通过飞书机器人提醒进入填写页提交日报,管理者在浏览页查看所有记录和未提交名单
这是独立窗口版桌面 App。团队日常只需要打开一个 exe不需要命令行、npm、VBS 或浏览器启动脚本
## 本地启动
## 日常使用
本项目只使用 Python 标准库,不需要安装第三方依赖。
```bash
python -m daily_report.web
```
默认地址:
- 提交页http://localhost:8787/submit
- 管理页http://localhost:8787/manager
## 配置
复制 `.env.example``.env`,或直接设置环境变量:
- `PORT`:服务端口,默认 `8787`
- `BASE_URL`:飞书消息里使用的外部访问地址
- `DATABASE_PATH`SQLite 数据库路径
- `EMPLOYEE_SEED_PATH`:员工名单 JSON 路径
- `WORKDAY_CALENDAR_PATH`:国家法定工作日历 JSON 路径
- `FEISHU_WEBHOOK_URL`:飞书自定义机器人 Webhook
- `FEISHU_WEBHOOK_SECRET`:预留字段,后续用于签名校验
- `FEISHU_APP_ID`:飞书企业自建应用的 App ID用于自动识别填写人
- `FEISHU_APP_SECRET`:飞书企业自建应用的 App Secret
- `SESSION_SECRET`:本系统登录 Cookie 签名密钥,可以填一串自己生成的随机字符
## 员工名单
第一版通过 `data/employees.json` 维护员工名单。每个员工需要:
- `feishu_user_id`
- `name`
- `department`
- `manager`
- `active`
修改员工名单后,重启服务会自动同步到 SQLite。
## 飞书机器人
在飞书群添加自定义机器人后,将 Webhook 地址配置到 `FEISHU_WEBHOOK_URL`
手动发送提醒:
```bash
curl -X POST http://localhost:8787/api/robot/send-reminder
```
手动发送汇总:
```bash
curl -X POST http://localhost:8787/api/robot/send-summary -H "content-type: application/json" -d "{\"date\":\"2026-05-07\"}"
```
## 飞书自动识别填写人
要让员工打开填写页时自动识别身份,需要使用飞书企业自建应用。
`.env` 中填写:
```dotenv
FEISHU_APP_ID=你的 App ID
FEISHU_APP_SECRET=你的 App Secret
SESSION_SECRET=任意一串较长随机字符
```
然后在飞书开放平台应用后台配置网页应用重定向地址:
打开:
```text
http://你的可访问地址:8787/auth/feishu/callback
节目热度采集工具-独立窗口版.exe
```
这个地址里的域名和端口要与 `BASE_URL` 一致。配置后重启本系统,员工打开 `/submit` 会先跳转到飞书授权,授权完成后自动回到填写页,并自动带出当前填写人。
关闭:
如果暂时不填 `FEISHU_APP_ID``FEISHU_APP_SECRET`,系统会退回手动填写员工 ID 的模式
在 App 窗口菜单里点 `工具` -> `退出后台`,或在右下角托盘图标里点 `退出后台`
## Windows 任务计划
注意:直接点窗口右上角关闭时,工具会隐藏到后台,方便半自动值班继续运行。
本项目提供 Windows 任务计划脚本。任务每天触发,但发送前会读取 `data/workday-calendar.json`,只在国家法定工作日发送:
## 桌面快捷方式
- 工作日 18:00日报填写提醒
- 工作日 18:50只提醒仍未提交的人
- 工作日 19:00日报提交汇总
这意味着:
- 周一到周五如果是法定假期,不发送。
- 周六、周日如果是调休补班,会发送。
任务计划会直接运行 Python 模块发送飞书消息,不要求 Web 服务正在运行。但 `BASE_URL` 必须是员工能打开的地址。
安装任务:
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\scripts\install-windows-tasks.ps1
```
也可以直接双击:
双击:
```text
install-tasks.bat
安装桌面App到桌面只需一次.vbs
```
如果你的 Python 不在默认路径,可以指定:
它会在桌面生成 `节目热度采集工具` 快捷方式,指向 `节目热度采集工具-独立窗口版.exe`
```powershell
.\scripts\install-windows-tasks.ps1 -PythonPath "C:\Path\To\python.exe"
```
## 开机自启动
手动测试提醒:
```powershell
python -m daily_report.scheduled reminder
```
手动测试汇总:
```powershell
python -m daily_report.scheduled summary
```
卸载任务:
```powershell
.\scripts\uninstall-windows-tasks.ps1
```
也可以直接双击:
值班电脑需要自动运行时,双击:
```text
uninstall-tasks.bat
开启节目热度采集工具开机自启动.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
```
## 验证
```bash
python -m unittest discover -s tests
python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/scheduled.py daily_report/web.py daily_report/workday.py
```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.

View File

@ -1,2 +0,0 @@
"""Feishu daily report application."""

View File

@ -1,57 +0,0 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Config:
root_dir: Path
port: int
base_url: str
database_path: Path
employee_seed_path: Path
workday_calendar_path: Path
feishu_webhook_url: str
feishu_webhook_secret: str
feishu_app_id: str
feishu_app_secret: str
session_secret: str
def _read_dotenv(path: Path) -> dict[str, str]:
if not path.exists():
return {}
values: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def read_config(env: dict[str, str] | None = None) -> Config:
root_dir = Path(__file__).resolve().parent.parent
dotenv_values = _read_dotenv(root_dir / ".env")
values = {**dotenv_values, **dict(env if env is not None else os.environ)}
port = int(values.get("PORT", "8787"))
if port <= 0:
raise ValueError("PORT must be a positive integer")
return Config(
root_dir=root_dir,
port=port,
base_url=values.get("BASE_URL", f"http://localhost:{port}"),
database_path=(root_dir / values.get("DATABASE_PATH", "data/daily-report.sqlite")).resolve(),
employee_seed_path=(root_dir / values.get("EMPLOYEE_SEED_PATH", "data/employees.json")).resolve(),
workday_calendar_path=(root_dir / values.get("WORKDAY_CALENDAR_PATH", "data/workday-calendar.json")).resolve(),
feishu_webhook_url=values.get("FEISHU_WEBHOOK_URL", ""),
feishu_webhook_secret=values.get("FEISHU_WEBHOOK_SECRET", ""),
feishu_app_id=values.get("FEISHU_APP_ID", ""),
feishu_app_secret=values.get("FEISHU_APP_SECRET", ""),
session_secret=values.get("SESSION_SECRET") or values.get("FEISHU_APP_SECRET") or "dev-session-secret",
)

View File

@ -1,213 +0,0 @@
from __future__ import annotations
import json
import sqlite3
import threading
from pathlib import Path
from typing import Any
class Database:
def __init__(self, database_path: Path, employee_seed_path: Path):
database_path.parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(database_path, check_same_thread=False)
self.connection.row_factory = sqlite3.Row
self._lock = threading.RLock()
self._migrate()
self.load_employees(employee_seed_path)
def close(self) -> None:
with self._lock:
self.connection.close()
def _migrate(self) -> None:
with self._lock:
self.connection.executescript(
"""
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feishu_user_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
department TEXT NOT NULL DEFAULT '',
manager TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'staff',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feishu_user_id TEXT NOT NULL,
employee_name TEXT NOT NULL,
report_date TEXT NOT NULL,
report_status TEXT NOT NULL DEFAULT 'normal',
today_done TEXT NOT NULL,
tomorrow_plan TEXT NOT NULL,
blockers TEXT NOT NULL DEFAULT '',
help_needed TEXT NOT NULL DEFAULT '',
submitted_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(feishu_user_id, report_date)
);
"""
)
try:
self.connection.execute("ALTER TABLE employees ADD COLUMN role TEXT NOT NULL DEFAULT 'staff'")
except sqlite3.OperationalError as error:
if "duplicate column name" not in str(error).lower():
raise
try:
self.connection.execute(
"ALTER TABLE daily_reports ADD COLUMN report_status TEXT NOT NULL DEFAULT 'normal'"
)
except sqlite3.OperationalError as error:
if "duplicate column name" not in str(error).lower():
raise
self.connection.commit()
def load_employees(self, employee_seed_path: Path) -> None:
if not employee_seed_path.exists():
return
employees = json.loads(employee_seed_path.read_text(encoding="utf-8"))
self.upsert_employees(employees)
def upsert_employees(self, employees: list[dict[str, Any]]) -> None:
with self._lock, self.connection:
for employee in employees:
role = employee.get("role")
if role is None:
existing = self.connection.execute(
"SELECT role FROM employees WHERE feishu_user_id = ?",
(employee["feishu_user_id"],),
).fetchone()
role = existing["role"] if existing else "staff"
self.connection.execute(
"""
INSERT INTO employees (feishu_user_id, name, department, manager, role, active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(feishu_user_id) DO UPDATE SET
name = excluded.name,
department = excluded.department,
manager = excluded.manager,
role = excluded.role,
active = excluded.active,
updated_at = CURRENT_TIMESTAMP
""",
(
employee["feishu_user_id"],
employee["name"],
employee.get("department", ""),
employee.get("manager", ""),
role,
0 if employee.get("active") is False else 1,
),
)
def upsert_employee(self, employee: dict[str, Any]) -> None:
self.upsert_employees([employee])
def list_active_employees(self) -> list[dict[str, Any]]:
with self._lock:
rows = self.connection.execute(
"""
SELECT feishu_user_id, name, department, manager, role, active
FROM employees
WHERE active = 1
ORDER BY department, name
"""
).fetchall()
return [dict(row) for row in rows]
def list_report_required_employees(self) -> list[dict[str, Any]]:
with self._lock:
rows = self.connection.execute(
"""
SELECT feishu_user_id, name, department, manager, role, active
FROM employees
WHERE active = 1 AND role != 'admin'
ORDER BY department, name
"""
).fetchall()
return [dict(row) for row in rows]
def find_employee(self, feishu_user_id: str) -> dict[str, Any] | None:
with self._lock:
row = self.connection.execute(
"""
SELECT feishu_user_id, name, department, manager, role, active
FROM employees
WHERE feishu_user_id = ?
""",
(feishu_user_id,),
).fetchone()
return dict(row) if row else None
def upsert_report(self, report: dict[str, Any]) -> None:
with self._lock, self.connection:
self.connection.execute(
"""
INSERT INTO daily_reports (
feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan,
blockers, help_needed, submitted_at, updated_at
)
VALUES (
:feishu_user_id, :employee_name, :report_date, :report_status, :today_done, :tomorrow_plan,
:blockers, :help_needed, :submitted_at, :updated_at
)
ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET
employee_name = excluded.employee_name,
report_status = excluded.report_status,
today_done = excluded.today_done,
tomorrow_plan = excluded.tomorrow_plan,
blockers = excluded.blockers,
help_needed = excluded.help_needed,
updated_at = excluded.updated_at
""",
report,
)
def list_reports_for_date(self, report_date: str) -> list[dict[str, Any]]:
with self._lock:
rows = self.connection.execute(
"""
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports
WHERE report_date = ?
ORDER BY employee_name
""",
(report_date,),
).fetchall()
return [dict(row) for row in rows]
def list_reports_for_employee(self, feishu_user_id: str, limit: int = 10) -> list[dict[str, Any]]:
with self._lock:
rows = self.connection.execute(
"""
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports
WHERE feishu_user_id = ?
ORDER BY report_date DESC, updated_at DESC
LIMIT ?
""",
(feishu_user_id, limit),
).fetchall()
return [dict(row) for row in rows]
def find_previous_report(self, feishu_user_id: str, before_date: str) -> dict[str, Any] | None:
with self._lock:
row = self.connection.execute(
"""
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
FROM daily_reports
WHERE feishu_user_id = ? AND report_date < ?
ORDER BY report_date DESC, updated_at DESC
LIMIT 1
""",
(feishu_user_id, before_date),
).fetchone()
return dict(row) if row else None

View File

@ -1,112 +0,0 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import urllib.parse
import urllib.request
from typing import Any
AUTHORIZE_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize"
TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"
USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
def _b64encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64decode(text: str) -> bytes:
padding = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + padding)
def create_session_cookie(session: dict[str, str], secret: str) -> str:
payload = _b64encode(json.dumps(session, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
signature = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
return f"{payload}.{signature}"
def parse_session_cookie(cookie_value: str | None, secret: str) -> dict[str, str] | None:
if not cookie_value or "." not in cookie_value:
return None
payload, signature = cookie_value.rsplit(".", 1)
expected = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return None
try:
data = json.loads(_b64decode(payload).decode("utf-8"))
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or not data.get("feishu_user_id"):
return None
return {str(key): str(value) for key, value in data.items()}
def create_state() -> str:
return secrets.token_urlsafe(24)
def authorize_url(app_id: str, redirect_uri: str, state: str) -> str:
query = urllib.parse.urlencode(
{
"app_id": app_id,
"redirect_uri": redirect_uri,
"state": state,
}
)
return f"{AUTHORIZE_URL}?{query}"
def exchange_code_for_user_access_token(app_id: str, app_secret: str, code: str, redirect_uri: str) -> str:
payload = json.dumps(
{
"grant_type": "authorization_code",
"client_id": app_id,
"client_secret": app_secret,
"code": code,
"redirect_uri": redirect_uri,
}
).encode("utf-8")
request = urllib.request.Request(
TOKEN_URL,
data=payload,
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
token = result.get("access_token") or result.get("data", {}).get("access_token")
if not token:
raise RuntimeError(f"Feishu token response missing access_token: {result}")
return str(token)
def fetch_user_info(user_access_token: str) -> dict[str, Any]:
request = urllib.request.Request(
USER_INFO_URL,
headers={"authorization": f"Bearer {user_access_token}"},
method="GET",
)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
data = result.get("data", result)
user_id = data.get("user_id") or data.get("open_id") or data.get("union_id")
name = data.get("name") or data.get("en_name") or data.get("email") or user_id
if not user_id:
raise RuntimeError(f"Feishu user_info response missing user id: {result}")
return {
"feishu_user_id": str(user_id),
"name": str(name),
"department": "",
"manager": "",
"active": True,
}

View File

@ -1,131 +0,0 @@
from __future__ import annotations
import csv
import io
from datetime import datetime, timezone
from typing import Any, Callable
from .db import Database
Clock = Callable[[], datetime]
def _required_text(value: Any, field_name: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{field_name} is required")
return text
def _optional_text(value: Any) -> str:
return str(value or "").strip()
def _report_status(value: Any) -> str:
status = str(value or "normal").strip()
allowed = {"normal", "risk", "need_help"}
if status not in allowed:
raise ValueError("report_status is invalid")
return status
class ReportService:
def __init__(self, database: Database, clock: Clock | None = None):
self.database = database
self.clock = clock or (lambda: datetime.now(timezone.utc))
def upsert_report(self, data: dict[str, Any]) -> dict[str, Any]:
feishu_user_id = _required_text(data.get("feishu_user_id"), "feishu_user_id")
report_date = _required_text(data.get("report_date"), "report_date")
today_done = _required_text(data.get("today_done"), "today_done")
tomorrow_plan = _required_text(data.get("tomorrow_plan"), "tomorrow_plan")
employee = self.database.find_employee(feishu_user_id)
if not employee or employee.get("active") != 1:
raise ValueError("employee is not active")
now = self.clock().isoformat()
self.database.upsert_report(
{
"feishu_user_id": feishu_user_id,
"employee_name": employee["name"],
"report_date": report_date,
"report_status": _report_status(data.get("report_status")),
"today_done": today_done,
"tomorrow_plan": tomorrow_plan,
"blockers": _optional_text(data.get("blockers")),
"help_needed": _optional_text(data.get("help_needed")),
"submitted_at": now,
"updated_at": now,
}
)
return next(
report
for report in self.database.list_reports_for_employee(feishu_user_id, 1)
if report["feishu_user_id"] == feishu_user_id
)
def list_reports_for_date(self, report_date: str) -> dict[str, Any]:
date = _required_text(report_date, "report_date")
employees = self.database.list_report_required_employees()
required_ids = {employee["feishu_user_id"] for employee in employees}
reports = [
report
for report in self.database.list_reports_for_date(date)
if report["feishu_user_id"] in required_ids
]
submitted_ids = {report["feishu_user_id"] for report in reports}
missing = [employee for employee in employees if employee["feishu_user_id"] not in submitted_ids]
return {
"date": date,
"expectedCount": len(employees),
"submittedCount": len(reports),
"missingCount": len(missing),
"reports": reports,
"missing": missing,
}
def export_reports_csv(self, report_date: str) -> str:
result = self.list_reports_for_date(report_date)
output = io.StringIO()
fieldnames = [
"employee_name",
"report_date",
"today_done",
"tomorrow_plan",
"blockers",
"help_needed",
"report_status",
"submitted_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(result["reports"])
return output.getvalue()
def list_employee_history(self, feishu_user_id: str, limit: int = 10) -> dict[str, Any]:
user_id = _required_text(feishu_user_id, "feishu_user_id")
employee = self.database.find_employee(user_id)
if not employee or employee.get("active") != 1:
raise ValueError("employee is not active")
return {
"employee": employee,
"reports": self.database.list_reports_for_employee(user_id, limit),
}
def previous_report_reference(self, feishu_user_id: str, before_date: str) -> dict[str, Any]:
user_id = _required_text(feishu_user_id, "feishu_user_id")
date = _required_text(before_date, "report_date")
employee = self.database.find_employee(user_id)
if not employee or employee.get("active") != 1:
raise ValueError("employee is not active")
return {
"employee": employee,
"report": self.database.find_previous_report(user_id, date),
}
def is_admin(self, feishu_user_id: str) -> bool:
user_id = _required_text(feishu_user_id, "feishu_user_id")
employee = self.database.find_employee(user_id)
return bool(employee and employee.get("active") == 1 and employee.get("role") == "admin")

View File

@ -1,138 +0,0 @@
from __future__ import annotations
import json
import base64
import hashlib
import hmac
import time
import urllib.error
import urllib.request
from typing import Any
TENANT_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
def _button(text: str, url: str) -> dict[str, Any]:
return {
"tag": "button",
"text": {"tag": "plain_text", "content": text},
"type": "primary",
"url": url,
}
def create_reminder_payload(submit_url: str, title: str = "每日工作汇报提醒") -> dict[str, Any]:
return {
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": "请提交今日日报。"}},
{"tag": "action", "actions": [_button("填写日报", submit_url)]},
],
},
}
def create_summary_payload(manager_url: str, summary: dict[str, Any]) -> dict[str, Any]:
missing_names = "".join(employee["name"] for employee in summary["missing"]) or ""
return {
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": f"{summary['date']} 日报提交汇总"},
"template": "orange" if summary["missingCount"] > 0 else "green",
},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"已提交:{summary['submittedCount']}/{summary['expectedCount']}\n未提交:{missing_names}",
},
},
{"tag": "action", "actions": [_button("查看全部日报", manager_url)]},
],
},
}
def create_bot_message_body(payload: dict[str, Any]) -> dict[str, str]:
return {
"receive_id": "",
"msg_type": "interactive",
"content": json.dumps(payload["card"], ensure_ascii=False),
}
def get_tenant_access_token(app_id: str, app_secret: str) -> str:
if not app_id or not app_secret:
raise ValueError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
request = urllib.request.Request(
TENANT_TOKEN_URL,
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
token = result.get("tenant_access_token")
if not token:
raise RuntimeError(f"Feishu tenant token response missing token: {result}")
return str(token)
def send_bot_interactive_message(tenant_access_token: str, receive_id: str, payload: dict[str, Any]) -> dict[str, Any]:
if not receive_id:
raise ValueError("receive_id is required")
body = create_bot_message_body(payload)
body["receive_id"] = receive_id
request = urllib.request.Request(
MESSAGE_URL,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={
"authorization": f"Bearer {tenant_access_token}",
"content-type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
return result
def create_webhook_sign(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
sign = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(sign).decode("utf-8")
def send_webhook(webhook_url: str, payload: dict[str, Any], secret: str = "") -> dict[str, Any]:
if not webhook_url:
raise ValueError("FEISHU_WEBHOOK_URL is required")
request_payload = dict(payload)
if secret:
timestamp = str(int(time.time()))
request_payload["timestamp"] = timestamp
request_payload["sign"] = create_webhook_sign(timestamp, secret)
request = urllib.request.Request(
webhook_url,
data=json.dumps(request_payload).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {"ok": True}
except urllib.error.HTTPError as error:
raise RuntimeError(f"Feishu webhook failed with status {error.code}") from error

View File

@ -1,80 +0,0 @@
from __future__ import annotations
import argparse
from datetime import date
from .config import read_config
from .db import Database
from .report_service import ReportService
from .robot_service import (
create_reminder_payload,
create_summary_payload,
get_tenant_access_token,
send_bot_interactive_message,
send_webhook,
)
from .workday import is_workday
def send_reminder() -> dict:
config = read_config()
if not is_workday(date.today(), config.workday_calendar_path):
return {"skipped": True, "reason": "not a workday"}
payload = create_reminder_payload(f"{config.base_url}/submit")
database = Database(config.database_path, config.employee_seed_path)
try:
service = ReportService(database)
employees = service.list_reports_for_date(date.today().isoformat())["missing"]
if config.feishu_app_id and config.feishu_app_secret:
token = get_tenant_access_token(config.feishu_app_id, config.feishu_app_secret)
sent = []
for employee in employees:
send_bot_interactive_message(token, employee["feishu_user_id"], payload)
sent.append(employee["name"])
return {"ok": True, "mode": "bot_private", "sent": sent}
return send_webhook(config.feishu_webhook_url, payload, config.feishu_webhook_secret)
finally:
database.close()
def send_summary(report_date: str | None = None) -> dict:
config = read_config()
selected_date = date.fromisoformat(report_date) if report_date else date.today()
if not is_workday(selected_date, config.workday_calendar_path):
return {"skipped": True, "reason": "not a workday", "date": selected_date.isoformat()}
database = Database(config.database_path, config.employee_seed_path)
try:
service = ReportService(database)
selected_date_text = selected_date.isoformat()
summary = service.list_reports_for_date(selected_date_text)
payload = create_summary_payload(f"{config.base_url}/manager?date={selected_date_text}", summary)
if config.feishu_app_id and config.feishu_app_secret:
token = get_tenant_access_token(config.feishu_app_id, config.feishu_app_secret)
admins = [employee for employee in database.list_active_employees() if employee.get("role") == "admin"]
sent = []
for admin in admins:
send_bot_interactive_message(token, admin["feishu_user_id"], payload)
sent.append(admin["name"])
return {"ok": True, "mode": "bot_private", "sent": sent}
return send_webhook(config.feishu_webhook_url, payload, config.feishu_webhook_secret)
finally:
database.close()
def main() -> None:
parser = argparse.ArgumentParser(description="发送飞书日报定时消息")
parser.add_argument("action", choices=["reminder", "summary"], help="reminder 发送提醒summary 发送汇总")
parser.add_argument("--date", help="汇总日期,格式 YYYY-MM-DD不传则使用当天")
args = parser.parse_args()
if args.action == "reminder":
result = send_reminder()
else:
result = send_summary(args.date)
print(result)
if __name__ == "__main__":
main()

View File

@ -1,94 +0,0 @@
const state = { data: null };
const statusLabels = {
normal: "正常",
risk: "有风险",
need_help: "需要支持"
};
function text(value) {
return String(value || "");
}
function escapeHtml(value) {
return text(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[char]));
}
function render() {
const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase();
const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase();
const onlyBlockers = document.querySelector("#blocker-filter").checked;
const data = state.data;
document.querySelector("#stats").innerHTML = `
<strong>应提交${data.expectedCount}</strong>
<strong>已提交${data.submittedCount}</strong>
<strong>未提交${data.missingCount}</strong>
`;
const reports = data.reports.filter((report) => {
const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery);
const searchable = [
report.today_done,
report.tomorrow_plan,
report.blockers,
report.help_needed,
statusLabels[report.report_status]
].join(" ").toLowerCase();
const keywordMatch = searchable.includes(keywordQuery);
const blockerMatch = !onlyBlockers || report.blockers || report.help_needed || report.report_status === "need_help";
return employeeMatch && keywordMatch && blockerMatch;
});
document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => `
<article class="report-card">
<div class="report-head">
<h3>${escapeHtml(report.employee_name)}</h3>
<span class="status-badge status-${escapeHtml(report.report_status || "normal")}">
${statusLabels[report.report_status] || "正常"}
</span>
</div>
<div class="report-grid">
<div><div class="field-title">今日完成</div><pre>${escapeHtml(report.today_done)}</pre></div>
<div><div class="field-title">明日计划</div><pre>${escapeHtml(report.tomorrow_plan)}</pre></div>
<div><div class="field-title">遇到的问题</div><pre>${escapeHtml(report.blockers || "")}</pre></div>
<div><div class="field-title">需要协助</div><pre>${escapeHtml(report.help_needed || "")}</pre></div>
</div>
<p>提交时间${escapeHtml(report.updated_at)}</p>
</article>
`).join("") : "<p>当前筛选下没有日报。</p>";
document.querySelector("#missing").textContent = data.missing.length
? data.missing.map((employee) => employee.name).join("、")
: "无";
}
async function loadReports() {
const date = document.querySelector("#date-filter").value;
document.querySelector("#export-link").href = `/api/reports/export?date=${date}`;
const response = await fetch(`/api/reports?date=${date}`);
state.data = await response.json();
render();
}
document.querySelector("#date-filter").addEventListener("change", loadReports);
document.querySelector("#employee-filter").addEventListener("input", render);
document.querySelector("#keyword-filter").addEventListener("input", render);
document.querySelector("#blocker-filter").addEventListener("change", render);
document.querySelector("#copy-summary").addEventListener("click", async () => {
const data = state.data;
const lines = [
`${data.date} 日报汇总`,
`已提交:${data.submittedCount}/${data.expectedCount}`,
`未提交:${data.missing.length ? data.missing.map((employee) => employee.name).join("、") : "无"}`
];
await navigator.clipboard.writeText(lines.join("\n"));
});
loadReports();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 KiB

View File

@ -1,275 +0,0 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at 18% 10%, rgba(255, 204, 116, 0.32), transparent 28%),
radial-gradient(circle at 86% 4%, rgba(91, 190, 226, 0.22), transparent 26%),
linear-gradient(180deg, #fff8ed 0%, #f6f7fb 42%, #f6f7f9 100%);
color: #1f2937;
}
.shell { width: min(1120px, calc(100% - 32px)); margin: 28px auto; }
.narrow { width: min(780px, calc(100% - 32px)); }
.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
h1 { margin: 0 0 8px; font-size: 28px; }
h2 { font-size: 18px; }
p { color: #5b6472; }
.panel, .filters, .stats, .report-card, .missing {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
}
.submit-hero {
min-height: 220px;
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 24px;
align-items: center;
margin-bottom: 18px;
padding: 22px 24px 22px 28px;
border: 1px solid #f3d49a;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 248, 225, 0.96), rgba(230, 246, 255, 0.96)),
#fff;
box-shadow: 0 18px 45px rgba(39, 45, 58, 0.08);
overflow: hidden;
}
.submit-hero-copy { min-width: 0; }
.hero-kicker {
display: inline-flex;
margin: 0 0 8px;
padding: 4px 10px;
border-radius: 999px;
background: #fff7d6;
color: #9a5b00;
font-size: 13px;
font-weight: 700;
}
.submit-hero h1 {
margin: 0 0 8px;
font-size: 32px;
}
.submit-hero p {
max-width: 420px;
margin: 0;
}
.submit-hero-art {
position: relative;
min-height: 178px;
}
.submit-hero-art img {
display: block;
object-fit: contain;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.88);
box-shadow: 0 12px 24px rgba(31, 41, 55, 0.12);
}
.hero-art-main {
width: 218px;
height: 218px;
margin-left: auto;
justify-self: end;
border-radius: 24px;
}
.hero-art-small {
position: absolute;
width: 92px;
height: 92px;
border-radius: 20px;
}
.hero-art-bed {
left: 16px;
top: 12px;
transform: rotate(-6deg);
}
.hero-art-bath {
left: 44px;
bottom: 4px;
transform: rotate(5deg);
}
form.panel, .history-panel, .previous-plan {
box-shadow: 0 14px 34px rgba(39, 45, 58, 0.06);
}
form { display: grid; gap: 14px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, textarea, button {
font: inherit;
border-radius: 6px;
border: 1px solid #cfd5df;
padding: 10px 12px;
}
textarea { resize: vertical; }
button {
background: #2563eb;
color: #fff;
border: 0;
cursor: pointer;
}
.identity-box {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #dbeafe;
border-radius: 6px;
background: #eff6ff;
}
.status-options {
display: flex;
flex-wrap: wrap;
gap: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
}
.status-options legend { font-weight: 700; }
.status-options label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 400;
}
.item-group {
display: grid;
gap: 8px;
}
.item-group h2 { margin: 0; }
.item-group-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.item-group [data-list] {
display: grid;
gap: 8px;
}
.item-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 36px;
gap: 8px;
align-items: center;
}
.item-index {
color: #5b6472;
text-align: right;
}
.add-item, .remove-item {
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.add-item {
width: auto;
min-width: 92px;
padding: 0 12px;
border: 1px solid #2563eb;
background: #fff;
color: #2563eb;
font-size: 14px;
font-weight: 700;
}
.add-item::after {
content: " 添加一条";
font-size: 13px;
}
.remove-item {
border: 1px solid #e5e7eb;
background: #fff;
color: #6b7280;
font-size: 20px;
line-height: 1;
}
.remove-item:hover {
border-color: #fecaca;
color: #b91c1c;
background: #fef2f2;
}
.previous-plan {
display: grid;
gap: 10px;
margin-top: 18px;
background: #f8fafc;
}
.previous-plan[hidden] { display: none; }
.previous-plan h2, .previous-plan p { margin: 0; }
.previous-plan button { justify-self: start; }
.report-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.status-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
}
.status-normal { background: #dcfce7; color: #166534; }
.status-risk { background: #fef3c7; color: #92400e; }
.status-need_help { background: #fee2e2; color: #991b1b; }
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
align-items: center;
margin: 20px 0;
}
.check { display: flex; align-items: center; gap: 8px; font-weight: 400; }
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
.report-list { display: grid; gap: 12px; }
.report-card h3 { margin: 0 0 10px; }
.report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.field-title { font-weight: 700; margin-bottom: 4px; }
.history-panel { margin-top: 18px; }
.history-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.history-list { display: grid; gap: 12px; }
.history-card {
border-top: 1px solid #e5e7eb;
padding-top: 12px;
}
.history-card h3 { margin: 0 0 10px; }
pre {
white-space: pre-wrap;
word-break: break-word;
margin: 6px 0 12px;
font: inherit;
color: #374151;
}
.message.success { color: #047857; }
.message.error { color: #b91c1c; }
@media (max-width: 760px) {
.topbar, .stats, .history-head, .report-head { display: grid; }
.filters, .report-grid { grid-template-columns: 1fr; }
.submit-hero {
grid-template-columns: 1fr;
padding: 20px;
}
.submit-hero-art {
width: min(330px, 100%);
min-height: 180px;
justify-self: center;
}
.hero-art-main {
width: 190px;
height: 190px;
}
.hero-art-small {
width: 76px;
height: 76px;
}
}

View File

@ -1,557 +0,0 @@
from __future__ import annotations
import json
from datetime import date
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from .config import Config, read_config
from .db import Database
from .feishu_auth import (
authorize_url,
create_session_cookie,
create_state,
exchange_code_for_user_access_token,
fetch_user_info,
parse_session_cookie,
)
from .report_service import ReportService
from .robot_service import create_reminder_payload, create_summary_payload, send_webhook
def today_string() -> str:
return date.today().isoformat()
def page(title: str, body: str, scripts: str = "") -> bytes:
html = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
{body}
{scripts}
</body>
</html>"""
return html.encode("utf-8")
def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes:
if session:
identity_html = f"""
<div class="identity-box">
<span>当前填写人</span>
<strong>{escape(session.get("name", session["feishu_user_id"]))}</strong>
</div>
<input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}">
"""
history_hint = "系统已自动识别你的飞书身份,这里会显示你最近提交过的日报。"
else:
identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>'
history_hint = "输入员工 ID 后,可以查看自己最近提交过的日报。"
return page(
"每日报告",
f"""
<main class="shell narrow">
<section class="submit-hero">
<div class="submit-hero-copy">
<p class="hero-kicker">今日收尾小站</p>
<h1>每日工作汇报</h1>
<p>把今天的进展明天的计划和需要协助的地方简单记下来</p>
</div>
<div class="submit-hero-art" aria-hidden="true">
<img src="/static/report-illust-work.png" alt="" class="hero-art-main">
<img src="/static/report-illust-bed.png" alt="" class="hero-art-small hero-art-bed">
<img src="/static/report-illust-bath.png" alt="" class="hero-art-small hero-art-bath">
</div>
</section>
<form id="report-form" class="panel">
{identity_html}
<label>日期<input id="report-date" name="report_date" type="date" required value="{escape(current_date)}"></label>
<fieldset class="status-options">
<legend>今日状态</legend>
<label><input type="radio" name="report_status" value="normal" checked> 正常</label>
<label><input type="radio" name="report_status" value="risk"> 有风险</label>
<label><input type="radio" name="report_status" value="need_help"> 需要支持</label>
</fieldset>
<section class="item-group">
<div class="item-group-head">
<h2>今日完成</h2>
<button class="add-item" type="button" data-add-list="today_done" aria-label="添加今日完成">+</button>
</div>
<div data-list="today_done"></div>
</section>
<section class="item-group">
<div class="item-group-head">
<h2>明日计划</h2>
<button class="add-item" type="button" data-add-list="tomorrow_plan" aria-label="添加明日计划">+</button>
</div>
<div data-list="tomorrow_plan"></div>
</section>
<label>遇到的问题<textarea name="blockers" rows="3" placeholder="没有可以不填"></textarea></label>
<label>需要协助<textarea name="help_needed" rows="3" placeholder="没有可以不填"></textarea></label>
<button type="submit">提交日报</button>
<p id="form-message" class="message"></p>
</form>
<section id="previous-plan" class="panel previous-plan" hidden>
<div>
<h2>上次明日计划参考</h2>
<p id="previous-plan-date"></p>
</div>
<pre id="previous-plan-content"></pre>
<button id="use-previous-plan" type="button">填入今日完成</button>
</section>
<section class="panel history-panel">
<div class="history-head">
<div>
<h2>我的历史日报</h2>
<p>{history_hint}</p>
</div>
<button id="load-history" type="button">刷新历史</button>
</div>
<div id="history-list" class="history-list">暂无历史记录</div>
</section>
</main>
<script>
const statusLabels = {{
normal: "正常",
risk: "有风险",
need_help: "需要支持"
}};
function escapeHtml(value) {{
return String(value || "").replace(/[&<>"']/g, (char) => ({{
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}}[char]));
}}
function collectItems(name) {{
const values = Array.from(document.querySelectorAll(`[data-list="${{name}}"] input`))
.map((input) => input.value.trim())
.filter(Boolean);
return values.map((value, index) => `${{index + 1}}. ${{value}}`).join("\\n");
}}
function renumberItems(name) {{
Array.from(document.querySelectorAll(`[data-list="${{name}}"] .item-row`)).forEach((row, index) => {{
row.querySelector(".item-index").textContent = `${{index + 1}}.`;
row.querySelector("input").placeholder = ` ${{index + 1}} `;
}});
}}
function addItem(name, value = "") {{
const list = document.querySelector(`[data-list="${{name}}"]`);
const row = document.createElement("div");
row.className = "item-row";
row.innerHTML = `
<span class="item-index"></span>
<input value="${{escapeHtml(value)}}" autocomplete="off">
<button class="remove-item" type="button" aria-label="删除这一条">×</button>
`;
row.querySelector(".remove-item").addEventListener("click", () => {{
if (list.querySelectorAll(".item-row").length <= 1) {{
row.querySelector("input").value = "";
return;
}}
row.remove();
renumberItems(name);
}});
list.appendChild(row);
renumberItems(name);
}}
function resetItems(name, values = []) {{
const list = document.querySelector(`[data-list="${{name}}"]`);
list.innerHTML = "";
const items = values.length ? values : ["", "", ""];
items.forEach((value) => addItem(name, value));
}}
function fillItemInputs(name, text) {{
const lines = String(text || "")
.split(/\\n+/)
.map((line) => line.replace(/^\\s*\\d+[\\.)]\\s*/, "").trim())
.filter(Boolean);
resetItems(name, lines);
}}
function renderHistory(data) {{
const container = document.querySelector("#history-list");
if (!data.reports.length) {{
container.innerHTML = "<p>还没有历史日报。</p>";
return;
}}
container.innerHTML = data.reports.map((report) => `
<article class="history-card">
<h3>${{escapeHtml(report.report_date)}} · ${{statusLabels[report.report_status] || "正常"}}</h3>
<div><strong>今日完成</strong><pre>${{escapeHtml(report.today_done)}}</pre></div>
<div><strong>明日计划</strong><pre>${{escapeHtml(report.tomorrow_plan)}}</pre></div>
<div><strong>问题</strong><pre>${{escapeHtml(report.blockers || "")}}</pre></div>
<div><strong>协助</strong><pre>${{escapeHtml(report.help_needed || "")}}</pre></div>
</article>
`).join("");
}}
async function loadHistory() {{
const userId = document.querySelector("#feishu-user-id").value.trim();
const container = document.querySelector("#history-list");
if (!userId) {{
container.textContent = "请先填写员工 ID。";
return;
}}
const response = await fetch(`/api/reports/history?feishu_user_id=${{encodeURIComponent(userId)}}`);
if (response.ok) {{
renderHistory(await response.json());
}} else {{
const result = await response.json();
container.textContent = result.error || "历史记录加载失败。";
}}
}}
async function loadPreviousPlan() {{
const userId = document.querySelector("#feishu-user-id").value.trim();
const reportDate = document.querySelector("#report-date").value;
const panel = document.querySelector("#previous-plan");
if (!userId || !reportDate) {{
panel.hidden = true;
return;
}}
const response = await fetch(`/api/reports/previous?feishu_user_id=${{encodeURIComponent(userId)}}&date=${{encodeURIComponent(reportDate)}}`);
if (!response.ok) {{
panel.hidden = true;
return;
}}
const result = await response.json();
if (!result.report) {{
panel.hidden = true;
return;
}}
document.querySelector("#previous-plan-date").textContent = `${{result.report.report_date}} 提交`;
document.querySelector("#previous-plan-content").textContent = result.report.tomorrow_plan;
panel.hidden = false;
}}
document.querySelector("#load-history").addEventListener("click", loadHistory);
document.querySelector("#report-date").addEventListener("change", loadPreviousPlan);
document.querySelectorAll("[data-add-list]").forEach((button) => {{
button.addEventListener("click", () => {{
addItem(button.dataset.addList);
}});
}});
document.querySelector("#use-previous-plan").addEventListener("click", () => {{
fillItemInputs("today_done", document.querySelector("#previous-plan-content").textContent);
}});
document.querySelector("#report-form").addEventListener("submit", async (event) => {{
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
data.today_done = collectItems("today_done");
data.tomorrow_plan = collectItems("tomorrow_plan");
const response = await fetch("/api/reports", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify(data)
}});
const message = document.querySelector("#form-message");
if (response.ok) {{
message.textContent = "提交成功。";
message.className = "message success";
loadHistory();
loadPreviousPlan();
}} else {{
const result = await response.json();
message.textContent = result.error || "提交失败。";
message.className = "message error";
}}
}});
resetItems("today_done");
resetItems("tomorrow_plan");
loadHistory();
loadPreviousPlan();
</script>""",
)
def manager_page(current_date: str) -> bytes:
return page(
"日报浏览",
f"""
<main class="shell">
<header class="topbar">
<div>
<h1>日报浏览</h1>
<p>按日期员工和关键词浏览所有日报记录</p>
</div>
<button id="copy-summary">复制今日汇总</button>
</header>
<section class="filters">
<input id="date-filter" type="date" value="{escape(current_date)}">
<input id="employee-filter" placeholder="筛选员工">
<input id="keyword-filter" placeholder="搜索日报内容">
<label class="check"><input id="blocker-filter" type="checkbox"> 只看问题/协助</label>
<a id="export-link" href="/api/reports/export?date={escape(current_date)}">导出 CSV</a>
</section>
<section id="stats" class="stats"></section>
<section id="reports" class="report-list"></section>
<section>
<h2>未提交人员</h2>
<div id="missing" class="missing"></div>
</section>
</main>""",
'<script src="/static/manager.js"></script>',
)
def forbidden_page() -> bytes:
return page(
"无权限",
"""
<main class="shell narrow">
<section class="panel">
<h1>无权限</h1>
<p>你没有查看日报汇总的权限请联系管理员开通</p>
<p><a href="/submit">返回日报填写页</a></p>
</section>
</main>""",
)
class DailyReportHandler(BaseHTTPRequestHandler):
report_service: ReportService
config: Config
static_dir: Path
def log_message(self, format: str, *args: object) -> None:
return
def _send(self, status: int, body: bytes, content_type: str = "text/html; charset=utf-8") -> None:
self.send_response(status)
self.send_header("content-type", content_type)
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, status: int, data: dict) -> None:
self._send(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def _read_json(self) -> dict:
length = int(self.headers.get("content-length", "0"))
if length == 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def _cookie(self, name: str) -> str | None:
for item in self.headers.get("cookie", "").split(";"):
if "=" not in item:
continue
key, value = item.strip().split("=", 1)
if key == name:
return value
return None
def _session(self) -> dict[str, str] | None:
return parse_session_cookie(self._cookie("daily_report_session"), self.config.session_secret)
def _oauth_enabled(self) -> bool:
return bool(self.config.feishu_app_id and self.config.feishu_app_secret)
def _is_admin_session(self) -> bool:
session = self._session()
return bool(session and self.report_service.is_admin(session["feishu_user_id"]))
def _require_admin(self) -> bool:
if self._is_admin_session():
return True
self._send(403, forbidden_page())
return False
def do_GET(self) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if parsed.path == "/":
self.send_response(302)
self.send_header("location", "/manager")
self.end_headers()
return
if parsed.path == "/submit":
session = self._session()
if self._oauth_enabled() and not session:
self.send_response(302)
self.send_header("location", "/auth/feishu/start")
self.end_headers()
return
self._send(200, submit_page(today_string(), session))
return
if parsed.path == "/auth/feishu/start":
state = create_state()
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
self.send_response(302)
self.send_header("set-cookie", f"daily_report_oauth_state={state}; Path=/; HttpOnly; SameSite=Lax")
self.send_header("location", authorize_url(self.config.feishu_app_id, redirect_uri, state))
self.end_headers()
return
if parsed.path == "/auth/feishu/callback":
state = query.get("state", [""])[0]
code = query.get("code", [""])[0]
if not code or not state or state != self._cookie("daily_report_oauth_state"):
self._json(400, {"error": "invalid feishu oauth callback"})
return
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
token = exchange_code_for_user_access_token(
self.config.feishu_app_id,
self.config.feishu_app_secret,
code,
redirect_uri,
)
employee = fetch_user_info(token)
self.report_service.database.upsert_employee(employee)
cookie = create_session_cookie(
{"feishu_user_id": employee["feishu_user_id"], "name": employee["name"]},
self.config.session_secret,
)
self.send_response(302)
self.send_header("set-cookie", f"daily_report_session={cookie}; Path=/; HttpOnly; SameSite=Lax")
self.send_header("set-cookie", "daily_report_oauth_state=; Path=/; Max-Age=0")
self.send_header("location", "/submit")
self.end_headers()
return
if parsed.path == "/manager":
if not self._require_admin():
return
self._send(200, manager_page(query.get("date", [today_string()])[0]))
return
if parsed.path == "/api/reports":
if not self._require_admin():
return
self._json(200, self.report_service.list_reports_for_date(query.get("date", [today_string()])[0]))
return
if parsed.path == "/api/reports/history":
user_id = query.get("feishu_user_id", [""])[0]
try:
self._json(200, self.report_service.list_employee_history(user_id))
except ValueError as error:
self._json(400, {"error": str(error)})
return
if parsed.path == "/api/reports/previous":
user_id = query.get("feishu_user_id", [""])[0]
report_date = query.get("date", [today_string()])[0]
try:
self._json(200, self.report_service.previous_report_reference(user_id, report_date))
except ValueError as error:
self._json(400, {"error": str(error)})
return
if parsed.path == "/api/reports/export":
if not self._require_admin():
return
report_date = query.get("date", [today_string()])[0]
csv_text = self.report_service.export_reports_csv(report_date)
self.send_response(200)
self.send_header("content-type", "text/csv; charset=utf-8")
self.send_header("content-disposition", f'attachment; filename="daily-reports-{report_date}.csv"')
self.end_headers()
self.wfile.write(csv_text.encode("utf-8-sig"))
return
if parsed.path.startswith("/static/"):
self._serve_static(parsed.path.removeprefix("/static/"))
return
self._json(404, {"error": "not found"})
def do_POST(self) -> None:
parsed = urlparse(self.path)
try:
if parsed.path == "/api/reports":
self._json(200, {"report": self.report_service.upsert_report(self._read_json())})
return
if parsed.path == "/api/robot/send-reminder":
payload = create_reminder_payload(f"{self.config.base_url}/submit")
self._json(
200,
{
"ok": True,
"result": send_webhook(
self.config.feishu_webhook_url,
payload,
self.config.feishu_webhook_secret,
),
},
)
return
if parsed.path == "/api/robot/send-summary":
body = self._read_json()
report_date = body.get("date") or today_string()
summary = self.report_service.list_reports_for_date(report_date)
payload = create_summary_payload(f"{self.config.base_url}/manager?date={report_date}", summary)
self._json(
200,
{
"ok": True,
"result": send_webhook(
self.config.feishu_webhook_url,
payload,
self.config.feishu_webhook_secret,
),
},
)
return
self._json(404, {"error": "not found"})
except (ValueError, json.JSONDecodeError) as error:
self._json(400, {"error": str(error)})
except Exception as error:
self._json(500, {"error": str(error)})
def _serve_static(self, name: str) -> None:
safe_name = name.replace("\\", "/").split("/")[-1]
file_path = self.static_dir / safe_name
if not file_path.exists():
self._json(404, {"error": "not found"})
return
content_types = {
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
}
content_type = content_types.get(file_path.suffix.lower(), "application/octet-stream")
self._send(200, file_path.read_bytes(), content_type)
def create_server(config: Config, database: Database | None = None) -> ThreadingHTTPServer:
db = database or Database(config.database_path, config.employee_seed_path)
service = ReportService(db)
class Handler(DailyReportHandler):
report_service = service
static_dir = config.root_dir / "daily_report" / "static"
Handler.config = config
server = ThreadingHTTPServer(("", config.port), Handler)
server.database = db # type: ignore[attr-defined]
return server
def main() -> None:
config = read_config()
server = create_server(config)
print(f"Daily report app listening on {config.base_url}")
try:
server.serve_forever()
finally:
server.server_close()
server.database.close() # type: ignore[attr-defined]
if __name__ == "__main__":
main()

View File

@ -1,21 +0,0 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
def is_workday(selected_date: date, calendar_path: Path) -> bool:
if not calendar_path.exists():
return selected_date.weekday() < 5
calendar = json.loads(calendar_path.read_text(encoding="utf-8"))
day = selected_date.isoformat()
holidays = set(calendar.get("holidays", []))
makeup_workdays = set(calendar.get("makeup_workdays", []))
if day in makeup_workdays:
return True
if day in holidays:
return False
return selected_date.weekday() < 5

View File

@ -1,18 +0,0 @@
[
{
"feishu_user_id": "u_alice",
"name": "Alice",
"department": "Operations",
"manager": "Manager",
"role": "staff",
"active": true
},
{
"feishu_user_id": "u_bob",
"name": "Bob",
"department": "Operations",
"manager": "Manager",
"role": "staff",
"active": true
}
]

View File

@ -1,47 +0,0 @@
{
"year": 2026,
"source": "https://www.gov.cn/yaowen/liebiao/202511/content_7047099.htm",
"holidays": [
"2026-01-01",
"2026-01-02",
"2026-01-03",
"2026-02-15",
"2026-02-16",
"2026-02-17",
"2026-02-18",
"2026-02-19",
"2026-02-20",
"2026-02-21",
"2026-02-22",
"2026-02-23",
"2026-04-04",
"2026-04-05",
"2026-04-06",
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
"2026-05-05",
"2026-06-19",
"2026-06-20",
"2026-06-21",
"2026-09-25",
"2026-09-26",
"2026-09-27",
"2026-10-01",
"2026-10-02",
"2026-10-03",
"2026-10-04",
"2026-10-05",
"2026-10-06",
"2026-10-07"
],
"makeup_workdays": [
"2026-01-04",
"2026-02-14",
"2026-02-28",
"2026-05-09",
"2026-09-20",
"2026-10-10"
]
}

View File

@ -0,0 +1,20 @@
module.exports = {
apps: [
{
name: "video-hotness",
script: "src/server.js",
cwd: "/www/video-hotness",
interpreter: "node",
env: {
NODE_ENV: "production",
HOST: "0.0.0.0",
PORT: "3000",
HOTNESS_ACCESS_PASSWORD: "CHANGE_ME",
HOTNESS_DATA_DIR: "/www/video-hotness/data",
},
max_restarts: 10,
restart_delay: 3000,
time: true,
},
],
};

View File

@ -0,0 +1,18 @@
[Unit]
Description=Video Hotness Collection Service
After=network.target
[Service]
Type=simple
WorkingDirectory=/www/video-hotness
ExecStart=/usr/bin/node /www/video-hotness/src/server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=3000
Environment=HOTNESS_ACCESS_PASSWORD=CHANGE_ME
Environment=HOTNESS_DATA_DIR=/www/video-hotness/data
[Install]
WantedBy=multi-user.target

View File

@ -1,139 +0,0 @@
# 飞书每日工作汇报系统实施计划
> **给执行代理的要求:** 实施本计划时必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框语法,便于逐项跟踪。
**目标:** 构建第一版飞书日报 Web 应用,让员工提交每日报告,让管理者在一个页面浏览所有日报记录。
**架构:** 使用 Python 标准库实现一个零第三方依赖的小型 Web 应用。后端使用 `http.server` 提供页面和 API使用 `sqlite3` 保存员工与日报数据,使用 `urllib.request` 调用飞书机器人 Webhook。前端使用原生 HTML/CSS/JavaScript。
**技术栈:** Python 标准库、`http.server``sqlite3``unittest`、原生 HTML/CSS/JavaScript、飞书自定义机器人 Webhook。
---
## 文件结构
- `requirements.txt`:说明项目只使用 Python 标准库。
- `.env.example`:环境变量示例。
- `.gitignore`:忽略本地数据库、环境变量和缓存文件。
- `data/employees.json`:第一版员工名单种子文件。
- `daily_report/config.py`:读取环境变量和 `.env` 配置。
- `daily_report/db.py`SQLite 建表、员工导入和基础查询。
- `daily_report/report_service.py`:日报校验、创建/更新、列表、未提交人员和 CSV 导出。
- `daily_report/robot_service.py`:飞书机器人提醒/汇总消息体和 Webhook 发送。
- `daily_report/web.py`HTTP 页面路由、API 路由和静态文件服务。
- `daily_report/static/styles.css`:提交页和管理页样式。
- `daily_report/static/manager.js`:管理页筛选、复制汇总和数据加载逻辑。
- `tests/test_report_service.py`:日报业务测试。
- `tests/test_robot_service.py`:机器人消息体测试。
- `tests/test_web.py`:页面和 API 冒烟测试。
- `README.md`:中文使用说明。
## 任务 1项目骨架
- [x] 创建 Python 包 `daily_report`
- [x] 创建 `requirements.txt`,说明无需第三方依赖。
- [x] 创建 `.gitignore`
- [x] 创建 `.env.example`
- [x] 创建 `data/employees.json` 示例员工名单。
- [x] 实现 `daily_report/config.py`,读取端口、基础 URL、数据库路径、员工名单路径和飞书 Webhook 配置。
验证:
```bash
python -m py_compile daily_report/config.py
```
## 任务 2数据层和日报服务
- [x] 实现 `daily_report/db.py`
- [x] 创建 `employees` 表。
- [x] 创建 `daily_reports` 表。
- [x] 导入 `data/employees.json` 中的有效员工。
- [x] 实现同一员工同一天唯一日报。
- [x] 实现 `daily_report/report_service.py`
- [x] 校验必填字段:员工 ID、日期、今日完成、明日计划。
- [x] 重复提交时更新当天已有日报。
- [x] 计算已提交、未提交和应提交人数。
- [x] 导出 CSV。
- [x] 编写 `tests/test_report_service.py`
验证:
```bash
python -m unittest tests.test_report_service
```
## 任务 3飞书机器人服务
- [x] 实现 `daily_report/robot_service.py`
- [x] 生成“填写日报”提醒消息体。
- [x] 生成“日报提交汇总”消息体。
- [x] 支持调用飞书自定义机器人 Webhook。
- [x] 编写 `tests/test_robot_service.py`
验证:
```bash
python -m unittest tests.test_robot_service
```
## 任务 4页面和 API
- [x] 实现 `daily_report/web.py`
- [x] 提供 `/submit` 员工日报提交页。
- [x] 提供 `/manager` 管理者日报浏览页。
- [x] 提供 `POST /api/reports` 创建或更新日报。
- [x] 提供 `GET /api/reports?date=YYYY-MM-DD` 查询某日汇总。
- [x] 提供 `GET /api/reports/export?date=YYYY-MM-DD` 导出 CSV。
- [x] 提供 `POST /api/robot/send-reminder` 发送飞书提醒。
- [x] 提供 `POST /api/robot/send-summary` 发送飞书汇总。
- [x] 实现 `daily_report/static/styles.css`
- [x] 实现 `daily_report/static/manager.js`
- [x] 编写 `tests/test_web.py`
验证:
```bash
python -m unittest tests.test_web
```
## 任务 5文档和手动验证
- [x] 编写中文 `README.md`
- [x] 更新设计文档中的实际技术方案。
- [x] 启动本地服务。
- [x] 验证管理页可以访问。
- [x] 验证日报提交 API 可以写入数据。
- [x] 验证日报汇总 API 可以读取数据。
启动:
```bash
python -m daily_report.web
```
访问:
- 提交页http://localhost:8787/submit
- 管理页http://localhost:8787/manager
## 最终验证
```bash
python -m unittest discover -s tests
python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/web.py
```
当前验证结果:
- `python -m unittest discover -s tests`8 个测试通过。
- `python -m py_compile ...`:通过。
- `http://localhost:8787/manager`:返回 200。
## 后续增强
- 接入飞书 OAuth自动识别员工身份。
- 增加管理者登录和权限控制。
- 增加飞书多维表格同步。
- 增加定时任务,让提醒和汇总自动发送。
- 增加员工名单管理页面,减少手工编辑 JSON。

View File

@ -1,212 +0,0 @@
# 飞书每日工作汇报系统设计
## 目标
建设一个轻量、实用的飞书每日工作汇报系统。员工每天通过飞书机器人提醒进入填写页面提交日报,管理者可以在一个内部浏览页面查看所有人的日报记录。
第一版优先保证快速上线、流程顺畅、后续容易扩展。核心能力包括:每日提醒、员工填写、数据保存、管理者统一浏览、未提交统计和每日汇总。
## 使用角色
- 员工:每天提交一份工作日报。
- 管理者:查看日报、检查未提交人员、复制或导出汇总。
- 管理员:维护员工名单和飞书机器人配置。
## 推荐方案
采用“飞书机器人 + 小型 Web 应用”的方案。
- 飞书机器人每天在指定群发送日报提醒。
- 提醒消息里包含“填写日报”的按钮或链接。
- 员工打开填写页面并提交日报。
- 管理者打开浏览页面,在一个页面里查看所有记录。
- 日报数据由应用保存,后续可以按需同步到飞书多维表格。
这个方案比只使用多维表格更适合阅读和管理,也比完整飞书自建应用更容易启动。
## 日报字段
第一版采用标准四段式日报模板:
- 员工姓名和飞书用户 ID
- 汇报日期
- 今日完成
- 明日计划
- 遇到的问题或阻塞
- 需要协助的事项
- 提交时间
- 更新时间
- 提交状态
必填字段:
- 员工身份
- 汇报日期
- 今日完成
- 明日计划
选填字段:
- 遇到的问题或阻塞
- 需要协助的事项
如果员工当天重复提交,系统更新当天已有记录,不额外创建重复日报。
## 飞书流程
1. 到达配置的提醒时间,例如 18:00机器人在工作汇报群发送日报提醒。
2. 员工点击提醒消息中的“填写日报”。
3. 系统打开日报填写页面,并默认选择当天日期。
4. 员工填写四段式日报并提交。
5. 后端校验并保存日报。
6. 页面显示提交成功状态。
7. 到达配置的汇总时间,例如 19:00机器人在群里发送完成情况汇总
- 应提交人数
- 已提交人数
- 未提交人员名单
- 管理者浏览页面链接
第一版可以先使用带签名的链接或简单的员工选择方式识别员工。后续版本再接入飞书 OAuth自动识别当前登录用户。
## 管理者浏览页面
管理者浏览页是日常查看日报的主工作台,打开后默认展示当天日报。
筛选控件:
- 日期选择
- 员工筛选
- 部门筛选,如果已配置部门
- 关键词搜索
- 只看未提交
- 只看有问题或需要协助的日报
主要内容:
- 当前日期的提交统计
- 按员工分组展示已提交日报
- 未提交人员名单
- 每条日报展示今日完成、明日计划、问题、协助需求和提交时间
管理操作:
- 复制今日汇总
- 导出 CSV
- 打开原始数据表
页面应优先保证阅读体验,而不是做成纯表格。管理者应该能在一个页面里快速扫完当天所有日报。
## 数据模型
### employees
- id
- feishu_user_id
- name
- department
- manager
- active
- created_at
- updated_at
### daily_reports
- id
- feishu_user_id
- employee_name
- report_date
- today_done
- tomorrow_plan
- blockers
- help_needed
- submitted_at
- updated_at
唯一约束:
- 同一个 `feishu_user_id` 在同一个 `report_date` 只能有一条日报
## API 设计
### 员工接口
- `GET /submit`
- 展示日报填写页面。
- `POST /api/reports`
- 创建或更新某员工某日期的日报。
### 管理者接口
- `GET /manager`
- 展示日报浏览页面。
- `GET /api/reports?date=YYYY-MM-DD`
- 返回指定日期的已提交日报和未提交人员。
- `GET /api/reports/export?date=YYYY-MM-DD`
- 导出指定日期的日报 CSV。
### 机器人接口
- `POST /api/robot/send-reminder`
- 向飞书群发送日报提醒。
- `POST /api/robot/send-summary`
- 向飞书群发送日报完成情况汇总。
这些接口第一版可以由本地定时任务或手动脚本调用。
## 技术方案
第一版实际技术栈:
- 后端Python 标准库 `http.server`
- 前端:原生 HTML/CSS/JavaScript
- 数据库SQLite
- 飞书集成:飞书自定义机器人 Webhook用于发送提醒和汇总
Python 标准库方案不依赖 npm 或第三方包适合当前本地环境快速运行。SQLite 足够支撑第一版,并且部署简单。如果后续多人并发或权限要求增强,可以迁移到 PostgreSQL用户流程不需要变化。
## 错误处理
- 必填字段缺失时,在表单内展示校验错误。
- 当天重复提交时,更新已有日报。
- 机器人发送失败时记录日志,并支持手动重试。
- 管理页在指定日期没有日报时展示空状态。
- 无法识别员工身份时,提交页要求员工从有效员工名单中选择姓名。
## 安全设计
第一版:
- 使用难以猜测的提交链接,或仅在公司内部网络/内部成员范围内访问。
- 管理页面只在内部使用。
- 飞书 Webhook 密钥不写入源代码。
后续版本:
- 接入飞书 OAuth 自动识别员工身份。
- 增加管理者权限控制。
- 增加日报编辑审计日志。
## 测试范围
核心检查项:
- 可以提交一份完整日报。
- 缺少必填字段时拒绝提交。
- 同一天重复提交会更新原记录。
- 管理页能展示某日期的已提交和未提交人员。
- 管理页能按员工和关键词筛选。
- 可以导出 CSV。
- 可以生成提醒消息和汇总消息。
## 里程碑
1. 搭建本地数据模型和日报提交流程。
2. 搭建管理者浏览页面。
3. 通过种子文件或简单配置维护员工名单。
4. 接入飞书机器人提醒和汇总发送。
5. 增加 CSV 导出和复制汇总功能。
6. 可选:接入飞书 OAuth 和多维表格同步。

View File

@ -1,20 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
set "LOG=%~dp0install-tasks.log"
echo Installing Windows scheduled tasks...
echo Installing Windows scheduled tasks... > "%LOG%"
echo Project: %CD% >> "%LOG%"
echo. >> "%LOG%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\install-windows-tasks.ps1" >> "%LOG%" 2>&1
type "%LOG%"
echo.
echo If there is no error above, tasks are installed:
echo - FeishuDailyReport-Reminder runs daily at 18:00 and skips holidays
echo - FeishuDailyReport-Summary runs daily at 19:00 and skips holidays
echo.
echo If there is an error, send Codex a screenshot.
pause

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"
}
}

2202
public/app.js Normal file

File diff suppressed because it is too large Load Diff

267
public/index.html Normal file
View File

@ -0,0 +1,267 @@
<!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>
<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>
<section id="task-queue-panel" class="task-queue-panel brand-task-queue 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>
</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="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>
<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>
</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");
}
}

829
public/mobile.css Normal file
View File

@ -0,0 +1,829 @@
: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,
.device-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;
}
.device-settings-panel {
padding: 0;
overflow: hidden;
}
.device-settings-panel > summary {
min-height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
color: var(--accent);
font-weight: 900;
cursor: pointer;
list-style: none;
}
.device-settings-panel > summary::-webkit-details-marker {
display: none;
}
.device-settings-panel > summary::before {
content: ">";
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
transition: transform 0.16s ease;
}
.device-settings-panel[open] > summary::before {
transform: rotate(90deg);
}
.device-settings-panel > summary span:first-child {
margin-right: auto;
}
.device-settings-panel .device-panel,
.device-settings-panel .app-settings-panel,
.device-settings-panel .install-hint,
.device-settings-panel .network {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
margin: 0;
background: transparent;
}
.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 1fr;
gap: 10px;
margin-top: 12px;
}
#collect-button {
grid-column: 1 / -1;
}
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);
}
.mobile-status-stack {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.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;
}
.mobile-status-stack .offline-status {
margin-bottom: 0;
}
.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;
}
@media (max-width: 420px) {
.mobile-shell {
padding-left: 10px;
padding-right: 10px;
}
.mobile-header {
align-items: flex-start;
}
h1 {
font-size: 20px;
}
.actions,
.setting-actions,
.device-panel,
.install-hint {
grid-template-columns: 1fr;
}
.draft-actions {
display: grid;
grid-template-columns: 1fr;
}
.desktop-link,
.secondary,
button {
min-height: 42px;
}
}

155
public/mobile.html Normal file
View File

@ -0,0 +1,155 @@
<!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>
<main class="mobile-shell">
<header class="mobile-header">
<div>
<h1>节目热度采集</h1>
<p>移动录入版</p>
</div>
<a class="desktop-link" href="/">桌面版</a>
</header>
<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>
<div class="mobile-status-stack">
<section id="offline-status" class="offline-status">
<strong>离线录入</strong>
<span>首次在局域网打开后,会缓存手机版;之后可离线打开并保存待同步。</span>
</section>
</div>
<details class="device-settings-panel">
<summary>
<span>设备与同步设置</span>
<span id="mobile-app-state" class="app-state">检测中</span>
</summary>
<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>
</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>
<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>
</details>
<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>

795
public/mobile.js Normal file
View File

@ -0,0 +1,795 @@
const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1";
const form = document.querySelector("#collect-form");
const input = document.querySelector("#program-name");
const button = document.querySelector("#collect-button");
const exportLink = document.querySelector("#export-link");
const statusDot = document.querySelector("#status-dot");
const statusText = document.querySelector("#status-text");
const tableTitle = document.querySelector("#table-title");
const runCount = document.querySelector("#run-count");
const 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");
});
consumeRedirectedAccessToken();
initializeApp();
async function initializeApp() {
if (!(await ensureAccessAuth())) return;
startApp();
}
function consumeRedirectedAccessToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get("access_token");
if (!token) return;
localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, token);
params.delete("access_token");
const search = params.toString();
const cleanUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
history.replaceState(null, "", cleanUrl || "/");
}
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) {
return true;
}
} catch {
return true;
}
redirectToLogin();
return false;
}
function authHeaders() {
const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || "";
return token ? { "x-hotness-auth-token": token } : {};
}
function handleAuthFailure(response, payload) {
if (response.status !== 401 || !payload?.requires_auth) return false;
localStorage.removeItem(HOTNESS_AUTH_TOKEN_KEY);
redirectToLogin();
return true;
}
function redirectToLogin() {
window.location.href = "/mobile.html";
}
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;
}
}

455
public/rankings.js Normal file
View File

@ -0,0 +1,455 @@
const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1";
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) {
window.addEventListener("hotness:auth-updated", () => init());
init();
}
async function init() {
render();
try {
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} 个节目`;
}
} catch (error) {
state.message = error.requiresAuth ? "请先输入访问密码" : error.message;
}
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, { headers: authHeaders() });
return parseApiResponse(response);
}
async function apiPost(path, payload) {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders() },
body: JSON.stringify(payload),
});
return parseApiResponse(response);
}
async function parseApiResponse(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || "request failed");
error.requiresAuth = Boolean(data.requires_auth || response.status === 401);
throw error;
}
return data;
}
function authHeaders() {
const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || "";
return token ? { "x-hotness-auth-token": token } : {};
}
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;");
}

1677
public/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
# This project uses only the Python standard library.

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="/root/kaikai_test"
cat > /etc/systemd/system/kaikai-daily-reminder.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Reminder
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled reminder
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-reminder.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Reminder at 18:00
[Timer]
OnCalendar=*-*-* 18:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > /etc/systemd/system/kaikai-daily-reminder-followup.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Follow-up Reminder
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled reminder
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-reminder-followup.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Follow-up Reminder at 18:50
[Timer]
OnCalendar=*-*-* 18:50:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > /etc/systemd/system/kaikai-daily-summary.service <<'EOF'
[Unit]
Description=Kaikai Daily Report Summary
[Service]
WorkingDirectory=/root/kaikai_test
ExecStart=/usr/bin/python3 -m daily_report.scheduled summary
Type=oneshot
EOF
cat > /etc/systemd/system/kaikai-daily-summary.timer <<'EOF'
[Unit]
Description=Run Kaikai Daily Report Summary at 19:00
[Timer]
OnCalendar=*-*-* 19:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now kaikai-daily-reminder.timer
systemctl enable --now kaikai-daily-reminder-followup.timer
systemctl enable --now kaikai-daily-summary.timer
systemctl list-timers --all | grep kaikai || true

View File

@ -1,48 +0,0 @@
param(
[string]$PythonPath = "C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe",
[string]$TaskPrefix = "FeishuDailyReport",
[string]$ReminderTime = "18:00",
[string]$SummaryTime = "19:00"
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
if (-not (Test-Path $PythonPath)) {
throw "Python not found: $PythonPath"
}
$reminderAction = New-ScheduledTaskAction `
-Execute $PythonPath `
-Argument "-m daily_report.scheduled reminder" `
-WorkingDirectory $ProjectRoot
$summaryAction = New-ScheduledTaskAction `
-Execute $PythonPath `
-Argument "-m daily_report.scheduled summary" `
-WorkingDirectory $ProjectRoot
$reminderTrigger = New-ScheduledTaskTrigger -Daily -At $ReminderTime
$summaryTrigger = New-ScheduledTaskTrigger -Daily -At $SummaryTime
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask `
-TaskName "$TaskPrefix-Reminder" `
-Action $reminderAction `
-Trigger $reminderTrigger `
-Settings $settings `
-Description "Runs daily at $ReminderTime; script sends only on configured workdays" `
-Force | Out-Null
Register-ScheduledTask `
-TaskName "$TaskPrefix-Summary" `
-Action $summaryAction `
-Trigger $summaryTrigger `
-Settings $settings `
-Description "Runs daily at $SummaryTime; script sends only on configured workdays" `
-Force | Out-Null
Write-Host "Windows scheduled tasks installed:"
Write-Host " - $TaskPrefix-Reminder: runs daily at $ReminderTime and skips non-workdays"
Write-Host " - $TaskPrefix-Summary: runs daily at $SummaryTime and skips non-workdays"
Write-Host "Project root: $ProjectRoot"

View File

@ -1,15 +0,0 @@
param(
[string]$TaskPrefix = "FeishuDailyReport"
)
$ErrorActionPreference = "Stop"
foreach ($name in @("$TaskPrefix-Reminder", "$TaskPrefix-Summary")) {
$task = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false
Write-Host "Removed task: $name"
} else {
Write-Host "Task not found: $name"
}
}

View File

@ -1,26 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
set "LOG=%~dp0send-reminder.log"
echo Sending Feishu reminder...
echo Sending Feishu reminder... > "%LOG%"
echo Project: %CD% >> "%LOG%"
echo Python: %PY% >> "%LOG%"
echo. >> "%LOG%"
if not exist "%PY%" (
echo Python not found: %PY%
echo Python not found: %PY% >> "%LOG%"
goto END
)
"%PY%" -m daily_report.scheduled reminder >> "%LOG%" 2>&1
type "%LOG%"
:END
echo.
echo Log file: %LOG%
echo Please check the Feishu group. If there is an error, send Codex a screenshot.
pause

View File

@ -1,26 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
set "LOG=%~dp0send-summary.log"
echo Sending Feishu summary...
echo Sending Feishu summary... > "%LOG%"
echo Project: %CD% >> "%LOG%"
echo Python: %PY% >> "%LOG%"
echo. >> "%LOG%"
if not exist "%PY%" (
echo Python not found: %PY%
echo Python not found: %PY% >> "%LOG%"
goto END
)
"%PY%" -m daily_report.scheduled summary >> "%LOG%" 2>&1
type "%LOG%"
:END
echo.
echo Log file: %LOG%
echo Please check the Feishu group. If there is an error, send Codex a screenshot.
pause

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

1184
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

@ -1,20 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
echo Starting Feishu daily report server...
echo Project: %CD%
echo.
if not exist "%PY%" (
echo Python not found: %PY%
echo Send Codex a screenshot.
pause
exit /b 1
)
"%PY%" -m daily_report.web
echo.
echo Server stopped.
pause

View File

@ -0,0 +1,93 @@
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");
const rankingsJs = await readFile(new URL("../public/rankings.js", 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, /\/auth\/login/);
assert.match(server, /sendLoginPage/);
assert.match(server, /isProtectedAppPage/);
assert.match(server, /readFormBody/);
assert.match(server, /sendRedirect/);
assert.match(server, /isAuthorizedRequest/);
assert.match(server, /sendAuthRequired/);
assert.match(server, /x-hotness-auth-token/i);
});
test("server renders a standalone password page before protected app pages", () => {
assert.match(server, /isProtectedAppPage\(url\.pathname\)/);
assert.match(server, /!isAuthorizedRequest\(request, url\)/);
assert.match(server, /sendLoginPage\(response, url\)/);
assert.match(server, /name="next"/);
assert.match(server, /输入访问密码/);
});
test("desktop app page has no embedded password overlay after server-side auth", () => {
assert.doesNotMatch(desktopHtml, /id="auth-gate"/);
assert.doesNotMatch(desktopHtml, /id="auth-form"/);
assert.doesNotMatch(desktopHtml, /id="auth-password"/);
assert.match(desktopHtml, /id="collect-form"/);
assert.match(desktopHtml, /采集一次/);
assert.match(desktopJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(desktopJs, /ensureAccessAuth/);
assert.match(desktopJs, /authHeaders/);
assert.match(desktopJs, /redirectToLogin/);
assert.match(desktopJs, /x-hotness-auth-token/i);
});
test("desktop login form is not blocked by JavaScript", () => {
assert.doesNotMatch(desktopJs, /authForm\?\.addEventListener\("submit"/);
assert.doesNotMatch(desktopJs, /authSubmit\?\.addEventListener\("click"/);
assert.doesNotMatch(desktopJs, /async function submitAccessPassword/);
});
test("desktop can finish login from a redirected access token", () => {
assert.match(server, /buildAuthRedirectLocation/);
assert.match(server, /access_token/);
assert.match(desktopJs, /consumeRedirectedAccessToken/);
assert.match(desktopJs, /URLSearchParams/);
assert.match(desktopJs, /history\.replaceState/);
});
test("mobile app page has no embedded password overlay after server-side auth", () => {
assert.doesNotMatch(mobileHtml, /id="auth-gate"/);
assert.doesNotMatch(mobileHtml, /id="auth-form"/);
assert.doesNotMatch(mobileHtml, /id="auth-password"/);
assert.match(mobileHtml, /id="collect-form"/);
assert.match(mobileJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(mobileJs, /ensureAccessAuth/);
assert.match(mobileJs, /authHeaders/);
assert.match(mobileJs, /redirectToLogin/);
assert.match(mobileJs, /x-hotness-auth-token/i);
});
test("mobile login form is not blocked by JavaScript", () => {
assert.doesNotMatch(mobileJs, /authForm\?\.addEventListener\("submit"/);
assert.doesNotMatch(mobileJs, /authSubmit\?\.addEventListener\("click"/);
assert.doesNotMatch(mobileJs, /async function submitAccessPassword/);
});
test("mobile can finish login from a redirected access token", () => {
assert.match(mobileJs, /consumeRedirectedAccessToken/);
assert.match(mobileJs, /URLSearchParams/);
assert.match(mobileJs, /history\.replaceState/);
});
test("ranking radar requests respect the shared cloud login token", () => {
assert.match(rankingsJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(rankingsJs, /authHeaders/);
assert.match(rankingsJs, /x-hotness-auth-token/i);
assert.match(rankingsJs, /requires_auth/);
assert.match(rankingsJs, /hotness:auth-updated/);
});

View File

@ -0,0 +1,59 @@
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, /brand-task-queue/);
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(css, /\.brand-task-queue/);
assert.match(app, /updateTaskQueue/);
});
test("task queue sits directly under the top history collection controls", () => {
assert.ok(html.indexOf('id="collect-history-button"') < html.indexOf('id="task-queue-panel"'));
assert.ok(html.indexOf('id="task-queue-panel"') < html.indexOf('id="collect-form"'));
assert.ok(html.indexOf('id="task-queue-panel"') < html.indexOf('class="statusline"'));
});
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,59 @@
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/);
});
test("mobile layout prioritizes quick capture before device settings", () => {
assert.ok(html.indexOf('id="collect-form"') < html.indexOf('class="device-settings-panel"'));
assert.ok(html.indexOf('id="collect-form"') < html.indexOf('id="mobile-device-name"'));
assert.ok(html.indexOf('id="offline-status"') < html.indexOf('id="mobile-server-url"'));
assert.match(html, /<details class="device-settings-panel">/);
assert.match(css, /\.device-settings-panel/);
assert.match(css, /\.mobile-status-stack/);
assert.match(css, /#collect-button\s*\{/);
});

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

51
test/mobile-sync.test.js Normal file
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/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/);
});
test("desktop mobile sync queue sits at the bottom of the main app content", () => {
assert.ok(desktopHtml.indexOf('id="temporary-query-panel"') < desktopHtml.indexOf('id="mobile-sync-panel"'));
assert.ok(desktopHtml.indexOf('id="mobile-sync-panel"') < desktopHtml.indexOf("</main>"));
});

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

@ -1,42 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from daily_report import config
class ConfigTest(unittest.TestCase):
def test_reads_dotenv_and_allows_environment_override(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-config-") as temp:
root = Path(temp)
(root / ".env").write_text(
"\n".join(
[
"PORT=9000",
"BASE_URL=http://example.test",
"FEISHU_WEBHOOK_URL=https://open.feishu.cn/webhook",
"FEISHU_APP_ID=cli_xxx",
"FEISHU_APP_SECRET=secret",
]
),
encoding="utf-8",
)
fake_file = root / "daily_report" / "config.py"
fake_file.parent.mkdir()
fake_file.write_text("", encoding="utf-8")
with patch.object(config, "__file__", str(fake_file)):
result = config.read_config({"PORT": "9001"})
self.assertEqual(result.port, 9001)
self.assertEqual(result.base_url, "http://example.test")
self.assertEqual(result.feishu_webhook_url, "https://open.feishu.cn/webhook")
self.assertEqual(result.feishu_app_id, "cli_xxx")
self.assertEqual(result.feishu_app_secret, "secret")
if __name__ == "__main__":
unittest.main()

View File

@ -1,19 +0,0 @@
from __future__ import annotations
import unittest
from daily_report.feishu_auth import create_session_cookie, parse_session_cookie
class FeishuAuthTest(unittest.TestCase):
def test_session_cookie_round_trip_and_rejects_tampering(self) -> None:
cookie = create_session_cookie({"feishu_user_id": "ou_1", "name": "张三"}, "secret")
session = parse_session_cookie(cookie, "secret")
self.assertEqual(session["feishu_user_id"], "ou_1")
self.assertEqual(session["name"], "张三")
self.assertIsNone(parse_session_cookie(cookie + "x", "secret"))
if __name__ == "__main__":
unittest.main()

View File

@ -1,151 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from daily_report.db import Database
from daily_report.report_service import ReportService
def make_database(employees: list[dict]) -> Database:
temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-"))
seed_path = temp_dir / "employees.json"
seed_path.write_text(json.dumps(employees), encoding="utf-8")
return Database(temp_dir / "test.sqlite", seed_path)
class ReportServiceTest(unittest.TestCase):
def test_creates_schema_and_loads_active_employees(self) -> None:
db = make_database(
[
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "manager": "Mo", "active": True},
{"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "manager": "Mo", "active": False},
]
)
try:
employees = db.list_active_employees()
self.assertEqual(len(employees), 1)
self.assertEqual(employees[0]["name"], "Lin")
finally:
db.close()
def test_validates_required_report_fields(self) -> None:
db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "active": True}])
service = ReportService(db)
try:
with self.assertRaisesRegex(ValueError, "today_done is required"):
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"today_done": "",
"tomorrow_plan": "完善管理页",
}
)
finally:
db.close()
def test_upserts_one_report_per_employee_and_date(self) -> None:
db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}])
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
try:
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"report_status": "risk",
"today_done": "1. 完成原型初稿",
"tomorrow_plan": "1. 和主管评审",
}
)
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"report_status": "need_help",
"today_done": "1. 完成原型初稿\n2. 调整文案",
"tomorrow_plan": "1. 和主管评审",
"blockers": "需要确认 logo",
"help_needed": "确认视觉方向",
}
)
result = service.list_reports_for_date("2026-05-07")
self.assertEqual(len(result["reports"]), 1)
self.assertEqual(result["reports"][0]["report_status"], "need_help")
self.assertEqual(result["reports"][0]["today_done"], "1. 完成原型初稿\n2. 调整文案")
self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo")
self.assertEqual(len(result["missing"]), 0)
finally:
db.close()
def test_returns_missing_active_employees_csv_and_previous_reference(self) -> None:
db = make_database(
[
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True},
{"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "active": True},
]
)
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
try:
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-06",
"today_done": "1. 完成上线检查清单",
"tomorrow_plan": "1. 准备发布",
}
)
service.upsert_report(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"today_done": "1. 准备发布",
"tomorrow_plan": "1. 复盘问题",
}
)
result = service.list_reports_for_date("2026-05-07")
self.assertEqual(result["submittedCount"], 1)
self.assertEqual(result["missing"][0]["name"], "Chen")
csv_text = service.export_reports_csv("2026-05-07")
self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,report_status,submitted_at", csv_text)
self.assertIn("Lin,2026-05-07,1. 准备发布,1. 复盘问题,,,normal,", csv_text)
reference = service.previous_report_reference("u_1", "2026-05-07")
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 准备发布")
finally:
db.close()
def test_admins_are_not_required_to_submit_reports(self) -> None:
db = make_database(
[
{"feishu_user_id": "u_admin", "name": "Admin", "active": True, "role": "admin"},
{"feishu_user_id": "u_staff", "name": "Staff", "active": True, "role": "staff"},
]
)
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
try:
service.upsert_report(
{
"feishu_user_id": "u_admin",
"report_date": "2026-05-07",
"today_done": "1. 查看汇总",
"tomorrow_plan": "1. 继续跟进",
}
)
result = service.list_reports_for_date("2026-05-07")
self.assertEqual(result["expectedCount"], 1)
self.assertEqual(result["submittedCount"], 0)
self.assertEqual(result["missingCount"], 1)
self.assertEqual(result["missing"][0]["name"], "Staff")
self.assertEqual(result["reports"], [])
finally:
db.close()
if __name__ == "__main__":
unittest.main()

View File

@ -1,52 +0,0 @@
from __future__ import annotations
import json
import unittest
from daily_report.robot_service import create_bot_message_body, create_reminder_payload, create_summary_payload, create_webhook_sign
class RobotServiceTest(unittest.TestCase):
def test_creates_reminder_payload_with_submit_link(self) -> None:
payload = create_reminder_payload("http://localhost:8787/submit", "每日工作汇报提醒")
text = json.dumps(payload, ensure_ascii=False)
self.assertEqual(payload["msg_type"], "interactive")
self.assertIn("每日工作汇报提醒", text)
self.assertIn("http://localhost:8787/submit", text)
def test_creates_summary_payload_with_missing_employees(self) -> None:
payload = create_summary_payload(
"http://localhost:8787/manager",
{
"date": "2026-05-07",
"expectedCount": 2,
"submittedCount": 1,
"missingCount": 1,
"missing": [{"name": "Chen"}],
},
)
text = json.dumps(payload, ensure_ascii=False)
self.assertIn("2026-05-07", text)
self.assertIn("1/2", text)
self.assertIn("Chen", text)
self.assertIn("http://localhost:8787/manager", text)
def test_creates_webhook_signature(self) -> None:
signature = create_webhook_sign("1599360473", "test-secret")
self.assertIsInstance(signature, str)
self.assertGreater(len(signature), 20)
def test_creates_bot_interactive_message_body(self) -> None:
payload = create_reminder_payload("http://localhost:8787/submit")
body = create_bot_message_body(payload)
self.assertEqual(body["msg_type"], "interactive")
self.assertEqual(body["receive_id"], "")
self.assertIn("请提交今日日报", body["content"])
if __name__ == "__main__":
unittest.main()

View File

@ -1,175 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import date
from pathlib import Path
from unittest.mock import patch
from daily_report import scheduled
class ScheduledTest(unittest.TestCase):
def test_send_summary_builds_today_summary_payload(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
root = Path(temp)
seed_path = root / "employees.json"
seed_path.write_text(
json.dumps(
[
{"feishu_user_id": "u_1", "name": "Lin", "active": True},
{"feishu_user_id": "u_2", "name": "Chen", "active": True},
],
ensure_ascii=False,
),
encoding="utf-8",
)
fake_file = root / "daily_report" / "config.py"
fake_file.parent.mkdir()
fake_file.write_text("", encoding="utf-8")
env = {
"DATABASE_PATH": "test.sqlite",
"EMPLOYEE_SEED_PATH": "employees.json",
"BASE_URL": "http://localhost:8787",
"FEISHU_WEBHOOK_URL": "https://example.test/webhook",
}
captured = {}
def fake_send(webhook_url: str, payload: dict, secret: str = "") -> dict:
captured["webhook_url"] = webhook_url
captured["payload"] = payload
captured["secret"] = secret
return {"ok": True}
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
"daily_report.scheduled.send_webhook", fake_send
):
result = scheduled.send_summary("2026-05-07")
self.assertEqual(result, {"ok": True})
self.assertEqual(captured["webhook_url"], "https://example.test/webhook")
self.assertEqual(captured["secret"], "")
text = json.dumps(captured["payload"], ensure_ascii=False)
self.assertIn("2026-05-07 日报提交汇总", text)
self.assertIn("0/2", text)
self.assertIn("Chen、Lin", text)
def test_send_reminder_private_messages_missing_employees_only(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
root = Path(temp)
seed_path = root / "employees.json"
seed_path.write_text(
json.dumps(
[
{"feishu_user_id": "ou_1", "name": "Lin", "active": True},
{"feishu_user_id": "ou_2", "name": "Chen", "active": True},
],
ensure_ascii=False,
),
encoding="utf-8",
)
fake_file = root / "daily_report" / "config.py"
fake_file.parent.mkdir()
fake_file.write_text("", encoding="utf-8")
env = {
"DATABASE_PATH": "test.sqlite",
"EMPLOYEE_SEED_PATH": "employees.json",
"BASE_URL": "http://localhost:8787",
"FEISHU_APP_ID": "cli_test",
"FEISHU_APP_SECRET": "secret",
}
sent = []
def fake_send(token: str, receive_id: str, payload: dict) -> dict:
sent.append((token, receive_id, payload))
return {"ok": True}
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
"daily_report.scheduled.get_tenant_access_token", lambda app_id, app_secret: "tenant-token"
), patch("daily_report.scheduled.send_bot_interactive_message", fake_send), patch("daily_report.scheduled.date") as fake_date:
fake_date.today.return_value = date(2026, 5, 7)
result = scheduled.send_reminder()
self.assertEqual(result["mode"], "bot_private")
self.assertEqual([item[1] for item in sent], ["ou_2", "ou_1"])
def test_send_reminder_skips_admins(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
root = Path(temp)
seed_path = root / "employees.json"
seed_path.write_text(
json.dumps(
[
{"feishu_user_id": "ou_admin", "name": "Admin", "active": True, "role": "admin"},
{"feishu_user_id": "ou_staff", "name": "Staff", "active": True, "role": "staff"},
],
ensure_ascii=False,
),
encoding="utf-8",
)
fake_file = root / "daily_report" / "config.py"
fake_file.parent.mkdir()
fake_file.write_text("", encoding="utf-8")
env = {
"DATABASE_PATH": "test.sqlite",
"EMPLOYEE_SEED_PATH": "employees.json",
"BASE_URL": "http://localhost:8787",
"FEISHU_APP_ID": "cli_test",
"FEISHU_APP_SECRET": "secret",
}
sent = []
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
"daily_report.scheduled.get_tenant_access_token", lambda app_id, app_secret: "tenant-token"
), patch(
"daily_report.scheduled.send_bot_interactive_message",
lambda token, receive_id, payload: sent.append(receive_id) or {"ok": True},
), patch("daily_report.scheduled.date") as fake_date:
fake_date.today.return_value = date(2026, 5, 7)
result = scheduled.send_reminder()
self.assertEqual(result["mode"], "bot_private")
self.assertEqual(sent, ["ou_staff"])
def test_send_summary_private_messages_admins_only(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
root = Path(temp)
seed_path = root / "employees.json"
seed_path.write_text(
json.dumps(
[
{"feishu_user_id": "ou_admin", "name": "Admin", "active": True, "role": "admin"},
{"feishu_user_id": "ou_staff", "name": "Staff", "active": True, "role": "staff"},
],
ensure_ascii=False,
),
encoding="utf-8",
)
fake_file = root / "daily_report" / "config.py"
fake_file.parent.mkdir()
fake_file.write_text("", encoding="utf-8")
env = {
"DATABASE_PATH": "test.sqlite",
"EMPLOYEE_SEED_PATH": "employees.json",
"BASE_URL": "http://localhost:8787",
"FEISHU_APP_ID": "cli_test",
"FEISHU_APP_SECRET": "secret",
}
sent = []
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
"daily_report.scheduled.get_tenant_access_token", lambda app_id, app_secret: "tenant-token"
), patch(
"daily_report.scheduled.send_bot_interactive_message",
lambda token, receive_id, payload: sent.append(receive_id) or {"ok": True},
):
result = scheduled.send_summary("2026-05-07")
self.assertEqual(result["mode"], "bot_private")
self.assertEqual(sent, ["ou_admin"])
if __name__ == "__main__":
unittest.main()

View File

@ -1,207 +0,0 @@
from __future__ import annotations
import json
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from daily_report.config import Config
from daily_report.db import Database
from daily_report.feishu_auth import create_session_cookie
from daily_report.web import create_server
def make_server(employees: list[dict] | None = None) -> tuple:
temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-web-"))
seed_path = temp_dir / "employees.json"
seed_path.write_text(
json.dumps(employees or [{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}]),
encoding="utf-8",
)
config = Config(
root_dir=Path(__file__).resolve().parent.parent,
port=0,
base_url="http://localhost:8787",
database_path=temp_dir / "test.sqlite",
employee_seed_path=seed_path,
workday_calendar_path=temp_dir / "workday-calendar.json",
feishu_webhook_url="",
feishu_webhook_secret="",
feishu_app_id="",
feishu_app_secret="",
session_secret="session-secret",
)
db = Database(config.database_path, config.employee_seed_path)
server = create_server(config, db)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, db, f"http://127.0.0.1:{server.server_address[1]}"
def get(url: str) -> tuple[int, str]:
with urllib.request.urlopen(url, timeout=5) as response:
return response.status, response.read().decode("utf-8")
def get_with_cookie(url: str, cookie: str) -> tuple[int, str]:
opener = urllib.request.build_opener()
opener.addheaders = [("Cookie", cookie)]
with opener.open(url, timeout=5) as response:
return response.status, response.read().decode("utf-8")
def admin_cookie(user_id: str = "u_1", name: str = "Lin") -> str:
return "daily_report_session=" + create_session_cookie(
{"feishu_user_id": user_id, "name": name}, "session-secret"
)
class WebTest(unittest.TestCase):
def test_serves_submit_and_manager_pages(self) -> None:
server, db, base_url = make_server()
try:
status, submit = get(f"{base_url}/submit")
self.assertEqual(status, 200)
self.assertIn("每日工作汇报", submit)
self.assertIn("/static/report-illust-work.png", submit)
self.assertIn("/static/report-illust-bed.png", submit)
self.assertIn("/static/report-illust-bath.png", submit)
self.assertIn("今日状态", submit)
self.assertIn('data-list="today_done"', submit)
self.assertIn('data-add-list="today_done"', submit)
self.assertIn('resetItems("today_done")', submit)
self.assertIn("我的历史日报", submit)
db.upsert_employee(
{
"feishu_user_id": "u_1",
"name": "Lin",
"department": "Design",
"manager": "",
"active": True,
"role": "admin",
}
)
status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie())
self.assertEqual(status, 200)
self.assertIn("日报浏览", manager)
with urllib.request.urlopen(f"{base_url}/static/report-illust-work.png", timeout=5) as response:
self.assertEqual(response.status, 200)
self.assertEqual(response.headers["content-type"], "image/png")
finally:
server.shutdown()
server.server_close()
db.close()
def test_accepts_report_submission_and_returns_summary_and_previous_reference(self) -> None:
server, db, base_url = make_server()
try:
first_request = urllib.request.Request(
f"{base_url}/api/reports",
data=json.dumps(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-06",
"today_done": "1. 完成 API",
"tomorrow_plan": "1. 完善界面",
},
ensure_ascii=False,
).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(first_request, timeout=5) as response:
self.assertEqual(response.status, 200)
second_request = urllib.request.Request(
f"{base_url}/api/reports",
data=json.dumps(
{
"feishu_user_id": "u_1",
"report_date": "2026-05-07",
"report_status": "need_help",
"today_done": "1. 完善界面",
"tomorrow_plan": "1. 上线测试",
},
ensure_ascii=False,
).encode("utf-8"),
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(second_request, timeout=5) as response:
self.assertEqual(response.status, 200)
db.upsert_employee(
{
"feishu_user_id": "u_admin",
"name": "Admin",
"department": "Design",
"manager": "",
"active": True,
"role": "admin",
}
)
status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie("u_admin", "Admin"))
summary = json.loads(body)
self.assertEqual(status, 200)
self.assertEqual(summary["submittedCount"], 1)
self.assertEqual(summary["reports"][0]["today_done"], "1. 完善界面")
self.assertEqual(summary["reports"][0]["report_status"], "need_help")
status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1")
history = json.loads(history_body)
self.assertEqual(status, 200)
self.assertEqual(history["employee"]["name"], "Lin")
self.assertEqual(history["reports"][0]["tomorrow_plan"], "1. 上线测试")
status, reference_body = get(f"{base_url}/api/reports/previous?feishu_user_id=u_1&date=2026-05-07")
reference = json.loads(reference_body)
self.assertEqual(status, 200)
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 完善界面")
finally:
server.shutdown()
server.server_close()
db.close()
def test_submit_page_uses_logged_in_feishu_identity(self) -> None:
server, db, base_url = make_server()
try:
status, body = get_with_cookie(f"{base_url}/submit", admin_cookie())
self.assertEqual(status, 200)
self.assertIn("Lin", body)
self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body)
self.assertNotIn("员工 ID<input", body)
finally:
server.shutdown()
server.server_close()
db.close()
def test_manager_page_requires_admin_role(self) -> None:
server, db, base_url = make_server(
[
{"feishu_user_id": "u_admin", "name": "Admin", "active": True, "role": "admin"},
{"feishu_user_id": "u_staff", "name": "Staff", "active": True, "role": "staff"},
]
)
try:
staff_cookie = admin_cookie("u_staff", "Staff")
with self.assertRaises(urllib.error.HTTPError) as error:
get_with_cookie(f"{base_url}/manager", staff_cookie)
self.assertEqual(error.exception.code, 403)
status, body = get_with_cookie(f"{base_url}/manager", admin_cookie("u_admin", "Admin"))
self.assertEqual(status, 200)
self.assertIn("日报浏览", body)
finally:
server.shutdown()
server.server_close()
db.close()
if __name__ == "__main__":
unittest.main()

View File

@ -1,33 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import date
from pathlib import Path
from daily_report.workday import is_workday
class WorkdayTest(unittest.TestCase):
def test_uses_holidays_and_makeup_workdays(self) -> None:
with tempfile.TemporaryDirectory(prefix="daily-report-workday-") as temp:
calendar_path = Path(temp) / "calendar.json"
calendar_path.write_text(
json.dumps(
{
"holidays": ["2026-05-01"],
"makeup_workdays": ["2026-05-09"],
}
),
encoding="utf-8",
)
self.assertFalse(is_workday(date(2026, 5, 1), calendar_path))
self.assertTrue(is_workday(date(2026, 5, 9), calendar_path))
self.assertFalse(is_workday(date(2026, 5, 10), calendar_path))
self.assertTrue(is_workday(date(2026, 5, 11), calendar_path))
if __name__ == "__main__":
unittest.main()

View File

@ -1,17 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
set "LOG=%~dp0uninstall-tasks.log"
echo Uninstalling Windows scheduled tasks...
echo Uninstalling Windows scheduled tasks... > "%LOG%"
echo Project: %CD% >> "%LOG%"
echo. >> "%LOG%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\uninstall-windows-tasks.ps1" >> "%LOG%" 2>&1
type "%LOG%"
echo.
echo If there is no error above, tasks are removed.
echo If there is an error, send Codex a screenshot.
pause

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,175 @@
# 火山引擎云服务器部署步骤(推荐 PM2 常驻)
服务器公网 IP
```text
118.196.84.249
```
部署后的访问地址:
```text
http://118.196.84.249:3000/
http://118.196.84.249:3000/mobile.html
```
## 1. 登录服务器
在火山引擎控制台进入:
```text
云服务器 ECS -> 实例 -> Kaikai专用服务器 -> 远程连接
```
打开命令行后先确认系统:
```bash
uname -a
```
## 2. 安装基础工具
Ubuntu / Debian / veLinux 可执行:
```bash
apt update
apt install -y git curl
```
如果提示没有权限,在命令前加 `sudo`
## 3. 安装 Node.js 20
先检查:
```bash
node -v
```
如果不是 `v20` 开头,执行:
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
node -v
npm -v
```
## 4. 拉取代码
```bash
mkdir -p /www
git clone https://gitea.airlabs.art/zyc/kaikai_test.git /www/video-hotness
cd /www/video-hotness
npm install
```
如果仓库要求登录,输入 Gitea 用户名和访问令牌。建议使用访问令牌,不建议长期使用账号密码。
## 5. 设置访问密码
复制 PM2 配置:
```bash
cp deploy/pm2/ecosystem.config.cjs ecosystem.config.cjs
```
编辑配置:
```bash
nano ecosystem.config.cjs
```
把这一行:
```text
HOTNESS_ACCESS_PASSWORD: "CHANGE_ME",
```
改成你的云端访问密码,例如:
```text
HOTNESS_ACCESS_PASSWORD: "Kaikai2026",
```
保存:`Ctrl + O`,回车。退出:`Ctrl + X`
## 6. 用 PM2 后台常驻
安装 PM2
```bash
npm install -g pm2
```
启动:
```bash
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
```
`pm2 startup` 会输出一行命令,复制它并执行一次。执行后,服务器重启也会自动恢复。
## 7. 查看运行状态
```bash
pm2 status
pm2 logs video-hotness
```
看到 `online` 就是正在运行。
## 8. 火山引擎放行端口
进入火山引擎控制台:
```text
云服务器 ECS -> 实例 -> 安全组
```
添加入方向规则:
```text
协议TCP
端口3000
授权对象0.0.0.0/0
策略:允许
```
保存后访问:
```text
http://118.196.84.249:3000/
```
## 9. 后续更新代码
以后本地开发推送后,服务器执行:
```bash
cd /www/video-hotness
git pull
npm install
pm2 restart video-hotness
```
## 10. 常见问题
如果网页打不开:
```bash
pm2 status
pm2 logs video-hotness
```
如果服务器能跑但外面打不开,优先检查火山引擎安全组是否放行 `TCP 3000`
如果忘记访问密码:
```bash
cd /www/video-hotness
nano ecosystem.config.cjs
pm2 restart video-hotness
```

Some files were not shown because too many files have changed in this diff Show More