diff --git a/collector-service/app/douyin_features.py b/collector-service/app/douyin_features.py index 1875c2f..393cf81 100644 --- a/collector-service/app/douyin_features.py +++ b/collector-service/app/douyin_features.py @@ -5,7 +5,7 @@ import json import math import re from collections import Counter -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from html import unescape from typing import Any, Iterable from urllib.parse import quote, unquote @@ -84,6 +84,16 @@ class DouyinBenchmarkLinkRequest(BaseModel): search_id: str = "" +class DouyinTrackedAccountRequest(BaseModel): + tracked_account_id: str = "" + assistant_id: str = "" + note: str = "" + + +class DouyinTrackingCursorRequest(BaseModel): + last_seen_at: str = "" + + def _safe_json_dumps(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")) @@ -1033,6 +1043,30 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: CREATE INDEX IF NOT EXISTS idx_douyin_account_relations_source ON douyin_account_relations(source_account_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS douyin_tracked_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + tracked_account_id TEXT NOT NULL, + assistant_id TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, tracked_account_id), + FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY(tracked_account_id) REFERENCES douyin_accounts(id) ON DELETE CASCADE, + FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_douyin_tracked_accounts_user_updated + ON douyin_tracked_accounts(user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS douyin_tracking_cursors ( + user_id TEXT PRIMARY KEY, + last_seen_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE + ); """ with legacy.db.session() as conn: conn.executescript(schema) @@ -1691,6 +1725,166 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: }) return payloads + def _load_owned_assistant(assistant_id: str, user_id: str) -> dict[str, Any] | None: + if not str(assistant_id or "").strip(): + return None + row = legacy.db.fetch_one( + "SELECT * FROM assistants WHERE id = ? AND user_id = ?", + (assistant_id, user_id) + ) + if not row: + raise HTTPException(status_code=404, detail="Assistant not found") + return row + + def _get_tracking_cursor(user_id: str) -> dict[str, Any] | None: + return legacy.db.fetch_one( + "SELECT * FROM douyin_tracking_cursors WHERE user_id = ?", + (user_id,) + ) + + def _set_tracking_cursor(user_id: str, last_seen_at: str) -> dict[str, Any]: + existing = _get_tracking_cursor(user_id) + timestamp = _first_non_empty(last_seen_at, now()) + updated_at = now() + if existing: + legacy.db.execute( + "UPDATE douyin_tracking_cursors SET last_seen_at = ?, updated_at = ? WHERE user_id = ?", + (timestamp, updated_at, user_id) + ) + else: + legacy.db.execute( + "INSERT INTO douyin_tracking_cursors (user_id, last_seen_at, updated_at) VALUES (?, ?, ?)", + (user_id, timestamp, updated_at) + ) + return legacy.db.fetch_one("SELECT * FROM douyin_tracking_cursors WHERE user_id = ?", (user_id,)) + + def _list_tracked_accounts(user_id: str) -> list[dict[str, Any]]: + rows = legacy.db.fetch_all( + """ + SELECT track.*, + assistant.name AS assistant_name + FROM douyin_tracked_accounts track + LEFT JOIN assistants assistant ON assistant.id = track.assistant_id + WHERE track.user_id = ? + ORDER BY track.updated_at DESC + """, + (user_id,) + ) + payloads: list[dict[str, Any]] = [] + for row in rows: + account_row = _require_owned_account(row["tracked_account_id"], user_id) + account_payload = _build_account_payload(account_row, include_recent_videos=6) + payloads.append({ + "id": row["id"], + "tracked_account_id": row["tracked_account_id"], + "assistant_id": row.get("assistant_id", "") or "", + "assistant_name": row.get("assistant_name", "") or "", + "note": row.get("note", "") or "", + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "account": account_payload + }) + return payloads + + def _extract_tracking_borrowing_points(video: dict[str, Any]) -> list[str]: + latest_analysis = (video.get("latest_analysis") or {}).get("parsed_json") or {} + candidates: list[str] = [] + + def _collect(value: Any) -> None: + if isinstance(value, list): + for item in value: + if isinstance(item, str) and item.strip(): + candidates.append(item.strip()) + elif isinstance(item, dict): + for inner in item.values(): + if isinstance(inner, str) and inner.strip(): + candidates.append(inner.strip()) + elif isinstance(value, str) and value.strip(): + candidates.append(value.strip()) + + for key in ("winning_patterns", "replicate_plan", "hook_patterns", "content_engine", "offer_directions", "next_actions"): + _collect(latest_analysis.get(key)) + + score = video.get("score", {}) or {} + stats = video.get("stats", {}) or {} + if float(score.get("hook_score") or 0) >= 70: + candidates.append("开头抓人,适合借前三秒强结论或反常识开场。") + if float(score.get("commercial_score") or 0) >= 65: + candidates.append("转化信号较强,可拆成交句式和行动指令。") + if float(score.get("performance_score") or 0) >= 70: + candidates.append("整体表现高,值得提炼成可复用栏目模板。") + if float(stats.get("comment") or 0) >= 100: + candidates.append("评论互动明显,适合提炼争议点或提问句。") + if str(video.get("content_type") or "") == "image_text": + candidates.append("图文结构清晰,可借分段标题和卡片式表达。") + + deduped: list[str] = [] + seen: set[str] = set() + for item in candidates: + normalized = _compact_text(item, 80) + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped.append(normalized) + return deduped[:4] + + def _build_tracking_digest_item(tracked_item: dict[str, Any], video: dict[str, Any]) -> dict[str, Any]: + latest_analysis = video.get("latest_analysis") or {} + summary = ( + (latest_analysis.get("parsed_json") or {}).get("executive_summary") + or latest_analysis.get("summary_text") + or latest_analysis.get("suggestion_text") + or video.get("description") + or video.get("title") + or "暂无摘要" + ) + borrowing_points = _extract_tracking_borrowing_points(video) + return { + "tracking_id": tracked_item["id"], + "tracked_account_id": tracked_item["tracked_account_id"], + "assistant_id": tracked_item["assistant_id"], + "assistant_name": tracked_item["assistant_name"], + "account": tracked_item["account"], + "video": video, + "summary": _compact_text(summary, 160), + "borrowing_points": borrowing_points, + "is_high_value": float((video.get("score") or {}).get("performance_score") or 0) >= 70 or bool(borrowing_points), + } + + def _build_tracking_digest(user_id: str, since_value: str = "", limit: int = 24) -> dict[str, Any]: + tracked_accounts = _list_tracked_accounts(user_id) + cursor = _get_tracking_cursor(user_id) + since_dt = _parse_iso_datetime(since_value) if since_value else None + if since_dt is None and cursor: + since_dt = _parse_iso_datetime(cursor.get("last_seen_at")) + if since_dt is None: + since_dt = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0) + + items: list[dict[str, Any]] = [] + for tracked in tracked_accounts: + account_row = _require_owned_account(tracked["tracked_account_id"], user_id) + workspace = _build_video_workspace_payload(account_row, limit=36) + for video in workspace.get("items", []): + published_at = _parse_iso_datetime(video.get("published_at")) + if published_at is None or published_at <= since_dt: + continue + items.append(_build_tracking_digest_item(tracked, video)) + + items.sort( + key=lambda item: ( + _parse_iso_datetime(item["video"].get("published_at")) or datetime.fromtimestamp(0, tz=timezone.utc), + float((item["video"].get("score") or {}).get("performance_score") or 0) + ), + reverse=True + ) + return { + "generated_at": now(), + "since": since_dt.isoformat(), + "cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""), + "tracked_accounts": tracked_accounts, + "items": items[: max(1, min(limit, 100))] + } + def _normalize_report_text(value: Any) -> str: text = str(value or "").strip() if not text: @@ -3133,3 +3327,69 @@ def register_douyin_routes(app: Any, legacy: Any) -> None: "relation_ids": linked_ids, "links": _list_linked_accounts(account_row) } + + @app.get("/v2/douyin/tracking/accounts") + def list_douyin_tracked_accounts( + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + cursor = _get_tracking_cursor(account["id"]) + return { + "cursor_last_seen_at": (cursor or {}).get("last_seen_at", ""), + "items": _list_tracked_accounts(account["id"]) + } + + @app.post("/v2/douyin/tracking/accounts") + def create_douyin_tracked_account( + request: DouyinTrackedAccountRequest, + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + tracked_account = _require_owned_account(request.tracked_account_id, account["id"]) + assistant = _load_owned_assistant(request.assistant_id, account["id"]) + existing = legacy.db.fetch_one( + "SELECT * FROM douyin_tracked_accounts WHERE user_id = ? AND tracked_account_id = ?", + (account["id"], tracked_account["id"]) + ) + updated_at = now() + if existing: + legacy.db.execute( + """ + UPDATE douyin_tracked_accounts + SET assistant_id = ?, note = ?, updated_at = ? + WHERE id = ? + """, + ((assistant or {}).get("id"), request.note.strip(), updated_at, existing["id"]) + ) + else: + legacy.db.execute( + """ + INSERT INTO douyin_tracked_accounts ( + id, user_id, tracked_account_id, assistant_id, note, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (make_id("dytrack"), account["id"], tracked_account["id"], (assistant or {}).get("id"), request.note.strip(), updated_at, updated_at) + ) + return { + "tracked_account_id": tracked_account["id"], + "assistant_id": (assistant or {}).get("id", ""), + "items": _list_tracked_accounts(account["id"]) + } + + @app.post("/v2/douyin/tracking/cursor") + def update_douyin_tracking_cursor( + request: DouyinTrackingCursorRequest, + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + cursor = _set_tracking_cursor(account["id"], request.last_seen_at) + return { + "user_id": account["id"], + "last_seen_at": cursor["last_seen_at"], + "updated_at": cursor["updated_at"] + } + + @app.get("/v2/douyin/tracking/digest") + def get_douyin_tracking_digest( + since: str | None = None, + limit: int = 24, + account: dict[str, Any] = Depends(legacy.require_approved) + ) -> dict[str, Any]: + return _build_tracking_digest(account["id"], since_value=(since or "").strip(), limit=limit) diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 0a51f0a..db456df 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -33,6 +33,8 @@ - 抖音对标账号 `/v2/douyin/accounts` - 单账号工作台 `/v2/douyin/accounts/{id}/workspace` - 单账号作品列表 `/v2/douyin/accounts/{id}/videos` +- 跟踪账号 `/v2/douyin/tracking/accounts` +- 跟踪日报 `/v2/douyin/tracking/digest` - 最近知识库文档 `/v2/knowledge-bases/{id}/documents` ## 当前已接入的真实动作 @@ -48,6 +50,8 @@ - 批量分析高分作品 - 查找相似对标账号 - 从相似候选一键保存对标关系 +- 把当前对标账号加入跟踪,并绑定 Agent +- 按上次打开后生成跟踪日报与借鉴点摘要 - 查看任务详情、事件、子任务和 artifacts/result - 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成 - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 @@ -76,6 +80,7 @@ python3 -m http.server 3918 - 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产 - 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整 +- 把跟踪日报从 Douyin 扩到多平台统一模型 - 把全局搜索和页内搜索合并成统一搜索体验 - 为 `生产中心 / 发布与复盘` 接入更完整的任务与成片对象 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index a35d3dc..4ea6ed0 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -20,6 +20,8 @@ const appState = { selectedProjectId: "", selectedAssistantId: "", lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()), + trackingAccounts: [], + trackingDigest: null, busy: false, message: "", lastAction: null, @@ -118,9 +120,15 @@ function persistSession(session) { } } +function setLastSeenAt(value) { + const date = value instanceof Date ? value : new Date(value); + const time = Number.isFinite(date.getTime()) ? date.getTime() : Date.now(); + appState.lastSeenAt = time; + localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(time)); +} + function markSeenNow() { - appState.lastSeenAt = Date.now(); - localStorage.setItem(STORAGE_KEY + ":lastSeenAt", String(appState.lastSeenAt)); + setLastSeenAt(Date.now()); } function setBusy(next, message = "") { @@ -477,6 +485,8 @@ async function logoutSession() { appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; appState.documents = []; + appState.trackingAccounts = []; + appState.trackingDigest = null; appState.lastAction = null; appState.lastGeneratedCopy = null; appState.lastSimilaritySearch = null; @@ -512,6 +522,12 @@ async function loadDouyinAccount(accountId) { appState.selectedVideos = videos; } +function getTrackingSinceIso() { + const date = new Date(appState.lastSeenAt || Date.now()); + if (Number.isNaN(date.getTime())) return new Date(Date.now() - 86400000).toISOString(); + return date.toISOString(); +} + async function bootstrap() { renderAll(); if (!appState.session) { @@ -529,14 +545,27 @@ async function bootstrap() { renderAll(); return; } - const [dashboard, contentSources, accounts] = await Promise.all([ + const [dashboard, contentSources, accounts, trackingAccountsPayload] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), - storyforgeFetch("/v2/douyin/accounts").catch(() => []) + storyforgeFetch("/v2/douyin/accounts").catch(() => []), + storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })) ]); + const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || ""; + if (trackingCursorLastSeenAt) { + setLastSeenAt(trackingCursorLastSeenAt); + } + const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso(); + const trackingDigest = await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({ + items: [], + tracked_accounts: [], + cursor_last_seen_at: trackingCursorLastSeenAt + })); appState.dashboard = dashboard; appState.contentSources = safeArray(contentSources); appState.accounts = safeArray(accounts); + appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload); + appState.trackingDigest = trackingDigest; appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || ""; const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId); @@ -550,7 +579,12 @@ async function bootstrap() { appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; } - markSeenNow(); + const nextSeenAt = new Date().toISOString(); + storyforgeFetch("/v2/douyin/tracking/cursor", { + method: "POST", + body: { last_seen_at: nextSeenAt } + }).catch(() => null); + setLastSeenAt(nextSeenAt); } catch (error) { appState.message = error.message; if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) { @@ -635,6 +669,14 @@ function getCurrentProjectSourcesForAccount(account, projectId) { return getContentSourcesForAccount(account).filter((source) => source.project_id === projectId); } +function isTrackedAccount(accountId) { + return safeArray(appState.trackingAccounts).some((item) => item.tracked_account_id === accountId); +} + +function getTrackingDigestItems(limit = 6) { + return safeArray(appState.trackingDigest?.items).slice(0, limit); +} + function getSelectedAccount() { return appState.selectedWorkspace?.account || appState.accounts.find((item) => item.id === appState.selectedAccountId) @@ -852,15 +894,15 @@ function renderDashboardScreen() { const jobs = safeArray(dashboard.recent_jobs); const assistants = safeArray(dashboard.assistants); const accounts = safeArray(appState.accounts); + const trackedAccounts = safeArray(appState.trackingAccounts); + const digestItems = getTrackingDigestItems(3); const actions = []; if (!projects.length) actions.push("先新建一个项目"); if (!assistants.length) actions.push("先创建第一个 Agent"); if (!accounts.length) actions.push("先导入一个抖音主页或作品"); + if (!trackedAccounts.length && accounts.length) actions.push("挑 1 个重点账号加入跟踪"); if (jobs.some((item) => item.status !== "completed")) actions.push("处理进行中的生产任务"); if (!actions.length) actions.push("继续补高分对标并安排生产"); - const digestItems = accounts - .flatMap((account) => safeArray(account.video_summary?.videos).slice(0, 1).map((video) => ({ account, video }))) - .slice(0, 3); return screenShell( "项目总台", "先看项目状态、待办动作和高价值对标。", @@ -869,7 +911,7 @@ function renderDashboardScreen() {
最近发布时间 ${escapeHtml(formatDateTime(video.published_at))},适合继续交给 Agent 做借鉴点标注。
- +${escapeHtml(item.summary || `最近发布时间 ${formatDateTime(item.video?.published_at)},适合继续交给 Agent 做借鉴点标注。`)}
+先同步一个抖音账号,日报才会开始累积。
先把重点账号加入跟踪,日报才会开始累积。
先从左侧列表选一个对标账号,再决定是否导入到当前项目。
按上次打开后汇总。上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 天,本次摘要优先展示有作品更新的账号。
+按上次打开后汇总。上次打开距今 ${escapeHtml(daysSince(appState.lastSeenAt))} 天,本次优先展示有更新且值得借鉴的内容。
最近作品 ${escapeHtml(formatNumber(account.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(account.video_summary?.avg_play))}
- +最近作品 ${escapeHtml(formatNumber(item.account?.video_summary?.count))} 条 · 平均播放 ${escapeHtml(formatNumber(item.account?.video_summary?.avg_play))}
+先去找对标导入一个主页。
先去找对标把重点账号加入跟踪。
发布时间 ${escapeHtml(formatDateTime(video.published_at))},建议交给当前项目 Agent 继续判断借鉴点。
- +${escapeHtml(item.summary || `发布时间 ${formatDateTime(item.video?.published_at)},建议继续判断借鉴点。`)}
+ + ${safeArray(item.borrowing_points).length ? ` + + ` : ""}先同步一个账号,日报才会开始累积。
先把账号加入跟踪,并等待新作品更新。
${escapeHtml(account.profile_url || account.signature || "")}