diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 81b6941..235d367 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -20,10 +20,16 @@ from ..analytics import ( METRICS, MIN_VIEW_FOR_GROWTH_RATE, compute_growth, + 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 @@ -34,8 +40,12 @@ 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_SORTS = {"views", "count", "avg_views", "interaction", "avg_interaction", "latest"} CAPACITY = 1000 CAPACITY_WARNING = 950 TRACK_FANS_NAMES = {37754047: "咻咻满"} @@ -355,6 +365,8 @@ class DashboardQueries: include_growth: bool = False, ) -> 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"), @@ -362,7 +374,12 @@ class DashboardQueries: "up_mid": video.get("up_mid"), "up_name": video.get("up_name"), "pubdate": int(video.get("pubdate") or 0), - "duration": video.get("duration"), + "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")), "folder_id": video.get("folder_id"), "folder_title": video.get("folder_title"), @@ -375,6 +392,14 @@ class DashboardQueries: "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_for(snapshot, previous).as_dict() @@ -672,6 +697,8 @@ class DashboardQueries: 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"} within_field = None if within_days is not None: if within_days < 1: @@ -710,6 +737,19 @@ class DashboardQueries: for video in videos ] + if sort in duration_sorts: + _, latest_end = day_bounds(latest) + minimum_seconds = max(0, int(analytics.min_duration_seconds)) + min_view = max(0, int(analytics.min_view_for_efficiency)) + 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 + and is_mature( + item["pubdate"], latest_end, analytics.maturity_days + ) + ] if sort == "interaction": items = [item for item in items if item["interaction_rate"] is not None] if sort in {"growth", "growth_rate"}: @@ -732,6 +772,9 @@ class DashboardQueries: 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"}: + value = item.get(sort) + return value if value 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) @@ -752,6 +795,19 @@ class DashboardQueries: if sort in {"growth", "growth_rate"} and not growth_available: available = False reason = "需要至少两天的快照才能计算增长" + 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": max(0, int(analytics.min_view_for_efficiency)), + } + if not items: + available = False + reason = ( + f"仅统计时长 ≥ {duration_filter['min_duration_seconds']} 秒、" + f"发布满 {duration_filter['maturity_days']} 天的视频" + ) return { "sort": sort, "within_days": within_days, @@ -763,6 +819,7 @@ class DashboardQueries: "items": items, "available": available, "reason": reason, + "duration_filter": duration_filter, } return self.db.read(run) @@ -940,10 +997,16 @@ class DashboardQueries: for metrics in metrics_list ) rates = [ - interaction_rate(metrics) - for metrics in metrics_list - if interaction_rate(metrics) is not None + 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), @@ -959,6 +1022,7 @@ class DashboardQueries: "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"), @@ -966,6 +1030,52 @@ class DashboardQueries: "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 + ) + items = [item for item in items if item["video_count"] >= min_videos] sort_map = { "views": lambda item: item["total_views"], @@ -974,6 +1084,9 @@ class DashboardQueries: "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) @@ -1024,6 +1137,18 @@ class DashboardQueries: 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 " @@ -1045,6 +1170,24 @@ class DashboardQueries: "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), "folders": folder_titles, @@ -1222,6 +1365,76 @@ class DashboardQueries: # ------------------------------------------------------------ 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() + return { + "data_date": latest, + "snapshot_days": self._snapshot_days(conn), + "snapshot_first_date": first_date, + "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_efficiency": cfg.analytics.min_view_for_efficiency, + "shrink_minutes": cfg.analytics.shrink_minutes, + }, + "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) + def candidates(self) -> dict[str, Any]: def run(conn: sqlite3.Connection) -> dict[str, Any]: cfg = self.config_store.get() diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 49496a0..93de511 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -324,7 +324,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} scope = _param(query, "scope", "video") if scope == "up": self._send_json(HTTPStatus.OK, queries.list_ups( - sort=_param(query, "sort", "avg_views"), + sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), @@ -354,7 +354,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} return if path == "/api/ups": self._send_json(HTTPStatus.OK, queries.list_ups( - sort=_param(query, "sort", "avg_views"), + sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), @@ -385,6 +385,9 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} recent_days=_int_param(query, "recent_days", 14, 1, 3650), )) return + if path == "/api/about": + self._send_json(HTTPStatus.OK, queries.about()) + return if path == "/api/candidates": self._send_json(HTTPStatus.OK, queries.candidates()) return diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index fe598d2..75045fe 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -346,6 +346,82 @@ def test_fans_candidates_system_and_export(): db.close() +def test_duration_efficiency_queries(): + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + db.write(lambda conn: conn.executemany( + "UPDATE video SET duration = ?, pubdate = ? WHERE aid = ?", + [ + (9000, 1_756_684_800, 101), # 2025-09-01 前后,2.5 小时 + (7200, 1_756_771_200, 102), # 2 小时 + (300, 1_756_857_600, 103), # 5 分钟,应被排除 + ], + )) + DASHBOARD_CACHE.clear() + + efficiency = q.list_videos(sort="efficiency") + assert [item["aid"] for item in efficiency["items"]] == [102, 101] + first = efficiency["items"][0] + assert first["duration"] == 7200 + assert first["content_hours"] == 2.0 + assert first["efficiency"] == 2600.0 + assert efficiency["duration_filter"] == { + "min_duration_seconds": 600, "maturity_days": 14, "min_view": 0, + } + + interaction = q.list_videos(sort="interaction_efficiency") + assert [item["aid"] for item in interaction["items"]] == [102, 101] + assert interaction["items"][0]["interaction_efficiency"] == 181.0 + + # 时长缺失不计入时长效率榜 + ups = q.list_ups(sort="efficiency") + assert ups["sort"] == "efficiency" + up_a = next(item for item in ups["items"] if item["up_mid"] == 1) + assert up_a["total_duration"] == int(9000 + 7200) + assert up_a["content_hours"] == 4.5 + assert up_a["views_per_hour"] == round(6400 / 4.5, 1) + assert up_a["efficiency"] is not None + assert up_a["interaction_efficiency"] is not None + + by_duration = q.list_ups(sort="duration") + assert [item["up_mid"] for item in by_duration["items"]][0] == 1 + + db.write(lambda conn: conn.execute( + "UPDATE video SET duration = NULL WHERE aid = 101" + )) + DASHBOARD_CACHE.clear() + missing = q.list_videos(sort="efficiency") + assert [item["aid"] for item in missing["items"]] == [102] + finally: + db.close() + + +def test_about_payload(): + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + about = q.about() + assert about["data_date"] == DATES[-1] + assert about["snapshot_days"] == 3 + assert about["snapshot_first_date"] == DATES[0] + assert about["schema_version"] == 4 + assert about["analytics"] == { + "min_duration_seconds": 600, + "maturity_days": 14, + "min_view_for_efficiency": 0, + "shrink_minutes": None, + } + assert about["heat_weights"]["view"] == 0.30 + assert about["followers"]["videos_total"] == 3 + assert about["followers"]["videos_covered"] == 0 + assert about["followers"]["videos_missing"] == 3 + assert about["folders"][0]["media_id"] == 3700023351 + assert about["preview"] is False + finally: + db.close() + + def main_test(): test_overview() test_video_listing_filters_and_sorting() @@ -353,9 +429,11 @@ def main_test(): test_video_detail_and_trend() test_up_queries() test_query_cache_invalidation() + test_duration_efficiency_queries() + test_about_payload() test_fans_custom_range_modes() test_fans_candidates_system_and_export() - print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过") + print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统测试全部通过") if __name__ == "__main__": diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 1bc24c8..8dba8e2 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -73,6 +73,10 @@ def test_readonly_routes_and_static(): assert status == 200 and payload["ok"] is True status, _, payload = running.json("/api/system") assert status == 200 and payload["ok"] is True + status, _, payload = running.json("/api/about") + assert status == 200 + assert payload["data"]["schema_version"] == 4 + assert payload["data"]["analytics"]["min_duration_seconds"] == 600 status, _, overview = running.json("/api/overview") assert status == 200 and overview["ok"] is True @@ -94,7 +98,7 @@ def test_readonly_routes_and_static(): assert status == 404 # 伪静态前端路由:直接访问 /rank、/video/... 返回 index.html - for route_path in ("/rank", "/video/114775988111033", "/up/230020975", "/fans"): + for route_path in ("/rank", "/videos", "/ups", "/about", "/video/114775988111033", "/up/230020975", "/fans"): status, route_headers, route_payload = running.request(route_path) assert status == 200, route_path assert "text/html" in route_headers["Content-Type"], route_path @@ -109,7 +113,7 @@ def test_readonly_routes_and_static(): status, _, payload = running.request("/api/ups") assert status == 200 - assert json.loads(payload)["data"]["sort"] == "avg_views" + assert json.loads(payload)["data"]["sort"] == "efficiency" status, _, payload = running.request("/api/fans?days=7&recent_days=14") assert status == 200