feat(web): add read-only dashboard server and query layer

This commit is contained in:
kairee committed 2026-10-31 23:13:54 +08:00
1 parent e7cae035e0
commit 19572171b8
10 files changed
+2603 -10

No files matched your search

+184 -10
View File
@@ -19,6 +19,7 @@ import argparse
import json
import sys
import threading
import time
from pathlib import Path
from typing import Sequence
@@ -48,7 +49,7 @@ def _build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument(
"--config",
default=str(DEFAULT_CONFIG),
default=argparse.SUPPRESS,
help="配置文件路径(默认项目根目录 config.json)",
)
@@ -56,7 +57,12 @@ def _build_parser() -> argparse.ArgumentParser:
prog="app.py",
description="咻咻满 Reaction 数据看板 —— 数据底座、同步与每日快照",
)
sub = parser.add_subparsers(dest="command", required=True)
parser.add_argument(
"--config",
default=str(DEFAULT_CONFIG),
help="配置文件路径(默认项目根目录 config.json)",
)
sub = parser.add_subparsers(dest="command", required=False)
sub.add_parser("init", parents=[common], help="建库、迁移并写入黑名单种子")
sync = sub.add_parser("sync", parents=[common], help="同步已配置的年度收藏夹")
@@ -109,6 +115,11 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="允许把远端 0 条视为真实清空(默认按异常跳过差集)",
)
serve = sub.add_parser("serve", parents=[common], help="启动只读本地看板(不运行调度)")
serve.add_argument(
"--preview", action="store_true",
help="以预览模式启动(要求数据库含 preview:seeded=1 标记)",
)
sub.add_parser("status", parents=[common], help="查看数据库状态(离线)")
return parser
@@ -128,6 +139,32 @@ def _print(payload) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2), flush=True)
def _record_task(
repo: Repository,
name: str,
*,
ok: bool,
started_at: int | None = None,
finished_at: int | None = None,
payload=None,
error: str | None = None,
) -> None:
"""把最近一次任务结果写入 kv,供只读系统页展示。"""
now = int(time.time())
record = {
"name": name,
"ok": bool(ok),
"started_at": int(started_at if started_at is not None else now),
"finished_at": int(finished_at if finished_at is not None else now),
"error": error,
"summary": payload,
}
try:
repo.kv_set(f"task:last:{name}", json.dumps(record, ensure_ascii=False))
except Exception: # noqa: BLE001 任务状态记录失败不影响主流程
pass
def _client(credentials, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
return BilibiliClient(
cookie=credentials.cookie,
@@ -174,10 +211,15 @@ def cmd_sync(args, logger: JsonLogger) -> int:
logger=logger,
allow_empty_folders=args.allow_empty_folder,
)
started = int(time.time())
summary = service.run(full_refresh=args.full)
payload = summary.as_dict()
payload["database"] = str(cfg.storage.database_path)
payload["counts"] = repo.counts()
_record_task(
repo, "folder_sync", ok=summary.ok, started_at=started,
finished_at=int(time.time()), payload=payload,
)
_print(payload)
return 0 if summary.ok else 1
finally:
@@ -199,8 +241,15 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
payload["database"] = str(cfg.storage.database_path)
payload["snapshot_counts"] = repo.snapshot_counts(summary.stat_date)
payload["counts"] = repo.counts()
ok = bool(summary.completed and not summary.backup_error)
_record_task(
repo, "snapshot", ok=ok,
started_at=int(summary.started_at or time.time()),
finished_at=int(time.time()), payload=payload,
error=summary.backup_error,
)
_print(payload)
return 0 if summary.completed and not summary.backup_error else 1
return 0 if ok else 1
finally:
db.close()
@@ -239,6 +288,13 @@ def cmd_discover(args, logger: JsonLogger) -> int:
payload = summary.as_dict()
payload["candidate_total"] = len(repo.list_candidates())
payload["database"] = str(cfg.storage.database_path)
_record_task(
repo, "discovery", ok=not summary.errors,
started_at=int(summary.started_at),
finished_at=int(summary.finished_at or time.time()),
payload=payload,
error=";".join(summary.errors) if summary.errors else None,
)
_print(payload)
return 0 if not summary.errors else 1
finally:
@@ -299,11 +355,12 @@ def cmd_auth_check(args, logger: JsonLogger) -> int:
return 0 if all(result["ok"] for result in results) else 1
def cmd_run(args, logger: JsonLogger) -> int:
def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
store = ConfigStore(args.config, require_auth=True)
cfg = store.get()
db = _open_db(cfg, logger)
last: dict[str, object] = {}
web_server = None
try:
repo = Repository(db)
folder_provider = ClientProvider(
@@ -349,12 +406,29 @@ def cmd_run(args, logger: JsonLogger) -> int:
)
def run_discover():
summary = discovery_service.run()
started = int(time.time())
try:
summary = discovery_service.run()
except Exception as exc: # noqa: BLE001 记录后交给调度器隔离
_record_task(
repo, "discovery", ok=False, started_at=started,
finished_at=int(time.time()), error=str(exc),
)
raise
payload = summary.as_dict()
_record_task(
repo, "discovery", ok=not summary.errors,
started_at=summary.started_at,
finished_at=summary.finished_at or int(time.time()),
payload=payload,
error=";".join(summary.errors) if summary.errors else None,
)
last["discover"] = summary
logger.info("discovery.summary", **summary.as_dict())
logger.info("discovery.summary", **payload)
return summary
def run_cleanup():
started = int(time.time())
removed = repo.delete_expired_candidates(
ttl_days=cfg.schedule.candidate_ttl_days
)
@@ -363,21 +437,58 @@ def cmd_run(args, logger: JsonLogger) -> int:
"cleanup.done",
candidates_removed=removed, fav_actions_expired=expired,
)
_record_task(
repo, "candidate_cleanup", ok=True, started_at=started,
finished_at=int(time.time()),
payload={
"candidates_removed": removed,
"fav_actions_expired": expired,
},
)
return {
"candidates_removed": removed,
"fav_actions_expired": expired,
}
def run_snapshot():
summary = snapshot_service.run_if_needed()
started = int(time.time())
try:
summary = snapshot_service.run_if_needed()
except Exception as exc: # noqa: BLE001
_record_task(
repo, "snapshot", ok=False, started_at=started,
finished_at=int(time.time()), error=str(exc),
)
raise
payload = summary.as_dict()
ok = bool(summary.completed and not summary.backup_error)
_record_task(
repo, "snapshot", ok=ok, started_at=summary.started_at,
finished_at=summary.finished_at or int(time.time()),
payload=payload, error=summary.backup_error,
)
last["snapshot"] = summary
logger.info("snapshot.summary", **summary.as_dict())
logger.info("snapshot.summary", **payload)
return summary
def run_sync():
summary = folder_service.run()
started = int(time.time())
try:
summary = folder_service.run()
except Exception as exc: # noqa: BLE001
_record_task(
repo, "folder_sync", ok=False, started_at=started,
finished_at=int(time.time()), error=str(exc),
)
raise
payload = summary.as_dict()
_record_task(
repo, "folder_sync", ok=summary.ok, started_at=started,
finished_at=int(time.time()), payload=payload,
error=None if summary.ok else ";".join(summary.errors),
)
last["folder"] = summary
logger.info("folder_sync.summary", **summary.as_dict())
logger.info("folder_sync.summary", **payload)
# 新入库/恢复的视频立即补当天快照,而不是等到次日。
if summary.added > 0:
run_snapshot()
@@ -416,6 +527,15 @@ def cmd_run(args, logger: JsonLogger) -> int:
],
logger=logger,
)
if serve_web and not args.once:
from .web.server import create_server, serve_forever
web_server = create_server(store, db, logger=logger)
threading.Thread(
target=lambda: serve_forever(web_server, logger=logger),
name="web-server",
daemon=True,
).start()
if notifier is not None and not args.once:
poller = TelegramPoller(notifier, favorite_service, logger=logger)
threading.Thread(
@@ -442,6 +562,52 @@ def cmd_run(args, logger: JsonLogger) -> int:
snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True)
discover_ok = discover_summary is None or not getattr(discover_summary, "errors", [])
return 0 if folder_ok and snapshot_ok and discover_ok else 1
finally:
if web_server is not None:
try:
web_server.shutdown()
finally:
web_server.server_close()
db.close()
def _assert_preview_database(path: Path) -> None:
import sqlite3
if not path.exists():
raise ConfigError(f"预览数据库不存在: {path}")
uri = f"file:{path.resolve().as_posix()}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as exc:
raise ConfigError(f"无法读取预览数据库: {exc}") from exc
try:
row = conn.execute(
"SELECT v FROM kv WHERE k = 'preview:seeded'"
).fetchone()
except sqlite3.Error as exc:
raise ConfigError(f"目标数据库不是预览库: {exc}") from exc
finally:
conn.close()
if row is None or str(row[0]) != "1":
raise ConfigError(
"目标数据库缺少 preview:seeded=1 标记;请先运行 "
"tools/seed_preview_db.py,或去掉 --preview"
)
def cmd_serve(args, logger: JsonLogger) -> int:
from .web.server import create_server, serve_forever
store = ConfigStore(args.config, require_auth=False)
cfg = store.get()
if args.preview:
_assert_preview_database(cfg.storage.database_path)
db = _open_db(cfg, logger)
try:
server = create_server(store, db, preview=args.preview, logger=logger)
serve_forever(server, logger=logger)
return 0
finally:
db.close()
@@ -491,6 +657,14 @@ def main(argv: Sequence[str] | None = None) -> int:
args = _build_parser().parse_args(list(argv) if argv is not None else None)
logger = JsonLogger()
try:
if args.command is None:
# 无子命令时启动完整服务:只读 Web + 调度 + Telegram。
args.command = "run"
args.once = False
args.allow_empty_folder = False
return cmd_run(args, logger, serve_web=True)
if args.command == "serve":
return cmd_serve(args, logger)
if args.command == "init":
return cmd_init(args, logger)
if args.command == "sync":
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""只读 Web 看板。"""
File diff suppressed because it is too large. Load diff
+649
View File
@@ -0,0 +1,649 @@
# -*- coding: utf-8 -*-
"""只读看板 HTTP 服务。
只注册 GET(以及登录所需的 POST /login)。数据管理仍由 Telegram、CLI 或
数据库完成,Web 不提供任何业务写接口。
"""
from __future__ import annotations
import csv
import hashlib
import hmac
import io
import json
import mimetypes
import os
import time
from dataclasses import dataclass
from datetime import date
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Mapping
from urllib.parse import parse_qs, unquote, urlsplit
from ..analytics import is_loopback
from ..config import ConfigStore
from ..database import Database
from ..timeutil import shanghai_date
from .queries import DashboardQueries
STATIC_ROOT = Path(__file__).resolve().parent / "static"
COOKIE_NAME = "rb_session"
SESSION_TTL = 7 * 86400
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str) -> None:
super().__init__(message)
self.status = status
self.code = code
self.message = message
@dataclass
class WebContext:
queries: DashboardQueries
config_store: ConfigStore
preview: bool = False
logger: Any = None
clock: Callable[[], float] = time.time
db_path: str | None = None
schema_version: int | None = None
@property
def auth_token(self) -> str:
return self.config_store.get().web.auth_token
class DashboardHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, context: WebContext) -> None:
self.context = context
super().__init__(address, DashboardHandler)
class DashboardHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "ReactionBoard/0.1"
# ------------------------------------------------------------ logging
def log_message(self, fmt: str, *args: Any) -> None:
# 请求级日志容易刷屏,只保留错误在 _send_error 里记录。
return
@property
def context(self) -> WebContext:
return self.server.context # type: ignore[attr-defined]
# ------------------------------------------------------------ HTTP
def do_GET(self) -> None:
self._dispatch("GET")
def do_HEAD(self) -> None:
self._dispatch("HEAD")
def do_POST(self) -> None:
self._dispatch("POST")
def do_PUT(self) -> None:
self._method_not_allowed()
def do_PATCH(self) -> None:
self._method_not_allowed()
def do_DELETE(self) -> None:
self._method_not_allowed()
def do_OPTIONS(self) -> None:
self._method_not_allowed()
def _method_not_allowed(self) -> None:
self._send_error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Web 看板只读")
# ------------------------------------------------------------ dispatch
def _dispatch(self, method: str) -> None:
parsed = urlsplit(self.path)
path = unquote(parsed.path)
query = parse_qs(parsed.query, keep_blank_values=True)
try:
if path == "/api/health":
self._send_json(HTTPStatus.OK, {
"ok": True,
"preview": self.context.preview,
"time": int(self.context.clock()),
})
return
if path == "/login":
if method == "POST":
self._login_post()
else:
self._login_page()
return
if path == "/logout":
self._logout()
return
if path.startswith("/api/"):
if not self._authorized():
self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录")
return
if method == "POST":
self._send_error(
HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed",
"Web 看板只读",
)
return
self._api(path, query, method)
return
if not self._authorized():
self._redirect("/login")
return
self._static(path, method)
except ApiError as exc:
self._send_error(exc.status, exc.code, exc.message)
except ValueError as exc:
self._send_error(HTTPStatus.BAD_REQUEST, "bad_request", str(exc))
except BrokenPipeError:
return
except Exception as exc: # noqa: BLE001
if self.context.logger is not None:
self.context.logger.error("web.error", path=path, error=str(exc))
self._send_error(
HTTPStatus.INTERNAL_SERVER_ERROR, "internal_error", "服务端错误"
)
# ------------------------------------------------------------ auth
def _session_value(self, expires_at: int) -> str:
token = self.context.auth_token
payload = f"rb:{int(expires_at)}"
signature = hmac.new(
token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{int(expires_at)}.{signature}"
def _valid_session(self, value: str) -> bool:
token = self.context.auth_token
if not token or "." not in value:
return False
raw_exp, signature = value.split(".", 1)
try:
expires_at = int(raw_exp)
except ValueError:
return False
if expires_at < int(self.context.clock()):
return False
payload = f"rb:{expires_at}"
expected = hmac.new(
token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
def _authorized(self) -> bool:
token = self.context.auth_token
if not token:
return True
header = self.headers.get("Authorization", "")
if header.startswith("Bearer "):
return hmac.compare_digest(header[7:].strip(), token)
cookie_header = self.headers.get("Cookie")
if not cookie_header:
return False
cookie = SimpleCookie()
try:
cookie.load(cookie_header)
except Exception: # noqa: BLE001
return False
morsel = cookie.get(COOKIE_NAME)
return morsel is not None and self._valid_session(morsel.value)
def _login_page(self) -> None:
if not self.context.auth_token:
self._redirect("/")
return
html = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 · Reaction 看板</title>
<style>
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;
place-items:center;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
background:#f6f7f9;color:#1c1f23}main{width:min(92vw,360px);padding:28px;
border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.08)}
h1{font-size:20px;margin:0 0 8px}p{color:#6b7280;font-size:14px;margin:0 0 18px}
input{width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d9dee6;
border-radius:10px;font-size:16px}button{width:100%;margin-top:12px;padding:12px;
border:0;border-radius:10px;background:#fb7299;color:#fff;font-size:16px;
font-weight:600;cursor:pointer}@media(prefers-color-scheme:dark){body{background:#111315;
color:#f3f4f6}main{background:#1b1e22}p{color:#9ca3af}input{background:#111315;
border-color:#333a42;color:#f3f4f6}}
</style></head><body><main>
<h1>Reaction 看板</h1><p>输入配置中的 web.auth_token 以继续。</p>
<form method="post" action="/login">
<input type="password" name="token" autocomplete="current-password" placeholder="访问令牌" required autofocus>
<button type="submit">进入看板</button>
</form></main></body></html>"""
self._send_html(HTTPStatus.OK, html)
def _login_post(self) -> None:
if not self.context.auth_token:
self._redirect("/")
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length <= 0 or length > 4096:
self._send_error(HTTPStatus.BAD_REQUEST, "bad_request", "请求体过大")
return
body = self.rfile.read(length).decode("utf-8", errors="replace")
token = (parse_qs(body).get("token") or [""])[0]
if not hmac.compare_digest(token, self.context.auth_token):
self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "令牌不正确")
return
expires_at = int(self.context.clock()) + SESSION_TTL
value = self._session_value(expires_at)
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/")
self.send_header("Set-Cookie", (
f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; "
f"Max-Age={SESSION_TTL}"
))
self.send_header("Content-Length", "0")
self._security_headers()
self.end_headers()
def _logout(self) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/login" if self.context.auth_token else "/")
self.send_header("Set-Cookie", f"{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
self.send_header("Content-Length", "0")
self._security_headers()
self.end_headers()
# ------------------------------------------------------------ API
def _api(self, path: str, query: Mapping[str, list[str]], method: str) -> None:
queries = self.context.queries
if path == "/api/overview":
self._send_json(HTTPStatus.OK, queries.overview())
return
if path == "/api/videos":
self._send_json(HTTPStatus.OK, queries.list_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
page=_int_param(query, "page", 1, 1, 1_000_000),
page_size=_int_param(
query, "page_size",
_optional_int(query, "limit") or 20,
1, 200,
),
))
return
if path.startswith("/api/videos/"):
parts = [part for part in path.split("/") if part]
if len(parts) == 3:
aid = _path_int(parts[2], "aid")
data = queries.video_detail(aid)
if data is None:
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "视频不存在")
self._send_json(HTTPStatus.OK, data)
return
if len(parts) == 4 and parts[3] == "trend":
aid = _path_int(parts[2], "aid")
days = _range_days(query)
self._send_json(HTTPStatus.OK, queries.video_trend(aid, days=days))
return
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在")
if path == "/api/rankings":
scope = _param(query, "scope", "video")
if scope == "up":
self._send_json(HTTPStatus.OK, queries.list_ups(
sort=_param(query, "sort", "views"),
within_days=_optional_int(query, "within_days"),
q=_optional_str(query, "q"),
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
page=_int_param(query, "page", 1, 1, 1_000_000),
page_size=_int_param(
query, "page_size",
_optional_int(query, "limit") or 20,
1, 200,
),
))
return
if scope != "video":
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "scope 必须是 video 或 up")
self._send_json(HTTPStatus.OK, queries.list_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
page=_int_param(query, "page", 1, 1, 1_000_000),
page_size=_int_param(
query, "page_size",
_optional_int(query, "limit") or 20,
1, 200,
),
))
return
if path == "/api/ups":
self._send_json(HTTPStatus.OK, queries.list_ups(
sort=_param(query, "sort", "views"),
within_days=_optional_int(query, "within_days"),
q=_optional_str(query, "q"),
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
page=_int_param(query, "page", 1, 1, 1_000_000),
page_size=_int_param(
query, "page_size",
_optional_int(query, "limit") or 20,
1, 200,
),
))
return
if path.startswith("/api/ups/"):
parts = [part for part in path.split("/") if part]
if len(parts) != 3:
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在")
mid = _path_int(parts[2], "mid")
data = queries.up_detail(mid)
if data is None:
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "UP 不存在")
self._send_json(HTTPStatus.OK, data)
return
if path == "/api/fans":
self._send_json(HTTPStatus.OK, queries.fans(
_optional_int(query, "mid"),
days=_int_param(query, "days", 90, 1, 3650),
))
return
if path == "/api/candidates":
self._send_json(HTTPStatus.OK, queries.candidates())
return
if path == "/api/system":
self._send_json(HTTPStatus.OK, queries.system())
return
if path == "/api/export.csv":
self._export_csv(query)
return
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在")
def _export_csv(self, query: Mapping[str, list[str]]) -> None:
data = self.context.queries.export_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
page=1,
page_size=10_000,
)
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerow([
"排名", "aid", "bvid", "标题", "UP", "发布时间", "时长(秒)",
"播放", "点赞", "投币", "收藏", "转发", "评论", "弹幕",
"互动率", "综合热度", "状态", "收藏夹", "快照日期",
])
for index, item in enumerate(data["items"], start=1):
metrics = item.get("metrics") or {}
writer.writerow([
index,
item["aid"],
item.get("bvid") or "",
item.get("title") or "",
item.get("up_name") or "",
_format_date(item.get("pubdate")),
item.get("duration") or "",
metrics.get("view", ""),
metrics.get("like", ""),
metrics.get("coin", ""),
metrics.get("favorite", ""),
metrics.get("share", ""),
metrics.get("reply", ""),
metrics.get("danmaku", ""),
"" if item.get("interaction_rate") is None else f"{item['interaction_rate']:.6f}",
"" if item.get("heat") is None else item["heat"],
item.get("status") or "",
item.get("folder_title") or "",
item.get("snapshot_date") or "",
])
payload = ("\ufeff" + output.getvalue()).encode("utf-8")
filename = f"reaction-board-{shanghai_date(self.context.clock())}.csv"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
# ------------------------------------------------------------ static
def _static(self, path: str, method: str) -> None:
if path in {"/", "/index.html"}:
target = STATIC_ROOT / "index.html"
elif path.startswith("/static/"):
relative = path[len("/static/"):]
target = (STATIC_ROOT / relative).resolve()
try:
target.relative_to(STATIC_ROOT.resolve())
except ValueError:
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在")
else:
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在")
if not target.is_file():
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在")
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {
"application/javascript", "application/json", "image/svg+xml",
}:
content_type += "; charset=utf-8"
payload = target.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.send_header(
"Cache-Control",
"no-cache" if target.name == "index.html" else "public, max-age=3600",
)
self._security_headers()
self.end_headers()
if method != "HEAD":
self.wfile.write(payload)
# ------------------------------------------------------------ responses
def _security_headers(self) -> None:
self.send_header(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self'; "
"connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("X-Frame-Options", "DENY")
def _send_json(self, status: int, data: Any) -> None:
payload = json.dumps({"ok": True, "data": data}, ensure_ascii=False).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def _send_error(self, status: int, code: str, message: str) -> None:
payload = json.dumps(
{"ok": False, "error": {"code": code, "message": message}},
ensure_ascii=False,
).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def _send_html(self, status: int, html: str) -> None:
payload = html.encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def _redirect(self, location: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self._security_headers()
self.end_headers()
# ------------------------------------------------------------ helpers
def _param(query: Mapping[str, list[str]], name: str, default: str) -> str:
values = query.get(name)
return default if not values else str(values[0])
def _optional_str(query: Mapping[str, list[str]], name: str) -> str | None:
value = _param(query, name, "").strip()
return value or None
def _optional_int(query: Mapping[str, list[str]], name: str) -> int | None:
value = _optional_str(query, name)
if value is None:
return None
try:
return int(value)
except ValueError as exc:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc
def _int_param(
query: Mapping[str, list[str]],
name: str,
default: int,
minimum: int,
maximum: int,
) -> int:
value = _optional_int(query, name)
if value is None:
return int(default)
if value < minimum or value > maximum:
raise ApiError(
HTTPStatus.BAD_REQUEST, "bad_request",
f"{name} 必须在 {minimum}~{maximum} 之间",
)
return value
def _path_int(value: str, name: str) -> int:
try:
return int(value)
except ValueError as exc:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc
def _range_days(query: Mapping[str, list[str]]) -> int | None:
raw = _optional_str(query, "range")
if raw in {None, "all", "0"}:
return None
if raw in {"7", "30", "90"}:
return int(raw)
days = _optional_int(query, "days")
if days is None:
return 30
if days < 1 or days > 3650:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "days 超出范围")
return days
def _format_date(timestamp: int | float | None) -> str:
if not timestamp:
return ""
try:
return shanghai_date(float(timestamp))
except (OverflowError, OSError, ValueError):
return ""
def _preview_marker(db: Database) -> bool:
def run(conn):
row = conn.execute("SELECT v FROM kv WHERE k = 'preview:seeded'").fetchone()
return row is not None and str(row["v"]) == "1"
return bool(db.read(run))
def create_server(
config_store: ConfigStore,
db: Database,
*,
host: str | None = None,
port: int | None = None,
preview: bool = False,
logger: Any = None,
clock: Callable[[], float] = time.time,
) -> DashboardHTTPServer:
cfg = config_store.get()
bind_host = cfg.web.host if host is None else host
bind_port = cfg.web.port if port is None else int(port)
if not cfg.web.auth_token and not is_loopback(bind_host):
raise ValueError(
"非回环地址必须配置 web.auth_token,或只绑定 127.0.0.1"
)
if preview and not _preview_marker(db):
raise ValueError("目标数据库不是预览库(缺少 preview:seeded=1 标记)")
context = WebContext(
queries=DashboardQueries(db, config_store, clock=clock),
config_store=config_store,
preview=preview,
logger=logger,
clock=clock,
db_path=str(db.path),
schema_version=db.schema_version,
)
return DashboardHTTPServer((bind_host, bind_port), context)
def serve_forever(server: DashboardHTTPServer, *, logger: Any = None) -> None:
host, port = server.server_address[:2]
if logger is not None:
logger.info(
"web.start",
url=f"http://{host}:{port}",
preview=server.context.preview,
auth=bool(server.context.auth_token),
)
try:
server.serve_forever()
finally:
server.server_close()
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>咻咻满 Reaction 看板</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">正在加载…</div>
<script type="module" src="/static/app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""离线测试与共享夹具。"""
+29
View File
@@ -184,6 +184,34 @@ def test_run_once_offline():
assert '"name": "snapshot"' in output
assert selected and all(credentials.mid == 1 for credentials in selected)
db = Database(Path(tmp) / "app.db")
db.initialize()
try:
repo = Repository(db)
for name in ("folder_sync", "snapshot", "discovery", "candidate_cleanup"):
assert repo.kv_get(f"task:last:{name}"), name
finally:
db.close()
def test_default_command_starts_web_and_scheduler():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp)
calls = []
original = cli.cmd_run
def fake_run(args, logger, *, serve_web=False):
calls.append((args.command, serve_web, args.once, args.allow_empty_folder))
return 0
cli.cmd_run = fake_run
try:
code = main(["--config", str(path)])
finally:
cli.cmd_run = original
assert code == 0
assert calls == [("run", True, False, False)]
def test_snapshot_backup_failure_returns_error_and_retries():
import reaction_board.services.snapshot as snapshot_module
@@ -271,6 +299,7 @@ def main_test():
test_auth_check_without_cookie()
test_auth_check_all_and_auth_dispatch()
test_run_once_offline()
test_default_command_starts_web_and_scheduler()
test_snapshot_backup_failure_returns_error_and_retries()
test_candidates_and_discover_commands()
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
+242
View File
@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""只读查询层测试(离线): uv run python tests/test_web_queries.py"""
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.web.queries import DashboardQueries # noqa: E402
from tests.web_fixtures import DATES, build_db, write_config # noqa: E402
FIXED_NOW = 1_793_459_000 # 2026-10-31 前后
def setup(tmp: str):
build_db(tmp)
config = write_config(tmp)
db = Database(Path(tmp) / "app.db")
db.initialize()
store = ConfigStore(config)
queries = DashboardQueries(db, store, clock=lambda: FIXED_NOW)
return db, queries
def test_overview():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
overview = q.overview()
assert overview["data_date"] == DATES[-1]
assert overview["snapshot_days"] == 3
assert overview["is_first_day"] is False
assert overview["counts"]["active"] == 2
assert overview["counts"]["unfaved"] == 1
assert overview["counts"]["removed"] == 1
assert overview["counts"]["active_ups"] == 2
assert overview["counts"]["candidates_pending"] == 1
assert overview["totals"]["view"] == 1200 + 5200 + 120
assert overview["interactions"] > 0
assert overview["view_delta"] == (100 + 100 + 0) # 101/102 的日增量
assert overview["snapshot_health"]["total"] == 3
assert overview["folders"][0]["media_count"] == 3
assert overview["folders"][0]["ratio"] < 1
assert overview["fans"]["follower"] == 1020
assert overview["fans"]["delta_1d"] == 10
assert overview["preview"] is False
assert "snapshot" in overview["tasks"]
finally:
db.close()
def test_video_listing_filters_and_sorting():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
heat = q.list_videos(sort="heat")
aids = [item["aid"] for item in heat["items"]]
assert 104 not in aids, "removed 视频不应进入榜单"
assert aids[0] == 102, aids
assert heat["items"][0]["heat"] > heat["items"][-1]["heat"]
assert heat["items"][0]["heat_percentile"] == 100.0
views = q.list_videos(sort="view")
assert [item["aid"] for item in views["items"]] == [102, 101, 103]
interactions = q.list_videos(sort="interaction")
assert interactions["items"][0]["aid"] == 101
added = q.list_videos(sort="added", within_days=1)
assert [item["aid"] for item in added["items"]] == [103]
published = q.list_videos(sort="published", within_days=2)
assert {item["aid"] for item in published["items"]} == {101, 103}
searched = q.list_videos(sort="view", q="UP B")
assert [item["aid"] for item in searched["items"]] == [103]
searched = q.list_videos(sort="view", q="视频 102")
assert [item["aid"] for item in searched["items"]] == [102]
folder = q.list_videos(sort="view", folder_id=3700023351)
assert folder["total"] == 3
folder = q.list_videos(sort="view", folder_id=999)
assert folder["total"] == 0
page = q.list_videos(sort="view", page=2, page_size=1)
assert page["total"] == 3
assert page["pages"] == 3
assert [item["aid"] for item in page["items"]] == [101]
finally:
db.close()
def test_growth_and_cold_start():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
growth = q.list_videos(sort="growth", growth_days=2)
assert growth["available"] is True
assert {item["aid"] for item in growth["items"]} == {101, 102}
first = next(item for item in growth["items"] if item["aid"] == 101)
assert first["growth"]["available"] is True
assert first["growth"]["delta"] == 200
assert first["growth"]["gap_days"] == 2
rate = q.list_videos(sort="growth_rate", growth_days=2)
assert {item["aid"] for item in rate["items"]} == {101, 102}
# 只有一个快照日时增长不可用,不能返回假 0
for day in DATES[:-1]:
db.write(lambda conn, day=day: conn.execute(
"DELETE FROM snapshot WHERE stat_date = ?", (day,)
))
cold = q.list_videos(sort="growth", growth_days=2)
assert cold["available"] is False
assert cold["items"] == []
assert "两天" in cold["reason"]
finally:
db.close()
def test_video_detail_and_trend():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
detail = q.video_detail(101)
assert detail["aid"] == 101
assert detail["metrics"]["view"] == 1200
assert detail["growth"]["delta"] == 100
assert detail["snapshot_count"] == 3
assert detail["folder_title"] == "2026"
assert detail["url"].endswith("BV101")
assert q.video_detail(999999) is None
trend = q.video_trend(101, days=3)
assert trend["dates"] == list(DATES)
assert trend["series"]["view"] == [1000, 1100, 1200]
assert trend["available"] is True
assert len(trend["points"]) == 3
# 删除中间一天,折线在缺失日期应为 None,而不是插值
db.write(lambda conn: conn.execute(
"DELETE FROM snapshot WHERE aid=101 AND stat_date=?",
("2026-10-30",),
))
trend = q.video_trend(101, days=3)
assert trend["series"]["view"] == [1000, None, 1200]
# 失败快照也视为缺失
db.write(lambda conn: conn.execute(
"UPDATE snapshot SET ok=0, view=NULL WHERE aid=101 AND stat_date=?",
(DATES[-1],),
))
trend = q.video_trend(101, days=3)
assert trend["series"]["view"][-1] is None
finally:
db.close()
def test_up_queries():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
ups = q.list_ups()
assert ups["total"] == 2
assert ups["items"][0]["up_mid"] == 1
assert ups["items"][0]["video_count"] == 2
assert ups["items"][0]["total_views"] == 6400
assert ups["items"][0]["avg_views"] == 3200.0
one = q.list_ups(min_videos=2)
assert one["total"] == 1
detail = q.up_detail(1)
assert detail["video_count"] == 2
assert detail["total_views"] == 6400
assert detail["blacklisted"] is False
assert detail["series"]["dates"] == list(DATES)
assert detail["series"]["views"] == [6000, 6200, 6400]
assert detail["space_url"].endswith("/1")
blacklisted = q.up_detail(2)
assert blacklisted["blacklisted"] is True
assert blacklisted["video_count"] == 1
assert q.up_detail(999999) is None
finally:
db.close()
def test_fans_candidates_system_and_export():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
fans = q.fans(days=3)
assert fans["mid"] == 37754047
assert fans["name"] == "咻咻满"
assert fans["dates"] == list(DATES)
assert fans["series"] == [1000, 1010, 1020]
assert fans["delta_1d"] == 10
assert fans["delta_7d"] is None
assert fans["delta_cum"] == 20
assert fans["options"][0]["mid"] == 37754047
candidates = q.candidates()
assert candidates["total"] == 2
assert candidates["pending"] == 1
assert candidates["items"][0]["aid"] == 201
assert 1 <= candidates["items"][0]["days_left"] <= 7
assert candidates["items"][1]["decision"] == "rejected"
system = q.system()
assert system["data_date"] == DATES[-1]
assert system["snapshot_days"] == 3
assert system["blacklist"][0]["video_count"] == 1
assert system["tasks"]["snapshot"]["ok"] is True
assert system["errors"] == []
assert system["preview"] is False
assert system["next_runs"]["snapshot"] > 0
exported = q.export_videos(sort="view")
assert exported["total"] == 3
assert all("metrics" in item for item in exported["items"])
finally:
db.close()
def main_test():
test_overview()
test_video_listing_filters_and_sorting()
test_growth_and_cold_start()
test_video_detail_and_trend()
test_up_queries()
test_fans_candidates_system_and_export()
print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过")
if __name__ == "__main__":
main_test()
+215
View File
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
"""只读 Web 服务测试(离线): uv run python tests/test_web_server.py"""
import http.client
import json
import os
import sys
import tempfile
import threading
import urllib.parse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.web.server import create_server # noqa: E402
from tests.web_fixtures import build_db, write_config # noqa: E402
class RunningServer:
def __init__(self, tmp: str, *, token: str = "", preview: bool = False):
self.config = write_config(tmp, token=token)
build_db(tmp)
self.db = Database(Path(tmp) / "app.db")
self.db.initialize()
if preview:
self.db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')"
))
self.store = ConfigStore(self.config)
self.server = create_server(
self.store, self.db, port=0, preview=preview,
clock=lambda: 1_793_459_000,
)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def request(self, path: str, *, method: str = "GET", headers=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request(method, path, body=body, headers=headers or {})
response = conn.getresponse()
payload = response.read()
result = response.status, dict(response.getheaders()), payload
conn.close()
return result
def json(self, path: str, **kwargs):
status, headers, payload = self.request(path, **kwargs)
return status, headers, json.loads(payload.decode("utf-8"))
def close(self):
self.server.shutdown()
self.server.server_close()
self.db.close()
def test_readonly_routes_and_static():
with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp)
try:
status, _, health = running.json("/api/health")
assert status == 200 and health["ok"] is True
assert health["data"]["preview"] is False
status, _, overview = running.json("/api/overview")
assert status == 200 and overview["ok"] is True
assert overview["data"]["data_date"] == "2026-10-31"
status, headers, payload = running.request("/")
assert status == 200
assert "text/html" in headers["Content-Type"]
assert b"<html" in payload.lower()
status, _, payload = running.request("/static/../config.json")
assert status == 404
status, _, payload = running.request("/api/videos")
assert status == 200
items = json.loads(payload)["data"]["items"]
assert items and items[0]["aid"] == 102
status, headers, payload = running.request("/api/videos", method="HEAD")
assert status == 200 and payload == b""
assert "Content-Length" in headers
# 只读:所有业务写方法都被拒绝,且数据库不变
before = running.db.read(lambda conn: conn.execute(
"SELECT COUNT(*) FROM candidate"
).fetchone()[0])
for method in ("POST", "PUT", "PATCH", "DELETE"):
status, _, _ = running.request("/api/videos/101", method=method)
assert status == 405, method
after = running.db.read(lambda conn: conn.execute(
"SELECT COUNT(*) FROM candidate"
).fetchone()[0])
assert after == before
status, _, error = running.json("/api/videos?sort=bad")
assert status == 400 and error["ok"] is False
status, _, error = running.json("/api/videos?page=0")
assert status == 400
status, _, error = running.json("/api/videos/abc")
assert status == 400
status, _, error = running.json("/api/nope")
assert status == 404
status, _, payload = running.request("/api/export.csv?sort=view")
assert status == 200
assert payload.startswith(b"\xef\xbb\xbf")
assert "咻咻满".encode("utf-8") in payload
status, headers, _ = running.request("/api/overview")
assert headers["X-Content-Type-Options"] == "nosniff"
assert "default-src 'self'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY"
finally:
running.close()
def test_token_auth_and_login():
with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp, token="secret-token")
try:
status, _, body = running.json("/api/overview")
assert status == 401 and body["error"]["code"] == "unauthorized"
status, headers, _ = running.request("/")
assert status == 303 and headers["Location"] == "/login"
status, _, payload = running.request("/login")
assert status == 200 and b"web.auth_token" in payload
status, _, _ = running.request(
"/login",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body="token=wrong",
)
assert status == 401
status, headers, _ = running.request(
"/login",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body="token=secret-token",
)
assert status == 303
cookie = headers["Set-Cookie"].split(";", 1)[0]
assert cookie.startswith("rb_session=")
status, _, body = running.json("/api/overview", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True
status, _, body = running.json(
"/api/overview", headers={"Authorization": "Bearer secret-token"}
)
assert status == 200
tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1")
status, _, _ = running.json("/api/overview", headers={"Cookie": tampered})
assert status == 401
# 健康检查始终匿名
status, _, _ = running.json("/api/health")
assert status == 200
finally:
running.close()
def test_preview_and_loopback_guards():
with tempfile.TemporaryDirectory() as tmp:
config = write_config(tmp)
build_db(tmp)
db = Database(Path(tmp) / "app.db")
db.initialize()
store = ConfigStore(config)
try:
try:
create_server(store, db, host="0.0.0.0", port=0)
except ValueError as exc:
assert "web.auth_token" in str(exc)
else:
raise AssertionError("非回环地址且无 token 应拒绝启动")
try:
create_server(store, db, port=0, preview=True)
except ValueError as exc:
assert "preview:seeded" in str(exc)
else:
raise AssertionError("非预览库不应允许 --preview")
db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')"
))
server = create_server(store, db, port=0, preview=True)
try:
assert server.context.preview is True
finally:
server.server_close()
finally:
db.close()
def main_test():
test_readonly_routes_and_static()
test_token_auth_and_login()
test_preview_and_loopback_guards()
print("OK: Web 只读路由/鉴权/预览/安全边界测试全部通过")
if __name__ == "__main__":
main_test()
+177
View File
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""Web 查询/服务测试共用的临时数据库与配置。"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from reaction_board.bilibili.models import FavResource
from reaction_board.config import FolderConfig
from reaction_board.database import Database
from reaction_board.repository import Repository
from reaction_board.timeutil import day_bounds
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
DATES = ("2026-10-29", "2026-10-30", "2026-10-31")
def write_config(
tmp: str | Path,
*,
token: str = "",
host: str = "127.0.0.1",
port: int = 8080,
) -> Path:
root = Path(tmp)
path = root / "config.json"
path.write_text(json.dumps({
"bilibili": {
"read": {"mid": 1, "csrf": "read-csrf", "cookie": "SESSDATA=fake-read"},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "SESSDATA=fake-write"},
},
"telegram": {"enabled": False, "bot_token": "", "chat_id": 0},
"web": {
"host": host, "port": port, "auth_token": token,
"public_base_url": "",
},
"storage": {
"database_path": str(root / "app.db"),
"backup_dir": str(root / "backup"),
"backup_keep_days": 7,
},
"folders": [
{"year": 2026, "media_id": 3700023351, "title": "2026"},
],
"track_fans": [37754047],
"up_blacklist_seed": [],
"schedule": {
"discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01",
"candidate_ttl_days": 7,
},
}, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def resource(
aid: int,
*,
up_mid: int,
up_name: str,
pubdate: int,
attr: int = 0,
title: str | None = None,
) -> FavResource:
return FavResource(
aid=aid,
bvid=f"BV{aid}",
title=title or f"咻咻满 reaction 视频 {aid}",
up_mid=up_mid,
up_name=up_name,
pubdate=pubdate,
duration=7200,
cover=f"//i0.hdslb.com/{aid}.jpg",
attr=attr,
fav_time=None,
)
def build_db(tmp: str | Path) -> Path:
root = Path(tmp)
path = root / "app.db"
db = Database(path)
db.initialize()
try:
repo = Repository(db)
repo.ensure_folder(FOLDER)
repo.seed_blacklist([2])
def ts(day: str) -> int:
return int(day_bounds(day)[0])
videos = {
101: (1, "UP A", int(day_bounds("2026-10-30")[0]), 0, "2026-10-29"),
102: (1, "UP A", int(day_bounds("2026-10-20")[0]), 0, "2026-10-29"),
103: (2, "UP B", int(day_bounds("2026-10-31")[0]), 0, "2026-10-31"),
104: (3, "UP C", int(day_bounds("2026-09-01")[0]), 9, "2026-10-29"),
}
for aid, (up_mid, up_name, pubdate, attr, first_day) in videos.items():
repo.upsert_from_fav(
resource(aid, up_mid=up_mid, up_name=up_name, pubdate=pubdate, attr=attr),
FOLDER.media_id,
was_added=True,
now=ts(first_day),
)
repo.set_video_status(103, "unfaved")
metrics = {
101: {"view": 1000, "like": 100, "coin": 20, "favorite": 30, "share": 2, "reply": 10, "danmaku": 12},
102: {"view": 5000, "like": 200, "coin": 50, "favorite": 100, "share": 10, "reply": 30, "danmaku": 40},
104: {"view": 50, "like": 1, "coin": 0, "favorite": 0, "share": 0, "reply": 0, "danmaku": 0},
}
for index, day in enumerate(DATES):
for aid in (101, 102):
base = metrics[aid]
values = {}
for key, value in base.items():
values[key] = value + (index * 100 if key == "view" else index * 2)
repo.upsert_snapshot(
day, aid, metrics=values, ok=True, err=None,
captured_at=ts(day) + 60,
)
repo.upsert_up_stat(
day, 37754047, follower=1000 + index * 10, following=5,
ok=True, captured_at=ts(day) + 60,
)
# 103 只有最后一天快照,用于验证样本不足;104 另加一条失败快照
repo.upsert_snapshot(
DATES[-1], 103,
metrics={"view": 120, "like": 5, "coin": 1, "favorite": 2, "share": 0, "reply": 1, "danmaku": 1},
ok=True, err=None, captured_at=ts(DATES[-1]) + 60,
)
repo.upsert_snapshot(
"2026-10-30", 104,
metrics=None, ok=False, err="simulated failure",
captured_at=ts("2026-10-30") + 60,
)
repo.upsert_candidate(
aid=201, bvid="BV201", title="咻咻满 reaction 候选",
up_mid=9, up_name="候选UP", pubdate=1_790_000_000,
cover=None, score=6, channel="search:test#p1",
now=ts("2026-10-30"), play=1000, like=50,
)
repo.mark_notified(
201, channel="telegram", batch_id="b1", ok=True,
now=ts("2026-10-30") + 10,
)
repo.upsert_candidate(
aid=202, bvid="BV202", title="咻咻满 切片候选",
up_mid=10, up_name="切片UP", pubdate=1_790_000_000,
cover=None, score=1, channel="search:test#p1",
now=ts("2026-10-29"),
)
repo.mark_candidate_decision(202, "rejected", now=ts("2026-10-30"))
repo.finish_folder_sync(FOLDER, media_count=3, full_refresh=True)
repo.kv_set("snapshot:last_completed", DATES[-1])
repo.kv_set("auth:ok", "1")
repo.kv_set("auth:message", "OK")
repo.kv_set(
"task:last:snapshot",
json.dumps({
"name": "snapshot", "ok": True,
"started_at": ts(DATES[-1]), "finished_at": ts(DATES[-1]) + 60,
"error": None, "summary": {"completed": True},
}),
)
finally:
db.close()
return path
def day_date(value: str) -> date:
return date.fromisoformat(value)