feat(web): add duration efficiency rankings and about payload
This commit is contained in:
1 parent
519fc39fca
commit
5a656d42a8
4 files changed
+308
-10
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user