feat: upgrade douyin work list filters and ranking
This commit is contained in:
@@ -245,11 +245,22 @@ def _video_score_breakdown(video: dict[str, Any]) -> dict[str, Any]:
|
||||
else:
|
||||
age_days = 999.0
|
||||
|
||||
engagement_rate = (like + comment * 2.2 + share * 4.2 + collect * 3.0) / max(play, 1.0)
|
||||
share_rate = share / max(play, 1.0)
|
||||
collect_rate = collect / max(play, 1.0)
|
||||
comment_rate = comment / max(play, 1.0)
|
||||
like_rate = like / max(play, 1.0)
|
||||
if play > 0:
|
||||
rate_denominator = play
|
||||
else:
|
||||
rate_denominator = max(
|
||||
like * 18.0,
|
||||
comment * 70.0,
|
||||
share * 95.0,
|
||||
collect * 55.0,
|
||||
1000.0
|
||||
)
|
||||
|
||||
engagement_rate = (like + comment * 2.2 + share * 4.2 + collect * 3.0) / max(rate_denominator, 1.0)
|
||||
share_rate = share / max(rate_denominator, 1.0)
|
||||
collect_rate = collect / max(rate_denominator, 1.0)
|
||||
comment_rate = comment / max(rate_denominator, 1.0)
|
||||
like_rate = like / max(rate_denominator, 1.0)
|
||||
|
||||
volume_component = min(36.0, math.log10(play + 1.0) * 9.0)
|
||||
interaction_component = min(28.0, engagement_rate * 100.0)
|
||||
@@ -261,6 +272,17 @@ def _video_score_breakdown(video: dict[str, Any]) -> dict[str, Any]:
|
||||
min(100.0, volume_component + interaction_component + spread_component + freshness_component + baseline_component),
|
||||
2
|
||||
)
|
||||
popularity_score = round(
|
||||
min(
|
||||
100.0,
|
||||
math.log10(play + 1.0) * 24.0
|
||||
+ math.log10(like + 1.0) * 22.0
|
||||
+ math.log10(comment + 1.0) * 20.0
|
||||
+ math.log10(share + 1.0) * 18.0
|
||||
+ math.log10(collect + 1.0) * 16.0
|
||||
),
|
||||
2
|
||||
)
|
||||
commercial_score = round(
|
||||
min(
|
||||
100.0,
|
||||
@@ -288,6 +310,7 @@ def _video_score_breakdown(video: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
return {
|
||||
"performance_score": performance_score,
|
||||
"popularity_score": popularity_score,
|
||||
"commercial_score": commercial_score,
|
||||
"engagement_rate": round(engagement_rate, 4),
|
||||
"share_rate": round(share_rate, 4),
|
||||
@@ -486,27 +509,79 @@ def _pick_best_profile(candidates: list[dict[str, Any]], fallback_url: str = "")
|
||||
|
||||
|
||||
def _normalize_video_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
|
||||
def _collect_image_urls(node: Any) -> list[str]:
|
||||
urls: list[str] = []
|
||||
|
||||
def _visit(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text.startswith("http"):
|
||||
urls.append(text)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value[:20]:
|
||||
_visit(item)
|
||||
return
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
|
||||
for key in ("url", "download_url", "origin_url", "display_url", "cover_url"):
|
||||
target = value.get(key)
|
||||
if isinstance(target, str) and target.strip().startswith("http"):
|
||||
urls.append(target.strip())
|
||||
|
||||
url_list = value.get("url_list")
|
||||
if isinstance(url_list, list):
|
||||
for item in url_list[:5]:
|
||||
_visit(item)
|
||||
|
||||
for key in ("image", "images", "cover", "display_image", "origin_image"):
|
||||
child = value.get(key)
|
||||
if child not in (None, "", [], {}):
|
||||
_visit(child)
|
||||
|
||||
_visit(node)
|
||||
return _dedupe_strings(urls)
|
||||
|
||||
stats_source = candidate.get("statistics") if isinstance(candidate.get("statistics"), dict) else {}
|
||||
video_source = candidate.get("video") if isinstance(candidate.get("video"), dict) else {}
|
||||
title = _first_non_empty(candidate.get("title"), candidate.get("desc"), candidate.get("share_title"))
|
||||
description = _first_non_empty(candidate.get("desc"), candidate.get("title"), candidate.get("text"))
|
||||
cover = candidate.get("cover") or video_source.get("cover")
|
||||
image_urls = _collect_image_urls(
|
||||
[
|
||||
candidate.get("images"),
|
||||
candidate.get("image_infos"),
|
||||
candidate.get("image_list"),
|
||||
candidate.get("slides"),
|
||||
candidate.get("photos"),
|
||||
candidate.get("photo"),
|
||||
candidate.get("image_post_info"),
|
||||
]
|
||||
)
|
||||
if isinstance(cover, dict):
|
||||
cover = _first_non_empty(
|
||||
cover.get("url_list", [""])[0] if isinstance(cover.get("url_list"), list) else "",
|
||||
cover.get("url")
|
||||
)
|
||||
duration_raw = float(candidate.get("duration") or video_source.get("duration") or 0)
|
||||
duration_sec = duration_raw / 1000.0 if duration_raw > 1000 else duration_raw
|
||||
has_video_media = bool(video_source) or duration_sec > 0.3
|
||||
aweme_type = str(candidate.get("aweme_type") or "")
|
||||
looks_like_image_text = bool(image_urls) and (not has_video_media or aweme_type in {"51", "55", "61", "68", "122", "150"})
|
||||
content_type = "image_text" if looks_like_image_text else "video"
|
||||
return {
|
||||
"aweme_id": _first_non_empty(candidate.get("aweme_id"), candidate.get("item_id"), candidate.get("group_id")),
|
||||
"title": title,
|
||||
"description": description,
|
||||
"share_url": _first_non_empty(candidate.get("share_url")),
|
||||
"cover_url": _first_non_empty(cover),
|
||||
"duration_sec": float(candidate.get("duration") or video_source.get("duration") or 0) / 1000.0
|
||||
if float(candidate.get("duration") or video_source.get("duration") or 0) > 1000
|
||||
else float(candidate.get("duration") or video_source.get("duration") or 0),
|
||||
"cover_url": _first_non_empty(cover, image_urls[0] if image_urls else ""),
|
||||
"duration_sec": duration_sec,
|
||||
"published_at": _normalize_timestamp(candidate.get("create_time") or candidate.get("publish_time")),
|
||||
"tags": _extract_hashtags(title, description),
|
||||
"content_type": content_type,
|
||||
"content_type_label": "图文" if content_type == "image_text" else "视频",
|
||||
"image_count": len(image_urls),
|
||||
"stats": {
|
||||
"play": _parse_count(stats_source.get("play_count") or candidate.get("play_count")),
|
||||
"like": _parse_count(stats_source.get("digg_count") or candidate.get("digg_count")),
|
||||
@@ -1341,6 +1416,8 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
)
|
||||
payloads: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
raw_payload = _safe_json_loads(row["raw_json"], {})
|
||||
normalized = _normalize_video_candidate(raw_payload) if isinstance(raw_payload, dict) and raw_payload else {}
|
||||
payloads.append({
|
||||
"id": row["id"],
|
||||
"aweme_id": row["aweme_id"],
|
||||
@@ -1352,7 +1429,10 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
"published_at": row["published_at"],
|
||||
"tags": _safe_json_loads(row["tags_json"], []),
|
||||
"stats": _safe_json_loads(row["stats_json"], {}),
|
||||
"raw": _safe_json_loads(row["raw_json"], {})
|
||||
"content_type": normalized.get("content_type", "video"),
|
||||
"content_type_label": normalized.get("content_type_label", "视频"),
|
||||
"image_count": int(normalized.get("image_count") or 0),
|
||||
"raw": raw_payload
|
||||
})
|
||||
return payloads
|
||||
|
||||
@@ -1409,6 +1489,9 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
"duration_sec": video["duration_sec"],
|
||||
"published_at": video["published_at"],
|
||||
"tags": video["tags"],
|
||||
"content_type": video.get("content_type", "video"),
|
||||
"content_type_label": video.get("content_type_label", "视频"),
|
||||
"image_count": int(video.get("image_count") or 0),
|
||||
"stats": video["stats"],
|
||||
"score": score
|
||||
}
|
||||
@@ -1417,6 +1500,12 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
return payload
|
||||
|
||||
def _video_sort_key(video: dict[str, Any], sort_by: str) -> tuple[Any, ...]:
|
||||
if sort_by in {"popular", "popularity"}:
|
||||
return (
|
||||
float(video.get("score", {}).get("popularity_score") or 0),
|
||||
float(video.get("score", {}).get("performance_score") or 0),
|
||||
float(video.get("score", {}).get("commercial_score") or 0)
|
||||
)
|
||||
if sort_by == "latest":
|
||||
return (
|
||||
_parse_iso_datetime(video.get("published_at")) or datetime.fromtimestamp(0, tz=timezone.utc),
|
||||
@@ -1467,6 +1556,8 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
high_score_threshold = 60.0
|
||||
high_score_videos = [video for video in videos_by_score if float(video["score"]["performance_score"]) >= high_score_threshold]
|
||||
analyzed_count = sum(1 for video in videos if video.get("latest_analysis"))
|
||||
video_only_count = sum(1 for video in videos if video.get("content_type") == "video")
|
||||
image_text_count = sum(1 for video in videos if video.get("content_type") == "image_text")
|
||||
return {
|
||||
"items": videos,
|
||||
"top_scored_video_ids": [video["id"] for video in videos_by_score[: min(12, len(videos_by_score))]],
|
||||
@@ -1475,7 +1566,9 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
"meta": {
|
||||
"total_count": len(videos),
|
||||
"analyzed_count": analyzed_count,
|
||||
"high_score_count": len(high_score_videos)
|
||||
"high_score_count": len(high_score_videos),
|
||||
"video_count": video_only_count,
|
||||
"image_text_count": image_text_count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2818,9 +2911,10 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
@app.get("/v2/douyin/accounts/{account_id}/videos")
|
||||
def list_douyin_account_videos(
|
||||
account_id: str,
|
||||
limit: int = 60,
|
||||
limit: int = 200,
|
||||
sort_by: str = "score",
|
||||
scope: str = "all",
|
||||
content_type: str = "all",
|
||||
q: str = "",
|
||||
tag: str = "",
|
||||
account: dict[str, Any] = Depends(legacy.require_approved)
|
||||
@@ -2836,6 +2930,13 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
elif normalized_scope == "latest":
|
||||
items = [item_map[video_id] for video_id in workspace["latest_video_ids"] if video_id in item_map]
|
||||
|
||||
normalized_content_type = (content_type or "all").strip().lower()
|
||||
if normalized_content_type in {"video", "image_text"}:
|
||||
items = [
|
||||
item for item in items
|
||||
if str(item.get("content_type") or "video").strip().lower() == normalized_content_type
|
||||
]
|
||||
|
||||
query_text = (q or "").strip().lower()
|
||||
if query_text:
|
||||
items = [
|
||||
@@ -2863,13 +2964,14 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
|
||||
"account_id": account_row["id"],
|
||||
"sort_by": normalized_sort,
|
||||
"scope": normalized_scope,
|
||||
"content_type": normalized_content_type,
|
||||
"query": q,
|
||||
"tag": tag,
|
||||
"high_score_threshold": workspace["high_score_threshold"],
|
||||
"meta": workspace["meta"],
|
||||
"top_scored_video_ids": workspace["top_scored_video_ids"],
|
||||
"latest_video_ids": workspace["latest_video_ids"],
|
||||
"items": items[: max(1, min(limit, 120))]
|
||||
"items": items[: max(1, min(limit, 1000))]
|
||||
}
|
||||
|
||||
@app.get("/v2/douyin/accounts/{account_id}/analysis-reports")
|
||||
|
||||
@@ -713,6 +713,52 @@ function renderPage() {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.video-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.video-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(11, 60, 93, 0.95), rgba(31, 110, 95, 0.9));
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(11, 60, 93, 0.16);
|
||||
}
|
||||
.work-link {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.work-link:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.work-type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(185, 117, 36, 0.12);
|
||||
color: #8a5517;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.video-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
@@ -728,7 +774,7 @@ function renderPage() {
|
||||
}
|
||||
.toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.score-badges {
|
||||
@@ -963,7 +1009,7 @@ function renderPage() {
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
|
||||
<div>
|
||||
<h3>作品工作台</h3>
|
||||
<p class="hint" style="margin:6px 0 0;">这里会把高分作品和最新作品拆开看,并给高分作品自动补运营分析。</p>
|
||||
<p class="hint" style="margin:6px 0 0;">这里展示完整作品列表,可按发布时间、AI 打分、受欢迎程度与作品类型筛选排序,每条作品下都带分析与跳转链接。</p>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="secondary" id="analyze-top-videos-button" type="button">自动分析高分作品</button>
|
||||
@@ -971,7 +1017,7 @@ function renderPage() {
|
||||
</div>
|
||||
<div class="toolbar-grid">
|
||||
<label>
|
||||
作品列表
|
||||
作品范围
|
||||
<select id="videos-scope-select">
|
||||
<option value="all">全部作品</option>
|
||||
<option value="top">高分作品</option>
|
||||
@@ -981,7 +1027,8 @@ function renderPage() {
|
||||
<label>
|
||||
排序方式
|
||||
<select id="videos-sort-select">
|
||||
<option value="score">综合高分</option>
|
||||
<option value="score">AI 综合分</option>
|
||||
<option value="popular">受欢迎程度</option>
|
||||
<option value="commercial">商业价值</option>
|
||||
<option value="latest">最新发布时间</option>
|
||||
<option value="play">播放量</option>
|
||||
@@ -990,6 +1037,14 @@ function renderPage() {
|
||||
<option value="comment">评论量</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
作品类型
|
||||
<select id="videos-type-filter">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="video">视频作品</option>
|
||||
<option value="image_text">图文作品</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
标签筛选
|
||||
<input id="videos-tag-filter" placeholder="例如:创业 / 文案" autocomplete="off" />
|
||||
@@ -1070,6 +1125,7 @@ function renderPage() {
|
||||
const analyzeTopVideosButton = document.getElementById("analyze-top-videos-button");
|
||||
const videosScopeEl = document.getElementById("videos-scope-select");
|
||||
const videosSortEl = document.getElementById("videos-sort-select");
|
||||
const videosTypeFilterEl = document.getElementById("videos-type-filter");
|
||||
const videosTagFilterEl = document.getElementById("videos-tag-filter");
|
||||
const videosQueryFilterEl = document.getElementById("videos-query-filter");
|
||||
const videosSummaryEl = document.getElementById("videos-summary");
|
||||
@@ -1269,9 +1325,79 @@ function renderPage() {
|
||||
return '<div class="stack">' + safeItems.map(renderFn).join("") + '</div>';
|
||||
}
|
||||
|
||||
function getVideoContentType(video) {
|
||||
return String(video?.content_type || "video").trim().toLowerCase() === "image_text" ? "image_text" : "video";
|
||||
}
|
||||
|
||||
function getVideoContentTypeLabel(video) {
|
||||
return getVideoContentType(video) === "image_text" ? "图文作品" : "视频作品";
|
||||
}
|
||||
|
||||
function buildFallbackVideoAnalysis(video) {
|
||||
const score = video.score || {};
|
||||
const stats = video.stats || {};
|
||||
const tags = safeArray(video.tags).slice(0, 4);
|
||||
const headline = [
|
||||
Number(score.performance_score || 0) >= 65 ? "AI 判断这条作品具备稳定复用价值" : "这条作品更适合先做轻量复刻验证",
|
||||
Number(score.popularity_score || 0) >= 70 ? "当前受欢迎程度较高" : "当前热度中性,可从题材切口继续优化",
|
||||
].join(",") + "。";
|
||||
const reasons = [
|
||||
"发布时间:" + (formatDateTime(video.published_at) || "-"),
|
||||
"综合指标:播放 " + formatNumber(stats.play) + " / 点赞 " + formatNumber(stats.like) + " / 评论 " + formatNumber(stats.comment),
|
||||
"核心信号:" + safeArray(score.signals).join(";")
|
||||
];
|
||||
const hookBreakdown = [];
|
||||
if (video.title) {
|
||||
hookBreakdown.push("标题直接暴露主题词,适合保留主关键词:" + String(video.title).slice(0, 28));
|
||||
}
|
||||
if (tags.length) {
|
||||
hookBreakdown.push("标签集中在 " + tags.join(" / ") + ",说明题材识别度已经形成。");
|
||||
}
|
||||
if (!hookBreakdown.length) {
|
||||
hookBreakdown.push("建议先从封面标题和前 3 秒钩子补强识别度。");
|
||||
}
|
||||
const structureBreakdown = [
|
||||
getVideoContentType(video) === "image_text"
|
||||
? "图文作品优先优化首图信息密度、第二屏承接和最后一屏 CTA。"
|
||||
: "视频作品优先拆前 3 秒钩子、中段论点推进和结尾 CTA。",
|
||||
"优先复刻已有高互动题材,再替换场景和对象人群。"
|
||||
];
|
||||
const operatorActions = [
|
||||
"把这条作品纳入可复刻题材库,优先测试 1 个同主题新切口。",
|
||||
"评论区补一句明确动作指令,承接收藏、私信或咨询。",
|
||||
];
|
||||
const riskNotes = [
|
||||
Number(score.age_days || 0) > 45 ? "发布时间较久,复刻时需要更新案例和场景。" : "仍可参考当前结构,但要避免直接照搬表达。",
|
||||
Number(score.comment_rate || 0) < 0.005 ? "评论互动偏弱,复刻时要补问题式结尾。": "评论反馈不错,可重点利用评论区做二次选题。"
|
||||
];
|
||||
return {
|
||||
headline_summary: headline,
|
||||
commercial_angle: {
|
||||
judgement: Number(score.commercial_score || 0) >= 60 ? "具备较好的内容转化潜力" : "更适合先做流量验证,再逐步补承接",
|
||||
suitable_for: [
|
||||
"选题模板复刻",
|
||||
"账号栏目化运营",
|
||||
Number(score.collect_rate || 0) >= 0.008 ? "知识产品/清单承接" : "私信或评论区动作优化"
|
||||
]
|
||||
},
|
||||
scores: {
|
||||
hook: Number(score.performance_score || 0),
|
||||
retention: Number(score.popularity_score || 0),
|
||||
conversion: Number(score.comment_rate || 0) * 10000,
|
||||
commercial: Number(score.commercial_score || 0)
|
||||
},
|
||||
replication_plan: reasons,
|
||||
operator_actions: operatorActions,
|
||||
hook_breakdown: hookBreakdown,
|
||||
structure_breakdown: structureBreakdown,
|
||||
risk_notes: riskNotes
|
||||
};
|
||||
}
|
||||
|
||||
function getSortedVideos() {
|
||||
const scope = videosScopeEl.value || "all";
|
||||
const sortBy = videosSortEl.value || "score";
|
||||
const typeFilter = videosTypeFilterEl.value || "all";
|
||||
const query = videosQueryFilterEl.value.trim().toLowerCase();
|
||||
const tag = videosTagFilterEl.value.trim().toLowerCase();
|
||||
const topSet = new Set(safeArray(workbenchState.topScoredVideoIds));
|
||||
@@ -1283,6 +1409,9 @@ function renderPage() {
|
||||
if (scope === "latest" && !latestSet.has(video.id)) {
|
||||
return false;
|
||||
}
|
||||
if (typeFilter !== "all" && getVideoContentType(video) !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (query) {
|
||||
const haystack = [video.title, video.description, video.aweme_id, ...safeArray(video.tags)].join(" ").toLowerCase();
|
||||
if (!haystack.includes(query)) {
|
||||
@@ -1298,6 +1427,7 @@ function renderPage() {
|
||||
return true;
|
||||
});
|
||||
const getValue = (video) => {
|
||||
if (sortBy === "popular") return Number(video.score?.popularity_score || 0);
|
||||
if (sortBy === "commercial") return Number(video.score?.commercial_score || 0);
|
||||
if (sortBy === "latest") return new Date(video.published_at || 0).getTime();
|
||||
if (sortBy === "play") return Number(video.stats?.play || 0);
|
||||
@@ -1353,32 +1483,41 @@ function renderPage() {
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderVideoAnalysisCard(video) {
|
||||
function renderVideoAnalysisCard(video, index) {
|
||||
const analysis = video.latest_analysis || {};
|
||||
const parsed = analysis.parsed_json || {};
|
||||
const parsed = analysis.parsed_json && Object.keys(analysis.parsed_json).length
|
||||
? analysis.parsed_json
|
||||
: buildFallbackVideoAnalysis(video);
|
||||
const score = video.score || {};
|
||||
const stats = video.stats || {};
|
||||
const workTitle = video.title || video.description || video.aweme_id || "未命名作品";
|
||||
const titleHtml = video.share_url
|
||||
? '<a class="work-link" href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">' + escapeHtml(workTitle) + '</a>'
|
||||
: '<span>' + escapeHtml(workTitle) + '</span>';
|
||||
const analysisLabel = video.latest_analysis ? "AI 深度分析" : "系统速评";
|
||||
return [
|
||||
'<article class="video-card">',
|
||||
'<div class="video-layout">',
|
||||
video.cover_url ? '<img class="cover-thumb" src="' + escapeHtml(video.cover_url) + '" alt="cover" />' : '<div class="cover-thumb"></div>',
|
||||
'<div class="stack">',
|
||||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||||
'<div><strong>' + escapeHtml(video.title || video.aweme_id || "未命名作品") + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div></div>',
|
||||
'<div class="video-card-header">',
|
||||
'<div class="video-card-title"><span class="rank-badge">#' + escapeHtml(String(index + 1)) + '</span><strong>' + titleHtml + '</strong><span class="work-type-pill">' + escapeHtml(getVideoContentTypeLabel(video)) + '</span></div>',
|
||||
'<span class="pill">' + escapeHtml(video.aweme_id || "-") + '</span>',
|
||||
'</div>',
|
||||
'<div class="meta">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + (Number(video.image_count || 0) > 0 && getVideoContentType(video) === "image_text" ? ',共 ' + escapeHtml(formatNumber(video.image_count)) + ' 张图' : '') + '</div>',
|
||||
'<div class="score-badges">',
|
||||
'<span class="score-badge"><strong>综合</strong>' + escapeHtml(formatNumber(score.performance_score)) + '</span>',
|
||||
'<span class="score-badge"><strong>受欢迎度</strong>' + escapeHtml(formatNumber(score.popularity_score)) + '</span>',
|
||||
'<span class="score-badge"><strong>商业</strong>' + escapeHtml(formatNumber(score.commercial_score)) + '</span>',
|
||||
'<span class="score-badge"><strong>播放</strong>' + escapeHtml(formatNumber(stats.play)) + '</span>',
|
||||
'<span class="score-badge"><strong>点赞</strong>' + escapeHtml(formatNumber(stats.like)) + '</span>',
|
||||
'<span class="score-badge"><strong>评论</strong>' + escapeHtml(formatNumber(stats.comment)) + '</span>',
|
||||
'<span class="score-badge"><strong>分享</strong>' + escapeHtml(formatNumber(stats.share)) + '</span>',
|
||||
'<span class="score-badge"><strong>收藏率</strong>' + escapeHtml(formatPercent(score.collect_rate)) + '</span>',
|
||||
'</div>',
|
||||
safeArray(video.tags).length ? '<div class="chips">' + safeArray(video.tags).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
|
||||
'<div class="meta">互动率:' + escapeHtml(formatPercent(score.engagement_rate)) + ',评论率:' + escapeHtml(formatPercent(score.comment_rate)) + ',发布时间距今:' + escapeHtml(score.age_days == null ? "-" : score.age_days + " 天") + '</div>',
|
||||
'<div class="meta">互动率:' + escapeHtml(formatPercent(score.engagement_rate)) + ',评论率:' + escapeHtml(formatPercent(score.comment_rate)) + ',收藏率:' + escapeHtml(formatPercent(score.collect_rate)) + ',发布时间距今:' + escapeHtml(score.age_days == null ? "-" : score.age_days + " 天") + '</div>',
|
||||
'<div class="chips">' + safeArray(score.signals).map((item) => '<span class="chip">' + escapeHtml(item) + '</span>').join("") + '</div>',
|
||||
'<div class="link-row">' + (video.share_url ? '<a href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">打开作品</a>' : '') + '</div>',
|
||||
'<div class="link-row"><span class="meta">' + analysisLabel + '</span>' + (video.share_url ? '<a href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">打开作品</a>' : '') + '</div>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
parsed.headline_summary ? '<div class="analysis-block"><div class="summary-callout">' + escapeHtml(parsed.headline_summary || analysis.summary_text || "暂无分析结论") + '</div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">为什么值得做</h4><div class="meta">商业判断:' + escapeHtml((parsed.commercial_angle || {}).judgement || "-") + '</div><div class="meta" style="margin-top:8px;">可承接方向</div>' + renderBulletList((parsed.commercial_angle || {}).suitable_for, "暂无承接方向") + '<div class="meta" style="margin-top:10px;">分项评分</div>' + renderBulletList(['钩子 ' + formatNumber((parsed.scores || {}).hook), '留存 ' + formatNumber((parsed.scores || {}).retention), '转化 ' + formatNumber((parsed.scores || {}).conversion), '商业 ' + formatNumber((parsed.scores || {}).commercial)], "暂无分项评分") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">复刻与运营动作</h4><div class="meta">复刻计划</div>' + renderBulletList(parsed.replication_plan, "暂无复刻计划") + '<div class="meta" style="margin-top:10px;">运营动作</div>' + renderBulletList(parsed.operator_actions, "暂无运营动作") + '</div></div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">钩子与结构</h4><div class="meta">钩子拆解</div>' + renderBulletList(parsed.hook_breakdown, "暂无钩子拆解") + '<div class="meta" style="margin-top:10px;">结构拆解</div>' + renderBulletList(parsed.structure_breakdown, "暂无结构拆解") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">风险提醒</h4>' + renderBulletList(parsed.risk_notes, "暂无风险提醒") + '</div></div></div>' : '<div class="detail-box"><p class="empty-state">这条作品还没有自动分析。点击上面的“自动分析高分作品”即可补齐。</p></div>',
|
||||
@@ -1393,16 +1532,17 @@ function renderPage() {
|
||||
'<div class="metric-grid">',
|
||||
'<div class="metric-card"><div class="metric-label">作品总数</div><div class="metric-value">' + escapeHtml(formatNumber(meta.total_count)) + '</div></div>',
|
||||
'<div class="metric-card"><div class="metric-label">已分析作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.analyzed_count)) + '</div></div>',
|
||||
'<div class="metric-card"><div class="metric-label">高分作品数</div><div class="metric-value">' + escapeHtml(formatNumber(meta.high_score_count)) + '</div></div>',
|
||||
'<div class="metric-card"><div class="metric-label">视频作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.video_count)) + '</div></div>',
|
||||
'<div class="metric-card"><div class="metric-label">图文作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.image_text_count)) + '</div></div>',
|
||||
'<div class="metric-card"><div class="metric-label">当前显示</div><div class="metric-value">' + escapeHtml(formatNumber(items.length)) + '</div></div>',
|
||||
'</div>',
|
||||
'<p class="hint" style="margin-top:12px;">高分阈值:' + escapeHtml(formatNumber(workbenchState.highScoreThreshold)) + '。高分榜更适合找商业化样板,最新榜更适合看近期题材窗口。</p>'
|
||||
'<p class="hint" style="margin-top:12px;">高分阈值:' + escapeHtml(formatNumber(workbenchState.highScoreThreshold)) + '。受欢迎程度会综合播放、点赞、评论、分享与收藏信号;点击作品标题或“打开作品”即可跳到原链接。</p>'
|
||||
].join("");
|
||||
if (!items.length) {
|
||||
videosListEl.innerHTML = '<p class="empty-state">当前筛选条件下没有作品。</p>';
|
||||
return;
|
||||
}
|
||||
videosListEl.innerHTML = '<div class="video-grid">' + items.map(renderVideoAnalysisCard).join("") + '</div>';
|
||||
videosListEl.innerHTML = '<div class="video-grid">' + items.map((video, index) => renderVideoAnalysisCard(video, index)).join("") + '</div>';
|
||||
}
|
||||
|
||||
function renderWorkbenchSession() {
|
||||
@@ -1519,7 +1659,7 @@ function renderPage() {
|
||||
}
|
||||
|
||||
const account = workspace.account;
|
||||
const videos = safeArray(account.video_summary?.videos);
|
||||
const videoMeta = workbenchState.videoMeta || {};
|
||||
const reports = safeArray(workspace.recent_reports);
|
||||
const linkedAccounts = safeArray(workspace.linked_accounts);
|
||||
const similarSearches = safeArray(workspace.recent_similarity_searches);
|
||||
@@ -1558,7 +1698,7 @@ function renderPage() {
|
||||
'</div>',
|
||||
safeArray(account.tags).length ? '<div class="chips" style="margin-top:16px;">' + safeArray(account.tags).slice(0, 18).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
|
||||
syncErrors.length ? '<div class="link-item" style="margin-top:16px;"><strong>同步提示</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(syncErrors.join(" / ")) + '</div></div>' : '',
|
||||
videos.length ? '<div class="stack" style="margin-top:16px;"><h4 style="margin:0;">最近作品</h4>' + videos.slice(0, 8).map((video) => '<div class="video-item"><strong>' + escapeHtml(video.title || video.description || video.aweme_id) + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div><div class="meta" style="margin-top:6px;">播放 ' + escapeHtml(formatNumber(video.stats?.play)) + ' / 点赞 ' + escapeHtml(formatNumber(video.stats?.like)) + ' / 评论 ' + escapeHtml(formatNumber(video.stats?.comment)) + '</div></div>').join("") + '</div>' : ''
|
||||
'<div class="link-item" style="margin-top:16px;"><strong>完整作品库已载入</strong><div class="meta" style="margin-top:8px;">当前账号共有 ' + escapeHtml(formatNumber(videoMeta.total_count || account.video_summary?.count)) + ' 条作品,其中视频 ' + escapeHtml(formatNumber(videoMeta.video_count)) + ' 条、图文 ' + escapeHtml(formatNumber(videoMeta.image_text_count)) + ' 条。</div><div class="meta" style="margin-top:6px;">请直接使用下方“作品工作台”按发布时间、AI 打分、受欢迎程度和作品类型查看完整列表,并可逐条打开原作品链接。</div></div>'
|
||||
].join("");
|
||||
|
||||
analysisReportsEl.innerHTML = reports.length ? reports.map((report) => {
|
||||
@@ -1658,7 +1798,7 @@ function renderPage() {
|
||||
const results = await Promise.all([
|
||||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/workspace"),
|
||||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => []),
|
||||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=80").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
|
||||
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=1000").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
|
||||
]);
|
||||
workbenchState.selectedWorkspace = results[0];
|
||||
workbenchState.snapshots = safeArray(results[1]);
|
||||
@@ -1977,6 +2117,7 @@ function renderPage() {
|
||||
accountsFilterEl.addEventListener("input", renderAccountList);
|
||||
videosScopeEl.addEventListener("change", renderVideos);
|
||||
videosSortEl.addEventListener("change", renderVideos);
|
||||
videosTypeFilterEl.addEventListener("change", renderVideos);
|
||||
videosTagFilterEl.addEventListener("input", renderVideos);
|
||||
videosQueryFilterEl.addEventListener("input", renderVideos);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user