# -*- coding: utf-8 -*- """只读看板查询层。 所有 SQL 都在这里集中,Web 层只负责参数解析与 JSON 序列化。查询不会写入 数据库,也不会访问 B 站。 """ from __future__ import annotations 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, heat_scores, interaction_rate, normalize_cover, score_percentiles, ) from ..config import ConfigStore from ..database import Database from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime TRACKED_STATUSES = ("active", "unfaved") VIDEO_SORTS = { "heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku", "interaction", "growth", "growth_rate", "added", "published", } UP_SORTS = {"views", "count", "avg_views", "interaction", "avg_interaction", "latest"} 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 _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 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 # ------------------------------------------------------------ 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 MAX(stat_date) AS d FROM snapshot WHERE ok = 1" ).fetchone() return None if row is None or row["d"] is None else str(row["d"]) @staticmethod def _snapshot_days(conn: sqlite3.Connection) -> int: row = conn.execute( "SELECT COUNT(DISTINCT stat_date) AS n FROM snapshot WHERE ok = 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 @staticmethod def _videos( conn: sqlite3.Connection, *, statuses: Sequence[str] = TRACKED_STATUSES, folder_id: int | None = None, q: str | None = None, within_field: str | None = None, within_start: 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) clauses.append( "(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')" ) params.extend([pattern, pattern]) if within_field and within_start: if within_field not in {"pubdate", "first_seen"}: raise ValueError("within_field 必须是 pubdate 或 first_seen") start_ts, _ = day_bounds(within_start) clauses.append(f"v.{within_field} >= ?") params.append(start_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 _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) 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, ) -> dict[str, Any]: metrics = _metric_map(snapshot) 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": video.get("duration"), "cover": normalize_cover(video.get("cover")), "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), } if include_growth: item["growth"] = self._growth_for(snapshot, previous).as_dict() return item # ------------------------------------------------------------ overview def overview(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) previous = self._snapshot_map(conn, aids, before=latest or self._today()) 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) prior = previous.get(aid) if snapshot is None or prior is None: continue growth = self._growth_for(snapshot, prior) if growth.available and growth.delta is not None: view_delta_sum += 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 = ?", (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) tasks = self._task_records(conn) 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, "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, "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, "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"]) 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" ).fetchall() result: dict[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"]} 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 baseline = None if latest: cutoff = _date_minus(latest, 7) candidates = [row for row in ok_rows if str(row["stat_date"]) <= cutoff] baseline = candidates[-1] if candidates else None 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, growth_days: int = 7, folder_id: int | None = None, q: str | None = None, page: int = 1, page_size: int = 20, export: bool = False, ) -> dict[str, Any]: if sort not in VIDEO_SORTS: raise ValueError(f"不支持的排序: {sort}") 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 if not export else 10_000)) def run(conn: sqlite3.Connection) -> dict[str, Any]: latest = self._latest_date(conn) or self._today() within_field = None if within_days is not None: if within_days < 1: raise ValueError("within_days 必须大于 0") within_field = "first_seen" if sort == "added" else "pubdate" videos = self._videos( conn, folder_id=folder_id, q=q, within_field=within_field, within_start=( _date_minus(latest, int(within_days) - 1) if within_days is not None else None ), ) aids = [int(video["aid"]) for video in videos] snapshots = self._snapshot_map(conn, aids) _, _, heat, percentiles = self._global_heat(conn) 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) 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"}, ) for video in videos ] 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["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE ] 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 {"growth", "growth_rate"}: field = "delta" if sort == "growth" else "rate" value = item.get("growth", {}).get(field) 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 ) if not export: 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 reason = "需要至少两天的快照才能计算增长" return { "sort": sort, "within_days": within_days, "growth_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, } 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) growth = self._growth_for(snapshot, previous) item = self._item( video, snapshot, heat=heat.get(aid), percentile=percentiles.get(aid), ) item["growth"] = growth.as_dict() 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 = "views", within_days: int | None = None, q: str | None = None, min_videos: int = 1, page: int = 1, page_size: int = 20, export: bool = False, ) -> 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 if not export else 10_000)) min_videos = max(1, int(min_videos)) def run(conn: sqlite3.Connection) -> dict[str, Any]: latest = self._latest_date(conn) or self._today() videos = self._videos( conn, q=q, within_field="pubdate" if within_days is not None else None, within_start=( _date_minus(latest, int(within_days) - 1) if within_days is not None else None ), ) 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": [], }) 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 = [ interaction_rate(metrics) for metrics in metrics_list if interaction_rate(metrics) is not None ] 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"], "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), "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"), }, }) 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"], } key = sort_map[sort] items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True) total = len(items) if not export: start = (page - 1) * page_size items = items[start:start + page_size] return { "sort": sort, "within_days": within_days, "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 ) 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) folder_titles = sorted({ str(item["folder_title"]) for item in items if item.get("folder_title") }) return { "up_mid": mid, "up_name": items[0].get("up_name") or f"UP {mid}", "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, "first_pubdate": min(item["pubdate"] for item in items), "latest_pubdate": max(item["pubdate"] for item in items), "folders": folder_titles, "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) -> dict[str, Any]: if days < 1: raise ValueError("days 必须大于 0") 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": [], "days": 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": [], "days": 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"]) start = max(_date_minus(latest, days - 1), first_date) dates = _date_range(start, latest) by_date = {str(row["stat_date"]): row for row in rows} series = [] for day in dates: row = by_date.get(day) if row is None or int(row["ok"]) != 1: series.append(None) else: series.append(row["follower"]) 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 cutoff = _date_minus(latest, 7) baselines = [row for row in ok_rows if str(row["stat_date"]) <= cutoff] baseline = baselines[-1] if baselines else None 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, "dates": dates, "series": series, "points": rows, "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 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), } folders = self._folders_overview(conn) 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) now = self._now() next_runs = { "discover": int(now + cfg.schedule.discover_minutes * 60), "folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60), "snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)), } for name, key, interval_minutes in ( ("discover", "discovery", cfg.schedule.discover_minutes), ("folder_sync", "folder_sync", cfg.schedule.folder_sync_minutes), ): record = tasks.get(key) if isinstance(record, dict) and record.get("finished_at"): interval = int(interval_minutes) * 60 candidate = int(record["finished_at"]) + interval while candidate <= now: candidate += interval next_runs[name] = candidate 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, "folders": folders, "blacklist": blacklist, "tasks": tasks, "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, "folder_sync_minutes": cfg.schedule.folder_sync_minutes, "snapshot_at": cfg.schedule.snapshot_at, "candidate_ttl_days": cfg.schedule.candidate_ttl_days, }, "telegram_enabled": bool(cfg.telegram.enabled), } return self.db.read(run) def export_videos(self, **filters) -> dict[str, Any]: filters.pop("page", None) filters.pop("page_size", None) return self.list_videos(**filters, export=True)