Keep small-base videos on the delta board only, and raise the smoothing prior to 1000 so videos that just clear the floor do not dominate the ranking.
2041 lines
83 KiB
Python
2041 lines
83 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""只读看板查询层。
|
||
|
||
所有 SQL 都在这里集中,Web 层只负责参数解析与 JSON 序列化。查询不会写入
|
||
数据库,也不会访问 B 站。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import calendar
|
||
import json
|
||
import math
|
||
import sqlite3
|
||
import time
|
||
from datetime import date, timedelta
|
||
from typing import Any, Iterable, Mapping, Sequence
|
||
|
||
from ..analytics import (
|
||
DEFAULT_HEAT_WEIGHTS,
|
||
METRICS,
|
||
MIN_VIEW_FOR_GROWTH_RATE,
|
||
compute_growth,
|
||
smoothed_growth_rate,
|
||
content_hours,
|
||
eb_rate,
|
||
effective_shrink_minutes,
|
||
heat_scores,
|
||
interactions_per_hour,
|
||
interaction_rate,
|
||
is_mature,
|
||
normalize_cover,
|
||
score_percentiles,
|
||
views_per_hour,
|
||
)
|
||
from ..config import ConfigStore
|
||
from ..database import Database
|
||
from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime
|
||
from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key
|
||
|
||
TRACKED_STATUSES = ("active", "unfaved")
|
||
VIDEO_SORTS = {
|
||
"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku",
|
||
"interaction", "growth", "growth_rate", "added", "published",
|
||
"efficiency", "interaction_efficiency",
|
||
}
|
||
UP_SORTS = {
|
||
"views", "count", "avg_views", "interaction", "avg_interaction", "latest",
|
||
"efficiency", "interaction_efficiency", "duration",
|
||
}
|
||
# 搜索字段白名单:视频搜索匹配标题或 UP 名,UP 搜索只匹配 UP 名。
|
||
_Q_FIELDS = {"title": "v.title", "up_name": "v.up_name"}
|
||
CAPACITY = 1000
|
||
CAPACITY_WARNING = 950
|
||
TRACK_FANS_NAMES = {37754047: "咻咻满"}
|
||
|
||
|
||
def _chunks(values: Sequence[int], size: int = 400) -> Iterable[Sequence[int]]:
|
||
for index in range(0, len(values), size):
|
||
yield values[index:index + size]
|
||
|
||
|
||
def _growth_baseline_view(item: Mapping[str, Any]) -> int | None:
|
||
"""增速分母:当前播放减去区间增量。zero 基线的增量等于当前播放,结果为 0。"""
|
||
growth = item.get("growth") or {}
|
||
current = (item.get("metrics") or {}).get("view")
|
||
delta = growth.get("delta")
|
||
if current is None or delta is None:
|
||
return None
|
||
try:
|
||
return int(current) - int(delta)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _date_minus(day: str, days: int) -> str:
|
||
return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat()
|
||
|
||
|
||
def _date_range(start: str, end: str) -> list[str]:
|
||
first = date.fromisoformat(start)
|
||
last = date.fromisoformat(end)
|
||
if last < first:
|
||
return []
|
||
return [
|
||
(first + timedelta(days=offset)).isoformat()
|
||
for offset in range((last - first).days + 1)
|
||
]
|
||
|
||
|
||
def _ts_date(timestamp: int | float | None) -> str | None:
|
||
if timestamp is None:
|
||
return None
|
||
return shanghai_date(float(timestamp))
|
||
|
||
|
||
def _like_pattern(value: str) -> str:
|
||
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
return f"%{escaped}%"
|
||
|
||
|
||
def _metric_map(row: Mapping[str, Any] | None) -> dict[str, int] | None:
|
||
if row is None:
|
||
return None
|
||
result: dict[str, int] = {}
|
||
for metric in METRICS:
|
||
value = row.get(metric)
|
||
result[metric] = 0 if value is None else int(value)
|
||
return result
|
||
|
||
|
||
def _safe_float(value: Any) -> float | None:
|
||
if value is None or isinstance(value, bool):
|
||
return None
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
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:
|
||
def __init__(
|
||
self,
|
||
db: Database,
|
||
config_store: ConfigStore,
|
||
*,
|
||
clock=time.time,
|
||
) -> None:
|
||
self.db = db
|
||
self.config_store = config_store
|
||
self._clock = clock
|
||
self._cache_scope = str(getattr(db, "path", "db"))
|
||
|
||
def _cache_version(self, group: str) -> int:
|
||
def run(conn: sqlite3.Connection) -> int:
|
||
row = conn.execute(
|
||
"SELECT v FROM kv WHERE k = ?", (f"cache:v:{group}",)
|
||
).fetchone()
|
||
if row is None:
|
||
return 0
|
||
try:
|
||
return int(str(row["v"]))
|
||
except ValueError:
|
||
return 0
|
||
return self.db.read(run)
|
||
|
||
def _cached(self, group: str, params: dict[str, Any], ttl: float, builder):
|
||
version = self._cache_version(group)
|
||
key = build_cache_key(self._cache_scope, group, version, params)
|
||
hit = DASHBOARD_CACHE.get(key)
|
||
if hit is not None:
|
||
return hit
|
||
value = builder()
|
||
DASHBOARD_CACHE.set(key, ttl, value)
|
||
return value
|
||
|
||
# ------------------------------------------------------------ helpers
|
||
|
||
def _now(self) -> float:
|
||
return float(self._clock())
|
||
|
||
def _today(self) -> str:
|
||
return shanghai_date(self._now())
|
||
|
||
@staticmethod
|
||
def _latest_date(conn: sqlite3.Connection) -> str | None:
|
||
"""数据日期:仅取视频快照任务完整成功后的完成日,避免采集中途抢跑。"""
|
||
row = conn.execute(
|
||
"SELECT v FROM kv WHERE k = 'snapshot:last_completed'"
|
||
).fetchone()
|
||
if row is not None and row["v"]:
|
||
return str(row["v"])
|
||
return None
|
||
|
||
@staticmethod
|
||
def _snapshot_days(conn: sqlite3.Connection) -> int:
|
||
"""快照天数:统计已完整完成的采集日(completed=1),与数据日期口径一致。"""
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) AS n FROM kv "
|
||
"WHERE k LIKE 'snapshot:completed:%' AND v = '1'"
|
||
).fetchone()
|
||
return int(row["n"] or 0)
|
||
|
||
@staticmethod
|
||
def _snapshot_map(
|
||
conn: sqlite3.Connection,
|
||
aids: Sequence[int],
|
||
*,
|
||
before: str | None = None,
|
||
before_inclusive: bool = False,
|
||
) -> dict[int, dict[str, Any]]:
|
||
if not aids:
|
||
return {}
|
||
result: dict[int, dict[str, Any]] = {}
|
||
for chunk in _chunks(list(aids)):
|
||
placeholders = ",".join("?" for _ in chunk)
|
||
if before is None:
|
||
sql = f"""
|
||
SELECT s.* FROM snapshot s
|
||
JOIN (
|
||
SELECT aid, MAX(stat_date) AS stat_date
|
||
FROM snapshot WHERE ok = 1 AND aid IN ({placeholders})
|
||
GROUP BY aid
|
||
) m ON m.aid = s.aid AND m.stat_date = s.stat_date
|
||
"""
|
||
params: list[Any] = list(chunk)
|
||
else:
|
||
operator = "<=" if before_inclusive else "<"
|
||
sql = f"""
|
||
SELECT s.* FROM snapshot s
|
||
JOIN (
|
||
SELECT aid, MAX(stat_date) AS stat_date
|
||
FROM snapshot
|
||
WHERE ok = 1 AND aid IN ({placeholders}) AND stat_date {operator} ?
|
||
GROUP BY aid
|
||
) m ON m.aid = s.aid AND m.stat_date = s.stat_date
|
||
"""
|
||
params = [*chunk, before]
|
||
for row in conn.execute(sql, params).fetchall():
|
||
data = dict(row)
|
||
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,
|
||
*,
|
||
statuses: Sequence[str] = TRACKED_STATUSES,
|
||
folder_id: int | None = None,
|
||
q: str | None = None,
|
||
q_fields: Sequence[str] | None = None,
|
||
within_field: str | None = None,
|
||
within_start: str | None = None,
|
||
within_end: str | None = None,
|
||
up_mid: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"]
|
||
params: list[Any] = list(statuses)
|
||
if folder_id is not None:
|
||
clauses.append("v.folder_id = ?")
|
||
params.append(int(folder_id))
|
||
if up_mid is not None:
|
||
clauses.append("v.up_mid = ?")
|
||
params.append(int(up_mid))
|
||
if q:
|
||
pattern = _like_pattern(q)
|
||
fields = tuple(q_fields) if q_fields else ("title", "up_name")
|
||
columns = []
|
||
for field in fields:
|
||
column = _Q_FIELDS.get(field)
|
||
if column is None:
|
||
raise ValueError(f"不支持的搜索字段: {field}")
|
||
columns.append(column)
|
||
clauses.append(
|
||
"(" + " OR ".join(f"{col} LIKE ? ESCAPE '\\'" for col in columns) + ")"
|
||
)
|
||
params.extend([pattern] * len(columns))
|
||
if within_field:
|
||
if within_field not in {"pubdate", "first_seen"}:
|
||
raise ValueError("within_field 必须是 pubdate 或 first_seen")
|
||
if within_start:
|
||
start_ts, _ = day_bounds(within_start)
|
||
clauses.append(f"v.{within_field} >= ?")
|
||
params.append(start_ts)
|
||
if within_end:
|
||
end_ts, _ = day_bounds(within_end)
|
||
clauses.append(f"v.{within_field} < ?")
|
||
params.append(end_ts)
|
||
sql = (
|
||
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
|
||
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
|
||
f"WHERE {' AND '.join(clauses)} ORDER BY v.aid"
|
||
)
|
||
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
||
|
||
def _global_heat(
|
||
self, conn: sqlite3.Connection
|
||
) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]], dict[int, float], dict[int, float]]:
|
||
videos = self._videos(conn)
|
||
snapshots = self._snapshot_map(conn, [int(v["aid"]) for v in videos])
|
||
rows = []
|
||
for video in videos:
|
||
snapshot = snapshots.get(int(video["aid"]))
|
||
if snapshot is None:
|
||
continue
|
||
item = {"aid": int(video["aid"])}
|
||
item.update(_metric_map(snapshot) or {})
|
||
rows.append(item)
|
||
weights = self.config_store.get().heat_weights
|
||
scores = heat_scores(rows, weights or DEFAULT_HEAT_WEIGHTS)
|
||
percentiles = score_percentiles(scores)
|
||
return videos, snapshots, scores, percentiles
|
||
|
||
@staticmethod
|
||
def _publish_date(pubdate: object) -> str | None:
|
||
if pubdate is None or isinstance(pubdate, bool):
|
||
return None
|
||
try:
|
||
ts = int(pubdate)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if ts <= 0:
|
||
return None
|
||
return shanghai_date(ts)
|
||
|
||
@staticmethod
|
||
def _growth_for(
|
||
snapshot: Mapping[str, Any] | None,
|
||
previous: Mapping[str, Any] | None,
|
||
metric: str = "view",
|
||
):
|
||
if snapshot is None:
|
||
return compute_growth(None, None, None)
|
||
current = snapshot.get(metric)
|
||
before = None if previous is None else previous.get(metric)
|
||
gap = None
|
||
if previous is not None and snapshot.get("stat_date") and previous.get("stat_date"):
|
||
try:
|
||
gap = (
|
||
date.fromisoformat(str(snapshot["stat_date"]))
|
||
- date.fromisoformat(str(previous["stat_date"]))
|
||
).days
|
||
except ValueError:
|
||
gap = None
|
||
return compute_growth(current, before, gap)
|
||
|
||
@classmethod
|
||
def _same_day_growth(
|
||
cls,
|
||
snapshot: Mapping[str, Any] | None,
|
||
video: Mapping[str, Any],
|
||
metric: str = "view",
|
||
) -> dict[str, Any] | None:
|
||
"""近 1 日、没有更早快照时:当日发布相对 0;否则用 view_open。"""
|
||
if snapshot is None:
|
||
return None
|
||
current = snapshot.get(metric)
|
||
if current is None:
|
||
return None
|
||
stat_date = str(snapshot.get("stat_date") or "")
|
||
published_on = cls._publish_date(video.get("pubdate"))
|
||
if published_on is not None and published_on == stat_date:
|
||
growth = compute_growth(current, 0, 0, allow_zero_gap=True)
|
||
payload = growth.as_dict()
|
||
payload["baseline"] = "zero"
|
||
# previous=0 时 rate = view/1,会按原始播放碾压真日环比。
|
||
payload["rate"] = None
|
||
return payload
|
||
try:
|
||
open_at = snapshot.get("open_captured_at")
|
||
captured_at = snapshot.get("captured_at")
|
||
view_open = snapshot.get("view_open")
|
||
open_ready = (
|
||
open_at is not None
|
||
and captured_at is not None
|
||
and int(open_at) < int(captured_at)
|
||
and view_open is not None
|
||
)
|
||
except (TypeError, ValueError):
|
||
open_ready = False
|
||
if not open_ready:
|
||
return None
|
||
growth = compute_growth(current, view_open, 0, allow_zero_gap=True)
|
||
payload = growth.as_dict()
|
||
payload["baseline"] = "open"
|
||
return payload
|
||
|
||
@classmethod
|
||
def _growth_result(
|
||
cls,
|
||
video: Mapping[str, Any],
|
||
snapshot: Mapping[str, Any] | None,
|
||
previous: Mapping[str, Any] | None,
|
||
*,
|
||
days_back: int,
|
||
metric: str = "view",
|
||
) -> dict[str, Any]:
|
||
if previous is not None:
|
||
growth = cls._growth_for(snapshot, previous, metric=metric)
|
||
payload = growth.as_dict()
|
||
payload["baseline"] = "snapshot" if growth.available else None
|
||
return payload
|
||
if days_back == 1:
|
||
fallback = cls._same_day_growth(snapshot, video, metric=metric)
|
||
if fallback is not None:
|
||
return fallback
|
||
payload = compute_growth(None, None, None).as_dict()
|
||
payload["baseline"] = None
|
||
return payload
|
||
|
||
def _item(
|
||
self,
|
||
video: Mapping[str, Any],
|
||
snapshot: Mapping[str, Any] | None,
|
||
*,
|
||
heat: float | None = None,
|
||
percentile: float | None = None,
|
||
previous: Mapping[str, Any] | None = None,
|
||
include_growth: bool = False,
|
||
growth_days: int | None = None,
|
||
) -> dict[str, Any]:
|
||
metrics = _metric_map(snapshot)
|
||
duration = video.get("duration")
|
||
hours = content_hours(duration)
|
||
item: dict[str, Any] = {
|
||
"aid": int(video["aid"]),
|
||
"bvid": video.get("bvid"),
|
||
"title": video.get("title") or "(无标题)",
|
||
"up_mid": video.get("up_mid"),
|
||
"up_name": video.get("up_name"),
|
||
"pubdate": int(video.get("pubdate") or 0),
|
||
"duration": duration,
|
||
"pages": video.get("pages"),
|
||
"content_hours": None if hours is None else round(hours, 3),
|
||
"up_follower": video.get("up_follower"),
|
||
"up_follower_at": video.get("up_follower_at"),
|
||
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
|
||
"cover": normalize_cover(video.get("cover")),
|
||
"up_face": normalize_cover(video.get("up_face")),
|
||
"folder_id": video.get("folder_id"),
|
||
"folder_title": video.get("folder_title"),
|
||
"folder_year": video.get("folder_year"),
|
||
"status": video.get("status"),
|
||
"first_seen": int(video.get("first_seen") or 0),
|
||
"attr": video.get("attr"),
|
||
"metrics": metrics,
|
||
"snapshot_date": None if snapshot is None else snapshot.get("stat_date"),
|
||
"heat": None if heat is None else round(float(heat), 3),
|
||
"heat_percentile": percentile,
|
||
"interaction_rate": None if metrics is None else interaction_rate(metrics),
|
||
"efficiency": (
|
||
None if metrics is None
|
||
else views_per_hour(metrics.get("view"), duration)
|
||
),
|
||
"interaction_efficiency": (
|
||
None if metrics is None
|
||
else interactions_per_hour(metrics, duration)
|
||
),
|
||
}
|
||
if include_growth:
|
||
item["growth"] = self._growth_result(
|
||
video, snapshot, previous, days_back=int(growth_days or 1),
|
||
)
|
||
return item
|
||
|
||
# ------------------------------------------------------------ overview
|
||
|
||
def overview(self) -> dict[str, Any]:
|
||
return self._cached("videos", {}, TTL_VIDEOS, self._overview_uncached)
|
||
|
||
def _overview_uncached(self) -> dict[str, Any]:
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
latest = self._latest_date(conn)
|
||
snapshot_days = self._snapshot_days(conn)
|
||
counts_rows = conn.execute(
|
||
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
|
||
).fetchall()
|
||
by_status = {str(row["status"]): int(row["n"]) for row in counts_rows}
|
||
videos = self._videos(conn)
|
||
aids = [int(video["aid"]) for video in videos]
|
||
snapshots = self._snapshot_map(conn, aids)
|
||
# 播放增量:近 1 日。有更早日用相邻成功日;当日发布相对 0;否则用 view_open。
|
||
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"])))
|
||
if metrics:
|
||
for metric in METRICS:
|
||
totals[metric] += metrics[metric]
|
||
|
||
view_delta: int | None = None
|
||
available_deltas = 0
|
||
view_delta_sum = 0
|
||
for video in videos:
|
||
aid = int(video["aid"])
|
||
snapshot = snapshots.get(aid)
|
||
if snapshot is None:
|
||
continue
|
||
growth = self._growth_result(
|
||
video, snapshot, previous.get(aid), days_back=1,
|
||
)
|
||
if growth.get("available") and growth.get("delta") is not None:
|
||
view_delta_sum += int(growth["delta"])
|
||
available_deltas += 1
|
||
if available_deltas:
|
||
view_delta = view_delta_sum
|
||
|
||
first_seen_row = conn.execute(
|
||
"SELECT MIN(first_seen) AS ts FROM video"
|
||
).fetchone()
|
||
first_seen_ts = first_seen_row["ts"] if first_seen_row else None
|
||
first_seen_date = _ts_date(first_seen_ts)
|
||
initial_import_count = 0
|
||
if first_seen_ts is not None:
|
||
initial_import_count = int(conn.execute(
|
||
"SELECT COUNT(*) FROM video WHERE first_seen = ? "
|
||
"AND status IN ('active','unfaved')",
|
||
(int(first_seen_ts),),
|
||
).fetchone()[0])
|
||
|
||
start_ts, end_ts = day_bounds(self._today())
|
||
added_today = int(conn.execute(
|
||
"SELECT COUNT(*) FROM video WHERE first_seen >= ? AND first_seen < ?",
|
||
(start_ts, end_ts),
|
||
).fetchone()[0])
|
||
is_first_day = snapshot_days <= 1
|
||
if is_first_day:
|
||
added_today = 0
|
||
|
||
snapshot_health = {"total": 0, "ok": 0, "failed": 0}
|
||
if latest:
|
||
rows = conn.execute(
|
||
"SELECT ok, COUNT(*) AS n FROM snapshot WHERE stat_date = ? GROUP BY ok",
|
||
(latest,),
|
||
).fetchall()
|
||
by_ok = {int(row["ok"]): int(row["n"]) for row in rows}
|
||
snapshot_health = {
|
||
"total": sum(by_ok.values()),
|
||
"ok": by_ok.get(1, 0),
|
||
"failed": by_ok.get(0, 0),
|
||
}
|
||
|
||
candidate_pending = int(conn.execute(
|
||
"SELECT COUNT(*) FROM candidate WHERE decision IS NULL"
|
||
).fetchone()[0])
|
||
candidate_total = int(conn.execute(
|
||
"SELECT COUNT(*) FROM candidate"
|
||
).fetchone()[0])
|
||
|
||
cfg = self.config_store.get()
|
||
fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days)
|
||
folders = self._folders_overview(conn)
|
||
pub_years = sorted(
|
||
{
|
||
int(shanghai_date(int(video["pubdate"]))[:4])
|
||
for video in videos
|
||
if int(video.get("pubdate") or 0) > 0
|
||
},
|
||
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"),
|
||
"last_check": self._kv(conn, "auth:last_check"),
|
||
"mid": self._kv(conn, "auth:mid"),
|
||
}
|
||
preview = self._kv(conn, "preview:seeded") == "1"
|
||
snapshot_completed_today = self._kv(
|
||
conn, f"snapshot:completed:{self._today()}"
|
||
) == "1"
|
||
return {
|
||
"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,
|
||
"initial_import_count": initial_import_count,
|
||
"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"
|
||
).fetchone()[0]),
|
||
"candidates_pending": candidate_pending,
|
||
"candidates_total": candidate_total,
|
||
},
|
||
"totals": totals,
|
||
"interactions": totals["like"] + totals["coin"] + totals["favorite"],
|
||
"view_delta": view_delta,
|
||
"view_delta_available": view_delta is not None,
|
||
"snapshot_health": snapshot_health,
|
||
"snapshot_completed_today": snapshot_completed_today,
|
||
"fans": fans,
|
||
"folders": folders,
|
||
"years": pub_years,
|
||
"tasks": tasks,
|
||
"auth": auth,
|
||
"preview": preview,
|
||
"capacity": CAPACITY,
|
||
"capacity_warning": CAPACITY_WARNING,
|
||
}
|
||
return self.db.read(run)
|
||
|
||
@staticmethod
|
||
def _kv(conn: sqlite3.Connection, key: str) -> str | None:
|
||
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
|
||
|
||
@staticmethod
|
||
def _task_started_sort_key(value: Any) -> tuple[int, int]:
|
||
"""缺 started_at 的排末尾;有值的按开始时间倒序(含进行中)。"""
|
||
if isinstance(value, Mapping):
|
||
raw = value.get("started_at")
|
||
try:
|
||
return (0, -int(raw))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return (1, 0)
|
||
|
||
def _task_records(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||
rows = conn.execute(
|
||
"SELECT k, v FROM kv WHERE k LIKE 'task:last:%'"
|
||
).fetchall()
|
||
items: list[tuple[str, Any]] = []
|
||
for row in rows:
|
||
name = str(row["k"]).removeprefix("task:last:")
|
||
try:
|
||
value = json.loads(str(row["v"]))
|
||
except (TypeError, json.JSONDecodeError):
|
||
value = {"raw": row["v"]}
|
||
items.append((name, value))
|
||
items.sort(key=lambda pair: self._task_started_sort_key(pair[1]))
|
||
# 插入序即开始时间倒序;前端 Object.entries 保留该序,勿再排序。
|
||
return dict(items)
|
||
|
||
def _task_running(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||
rows = conn.execute(
|
||
"SELECT k, v FROM kv WHERE k LIKE 'task:running:%' ORDER BY k"
|
||
).fetchall()
|
||
result: dict[str, Any] = {}
|
||
for row in rows:
|
||
name = str(row["k"]).removeprefix("task:running:")
|
||
try:
|
||
value = json.loads(str(row["v"]))
|
||
except (TypeError, json.JSONDecodeError):
|
||
value = {"raw": row["v"]}
|
||
if not isinstance(value, dict):
|
||
value = {"raw": value}
|
||
value["running"] = True
|
||
result[name] = value
|
||
return result
|
||
|
||
def _folders_overview(self, conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT f.*,
|
||
(SELECT COUNT(*) FROM folder_resource fr
|
||
WHERE fr.media_id = f.media_id AND fr.active = 1) AS local_count
|
||
FROM folder f ORDER BY f.year DESC, f.media_id DESC
|
||
"""
|
||
).fetchall()
|
||
result = []
|
||
for row in rows:
|
||
media_count = row["media_count"]
|
||
count = int(media_count) if media_count is not None else int(row["local_count"] or 0)
|
||
result.append({
|
||
"media_id": int(row["media_id"]),
|
||
"year": int(row["year"]),
|
||
"title": row["title"] or "",
|
||
"media_count": count,
|
||
"local_count": int(row["local_count"] or 0),
|
||
"last_ids_at": row["last_ids_at"],
|
||
"last_full_at": row["last_full_at"],
|
||
"enabled": bool(row["enabled"]),
|
||
"capacity": CAPACITY,
|
||
"ratio": min(1.0, count / CAPACITY),
|
||
"warning": count >= CAPACITY_WARNING,
|
||
})
|
||
return result
|
||
|
||
def _fans_overview(
|
||
self,
|
||
conn: sqlite3.Connection,
|
||
mid_list: Sequence[int],
|
||
latest: str | None,
|
||
snapshot_days: int,
|
||
) -> dict[str, Any] | None:
|
||
mids = list(mid_list) or [
|
||
int(row["mid"])
|
||
for row in conn.execute("SELECT DISTINCT mid FROM up_stat ORDER BY mid").fetchall()
|
||
]
|
||
if not mids:
|
||
return None
|
||
mid = int(mids[0])
|
||
rows = conn.execute(
|
||
"SELECT * FROM up_stat WHERE mid = ? ORDER BY stat_date", (mid,)
|
||
).fetchall()
|
||
if not rows:
|
||
return None
|
||
ok_rows = [dict(row) for row in rows if int(row["ok"]) == 1]
|
||
if not ok_rows:
|
||
return None
|
||
current = ok_rows[-1]
|
||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
||
if previous is not None:
|
||
try:
|
||
gap = (
|
||
date.fromisoformat(str(current["stat_date"]))
|
||
- date.fromisoformat(str(previous["stat_date"]))
|
||
).days
|
||
except ValueError:
|
||
gap = 0
|
||
if gap != 1:
|
||
previous = None
|
||
baseline = None
|
||
if latest:
|
||
cutoff = _date_minus(latest, 7)
|
||
by_date = {str(row["stat_date"]): row for row in ok_rows}
|
||
baseline = by_date.get(cutoff)
|
||
first = ok_rows[0]
|
||
return {
|
||
"mid": mid,
|
||
"name": TRACK_FANS_NAMES.get(mid, f"UP {mid}"),
|
||
"stat_date": current["stat_date"],
|
||
"follower": current["follower"],
|
||
"following": current["following"],
|
||
"delta_1d": None if previous is None else int(current["follower"] or 0) - int(previous["follower"] or 0),
|
||
"delta_7d": None if baseline is None else int(current["follower"] or 0) - int(baseline["follower"] or 0),
|
||
"delta_cum": int(current["follower"] or 0) - int(first["follower"] or 0),
|
||
"first_date": first["stat_date"],
|
||
"snapshot_days": snapshot_days,
|
||
"points": [
|
||
{
|
||
"date": row["stat_date"],
|
||
"follower": row["follower"] if int(row["ok"]) == 1 else None,
|
||
"ok": bool(row["ok"]),
|
||
}
|
||
for row in rows[-30:]
|
||
],
|
||
}
|
||
|
||
# ------------------------------------------------------------ videos
|
||
|
||
def list_videos(
|
||
self,
|
||
*,
|
||
sort: str = "heat",
|
||
within_days: int | None = None,
|
||
year: int | None = None,
|
||
growth_days: int | str = "auto",
|
||
folder_id: int | None = None,
|
||
q: str | None = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> dict[str, Any]:
|
||
params = {
|
||
"sort": sort,
|
||
"within_days": within_days,
|
||
"year": year,
|
||
"growth_days": growth_days,
|
||
"folder_id": folder_id,
|
||
"q": q or "",
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
return self._cached(
|
||
"videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached(
|
||
sort=sort,
|
||
within_days=within_days,
|
||
year=year,
|
||
growth_days=growth_days,
|
||
folder_id=folder_id,
|
||
q=q,
|
||
page=page,
|
||
page_size=page_size,
|
||
),
|
||
)
|
||
|
||
def _list_videos_uncached(
|
||
self,
|
||
*,
|
||
sort: str = "heat",
|
||
within_days: int | None = None,
|
||
year: int | None = None,
|
||
growth_days: int | str = "auto",
|
||
folder_id: int | None = None,
|
||
q: str | None = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> dict[str, Any]:
|
||
if sort not in VIDEO_SORTS:
|
||
raise ValueError(f"不支持的排序: {sort}")
|
||
# auto:仅增长类排序由服务端选窗(配置 home_growth_days;默认先 7 再降 1)。
|
||
# 首页应省略 growth_days,勿自行探查。其它排序把 auto 当作 7(兼容旧默认)。
|
||
if growth_days == "auto":
|
||
if sort in {"growth", "growth_rate"}:
|
||
preferred = self.config_store.get().analytics.home_growth_days
|
||
if preferred in (1, 7):
|
||
growth_days = int(preferred)
|
||
else:
|
||
trial = self._list_videos_uncached(
|
||
sort=sort,
|
||
within_days=within_days,
|
||
year=year,
|
||
growth_days=7,
|
||
folder_id=folder_id,
|
||
q=q,
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
if trial.get("available") and int(trial.get("total") or 0) > 0:
|
||
return trial
|
||
growth_days = 1
|
||
else:
|
||
growth_days = 7
|
||
growth_days = int(growth_days)
|
||
if growth_days < 1 or growth_days > 365:
|
||
raise ValueError("growth_days 必须在 1~365 之间")
|
||
page = max(1, int(page))
|
||
page_size = max(1, min(int(page_size), 200))
|
||
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
latest = self._latest_date(conn) or self._today()
|
||
analytics = self.config_store.get().analytics
|
||
duration_sorts = {"efficiency", "interaction_efficiency"}
|
||
min_view_for_ranking = max(0, int(analytics.min_view_for_ranking))
|
||
within_field = None
|
||
within_start: str | None = None
|
||
within_end: str | None = None
|
||
if year is not None:
|
||
within_field = "pubdate"
|
||
within_start = f"{int(year)}-01-01"
|
||
within_end = f"{int(year) + 1}-01-01"
|
||
elif within_days is not None:
|
||
if within_days < 1:
|
||
raise ValueError("within_days 必须大于 0")
|
||
within_field = "first_seen" if sort == "added" else "pubdate"
|
||
within_start = _date_minus(latest, int(within_days) - 1)
|
||
videos = self._videos(
|
||
conn,
|
||
folder_id=folder_id,
|
||
q=q,
|
||
within_field=within_field,
|
||
within_start=within_start,
|
||
within_end=within_end,
|
||
)
|
||
aids = [int(video["aid"]) for video in videos]
|
||
snapshots = self._snapshot_map(conn, aids)
|
||
videos_all, snapshots_all, heat, percentiles = self._global_heat(conn)
|
||
|
||
previous: dict[int, dict[str, Any]] = {}
|
||
if sort in {"growth", "growth_rate", "interaction"}:
|
||
# 相对各片当前快照日回退 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 = {}
|
||
|
||
items = [
|
||
self._item(
|
||
video,
|
||
snapshots.get(int(video["aid"])),
|
||
heat=heat.get(int(video["aid"])),
|
||
percentile=percentiles.get(int(video["aid"])),
|
||
previous=previous.get(int(video["aid"])),
|
||
include_growth=sort in {"growth", "growth_rate"},
|
||
growth_days=growth_days,
|
||
)
|
||
for video in videos
|
||
]
|
||
|
||
if sort in duration_sorts:
|
||
latest_start, _ = day_bounds(latest)
|
||
minimum_seconds = max(0, int(analytics.min_duration_seconds))
|
||
items = [
|
||
item for item in items
|
||
if item["metrics"] is not None
|
||
and int(item["duration"] or 0) >= minimum_seconds
|
||
and int((item["metrics"] or {}).get("view", 0)) >= min_view_for_ranking
|
||
and is_mature(
|
||
item["pubdate"], latest_start, analytics.maturity_days
|
||
)
|
||
]
|
||
kappa_hours = max(0, int(analytics.video_shrink_minutes)) / 60.0
|
||
total_hours = 0.0
|
||
total_views = 0.0
|
||
total_interactions = 0.0
|
||
for video in videos_all:
|
||
hours = content_hours(video.get("duration"))
|
||
if hours is None:
|
||
continue
|
||
metrics = _metric_map(snapshots_all.get(int(video["aid"])))
|
||
if metrics is None:
|
||
continue
|
||
total_hours += hours
|
||
total_views += metrics["view"]
|
||
total_interactions += (
|
||
metrics["like"] + metrics["coin"] + metrics["favorite"]
|
||
)
|
||
prior_views = total_views / total_hours if total_hours > 0 else 0.0
|
||
prior_interactions = (
|
||
total_interactions / total_hours if total_hours > 0 else 0.0
|
||
)
|
||
for item in items:
|
||
hours = content_hours(item.get("duration")) or 0.0
|
||
metrics = item["metrics"] or {}
|
||
denominator = hours + kappa_hours
|
||
if hours <= 0 or denominator <= 0:
|
||
item["efficiency_score"] = None
|
||
item["interaction_efficiency_score"] = None
|
||
continue
|
||
view = int(metrics.get("view", 0))
|
||
interactions = (
|
||
int(metrics.get("like", 0))
|
||
+ int(metrics.get("coin", 0))
|
||
+ int(metrics.get("favorite", 0))
|
||
)
|
||
item["efficiency_score"] = (
|
||
view + kappa_hours * prior_views
|
||
) / denominator
|
||
item["interaction_efficiency_score"] = (
|
||
interactions + kappa_hours * prior_interactions
|
||
) / denominator
|
||
if sort == "heat":
|
||
items = [
|
||
item for item in items
|
||
if int((item["metrics"] or {}).get("view", 0)) >= min_view_for_ranking
|
||
]
|
||
if sort == "interaction":
|
||
items = [item for item in items if item["interaction_rate"] is not None]
|
||
if sort in {"growth", "growth_rate"}:
|
||
items = [
|
||
item for item in items
|
||
if item.get("growth", {}).get("available")
|
||
]
|
||
if sort == "growth_rate":
|
||
items = [
|
||
item for item in items
|
||
if item.get("growth", {}).get("baseline") != "zero"
|
||
and item.get("growth", {}).get("rate") is not None
|
||
and (_growth_baseline_view(item) or 0) >= MIN_VIEW_FOR_GROWTH_RATE
|
||
]
|
||
|
||
rate_prior = max(0, int(analytics.growth_rate_prior))
|
||
|
||
def sort_key(item: Mapping[str, Any]):
|
||
if sort == "added":
|
||
return (int(item["first_seen"]), int(item["pubdate"]))
|
||
if sort == "published":
|
||
return (int(item["pubdate"]), int(item["first_seen"]))
|
||
if sort == "heat":
|
||
return item["heat"] if item["heat"] is not None else float("-inf")
|
||
if sort == "interaction":
|
||
return item["interaction_rate"] if item["interaction_rate"] is not None else float("-inf")
|
||
if sort in {"efficiency", "interaction_efficiency"}:
|
||
field = (
|
||
"efficiency_score" if sort == "efficiency"
|
||
else "interaction_efficiency_score"
|
||
)
|
||
value = item.get(field)
|
||
return value if value is not None else float("-inf")
|
||
if sort == "growth":
|
||
value = item.get("growth", {}).get("delta")
|
||
return value if value is not None else float("-inf")
|
||
if sort == "growth_rate":
|
||
growth = item.get("growth") or {}
|
||
delta = growth.get("delta")
|
||
current = (item.get("metrics") or {}).get("view")
|
||
if delta is None or current is None:
|
||
return float("-inf")
|
||
value = smoothed_growth_rate(
|
||
delta, int(current) - int(delta), rate_prior,
|
||
)
|
||
return value if value is not None else float("-inf")
|
||
value = (item["metrics"] or {}).get(sort)
|
||
return value if value is not None else -1
|
||
|
||
items.sort(key=lambda item: item["aid"])
|
||
items.sort(key=sort_key, reverse=True)
|
||
total = len(items)
|
||
growth_available = any(
|
||
item.get("growth", {}).get("available") for item in items
|
||
)
|
||
start = (page - 1) * page_size
|
||
items = items[start:start + page_size]
|
||
available = True
|
||
reason = None
|
||
if sort in {"growth", "growth_rate"} and not growth_available:
|
||
available = False
|
||
if growth_days == 1 and sort == "growth_rate":
|
||
reason = (
|
||
"近 1 日增速需要基线播放至少 "
|
||
f"{MIN_VIEW_FOR_GROWTH_RATE};当日发布不计入增速"
|
||
)
|
||
elif growth_days == 1:
|
||
reason = (
|
||
"近 1 日增长需要前一日快照;当日发布有一次成功采样即可;"
|
||
"更早发布的新入库需要当天至少两次成功采样"
|
||
)
|
||
else:
|
||
reason = (
|
||
f"近 {growth_days} 日增长需要至少 {growth_days + 1} 天快照"
|
||
)
|
||
duration_filter = None
|
||
if sort in duration_sorts:
|
||
duration_filter = {
|
||
"min_duration_seconds": max(0, int(analytics.min_duration_seconds)),
|
||
"maturity_days": max(0, int(analytics.maturity_days)),
|
||
"min_view": min_view_for_ranking,
|
||
"shrink_minutes": max(0, int(analytics.video_shrink_minutes)),
|
||
}
|
||
if not items:
|
||
available = False
|
||
reason = (
|
||
f"仅统计时长 ≥ {duration_filter['min_duration_seconds']} 秒、"
|
||
f"发布满 {duration_filter['maturity_days']} 天、"
|
||
f"播放 ≥ {duration_filter['min_view']} 的视频"
|
||
)
|
||
return {
|
||
"sort": sort,
|
||
"within_days": within_days,
|
||
"year": year,
|
||
"growth_days": growth_days,
|
||
# days:与 growth_days 同步;首页 auto 时为 1|7,供前端链接/展示
|
||
"days": growth_days,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"pages": max(1, math.ceil(total / page_size)) if total else 0,
|
||
"items": items,
|
||
"available": available,
|
||
"reason": reason,
|
||
"duration_filter": duration_filter,
|
||
"growth_rate_prior": rate_prior if sort == "growth_rate" else None,
|
||
}
|
||
return self.db.read(run)
|
||
|
||
def video_detail(self, aid: int) -> dict[str, Any] | None:
|
||
aid = int(aid)
|
||
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
|
||
video_rows = self._videos(conn)
|
||
video = next((item for item in video_rows if int(item["aid"]) == aid), None)
|
||
if video is None:
|
||
row = conn.execute(
|
||
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
|
||
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
|
||
"WHERE v.aid = ?",
|
||
(aid,),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
video = dict(row)
|
||
snapshots = self._snapshot_map(conn, [aid])
|
||
snapshot = snapshots.get(aid)
|
||
previous = self._snapshot_map(
|
||
conn, [aid], before=snapshot["stat_date"] if snapshot else None,
|
||
).get(aid)
|
||
_, _, heat, percentiles = self._global_heat(conn)
|
||
item = self._item(
|
||
video,
|
||
snapshot,
|
||
heat=heat.get(aid),
|
||
percentile=percentiles.get(aid),
|
||
)
|
||
item["growth"] = self._growth_result(
|
||
video, snapshot, previous, days_back=1,
|
||
)
|
||
item["snapshot_count"] = int(conn.execute(
|
||
"SELECT COUNT(*) FROM snapshot WHERE aid = ?", (aid,)
|
||
).fetchone()[0])
|
||
item["blacklisted"] = bool(conn.execute(
|
||
"SELECT 1 FROM up_blacklist WHERE mid = ?", (video.get("up_mid"),)
|
||
).fetchone()) if video.get("up_mid") is not None else False
|
||
item["url"] = (
|
||
f"https://www.bilibili.com/video/{video.get('bvid')}"
|
||
if video.get("bvid") else None
|
||
)
|
||
return item
|
||
return self.db.read(run)
|
||
|
||
def video_trend(self, aid: int, *, days: int | None = 30) -> dict[str, Any]:
|
||
aid = int(aid)
|
||
if days is not None and days < 1:
|
||
raise ValueError("days 必须大于 0")
|
||
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
latest = self._latest_date(conn)
|
||
rows = conn.execute(
|
||
"SELECT * FROM snapshot WHERE aid = ? ORDER BY stat_date", (aid,)
|
||
).fetchall()
|
||
points = [dict(row) for row in rows]
|
||
if latest is None or not points:
|
||
return {
|
||
"aid": aid,
|
||
"days": days,
|
||
"dates": [],
|
||
"series": {metric: [] for metric in METRICS},
|
||
"points": [],
|
||
"available": False,
|
||
}
|
||
requested_start = (
|
||
_date_minus(latest, days - 1) if days is not None
|
||
else str(points[0]["stat_date"])
|
||
)
|
||
first_point = str(points[0]["stat_date"])
|
||
start = max(requested_start, first_point)
|
||
dates = _date_range(start, latest)
|
||
by_date = {str(point["stat_date"]): point for point in points}
|
||
series: dict[str, list[int | None]] = {}
|
||
for metric in METRICS:
|
||
values: list[int | None] = []
|
||
for day in dates:
|
||
point = by_date.get(day)
|
||
if point is None or int(point["ok"]) != 1 or point.get(metric) is None:
|
||
values.append(None)
|
||
else:
|
||
values.append(int(point[metric]))
|
||
series[metric] = values
|
||
filtered_points = [
|
||
point for point in points if str(point["stat_date"]) in set(dates)
|
||
]
|
||
return {
|
||
"aid": aid,
|
||
"days": days,
|
||
"dates": dates,
|
||
"series": series,
|
||
"points": filtered_points,
|
||
"available": any(point["ok"] == 1 for point in filtered_points),
|
||
}
|
||
return self.db.read(run)
|
||
|
||
# ------------------------------------------------------------ ups
|
||
|
||
def list_ups(
|
||
self,
|
||
*,
|
||
sort: str = "avg_views",
|
||
within_days: int | None = None,
|
||
year: int | None = None,
|
||
q: str | None = None,
|
||
min_videos: int = 1,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> dict[str, Any]:
|
||
params = {
|
||
"sort": sort,
|
||
"within_days": within_days,
|
||
"year": year,
|
||
"q": q or "",
|
||
"min_videos": min_videos,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
return self._cached(
|
||
"videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached(
|
||
sort=sort,
|
||
within_days=within_days,
|
||
year=year,
|
||
q=q,
|
||
min_videos=min_videos,
|
||
page=page,
|
||
page_size=page_size,
|
||
),
|
||
)
|
||
|
||
def _list_ups_uncached(
|
||
self,
|
||
*,
|
||
sort: str = "avg_views",
|
||
within_days: int | None = None,
|
||
year: int | None = None,
|
||
q: str | None = None,
|
||
min_videos: int = 1,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> dict[str, Any]:
|
||
if sort not in UP_SORTS:
|
||
raise ValueError(f"不支持的 UP 排序: {sort}")
|
||
page = max(1, int(page))
|
||
page_size = max(1, min(int(page_size), 200))
|
||
min_videos = max(1, int(min_videos))
|
||
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
latest = self._latest_date(conn) or self._today()
|
||
if year is not None:
|
||
within_field = "pubdate"
|
||
within_start: str | None = f"{int(year)}-01-01"
|
||
within_end: str | None = f"{int(year) + 1}-01-01"
|
||
elif within_days is not None:
|
||
within_field = "pubdate"
|
||
within_start = _date_minus(latest, int(within_days) - 1)
|
||
within_end = None
|
||
else:
|
||
within_field = None
|
||
within_start = None
|
||
within_end = None
|
||
videos = self._videos(
|
||
conn,
|
||
q=q,
|
||
q_fields=("up_name",) if q else None,
|
||
within_field=within_field,
|
||
within_start=within_start,
|
||
within_end=within_end,
|
||
)
|
||
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
|
||
groups: dict[int, dict[str, Any]] = {}
|
||
for video in videos:
|
||
mid = video.get("up_mid")
|
||
if mid is None:
|
||
continue
|
||
mid = int(mid)
|
||
group = groups.setdefault(mid, {
|
||
"up_mid": mid,
|
||
"up_name": video.get("up_name") or f"UP {mid}",
|
||
"videos": [],
|
||
"face": None,
|
||
})
|
||
if group["face"] is None and video.get("up_face"):
|
||
group["face"] = video.get("up_face")
|
||
metrics = _metric_map(snapshots.get(int(video["aid"])))
|
||
group["videos"].append((video, metrics))
|
||
items = []
|
||
for group in groups.values():
|
||
group_videos = group["videos"]
|
||
metrics_list = [metrics for _, metrics in group_videos if metrics]
|
||
total_views = sum(metrics["view"] for metrics in metrics_list)
|
||
total_interactions = sum(
|
||
metrics["like"] + metrics["coin"] + metrics["favorite"]
|
||
for metrics in metrics_list
|
||
)
|
||
rates = [
|
||
rate for rate in (
|
||
interaction_rate(metrics) for metrics in metrics_list
|
||
) if rate is not None
|
||
]
|
||
exposure_seconds = sum(
|
||
int(video.get("duration") or 0)
|
||
for video, metrics in group_videos
|
||
if metrics is not None and int(video.get("duration") or 0) > 0
|
||
)
|
||
exposure_minutes = exposure_seconds / 60.0
|
||
best = max(
|
||
group_videos,
|
||
key=lambda pair: (pair[1] or {}).get("view", 0),
|
||
default=None,
|
||
)
|
||
items.append({
|
||
"up_mid": group["up_mid"],
|
||
"up_name": group["up_name"],
|
||
"up_face": normalize_cover(group["face"]),
|
||
"follower_latest": None,
|
||
"video_count": len(group_videos),
|
||
"total_views": total_views,
|
||
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
||
"total_interactions": total_interactions,
|
||
"avg_interaction": round(sum(rates) / len(rates), 6) if rates else None,
|
||
"latest_pubdate": max(int(video["pubdate"]) for video, _ in group_videos),
|
||
"first_pubdate": min(int(video["pubdate"]) for video, _ in group_videos),
|
||
"_exposure_minutes": exposure_minutes,
|
||
"best_video": None if best is None else {
|
||
"aid": int(best[0]["aid"]),
|
||
"title": best[0].get("title"),
|
||
"cover": normalize_cover(best[0].get("cover")),
|
||
"view": (best[1] or {}).get("view"),
|
||
},
|
||
})
|
||
|
||
total_minutes = sum(item["_exposure_minutes"] for item in items)
|
||
total_views_all = sum(item["total_views"] for item in items)
|
||
total_interactions_all = sum(item["total_interactions"] for item in items)
|
||
prior_views = (
|
||
total_views_all / total_minutes if total_minutes > 0 else 0.0
|
||
)
|
||
prior_interactions = (
|
||
total_interactions_all / total_minutes if total_minutes > 0 else 0.0
|
||
)
|
||
shrink_minutes = effective_shrink_minutes(
|
||
item["_exposure_minutes"] for item in items
|
||
)
|
||
for item in items:
|
||
minutes = item.pop("_exposure_minutes")
|
||
item["total_duration"] = int(round(minutes * 60))
|
||
item["content_hours"] = round(minutes / 60.0, 2)
|
||
item["views_per_hour"] = (
|
||
round(item["total_views"] / minutes * 60.0, 1)
|
||
if minutes > 0 else None
|
||
)
|
||
item["interactions_per_hour"] = (
|
||
round(item["total_interactions"] / minutes * 60.0, 1)
|
||
if minutes > 0 else None
|
||
)
|
||
item["efficiency"] = (
|
||
round(
|
||
eb_rate(
|
||
item["total_views"], minutes,
|
||
prior_views, shrink_minutes,
|
||
) * 60.0,
|
||
2,
|
||
)
|
||
if minutes > 0 else None
|
||
)
|
||
item["interaction_efficiency"] = (
|
||
round(
|
||
eb_rate(
|
||
item["total_interactions"], minutes,
|
||
prior_interactions, shrink_minutes,
|
||
) * 60.0,
|
||
2,
|
||
)
|
||
if minutes > 0 else None
|
||
)
|
||
|
||
mids = [item["up_mid"] for item in items]
|
||
if mids:
|
||
placeholders = ",".join("?" for _ in mids)
|
||
profile_rows = conn.execute(
|
||
f"SELECT mid, follower_latest FROM up_profile "
|
||
f"WHERE mid IN ({placeholders})",
|
||
mids,
|
||
).fetchall()
|
||
followers = {
|
||
int(row["mid"]): row["follower_latest"] for row in profile_rows
|
||
}
|
||
for item in items:
|
||
item["follower_latest"] = followers.get(item["up_mid"])
|
||
|
||
items = [item for item in items if item["video_count"] >= min_videos]
|
||
sort_map = {
|
||
"views": lambda item: item["total_views"],
|
||
"count": lambda item: item["video_count"],
|
||
"avg_views": lambda item: item["avg_views"] or 0,
|
||
"interaction": lambda item: item["total_interactions"],
|
||
"avg_interaction": lambda item: item["avg_interaction"] or 0,
|
||
"latest": lambda item: item["latest_pubdate"],
|
||
"duration": lambda item: item["total_duration"],
|
||
"efficiency": lambda item: item["efficiency"] or 0,
|
||
"interaction_efficiency": lambda item: item["interaction_efficiency"] or 0,
|
||
}
|
||
key = sort_map[sort]
|
||
items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True)
|
||
total = len(items)
|
||
start = (page - 1) * page_size
|
||
items = items[start:start + page_size]
|
||
return {
|
||
"sort": sort,
|
||
"within_days": within_days,
|
||
"year": year,
|
||
"min_videos": min_videos,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"pages": max(1, math.ceil(total / page_size)) if total else 0,
|
||
"items": items,
|
||
}
|
||
return self.db.read(run)
|
||
|
||
def up_detail(self, mid: int) -> dict[str, Any] | None:
|
||
mid = int(mid)
|
||
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
|
||
latest = self._latest_date(conn) or self._today()
|
||
videos = self._videos(conn, up_mid=mid)
|
||
if not videos:
|
||
row = conn.execute(
|
||
"SELECT 1 FROM up_blacklist WHERE mid = ?", (mid,)
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return {
|
||
"up_mid": mid,
|
||
"up_name": f"UP {mid}",
|
||
"videos": [],
|
||
"video_count": 0,
|
||
"blacklisted": True,
|
||
"series": {"dates": [], "views": []},
|
||
}
|
||
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
|
||
items = [
|
||
self._item(video, snapshots.get(int(video["aid"])))
|
||
for video in videos
|
||
]
|
||
metrics_list = [item["metrics"] for item in items if item["metrics"]]
|
||
rates = [item["interaction_rate"] for item in items if item["interaction_rate"] is not None]
|
||
total_views = sum(metrics["view"] for metrics in metrics_list)
|
||
total_interactions = sum(
|
||
metrics["like"] + metrics["coin"] + metrics["favorite"]
|
||
for metrics in metrics_list
|
||
)
|
||
exposure_seconds = sum(
|
||
int(item["duration"] or 0)
|
||
for item in items
|
||
if item["metrics"] is not None and int(item["duration"] or 0) > 0
|
||
)
|
||
exposure_minutes = exposure_seconds / 60.0
|
||
profile = conn.execute(
|
||
"SELECT * FROM up_profile WHERE mid = ?", (mid,)
|
||
).fetchone()
|
||
follower_item = next(
|
||
(item for item in items if item.get("up_follower") is not None), None
|
||
)
|
||
aids = [int(video["aid"]) for video in videos]
|
||
rows = conn.execute(
|
||
f"SELECT stat_date, SUM(view) AS view FROM snapshot "
|
||
f"WHERE ok = 1 AND aid IN ({','.join('?' for _ in aids)}) "
|
||
"GROUP BY stat_date ORDER BY stat_date",
|
||
aids,
|
||
).fetchall()
|
||
view_by_date = {str(row["stat_date"]): int(row["view"] or 0) for row in rows}
|
||
dates = _date_range(min(view_by_date), latest) if view_by_date else []
|
||
items.sort(key=lambda item: ((item["metrics"] or {}).get("view", 0), item["pubdate"]), reverse=True)
|
||
return {
|
||
"up_mid": mid,
|
||
"up_name": items[0].get("up_name") or f"UP {mid}",
|
||
"up_face": next(
|
||
(item["up_face"] for item in items if item.get("up_face")), None
|
||
),
|
||
"video_count": len(items),
|
||
"total_views": total_views,
|
||
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
||
"total_interactions": total_interactions,
|
||
"avg_interaction": round(sum(rates) / len(rates), 6) if rates else None,
|
||
"total_duration": int(round(exposure_minutes * 60)),
|
||
"content_hours": round(exposure_minutes / 60.0, 2),
|
||
"views_per_hour": (
|
||
round(total_views / exposure_minutes * 60.0, 1)
|
||
if exposure_minutes > 0 else None
|
||
),
|
||
"interactions_per_hour": (
|
||
round(total_interactions / exposure_minutes * 60.0, 1)
|
||
if exposure_minutes > 0 else None
|
||
),
|
||
"up_follower": None if follower_item is None else follower_item["up_follower"],
|
||
"up_follower_at": None if follower_item is None else follower_item["up_follower_at"],
|
||
"up_follower_backfilled": bool(
|
||
follower_item is not None
|
||
and follower_item.get("up_follower_backfilled")
|
||
),
|
||
"follower_first": None if profile is None else profile["follower_first"],
|
||
"follower_latest": None if profile is None else profile["follower_latest"],
|
||
"first_pubdate": min(item["pubdate"] for item in items),
|
||
"latest_pubdate": max(item["pubdate"] for item in items),
|
||
"blacklisted": bool(conn.execute(
|
||
"SELECT 1 FROM up_blacklist WHERE mid = ?", (mid,)
|
||
).fetchone()),
|
||
"space_url": f"https://space.bilibili.com/{mid}",
|
||
"videos": items,
|
||
"series": {
|
||
"dates": dates,
|
||
"views": [view_by_date.get(day) for day in dates],
|
||
},
|
||
}
|
||
return self.db.read(run)
|
||
|
||
# ------------------------------------------------------------ fans
|
||
|
||
def fans(
|
||
self,
|
||
mid: int | None = None,
|
||
*,
|
||
days: int = 90,
|
||
start: str | None = None,
|
||
end: str | None = None,
|
||
recent_days: int = 14,
|
||
) -> dict[str, Any]:
|
||
params = {
|
||
"mid": mid if mid is not None else "",
|
||
"days": days,
|
||
"start": start or "",
|
||
"end": end or "",
|
||
"recent_days": recent_days,
|
||
}
|
||
return self._cached(
|
||
"fans", params, TTL_FANS, lambda: self._fans_uncached(
|
||
mid=mid,
|
||
days=days,
|
||
start=start,
|
||
end=end,
|
||
recent_days=recent_days,
|
||
),
|
||
)
|
||
|
||
def _fans_uncached(
|
||
self,
|
||
mid: int | None = None,
|
||
*,
|
||
days: int = 90,
|
||
start: str | None = None,
|
||
end: str | None = None,
|
||
recent_days: int = 14,
|
||
) -> dict[str, Any]:
|
||
if days < 1:
|
||
raise ValueError("days 必须大于 0")
|
||
if recent_days < 1 or recent_days > 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]:
|
||
cfg = self.config_store.get()
|
||
mids = list(cfg.track_fans)
|
||
if not mids:
|
||
mids = [
|
||
int(row["mid"]) for row in conn.execute(
|
||
"SELECT DISTINCT mid FROM up_stat ORDER BY mid"
|
||
).fetchall()
|
||
]
|
||
selected = int(mid) if mid is not None else (mids[0] if mids else None)
|
||
options = [
|
||
{"mid": value, "name": TRACK_FANS_NAMES.get(value, f"UP {value}")}
|
||
for value in mids
|
||
]
|
||
if selected is None:
|
||
return {
|
||
"mid": None, "name": None, "available": False,
|
||
"options": options, "dates": [], "series": [],
|
||
"points": [], "recent_points": [], "days": days,
|
||
"mode": "daily", "range_start": None, "range_end": None,
|
||
"span_days": 0, "recent_days": recent_days,
|
||
}
|
||
rows = [
|
||
dict(row) for row in conn.execute(
|
||
"SELECT * FROM up_stat WHERE mid = ? ORDER BY stat_date",
|
||
(selected,),
|
||
).fetchall()
|
||
]
|
||
if not rows:
|
||
return {
|
||
"mid": selected,
|
||
"name": TRACK_FANS_NAMES.get(selected, f"UP {selected}"),
|
||
"available": False,
|
||
"options": options,
|
||
"dates": [],
|
||
"series": [],
|
||
"points": [],
|
||
"recent_points": [],
|
||
"days": 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"])
|
||
first_date = str(rows[0]["stat_date"])
|
||
recent_start = _date_minus(latest, recent_days - 1)
|
||
recent_points = _with_fan_deltas(rows, recent_start)
|
||
if start or end:
|
||
range_start = max(str(start or first_date), first_date)
|
||
range_end = min(str(end or latest), latest)
|
||
if range_start > range_end:
|
||
raise ValueError("所选日期范围内没有粉丝数据")
|
||
else:
|
||
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]
|
||
current = ok_rows[-1] if ok_rows else rows[-1]
|
||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
||
if previous is not None:
|
||
try:
|
||
gap = (
|
||
date.fromisoformat(str(current["stat_date"]))
|
||
- date.fromisoformat(str(previous["stat_date"]))
|
||
).days
|
||
except ValueError:
|
||
gap = 0
|
||
if gap != 1:
|
||
previous = None
|
||
cutoff = _date_minus(latest, 7)
|
||
by_date = {str(row["stat_date"]): row for row in ok_rows}
|
||
baseline = by_date.get(cutoff)
|
||
first = ok_rows[0] if ok_rows else rows[0]
|
||
return {
|
||
"mid": selected,
|
||
"name": TRACK_FANS_NAMES.get(selected, f"UP {selected}"),
|
||
"available": bool(ok_rows),
|
||
"options": options,
|
||
"days": days,
|
||
"mode": mode,
|
||
"range_start": range_start,
|
||
"range_end": range_end,
|
||
"span_days": span_days,
|
||
"dates": dates,
|
||
"series": series,
|
||
"points": points,
|
||
"recent_points": recent_points,
|
||
"recent_days": recent_days,
|
||
"stat_date": current["stat_date"],
|
||
"follower": current.get("follower"),
|
||
"following": current.get("following"),
|
||
"delta_1d": None if previous is None else int(current.get("follower") or 0) - int(previous.get("follower") or 0),
|
||
"delta_7d": None if baseline is None else int(current.get("follower") or 0) - int(baseline.get("follower") or 0),
|
||
"delta_cum": int(current.get("follower") or 0) - int(first.get("follower") or 0),
|
||
"first_date": first["stat_date"],
|
||
}
|
||
return self.db.read(run)
|
||
|
||
# ------------------------------------------------------------ candidates
|
||
|
||
def about(self) -> dict[str, Any]:
|
||
"""数据说明页所需的动态口径;不包含任何凭据。"""
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
cfg = self.config_store.get()
|
||
latest = self._latest_date(conn)
|
||
first_row = conn.execute(
|
||
"SELECT MIN(stat_date) AS d FROM snapshot WHERE ok = 1"
|
||
).fetchone()
|
||
first_date = None if first_row is None else first_row["d"]
|
||
counts = {
|
||
str(row["status"]): int(row["n"])
|
||
for row in conn.execute(
|
||
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
|
||
).fetchall()
|
||
}
|
||
follower_row = conn.execute(
|
||
"""
|
||
SELECT COUNT(*) AS total,
|
||
SUM(CASE WHEN up_follower IS NOT NULL THEN 1 ELSE 0 END) AS covered
|
||
FROM video
|
||
WHERE status IN ('active', 'unfaved') AND up_mid IS NOT NULL
|
||
"""
|
||
).fetchone()
|
||
follower_total = int(follower_row["total"] or 0)
|
||
follower_covered = int(follower_row["covered"] or 0)
|
||
profile_count = int(conn.execute(
|
||
"SELECT COUNT(*) FROM up_profile"
|
||
).fetchone()[0])
|
||
preview_row = conn.execute(
|
||
"SELECT v FROM kv WHERE k = 'preview:seeded'"
|
||
).fetchone()
|
||
fans_start_date = None
|
||
track_fans = [
|
||
int(mid) for mid in cfg.track_fans if not isinstance(mid, bool)
|
||
]
|
||
if track_fans:
|
||
placeholders = ",".join("?" for _ in track_fans)
|
||
row = conn.execute(
|
||
f"SELECT MIN(stat_date) AS d FROM up_stat "
|
||
f"WHERE ok = 1 AND mid IN ({placeholders})",
|
||
track_fans,
|
||
).fetchone()
|
||
fans_start_date = None if row is None else row["d"]
|
||
return {
|
||
"data_date": latest,
|
||
"snapshot_days": self._snapshot_days(conn),
|
||
"snapshot_first_date": first_date,
|
||
"fans_start_date": fans_start_date,
|
||
"track_fans": track_fans,
|
||
"schema_version": int(self.db.schema_version or 0),
|
||
"preview": bool(preview_row and str(preview_row["v"]) == "1"),
|
||
"counts": counts,
|
||
"library": {
|
||
"active_videos": counts.get("active", 0),
|
||
"unfaved_videos": counts.get("unfaved", 0),
|
||
"removed_videos": counts.get("removed", 0),
|
||
},
|
||
"analytics": {
|
||
"min_duration_seconds": cfg.analytics.min_duration_seconds,
|
||
"maturity_days": cfg.analytics.maturity_days,
|
||
"min_view_for_ranking": cfg.analytics.min_view_for_ranking,
|
||
"video_shrink_minutes": cfg.analytics.video_shrink_minutes,
|
||
"shrink_minutes": cfg.analytics.shrink_minutes,
|
||
"home_growth_days": cfg.analytics.home_growth_days,
|
||
"growth_rate_prior": cfg.analytics.growth_rate_prior,
|
||
},
|
||
"heat_weights": {
|
||
metric: float(value)
|
||
for metric, value in (cfg.heat_weights or {}).items()
|
||
},
|
||
"followers": {
|
||
"videos_total": follower_total,
|
||
"videos_covered": follower_covered,
|
||
"videos_missing": max(0, follower_total - follower_covered),
|
||
"ups_tracked": profile_count,
|
||
},
|
||
"folders": [
|
||
{
|
||
"year": folder.year,
|
||
"media_id": folder.media_id,
|
||
"title": folder.title,
|
||
}
|
||
for folder in cfg.folders
|
||
],
|
||
}
|
||
return self.db.read(run)
|
||
|
||
# ------------------------------------------------------------ stats
|
||
|
||
def stats(
|
||
self,
|
||
*,
|
||
days: int = 30,
|
||
pending: Sequence[Mapping[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""访问统计:区间内每日 PV/UV 与 Top 页面。
|
||
|
||
``pending`` 为内存缓冲中尚未落库的上报行;与已落库数据按
|
||
(日期, IP, 路由) 合并去重后统一聚合,因此页面基本是实时可见的。
|
||
"""
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
cfg = self.config_store.get()
|
||
today = self._today()
|
||
start = _date_minus(today, int(days) - 1)
|
||
rows: list[Mapping[str, Any]] = [
|
||
dict(row) for row in conn.execute(
|
||
"SELECT stat_date, ip, path, hits, first_at, last_at FROM visit "
|
||
"WHERE stat_date >= ? AND stat_date <= ?",
|
||
(start, today),
|
||
).fetchall()
|
||
]
|
||
if pending:
|
||
rows.extend(dict(row) for row in pending)
|
||
|
||
buckets: dict[tuple[str, str, str], list[int]] = {}
|
||
for row in rows:
|
||
day = str(row["stat_date"])
|
||
ip = str(row["ip"])
|
||
path = str(row["path"])
|
||
hits = int(row.get("hits") or 0)
|
||
first = int(row.get("first_at") or 0)
|
||
last = int(row.get("last_at") or 0)
|
||
bucket = buckets.get((day, ip, path))
|
||
if bucket is None:
|
||
buckets[(day, ip, path)] = [hits, first, last]
|
||
else:
|
||
bucket[0] += hits
|
||
bucket[1] = min(bucket[1], first) if first else bucket[1]
|
||
bucket[2] = max(bucket[2], last)
|
||
|
||
dates = _date_range(start, today)
|
||
daily_pv = {day: 0 for day in dates}
|
||
daily_uv: dict[str, set[str]] = {day: set() for day in dates}
|
||
page_pv: dict[str, int] = {}
|
||
page_uv: dict[str, set[str]] = {}
|
||
total_pv = 0
|
||
total_ips: set[str] = set()
|
||
for (day, ip, path), bucket in buckets.items():
|
||
if day not in daily_pv:
|
||
continue
|
||
daily_pv[day] += bucket[0]
|
||
daily_uv[day].add(ip)
|
||
total_pv += bucket[0]
|
||
total_ips.add(ip)
|
||
page_pv[path] = page_pv.get(path, 0) + bucket[0]
|
||
page_uv.setdefault(path, set()).add(ip)
|
||
|
||
top_pages = [
|
||
{"path": path, "pv": hits, "uv": len(page_uv[path])}
|
||
for path, hits in sorted(
|
||
page_pv.items(), key=lambda kv: (-kv[1], kv[0])
|
||
)[:10]
|
||
]
|
||
return {
|
||
"enabled": bool(cfg.web.track.enabled),
|
||
"days": int(days),
|
||
"range": {"start": start, "end": today},
|
||
"today": today,
|
||
"today_pv": daily_pv.get(today, 0),
|
||
"today_uv": len(daily_uv.get(today, set())),
|
||
"total_pv": total_pv,
|
||
"total_uv": len(total_ips),
|
||
"series": {
|
||
"dates": dates,
|
||
"pv": [daily_pv[day] for day in dates],
|
||
"uv": [len(daily_uv[day]) for day in dates],
|
||
},
|
||
"top_pages": top_pages,
|
||
"pending": bool(pending),
|
||
}
|
||
return self.db.read(run)
|
||
|
||
def candidates(self) -> dict[str, Any]:
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
cfg = self.config_store.get()
|
||
now = int(self._now())
|
||
ttl = int(cfg.schedule.candidate_ttl_days)
|
||
rows = conn.execute(
|
||
"SELECT * FROM candidate ORDER BY "
|
||
"CASE WHEN decision IS NULL THEN 0 ELSE 1 END, "
|
||
"COALESCE(score, 0) DESC, first_seen DESC, aid"
|
||
).fetchall()
|
||
items = []
|
||
for row in rows:
|
||
data = dict(row)
|
||
first_seen = int(data.get("first_seen") or 0)
|
||
expires_at = first_seen + ttl * 86400
|
||
items.append({
|
||
**data,
|
||
"cover": normalize_cover(data.get("cover")),
|
||
"expires_at": expires_at,
|
||
"days_left": max(0, math.ceil((expires_at - now) / 86400)),
|
||
"url": (
|
||
f"https://www.bilibili.com/video/{data.get('bvid')}"
|
||
if data.get("bvid") else None
|
||
),
|
||
})
|
||
pending = sum(1 for item in items if item.get("decision") is None)
|
||
return {
|
||
"items": items,
|
||
"total": len(items),
|
||
"pending": pending,
|
||
"ttl_days": ttl,
|
||
"telegram_enabled": bool(cfg.telegram.enabled),
|
||
"now": now,
|
||
}
|
||
return self.db.read(run)
|
||
|
||
# ------------------------------------------------------------ system
|
||
|
||
def system(self) -> dict[str, Any]:
|
||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
cfg = self.config_store.get()
|
||
latest = self._latest_date(conn)
|
||
snapshot_days = self._snapshot_days(conn)
|
||
health = {"total": 0, "ok": 0, "failed": 0}
|
||
if latest:
|
||
rows = conn.execute(
|
||
"SELECT ok, COUNT(*) AS n FROM snapshot WHERE stat_date = ? GROUP BY ok",
|
||
(latest,),
|
||
).fetchall()
|
||
by_ok = {int(row["ok"]): int(row["n"]) for row in rows}
|
||
health = {
|
||
"total": sum(by_ok.values()),
|
||
"ok": by_ok.get(1, 0),
|
||
"failed": by_ok.get(0, 0),
|
||
}
|
||
blacklist = [
|
||
dict(row) for row in conn.execute(
|
||
"""
|
||
SELECT b.*,
|
||
(SELECT COUNT(*) FROM video v WHERE v.up_mid = b.mid
|
||
AND v.status IN ('active','unfaved')) AS video_count
|
||
FROM up_blacklist b ORDER BY b.created_at, b.mid
|
||
"""
|
||
).fetchall()
|
||
]
|
||
tasks = self._task_records(conn)
|
||
running = self._task_running(conn)
|
||
now = self._now()
|
||
discover_interval = int(cfg.schedule.discover_minutes) * 60
|
||
recent_interval = int(cfg.schedule.recent_snapshot_minutes) * 60
|
||
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, 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")):
|
||
continue
|
||
finished_at = int(record["finished_at"])
|
||
if name in {"discover", "snapshot_recent"}:
|
||
# 只按上次完成推下一档;进行中由 running 展示,不按墙钟抢跑。
|
||
interval = (
|
||
recent_interval if name == "snapshot_recent"
|
||
else discover_interval
|
||
)
|
||
next_runs[name] = finished_at + interval
|
||
else:
|
||
next_runs[name] = int(next_daily_run(finished_at, at))
|
||
errors = [
|
||
{"task": name, **record}
|
||
for name, record in tasks.items()
|
||
if isinstance(record, dict) and record.get("ok") is False
|
||
]
|
||
counts = {str(row["status"]): int(row["n"]) for row in conn.execute(
|
||
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
|
||
).fetchall()}
|
||
return {
|
||
"generated_at": int(now),
|
||
"today": self._today(),
|
||
"data_date": latest,
|
||
"snapshot_days": snapshot_days,
|
||
"snapshot_health": health,
|
||
"counts": counts,
|
||
"blacklist": blacklist,
|
||
"tasks": tasks,
|
||
"running": running,
|
||
"errors": errors,
|
||
"next_runs": next_runs,
|
||
"auth": {
|
||
"ok": self._kv(conn, "auth:ok"),
|
||
"message": self._kv(conn, "auth:message"),
|
||
"last_check": self._kv(conn, "auth:last_check"),
|
||
"mid": self._kv(conn, "auth:mid"),
|
||
},
|
||
"backup": {
|
||
"last_completed": self._kv(conn, "snapshot:last_completed"),
|
||
"last_attempt": self._kv(conn, "snapshot:last_attempt"),
|
||
"ok": self._kv(conn, f"snapshot:backup:{latest}" if latest else "snapshot:backup"),
|
||
"error": self._kv(conn, f"snapshot:backup_error:{latest}" if latest else "snapshot:backup_error"),
|
||
},
|
||
"preview": self._kv(conn, "preview:seeded") == "1",
|
||
"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,
|
||
},
|
||
"telegram_enabled": bool(cfg.telegram.enabled),
|
||
}
|
||
return self.db.read(run)
|