diff --git a/README.md b/README.md
index 8777eab..cbc5e2a 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,9 @@ http://127.0.0.1:3618
这个本地页面现在包含两部分:
- 上半部分是浏览器辅助采集控制台
-- 下半部分是 `Douyin Workbench`,可直接查看账号列表、Agent 分析结论、快照详情、相似账号和对标关系
+- 下半部分是 `Douyin Workbench`,可直接查看账号列表、商业化账号分析、快照详情、相似账号和对标关系
+- 作品工作台支持按 `高分作品 / 最新作品 / 全部作品` 切换,并可按综合分、商业价值、发布时间、播放、点赞、分享、评论排序
+- 高分作品支持自动化分析,每条作品卡片下都会展示商业判断、复刻计划、运营动作和风险提醒
或者继续用命令行:
diff --git a/collector-service/app/douyin_features.py b/collector-service/app/douyin_features.py
index 86f16ac..1900cfa 100644
--- a/collector-service/app/douyin_features.py
+++ b/collector-service/app/douyin_features.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
+import math
import re
from collections import Counter
from datetime import datetime, timezone
@@ -53,6 +54,15 @@ class DouyinAccountAnalysisRequest(BaseModel):
max_videos: int = 12
extra_focus: str = ""
temperature: float = 0.35
+ auto_analyze_top_videos: bool = True
+ top_video_analysis_count: int = 6
+
+
+class DouyinTopVideoAnalysisRequest(BaseModel):
+ model_profile_id: str | None = None
+ top_video_count: int = 6
+ min_score: float = 45.0
+ temperature: float = 0.25
class DouyinSimilarSearchRequest(BaseModel):
@@ -173,6 +183,20 @@ def _normalize_timestamp(value: Any) -> str | None:
return None
+def _parse_iso_datetime(value: Any) -> datetime | None:
+ text = str(value or "").strip()
+ if not text:
+ return None
+ normalized = text.replace("Z", "+00:00")
+ try:
+ parsed = datetime.fromisoformat(normalized)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
def _extract_hashtags(*texts: str) -> list[str]:
tags: list[str] = []
for text in texts:
@@ -207,6 +231,81 @@ def _extract_keywords(*texts: str) -> list[str]:
return _dedupe_strings(filtered)
+def _video_score_breakdown(video: dict[str, Any]) -> dict[str, Any]:
+ stats = video.get("stats", {}) or {}
+ play = float(stats.get("play") or 0)
+ like = float(stats.get("like") or 0)
+ comment = float(stats.get("comment") or 0)
+ share = float(stats.get("share") or 0)
+ collect = float(stats.get("collect") or 0)
+
+ published_dt = _parse_iso_datetime(video.get("published_at"))
+ if published_dt:
+ age_days = max(0.0, (datetime.now(timezone.utc) - published_dt).total_seconds() / 86400.0)
+ 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)
+
+ volume_component = min(36.0, math.log10(play + 1.0) * 9.0)
+ interaction_component = min(28.0, engagement_rate * 100.0)
+ spread_component = min(18.0, (share_rate * 1200.0) + (collect_rate * 700.0))
+ freshness_component = max(0.0, 18.0 - min(age_days, 36.0) * 0.5)
+ baseline_component = 6.0 if play > 0 or like > 0 else 0.0
+
+ performance_score = round(
+ min(100.0, volume_component + interaction_component + spread_component + freshness_component + baseline_component),
+ 2
+ )
+ commercial_score = round(
+ min(
+ 100.0,
+ performance_score * 0.58
+ + min(24.0, share_rate * 2200.0)
+ + min(18.0, collect_rate * 2000.0)
+ + min(12.0, comment_rate * 900.0)
+ ),
+ 2
+ )
+
+ signals: list[str] = []
+ if share_rate >= 0.01:
+ signals.append("分享率高,具备扩散和二次传播潜力")
+ if collect_rate >= 0.008:
+ signals.append("收藏率高,适合沉淀模板、知识产品或私域承接")
+ if like_rate >= 0.05:
+ signals.append("点赞率突出,说明钩子与情绪价值有效")
+ if comment_rate >= 0.01:
+ signals.append("评论率较高,适合做互动运营和评论区转化")
+ if age_days <= 14 and play >= 10_000:
+ signals.append("近期作品仍有较高播放,说明题材仍在窗口期")
+ if not signals:
+ signals.append("当前数据中性,需要结合转化目标继续验证")
+
+ return {
+ "performance_score": performance_score,
+ "commercial_score": commercial_score,
+ "engagement_rate": round(engagement_rate, 4),
+ "share_rate": round(share_rate, 4),
+ "collect_rate": round(collect_rate, 4),
+ "comment_rate": round(comment_rate, 4),
+ "like_rate": round(like_rate, 4),
+ "age_days": round(age_days if age_days < 999 else 0.0, 1) if published_dt else None,
+ "components": {
+ "volume": round(volume_component, 2),
+ "interaction": round(interaction_component, 2),
+ "spread": round(spread_component, 2),
+ "freshness": round(freshness_component, 2),
+ "baseline": round(baseline_component, 2)
+ },
+ "signals": signals[:4]
+ }
+
+
def _flatten_json(value: Any, prefix: str = "") -> list[tuple[str, str, str]]:
rows: list[tuple[str, str, str]] = []
if isinstance(value, dict):
@@ -744,6 +843,37 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
CREATE INDEX IF NOT EXISTS idx_douyin_videos_account_aweme
ON douyin_videos(account_id, aweme_id);
+ CREATE TABLE IF NOT EXISTS douyin_video_analyses (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ video_id TEXT NOT NULL,
+ report_id TEXT NOT NULL DEFAULT '',
+ model_profile_id TEXT NOT NULL DEFAULT '',
+ model_label TEXT NOT NULL DEFAULT '',
+ source_type TEXT NOT NULL DEFAULT 'top_score_auto',
+ status TEXT NOT NULL DEFAULT 'ok',
+ performance_score REAL NOT NULL DEFAULT 0,
+ commercial_score REAL NOT NULL DEFAULT 0,
+ hook_score REAL NOT NULL DEFAULT 0,
+ retention_score REAL NOT NULL DEFAULT 0,
+ conversion_score REAL NOT NULL DEFAULT 0,
+ summary_text TEXT NOT NULL DEFAULT '',
+ suggestion_text TEXT NOT NULL DEFAULT '',
+ parsed_json TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY(account_id) REFERENCES douyin_accounts(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
+ FOREIGN KEY(video_id) REFERENCES douyin_videos(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_douyin_video_analyses_video_created
+ ON douyin_video_analyses(video_id, created_at DESC);
+
+ CREATE INDEX IF NOT EXISTS idx_douyin_video_analyses_account_created
+ ON douyin_video_analyses(account_id, created_at DESC);
+
CREATE TABLE IF NOT EXISTS douyin_analysis_reports (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
@@ -1226,6 +1356,129 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
})
return payloads
+ def _latest_video_analysis_map(account_id: str) -> dict[str, dict[str, Any]]:
+ rows = legacy.db.fetch_all(
+ """
+ SELECT analysis.*
+ FROM douyin_video_analyses analysis
+ INNER JOIN (
+ SELECT video_id, MAX(created_at) AS latest_created_at
+ FROM douyin_video_analyses
+ WHERE account_id = ?
+ GROUP BY video_id
+ ) latest
+ ON latest.video_id = analysis.video_id
+ AND latest.latest_created_at = analysis.created_at
+ WHERE analysis.account_id = ?
+ """,
+ (account_id, account_id)
+ )
+ payloads: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ parsed = _safe_json_loads(row["parsed_json"], {})
+ payloads[row["video_id"]] = {
+ "id": row["id"],
+ "video_id": row["video_id"],
+ "report_id": row["report_id"],
+ "model_profile_id": row["model_profile_id"],
+ "model_label": row["model_label"],
+ "source_type": row["source_type"],
+ "status": row["status"],
+ "performance_score": float(row["performance_score"] or 0),
+ "commercial_score": float(row["commercial_score"] or 0),
+ "hook_score": float(row["hook_score"] or 0),
+ "retention_score": float(row["retention_score"] or 0),
+ "conversion_score": float(row["conversion_score"] or 0),
+ "summary_text": row["summary_text"],
+ "suggestion_text": row["suggestion_text"],
+ "parsed_json": parsed,
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"]
+ }
+ return payloads
+
+ def _build_video_payload(video: dict[str, Any], latest_analysis: dict[str, Any] | None = None) -> dict[str, Any]:
+ score = _video_score_breakdown(video)
+ payload = {
+ "id": video["id"],
+ "aweme_id": video["aweme_id"],
+ "title": video["title"],
+ "description": video["description"],
+ "share_url": video["share_url"],
+ "cover_url": video["cover_url"],
+ "duration_sec": video["duration_sec"],
+ "published_at": video["published_at"],
+ "tags": video["tags"],
+ "stats": video["stats"],
+ "score": score
+ }
+ if latest_analysis:
+ payload["latest_analysis"] = latest_analysis
+ return payload
+
+ def _video_sort_key(video: dict[str, Any], sort_by: str) -> tuple[Any, ...]:
+ if sort_by == "latest":
+ return (
+ _parse_iso_datetime(video.get("published_at")) or datetime.fromtimestamp(0, tz=timezone.utc),
+ video.get("score", {}).get("performance_score", 0)
+ )
+ if sort_by == "commercial":
+ return (
+ float(video.get("score", {}).get("commercial_score") or 0),
+ float(video.get("score", {}).get("performance_score") or 0)
+ )
+ if sort_by == "play":
+ return (
+ float((video.get("stats") or {}).get("play") or 0),
+ float(video.get("score", {}).get("performance_score") or 0)
+ )
+ if sort_by == "like":
+ return (
+ float((video.get("stats") or {}).get("like") or 0),
+ float(video.get("score", {}).get("performance_score") or 0)
+ )
+ if sort_by == "share":
+ return (
+ float((video.get("stats") or {}).get("share") or 0),
+ float(video.get("score", {}).get("performance_score") or 0)
+ )
+ if sort_by == "comment":
+ return (
+ float((video.get("stats") or {}).get("comment") or 0),
+ float(video.get("score", {}).get("performance_score") or 0)
+ )
+ return (
+ float(video.get("score", {}).get("performance_score") or 0),
+ float(video.get("score", {}).get("commercial_score") or 0)
+ )
+
+ def _build_video_workspace_payload(
+ account_row: dict[str, Any],
+ limit: int = 60
+ ) -> dict[str, Any]:
+ raw_videos = _list_videos(account_row["id"], limit=max(limit, 24))
+ latest_analysis_map = _latest_video_analysis_map(account_row["id"])
+ videos = [
+ _build_video_payload(video, latest_analysis_map.get(video["id"]))
+ for video in raw_videos
+ ]
+ videos_by_score = sorted(videos, key=lambda item: _video_sort_key(item, "score"), reverse=True)
+ videos_by_latest = sorted(videos, key=lambda item: _video_sort_key(item, "latest"), reverse=True)
+ 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"))
+ return {
+ "items": videos,
+ "top_scored_video_ids": [video["id"] for video in videos_by_score[: min(12, len(videos_by_score))]],
+ "latest_video_ids": [video["id"] for video in videos_by_latest[: min(12, len(videos_by_latest))]],
+ "high_score_threshold": high_score_threshold,
+ "meta": {
+ "total_count": len(videos),
+ "analyzed_count": analyzed_count,
+ "high_score_count": len(high_score_videos)
+ }
+ }
+
def _finalize_sync_workspace(
owner: dict[str, Any],
request: DouyinAccountSyncRequest,
@@ -1347,6 +1600,7 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
def _build_workspace_payload(account_row: dict[str, Any]) -> dict[str, Any]:
account_payload = _build_account_payload(account_row)
+ video_workspace = _build_video_workspace_payload(account_row)
latest_public_snapshot = legacy.db.fetch_one(
"""
SELECT *
@@ -1429,6 +1683,12 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
} if latest_creator_snapshot else None,
"linked_accounts": _list_linked_accounts(account_row),
"recent_reports": report_payloads,
+ "video_workspace": {
+ "top_scored_video_ids": video_workspace["top_scored_video_ids"],
+ "latest_video_ids": video_workspace["latest_video_ids"],
+ "high_score_threshold": video_workspace["high_score_threshold"],
+ "meta": video_workspace["meta"]
+ },
"recent_similarity_searches": [
{
"id": row["id"],
@@ -1504,12 +1764,421 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
"fields": fields
}
+ def _build_video_context_items(
+ video_workspace: dict[str, Any],
+ max_top_items: int = 6,
+ max_latest_items: int = 6
+ ) -> dict[str, list[dict[str, Any]]]:
+ items = video_workspace.get("items", [])
+ item_map = {item["id"]: item for item in items}
+ top_items = [
+ item_map[video_id]
+ for video_id in video_workspace.get("top_scored_video_ids", [])[:max_top_items]
+ if video_id in item_map
+ ]
+ latest_items = [
+ item_map[video_id]
+ for video_id in video_workspace.get("latest_video_ids", [])[:max_latest_items]
+ if video_id in item_map
+ ]
+
+ def _brief(video: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "video_id": video["id"],
+ "aweme_id": video["aweme_id"],
+ "title": video["title"],
+ "description": video["description"],
+ "published_at": video["published_at"],
+ "tags": video["tags"][:6],
+ "stats": video["stats"],
+ "score": video["score"],
+ "latest_analysis": (video.get("latest_analysis") or {}).get("parsed_json") or {}
+ }
+
+ return {
+ "top_performing_videos": [_brief(item) for item in top_items],
+ "latest_videos": [_brief(item) for item in latest_items]
+ }
+
+ def _bounded_score(value: Any, fallback: float = 0.0) -> float:
+ parsed = _parse_count(value)
+ if parsed <= 0 and value not in (0, "0", 0.0):
+ parsed = fallback
+ return round(max(0.0, min(100.0, parsed or fallback)), 2)
+
+ def _merge_structured_payload(fallback: Any, parsed: Any) -> Any:
+ if not isinstance(fallback, dict) or not isinstance(parsed, dict):
+ return parsed or fallback
+ merged: dict[str, Any] = {}
+ for key, fallback_value in fallback.items():
+ if key not in parsed:
+ merged[key] = fallback_value
+ continue
+ parsed_value = parsed[key]
+ if isinstance(fallback_value, dict) and isinstance(parsed_value, dict):
+ merged[key] = _merge_structured_payload(fallback_value, parsed_value)
+ else:
+ merged[key] = parsed_value if parsed_value not in (None, "", [], {}) else fallback_value
+ for key, parsed_value in parsed.items():
+ if key not in merged:
+ merged[key] = parsed_value
+ return merged
+
+ def _infer_offer_directions(keywords: list[str]) -> list[str]:
+ normalized = {item.lower() for item in keywords}
+ offers: list[str] = []
+ if {"创业", "成交", "获客"} & normalized:
+ offers.append("创业获客咨询、成交训练营或老板 IP 陪跑")
+ if {"文案", "短视频文案", "口播", "二创"} & normalized:
+ offers.append("短视频文案模板包、脚本代写或内容陪跑服务")
+ if {"教育", "教育规划"} & normalized:
+ offers.append("教育规划咨询、升学产品或高客单咨询服务")
+ if {"后期", "剪辑", "产品"} & normalized:
+ offers.append("剪辑优化、内容包装或产品策划服务")
+ if not offers:
+ offers.append("内容咨询、账号诊断和主题训练营")
+ offers.append("以高分作品为样板的复刻栏目和线索承接页")
+ return _dedupe_strings(offers)[:4]
+
+ def _build_video_analysis_fallback(account_payload: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]:
+ score = video["score"]
+ tags = video.get("tags") or []
+ keywords = _extract_keywords(video.get("title", ""), video.get("description", ""))
+ hook_patterns: list[str] = []
+ title = video.get("title", "")
+ if any(token in title for token in ("怎么", "如何", "为什么")):
+ hook_patterns.append("问题解决型开场,先给结果再给方法")
+ if any(token in title for token in ("坑", "误区", "别", "不要")):
+ hook_patterns.append("避坑警示型开场,容易拉停留和评论")
+ if re.search(r"\d", title):
+ hook_patterns.append("数字型表达能快速建立信息密度和预期")
+ if not hook_patterns:
+ hook_patterns.append("强结论或冲突判断先出,适合 3 秒内抢注意力")
+
+ structure_patterns = [
+ "开头给结论或反常识观点,中段拆 2-3 个要点,结尾给执行动作",
+ "用具体场景或常见错误承接,降低理解门槛",
+ "把方法论压缩成可收藏的清单,利于后续转化"
+ ]
+ commercial_judgement = (
+ "这条内容适合做高意向线索承接,优先放在咨询、训练营或模板产品前链路。"
+ if score["commercial_score"] >= 70
+ else "这条内容更适合作为流量内容,用来放大覆盖,再通过评论区和私信承接。"
+ )
+ operator_actions = [
+ "把标题里的核心钩子沉淀成 3-5 个固定开场模板,持续复用",
+ "在评论区补一个可执行清单,测试评论区转化和私信承接",
+ "围绕同主题连续发 3 条变体,验证题材是否可规模化"
+ ]
+ if score["collect_rate"] >= 0.008:
+ operator_actions.append("把这条内容延展成可下载资料或收藏型产品,提高转化效率")
+ if score["share_rate"] >= 0.01:
+ operator_actions.append("把传播点提炼成系列化选题,优先投放同类话题")
+
+ return {
+ "headline_summary": f"《{_compact_text(title, 30)}》属于高可复制内容,核心价值在于{score['signals'][0]}。",
+ "hook_breakdown": hook_patterns[:3],
+ "structure_breakdown": structure_patterns,
+ "commercial_angle": {
+ "score": score["commercial_score"],
+ "judgement": commercial_judgement,
+ "suitable_for": _infer_offer_directions(account_payload.get("keywords", []))[:3]
+ },
+ "replication_plan": [
+ f"围绕 {tags[0] if tags else '当前主题'} 再做 3 条不同人群切口",
+ "保持同类开头结构,但替换成更具体的场景和结果承诺",
+ "在结尾加入明确的下一步动作,承接评论、私信或表单"
+ ],
+ "operator_actions": _dedupe_strings(operator_actions)[:5],
+ "risk_notes": [
+ "如果后续复刻只保留题材、不保留强钩子,数据会明显回落",
+ "如果评论区没有承接动作,商业化价值会停留在播放层"
+ ],
+ "scores": {
+ "hook": min(100.0, round(score["performance_score"] * 0.92 + 4, 2)),
+ "retention": min(100.0, round(score["performance_score"] * 0.88 + 6, 2)),
+ "conversion": min(100.0, round(score["commercial_score"] * 0.93 + 3, 2)),
+ "commercial": score["commercial_score"]
+ },
+ "raw_keywords": keywords[:8]
+ }
+
+ def _build_account_analysis_fallback(
+ target_payload: dict[str, Any],
+ benchmark_payloads: list[dict[str, Any]],
+ analysis_context: dict[str, Any]
+ ) -> dict[str, Any]:
+ video_workspace = analysis_context.get("video_workspace", {})
+ top_videos = video_workspace.get("top_performing_videos", [])
+ latest_videos = video_workspace.get("latest_videos", [])
+ keywords = _dedupe_strings(
+ list(target_payload.get("keywords", []))
+ + list(target_payload.get("tags", []))
+ + list(target_payload.get("video_summary", {}).get("top_tags", []))
+ )
+ avg_top_score = round(
+ sum(float(item.get("score", {}).get("performance_score") or 0) for item in top_videos) / max(len(top_videos), 1),
+ 2
+ )
+ avg_latest_score = round(
+ sum(float(item.get("score", {}).get("performance_score") or 0) for item in latest_videos) / max(len(latest_videos), 1),
+ 2
+ )
+ monetization_score = round(
+ min(
+ 100.0,
+ avg_top_score * 0.55
+ + float(target_payload.get("video_summary", {}).get("avg_share") or 0) / 120.0
+ + float(target_payload.get("video_summary", {}).get("avg_comment") or 0) / 80.0
+ ),
+ 2
+ )
+
+ audience = "想提升短视频获客效率、内容转化和账号定位的创业者与内容运营者"
+ if {"教育", "教育规划"} & {item.lower() for item in keywords}:
+ audience = "关注教育规划、升学决策和信息差机会的人群"
+ core_promise = (
+ f"用 {_compact_text(target_payload.get('video_summary', {}).get('top_tags', ['内容方法'])[0], 10)} 相关主题,"
+ "快速给用户一个能立刻套用的内容方法或判断。"
+ )
+ hook_patterns = []
+ titles = [item.get("title", "") for item in top_videos[:5]]
+ if any(re.search(r"\d", title) for title in titles):
+ hook_patterns.append("数字型开头,直接降低理解成本")
+ if any(any(token in title for token in ("怎么", "如何", "为什么")) for title in titles):
+ hook_patterns.append("问题解决型开头,先抛问题再给答案")
+ if any(any(token in title for token in ("坑", "误区", "别", "不要")) for title in titles):
+ hook_patterns.append("避坑警示型开头,容易拉停留和讨论")
+ hook_patterns = _dedupe_strings(hook_patterns + ["强结论先行,适合 3 秒内抢注意力"])[:4]
+
+ winning_patterns = []
+ for video in top_videos[:4]:
+ winning_patterns.append({
+ "video_title": video.get("title", ""),
+ "score": video.get("score", {}).get("performance_score", 0),
+ "why": "高分原因主要来自 " + "、".join(video.get("score", {}).get("signals", [])[:2]),
+ "replication_angle": "保留原题材与开头结构,再改写为更具体的人群场景和结果承诺"
+ })
+
+ latest_signal = []
+ for video in latest_videos[:4]:
+ signal = "近期内容仍在有效窗口期"
+ if float(video.get("score", {}).get("performance_score") or 0) + 8 < avg_top_score:
+ signal = "最近作品热度弱于历史高分样本,需要回到已验证题材"
+ latest_signal.append({
+ "video_title": video.get("title", ""),
+ "signal": signal,
+ "action": "优先做同题材复刻、加强开头结论和结尾承接动作"
+ })
+
+ benchmark_insights = [
+ f"对标账号 {payload.get('nickname', '未命名账号')} 可借鉴其 {', '.join(payload.get('video_summary', {}).get('top_tags', [])[:3]) or '选题聚焦'},但不要直接照搬口吻。"
+ for payload in benchmark_payloads[:3]
+ ] or [
+ "当前可用对标账号较少,建议优先围绕高分作品题材扩充对标池。"
+ ]
+
+ operational_gaps = [
+ "高分内容和最近内容之间如果存在明显分差,说明选题复盘还没有形成固定机制",
+ "如果收藏和评论信号强,但页面承接动作弱,商业化效率会被浪费",
+ "账号标签较散时,用户对你卖什么、能解决什么问题的认知会不够集中"
+ ]
+ if avg_latest_score >= avg_top_score - 5:
+ operational_gaps[0] = "最近内容与高分内容差距不大,可以开始标准化选题库和周更节奏"
+
+ return {
+ "executive_summary": (
+ f"这个账号当前最值得放大的内容方向是 {', '.join(target_payload.get('video_summary', {}).get('top_tags', [])[:3]) or '已验证高分题材'}。"
+ f"高分作品平均得分 {avg_top_score},最近作品平均得分 {avg_latest_score},"
+ "已经具备做商业化内容矩阵和固定转化链路的基础。"
+ ),
+ "commercial_positioning": {
+ "audience": audience,
+ "core_promise": core_promise,
+ "monetization_readiness_score": monetization_score,
+ "offer_directions": _infer_offer_directions(keywords)
+ },
+ "content_engine": {
+ "pillars": target_payload.get("video_summary", {}).get("top_tags", [])[:6],
+ "hook_patterns": hook_patterns,
+ "structure_patterns": [
+ "开头先给结论或冲突点,中段拆 2-3 个关键动作,结尾给明确下一步",
+ "围绕用户熟悉的问题场景切入,降低完播门槛",
+ "把方法论做成可收藏的清单,提高后续转化机会"
+ ],
+ "cta_patterns": [
+ "高收藏内容结尾引导先收藏再执行",
+ "高评论内容结尾抛反问,引导评论区互动",
+ "高分享内容结尾补一句适合谁转发给谁,放大自然传播"
+ ]
+ },
+ "winning_patterns": winning_patterns,
+ "latest_signal": latest_signal,
+ "benchmark_insights": benchmark_insights,
+ "monetization_plan": [
+ "把高分题材拆成免费内容、低门槛产品和咨询服务三级承接",
+ "优先围绕高收藏内容制作模板、清单或训练营资料",
+ "让评论区和私信都指向同一个明确转化动作,避免流量浪费"
+ ],
+ "operational_gaps": operational_gaps,
+ "next_30_day_actions": [
+ "每周固定复刻 2 条高分题材,再测试 1 条新角度",
+ "给高分作品统一补评论区承接话术和私信关键词",
+ "每周复盘高分榜和最新榜,保留题材、更新场景和切口",
+ "把表现最稳的 3 条内容做成系列化栏目"
+ ],
+ "risk_watchlist": [
+ "题材过多会稀释账号定位,影响成交效率",
+ "如果只追求播放而不设计承接动作,商业化会停留在表层流量",
+ "复制高分标题但不复制结构和场景,容易出现数据回落"
+ ]
+ }
+
+ async def _run_top_video_analyses(
+ account_row: dict[str, Any],
+ owner: dict[str, Any],
+ profile: dict[str, Any],
+ *,
+ top_video_count: int = 6,
+ min_score: float = 45.0,
+ report_id: str = "",
+ source_type: str = "top_score_auto",
+ temperature: float = 0.25
+ ) -> list[dict[str, Any]]:
+ video_workspace = _build_video_workspace_payload(account_row, limit=max(top_video_count * 3, 24))
+ item_map = {item["id"]: item for item in video_workspace["items"]}
+ ranked_videos = [
+ item_map[video_id]
+ for video_id in video_workspace["top_scored_video_ids"]
+ if video_id in item_map and float(item_map[video_id]["score"]["performance_score"]) >= float(min_score)
+ ][: max(1, min(top_video_count, 12))]
+
+ if not ranked_videos:
+ return []
+
+ account_payload = _build_account_payload(account_row, include_recent_videos=8)
+ system_prompt = (
+ "你是商业化短视频拆解顾问。你要针对单条作品给出可用于商业化运营的复盘。"
+ "请返回严格 JSON 对象,字段必须包含:headline_summary、hook_breakdown、"
+ "structure_breakdown、commercial_angle、replication_plan、operator_actions、"
+ "risk_notes、scores。scores 里必须包含 hook、retention、conversion、commercial,范围 0-100。"
+ )
+
+ async def _analyze_video(video: dict[str, Any]) -> dict[str, Any]:
+ prompt_context = {
+ "account": {
+ "id": account_payload["id"],
+ "nickname": account_payload["nickname"],
+ "signature": account_payload["signature"],
+ "tags": account_payload["tags"][:12]
+ },
+ "video": {
+ "id": video["id"],
+ "aweme_id": video["aweme_id"],
+ "title": video["title"],
+ "description": video["description"],
+ "published_at": video["published_at"],
+ "tags": video["tags"],
+ "stats": video["stats"],
+ "score": video["score"]
+ }
+ }
+ user_prompt = (
+ "请从商业化运营视角拆解这条作品。重点回答:为什么这条作品值得关注、"
+ "适合承接什么产品/服务、下一步怎么复刻、运营动作怎么排。"
+ f"\n\n输入上下文:\n{json.dumps(prompt_context, ensure_ascii=False, indent=2)}"
+ )
+ try:
+ output = await legacy.call_model(
+ profile,
+ system_prompt=system_prompt,
+ user_prompt=user_prompt,
+ temperature=temperature
+ )
+ parsed = _try_parse_agent_json(output)
+ status = "ok"
+ except Exception as exc:
+ output = str(exc)
+ parsed = {}
+ status = "error"
+
+ score = video["score"]
+ if not isinstance(parsed, dict):
+ parsed = {}
+ parsed = _merge_structured_payload(_build_video_analysis_fallback(account_payload, video), parsed)
+ parsed_scores = parsed.get("scores", {}) if isinstance(parsed, dict) else {}
+ analysis_id = make_id("dyva")
+ summary_text = _first_non_empty(
+ (parsed.get("headline_summary") if isinstance(parsed, dict) else ""),
+ (parsed.get("summary") if isinstance(parsed, dict) else ""),
+ (parsed.get("commercial_angle", {}) or {}).get("judgement") if isinstance(parsed, dict) else "",
+ output
+ )
+ hook_score = _bounded_score(parsed_scores.get("hook"), fallback=score["performance_score"])
+ retention_score = _bounded_score(parsed_scores.get("retention"), fallback=score["performance_score"])
+ conversion_score = _bounded_score(parsed_scores.get("conversion"), fallback=score["commercial_score"])
+ commercial_score = _bounded_score(parsed_scores.get("commercial"), fallback=score["commercial_score"])
+ created_at = now()
+ legacy.db.execute(
+ """
+ INSERT INTO douyin_video_analyses (
+ id, account_id, user_id, video_id, report_id, model_profile_id, model_label,
+ source_type, status, performance_score, commercial_score, hook_score,
+ retention_score, conversion_score, summary_text, suggestion_text,
+ parsed_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ analysis_id,
+ account_row["id"],
+ owner["id"],
+ video["id"],
+ report_id,
+ profile["id"],
+ _build_model_label(profile),
+ source_type,
+ status,
+ score["performance_score"],
+ commercial_score,
+ hook_score,
+ retention_score,
+ conversion_score,
+ _compact_text(summary_text, 240),
+ output if output.strip() else _safe_json_dumps(parsed),
+ _safe_json_dumps(parsed),
+ created_at,
+ created_at
+ )
+ )
+ return {
+ "id": analysis_id,
+ "video_id": video["id"],
+ "video_title": video["title"],
+ "status": status,
+ "summary_text": _compact_text(summary_text, 240),
+ "parsed_json": parsed,
+ "performance_score": score["performance_score"],
+ "commercial_score": commercial_score,
+ "hook_score": hook_score,
+ "retention_score": retention_score,
+ "conversion_score": conversion_score,
+ "created_at": created_at
+ }
+
+ return await asyncio.gather(*[_analyze_video(video) for video in ranked_videos])
+
async def _run_account_analysis(
account_row: dict[str, Any],
owner: dict[str, Any],
request: DouyinAccountAnalysisRequest
) -> dict[str, Any]:
target_payload = _build_account_payload(account_row, include_recent_videos=max(4, min(request.max_videos, 12)))
+ video_workspace = _build_video_workspace_payload(account_row, limit=max(request.max_videos * 3, 24))
+ video_context = _build_video_context_items(
+ video_workspace,
+ max_top_items=min(max(request.max_videos, 4), 8),
+ max_latest_items=min(max(request.max_videos, 4), 8)
+ )
linked_rows = _list_linked_accounts(account_row)
linked_account_ids = list(request.linked_account_ids)
if request.include_linked_accounts:
@@ -1554,15 +2223,25 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
profiles = _resolve_model_profiles(owner, request.model_profile_ids)
system_prompt = (
- "你是资深抖音增长顾问。你会基于账号画像、创作者中心字段、作品表现和对标账号内容,"
- "给出可执行的优化建议。请始终返回 JSON 对象,包含这些字段:"
- "summary、strengths、weaknesses、benchmark_insights、content_plan、"
- "growth_actions、deep_search_hypotheses。每个数组字段请给出 3-6 条中文建议。"
+ "你是资深抖音商业化增长顾问。你会基于账号画像、创作者中心字段、作品表现、"
+ "高分作品样本、最近更新信号和对标账号内容,给出可直接指导运营和商业化的结论。"
+ "请始终返回严格 JSON 对象,包含这些字段:executive_summary、commercial_positioning、"
+ "content_engine、winning_patterns、latest_signal、benchmark_insights、"
+ "monetization_plan、operational_gaps、next_30_day_actions、risk_watchlist。"
+ "commercial_positioning 必须是对象,至少包含 audience、core_promise、monetization_readiness_score、"
+ "offer_directions。content_engine 必须包含 pillars、hook_patterns、structure_patterns、cta_patterns。"
+ "winning_patterns、latest_signal、benchmark_insights、offer_directions、next_30_day_actions、"
+ "risk_watchlist 每个字段请给 3-6 条中文建议。"
)
analysis_context = {
"target_account": target_payload,
"benchmark_accounts": benchmark_payloads[:6],
"focus": request.extra_focus,
+ "video_workspace": {
+ "high_score_threshold": video_workspace["high_score_threshold"],
+ "meta": video_workspace["meta"],
+ **video_context
+ },
"creator_center_snapshot_summary": _safe_json_loads(
(legacy.db.fetch_one(
"""
@@ -1578,7 +2257,9 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
)
}
user_prompt = (
- "请分析以下抖音账号,并分别给出内容方向、选题结构、互动增长、账号定位和对标拆解建议。"
+ "请从商业化运营视角分析以下抖音账号。除了账号定位和内容打法,"
+ "还要明确给出:什么内容最值得继续放大、什么内容已经过时、"
+ "适合承接什么类型的产品/服务、未来 30 天运营动作如何排优先级。"
"如果提供了对标账号,要重点指出可借鉴但不应直接照搬的部分。"
f"\n\n输入上下文:\n{json.dumps(analysis_context, ensure_ascii=False, indent=2)}"
)
@@ -1619,6 +2300,12 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
output = str(exc)
parsed = {}
status = "error"
+ if not isinstance(parsed, dict):
+ parsed = {}
+ parsed = _merge_structured_payload(
+ _build_account_analysis_fallback(target_payload, benchmark_payloads, analysis_context),
+ parsed
+ )
suggestion_id = make_id("dysady")
legacy.db.execute(
"""
@@ -1633,7 +2320,7 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
profile["id"],
_build_model_label(profile),
status,
- output,
+ output if output.strip() else _safe_json_dumps(parsed),
_safe_json_dumps(parsed),
now()
)
@@ -1648,6 +2335,18 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
}
suggestions = await asyncio.gather(*[_analyze_with_model(profile) for profile in profiles])
+ auto_video_analyses: list[dict[str, Any]] = []
+ if request.auto_analyze_top_videos and profiles:
+ auto_video_analyses = await _run_top_video_analyses(
+ account_row,
+ owner,
+ profiles[0],
+ top_video_count=request.top_video_analysis_count,
+ min_score=45.0,
+ report_id=report_id,
+ source_type="account_analysis_auto",
+ temperature=min(request.temperature, 0.3)
+ )
legacy.db.execute(
"UPDATE douyin_accounts SET last_analysis_at = ?, updated_at = ? WHERE id = ?",
(now(), now(), account_row["id"])
@@ -1656,7 +2355,8 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
"report_id": report_id,
"created_at": created_at,
"context": analysis_context,
- "suggestions": suggestions
+ "suggestions": suggestions,
+ "auto_video_analyses": auto_video_analyses
}
async def _prepare_similarity_source(
@@ -2010,6 +2710,63 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
account_row = _require_owned_account(account_id, account["id"])
return _build_workspace_payload(account_row)
+ @app.get("/v2/douyin/accounts/{account_id}/videos")
+ def list_douyin_account_videos(
+ account_id: str,
+ limit: int = 60,
+ sort_by: str = "score",
+ scope: str = "all",
+ q: str = "",
+ tag: str = "",
+ account: dict[str, Any] = Depends(legacy.require_approved)
+ ) -> dict[str, Any]:
+ account_row = _require_owned_account(account_id, account["id"])
+ workspace = _build_video_workspace_payload(account_row, limit=max(limit, 24))
+ items = list(workspace["items"])
+ item_map = {item["id"]: item for item in items}
+
+ normalized_scope = (scope or "all").strip().lower()
+ if normalized_scope == "top":
+ items = [item_map[video_id] for video_id in workspace["top_scored_video_ids"] if video_id in item_map]
+ elif normalized_scope == "latest":
+ items = [item_map[video_id] for video_id in workspace["latest_video_ids"] if video_id in item_map]
+
+ query_text = (q or "").strip().lower()
+ if query_text:
+ items = [
+ item for item in items
+ if query_text in " ".join(
+ [
+ item.get("title", ""),
+ item.get("description", ""),
+ item.get("aweme_id", ""),
+ *[str(tag_item) for tag_item in item.get("tags", [])]
+ ]
+ ).lower()
+ ]
+
+ tag_text = (tag or "").strip().lower()
+ if tag_text:
+ items = [
+ item for item in items
+ if any(tag_text in str(tag_item).lower() for tag_item in item.get("tags", []))
+ ]
+
+ normalized_sort = (sort_by or "score").strip().lower()
+ items.sort(key=lambda item: _video_sort_key(item, normalized_sort), reverse=True)
+ return {
+ "account_id": account_row["id"],
+ "sort_by": normalized_sort,
+ "scope": normalized_scope,
+ "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))]
+ }
+
@app.get("/v2/douyin/accounts/{account_id}/analysis-reports")
def list_douyin_analysis_reports(
account_id: str,
@@ -2027,6 +2784,30 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
account_row = _require_owned_account(account_id, account["id"])
return await _run_account_analysis(account_row, account, request)
+ @app.post("/v2/douyin/accounts/{account_id}/videos/analyze-top")
+ async def analyze_douyin_top_videos(
+ account_id: str,
+ request: DouyinTopVideoAnalysisRequest,
+ account: dict[str, Any] = Depends(legacy.require_approved)
+ ) -> dict[str, Any]:
+ account_row = _require_owned_account(account_id, account["id"])
+ profile = legacy.model_profile_for_account(account["id"], request.model_profile_id)
+ results = await _run_top_video_analyses(
+ account_row,
+ account,
+ profile,
+ top_video_count=request.top_video_count,
+ min_score=request.min_score,
+ source_type="manual_top_video_refresh",
+ temperature=request.temperature
+ )
+ return {
+ "account_id": account_row["id"],
+ "model_profile_id": profile["id"],
+ "analyzed_count": len(results),
+ "items": results
+ }
+
@app.post("/v2/douyin/similar-searches")
async def create_douyin_similarity_search(
request: DouyinSimilarSearchRequest,
diff --git a/docs/LAN_E2E_GUIDE_2026-03-18.md b/docs/LAN_E2E_GUIDE_2026-03-18.md
index 0b31fcb..4636204 100644
--- a/docs/LAN_E2E_GUIDE_2026-03-18.md
+++ b/docs/LAN_E2E_GUIDE_2026-03-18.md
@@ -177,6 +177,8 @@ http://127.0.0.1:3618
1. 填写抖音主页链接和 StoryForge 账号
2. 如需查看采集结果,不用离开这个页面;下半部分 `Douyin Workbench` 会展示账号列表、Agent 结论、快照详情和对标结果
+3. `作品工作台` 支持高分榜、最新榜和全部作品切换,并支持多种排序方式
+4. 点击“自动分析高分作品”后,每条高分作品下会补齐商业判断、复刻建议、运营动作和风险提醒
2. 点击 `开始采集`
3. 在弹出的 Chromium 里登录或通过挑战页
4. 回到控制台点击 `已完成登录,继续采集`
diff --git a/scripts/douyin-browser-capture/control_panel.mjs b/scripts/douyin-browser-capture/control_panel.mjs
index d1ed0ef..7fb9184 100644
--- a/scripts/douyin-browser-capture/control_panel.mjs
+++ b/scripts/douyin-browser-capture/control_panel.mjs
@@ -640,6 +640,97 @@ function renderPage() {
white-space: pre-wrap;
line-height: 1.6;
}
+ .analysis-grid,
+ .video-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .summary-callout {
+ border-radius: 16px;
+ padding: 14px;
+ background: linear-gradient(135deg, rgba(31, 110, 95, 0.1), rgba(185, 117, 36, 0.08));
+ border: 1px solid rgba(31, 110, 95, 0.12);
+ line-height: 1.65;
+ }
+ .bullet-list {
+ margin: 0;
+ padding-left: 18px;
+ display: grid;
+ gap: 8px;
+ color: var(--ink);
+ line-height: 1.6;
+ }
+ .analysis-block {
+ display: grid;
+ gap: 12px;
+ }
+ .video-card {
+ border: 1px solid rgba(22, 49, 61, 0.1);
+ border-radius: 18px;
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.85);
+ display: grid;
+ gap: 14px;
+ }
+ .video-layout {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 14px;
+ }
+ .cover-thumb {
+ width: 100%;
+ aspect-ratio: 3 / 4;
+ border-radius: 16px;
+ object-fit: cover;
+ background: rgba(22, 49, 61, 0.08);
+ border: 1px solid rgba(22, 49, 61, 0.1);
+ }
+ .toolbar-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ }
+ .score-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ .score-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(31, 110, 95, 0.08);
+ color: var(--ink);
+ font-size: 12px;
+ }
+ .score-badge strong {
+ color: var(--accent);
+ }
+ .link-row {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+ }
+ .link-row a {
+ color: var(--accent);
+ text-decoration: none;
+ font-weight: 600;
+ }
+ .link-row a:hover {
+ text-decoration: underline;
+ }
+ details {
+ border-top: 1px solid rgba(22, 49, 61, 0.1);
+ padding-top: 10px;
+ }
+ details summary {
+ cursor: pointer;
+ color: var(--muted);
+ }
.empty-state {
color: var(--muted);
font-size: 14px;
@@ -651,7 +742,7 @@ function renderPage() {
margin: 4px 0;
}
@media (max-width: 900px) {
- .grid, .row, .checks, .workbench-layout, .metric-grid, .two-col { grid-template-columns: 1fr; }
+ .grid, .row, .checks, .workbench-layout, .metric-grid, .two-col, .analysis-grid, .video-grid, .video-layout, .toolbar-grid { grid-template-columns: 1fr; }
}
@@ -816,6 +907,50 @@ function renderPage() {
+
+
+
+
作品工作台
+
这里会把高分作品和最新作品拆开看,并给高分作品自动补运营分析。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
快照与原始采集
@@ -877,6 +1012,13 @@ function renderPage() {
const analysisFocusEl = document.getElementById("analysis-focus");
const analysisModelSelectEl = document.getElementById("analysis-model-select");
const analysisMaxVideosEl = document.getElementById("analysis-max-videos");
+ const analyzeTopVideosButton = document.getElementById("analyze-top-videos-button");
+ const videosScopeEl = document.getElementById("videos-scope-select");
+ const videosSortEl = document.getElementById("videos-sort-select");
+ const videosTagFilterEl = document.getElementById("videos-tag-filter");
+ const videosQueryFilterEl = document.getElementById("videos-query-filter");
+ const videosSummaryEl = document.getElementById("videos-summary");
+ const videosListEl = document.getElementById("videos-list");
const snapshotSummaryEl = document.getElementById("snapshot-summary");
const snapshotListEl = document.getElementById("snapshot-list");
const snapshotDetailEl = document.getElementById("snapshot-detail");
@@ -894,6 +1036,11 @@ function renderPage() {
accounts: [],
selectedAccountId: "",
selectedWorkspace: null,
+ videoItems: [],
+ videoMeta: null,
+ topScoredVideoIds: [],
+ latestVideoIds: [],
+ highScoreThreshold: 60,
snapshots: [],
selectedSnapshotId: "",
selectedSnapshotDetail: null,
@@ -939,6 +1086,18 @@ function renderPage() {
return date.toLocaleString("zh-CN", { hour12: false });
}
+ function formatPercent(value) {
+ const num = Number(value || 0);
+ if (!Number.isFinite(num)) {
+ return "-";
+ }
+ let text = (num * 100).toFixed(2);
+ if (text.endsWith(".00")) {
+ text = text.slice(0, -3);
+ }
+ return text + "%";
+ }
+
function normalizeBackendUrl(value) {
let normalized = String(value || "").trim();
while (normalized.endsWith("/")) {
@@ -971,6 +1130,11 @@ function renderPage() {
workbenchState.accounts = [];
workbenchState.selectedAccountId = "";
workbenchState.selectedWorkspace = null;
+ workbenchState.videoItems = [];
+ workbenchState.videoMeta = null;
+ workbenchState.topScoredVideoIds = [];
+ workbenchState.latestVideoIds = [];
+ workbenchState.highScoreThreshold = 60;
workbenchState.snapshots = [];
workbenchState.selectedSnapshotId = "";
workbenchState.selectedSnapshotDetail = null;
@@ -1026,6 +1190,158 @@ function renderPage() {
return payload;
}
+ function renderBulletList(items, emptyText) {
+ const safeItems = safeArray(items).filter(Boolean);
+ if (!safeItems.length) {
+ return '' + escapeHtml(emptyText || "暂无内容。") + '
';
+ }
+ return '' + safeItems.map((item) => '- ' + escapeHtml(typeof item === "string" ? item : JSON.stringify(item)) + '
').join("") + '
';
+ }
+
+ function renderObjectBulletList(items, renderFn, emptyText) {
+ const safeItems = safeArray(items);
+ if (!safeItems.length) {
+ return '' + escapeHtml(emptyText || "暂无内容。") + '
';
+ }
+ return '' + safeItems.map(renderFn).join("") + '
';
+ }
+
+ function getSortedVideos() {
+ const scope = videosScopeEl.value || "all";
+ const sortBy = videosSortEl.value || "score";
+ const query = videosQueryFilterEl.value.trim().toLowerCase();
+ const tag = videosTagFilterEl.value.trim().toLowerCase();
+ const topSet = new Set(safeArray(workbenchState.topScoredVideoIds));
+ const latestSet = new Set(safeArray(workbenchState.latestVideoIds));
+ let items = safeArray(workbenchState.videoItems).filter((video) => {
+ if (scope === "top" && !topSet.has(video.id)) {
+ return false;
+ }
+ if (scope === "latest" && !latestSet.has(video.id)) {
+ return false;
+ }
+ if (query) {
+ const haystack = [video.title, video.description, video.aweme_id, ...safeArray(video.tags)].join(" ").toLowerCase();
+ if (!haystack.includes(query)) {
+ return false;
+ }
+ }
+ if (tag) {
+ const tags = safeArray(video.tags).map((item) => String(item).toLowerCase());
+ if (!tags.some((item) => item.includes(tag))) {
+ return false;
+ }
+ }
+ return true;
+ });
+ const getValue = (video) => {
+ 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);
+ if (sortBy === "like") return Number(video.stats?.like || 0);
+ if (sortBy === "share") return Number(video.stats?.share || 0);
+ if (sortBy === "comment") return Number(video.stats?.comment || 0);
+ return Number(video.score?.performance_score || 0);
+ };
+ items.sort((left, right) => getValue(right) - getValue(left));
+ return items;
+ }
+
+ function renderAccountSuggestion(suggestion) {
+ const parsed = suggestion.parsed_json || {};
+ if (!parsed.executive_summary) {
+ return [
+ '',
+ '
',
+ '' + escapeHtml(suggestion.model_label || "模型") + '',
+ '' + escapeHtml(suggestion.status || "-") + '',
+ '
',
+ '
' + escapeHtmlWithBreaks(suggestion.suggestion_text || "暂无结论") + '
',
+ '
'
+ ].join("");
+ }
+ const positioning = parsed.commercial_positioning || {};
+ const engine = parsed.content_engine || {};
+ return [
+ '',
+ '
',
+ '' + escapeHtml(suggestion.model_label || "模型") + '',
+ '' + escapeHtml(suggestion.status || "-") + '',
+ '
',
+ '
' + escapeHtml(parsed.executive_summary || "暂无总结") + '
',
+ '
',
+ '
商业定位
受众:' + escapeHtml(positioning.audience || "-") + '
核心承诺:' + escapeHtml(positioning.core_promise || "-") + '
商业化准备度:' + escapeHtml(formatNumber(positioning.monetization_readiness_score)) + '
' + renderBulletList(positioning.offer_directions, "暂无产品方向建议") + '
',
+ '
内容引擎
内容支柱
' + renderBulletList(engine.pillars, "暂无内容支柱") + '
开头模式
' + renderBulletList(engine.hook_patterns, "暂无开头模式") + '
',
+ '
',
+ '
',
+ '
结构与 CTA
结构模式
' + renderBulletList(engine.structure_patterns, "暂无结构结论") + '
CTA 模式
' + renderBulletList(engine.cta_patterns, "暂无 CTA 建议") + '
',
+ '
商业化与运营动作
承接路径
' + renderBulletList(parsed.monetization_plan, "暂无商业化承接建议") + '
30 天动作
' + renderBulletList(parsed.next_30_day_actions, "暂无 30 天动作建议") + '
',
+ '
',
+ '
',
+ '
高分作品规律
' + renderObjectBulletList(parsed.winning_patterns, (item) => '
' + escapeHtml(item.video_title || "高分作品") + '' + escapeHtml(item.why || "-") + '
复刻建议:' + escapeHtml(item.replication_angle || "-") + '
', "暂无高分作品分析") + '
',
+ '
最近内容信号
' + renderObjectBulletList(parsed.latest_signal, (item) => '
' + escapeHtml(item.video_title || "最近作品") + '' + escapeHtml(item.signal || "-") + '
动作:' + escapeHtml(item.action || "-") + '
', "暂无最新作品信号") + '
',
+ '
',
+ '
',
+ '
对标与风险
对标洞察
' + renderBulletList(parsed.benchmark_insights, "暂无对标洞察") + '
风险观察
' + renderBulletList(parsed.risk_watchlist, "暂无风险提示") + '
',
+ '
当前缺口
' + renderBulletList(parsed.operational_gaps, "暂无明显缺口") + '',
+ '
',
+ suggestion.suggestion_text ? '
查看模型原始输出
' + escapeHtmlWithBreaks(suggestion.suggestion_text) + '
' : '',
+ '
'
+ ].join("");
+ }
+
+ function renderVideoAnalysisCard(video) {
+ const analysis = video.latest_analysis || {};
+ const parsed = analysis.parsed_json || {};
+ const score = video.score || {};
+ const stats = video.stats || {};
+ return [
+ '',
+ '',
+ video.cover_url ? '
 + ')
' : '
',
+ '
',
+ '
',
+ '
' + escapeHtml(video.title || video.aweme_id || "未命名作品") + '发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '
',
+ '
' + escapeHtml(video.aweme_id || "-") + '',
+ '
',
+ '
',
+ '综合' + escapeHtml(formatNumber(score.performance_score)) + '',
+ '商业' + escapeHtml(formatNumber(score.commercial_score)) + '',
+ '播放' + escapeHtml(formatNumber(stats.play)) + '',
+ '点赞' + escapeHtml(formatNumber(stats.like)) + '',
+ '分享' + escapeHtml(formatNumber(stats.share)) + '',
+ '收藏率' + escapeHtml(formatPercent(score.collect_rate)) + '',
+ '
',
+ safeArray(video.tags).length ? '
' + safeArray(video.tags).map((tag) => '' + escapeHtml(tag) + '').join("") + '
' : '',
+ '
互动率:' + escapeHtml(formatPercent(score.engagement_rate)) + ',评论率:' + escapeHtml(formatPercent(score.comment_rate)) + ',发布时间距今:' + escapeHtml(score.age_days == null ? "-" : score.age_days + " 天") + '
',
+ '
' + safeArray(score.signals).map((item) => '' + escapeHtml(item) + '').join("") + '
',
+ '
' + (video.share_url ? '
打开作品' : '') + '
',
+ '
',
+ '
',
+ parsed.headline_summary ? '' + escapeHtml(parsed.headline_summary || analysis.summary_text || "暂无分析结论") + '
为什么值得做
商业判断:' + escapeHtml((parsed.commercial_angle || {}).judgement || "-") + '
可承接方向
' + renderBulletList((parsed.commercial_angle || {}).suitable_for, "暂无承接方向") + '
分项评分
' + renderBulletList(['钩子 ' + formatNumber((parsed.scores || {}).hook), '留存 ' + formatNumber((parsed.scores || {}).retention), '转化 ' + formatNumber((parsed.scores || {}).conversion), '商业 ' + formatNumber((parsed.scores || {}).commercial)], "暂无分项评分") + '
复刻与运营动作
复刻计划
' + renderBulletList(parsed.replication_plan, "暂无复刻计划") + '
运营动作
' + renderBulletList(parsed.operator_actions, "暂无运营动作") + '
钩子与结构
钩子拆解
' + renderBulletList(parsed.hook_breakdown, "暂无钩子拆解") + '
结构拆解
' + renderBulletList(parsed.structure_breakdown, "暂无结构拆解") + '
风险提醒
' + renderBulletList(parsed.risk_notes, "暂无风险提醒") + ' ' : '这条作品还没有自动分析。点击上面的“自动分析高分作品”即可补齐。
',
+ ''
+ ].join("");
+ }
+
+ function renderVideos() {
+ const items = getSortedVideos();
+ const meta = workbenchState.videoMeta || {};
+ videosSummaryEl.innerHTML = [
+ '',
+ '
作品总数
' + escapeHtml(formatNumber(meta.total_count)) + '
',
+ '
已分析作品
' + escapeHtml(formatNumber(meta.analyzed_count)) + '
',
+ '
高分作品数
' + escapeHtml(formatNumber(meta.high_score_count)) + '
',
+ '
当前显示
' + escapeHtml(formatNumber(items.length)) + '
',
+ '
',
+ '高分阈值:' + escapeHtml(formatNumber(workbenchState.highScoreThreshold)) + '。高分榜更适合找商业化样板,最新榜更适合看近期题材窗口。
'
+ ].join("");
+ if (!items.length) {
+ videosListEl.innerHTML = '当前筛选条件下没有作品。
';
+ return;
+ }
+ videosListEl.innerHTML = '' + items.map(renderVideoAnalysisCard).join("") + '
';
+ }
+
function renderWorkbenchSession() {
const session = workbenchState.session;
if (!session) {
@@ -1132,6 +1448,8 @@ function renderPage() {
snapshotListEl.innerHTML = "";
linkedAccountsEl.innerHTML = "";
similarSearchListEl.innerHTML = "";
+ videosSummaryEl.innerHTML = "";
+ videosListEl.innerHTML = "";
renderSnapshotDetail();
renderSimilarSearchDetail();
return;
@@ -1187,7 +1505,7 @@ function renderPage() {
'' + escapeHtml(report.focus_text || "默认分析") + '',
'' + escapeHtml(formatDateTime(report.created_at)) + '',
'',
- safeArray(report.suggestions).length ? safeArray(report.suggestions).map((suggestion) => '' + escapeHtml(suggestion.model_label || "模型") + ' / ' + escapeHtml(suggestion.status || "-") + '
' + escapeHtml(suggestion.suggestion_text || "暂无结论") + '
').join("") : '这份报告还没有 suggestion。
',
+ safeArray(report.suggestions).length ? safeArray(report.suggestions).map(renderAccountSuggestion).join("") : '这份报告还没有 suggestion。
',
''
].join("");
}).join("") : '这个账号还没有分析报告。你可以直接点上面的“运行分析”。
';
@@ -1214,6 +1532,7 @@ function renderPage() {
return '';
}).join("") : '这个账号还没有相似搜索记录。
';
renderSimilarSearchDetail();
+ renderVideos();
}
async function loadSnapshotDetail(snapshotId) {
@@ -1274,10 +1593,16 @@ function renderPage() {
try {
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) + "/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 }))
]);
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
+ workbenchState.videoItems = safeArray(results[2]?.items);
+ workbenchState.videoMeta = results[2]?.meta || {};
+ workbenchState.topScoredVideoIds = safeArray(results[2]?.top_scored_video_ids);
+ workbenchState.latestVideoIds = safeArray(results[2]?.latest_video_ids);
+ workbenchState.highScoreThreshold = Number(results[2]?.high_score_threshold || 60);
workbenchState.selectedSnapshotId = options.snapshotId || workbenchState.snapshots[0]?.id || "";
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
@@ -1545,7 +1870,38 @@ function renderPage() {
}
});
+ analyzeTopVideosButton.addEventListener("click", async () => {
+ if (!workbenchState.selectedAccountId) {
+ alert("请先选择一个账号。");
+ return;
+ }
+ analyzeTopVideosButton.disabled = true;
+ analysisFeedbackEl.textContent = "正在自动分析高分作品...";
+ try {
+ await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/videos/analyze-top", {
+ method: "POST",
+ body: {
+ model_profile_id: analysisModelSelectEl.value || null,
+ top_video_count: Math.max(2, Math.min(8, Number.parseInt(analysisMaxVideosEl.value || "6", 10) || 6)),
+ min_score: Number(workbenchState.highScoreThreshold || 45),
+ temperature: 0.2
+ }
+ });
+ workbenchState.lastAnalysisMessage = "高分作品自动分析已更新。";
+ analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
+ await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
+ } catch (error) {
+ analysisFeedbackEl.textContent = "高分作品自动分析失败: " + error.message;
+ } finally {
+ analyzeTopVideosButton.disabled = false;
+ }
+ });
+
accountsFilterEl.addEventListener("input", renderAccountList);
+ videosScopeEl.addEventListener("change", renderVideos);
+ videosSortEl.addEventListener("change", renderVideos);
+ videosTagFilterEl.addEventListener("input", renderVideos);
+ videosQueryFilterEl.addEventListener("input", renderVideos);
document.addEventListener("click", async (event) => {
const accountButton = event.target.closest("[data-account-id]");