Compare commits
No commits in common. "codex/feishu-daily-report" and "main" have entirely different histories.
codex/feis
...
main
10
.env.example
10
.env.example
@ -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
15
.gitignore
vendored
@ -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/
|
||||
|
||||
BIN
Microsoft.Web.WebView2.Core.dll
Normal file
BIN
Microsoft.Web.WebView2.Core.dll
Normal file
Binary file not shown.
BIN
Microsoft.Web.WebView2.WinForms.dll
Normal file
BIN
Microsoft.Web.WebView2.WinForms.dll
Normal file
Binary file not shown.
174
README.md
174
README.md
@ -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
BIN
WebView2Loader.dll
Normal file
Binary file not shown.
@ -1,2 +0,0 @@
|
||||
"""Feishu daily report application."""
|
||||
|
||||
@ -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",
|
||||
)
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
@ -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")
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[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 |
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) => ({{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}}[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()
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
20
deploy/pm2/ecosystem.config.cjs
Normal file
20
deploy/pm2/ecosystem.config.cjs
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
18
deploy/systemd/video-hotness.service.example
Normal file
18
deploy/systemd/video-hotness.service.example
Normal 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
|
||||
@ -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。
|
||||
@ -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 和多维表格同步。
|
||||
@ -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
15
package.json
Normal 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
2202
public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
267
public/index.html
Normal file
267
public/index.html
Normal 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>
|
||||
9
public/manifest.webmanifest
Normal file
9
public/manifest.webmanifest
Normal 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
52
public/mobile-sw.js
Normal 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
829
public/mobile.css
Normal 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
155
public/mobile.html
Normal 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
795
public/mobile.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttribute(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
}
|
||||
360
public/rankings.css
Normal file
360
public/rankings.css
Normal 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
455
public/rankings.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
1677
public/styles.css
Normal file
1677
public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
# This project uses only the Python standard library.
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
72
src/anomaly.js
Normal 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
377
src/collector.js
Normal 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
54
src/credibility.js
Normal 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
95
src/csv.js
Normal 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
394
src/extract.js
Normal 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(/ /g, " ")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function snippet(text, index = 0, length = 0, padding = 40) {
|
||||
const start = Math.max(0, index - padding);
|
||||
const end = Math.min(text.length, index + length + padding);
|
||||
return text.slice(start, end).replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[《》【】[\]()()::\s\-_/]+/g, "");
|
||||
}
|
||||
92
src/identity.js
Normal file
92
src/identity.js
Normal 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
120
src/index.js
Normal 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
85
src/kidsTrend.js
Normal 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
85
src/known.js
Normal 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
185
src/linkLibrary.js
Normal 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))];
|
||||
}
|
||||
32
src/native-launcher/HotnessDisableStartup.cs
Normal file
32
src/native-launcher/HotnessDisableStartup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/native-launcher/HotnessEnableStartup.cs
Normal file
38
src/native-launcher/HotnessEnableStartup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
358
src/native-launcher/HotnessWebViewApp.cs
Normal file
358
src/native-launcher/HotnessWebViewApp.cs
Normal 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
83
src/ocr.js
Normal 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
209
src/rankingDiscovery.js
Normal 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(/ /g, " ")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
165
src/rankingKids.js
Normal file
165
src/rankingKids.js
Normal 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
139
src/rankingMetrics.js
Normal 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
64
src/rankingScoring.js
Normal 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
392
src/rankingStorage.js
Normal 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
39
src/retryQueue.js
Normal 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
119
src/scraper.js
Normal 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(/ /g, " ")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
1141
src/search.js
Normal file
1141
src/search.js
Normal file
File diff suppressed because it is too large
Load Diff
1184
src/server.js
Normal file
1184
src/server.js
Normal file
File diff suppressed because it is too large
Load Diff
153
src/sites.js
Normal file
153
src/sites.js
Normal 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
455
src/storage.js
Normal 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
52
src/windows-ocr.ps1
Normal 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
|
||||
@ -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
|
||||
93
test/access-password.test.js
Normal file
93
test/access-password.test.js
Normal 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/);
|
||||
});
|
||||
59
test/desktop-dashboard.test.js
Normal file
59
test/desktop-dashboard.test.js
Normal 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/);
|
||||
});
|
||||
13
test/desktop-instance.test.js
Normal file
13
test/desktop-instance.test.js
Normal 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
37
test/duty-tool.test.js
Normal 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/);
|
||||
});
|
||||
125
test/history-collect-selected.test.js
Normal file
125
test/history-collect-selected.test.js
Normal 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;/);
|
||||
});
|
||||
|
||||
|
||||
34
test/history-run-collapse.test.js
Normal file
34
test/history-run-collapse.test.js
Normal 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/);
|
||||
});
|
||||
59
test/mobile-capture-system.test.js
Normal file
59
test/mobile-capture-system.test.js
Normal 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*\{/);
|
||||
});
|
||||
29
test/mobile-offline-drafts.test.js
Normal file
29
test/mobile-offline-drafts.test.js
Normal 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/);
|
||||
});
|
||||
50
test/mobile-pwa-offline.test.js
Normal file
50
test/mobile-pwa-offline.test.js
Normal 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
51
test/mobile-sync.test.js
Normal 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>"));
|
||||
});
|
||||
49
test/native-launcher.test.js
Normal file
49
test/native-launcher.test.js
Normal 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
74
test/retry-queue.test.js
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
19
test/search-timeout.test.js
Normal file
19
test/search-timeout.test.js
Normal 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/);
|
||||
});
|
||||
12
test/single-collect-speed.test.js
Normal file
12
test/single-collect-speed.test.js
Normal 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/);
|
||||
});
|
||||
71
test/temporary-query.test.js
Normal file
71
test/temporary-query.test.js
Normal 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/);
|
||||
});
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
106
云服务器部署说明(带访问密码).md
Normal file
106
云服务器部署说明(带访问密码).md
Normal 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 对手机安装到桌面、离线缓存、云端同步都更稳定。
|
||||
BIN
取消节目热度采集工具开机自启动.exe
Normal file
BIN
取消节目热度采集工具开机自启动.exe
Normal file
Binary file not shown.
532
团队操作指引(从打开到手机同步,先看这个).md
Normal file
532
团队操作指引(从打开到手机同步,先看这个).md
Normal 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. 需要正式采集的再点 `填入采集栏` 和 `采集一次`。
|
||||
|
||||
29
安装桌面App到桌面(只需一次).vbs
Normal file
29
安装桌面App到桌面(只需一次).vbs
Normal 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")
|
||||
BIN
开启节目热度采集工具开机自启动.exe
Normal file
BIN
开启节目热度采集工具开机自启动.exe
Normal file
Binary file not shown.
175
火山引擎云服务器部署步骤(推荐PM2常驻).md
Normal file
175
火山引擎云服务器部署步骤(推荐PM2常驻).md
Normal 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
Loading…
x
Reference in New Issue
Block a user