feat(web): add fan date range modes and delta column
This commit is contained in:
1 parent
998d0fd0c0
commit
274eccba93
6 files changed
+253
-22
No files matched your search
+120
-15
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -89,6 +90,84 @@ def _safe_float(value: Any) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _with_fan_deltas(
|
||||||
|
rows: Sequence[Mapping[str, Any]], recent_start: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""给最近记录附加“相较上一成功日期”的增量。"""
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
last_ok: int | None = None
|
||||||
|
for row in rows:
|
||||||
|
day = str(row["stat_date"])
|
||||||
|
ok = int(row.get("ok") or 0) == 1
|
||||||
|
delta: int | None = None
|
||||||
|
if ok:
|
||||||
|
follower = int(row.get("follower") or 0)
|
||||||
|
if last_ok is not None:
|
||||||
|
delta = follower - last_ok
|
||||||
|
last_ok = follower
|
||||||
|
if day >= recent_start:
|
||||||
|
result.append({**dict(row), "delta": delta})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_fan_series(
|
||||||
|
rows: Sequence[Mapping[str, Any]], start: str, end: str
|
||||||
|
) -> tuple[list[str], list[int | None]]:
|
||||||
|
by_date = {str(row["stat_date"]): row for row in rows}
|
||||||
|
dates = _date_range(start, end)
|
||||||
|
series: list[int | None] = []
|
||||||
|
for day in dates:
|
||||||
|
row = by_date.get(day)
|
||||||
|
if row is None or int(row.get("ok") or 0) != 1:
|
||||||
|
series.append(None)
|
||||||
|
else:
|
||||||
|
series.append(int(row["follower"] or 0))
|
||||||
|
return dates, series
|
||||||
|
|
||||||
|
|
||||||
|
def _monthly_peak_series(
|
||||||
|
rows: Sequence[Mapping[str, Any]], start: str, end: str
|
||||||
|
) -> tuple[list[str], list[int | None]]:
|
||||||
|
"""超过一年时按自然月聚合,每月取粉丝峰值所在的日期。"""
|
||||||
|
start_day = date.fromisoformat(start)
|
||||||
|
end_day = date.fromisoformat(end)
|
||||||
|
peaks: dict[tuple[int, int], tuple[str, int]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if int(row.get("ok") or 0) != 1:
|
||||||
|
continue
|
||||||
|
day = date.fromisoformat(str(row["stat_date"]))
|
||||||
|
key = (day.year, day.month)
|
||||||
|
value = int(row["follower"] or 0)
|
||||||
|
current = peaks.get(key)
|
||||||
|
if current is None or value > current[1] or (
|
||||||
|
value == current[1] and str(row["stat_date"]) > current[0]
|
||||||
|
):
|
||||||
|
peaks[key] = (str(row["stat_date"]), value)
|
||||||
|
|
||||||
|
dates: list[str] = []
|
||||||
|
series: list[int | None] = []
|
||||||
|
year, month = start_day.year, start_day.month
|
||||||
|
while (year, month) <= (end_day.year, end_day.month):
|
||||||
|
peak = peaks.get((year, month))
|
||||||
|
if peak is not None:
|
||||||
|
dates.append(peak[0])
|
||||||
|
series.append(peak[1])
|
||||||
|
else:
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
placeholder = date(year, month, last_day)
|
||||||
|
if placeholder < start_day:
|
||||||
|
placeholder = start_day
|
||||||
|
if placeholder > end_day:
|
||||||
|
placeholder = end_day
|
||||||
|
dates.append(placeholder.isoformat())
|
||||||
|
series.append(None)
|
||||||
|
month += 1
|
||||||
|
if month > 12:
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
return dates, series
|
||||||
|
|
||||||
|
|
||||||
class DashboardQueries:
|
class DashboardQueries:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -899,12 +978,22 @@ class DashboardQueries:
|
|||||||
mid: int | None = None,
|
mid: int | None = None,
|
||||||
*,
|
*,
|
||||||
days: int = 90,
|
days: int = 90,
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
recent_days: int = 14,
|
recent_days: int = 14,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if days < 1:
|
if days < 1:
|
||||||
raise ValueError("days 必须大于 0")
|
raise ValueError("days 必须大于 0")
|
||||||
if recent_days < 1 or recent_days > 3650:
|
if recent_days < 1 or recent_days > 3650:
|
||||||
raise ValueError("recent_days 必须在 1~3650 之间")
|
raise ValueError("recent_days 必须在 1~3650 之间")
|
||||||
|
for label, value in (("start", start), ("end", end)):
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
date.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{label} 必须是 YYYY-MM-DD") from exc
|
||||||
|
if start and end and start > end:
|
||||||
|
raise ValueError("start 不能晚于 end")
|
||||||
|
|
||||||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
cfg = self.config_store.get()
|
cfg = self.config_store.get()
|
||||||
@@ -925,7 +1014,8 @@ class DashboardQueries:
|
|||||||
"mid": None, "name": None, "available": False,
|
"mid": None, "name": None, "available": False,
|
||||||
"options": options, "dates": [], "series": [],
|
"options": options, "dates": [], "series": [],
|
||||||
"points": [], "recent_points": [], "days": days,
|
"points": [], "recent_points": [], "days": days,
|
||||||
"recent_days": recent_days,
|
"mode": "daily", "range_start": None, "range_end": None,
|
||||||
|
"span_days": 0, "recent_days": recent_days,
|
||||||
}
|
}
|
||||||
rows = [
|
rows = [
|
||||||
dict(row) for row in conn.execute(
|
dict(row) for row in conn.execute(
|
||||||
@@ -944,24 +1034,35 @@ class DashboardQueries:
|
|||||||
"points": [],
|
"points": [],
|
||||||
"recent_points": [],
|
"recent_points": [],
|
||||||
"days": days,
|
"days": days,
|
||||||
"recent_days": recent_days,
|
"mode": "daily", "range_start": None, "range_end": None,
|
||||||
|
"span_days": 0, "recent_days": recent_days,
|
||||||
}
|
}
|
||||||
latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"])
|
latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"])
|
||||||
first_date = str(rows[0]["stat_date"])
|
first_date = str(rows[0]["stat_date"])
|
||||||
recent_start = _date_minus(latest, recent_days - 1)
|
recent_start = _date_minus(latest, recent_days - 1)
|
||||||
recent_points = [
|
recent_points = _with_fan_deltas(rows, recent_start)
|
||||||
row for row in rows if str(row["stat_date"]) >= recent_start
|
if start or end:
|
||||||
]
|
range_start = max(str(start or first_date), first_date)
|
||||||
start = max(_date_minus(latest, days - 1), first_date)
|
range_end = min(str(end or latest), latest)
|
||||||
dates = _date_range(start, latest)
|
if range_start > range_end:
|
||||||
by_date = {str(row["stat_date"]): row for row in rows}
|
raise ValueError("所选日期范围内没有粉丝数据")
|
||||||
series = []
|
|
||||||
for day in dates:
|
|
||||||
row = by_date.get(day)
|
|
||||||
if row is None or int(row["ok"]) != 1:
|
|
||||||
series.append(None)
|
|
||||||
else:
|
else:
|
||||||
series.append(row["follower"])
|
range_start = max(_date_minus(latest, days - 1), first_date)
|
||||||
|
range_end = latest
|
||||||
|
span_days = (
|
||||||
|
date.fromisoformat(range_end) - date.fromisoformat(range_start)
|
||||||
|
).days + 1
|
||||||
|
mode = "daily" if span_days <= 366 else "monthly"
|
||||||
|
points = [
|
||||||
|
row for row in rows
|
||||||
|
if range_start <= str(row["stat_date"]) <= range_end
|
||||||
|
]
|
||||||
|
if mode == "daily":
|
||||||
|
dates, series = _daily_fan_series(points, range_start, range_end)
|
||||||
|
else:
|
||||||
|
dates, series = _monthly_peak_series(
|
||||||
|
points, range_start, range_end
|
||||||
|
)
|
||||||
ok_rows = [row for row in rows if int(row["ok"]) == 1]
|
ok_rows = [row for row in rows if int(row["ok"]) == 1]
|
||||||
current = ok_rows[-1] if ok_rows else rows[-1]
|
current = ok_rows[-1] if ok_rows else rows[-1]
|
||||||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
||||||
@@ -985,9 +1086,13 @@ class DashboardQueries:
|
|||||||
"available": bool(ok_rows),
|
"available": bool(ok_rows),
|
||||||
"options": options,
|
"options": options,
|
||||||
"days": days,
|
"days": days,
|
||||||
|
"mode": mode,
|
||||||
|
"range_start": range_start,
|
||||||
|
"range_end": range_end,
|
||||||
|
"span_days": span_days,
|
||||||
"dates": dates,
|
"dates": dates,
|
||||||
"series": series,
|
"series": series,
|
||||||
"points": rows,
|
"points": points,
|
||||||
"recent_points": recent_points,
|
"recent_points": recent_points,
|
||||||
"recent_days": recent_days,
|
"recent_days": recent_days,
|
||||||
"stat_date": current["stat_date"],
|
"stat_date": current["stat_date"],
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
self._send_json(HTTPStatus.OK, queries.fans(
|
self._send_json(HTTPStatus.OK, queries.fans(
|
||||||
_optional_int(query, "mid"),
|
_optional_int(query, "mid"),
|
||||||
days=_int_param(query, "days", 90, 1, 3650),
|
days=_int_param(query, "days", 90, 1, 3650),
|
||||||
|
start=_optional_str(query, "start"),
|
||||||
|
end=_optional_str(query, "end"),
|
||||||
recent_days=_int_param(query, "recent_days", 14, 1, 3650),
|
recent_days=_int_param(query, "recent_days", 14, 1, 3650),
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -531,6 +531,24 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.filter-row .select { width: 100%; max-width: none; }
|
.filter-row .select { width: 100%; max-width: none; }
|
||||||
}
|
}
|
||||||
.growth-days { padding-top: 2px; }
|
.growth-days { padding-top: 2px; }
|
||||||
|
.date-range {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.date-range input[type="date"] {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.date-range span { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
.rank-list { display: flex; flex-direction: column; gap: 8px; }
|
.rank-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.pager {
|
.pager {
|
||||||
@@ -632,6 +650,7 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.data-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 600; }
|
.data-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||||
.data-table tr:last-child td { border-bottom: 0; }
|
.data-table tr:last-child td { border-bottom: 0; }
|
||||||
.data-table .failed-row td { color: var(--muted); background: color-mix(in srgb, var(--danger) 5%, transparent); }
|
.data-table .failed-row td { color: var(--muted); background: color-mix(in srgb, var(--danger) 5%, transparent); }
|
||||||
|
.fans-page .data-table { min-width: 0; }
|
||||||
.summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
.summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
.hero-number {
|
.hero-number {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { api } from "../api.js";
|
|||||||
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
||||||
import { lineChart, themeColor } from "../charts.js";
|
import { lineChart, themeColor } from "../charts.js";
|
||||||
import {
|
import {
|
||||||
escapeHtml, formatCount, formatDelta, formatNumber,
|
escapeAttr, escapeHtml, formatCount, formatDelta, formatNumber,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
import { replaceQuery } from "../router.js";
|
import { replaceQuery } from "../router.js";
|
||||||
|
|
||||||
@@ -11,7 +11,15 @@ const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", "
|
|||||||
export async function renderFans(root, { query }) {
|
export async function renderFans(root, { query }) {
|
||||||
const days = query.get("days") || "90";
|
const days = query.get("days") || "90";
|
||||||
const mid = query.get("mid");
|
const mid = query.get("mid");
|
||||||
const fans = await api("/api/fans", { days, mid });
|
const start = query.get("start") || "";
|
||||||
|
const end = query.get("end") || "";
|
||||||
|
const customRange = Boolean(start || end);
|
||||||
|
const fans = await api("/api/fans", {
|
||||||
|
days: customRange ? null : days,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
mid,
|
||||||
|
});
|
||||||
const options = fans.options || [];
|
const options = fans.options || [];
|
||||||
|
|
||||||
root.innerHTML = `<section class="page fans-page">
|
root.innerHTML = `<section class="page fans-page">
|
||||||
@@ -43,10 +51,22 @@ export async function renderFans(root, { query }) {
|
|||||||
${sectionHeader("粉丝曲线")}
|
${sectionHeader("粉丝曲线")}
|
||||||
<div class="chip-row scroll-x">
|
<div class="chip-row scroll-x">
|
||||||
${RANGES.map(([value, label]) => `<button type="button"
|
${RANGES.map(([value, label]) => `<button type="button"
|
||||||
class="chip${days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
|
<form class="date-range" id="fan-range">
|
||||||
|
<input type="date" name="start" value="${escapeAttr(start)}" aria-label="开始日期">
|
||||||
|
<span>至</span>
|
||||||
|
<input type="date" name="end" value="${escapeAttr(end)}" aria-label="结束日期">
|
||||||
|
<button class="button" type="submit">应用</button>
|
||||||
|
${customRange ? `<button class="button ghost" type="button" id="fan-range-clear">清除</button>` : ""}
|
||||||
|
</form>
|
||||||
<div class="chart" id="fans-chart"></div>
|
<div class="chart" id="fans-chart"></div>
|
||||||
<p class="section-note">只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。</p>
|
<p class="section-note">
|
||||||
|
只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。
|
||||||
|
${fans.mode === "monthly"
|
||||||
|
? "当前范围超过一年,按每月粉丝峰值所在日期绘制。"
|
||||||
|
: `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -54,13 +74,13 @@ export async function renderFans(root, { query }) {
|
|||||||
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
|
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>日期</th><th>粉丝</th><th>状态</th></tr></thead>
|
<thead><tr><th>日期</th><th>粉丝</th><th>较上一成功日</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${(fans.recent_points || []).slice().reverse().map((point) => `
|
${(fans.recent_points || []).slice().reverse().map((point) => `
|
||||||
<tr class="${point.ok ? "" : "failed-row"}">
|
<tr class="${point.ok ? "" : "failed-row"}">
|
||||||
<td>${escapeHtml(point.stat_date)}</td>
|
<td>${escapeHtml(point.stat_date)}</td>
|
||||||
<td>${point.ok ? formatNumber(point.follower) : "—"}</td>
|
<td>${point.ok ? formatNumber(point.follower) : "—"}</td>
|
||||||
<td>${point.ok ? "成功" : "失败"}</td>
|
<td>${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)}</td>
|
||||||
</tr>`).join("")}
|
</tr>`).join("")}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -69,11 +89,25 @@ export async function renderFans(root, { query }) {
|
|||||||
</section>`;
|
</section>`;
|
||||||
|
|
||||||
root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => {
|
root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => {
|
||||||
replaceQuery({ days: button.dataset.days });
|
replaceQuery({ days: button.dataset.days, start: null, end: null });
|
||||||
}));
|
}));
|
||||||
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
||||||
replaceQuery({ mid: event.target.value });
|
replaceQuery({ mid: event.target.value });
|
||||||
});
|
});
|
||||||
|
root.querySelector("#fan-range")?.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.target);
|
||||||
|
const nextStart = String(form.get("start") || "").trim();
|
||||||
|
const nextEnd = String(form.get("end") || "").trim();
|
||||||
|
if (nextStart && nextEnd && nextStart > nextEnd) {
|
||||||
|
window.alert("开始日期不能晚于结束日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceQuery({ start: nextStart || null, end: nextEnd || null, days: null });
|
||||||
|
});
|
||||||
|
root.querySelector("#fan-range-clear")?.addEventListener("click", () => {
|
||||||
|
replaceQuery({ start: null, end: null, days: null });
|
||||||
|
});
|
||||||
centerActiveChips(root);
|
centerActiveChips(root);
|
||||||
const chartElement = root.querySelector("#fans-chart");
|
const chartElement = root.querySelector("#fans-chart");
|
||||||
if (chartElement) {
|
if (chartElement) {
|
||||||
|
|||||||
@@ -197,6 +197,52 @@ def test_up_queries():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fans_custom_range_modes():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db, q = setup(tmp)
|
||||||
|
try:
|
||||||
|
daily = q.fans(start="2026-10-29", end="2026-10-31")
|
||||||
|
assert daily["mode"] == "daily"
|
||||||
|
assert daily["range_start"] == "2026-10-29"
|
||||||
|
assert daily["range_end"] == "2026-10-31"
|
||||||
|
assert daily["dates"] == list(DATES)
|
||||||
|
assert daily["series"] == [1000, 1010, 1020]
|
||||||
|
|
||||||
|
# 超过一年触发按月峰值模式
|
||||||
|
db.write(lambda conn: conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO up_stat"
|
||||||
|
"(stat_date, mid, follower, following, captured_at, ok) "
|
||||||
|
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
|
||||||
|
))
|
||||||
|
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||||||
|
assert monthly["mode"] == "monthly"
|
||||||
|
assert monthly["span_days"] == 669
|
||||||
|
assert monthly["dates"][0] == "2025-01-01"
|
||||||
|
assert monthly["series"][0] == 500
|
||||||
|
assert monthly["dates"][-1] == "2026-10-31"
|
||||||
|
assert monthly["series"][-1] == 1020
|
||||||
|
|
||||||
|
# 同月多条时取峰值日期,并优先较晚日期
|
||||||
|
db.write(lambda conn: conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO up_stat"
|
||||||
|
"(stat_date, mid, follower, following, captured_at, ok) "
|
||||||
|
"VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), "
|
||||||
|
"('2026-10-16', 37754047, 5000, NULL, 1, 1)"
|
||||||
|
))
|
||||||
|
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||||||
|
assert monthly["dates"][-1] == "2026-10-16"
|
||||||
|
assert monthly["series"][-1] == 5000
|
||||||
|
|
||||||
|
try:
|
||||||
|
q.fans(start="2026-11-01", end="2026-10-01")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("start 晚于 end 应报错")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_fans_candidates_system_and_export():
|
def test_fans_candidates_system_and_export():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
db, q = setup(tmp)
|
db, q = setup(tmp)
|
||||||
@@ -223,6 +269,10 @@ def test_fans_candidates_system_and_export():
|
|||||||
recent = q.fans(days=90)
|
recent = q.fans(days=90)
|
||||||
assert recent["recent_days"] == 14
|
assert recent["recent_days"] == 14
|
||||||
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
|
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
|
||||||
|
deltas = {point["stat_date"]: point["delta"] for point in recent["recent_points"]}
|
||||||
|
assert deltas["2026-10-29"] == 100 # 对比 2026-10-01 的 900
|
||||||
|
assert deltas["2026-10-30"] == 10
|
||||||
|
assert deltas["2026-10-31"] == 10
|
||||||
one_day = q.fans(days=90, recent_days=1)
|
one_day = q.fans(days=90, recent_days=1)
|
||||||
assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]]
|
assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]]
|
||||||
|
|
||||||
@@ -262,6 +312,7 @@ def main_test():
|
|||||||
test_growth_and_cold_start()
|
test_growth_and_cold_start()
|
||||||
test_video_detail_and_trend()
|
test_video_detail_and_trend()
|
||||||
test_up_queries()
|
test_up_queries()
|
||||||
|
test_fans_custom_range_modes()
|
||||||
test_fans_candidates_system_and_export()
|
test_fans_candidates_system_and_export()
|
||||||
print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过")
|
print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过")
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,26 @@ def test_readonly_routes_and_static():
|
|||||||
point["stat_date"] >= "2026-10-18"
|
point["stat_date"] >= "2026-10-18"
|
||||||
for point in fans["recent_points"]
|
for point in fans["recent_points"]
|
||||||
)
|
)
|
||||||
|
assert all("delta" in point for point in fans["recent_points"])
|
||||||
|
|
||||||
|
status, _, payload = running.request(
|
||||||
|
"/api/fans?start=2026-10-29&end=2026-10-31"
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
data = json.loads(payload)["data"]
|
||||||
|
assert data["mode"] == "daily"
|
||||||
|
assert data["dates"] == ["2026-10-29", "2026-10-30", "2026-10-31"]
|
||||||
|
|
||||||
|
running.db.write(lambda conn: conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO up_stat"
|
||||||
|
"(stat_date, mid, follower, following, captured_at, ok) "
|
||||||
|
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
|
||||||
|
))
|
||||||
|
status, _, payload = running.request(
|
||||||
|
"/api/fans?start=2025-01-01&end=2026-10-31"
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert json.loads(payload)["data"]["mode"] == "monthly"
|
||||||
|
|
||||||
status, headers, payload = running.request("/api/videos", method="HEAD")
|
status, headers, payload = running.request("/api/videos", method="HEAD")
|
||||||
assert status == 200 and payload == b""
|
assert status == 200 and payload == b""
|
||||||
|
|||||||
Reference in new issue
Block a user