feat: deepen douyin commercial workbench
This commit is contained in:
@@ -37,7 +37,9 @@ http://127.0.0.1:3618
|
||||
这个本地页面现在包含两部分:
|
||||
|
||||
- 上半部分是浏览器辅助采集控制台
|
||||
- 下半部分是 `Douyin Workbench`,可直接查看账号列表、Agent 分析结论、快照详情、相似账号和对标关系
|
||||
- 下半部分是 `Douyin Workbench`,可直接查看账号列表、商业化账号分析、快照详情、相似账号和对标关系
|
||||
- 作品工作台支持按 `高分作品 / 最新作品 / 全部作品` 切换,并可按综合分、商业价值、发布时间、播放、点赞、分享、评论排序
|
||||
- 高分作品支持自动化分析,每条作品卡片下都会展示商业判断、复刻计划、运营动作和风险提醒
|
||||
|
||||
或者继续用命令行:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -177,6 +177,8 @@ http://127.0.0.1:3618
|
||||
|
||||
1. 填写抖音主页链接和 StoryForge 账号
|
||||
2. 如需查看采集结果,不用离开这个页面;下半部分 `Douyin Workbench` 会展示账号列表、Agent 结论、快照详情和对标结果
|
||||
3. `作品工作台` 支持高分榜、最新榜和全部作品切换,并支持多种排序方式
|
||||
4. 点击“自动分析高分作品”后,每条高分作品下会补齐商业判断、复刻建议、运营动作和风险提醒
|
||||
2. 点击 `开始采集`
|
||||
3. 在弹出的 Chromium 里登录或通过挑战页
|
||||
4. 回到控制台点击 `已完成登录,继续采集`
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -816,6 +907,50 @@ function renderPage() {
|
||||
<div id="analysis-reports" class="stack"></div>
|
||||
</section>
|
||||
|
||||
<section class="subpanel stack">
|
||||
<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>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button class="secondary" id="analyze-top-videos-button" type="button">自动分析高分作品</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-grid">
|
||||
<label>
|
||||
作品列表
|
||||
<select id="videos-scope-select">
|
||||
<option value="all">全部作品</option>
|
||||
<option value="top">高分作品</option>
|
||||
<option value="latest">最新作品</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
排序方式
|
||||
<select id="videos-sort-select">
|
||||
<option value="score">综合高分</option>
|
||||
<option value="commercial">商业价值</option>
|
||||
<option value="latest">最新发布时间</option>
|
||||
<option value="play">播放量</option>
|
||||
<option value="like">点赞量</option>
|
||||
<option value="share">分享量</option>
|
||||
<option value="comment">评论量</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
标签筛选
|
||||
<input id="videos-tag-filter" placeholder="例如:创业 / 文案" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
关键词搜索
|
||||
<input id="videos-query-filter" placeholder="按标题或描述搜索" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<div id="videos-summary"></div>
|
||||
<div id="videos-list" class="stack"></div>
|
||||
</section>
|
||||
|
||||
<section class="subpanel stack">
|
||||
<h3>快照与原始采集</h3>
|
||||
<div id="snapshot-summary"></div>
|
||||
@@ -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 '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
|
||||
}
|
||||
return '<ul class="bullet-list">' + safeItems.map((item) => '<li>' + escapeHtml(typeof item === "string" ? item : JSON.stringify(item)) + '</li>').join("") + '</ul>';
|
||||
}
|
||||
|
||||
function renderObjectBulletList(items, renderFn, emptyText) {
|
||||
const safeItems = safeArray(items);
|
||||
if (!safeItems.length) {
|
||||
return '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
|
||||
}
|
||||
return '<div class="stack">' + safeItems.map(renderFn).join("") + '</div>';
|
||||
}
|
||||
|
||||
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 [
|
||||
'<div class="report-item">',
|
||||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||||
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
|
||||
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
|
||||
'</div>',
|
||||
'<div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text || "暂无结论") + '</div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
const positioning = parsed.commercial_positioning || {};
|
||||
const engine = parsed.content_engine || {};
|
||||
return [
|
||||
'<div class="report-item">',
|
||||
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
|
||||
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
|
||||
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
|
||||
'</div>',
|
||||
'<div class="summary-callout" style="margin-top:12px;">' + escapeHtml(parsed.executive_summary || "暂无总结") + '</div>',
|
||||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业定位</h4><div class="meta">受众:' + escapeHtml(positioning.audience || "-") + '</div><div class="meta" style="margin-top:8px;">核心承诺:' + escapeHtml(positioning.core_promise || "-") + '</div><div class="meta" style="margin-top:8px;">商业化准备度:' + escapeHtml(formatNumber(positioning.monetization_readiness_score)) + '</div><div style="margin-top:12px;">' + renderBulletList(positioning.offer_directions, "暂无产品方向建议") + '</div></div>',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">内容引擎</h4><div class="meta">内容支柱</div>' + renderBulletList(engine.pillars, "暂无内容支柱") + '<div class="meta" style="margin-top:10px;">开头模式</div>' + renderBulletList(engine.hook_patterns, "暂无开头模式") + '</div>',
|
||||
'</div>',
|
||||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">结构与 CTA</h4><div class="meta">结构模式</div>' + renderBulletList(engine.structure_patterns, "暂无结构结论") + '<div class="meta" style="margin-top:10px;">CTA 模式</div>' + renderBulletList(engine.cta_patterns, "暂无 CTA 建议") + '</div>',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业化与运营动作</h4><div class="meta">承接路径</div>' + renderBulletList(parsed.monetization_plan, "暂无商业化承接建议") + '<div class="meta" style="margin-top:10px;">30 天动作</div>' + renderBulletList(parsed.next_30_day_actions, "暂无 30 天动作建议") + '</div>',
|
||||
'</div>',
|
||||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">高分作品规律</h4>' + renderObjectBulletList(parsed.winning_patterns, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "高分作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.why || "-") + '</div><div class="meta" style="margin-top:6px;">复刻建议:' + escapeHtml(item.replication_angle || "-") + '</div></div>', "暂无高分作品分析") + '</div>',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">最近内容信号</h4>' + renderObjectBulletList(parsed.latest_signal, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "最近作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.signal || "-") + '</div><div class="meta" style="margin-top:6px;">动作:' + escapeHtml(item.action || "-") + '</div></div>', "暂无最新作品信号") + '</div>',
|
||||
'</div>',
|
||||
'<div class="analysis-grid" style="margin-top:12px;">',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">对标与风险</h4><div class="meta">对标洞察</div>' + renderBulletList(parsed.benchmark_insights, "暂无对标洞察") + '<div class="meta" style="margin-top:10px;">风险观察</div>' + renderBulletList(parsed.risk_watchlist, "暂无风险提示") + '</div>',
|
||||
'<div class="detail-box"><h4 style="margin:0 0 10px;">当前缺口</h4>' + renderBulletList(parsed.operational_gaps, "暂无明显缺口") + '</div>',
|
||||
'</div>',
|
||||
suggestion.suggestion_text ? '<details style="margin-top:12px;"><summary>查看模型原始输出</summary><div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text) + '</div></details>' : '',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderVideoAnalysisCard(video) {
|
||||
const analysis = video.latest_analysis || {};
|
||||
const parsed = analysis.parsed_json || {};
|
||||
const score = video.score || {};
|
||||
const stats = video.stats || {};
|
||||
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>',
|
||||
'<span class="pill">' + escapeHtml(video.aweme_id || "-") + '</span>',
|
||||
'</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.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.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="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>',
|
||||
'</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>',
|
||||
'</article>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderVideos() {
|
||||
const items = getSortedVideos();
|
||||
const meta = workbenchState.videoMeta || {};
|
||||
videosSummaryEl.innerHTML = [
|
||||
'<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(items.length)) + '</div></div>',
|
||||
'</div>',
|
||||
'<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>';
|
||||
}
|
||||
|
||||
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() {
|
||||
'<strong>' + escapeHtml(report.focus_text || "默认分析") + '</strong>',
|
||||
'<span class="pill">' + escapeHtml(formatDateTime(report.created_at)) + '</span>',
|
||||
'</div>',
|
||||
safeArray(report.suggestions).length ? safeArray(report.suggestions).map((suggestion) => '<div class="report-suggestion"><div class="meta">' + escapeHtml(suggestion.model_label || "模型") + ' / ' + escapeHtml(suggestion.status || "-") + '</div><div style="margin-top:8px;">' + escapeHtml(suggestion.suggestion_text || "暂无结论") + '</div></div>').join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
|
||||
safeArray(report.suggestions).length ? safeArray(report.suggestions).map(renderAccountSuggestion).join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}).join("") : '<p class="empty-state">这个账号还没有分析报告。你可以直接点上面的“运行分析”。</p>';
|
||||
@@ -1214,6 +1532,7 @@ function renderPage() {
|
||||
return '<button type="button" class="similar-item ' + (selected ? "active" : "") + '" data-search-id="' + escapeHtml(search.id) + '"><strong>' + escapeHtml(safeArray(search.keywords).slice(0, 4).join(" / ") || search.id) + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(formatDateTime(search.created_at)) + '</div></button>';
|
||||
}).join("") : '<p class="empty-state">这个账号还没有相似搜索记录。</p>';
|
||||
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]");
|
||||
|
||||
Reference in New Issue
Block a user