feat(web): add duration efficiency rankings and about payload

This commit is contained in:
kairee committed 2026-11-01 11:21:42 +08:00
1 parent 519fc39fca
commit 5a656d42a8
4 files changed
+308 -10

No files matched your search

+218 -5
View File
@@ -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()
+5 -2
View File
@@ -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