feat: EOD 23:15 seal + midnight seed shell for video/fans

Move daily snapshot/fans to 23:15 and add 00:01 seed that clones
last_completed ok=1 rows into today without advancing completed.
Evening runs force through seed markers; growth previous is per-aid
adjacent. Home shows short-update clock + 全站数据 date, and 收录视频
counts.
This commit is contained in:
bot-ci committed 2026-11-02 12:38:55 +08:00
1 parent 374b138041
commit 8a9cb21408
17 files changed
+520 -39

No files matched your search

+65 -11
View File
@@ -284,6 +284,37 @@ class DashboardQueries:
result[int(data["aid"])] = data
return result
@classmethod
def _snapshot_previous_relative(
cls,
conn: sqlite3.Connection,
current: Mapping[int, Mapping[str, Any]],
*,
days_back: int,
) -> dict[int, dict[str, Any]]:
"""相对各片当前快照日取 previous:days_back=1 为相邻成功日,否则 <= current-N。"""
if not current or days_back < 1:
return {}
by_date: dict[str, list[int]] = {}
for aid, snap in current.items():
day = snap.get("stat_date")
if not day:
continue
by_date.setdefault(str(day), []).append(int(aid))
result: dict[int, dict[str, Any]] = {}
for day, aids in by_date.items():
if days_back <= 1:
prev = cls._snapshot_map(
conn, aids, before=day, before_inclusive=False,
)
else:
baseline = _date_minus(day, days_back)
prev = cls._snapshot_map(
conn, aids, before=baseline, before_inclusive=True,
)
result.update(prev)
return result
@staticmethod
def _videos(
conn: sqlite3.Connection,
@@ -443,7 +474,8 @@ class DashboardQueries:
videos = self._videos(conn)
aids = [int(video["aid"]) for video in videos]
snapshots = self._snapshot_map(conn, aids)
previous = self._snapshot_map(conn, aids, before=latest or self._today())
# 播放增量:按片相对当前点的相邻成功日,避免 seed/recent 拉到 T-2。
previous = self._snapshot_previous_relative(conn, snapshots, days_back=1)
totals = {metric: 0 for metric in METRICS}
for video in videos:
metrics = _metric_map(snapshots.get(int(video["aid"])))
@@ -521,6 +553,7 @@ class DashboardQueries:
reverse=True,
)
tasks = self._task_records(conn)
updated_at = self._latest_short_update_at(tasks)
auth = {
"ok": self._kv(conn, "auth:ok"),
"message": self._kv(conn, "auth:message"),
@@ -535,6 +568,7 @@ class DashboardQueries:
"generated_at": int(self._now()),
"today": self._today(),
"data_date": latest,
"updated_at": updated_at,
"snapshot_days": snapshot_days,
"first_seen_date": first_seen_date,
"is_first_day": is_first_day,
@@ -542,6 +576,7 @@ class DashboardQueries:
"added_today": added_today,
"counts": {
**by_status,
"total": sum(by_status.values()),
"active_ups": int(conn.execute(
"SELECT COUNT(DISTINCT up_mid) FROM video "
"WHERE status IN ('active','unfaved') AND up_mid IS NOT NULL"
@@ -571,6 +606,22 @@ class DashboardQueries:
row = conn.execute("SELECT v FROM kv WHERE k = ?", (key,)).fetchone()
return None if row is None else str(row["v"])
@staticmethod
def _latest_short_update_at(tasks: Mapping[str, Any]) -> int | None:
"""首页「更新」时刻:优先短更新任务,其次全日更 / 粉丝。"""
for name in ("snapshot_recent", "snapshot", "fans"):
raw = tasks.get(name)
if not isinstance(raw, Mapping):
continue
finished = raw.get("finished_at")
if finished is None:
continue
try:
return int(finished)
except (TypeError, ValueError):
continue
return None
def _task_records(self, conn: sqlite3.Connection) -> dict[str, Any]:
rows = conn.execute(
"SELECT k, v FROM kv WHERE k LIKE 'task:last:%' ORDER BY k"
@@ -802,8 +853,10 @@ class DashboardQueries:
previous: dict[int, dict[str, Any]] = {}
if sort in {"growth", "growth_rate", "interaction"}:
baseline_date = _date_minus(latest, growth_days)
previous = self._snapshot_map(conn, aids, before=baseline_date, before_inclusive=True)
# 相对各片当前快照日回退 growth_days,避免 seed/recent 相对 last_completed 拉长窗口。
previous = self._snapshot_previous_relative(
conn, snapshots, days_back=growth_days,
)
elif sort in {"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku"}:
previous = {}
@@ -1780,14 +1833,16 @@ class DashboardQueries:
next_runs = {
"fans": int(next_daily_run(now, cfg.schedule.snapshot_at)),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
"snapshot_seed": int(next_daily_run(now, cfg.schedule.seed_at)),
"discover": int(now + discover_interval),
"snapshot_recent": int(now + recent_interval),
}
for name, key in (
("fans", "fans"),
("snapshot", "snapshot"),
("discover", "discovery"),
("snapshot_recent", "snapshot_recent"),
for name, key, at in (
("fans", "fans", cfg.schedule.snapshot_at),
("snapshot", "snapshot", cfg.schedule.snapshot_at),
("snapshot_seed", "snapshot_seed", cfg.schedule.seed_at),
("discover", "discovery", None),
("snapshot_recent", "snapshot_recent", None),
):
record = tasks.get(key)
if not (isinstance(record, dict) and record.get("finished_at")):
@@ -1801,9 +1856,7 @@ class DashboardQueries:
)
next_runs[name] = finished_at + interval
else:
next_runs[name] = int(
next_daily_run(finished_at, cfg.schedule.snapshot_at)
)
next_runs[name] = int(next_daily_run(finished_at, at))
errors = [
{"task": name, **record}
for name, record in tasks.items()
@@ -1840,6 +1893,7 @@ class DashboardQueries:
"schedule": {
"discover_minutes": cfg.schedule.discover_minutes,
"snapshot_at": cfg.schedule.snapshot_at,
"seed_at": cfg.schedule.seed_at,
"candidate_ttl_days": cfg.schedule.candidate_ttl_days,
"recent_snapshot_minutes": cfg.schedule.recent_snapshot_minutes,
"recent_video_days": cfg.schedule.recent_video_days,
+16
View File
@@ -55,6 +55,22 @@ export function formatDate(timestamp) {
}).format(date);
}
export function formatClock(timestamp) {
if (!timestamp) return "—";
const date = new Date(Number(timestamp) * 1000);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", hour12: false,
timeZone: "Asia/Shanghai",
}).format(date);
}
export function formatDataDate(value) {
if (!value) return "—";
return String(value).replace(/-/g, "/");
}
export function formatDateTime(timestamp) {
if (!timestamp) return "—";
const date = new Date(Number(timestamp) * 1000);
+11 -4
View File
@@ -1,8 +1,8 @@
import { api } from "../api.js";
import { emptyState, mediaCard } from "../components.js";
import {
escapeAttr, escapeHtml, formatCount, formatDelta,
formatPercent, formatScore,
escapeAttr, escapeHtml, formatClock, formatCount, formatDataDate,
formatDelta, formatPercent, formatScore,
} from "../format.js";
import { canAccessProtected } from "../session.js";
@@ -55,6 +55,11 @@ export async function renderHome(root) {
? formatCount(overview.initial_import_count)
: formatCount(overview.added_today);
const activeCount = overview.counts?.active || 0;
const totalCount = overview.counts?.total
?? ((overview.counts?.active || 0)
+ (overview.counts?.unfaved || 0)
+ (overview.counts?.removed || 0)
+ (overview.counts?.dropped || 0));
const pending = overview.counts?.candidates_pending || 0;
// 候选提示仅对已登录(或未启用鉴权)用户可见,访客不展示运行期信息。
const showCandidates = pending > 0 && canAccessProtected();
@@ -66,12 +71,14 @@ export async function renderHome(root) {
const growthEmptyHint = growthDays <= 1
? "当前快照不足,完成至少 2 天快照后即可显示播放增量。"
: `需要至少 ${growthDays + 1} 天快照才能计算近 ${growthDays} 日增长。`;
const updatedLabel = formatClock(overview.updated_at);
const dataDateLabel = formatDataDate(overview.data_date);
root.innerHTML = `<section class="page home-page">
<header class="page-head home-head">
<div>
<h1>最新动态</h1>
<p class="page-sub">收录视频 ${formatCount(activeCount)}</p>
<p class="page-sub">更新:${escapeHtml(updatedLabel)} / 全站数据:${escapeHtml(dataDateLabel)}</p>
</div>
<button class="icon-button" id="refresh-home" type="button" title="刷新">刷新</button>
</header>
@@ -86,7 +93,7 @@ export async function renderHome(root) {
${statChip("7 日粉丝变化", fans?.delta_7d === null || fans?.delta_7d === undefined ? "—" : formatDelta(fans.delta_7d), {
href: "/fans",
})}
${statChip("数据日期", escapeHtml(overview.data_date || "—"))}
${statChip("收录视频", `${formatCount(activeCount)} / ${formatCount(totalCount)}`)}
</div>
${showCandidates ? `<a class="notice-row" href="/candidates">
@@ -94,6 +94,7 @@ export async function renderSystem(root) {
<section class="panel">
${sectionHeader("预计下次运行")}
<div class="status-grid status-row-4">
<div><span>午夜落盘克隆</span><strong>${running.snapshot_seed ? "运行中" : escapeHtml(formatDateTime(next.snapshot_seed))}</strong></div>
<div><span>咻咻满粉丝数更新</span><strong>${running.fans ? "运行中" : escapeHtml(formatDateTime(next.fans))}</strong></div>
<div><span>视频数据快照 + UP 信息</span><strong>${running.snapshot ? "运行中" : escapeHtml(formatDateTime(next.snapshot))}</strong></div>
<div><span>新视频小时同步</span><strong>${running.snapshot_recent ? "运行中" : escapeHtml(formatDateTime(next.snapshot_recent))}</strong></div>