From 84ce1e41e81bbbd2147f2e3da11cb092511dae6a Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 17:06:26 +0800 Subject: [PATCH] feat(web): visit 204 response, up page header/sort, trim system page --- project.md | 2 +- reaction_board/web/queries.py | 2 -- reaction_board/web/server.py | 27 +++++++++++-------- reaction_board/web/static/style.css | 2 ++ reaction_board/web/static/views/system.js | 17 +----------- reaction_board/web/static/views/up.js | 13 ++++----- tests/test_web_server.py | 32 +++++++++++------------ 7 files changed, 41 insertions(+), 54 deletions(-) diff --git a/project.md b/project.md index a9c9716..57c1b0b 100644 --- a/project.md +++ b/project.md @@ -710,7 +710,7 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时 | UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 | | **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 | | **候选池** | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI | -| **系统状态** | 最近任务、采集成功率、收藏夹同步时间、下次任务、备份、鉴权、黑名单 | +| **系统状态** | 最近任务、采集成功率、下次任务、备份、鉴权、黑名单与访问统计(PV/UV) | ### 9.2 技术 diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 37f8bb9..2122c01 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -1700,7 +1700,6 @@ class DashboardQueries: "ok": by_ok.get(1, 0), "failed": by_ok.get(0, 0), } - folders = self._folders_overview(conn) blacklist = [ dict(row) for row in conn.execute( """ @@ -1742,7 +1741,6 @@ class DashboardQueries: "snapshot_days": snapshot_days, "snapshot_health": health, "counts": counts, - "folders": folders, "blacklist": blacklist, "tasks": tasks, "errors": errors, diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 5de97c7..1d299b2 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -315,11 +315,15 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} raise ApiError(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "不支持该操作") def _visit_post(self, query: Mapping[str, list[str]]) -> None: - """记录一次页面浏览;前端 sendBeacon 上报,写内存后定期批量落库。""" + """记录一次页面浏览;前端 sendBeacon 上报,写内存后定期批量落库。 + + 埋点不需要响应内容,统一返回 ``204 No Content``,避免为加密体积和 + 前端解析负担;异常(如请求体过大)仍返回 JSON 错误。 + """ track = self.context.config_store.get().web.track collector = self.context.visits if not track.enabled or collector is None: - self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "disabled"}) + self._send_no_content() return path_value = _param(query, "path", "") @@ -329,7 +333,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} user_agent = self.headers.get("User-Agent") if not track.count_bots and is_bot(user_agent): - self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "bot"}) + self._send_no_content() return peer = self.client_address[0] if self.client_address else None @@ -340,16 +344,11 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} header_order=track.header_order, ) if not ip: - self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "no_ip"}) + self._send_no_content() return - route = normalize_route(path_value) - recorded = collector.record(ip, route) - self._send_json(HTTPStatus.OK, { - "recorded": bool(recorded), - "route": route, - "reason": None if recorded else "capped", - }) + collector.record(ip, normalize_route(path_value)) + self._send_no_content() def _read_small_body(self) -> bytes: try: @@ -570,6 +569,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} if self.command != "HEAD": self.wfile.write(payload) + def _send_no_content(self, status: int = HTTPStatus.NO_CONTENT) -> None: + self.send_response(int(status)) + self.send_header("Content-Length", "0") + self._security_headers() + self.end_headers() + def _send_html(self, status: int, html: str) -> None: payload = html.encode("utf-8") self.send_response(int(status)) diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 28928ec..78a0972 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -494,6 +494,8 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo } .status-grid span { display: block; color: var(--muted); font-size: 10.5px; } .status-grid strong { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 650; } +/* 预计下次运行等 3 项:PC 与移动端都保持一行 */ +.status-grid.status-row-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .status-panel .text-link { display: inline-block; margin-top: 10px; } .segmented { diff --git a/reaction_board/web/static/views/system.js b/reaction_board/web/static/views/system.js index c3343da..9fd9767 100644 --- a/reaction_board/web/static/views/system.js +++ b/reaction_board/web/static/views/system.js @@ -92,7 +92,7 @@ export async function renderSystem(root) {
${sectionHeader("预计下次运行")} -
+
搜索发现${escapeHtml(formatDateTime(next.discover))}
每日快照${escapeHtml(formatDateTime(next.snapshot))}
发现周期${system.schedule.discover_minutes} 分钟
@@ -100,21 +100,6 @@ export async function renderSystem(root) {

预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。

-
- ${sectionHeader("历史收藏夹")} -

系统已不再同步收藏夹;下列统计仅保留历史来源。收藏夹由人工维护,重排序使用 favsort.py。

- ${(system.folders || []).map((folder) => ` -
-
- ${escapeHtml(folder.title || String(folder.year))} - ${formatCount(folder.media_count)} / ${formatCount(folder.capacity)} -
-
-

历史快照 ${escapeHtml(formatDateTime(folder.last_ids_at))}

-
`).join("") || emptyState("无历史收藏夹记录")} -
-
${sectionHeader("授权与备份")}
diff --git a/reaction_board/web/static/views/up.js b/reaction_board/web/static/views/up.js index f178002..0d06b84 100644 --- a/reaction_board/web/static/views/up.js +++ b/reaction_board/web/static/views/up.js @@ -9,7 +9,7 @@ import { import { replaceQuery } from "../router.js"; const SORTS = [ - ["view", "播放"], ["efficiency", "内容效率"], ["latest", "最新发布"], + ["latest", "发布时间"], ["view", "播放"], ["efficiency", "内容效率"], ["interaction", "互动率"], ]; @@ -29,7 +29,7 @@ function sortedVideos(videos, sort) { export async function renderUp(root, { params, query }) { const mid = Number(params[0]); - const sort = query.get("sort") || "view"; + const sort = query.get("sort") || "latest"; const up = await api(`/api/ups/${mid}`); const videos = sortedVideos(up.videos || [], sort); const topForBar = [...(up.videos || [])] @@ -47,12 +47,9 @@ export async function renderUp(root, { params, query }) { ${avatar(up, "hero-avatar")}

${escapeHtml(up.up_name || `UP ${mid}`)}

-
- mid ${up.up_mid} - 首RE时间 ${escapeHtml(formatDate(up.first_pubdate))} - ${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""} -
-

最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}

+ ${up.blacklisted ? `
${badge("黑名单 · 仅影响发现", "warning")}
` : ""} +

首次观看 ${escapeHtml(formatDate(up.first_pubdate))}

+

最近观看 ${escapeHtml(formatDate(up.latest_pubdate))}

diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 7302535..5429a52 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -194,37 +194,37 @@ def test_visit_endpoint_and_stats(): running = RunningServer(tmp) try: ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/155.0"} - # 上报:可信对端 127.0.0.1 + X-Real-IP → 记录真实 IP 与归一化路由 - status, _, payload = running.json( + collector = running.server.context.visits + + # 上报:返回 204(无响应体),可信对端 + X-Real-IP → 记录真实 IP 与归一化路由 + status, _, body = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"}, body=json.dumps({"path": "/video/123"}), ) - assert status == 200 and payload["data"]["recorded"] is True - assert payload["data"]["route"] == "/video/:aid" - - status, _, payload = running.json( + assert status == 204 and body == b"" + status, _, body = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"}, body=json.dumps({"path": "/videos"}), ) - assert status == 200 and payload["data"]["recorded"] is True + assert status == 204 and body == b"" - collector = running.server.context.visits assert collector.pending_keys == 2 + routes = {(row["ip"], row["path"]) for row in collector.pending_rows()} + assert routes == {("203.0.113.7", "/video/:aid"), ("203.0.113.7", "/videos")} # 机器人 UA 被过滤,不计数 - status, _, payload = running.json( + status, _, body = running.request( "/api/visit", method="POST", headers={"User-Agent": "Googlebot/2.1", "Content-Type": "application/json"}, body=json.dumps({"path": "/home"}), ) - assert status == 200 and payload["data"]["recorded"] is False - assert payload["data"]["reason"] == "bot" + assert status == 204 assert collector.pending_keys == 2 - # 超大请求体被拒绝 - status, _, _ = running.json( + # 超大请求体被拒绝(JSON 错误仍返回) + status, _, _ = running.request( "/api/visit", method="POST", headers={**ua, "Content-Type": "application/json"}, body=json.dumps({"path": "/" + "x" * 4000}), @@ -283,9 +283,9 @@ def test_visit_query_path_drains_body(): headers={**ua, "Content-Type": "application/json"}, ) first = conn.getresponse() - first_payload = json.loads(first.read()) - assert first.status == 200 - assert first_payload["data"]["route"] == "/about" + assert first.status == 204 and first.read() == b"" + # 路径来自查询串而非请求体 + assert running.server.context.visits.pending_rows()[0]["path"] == "/about" # 同一连接继续发请求:若上一次请求体未被读走,这里会解析错位 conn.request("GET", "/api/health")