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 ''; + } + + 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) => '', "暂无高分作品分析") + '
', + '

最近内容信号

' + renderObjectBulletList(parsed.latest_signal, (item) => '', "暂无最新作品信号") + '
', + '
', + '
', + '

对标与风险

对标洞察
' + 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 ? 'cover' : '
', + '
', + '
', + '
' + 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("") + '
', + '', + '
', + '
', + 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]");