feat(web): visit 204 response, up page header/sort, trim system page
This commit is contained in:
1 parent
e3bd2e601b
commit
84ce1e41e8
7 files changed
+41
-54
No files matched your search
+1
-1
@@ -710,7 +710,7 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时
|
||||
| UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 |
|
||||
| **粉丝页** | 咻咻满粉丝数曲线 + 每日/7 日净增 + 累计增长(标注起始日期)+ 最近 14 天记录;支持 7/30/90/全部与自选起止日期,范围不超过一年按日绘制,超过一年按每月峰值日期绘制;只画粉丝,不叠加视频数据 |
|
||||
| **候选池** | 候选、得分、通知时间、剩余 TTL;只读展示,操作指向 Telegram/CLI |
|
||||
| **系统状态** | 最近任务、采集成功率、收藏夹同步时间、下次任务、备份、鉴权、黑名单 |
|
||||
| **系统状态** | 最近任务、采集成功率、下次任务、备份、鉴权、黑名单与访问统计(PV/UV) |
|
||||
|
||||
### 9.2 技术
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function renderSystem(root) {
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("预计下次运行")}
|
||||
<div class="status-grid">
|
||||
<div class="status-grid status-row-3">
|
||||
<div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div>
|
||||
<div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div>
|
||||
<div><span>发现周期</span><strong>${system.schedule.discover_minutes} 分钟</strong></div>
|
||||
@@ -100,21 +100,6 @@ export async function renderSystem(root) {
|
||||
<p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("历史收藏夹")}
|
||||
<p class="section-note">系统已不再同步收藏夹;下列统计仅保留历史来源。收藏夹由人工维护,重排序使用 favsort.py。</p>
|
||||
${(system.folders || []).map((folder) => `
|
||||
<div class="capacity-row">
|
||||
<div class="capacity-label">
|
||||
<span>${escapeHtml(folder.title || String(folder.year))}</span>
|
||||
<strong>${formatCount(folder.media_count)} / ${formatCount(folder.capacity)}</strong>
|
||||
</div>
|
||||
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
|
||||
class="${folder.warning ? "warning" : ""}"></span></div>
|
||||
<p class="section-note">历史快照 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p>
|
||||
</div>`).join("") || emptyState("无历史收藏夹记录")}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("授权与备份")}
|
||||
<div class="status-grid">
|
||||
|
||||
@@ -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")}
|
||||
<div class="detail-hero-body">
|
||||
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
|
||||
<div class="detail-meta">
|
||||
<span>mid ${up.up_mid}</span>
|
||||
<span>首RE时间 ${escapeHtml(formatDate(up.first_pubdate))}</span>
|
||||
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
|
||||
</div>
|
||||
<p class="section-note">最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
|
||||
${up.blacklisted ? `<div class="detail-meta">${badge("黑名单 · 仅影响发现", "warning")}</div>` : ""}
|
||||
<p class="section-note">首次观看 ${escapeHtml(formatDate(up.first_pubdate))}</p>
|
||||
<p class="section-note">最近观看 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-16
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user