// dsh-plugin-mcn 宿主端:MCN 抖音账号列表数据层 // - 本地 SQLite 缓存(node:sqlite,Node 24 内置) // - 首次/更新时通过 stdio MCP 协议从 myai-mcp 拉取 list_hot_accounts // - 注册 /mcn/api/* HTTP 路由供客户端查询 // 模块结构(拆分后): // config.js 常量 · db.js SQLite · mcp.js MCP 客户端 · data.js 数据访问 · http.js 路由工具 // video-tools.js 匹配工具 · imports.js 外部导入 · index.js 任务管理 + 路由注册 import { createUserMessage } from "@deepseek-ai/dsh-llm"; import { homedir } from "node:os"; import { join, resolve, dirname } from "node:path"; import { readFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { zstdDecompressSync } from "node:zlib"; import { STATS_TTL, CREATIVE_SESSION_ID, CREATIVE_PROVIDER, CREATIVE_MODEL, CHROME_PORTS, IMPORT_PREFIX, REFRESH_PREFIX, VIDEO_ANALYZE_PREFIX, REWRITE_PREFIX, PERSONA_PREFIX, STORYBOARD_PREFIX, REVIEW_PREFIX, ACCT_ANALYSIS_PREFIX, VIDEO_ANALYZE_MAX_CONCURRENT, REWRITE_MAX_CONCURRENT, STORYBOARD_MAX_CONCURRENT, REVIEW_MAX_CONCURRENT, MAX_UNGROUPED, SESSION_ACTIVE_WINDOW_MS, SESSION_RETENTION_MS, resolveCreativeCwd, RANK_UPDATE_COOLDOWN_MS, RANK_SESSION_ID, RANK_META_FILE, RANK_TASK_MAX_MS, } from "./config.js"; import { initDb, extractScriptTitle } from "./db.js"; import { ensureData, refreshData, queryAccounts, listTracks, queryAccountVideos } from "./data.js"; import { parseQuery, json, readBody } from "./http.js"; import { isInternalAccount, readVideoSource, readVideoParse, backfillVideoSource, importExternalDir, importExternalData, previewExternalDir, } from "./imports.js"; import { getRanking, rankingFreshness, listCustomScripts, getCustomScriptRow, countRewriteStats, batchDeleteCascade, } from "./workshop.js"; // 数据预览统计缓存(60s TTL,避免每次从数据库刷新) let statsCache = { ts: 0, data: null }; //#region 创作会话 async function composeDefaultPreset(ctx) { const presets = ctx.get("agentPresets"); if (!presets) return { setup: () => Promise.resolve() }; let resolvedId; // 优先 MCN 模式(标准能力 + myai MCP 工具:视频解析/账号设定/改写等会话需要 MCP);缺失时回退默认 preset try { resolvedId = (await presets.resolve("mcn")).id; } catch (e) { try { resolvedId = (await presets.resolve(undefined)).id; } catch (e2) { return { setup: () => Promise.resolve() }; } } return { agentPreset: resolvedId, setup: async (agentCtx) => { await presets.mount(agentCtx, resolvedId); }, }; } /** * 获取(或创建)创作专用会话 agent。 * 默认会话名 dsh_short-video-script;可传自定义 sessionId(如对标创作 dsh_benchmark-), * 已存在则复用,否则新建(指定 cwd 归入工作区,重启后仍在会话列表显示)。 */ async function getOrCreateCreativeAgent(ctx, sessionId = CREATIVE_SESSION_ID) { const existing = ctx.agents.get(sessionId); if (existing !== undefined) { return { agent: existing, created: false }; } const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); try { // 磁盘有日志:resume 恢复历史(重启后会话不 live 时走这里,避免 create 触发 id collision) const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { // resume 失败(如无磁盘日志)→ 新建 const handle = await ctx.agents.create({ sessionId, // 必须指定 provider/model,否则 deployment:persona 的 {{model}} 变量无值 agentOptions, // 必须指定 cwd,否则会话无归属目录,DSH 重启后从会话列表消失(仅存于 _no-cwd) meta: { cwd: resolveCreativeCwd(), agentPreset: compose.agentPreset, }, // 挂载默认 preset:获得文件/shell/skill 工具(否则工具集为空) setup: compose.setup, }); return { agent: handle.agent, created: true }; } } //#endregion //#region 外部账号导入/刷新(AI 分析确认链路,每任务独立会话支持并发) //#region 外部账号导入/刷新(AI 分析确认链路,每任务独立会话支持并发) /** 简单字符串 hash(djb2),用于生成会话 key */ function hashKey(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(16).padStart(8, "0"); } /** 并发浏览器任务隔离:调试 Chrome 端口池(需预启动对应实例),按会话 id 稳定分配。 * 导入/刷新等浏览器类任务各自使用独立端口,避免多个 AI 会话抢同一 Chrome。 */ function chromePortFor(sessionId) { let h = 5381; for (let i = 0; i < sessionId.length; i++) h = ((h << 5) + h + sessionId.charCodeAt(i)) >>> 0; return CHROME_PORTS[h % CHROME_PORTS.length]; } /** 浏览器操作提示(注入 prompt):固定使用 9223 调试 Chrome(浏览器任务已串行,无需多端口隔离; * 避免每次任务换端口导致冷启动新 Chrome 窗口、操作出错);优先复用已打开标签页。 */ function chromeHint(sessionId) { const port = 9223; return `浏览器操作:本任务使用**调试 Chrome 实例**(固定端口 ${port})。\n` + `执行前确认该端口实例可用(http://127.0.0.1:${port}/json/version);若未启动,用 pwsh 启动:Start-Process "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" -ArgumentList "--remote-debugging-port=${port}","--user-data-dir=C:\\Users\\$env:USERNAME\\.chrome-dbg-${port}","about:blank"\n` + `然后设置 $env:BU_CDP_URL="http://127.0.0.1:${port}",并清除代理环境变量(Remove-Item Env:HTTP_PROXY,Env:HTTPS_PROXY,Env:ALL_PROXY -ErrorAction SilentlyContinue; $env:NO_PROXY="*")后再调用 browser-harness。\n` + `**标签页复用**:操作前先检查已打开的标签页(cdp("Target.getTargets", flatten=True)),有已打开的抖音标签页直接 goto_url 导航复用,不要每次 new_tab 新建;无标签页时才 new_tab 新建;任务完成保留标签页供后续任务复用(详见技能 references/浏览器搜索抖音账号操作规范.md 5.5)。\n`; } /** 导入/刷新任务注册表:会话 key -> 提交时间戳(每任务独立会话,不同任务互不阻塞) */ const importTasks = new Map(); /** * 计算不与「已归档会话 id」冲突的自增 id(rewrite/storyboard 用): * 会话 id = 前缀 + 自增 id(如 dsh_rewrite-{logId})。SQLite 删除最大行后自增 id 会被复用, * 而归档状态按会话 id 记录——复用的新会话会继承旧会话的归档状态,导致侧边栏不显示。 * 这里在插入前跳过 archivedSessionIds 中已占用的会话 id(与「解析抖音账号」会话用内容 hash * 永不冲突的思路等价:保证新任务会话 id 从未被归档过,从而正常显示在侧边栏)。 * @param {import("node:sqlite").DatabaseSync} d * @param {any} ctx * @param {string} prefix 会话前缀(REWRITE_PREFIX / STORYBOARD_PREFIX) * @param {string} table 自增 id 来源表(rewrite_log / storyboard_log) * @returns {number} 不与归档会话 id 冲突的可用 id */ function nextTaskIdAvoidingArchived(d, ctx, prefix, table) { const row = d.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`).get(); let id = Number(row.m) + 1; // 归档集(workspaceRegistry 内存态为准,缺失时退化为不跳过——仅影响侧边栏显示,不影响任务执行) const reg = ctx.get("workspaceRegistry"); if (!reg) return id; const archived = new Set(reg.archivedSessionIds); while (archived.has(prefix + id)) id++; return id; } /** 获取(或创建)导入/刷新专用会话 agent(每任务一个会话:不同账号/目录可并发;同一 key 重复提交复用同一会话)。 * 会话创建于「解析任务」工作区(cwd = {主工作区}/解析任务),并 attach 到该工作区分组。 */ async function getOrCreateImportAgent(ctx, sessionKey) { const sessionId = IMPORT_PREFIX + sessionKey; const existing = ctx.agents.get(sessionId); if (existing !== undefined) { await attachAccountAnalyzeSession(ctx, sessionId); return { agent: existing, created: false }; } const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); const cwd = resolvePersonaCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup, }); return { agent: handle.agent, created: true }; } finally { await attachAccountAnalyzeSession(ctx, sessionId); } } /** 刷新专用会话(key = accountId) */ async function getOrCreateRefreshAgent(ctx, accountId) { const sessionId = REFRESH_PREFIX + accountId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) { await attachAccountAnalyzeSession(ctx, sessionId); return { agent: existing, created: false }; } const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); const cwd = resolvePersonaCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup, }); return { agent: handle.agent, created: true }; } finally { await attachAccountAnalyzeSession(ctx, sessionId); } } /** 当前导入/刷新任务列表(供前端轮询:并发多任务各自状态;key 前缀 "refresh:" 对应 dsh_refresh-{id} 会话) */ function listImportTasks(ctx) { const out = []; for (const [key, submittedAt] of importTasks) { if (key.startsWith("refresh:")) { const accountId = key.slice("refresh:".length); const a = ctx.agents.get(REFRESH_PREFIX + accountId); out.push({ id: key, status: a !== undefined ? a.status : "idle", submittedAt }); } else { const a = ctx.agents.get(IMPORT_PREFIX + key); out.push({ id: key, status: a !== undefined ? a.status : "idle", submittedAt }); } } while (out.length > 200) out.shift(); return out; } /** 浏览器类任务判定:刷新(refresh:*)与账号网页导入(acc:*)操作本地调试 Chrome,串行执行避免抢浏览器; * dir:* 目录导入读本地文件+AI 分析,不涉及浏览器,可并行。 */ function isBrowserTaskKey(key) { return key.startsWith("refresh:") || key.startsWith("acc:"); } /** 是否有浏览器类任务正在运行(全局互斥锁:同一时刻只允许一个浏览器任务) */ function browserTaskRunning(ctx) { for (const [key] of importTasks) { if (!isBrowserTaskKey(key)) continue; const sessionId = key.startsWith("refresh:") ? REFRESH_PREFIX + key.slice("refresh:".length) : IMPORT_PREFIX + key; const a = ctx.agents.get(sessionId); if (a !== undefined && a.status === "running") return true; } return false; } /** 视频解析任务注册表:videoId -> 提交时间戳。用于 task/status 枚举与同视频重复提交保护(重启后为空,会话按 videoId 幂等恢复) */ const videoAnalyzeTasks = new Map(); /** 视频解析会话工作目录:{主工作区}/解析任务(与解析任务共用「解析任务」目录 → 同一工作区,会话归「解析任务」分组) */ function resolveVideoAnalyzeCwd() { return join(resolveCreativeCwd(), "解析任务"); } let videoAnalyzeWorkspace = null; /** * 确保「视频解析」工作区存在(幂等):目录不存在则创建;registry.create 按 canonical path 复用已有记录。 * 会话 cwd 与该目录一致后 attachSession 才能通过校验,从而在会话列表归入「视频解析」分组。 * workspaceRegistry 服务不可用时降级返回 null(会话不分组,保持未分组行为)。 */ async function ensureVideoAnalyzeWorkspace(ctx) { const reg = ctx.get("workspaceRegistry"); if (!reg) return null; if (videoAnalyzeWorkspace) { // 缓存失效兜底(服务重建后实体可能已替换) if (reg.get(videoAnalyzeWorkspace.id) !== undefined) return videoAnalyzeWorkspace; videoAnalyzeWorkspace = null; } const dir = resolveVideoAnalyzeCwd(); try { mkdirSync(dir, { recursive: true }); } catch (e) { console.warn(`[dsh-plugin-mcn] 视频解析目录创建失败: ${e.message}`); return null; } try { videoAnalyzeWorkspace = await reg.create(dir, "解析任务"); // title「解析任务」(与解析任务归组) if (videoAnalyzeWorkspace.title !== "解析任务") { try { await videoAnalyzeWorkspace.setTitle("解析任务"); } catch (e) { /* 更名失败不阻塞 */ } } return videoAnalyzeWorkspace; } catch (e) { console.warn(`[dsh-plugin-mcn] 「视频解析」工作区创建失败: ${e.message}`); return null; } } /** 获取(或创建)视频解析专用会话 agent(每视频一个会话:与导入/刷新解耦互不阻塞,支持并发解析多个视频;同一视频重复解析复用同一会话)。 * 会话创建于「视频解析」工作区(cwd = {主工作区}/视频解析),并 attach 到该工作区分组。 */ async function getOrCreateVideoAnalyzeAgent(ctx, videoId) { const sessionId = VIDEO_ANALYZE_PREFIX + videoId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) return { agent: existing, created: false }; const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); const cwd = resolveVideoAnalyzeCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup, }); return { agent: handle.agent, created: true }; } finally { // 归入「视频解析」工作区分组(幂等;旧会话 cwd 不匹配时 attach 抛错,仅告警不影响解析) const ws = await ensureVideoAnalyzeWorkspace(ctx); if (ws && !ws.sessionIds.includes(sessionId)) { try { await ws.attachSession(sessionId); console.log(`[dsh-plugin-mcn] 会话 ${sessionId} 已归入「视频解析」分组`); } catch (attachErr) { console.warn(`[dsh-plugin-mcn] 会话 ${sessionId} 归组失败(cwd 与工作区不匹配): ${attachErr.message}`); } } } } /** 当前视频解析任务列表(供前端轮询:并发多视频各自状态) */ function listVideoAnalyzeTasks(ctx) { const out = []; for (const [videoId, submittedAt] of videoAnalyzeTasks) { const a = ctx.agents.get(VIDEO_ANALYZE_PREFIX + videoId); out.push({ videoId, status: a !== undefined ? a.status : "idle", submittedAt }); } // 数量上限保护(FIFO 裁剪,防无限增长) while (out.length > 200) out.shift(); return out; } /** 改写任务注册表:logId -> 提交时间戳 */ const rewriteTasks = new Map(); // logId -> 提交时间戳 /** 当前运行中的视频改写任务数 */ function runningRewriteCount(ctx) { let n = 0; for (const [logId] of rewriteTasks) { const a = ctx.agents.get(REWRITE_PREFIX + logId); if (a !== undefined && a.status === "running") n++; } return n; } /** 改写用账号设定:去掉人设卡末尾两个二级段落(「当前需解决的问题」「人设底线」——运营向内容,与脚本创作无关) */ function trimPersonaForRewrite(text) { const lines = String(text || "").split(/\r?\n/); const h2 = []; lines.forEach((l, i) => { if (/^##\s+\S/.test(l.trim())) h2.push(i); }); if (h2.length >= 2) { const cut = h2[h2.length - 2]; return lines.slice(0, cut).join("\n").trimEnd(); } return String(text || "").trim(); } /** 改写会话工作目录:{主工作区}/创作任务(与复盘共用「创作任务」目录 → 同一工作区,会话归「创作任务」分组) */ function resolveRewriteCwd() { return join(resolveCreativeCwd(), "创作任务"); } /** 改写工作区实体缓存(workspaceRegistry 服务不可用时为 null,会话不归组) */ let rewriteWorkspace = null; /** 确保「改写脚本」工作区存在(幂等):会话 cwd 与该目录一致后 attachSession 才能通过校验, * 从而在会话列表归入「改写脚本」分组(方案 A:改写会话可见)。 */ async function ensureRewriteWorkspace(ctx) { const reg = ctx.get("workspaceRegistry"); if (!reg) return null; if (rewriteWorkspace) { if (reg.get(rewriteWorkspace.id) !== undefined) return rewriteWorkspace; rewriteWorkspace = null; } const dir = resolveRewriteCwd(); try { mkdirSync(dir, { recursive: true }); } catch (e) { console.warn(`[dsh-plugin-mcn] 改写脚本目录创建失败: ${e.message}`); return null; } try { rewriteWorkspace = await reg.create(dir, "创作任务"); // title「创作任务」(与复盘归组) if (rewriteWorkspace.title !== "创作任务") { try { await rewriteWorkspace.setTitle("创作任务"); } catch (e) { /* 更名失败不阻塞 */ } } return rewriteWorkspace; } catch (e) { console.warn(`[dsh-plugin-mcn] 「改写脚本」工作区创建失败: ${e.message}`); return null; } } /** 获取(或创建)改写会话:dsh_rewrite-{logId},每个改写任务独立会话(可 8 并发),归入「改写脚本」工作区分组 */ async function getOrCreateRewriteAgent(ctx, logId) { const sessionId = REWRITE_PREFIX + logId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) return { agent: existing, created: false }; const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL }; const compose = await composeDefaultPreset(ctx); const cwd = resolveRewriteCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup }); return { agent: handle.agent, created: true }; } finally { // 归入「改写脚本」工作区分组(幂等;旧 cwd 会话 attach 抛错仅告警) try { const ws = await ensureRewriteWorkspace(ctx); if (ws && !ws.sessionIds.includes(sessionId)) await ws.attachSession(sessionId); } catch (e) { /* 归组失败不影响改写 */ } } } /** 分镜提示词任务注册表:storyboardId -> 提交时间戳 */ const storyboardTasks = new Map(); /** 分镜提示词会话工作目录:{主工作区}/分镜提示词(与改写脚本同级,不注册为工作区 → 会话显示在「未分组」) */ function resolveStoryboardCwd() { return join(resolveCreativeCwd(), "分镜提示词"); } /** 获取(或创建)分镜提示词会话:dsh_storyboard-{logId},每个生成任务独立会话(可 8 并发),不归入工作区分组 */ async function getOrCreateStoryboardAgent(ctx, logId) { const sessionId = STORYBOARD_PREFIX + logId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) return { agent: existing, created: false }; const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL }; const compose = await composeDefaultPreset(ctx); const cwd = resolveStoryboardCwd(); // 会话工作目录必须存在:agent 内工具(pwsh/glob 等)以 cwd 为工作目录 spawn,目录缺失会 ENOENT try { mkdirSync(cwd, { recursive: true }); } catch (e) { /* 目录创建失败不阻塞,工具可能受影响 */ } try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup }); return { agent: handle.agent, created: true }; } } /** 当前运行中的分镜提示词任务数 */ function runningStoryboardCount(ctx) { let n = 0; for (const [id] of storyboardTasks) { const a = ctx.agents.get(STORYBOARD_PREFIX + id); if (a !== undefined && a.status === "running") n++; } return n; } /** 当前运行中的视频解析任务数 */ function runningVideoAnalyzeCount(ctx) { let n = 0; for (const [videoId] of videoAnalyzeTasks) { const a = ctx.agents.get(VIDEO_ANALYZE_PREFIX + videoId); if (a !== undefined && a.status === "running") n++; } return n; } //#region 脚本复盘会话(AI写作复盘页「复盘」:source 原视频脚本 / ai AI脚本 / compare 对比分析,三份独立存储) /** 复盘会话工作目录:{主工作区}/创作任务(与改写共用「创作任务」目录 → 同一工作区,会话归「创作任务」分组) */ function resolveReviewCwd() { return join(resolveCreativeCwd(), "创作任务"); } /** 复盘工作区实体缓存(workspaceRegistry 服务不可用时为 null,会话不归组) */ let reviewWorkspace = null; /** 确保「脚本复盘」工作区存在(幂等,title「创作任务」与改写脚本归组):会话 cwd 与该目录一致后 attach 才能通过校验 */ async function ensureReviewWorkspace(ctx) { const reg = ctx.get("workspaceRegistry"); if (!reg) return null; if (reviewWorkspace) { if (reg.get(reviewWorkspace.id) !== undefined) return reviewWorkspace; reviewWorkspace = null; } const dir = resolveReviewCwd(); try { mkdirSync(dir, { recursive: true }); } catch (e) { console.warn(`[dsh-plugin-mcn] 脚本复盘目录创建失败: ${e.message}`); return null; } try { reviewWorkspace = await reg.create(dir, "创作任务"); return reviewWorkspace; } catch (e) { console.warn(`[dsh-plugin-mcn] 「脚本复盘」工作区创建失败: ${e.message}`); return null; } } /** 复盘任务注册表:`${type}:${id}` -> 提交时间戳(type: source|ai|compare;id: video_id 或 rewrite_id) */ const reviewTasks = new Map(); /** 复盘会话 id:dsh_review-{type}-{id} */ function reviewSessionId(type, id) { return REVIEW_PREFIX + type + "-" + id; } /** 获取(或创建)复盘会话:每个复盘任务独立会话(可并发,不归入工作区分组) */ async function getOrCreateReviewAgent(ctx, type, id) { const sessionId = reviewSessionId(type, id); const existing = ctx.agents.get(sessionId); if (existing !== undefined) return { agent: existing, created: false }; const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL }; const compose = await composeDefaultPreset(ctx); const cwd = resolveReviewCwd(); // 会话工作目录必须存在:agent 内工具(pwsh/glob 等)以 cwd 为工作目录 spawn,目录缺失会 ENOENT try { mkdirSync(cwd, { recursive: true }); } catch (e) { /* 目录创建失败不阻塞,工具可能受影响 */ } try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup }); return { agent: handle.agent, created: true }; } finally { // 归入「创作任务」工作区分组(幂等;旧 cwd 会话 attach 抛错仅告警) try { const ws = await ensureReviewWorkspace(ctx); if (ws && !ws.sessionIds.includes(sessionId)) await ws.attachSession(sessionId); } catch (e) { /* 归组失败不影响复盘 */ } } } /** 当前运行中的复盘任务数 */ function runningReviewCount(ctx) { let n = 0; for (const [k] of reviewTasks) { const sep = k.indexOf(":"); if (sep < 0) continue; const a = ctx.agents.get(reviewSessionId(k.slice(0, sep), k.slice(sep + 1))); if (a !== undefined && a.status === "running") n++; } return n; } /** 从复盘会话目录名解析 type 与 id(启动恢复用):"dsh_review-ai-23" → {type:"ai", id:"23"} */ function parseReviewSessionId(name) { const pre = REVIEW_PREFIX.length; if (!name.startsWith(REVIEW_PREFIX)) return null; const rest = name.slice(pre); const sep = rest.indexOf("-"); if (sep <= 0) return null; const type = rest.slice(0, sep); const id = rest.slice(sep + 1); if (!/^(source|ai|compare)$/.test(type) || !/^\d+$/.test(id)) return null; return { type, id }; } //#endregion /** 账号设定提炼任务注册表:accountId -> 提交时间戳(每账号独立会话,与导入/刷新/视频解析互不阻塞) */ const personaTasks = new Map(); /** 账号数据分析任务注册表:accountId -> 提交时间戳(V1.0 同步:账号详情「数据分析」tab 触发,每账号独立会话 dsh_acctana-{id}) */ const acctAnalysisTasks = new Map(); /** 解析任务相关会话工作目录:{主工作区}/解析任务(与视频解析共用「解析任务」目录 → 同一工作区)。 * 覆盖账号设定提炼、导入账号、刷新账号数据三类会话。 */ function resolvePersonaCwd() { return join(resolveCreativeCwd(), "解析任务"); } let personaWorkspace = null; /** * 确保「解析任务」工作区存在(幂等):目录(产出根目录)不存在则创建; * workspace.create 按 canonical path 复用已有记录,title 显式指定「解析任务」(目录名为「解析任务」); * 旧版创建的 title 为「账号分析」,此处自动更名为「解析任务」)。 * 会话 cwd 与该目录一致后 attachSession 才能通过校验,从而在会话列表归入「解析任务」分组。 */ async function ensurePersonaWorkspace(ctx) { const reg = ctx.get("workspaceRegistry"); if (!reg) return null; if (personaWorkspace) { if (reg.get(personaWorkspace.id) !== undefined) return personaWorkspace; personaWorkspace = null; } const dir = resolvePersonaCwd(); try { mkdirSync(dir, { recursive: true }); } catch (e) { console.warn(`[dsh-plugin-mcn] 解析任务目录创建失败: ${e.message}`); return null; } try { personaWorkspace = await reg.create(dir, "解析任务"); // title「解析任务」(与视频解析归组) if (personaWorkspace.title !== "解析任务") { try { await personaWorkspace.setTitle("解析任务"); } catch (e) { /* 更名失败不阻塞 */ } } return personaWorkspace; } catch (e) { console.warn(`[dsh-plugin-mcn] 「解析任务」工作区创建失败: ${e.message}`); return null; } } /** 获取(或创建)账号设定提炼专用会话 agent(每账号一个会话:不同账号可并发;同一账号重复提炼复用同一会话)。 * 会话创建于「解析任务」工作区(cwd = {主工作区}/解析任务),并 attach 到该工作区分组。 * 注意:旧版本创建的会话 cwd 为旧路径(如 D:\dshworkspace),attachSession 校验 cwd===workspace.path 会失败, * 此类会话归组失败(仅告警);旧会话迁移见 attachAccountAnalyzeSession 注释。 */ async function getOrCreatePersonaAgent(ctx, accountId) { const sessionId = PERSONA_PREFIX + accountId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) { // live 会话也要确保归组(早期版本提前 return 导致不 attach) await attachAccountAnalyzeSession(ctx, sessionId); return { agent: existing, created: false }; } const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); const cwd = resolvePersonaCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup, }); return { agent: handle.agent, created: true }; } finally { // 归入「解析任务」工作区分组(幂等;旧会话 cwd 不匹配时 attach 抛错,仅告警不影响提炼) await attachAccountAnalyzeSession(ctx, sessionId); } } /** 将解析任务类会话(导入/刷新 dsh_external-import、提炼 dsh_persona-*)归入「解析任务」工作区分组(幂等)。 * 旧 cwd 会话(如 D:\dshworkspace 下创建的 dsh_persona-* / dsh_external-import)attach 校验失败无法归组, * 迁移方式:等会话空闲后删除其磁盘会话目录,下次触发时将以新 cwd 重建并自动归组。 */ async function attachAccountAnalyzeSession(ctx, sessionId) { const ws = await ensurePersonaWorkspace(ctx); if (ws && !ws.sessionIds.includes(sessionId)) { try { await ws.attachSession(sessionId); console.log(`[dsh-plugin-mcn] 会话 ${sessionId} 已归入「解析任务」分组`); } catch (attachErr) { console.warn(`[dsh-plugin-mcn] 会话 ${sessionId} 归组失败(cwd 与工作区不匹配): ${attachErr.message}`); } } } /** 获取(或创建)账号数据分析专用会话 agent(V1.0 同步:每账号一个会话,复用「解析任务」工作区/分组; * 与账号设定提炼(dsh_persona-*)并存互不干扰;同一账号重复发起复用同一会话)。 */ async function getOrCreateAcctAnalysisAgent(ctx, accountId) { const sessionId = ACCT_ANALYSIS_PREFIX + accountId; const existing = ctx.agents.get(sessionId); if (existing !== undefined) { await attachAccountAnalyzeSession(ctx, sessionId); return { agent: existing, created: false }; } const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL, }; const compose = await composeDefaultPreset(ctx); const cwd = resolvePersonaCwd(); try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup, }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup, }); return { agent: handle.agent, created: true }; } finally { await attachAccountAnalyzeSession(ctx, sessionId); } } /** 账号数据分析任务列表(供前端轮询:并发多账号各自状态) */ function listAcctAnalysisTasks(ctx) { const out = []; for (const [accountId, submittedAt] of acctAnalysisTasks) { const a = ctx.agents.get(ACCT_ANALYSIS_PREFIX + accountId); out.push({ accountId, status: a !== undefined && a.status === "running" ? "running" : "idle", submittedAt }); } return out; } /** * 启动修复:把磁盘上已存在的解析/提炼/导入会话重新 attach 到对应分组(幂等)。 * workspaceRegistry 的 sessionPaths 索引只在 attach/live 时建立,重启后磁盘会话 * (dsh_video-analyze-* / dsh_persona-* / dsh_external-import)不在索引中会导致分组不显示; * 这里对磁盘会话逐个 attachSession(内部按 header cwd 校验并重建索引),恢复分组显示。 */ async function repairWorkspaceSessions(ctx) { const reg = ctx.get("workspaceRegistry"); if (!reg) return; try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return; const dirs = readdirSync(sessionsRoot, { withFileTypes: true }) .filter((e) => e.isDirectory()) .flatMap((e) => readdirSync(join(sessionsRoot, e.name), { withFileTypes: true }) .filter((s) => s.isDirectory() && /^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_rewrite-\d+|dsh_review-\w+-\d+|dsh_import-|dsh_refresh-|dsh_external-import)$/.test(s.name)) .map((s) => s.name)); for (const sessionId of dirs) { let ws; if (sessionId.startsWith(VIDEO_ANALYZE_PREFIX)) ws = await ensureVideoAnalyzeWorkspace(ctx); else if (sessionId.startsWith(REWRITE_PREFIX)) ws = await ensureRewriteWorkspace(ctx); else if (parseReviewSessionId(sessionId)) ws = await ensureReviewWorkspace(ctx); else ws = await ensurePersonaWorkspace(ctx); if (!ws || ws.sessionIds.includes(sessionId)) continue; try { await ws.attachSession(sessionId); console.log(`[dsh-plugin-mcn] 启动修复:会话 ${sessionId} 已归入「${ws.title}」分组`); } catch (e) { // 旧 cwd 会话(如 D:\dshworkspace 下创建的视频解析/提炼会话)attach 校验必然失败, // 属已知情形(数据已落库),静默跳过,不打印告警 } } } catch (e) { console.warn(`[dsh-plugin-mcn] 启动会话归组修复失败: ${e.message}`); } } /** * 未分组会话自动清理:未分组(不在任何工作区且未归档)会话超过上限时, * 按最后活动时间归档最老的已完成(非运行中改写任务)会话,直到不超过上限。 */ async function cleanupUngroupedSessions(ctx) { try { const reg = ctx.get("workspaceRegistry"); if (!reg) return; const archived = new Set(reg.archivedSessionIds); const grouped = new Set(); for (const w of reg.list()) for (const sid of w.sessionIds) grouped.add(sid); const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return; const all = []; for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) { const log = join(gdir, s.name, "session.jsonl.zstd"); if (!existsSync(log)) continue; all.push({ id: s.name, mtime: statSync(log).mtimeMs }); } } const ungrouped = all.filter((s) => !archived.has(s.id) && !grouped.has(s.id)); if (ungrouped.length <= MAX_UNGROUPED) return; const sorted = [...ungrouped].sort((a, b) => a.mtime - b.mtime); // 最老在前 const d = initDb(); // 状态确认:agent 实时运行状态优先,改写任务记录(rewrite_log)补充——运行中的会话一律不处理 const isRunning = (id) => { try { const a = ctx.agents.get(id); if (a !== undefined && a.status === "running") return true; } catch (e) { /* agent 查询失败按非运行处理 */ } const m = /^dsh_rewrite-(\d+)$/.exec(id); if (m) { const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "running"; } const ms = /^dsh_storyboard-(\d+)$/.exec(id); if (ms) { const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(ms[1])); return !!r && r.status === "running"; } return false; }; let removed = 0; const now = Date.now(); for (const s of sorted) { if (ungrouped.length - removed <= MAX_UNGROUPED) break; if (isRunning(s.id)) continue; // 运行中的会话不处理 if (now - s.mtime <= SESSION_ACTIVE_WINDOW_MS) continue; // 近期活跃(15 分钟内有写入)不归档 try { await reg.archiveSession(s.id); removed++; console.log(`[dsh-plugin-mcn] 未分组会话超限(${ungrouped.length} 个),已归档最老会话 ${s.id}`); } catch (e) { /* 单个归档失败(如会话不可知)继续处理下一个 */ } } } catch (e) { console.warn(`[dsh-plugin-mcn] 未分组会话清理失败: ${e.message}`); } } /** 启动恢复改写会话:resume 使会话回到 live(UI 列表可见 + 未完成任务继续) */ /** 任务是否已完成(结果已入库):改写 done / 视频已解析 / 人设卡已入库 */ function taskDone(id) { const d = initDb(); let m = /^dsh_rewrite-(\d+)$/.exec(id); if (m) { const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "done"; } m = /^dsh_storyboard-(\d+)$/.exec(id); if (m) { const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "done"; } m = /^dsh_video-analyze-(\d+)$/.exec(id); if (m) { const videoId = Number(m[1]); const hit = d.prepare(`SELECT COUNT(*) c FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?)`).get(videoId, videoId).c + d.prepare(`SELECT COUNT(*) c FROM account_video_analysis WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?)`).get(videoId, videoId).c; return hit > 0; } m = /^dsh_persona-(\d+)$/.exec(id); if (m) { const accId = Number(m[1]); return d.prepare(`SELECT COUNT(*) c FROM account_persona WHERE account_id=?`).get(accId).c > 0; } m = /^dsh_acctana-(\d+)$/.exec(id); if (m) { const accId = Number(m[1]); return d.prepare(`SELECT COUNT(*) c FROM account_analysis WHERE account_id=?`).get(accId).c > 0; } return false; } /** agent 是否运行中 */ function agentRunning(ctx, id) { try { const a = ctx.agents.get(id); return a !== undefined && a.status === "running"; } catch (e) { return false; } } function sessionMtime(id) { try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const log = join(sessionsRoot, grp.name, id, "session.jsonl.zstd"); if (existsSync(log)) return statSync(log).mtimeMs; } } catch (e) { /* 忽略 */ } return 0; } /** * 启动恢复 mcn 磁盘会话(方案 B:任务驱动挂载): * 只恢复「最近活跃」的会话(15 分钟内仍在写入 = 任务进行中,可能是重启前未完成的重新解析/改写); * 长期无写入的会话(已完成或中断)不恢复(不挂 MCP)。导入/刷新类不自动恢复。 */ async function resumeMcNSessions(ctx) { try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return; const now = Date.now(); const dirs = readdirSync(sessionsRoot, { withFileTypes: true }) .filter((e) => e.isDirectory()) .flatMap((e) => readdirSync(join(sessionsRoot, e.name), { withFileTypes: true }) .filter((s) => s.isDirectory() && /^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_import-|dsh_refresh-\d+|dsh_external-import|dsh_rewrite-\d+|dsh_storyboard-\d+)$/.test(s.name)) .map((s) => s.name)); for (const id of dirs) { try { // 长期无写入的会话不恢复(结果已入库或中断,恢复只会白挂 MCP) if (now - sessionMtime(id) > SESSION_ACTIVE_WINDOW_MS) continue; if (id.startsWith(VIDEO_ANALYZE_PREFIX)) await getOrCreateVideoAnalyzeAgent(ctx, Number(id.slice(VIDEO_ANALYZE_PREFIX.length))); else if (id.startsWith(PERSONA_PREFIX)) await getOrCreatePersonaAgent(ctx, Number(id.slice(PERSONA_PREFIX.length))); else if (id.startsWith(ACCT_ANALYSIS_PREFIX)) await getOrCreateAcctAnalysisAgent(ctx, Number(id.slice(ACCT_ANALYSIS_PREFIX.length))); else if (id.startsWith(IMPORT_PREFIX)) await getOrCreateImportAgent(ctx, id.slice(IMPORT_PREFIX.length)); else if (id.startsWith(REFRESH_PREFIX)) await getOrCreateRefreshAgent(ctx, Number(id.slice(REFRESH_PREFIX.length))); else if (id.startsWith(REWRITE_PREFIX)) await getOrCreateRewriteAgent(ctx, Number(id.slice(REWRITE_PREFIX.length))); else if (id.startsWith(STORYBOARD_PREFIX)) await getOrCreateStoryboardAgent(ctx, Number(id.slice(STORYBOARD_PREFIX.length))); else if (id === "dsh_external-import") await getOrCreateImportAgent(ctx, "external-import"); else { const pv = parseReviewSessionId(id); if (pv) await getOrCreateReviewAgent(ctx, pv.type, Number(pv.id)); } } catch (e) { /* 单会话恢复失败不影响其他 */ } } } catch (e) { console.warn(`[dsh-plugin-mcn] MCN 会话恢复失败: ${e.message}`); } } /** * 杀掉 DSH(本进程)spawn 的 myai-mcp 孤儿进程树(父链归属 web 进程的实例; * WorkBuddy 等外部应用的 myai 实例父链不同,不受影响)。 */ function killMcpChildren() { try { const webPid = process.pid; const ps = spawn("powershell", [ "-NoProfile", "-NonInteractive", "-Command", `$web=${webPid}; $ps = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'myai' }; foreach ($p in $ps) { $cur = $p; $ok = $false; for ($i = 0; $i -lt 12 -and $cur; $i++) { if ($cur.ProcessId -eq $web) { $ok = $true; break }; $cur = Get-CimInstance Win32_Process -Filter "ProcessId=$($cur.ParentProcessId)" -ErrorAction SilentlyContinue }; if ($ok) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; Write-Output ("killed " + $p.ProcessId) } }`, ], { stdio: ["ignore", "pipe", "inherit"], windowsHide: true }); ps.stdout.on("data", (c) => { const t = c.toString().trim(); if (t) console.log(`[dsh-plugin-mcn] ${t}`); }); ps.on("error", () => { /* 忽略 */ }); } catch (e) { /* 忽略 */ } } /** * 已完成任务会话清理(方案 B):任务结果已入库、会话长期无写入(>30 分钟)且不在运行中的 * mcn 任务会话,物理删除(数据在库,会话删除释放 MCP 连接)。改写/解析/提炼会话适用。 * 近期活跃的会话不删(重新解析/改写可能仍在执行)。 */ async function cleanupCompletedTaskSessions(ctx) { try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return; const now = Date.now(); const targets = []; for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) { const id = s.name; if (!/^(dsh_video-analyze-\d+|dsh_persona-\d+|dsh_acctana-\d+|dsh_rewrite-\d+|dsh_storyboard-\d+)$/.test(id)) continue; if (now - sessionMtime(id) <= 30 * 60 * 1000) continue; // 近期活跃(30 分钟内有写入)不清理 if (!taskDone(id)) continue; if (agentRunning(ctx, id)) continue; // 运行中不删 targets.push({ id, dir: join(gdir, id) }); } } for (const t of targets) { try { const reg = ctx.get("workspaceRegistry"); if (reg) { for (const w of reg.list()) { if (w.sessionIds.includes(t.id)) { try { await w.detachSession(t.id); } catch (e) { /* 忽略 */ } } } } rmSync(t.dir, { recursive: true, force: true }); console.log(`[dsh-plugin-mcn] 已完成任务会话已清理(释放 MCP): ${t.id}`); } catch (e) { /* 单个失败继续 */ } } // 清理本进程派生的 myai-mcp 孤儿进程(会话已删但 MCP 进程可能残留) if (targets.length > 0) killMcpChildren(); } catch (e) { console.warn(`[dsh-plugin-mcn] 已完成任务会话清理失败: ${e.message}`); } } /** * MCN 任务会话自动删除:最后活动时间距今超过 SESSION_RETENTION_MS(24h)且不在运行中的 * dsh_* 会话记录,物理删除会话目录(数据在库,删除仅释放磁盘/列表)。 * 运行中会话(agent 实时状态 + 任务表状态双保险)与 24h 内有写入的会话一律保留。 */ async function cleanupOldMcNSessions(ctx) { try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return; const now = Date.now(); const targets = []; const d = initDb(); // 任务表运行状态(agent 实时状态之外的补充,仅对有任务表的类型):改写/分镜/复盘 const taskRunning = (id) => { let m = /^dsh_rewrite-(\d+)$/.exec(id); if (m) { const r = d.prepare(`SELECT status FROM rewrite_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "running"; } m = /^dsh_storyboard-(\d+)$/.exec(id); if (m) { const r = d.prepare(`SELECT status FROM storyboard_log WHERE id=?`).get(Number(m[1])); return !!r && r.status === "running"; } m = /^dsh_review-(ai|source|compare)-(\d+)$/.exec(id); if (m) { // review 会话名的数字是 rewrite_id(ai/compare)或 video_id(source),查对应类型的 running 记录 const key = Number(m[2]); const r = m[1] === "source" ? d.prepare(`SELECT id FROM script_review WHERE status='running' AND type='source' AND video_id=? LIMIT 1`).get(key) : d.prepare(`SELECT id FROM script_review WHERE status='running' AND type=? AND rewrite_id=? LIMIT 1`).get(m[1], key); return !!r; } // 视频解析/人设提炼等无任务状态表,运行中判断完全依赖 agentRunning(实时 agent 状态) return false; }; for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) { const id = s.name; if (!/^dsh_/.test(id)) continue; // 只处理 MCN 任务会话(dsh_* 前缀) if (id === CREATIVE_SESSION_ID) continue; // 固定创作会话永不自动删除 const log = join(gdir, id, "session.jsonl.zstd"); if (!existsSync(log)) continue; if (now - statSync(log).mtimeMs <= SESSION_RETENTION_MS) continue; // 24h 内有写入 → 保留 if (agentRunning(ctx, id)) continue; // 运行中不删 if (taskRunning(id)) continue; // 任务表 running 不删 targets.push({ id, dir: join(gdir, id) }); } } for (const t of targets) { try { const reg = ctx.get("workspaceRegistry"); if (reg) { for (const w of reg.list()) { if (w.sessionIds.includes(t.id)) { try { await w.detachSession(t.id); } catch (e) { /* 忽略 */ } } } } rmSync(t.dir, { recursive: true, force: true }); console.log(`[dsh-plugin-mcn] 超过 24h 未活动的 MCN 会话记录已自动删除: ${t.id}`); } catch (e) { /* 单个失败继续 */ } } } catch (e) { console.warn(`[dsh-plugin-mcn] MCN 会话自动删除失败: ${e.message}`); } } /** 当前账号设定提炼任务列表(供前端轮询:并发多账号各自状态) */ function listPersonaTasks(ctx) { const out = []; for (const [accountId, submittedAt] of personaTasks) { const a = ctx.agents.get(PERSONA_PREFIX + accountId); out.push({ accountId, status: a !== undefined ? a.status : "idle", submittedAt }); } while (out.length > 100) out.shift(); return out; } /** 当前改写任务列表(供前端轮询:rewrite_log 运行中 + 会话存活;与 persona/analyze 交互一致) */ function listRewriteTasks(ctx) { const out = []; for (const [logId, submittedAt] of rewriteTasks) { const a = ctx.agents.get(REWRITE_PREFIX + logId); out.push({ id: Number(logId), status: a !== undefined ? a.status : "idle", submittedAt }); } while (out.length > 100) out.shift(); return out; } /** 当前复盘任务列表(供前端轮询:reviewTasks 注册 + 会话存活;与 rewrite/persona 交互一致) */ function listReviewTasks(ctx) { const out = []; for (const [k, submittedAt] of reviewTasks) { const sep = k.indexOf(":"); if (sep < 0) continue; const type = k.slice(0, sep); const id = Number(k.slice(sep + 1)); const a = ctx.agents.get(reviewSessionId(type, id)); out.push({ key: k, type, id, status: a !== undefined ? a.status : "idle", submittedAt }); } while (out.length > 100) out.shift(); return out; } //#region AI会话模块(MCN 工作台内查看插件创建的 dsh_* 会话:扫描 + 多帧 zstd 解压 + 对话解析) /** zstd 帧扫描(复制 DSH session-persistence-jsonl 逻辑):解析多帧边界 */ const ZSTD_MAGIC = 4247762216; function scanZstdFrames(buffer) { const frames = []; let offset = 0; while (offset < buffer.length) { const start = offset; if (buffer.length - offset < 4) break; if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error("invalid zstd frame magic"); offset += 4; if (offset === buffer.length) break; const descriptor = buffer.readUInt8(offset); offset += 1; const contentSizeFlag = descriptor >>> 6; const singleSegment = (descriptor & 32) !== 0; const checksum = (descriptor & 4) !== 0; const dictionaryFlag = descriptor & 3; const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag; const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeFlag); const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes; if (buffer.length - offset < remainingHeaderBytes) break; offset += remainingHeaderBytes; for (;;) { if (buffer.length - offset < 3) return { frames, tornStart: start }; const blockHeader = buffer.readUIntLE(offset, 3); offset += 3; const lastBlock = (blockHeader & 1) !== 0; const blockType = (blockHeader >>> 1) & 3; const blockSize = blockHeader >>> 3; if (blockType === 3) throw new Error("reserved zstd block type"); const payloadBytes = blockType === 1 ? 1 : blockSize; if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }; offset += payloadBytes; if (lastBlock) break; } if (checksum) { if (buffer.length - offset < 4) return { frames, tornStart: start }; offset += 4; } frames.push({ start, end: offset }); } return { frames, tornStart: -1 }; } /** 多帧 zstd 解压会话日志 → 完整 JSONL 文本(Node 内置 zstdDecompressSync 逐帧) */ function decompressSessionLog(buffer) { const { frames } = scanZstdFrames(buffer); const parts = []; for (const { start, end } of frames) parts.push(zstdDecompressSync(buffer.subarray(start, end))); return Buffer.concat(parts).toString("utf8"); } /** 扫描 sessions 目录下插件创建的 dsh_* 会话(MCN 工作台 AI会话 列表数据源) */ function listMcNSessions(ctx) { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return []; const out = []; for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) { if (!/^dsh_/.test(s.name)) continue; const log = join(gdir, s.name, "session.jsonl.zstd"); if (!existsSync(log)) continue; const mtime = statSync(log).mtimeMs; let status = "idle"; try { const a = ctx.agents.get(s.name); if (a !== undefined && a.status === "running") status = "running"; } catch (e) { /* agent 查询失败按 idle */ } let group = "其他"; if (/^dsh_rewrite-\d+$/.test(s.name) || /^dsh_review-/.test(s.name)) group = "创作任务"; else if (/^dsh_(video-analyze-\d+|persona-\d+|acctana-\d+|import-|refresh-|external-import)$/.test(s.name)) group = "解析任务"; else if (/^dsh_storyboard-/.test(s.name)) group = "分镜"; else if (/^dsh_schedule-/.test(s.name)) group = "计划任务"; else if (/^dsh_benchmark-/.test(s.name)) group = "对标创作"; out.push({ id: s.name, group, status, mtime, updated: new Date(mtime).toISOString() }); } } out.sort((a, b) => b.mtime - a.mtime); return out; } /** 解析会话事件 content → 纯文本 */ function extractSessionText(content) { if (!content) return ""; if (typeof content === "string") return content; if (Array.isArray(content)) { return content.map((b) => { if (typeof b === "string") return b; if (b && b.type === "text") return b.text || ""; if (b && b.text) return b.text; return ""; }).filter(Boolean).join("\n"); } return ""; } /** 读取会话对话消息(user/assistant/tool 摘要),live 写入中容错 */ function readSessionMessages(id) { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (!existsSync(sessionsRoot)) return null; let file = null; for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const p = join(sessionsRoot, grp.name, id, "session.jsonl.zstd"); if (existsSync(p)) { file = p; break; } } if (!file) return null; let buf; try { buf = readFileSync(file); } catch (e) { return null; } // 大文件防护:超 8MB 只读尾部(最近帧) if (buf.length > 8 * 1024 * 1024) buf = buf.subarray(buf.length - 8 * 1024 * 1024); let text; try { text = decompressSessionLog(buf); } catch (e) { return { error: "会话日志解压失败(可能正在写入中,稍后再试)" }; } const messages = []; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; let ev; try { ev = JSON.parse(line); } catch (e) { continue; } if (ev.type === "user/message") { const t = extractSessionText(ev.data && ev.data.content); if (t) messages.push({ role: "user", text: t }); } else if (ev.type === "assistant/message") { const t = extractSessionText(ev.data && ev.data.content); if (t) messages.push({ role: "assistant", text: t }); } else if (ev.type === "tool/call") { const d = ev.data || {}; const args = String(d.arguments || "").slice(0, 300); messages.push({ role: "tool", name: d.name || "", args }); } } // 限制返回消息数(最近 200 条) if (messages.length > 200) messages.splice(0, messages.length - 200); return { messages, count: messages.length }; } //#endregion //#endregion //#region 外部账号导入提交(保留在宿主:依赖会话/任务状态) async function stageExternalImport(ctx, body) { const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : ""; const acc = body.account && typeof body.account === "object" ? body.account : null; const accName = acc && typeof acc.name === "string" && acc.name.trim() ? acc.name.trim() : ""; const accDid = acc && typeof acc.douyinId === "string" && acc.douyinId.trim() ? acc.douyinId.trim() : ""; const accUrl = acc && typeof acc.url === "string" && acc.url.trim() ? acc.url.trim() : ""; // 周榜赛道(热门账号周榜 category):提交时携带,写库 track 直接采用(按 SKILL.md 29 赛道枚举归一化) const accCat = acc && typeof acc.category === "string" && acc.category.trim() ? acc.category.trim() : ""; const hasAccount = !!(accName || accDid || accUrl); if (!dir && !hasAccount) throw new Error("缺少参数:请提供本地文件夹路径(dir)或抖音账号信息(account.name / account.douyinId / account.url)"); if (dir && !existsSync(dir)) throw new Error(`目录不存在: ${dir}`); // 每任务独立会话(key = 目录 hash 或账号信息 hash),不同任务可并发;同 key 60s 防抖保护 const key = dir ? "dir:" + hashKey(dir) : "acc:" + hashKey(accName + "|" + accDid + "|" + accUrl); const sessionId = IMPORT_PREFIX + key; // 公共:接口调用方式说明(本会话无 HTTP 工具,用 pwsh 调本地接口) const httpHint = `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n` + `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。\n`; let prompt; if (dir) { prompt = `用户通过「抖音账号」页面导入了外部账号数据文件夹:${dir}\n` + `请用文件工具(read/glob 等)读取并分析该目录下文件的结构(账号信息.json / 短视频表格.xlsx / *拆解分析.md / 账号设定.md / 账号数据分析.md 等)。\n` + `如果文件字段/格式与标准结构有出入,请归一化为标准结构(字段名、类型、标签拆分等)。\n` + `「定位/内容」(content)从 *账号设定.md 的「账号核心定位」章节提取,转纯文本段落(键名:值,逐行,去掉表格样式);**该字段只在提炼账号设定后才有,提取不到则留空,禁止用简介(bio)填充**。详见插件 docs/外部账号导入处理规范.md。\n` + `「赛道」(track)从 *账号设定.md「账号核心定位」的「赛道」键提取,取第一个主赛道(" / " 分隔取首段),如「生活vlog赛道」。\n` + httpHint + `先调用 POST /mcn/api/external/preview {"dir":"<目录路径>"} 获取导入差异(新增/更新/已存在),向用户展示后确认是否导入。\n` + `用户确认后调用 /mcn/api/external/import 接口完成导入,推荐使用 data 模式:` + `POST {"data":{"accounts":[{account_name,douyin_id,sec_uid,followers,total_likes,works_count,ip_location,location,age,bio}],` + `"videos":[{account_name,aweme_id,video_title,video_url,like_count,like_display,comment_count,share_count,collect_count,play_count,duration,publish_time,tags}],` + `"analyses":[{type:"persona"|"account"|"video",account_name,video_aweme_id,content,summary}]}}(脚本层会自动去重)。\n` + `若数据结构与标准完全一致,也可用 POST {"dir":"<目录路径>"} 让脚本直接扫描解析。`; } else { // 网页获取模式:AI 判断 → mcn-dou-analysis 技能浏览器采集 → 写库 const accInfo = [ accName ? `账号名称:${accName}` : "", accDid ? `抖音号:${accDid}` : "", accUrl ? `抖音账号地址:${accUrl}` : "", accCat ? `热门账号周榜赛道:${accCat}` : "", ].filter(Boolean).join(";"); prompt = `用户通过「抖音账号」页面提交了账号信息:${accInfo}\n` + (accCat ? `该账号来自热门账号周榜,周榜赛道(category)为「${accCat}」。写入数据库时 accounts.track 直接采用该赛道,并按技能 SKILL.md「赛道标准枚举(29个)」归一化(不带「赛道」后缀;如「化妆美容」→「美妆」);此赛道为周榜权威分类,无需自行推断。\n` : "") + `请先判断处理方式:\n` + `1) 若用户提供的是本地文件夹路径(以盘符或 / 开头)→ 按「本地文件夹导入」流程处理(读取目录文件结构 → preview → 用户确认 → import)。\n` + `2) 若提供的是抖音账号信息(名称/抖音号/账号地址)→ 调用「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)通过抖音网页获取账号数据:\n` + ` - 用 browser-harness 打开抖音网页版搜索该账号(优先用抖音号,其次账号地址/名称),进入达人主页;\n` + ` - 提取账号信息:昵称、抖音号、抖音地址、粉丝数、获赞数、作品数、简介等;\n` + ` - 滚动加载视频列表(间隔1~3秒,收集≥30条),保存 13 列 Excel 到「{工作空间}/解析任务/{达人昵称}/短视频表格.xlsx」(工作空间 = D:\\dshworkspace,产出路径以技能 references/路径配置.md 为准);\n` + ` - 将账号信息与视频列表数据**写入数据库**:调用 POST /mcn/api/external/import 的 data 模式(见下方格式),账号信息存 hot_accounts(account_type 用 "external"),视频存 account_videos(account_name 关联账号);\n` + ` - 视频列表不足时按技能规则处理(解析失败补充下一位等);\n` + chromeHint(IMPORT_PREFIX + key) + httpHint + `写入数据库时调用:POST {"data":{"accounts":[{account_name,douyin_id,sec_uid,followers,total_likes,works_count,ip_location,location,age,bio}],` + `"videos":[{account_name,aweme_id,video_title,video_url,like_count,like_display,comment_count,share_count,collect_count,play_count,duration,publish_time,tags}]}}(脚本层自动去重/更新)。\n` + `完成后向用户汇报:账号信息、视频数量、保存路径、数据库写入结果。`; } // 每任务独立会话(key = 目录 hash 或账号信息 hash),不同任务可并发;同 key 60s 防抖保护 const { agent } = await getOrCreateImportAgent(ctx, key); const lastSubmit = importTasks.get(key) || 0; if (agent.status === "running" || Date.now() - lastSubmit < 60000) { throw new Error("该导入任务正在执行中(不同账号/目录可同时导入),请稍后再试"); } importTasks.set(key, Date.now()); agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); return { ok: true, dir: dir || null, account: hasAccount ? { name: accName, douyinId: accDid, url: accUrl } : null }; } //#endregion //#region HTTP 路由 function apply(ctx) { const server = ctx.get("webServer"); if (!server) { console.warn("[dsh-plugin-mcn] webServer 服务不可用,跳过路由注册"); return; } // 启动时尝试预加载(失败不阻塞,查询时会懒加载兜底) ensureData().then((r) => { console.log(`[dsh-plugin-mcn] 数据就绪: cached=${r.cached} count=${r.count}`); }).catch((e) => { console.log(`[dsh-plugin-mcn] 启动预加载跳过(查询时懒加载兜底): ${e.message}`); }); // 启动修复:确保「视频解析」「解析任务」工作区存在/改名(幂等),并把磁盘上已有会话重新归组 setTimeout(() => { Promise.all([ensureVideoAnalyzeWorkspace(ctx), ensurePersonaWorkspace(ctx)]) .catch((e) => console.warn(`[dsh-plugin-mcn] 工作区初始化失败: ${e.message}`)) .finally(() => { repairWorkspaceSessions(ctx).catch((e) => console.warn(`[dsh-plugin-mcn] 会话归组修复失败: ${e.message}`)); // 恢复改写会话到 live(UI 会话列表可见 + 未完成任务继续执行) resumeMcNSessions(ctx).catch((e) => console.warn(`[dsh-plugin-mcn] 改写会话恢复失败: ${e.message}`)); // 未分组会话超限清理 + 已完成任务会话清理(释放 MCP)+ 超过 24h 的 MCN 会话记录自动删除 cleanupUngroupedSessions(ctx).catch(() => {}); cleanupCompletedTaskSessions(ctx).catch(() => {}); cleanupOldMcNSessions(ctx).catch(() => {}); }); }, 3000); // 未分组会话定期清理(每 5 分钟兜底,防长时间运行累积) const cleanupTimer = setInterval(() => { cleanupUngroupedSessions(ctx).catch(() => {}); cleanupCompletedTaskSessions(ctx).catch(() => {}); cleanupOldMcNSessions(ctx).catch(() => {}); }, 5 * 60 * 1000); cleanupTimer.unref?.(); server.register({ path: "/mcn/api/accounts", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); // 不自动从 MCP 拉取账号数据(除非开发人员明确要求点击「拉取」按钮);空库返回空列表 const result = queryAccounts(q); json(res, 200, { ok: true, ...result }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 账号数据分析信息(账号详情页「数据分析」tab,V1.0 同步):account_analysis 最新一条 markdown 内容(来源:{名}账号数据分析.md 导入) server.register({ path: "/mcn/api/account/analysis", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const accountId = Number(q.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const d = initDb(); const row = d.prepare( `SELECT content_json AS content, analysis_time FROM account_analysis WHERE account_id=? ORDER BY id DESC LIMIT 1` ).get(accountId); json(res, 200, { ok: true, content: row ? row.content : null, analysisTime: row ? row.analysis_time : null, }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号数据分析查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 账号收藏切换:{accountId, favorite: 0|1}(收藏的账号在列表中排最前,按粉丝数降序) server.register({ path: "/mcn/api/account/favorite", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const accountId = Number(body.accountId); const favorite = body.favorite ? 1 : 0; if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const d = initDb(); const r = d.prepare(`UPDATE hot_accounts SET favorite=? WHERE id=?`).run(favorite, accountId); if (r.changes === 0) { json(res, 404, { ok: false, error: "账号不存在" }); return; } json(res, 200, { ok: true, favorite }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号收藏失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 账号视频分页(账号详情页):GET ?accountId=&page=&pageSize= → { account, total, items } server.register({ path: "/mcn/api/account/videos", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const accountId = Number(q.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const result = queryAccountVideos(accountId, Number(q.page) || 1, Number(q.pageSize) || 20); json(res, 200, { ok: true, ...result }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 账号设定信息(账号详情页):account_persona 最新一条 markdown 内容(来源:账号设定.md 导入) server.register({ path: "/mcn/api/account/persona", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const accountId = Number(q.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const d = initDb(); const row = d.prepare( `SELECT content_json AS content, analysis_time FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1` ).get(accountId); json(res, 200, { ok: true, content: row ? row.content : null, analysisTime: row ? row.analysis_time : null, }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号设定信息查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 全部视频列表(AI写作提升页):跨账号分页,排序 = 已解析优先 → 点赞数降序 server.register({ path: "/mcn/api/videos", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const page = Math.max(1, Number(q.page) || 1); const pageSize = Math.max(1, Math.min(Number(q.pageSize) || 20, 100)); const search = String(q.search || "").trim(); const parsed = String(q.parsed || "").trim(); // V1.0 同步:account=账号级预筛(AI写脚本页按账号进入;此时放开爆款窗,展示该账号全部视频);days=发布时间窗口(天) const account = Number(q.account) || 0; const days = Number(q.days) || 0; const d = initDb(); const where = []; const params = []; if (account > 0) { // 账号级:全部视频(关注账号优先/按账号预筛,与爆款窗语义区分) where.push("v.account_id = ?"); params.push(account); } else { // 数据规则:每个账号取「点赞 30W 以上 且 评论 1W 以上」的视频中「近期(发布时间倒序)」的 10 条 where.push("v.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY publish_time DESC, id DESC) rn FROM account_videos WHERE like_count >= 300000 AND comment_count >= 10000) t WHERE rn <= 10)"); } if (search) { where.push("(v.video_title LIKE ? OR h.account_name LIKE ?)"); params.push(`%${search}%`, `%${search}%`); } if (days > 0) { where.push("substr(v.publish_time, 1, 10) >= ?"); params.push(new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)); } // 解析状态筛选:1=已解析,0=未解析,空=全部 if (parsed === "1" || parsed === "0") { const parsedCond = "(v.id IN (SELECT video_id FROM account_video_source) OR v.id IN (SELECT video_id FROM account_video_analysis))"; where.push(parsed === "1" ? parsedCond : `NOT (${parsedCond})`); } const w = where.length ? "WHERE " + where.join(" AND ") : ""; const total = d.prepare(`SELECT COUNT(*) c FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id ${w}`).get(...params).c; const items = d.prepare(` SELECT v.*, h.account_name, SUM(v.like_count) OVER (PARTITION BY v.account_id) AS acc_likes, (SELECT COUNT(*) FROM account_video_source s WHERE s.video_id = v.id OR s.aweme_id = v.aweme_id) AS has_source, (SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id = v.id OR a.aweme_id = v.aweme_id) AS has_analysis, (SELECT COUNT(*) FROM rewrite_log r WHERE r.video_id = v.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count, CASE WHEN v.topic IS NOT NULL AND v.topic <> '' THEN 1 ELSE 0 END AS has_topic, CASE WHEN (h.content IS NOT NULL AND h.content <> '') OR EXISTS(SELECT 1 FROM account_persona p WHERE p.account_id = v.account_id) THEN 1 ELSE 0 END AS has_content FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id ${w} ORDER BY acc_likes DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ? `).all(...params, pageSize, (page - 1) * pageSize); // 改写执行中标记:rewrite_log status=running 且对应 AI 改写会话存活(网页/聊天窗口触发的改写任务都实时反映到列表) const runningByVideo = new Map(); for (const log of d.prepare(`SELECT id, video_id FROM rewrite_log WHERE status='running'`).all()) { const a = ctx.agents.get(REWRITE_PREFIX + log.id); if (a !== undefined && a.status === "running") runningByVideo.set(log.video_id, true); } for (const it of items) it.rewrite_running = !!runningByVideo.get(it.id); // 解析中标记:存在解析会话且 session.jsonl.zstd 最近 3 分钟内活跃(会话活跃=任务执行中; // 用会话目录而非内存注册表,服务重启后恢复的解析任务仍能正确显示「解析中」) const analyzingByVideo = new Map(); try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (existsSync(sessionsRoot)) { const nowTs = Date.now(); for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const s of readdirSync(gdir, { withFileTypes: true }).filter((x) => x.isDirectory())) { const m = /^dsh_video-analyze-(\d+)$/.exec(s.name); if (!m) continue; const log = join(gdir, s.name, "session.jsonl.zstd"); if (!existsSync(log)) continue; if (nowTs - statSync(log).mtimeMs <= 3 * 60 * 1000) analyzingByVideo.set(Number(m[1]), true); } } } } catch (e) { /* 解析中标记失败不影响列表 */ } for (const it of items) it.analyzing = !!analyzingByVideo.get(it.id) && !(it.has_source > 0 || it.has_analysis > 0); json(res, 200, { ok: true, total, page, pageSize, items }); } catch (e) { console.error(`[dsh-plugin-mcn] 全部视频查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 视频改写提交:{videoId}。要求账号设定(hot_accounts.content)与视频选题(topic)都存在;并发上限 8;同一视频可多次改写(一对多) server.register({ path: "/mcn/api/video/rewrite", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const videoId = Number(body.videoId); if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; } if (runningRewriteCount(ctx) >= REWRITE_MAX_CONCURRENT) { json(res, 409, { ok: false, error: `改写任务已达并发上限(${REWRITE_MAX_CONCURRENT} 条),请等待部分完成后再提交` }); return; } const d = initDb(); const v = d.prepare(`SELECT * FROM account_videos WHERE id=?`).get(videoId); if (!v) { json(res, 404, { ok: false, error: "视频不存在" }); return; } const acc = v.account_id ? d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(v.account_id) : null; // 账号设定:优先完整人设卡(account_persona.content_json,去掉末尾与脚本创作无关的两段「当前需解决的问题」「人设底线」), // 无则回退「定位/内容」摘要(hot_accounts.content) let setting = ""; if (v.account_id) { const persona = d.prepare(`SELECT content_json FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(v.account_id); if (persona && typeof persona.content_json === "string" && persona.content_json.trim()) { setting = trimPersonaForRewrite(persona.content_json); } } if (!setting && acc && typeof acc.content === "string") setting = acc.content.trim(); const topic = v.topic && typeof v.topic === "string" ? v.topic.trim() : ""; if (!setting || !topic) { const missing = [!setting ? "账号设定" : null, !topic ? "视频选题" : null].filter(Boolean).join("、"); json(res, 400, { ok: false, error: `该视频缺少${missing},无法改写(需先完成账号设定提炼与视频选题解析)` }); return; } const now = new Date().toISOString(); // 自增 id 跳过已归档会话 id(防删除后 id 复用继承旧归档状态 → 侧边栏不显示) const logId = nextTaskIdAvoidingArchived(d, ctx, REWRITE_PREFIX, "rewrite_log"); const ins = d.prepare(`INSERT INTO rewrite_log (id, video_id, aweme_id, account_id, topic, gen_method, status, created_time, updated_time) VALUES (?, ?, ?, ?, ?, 'ref', 'pending', ?, ?)`); const r = ins.run(logId, videoId, v.aweme_id || null, v.account_id || null, topic, now, now); rewriteTasks.set(logId, Date.now()); const { agent } = await getOrCreateRewriteAgent(ctx, logId); if (agent.status === "running") { json(res, 409, { ok: false, error: "该视频的改写任务正在执行中,请等待完成后再试" }); return; } const prompt = `用户请求改写爆款视频「${v.video_title || v.aweme_id || videoId}」的故事脚本。\n` + `请调用「short-video-script」技能(短视频脚本创作技能,已全局注册,路径 ~/.dsh/skills/short-video-script/)的完整流程,基于以下「账号设定」与「视频选题」改写一条全新的 **S9 故事脚本**(角色的行为、对话和情绪推进,不含分镜):\n` + `【账号设定】\n${setting}\n` + `【视频选题】\n${topic}\n` + `要求:\n` + `1. 严格按技能流程生成 S9 故事脚本,最终只输出脚本正文;\n` + `2. 保留原视频的爆款基因(开场钩子/节奏/情绪),但在剧情、台词、场景上有明显改写,不照抄原文;\n` + `3. 完成后调用 POST /mcn/api/rewrite/save 保存:body {"id": ${logId}, "script": ""}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n` + `4. 保存方式(重要,避免中文编码问题):先把脚本全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } })); d.prepare(`UPDATE rewrite_log SET status='running', updated_time=? WHERE id=?`).run(now, logId); json(res, 200, { ok: true, id: logId, message: "已提交改写,AI 正在生成 S9 故事脚本(当前并发 " + runningRewriteCount(ctx) + "/" + REWRITE_MAX_CONCURRENT + ")" }); } catch (e) { console.error(`[dsh-plugin-mcn] 视频改写提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 改写结果保存(AI 会话调用):{id, script} server.register({ path: "/mcn/api/rewrite/save", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const script = typeof body.script === "string" && body.script.trim() ? body.script.trim() : ""; if (!script) { json(res, 400, { ok: false, error: "脚本内容为空" }); return; } const d = initDb(); const rec = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(id); if (!rec) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; } d.prepare(`UPDATE rewrite_log SET script_text=?, title=?, status='done', updated_time=? WHERE id=?`).run(script, extractScriptTitle(script), new Date().toISOString(), id); statsCache = { ts: 0, data: null }; // 失效统计缓存(改写脚本计数变化) // 改写任务完成:不立即归档(会话保留在侧边栏,与解析任务一致), // 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理 cleanupUngroupedSessions(ctx).catch(() => {}); json(res, 200, { ok: true }); } catch (e) { console.error(`[dsh-plugin-mcn] 改写结果保存失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 改写脚本删除:{id}(rewrite_log 记录,删除后弹窗/列表同步刷新) server.register({ path: "/mcn/api/rewrite/delete", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const d = initDb(); const r = d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(id); if (r.changes === 0) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; } // 联动删除该脚本的 ai/compare 复盘(source 为视频级,保留复用) try { d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(id); } catch (e) { /* 联动删除失败不影响主删除 */ } // 联动删除该脚本的分镜提示词(storyboard_log 按 rewrite_id 关联)+ 对应会话目录 const sbIds = []; try { sbIds.push(...d.prepare(`SELECT id FROM storyboard_log WHERE rewrite_id=?`).all(id).map((x) => x.id)); d.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(id); } catch (e) { /* 分镜联动删除失败不影响主删除 */ } // 同步删除对应会话目录(防止 logId 被 SQLite 复用后旧会话上下文污染新任务) try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (existsSync(sessionsRoot)) { for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); const sids = [REWRITE_PREFIX + id, reviewSessionId("ai", id), reviewSessionId("compare", id)]; for (const sbId of sbIds) sids.push(STORYBOARD_PREFIX + sbId); for (const sid of sids) { const sdir = join(gdir, sid); if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true }); } } } } catch (e) { /* 会话清理失败不影响删除结果 */ } json(res, 200, { ok: true }); } catch (e) { console.error(`[dsh-plugin-mcn] 改写脚本删除失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 视频源脚本与改写记录:?videoId=(弹窗对比用:左源脚本 + 右改写脚本列表) server.register({ path: "/mcn/api/video/script", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const videoId = Number(q.videoId); if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; } const d = initDb(); let source = null; const src = d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId); if (src && src.content_json) { try { const j = JSON.parse(src.content_json); const c = String(j.content || j.script || src.content_json); const idx = c.indexOf("# 视频脚本"); source = idx >= 0 ? c.slice(idx) : c; } catch (e) { source = src.content_json; } } const rewrites = d.prepare(`SELECT id, script_text, created_time FROM rewrite_log WHERE video_id=? AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId); json(res, 200, { ok: true, source, rewrites }); } catch (e) { console.error(`[dsh-plugin-mcn] 视频脚本查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 改写历史查询:?videoId=(该视频的改写记录;AI写作复盘 列表数据源,附带三类复盘结果摘要) server.register({ path: "/mcn/api/rewrite/list", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const videoId = Number(q.videoId); const d = initDb(); const rows = videoId > 0 ? d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log WHERE video_id=? ORDER BY id DESC`).all(videoId) : d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log ORDER BY id DESC LIMIT 100`).all(); // 复盘结果摘要联表:ai/compare 按 rewrite_id;source 按 video_id(视频级一份,避免重复复盘源脚本) const revCols = `id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time`; const aiRev = new Map(); const cmpRev = new Map(); const srcRev = new Map(); const ids = rows.map((r) => r.id); const vidIds = [...new Set(rows.map((r) => r.video_id).filter((v) => v > 0))]; if (ids.length > 0) { for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type IN ('ai','compare') AND rewrite_id IN (${ids.map(() => "?").join(",")}) ORDER BY id DESC`).all(...ids)) { if (rv.type === "ai") { if (!aiRev.has(rv.rewrite_id)) aiRev.set(rv.rewrite_id, rv); } else if (!cmpRev.has(rv.rewrite_id)) cmpRev.set(rv.rewrite_id, rv); } } if (vidIds.length > 0) { for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type='source' AND video_id IN (${vidIds.map(() => "?").join(",")}) ORDER BY id DESC`).all(...vidIds)) { if (!srcRev.has(rv.video_id)) srcRev.set(rv.video_id, rv); } } // 复盘运行中标记:以 script_review.status='running' 行为准(任务真在执行中; // 不用内存 agent 状态——归档/完成后 agent.status 可能未及时更新导致误报) const runningKeys = new Set(); for (const rr of d.prepare(`SELECT type, rewrite_id, video_id, status FROM script_review WHERE status='running'`).all()) { const key = rr.type === "source" ? "source:" + rr.video_id : rr.type + ":" + rr.rewrite_id; runningKeys.add(key); } for (const it of rows) { it.review_ai = aiRev.get(it.id) || null; it.review_compare = cmpRev.get(it.id) || null; it.review_source = (it.video_id && srcRev.get(it.video_id)) || null; it.review_running = runningKeys.has("ai:" + it.id) || runningKeys.has("compare:" + it.id) || (it.video_id && runningKeys.has("source:" + it.video_id)); } json(res, 200, { ok: true, items: rows }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 复盘提交:{logId, type}(type: ai=AI脚本复盘 / source=原视频脚本复盘 / compare=对比分析) // 三份独立存储:ai/compare 按 rewrite_id 去重;source 按 video_id 去重(同一视频多版脚本只复盘一次源脚本) server.register({ path: "/mcn/api/review/start", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const logId = Number(body.logId); const type = String(body.type || ""); if (!Number.isFinite(logId) || logId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 logId 参数" }); return; } if (!["ai", "source", "compare"].includes(type)) { json(res, 400, { ok: false, error: "缺少有效的 type 参数(ai/source/compare)" }); return; } if (runningReviewCount(ctx) >= REVIEW_MAX_CONCURRENT) { json(res, 409, { ok: false, error: `复盘任务已达并发上限(${REVIEW_MAX_CONCURRENT} 条),请等待部分完成后再提交` }); return; } const d = initDb(); const rw = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(logId); if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; } const script = rw.script_text && String(rw.script_text).trim() ? String(rw.script_text).trim() : ""; if (!script) { json(res, 400, { ok: false, error: "该 AI 脚本没有内容,无法复盘" }); return; } const videoId = rw.video_id || 0; const now = new Date().toISOString(); const video = videoId ? d.prepare(`SELECT video_title FROM account_videos WHERE id=?`).get(videoId) : null; const title = (video && video.video_title) || rw.aweme_id || logId; // 源脚本正文提取(复用 /video/script 逻辑:优先 # 视频脚本 新格式) const readSource = () => { const src = videoId ? d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId) : null; if (!src || !src.content_json) return ""; try { const j = JSON.parse(src.content_json); const c = String(j.content || j.script || src.content_json); const idx = c.indexOf("# 视频脚本"); return idx >= 0 ? c.slice(idx) : c; } catch (e) { return src.content_json; } }; // 复盘会话已存在且正在执行 → 409(防重复提交) const sessionId = reviewSessionId(type, type === "source" ? videoId : logId); const liveAgent = ctx.agents.get(sessionId); if (liveAgent !== undefined && liveAgent.status === "running") { json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" }); return; } // 已存在 done 结果 → 409 + 返回已有(避免重复复盘) const doneKey = type === "source" ? d.prepare(`SELECT id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId) : d.prepare(`SELECT id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time FROM script_review WHERE type=? AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(type, logId); if (doneKey) { json(res, 409, { ok: false, error: type === "source" ? "该视频源脚本已复盘,直接查看即可(避免重复复盘)" : "该脚本已复盘,直接查看即可(避免重复复盘)", review: doneKey }); return; } const runKey = type === "source" ? d.prepare(`SELECT id FROM script_review WHERE type='source' AND video_id=? AND status='running' ORDER BY id DESC LIMIT 1`).get(videoId) : d.prepare(`SELECT id FROM script_review WHERE type=? AND rewrite_id=? AND status='running' ORDER BY id DESC LIMIT 1`).get(type, logId); if (runKey) { json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" }); return; } let reviewId; let prompt; if (type === "source") { if (!videoId) { json(res, 400, { ok: false, error: "该脚本没有关联视频,无法复盘源脚本" }); return; } const sourceText = readSource(); if (!sourceText) { json(res, 400, { ok: false, error: "该视频没有源脚本正文,无法复盘源脚本(需先完成视频解析)" }); return; } const ins = d.prepare(`INSERT INTO script_review (type, video_id, status, created_time, updated_time) VALUES ('source', ?, 'running', ?, ?)`).run(videoId, now, now); reviewId = Number(ins.lastInsertRowid); reviewTasks.set("source:" + videoId, Date.now()); prompt = `用户请求对视频「${title}」的原视频脚本做质量复盘。\n` + `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【独立分析模式(评分制)】:\n` + `1. 读取下方【源脚本全文】与【选题】,先判定内容类型(剧情/教程/口播),决定检查范围(剧情类查故事成立性、教程类查教程完整链,互不误用);\n` + `2. 按 10 维逐维评分(每维 100 分制,锚点 90-100 优秀/75-89 良好/60-74 及格/40-59 有问题/0-39 严重):表格6维【设定承接/框架节奏/钩子设置(全片留人链路)/叙事逻辑/极致事件/共鸣和共情】+ 追加4维【格式规范性/台词口语化/内容合规/教程完整链(仅教程类评分,不适用给 null)】;\n` + `3. 输出复盘结果后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n` + ` body {"id": ${reviewId}, "dims": {"设定承接":85,"框架节奏":80,...,"教程完整链":null}, "issues": [{"severity":"高|中|低","issue":"问题描述","locate":"定位(场/段)","suggestion":"修改建议","step":"所属环节S5-S11"}], "summary": "综合评语2-3句", "content": "完整复盘报告(markdown:各维得分+档位+依据+问题清单+建议)"}\n` + ` (total/verdict 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n` + `4. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n` + `【源脚本全文】\n${sourceText}\n` + `【选题】\n${rw.topic || ""}\n`; } else if (type === "ai") { const ins = d.prepare(`INSERT INTO script_review (type, rewrite_id, video_id, status, created_time, updated_time) VALUES ('ai', ?, ?, 'running', ?, ?)`).run(logId, videoId || null, now, now); reviewId = Number(ins.lastInsertRowid); reviewTasks.set("ai:" + logId, Date.now()); prompt = `用户请求对 AI 写脚本 #${logId}(视频「${title}」)做质量复盘。\n` + `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【独立分析模式(评分制)】:\n` + `1. 读取下方【AI脚本全文】与【选题】,先判定内容类型(剧情/教程/口播),决定检查范围(剧情类查故事成立性、教程类查教程完整链,互不误用);\n` + `2. 按 10 维逐维评分(每维 100 分制,锚点 90-100 优秀/75-89 良好/60-74 及格/40-59 有问题/0-39 严重):表格6维【设定承接/框架节奏/钩子设置(全片留人链路)/叙事逻辑/极致事件/共鸣和共情】+ 追加4维【格式规范性/台词口语化/内容合规/教程完整链(仅教程类评分,不适用给 null)】;\n` + `3. 输出复盘结果后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n` + ` body {"id": ${reviewId}, "dims": {"设定承接":85,"框架节奏":80,...,"教程完整链":null}, "issues": [{"severity":"高|中|低","issue":"问题描述","locate":"定位(场/段)","suggestion":"修改建议","step":"所属环节S5-S11"}], "summary": "综合评语2-3句", "content": "完整复盘报告(markdown:各维得分+档位+依据+问题清单+建议)"}\n` + ` (total/verdict 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n` + `4. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `5. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n` + `【AI脚本全文】\n${script}\n` + `【选题】\n${rw.topic || ""}\n`; } else { // compare:前置要求两份独立复盘已完成(source 按 video_id、ai 按 rewrite_id) if (!videoId) { json(res, 400, { ok: false, error: "该脚本没有关联视频,无法对比分析" }); return; } const aiRev = d.prepare(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(logId); const srcRev = d.prepare(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId); if (!aiRev) { json(res, 409, { ok: false, error: "请先完成该脚本的 AI 脚本复盘,再进行对比分析" }); return; } if (!srcRev) { json(res, 409, { ok: false, error: "请先完成该视频的原视频脚本复盘,再进行对比分析" }); return; } const ins = d.prepare(`INSERT INTO script_review (type, rewrite_id, video_id, status, created_time, updated_time) VALUES ('compare', ?, ?, 'running', ?, ?)`).run(logId, videoId, now, now); reviewId = Number(ins.lastInsertRowid); reviewTasks.set("compare:" + logId, Date.now()); prompt = `用户请求对视频「${title}」的 AI 写脚本 #${logId} 与源脚本做对比分析。\n` + `请调用「script-review」技能(脚本复盘技能,已全局注册,路径 ~/.dsh/skills/script-review/)的【对比分析模式(三段式)】。\n` + `两份独立复盘已存在,**无需重新分析任何脚本**,直接基于两份复盘结果做第 3 段对比:\n` + `1. 读取下方【AI脚本复盘结果】与【源脚本复盘结果】;\n` + `2. 逐维对比表:**相同的点 / AI 好的点 / AI 差的点 / 中立结论(AI优于/劣于/持平源)**,每维给依据;\n` + `3. 总分对比:AI X/100 vs 源 Y/100(取自两份复盘的 total);\n` + `4. 综合判断:AI 整体优于/劣于/各有优劣 + 建议(保留优势、补回被削弱的);\n` + `5. 输出后调用 POST /mcn/api/review/save 保存(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080):\n` + ` body {"id": ${reviewId}, "dims": {"ai": {AI 10维...}, "source": {源 10维...}}, "issues": [{"severity":"高|中|低","issue":"问题描述","suggestion":"建议"}], "summary": "综合判断1-2句", "verdict": "AI 整体优于/劣于/各有优劣源", "content": "完整三段式对比报告(markdown)"}\n` + ` (total 由服务端按 dims 等权平均自动计算,无需提交;严重度按技能规则执行)\n` + `6. 保存方式(重要,避免中文编码问题):先把 JSON 写入工作区文件(如 save_review_${reviewId}.json,用文件工具写 UTF-8,注意字符串转义),再用 curl.exe --data-binary "@save_review_${reviewId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `7. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。\n` + `【AI脚本复盘结果】\n${JSON.stringify({ dims: aiRev.dims_json, total: aiRev.total, verdict: aiRev.verdict, issues: aiRev.issues_json, summary: aiRev.summary })}\n` + `【源脚本复盘结果】\n${JSON.stringify({ dims: srcRev.dims_json, total: srcRev.total, verdict: srcRev.verdict, issues: srcRev.issues_json, summary: srcRev.summary })}\n`; } const { agent } = await getOrCreateReviewAgent(ctx, type, type === "source" ? videoId : logId); if (agent.status === "running") { json(res, 409, { ok: false, error: "该对象的复盘任务正在执行中,请等待完成后再试" }); return; } agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } })); d.prepare(`UPDATE script_review SET updated_time=? WHERE id=?`).run(now, reviewId); const typeName = type === "source" ? "原视频脚本复盘" : type === "ai" ? "AI 脚本复盘" : "对比分析"; json(res, 200, { ok: true, id: reviewId, type, message: `已提交${typeName},AI 正在执行(当前并发 ${runningReviewCount(ctx)}/${REVIEW_MAX_CONCURRENT})` }); } catch (e) { console.error(`[dsh-plugin-mcn] 复盘提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 复盘结果保存(AI 会话调用):{id, dims, issues, summary, verdict, content} // 服务端按 dims 等权平均计算 total 与"其他"聚合分;compare 模式 dims 为 {ai:{...}, source:{...}} server.register({ path: "/mcn/api/review/save", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const d = initDb(); const rec = d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id); if (!rec) { json(res, 404, { ok: false, error: "复盘记录不存在" }); return; } if (typeof body.dims !== "object" || body.dims === null) { json(res, 400, { ok: false, error: "复盘结果缺少 dims 评分数据" }); return; } // 评分计算:叙事逻辑 2 倍权重(故事逻辑是剧情类立身之本),忽略 null 不适用维 const avgOf = (obj) => { const entries = Object.entries(obj).filter(([k, v]) => typeof v === "number" && Number.isFinite(v)); if (entries.length === 0) return null; let sum = 0, wsum = 0; for (const [k, v] of entries) { const w = k === "叙事逻辑" ? 2 : 1; sum += v * w; wsum += w; } return Math.round((sum / wsum) * 10) / 10; }; const OTHER_DIMS = ["格式规范性", "台词口语化", "内容合规", "教程完整链"]; let dimsStored; let totalStored; let verdictStored = typeof body.verdict === "string" && body.verdict.trim() ? body.verdict.trim() : ""; if (rec.type === "compare") { const ai = body.dims.ai || {}; const src = body.dims.source || {}; const otherAi = avgOf(Object.fromEntries(Object.entries(ai).filter(([k]) => OTHER_DIMS.includes(k)))); const otherSrc = avgOf(Object.fromEntries(Object.entries(src).filter(([k]) => OTHER_DIMS.includes(k)))); dimsStored = JSON.stringify({ ai: { ...ai, 其他: otherAi }, source: { ...src, 其他: otherSrc } }); let totalAi = avgOf(ai); let totalSrc = avgOf(src); // 叙事逻辑 <80 → 各自总分不超过叙事逻辑分(与单复盘口径一致) const logicAi = typeof ai["叙事逻辑"] === "number" ? ai["叙事逻辑"] : null; if (logicAi !== null && logicAi < 80 && totalAi !== null) totalAi = Math.min(totalAi, logicAi); const logicSrc = typeof src["叙事逻辑"] === "number" ? src["叙事逻辑"] : null; if (logicSrc !== null && logicSrc < 80 && totalSrc !== null) totalSrc = Math.min(totalSrc, logicSrc); totalStored = JSON.stringify({ ai: totalAi, source: totalSrc }); if (!verdictStored && totalAi != null && totalSrc != null) { verdictStored = totalAi - totalSrc >= 1 ? "AI 整体优于源" : totalSrc - totalAi >= 1 ? "AI 整体劣于源" : "AI 与源各有优劣"; } } else { const other = avgOf(Object.fromEntries(Object.entries(body.dims).filter(([k]) => OTHER_DIMS.includes(k)))); dimsStored = JSON.stringify({ ...body.dims, 其他: other }); totalStored = avgOf(body.dims); // 叙事逻辑 <80 → 总分不超过叙事逻辑分(逻辑是根本,总分不能高于逻辑分) const logicScore = typeof body.dims["叙事逻辑"] === "number" ? body.dims["叙事逻辑"] : null; if (logicScore !== null && logicScore < 80 && totalStored !== null) { totalStored = Math.min(totalStored, logicScore); } if (!verdictStored && totalStored != null) { verdictStored = totalStored >= 75 ? "可交付" : totalStored >= 60 ? "需优化" : "需大改"; } } const issues = typeof body.issues === "string" ? body.issues : (body.issues ? JSON.stringify(body.issues) : null); const summary = typeof body.summary === "string" ? body.summary.trim() : ""; const content = typeof body.content === "string" ? body.content.trim() : ""; d.prepare(`UPDATE script_review SET dims_json=?, total=?, verdict=?, issues_json=?, summary=?, content=?, status='done', updated_time=? WHERE id=?`) .run(dimsStored, totalStored != null ? String(totalStored) : null, verdictStored, issues, summary, content, new Date().toISOString(), id); // 复盘任务完成:不立即归档(会话保留在侧边栏,与解析任务一致), // 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理 cleanupUngroupedSessions(ctx).catch(() => {}); json(res, 200, { ok: true }); } catch (e) { console.error(`[dsh-plugin-mcn] 复盘结果保存失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 复盘详情:?id=(完整结果,含 content 报告全文与 issues 清单) server.register({ path: "/mcn/api/review/detail", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const id = Number(q.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const d = initDb(); const rv = d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id); if (!rv) { json(res, 404, { ok: false, error: "复盘记录不存在" }); return; } json(res, 200, { ok: true, review: rv }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 复盘数据删除:{rewriteId}(删除该 AI 脚本的 ai/compare 复盘记录与对应会话; // source 为视频级共享保留,脚本本身(rewrite_log)保留) server.register({ path: "/mcn/api/review/delete", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const rewriteId = Number(body.rewriteId); if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; } const d = initDb(); const r = d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(rewriteId); // 同步删除对应复盘会话目录(防 id 复用后旧会话上下文污染) try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (existsSync(sessionsRoot)) { for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const sid of [reviewSessionId("ai", rewriteId), reviewSessionId("compare", rewriteId)]) { const sdir = join(gdir, sid); if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true }); } } } } catch (e) { /* 会话清理失败不影响删除结果 */ } json(res, 200, { ok: true, deleted: r.changes }); } catch (e) { console.error(`[dsh-plugin-mcn] 复盘数据删除失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 完整复盘三栏数据:?rewriteId=(AI脚本 #N 的 ai/source/compare 三份完整复盘, // 供「完整报告」弹窗左=源视频复盘 / 右=AI脚本复盘 / 下=对比分析) server.register({ path: "/mcn/api/review/full", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const rewriteId = Number(q.rewriteId); if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; } const d = initDb(); const rw = d.prepare(`SELECT id, video_id FROM rewrite_log WHERE id=?`).get(rewriteId); if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; } const one = (sql, ...p) => d.prepare(sql).get(...p) || null; const ai = one(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rewriteId); const cmp = one(`SELECT * FROM script_review WHERE type='compare' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rewriteId); const src = rw.video_id ? one(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`, rw.video_id) : null; json(res, 200, { ok: true, rewriteId, videoId: rw.video_id, reviews: { ai, source: src, compare: cmp } }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // AI会话列表:MCN 工作台内查看插件创建的 dsh_* 会话(扫描 sessions 目录,按类型分组) server.register({ path: "/mcn/api/sessions", exact: true, handler: async (_req, res) => { try { const items = listMcNSessions(ctx); json(res, 200, { ok: true, items }); } catch (e) { console.error(`[dsh-plugin-mcn] AI会话列表失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // AI会话详情:?id=(多帧 zstd 解压会话日志 → 对话消息 user/assistant/tool) server.register({ path: "/mcn/api/session/detail", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const id = String(q.id || ""); if (!id || !/^dsh_[\w-]+$/.test(id)) { json(res, 400, { ok: false, error: "缺少有效的会话 id" }); return; } const data = readSessionMessages(id); if (!data) { json(res, 404, { ok: false, error: "会话不存在" }); return; } json(res, 200, { ok: true, id, ...data }); } catch (e) { console.error(`[dsh-plugin-mcn] AI会话详情失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 分镜提示词生成提交:{rewriteId}(基于指定版本的 AI 改写脚本,调用 storyboard-prompt 技能生成分镜提示词) server.register({ path: "/mcn/api/storyboard/create", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const rewriteId = Number(body.rewriteId); if (!Number.isFinite(rewriteId) || rewriteId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 rewriteId 参数" }); return; } if (runningStoryboardCount(ctx) >= STORYBOARD_MAX_CONCURRENT) { json(res, 409, { ok: false, error: `分镜提示词任务已达并发上限(${STORYBOARD_MAX_CONCURRENT} 条),请等待部分完成后再提交` }); return; } const d = initDb(); const rw = d.prepare(`SELECT * FROM rewrite_log WHERE id=?`).get(rewriteId); if (!rw) { json(res, 404, { ok: false, error: "改写记录不存在" }); return; } const script = rw.script_text && String(rw.script_text).trim() ? String(rw.script_text).trim() : ""; if (!script) { json(res, 400, { ok: false, error: "该改写版本没有脚本内容,无法生成分镜提示词" }); return; } const now = new Date().toISOString(); // 自增 id 跳过已归档会话 id(防 id 复用继承旧归档状态 → 侧边栏不显示) const logId = nextTaskIdAvoidingArchived(d, ctx, STORYBOARD_PREFIX, "storyboard_log"); const ins = d.prepare(`INSERT INTO storyboard_log (id, video_id, rewrite_id, status, created_time, updated_time) VALUES (?, ?, ?, 'pending', ?, ?)`); const r = ins.run(logId, rw.video_id || null, rewriteId, now, now); storyboardTasks.set(logId, Date.now()); const { agent } = await getOrCreateStoryboardAgent(ctx, logId); if (agent.status === "running") { json(res, 409, { ok: false, error: "该视频的分镜提示词任务正在执行中,请等待完成后再试" }); return; } const video = rw.video_id ? d.prepare(`SELECT * FROM account_videos WHERE id=?`).get(rw.video_id) : null; const title = video && video.video_title ? video.video_title : (rw.aweme_id || rw.video_id || rewriteId); const prompt = `用户请求为视频「${title}」的 AI 改写脚本生成分镜提示词。\n` + `请调用「storyboard-prompt」技能(短视频分镜脚本技能,已全局注册,路径 ~/.dsh/skills/storyboard-prompt/)的 **F4 功能(生成分镜提示词:脚本→分镜提示词序列)**,基于以下「AI改写脚本」生成可直接用于 AI 生视频平台(即梦 Seedance 2.0/2.5、NanoBanana 等)的分镜提示词序列:\n` + `【AI改写脚本】\n${script}\n` + `要求:\n` + `1. 严格按技能 SKILL.md 的 **4 层结构**(制作流程第三节 3.2)生成,最终只输出分镜提示词正文;\n` + `2. **分段输出(重要)**:先把「AI改写脚本」按叙事段落/场景切分为**多段**(参考技能「长视频分段制作流程」:每段 3~6 个镜头、时长 15 秒内为宜),**每段(即一个分镜)输出一份完整独立的 4 层结构分镜提示词**;段与段之间用一行 \`---\` 分隔;\n` + `3. **段首行(重要,供前端分镜 header 展示)**:每段第一行必须是 \`段N(开始-结束秒)|段标题\` 格式(如 \`段1(0-6秒)|场1:二爸家卧室·深夜\`、\`段2(6-18秒)|场2前半:二爸家堂屋·正午\`),段标题简述该段场景/情节;\n` + `4. **每段内部结构**(按顺序,段内各层之间用空行分隔,不用 \`---\`):\n` + ` a. 主体:主体列表(置顶,按类别分行):\`人物:xxx 兄弟\`、\`场景:xxx\`、\`道具:xxx\`;\n` + ` b. 设定:以「设定」开头,逐项列出「人物」(每人——气质/状态1句)、「场景」(空间布置1句)、「光线」(1句)、「构图」(1句)、「视觉风格」(风格关键词)、「摄影质感」(英文摄影参数,用[]框起,如 [ARRI Alexa Mini LF, 35mm prime lens, f/2.8, 180 degree shutter, ISO 800, Natural color palette, realistic tones, ...]);\n` + ` c. 时长:\`视频总时长:N秒\`(放设定层之后、镜头序列之前);\n` + ` d. 镜头叙事:每个镜头一段(\`镜头1:\`、\`镜头2:\`…),段内为完整流畅的自然语言描述(含景别、运镜、主体动作、表情、台词及语气,可直接作为生视频提示词);\n` + ` e. 负面提示词:\`负面提示词:\` + 标准禁止清单(制作流程 3.3 通用项全带 + 场景勾选项):文字,字幕,水印,logo,签名,变形,多手指,少手指,肢体融合,错误手部,错误嘴型,面部扭曲,穿模,塑料人物质感,过度磨皮,蜡像感,CG感,3D渲染感,夸张表演,油腻表演,慢动作,背景音乐,BGM,配乐,廉价电视剧质感,网红短视频风格,画面噪点,人物畸形,鱼眼畸变,桶形畸变,枕形畸变,超广角拉伸,人脸边缘拉伸,微缩景观感,玩具模型感,数字锐化,过度降噪,伪HDR,纯黑死影,规则化颗粒(每段都要带);\n` + `5. 段数视脚本长度而定(脚本越长段越多),每段独立完整、不跨段引用;\n` + `6. 完成后调用 POST /mcn/api/storyboard/save 保存:body {"id": ${logId}, "script": "<分镜提示词全文>"}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n` + `7. 保存方式(重要,避免中文编码问题):先把全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `8. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } })); d.prepare(`UPDATE storyboard_log SET status='running', updated_time=? WHERE id=?`).run(now, logId); json(res, 200, { ok: true, id: logId, message: "已提交分镜提示词生成,AI 正在执行(当前并发 " + runningStoryboardCount(ctx) + "/" + STORYBOARD_MAX_CONCURRENT + ")" }); } catch (e) { console.error(`[dsh-plugin-mcn] 分镜提示词提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 分镜提示词结果保存(AI 会话调用):{id, script} server.register({ path: "/mcn/api/storyboard/save", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const script = typeof body.script === "string" && body.script.trim() ? body.script.trim() : ""; if (!script) { json(res, 400, { ok: false, error: "分镜提示词内容为空" }); return; } const d = initDb(); const rec = d.prepare(`SELECT * FROM storyboard_log WHERE id=?`).get(id); if (!rec) { json(res, 404, { ok: false, error: "分镜提示词记录不存在" }); return; } d.prepare(`UPDATE storyboard_log SET script_text=?, status='done', updated_time=? WHERE id=?`).run(script, new Date().toISOString(), id); // 任务完成:不立即归档(会话保留在侧边栏,与解析任务一致), // 由 cleanupOldMcNSessions(>24h 未活动且不在运行中 → 物理删除 + detach)统一清理 cleanupUngroupedSessions(ctx).catch(() => {}); json(res, 200, { ok: true }); } catch (e) { console.error(`[dsh-plugin-mcn] 分镜提示词保存失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 分镜提示词删除:{id}(同步删除对应会话目录,防止 rowid 复用污染) server.register({ path: "/mcn/api/storyboard/delete", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const d = initDb(); const r = d.prepare(`DELETE FROM storyboard_log WHERE id=?`).run(id); if (r.changes === 0) { json(res, 404, { ok: false, error: "分镜提示词记录不存在" }); return; } try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (existsSync(sessionsRoot)) { for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); const sid = STORYBOARD_PREFIX + id; const sdir = join(gdir, sid); if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true }); } } } catch (e) { /* 会话清理失败不影响删除结果 */ } json(res, 200, { ok: true }); } catch (e) { console.error(`[dsh-plugin-mcn] 分镜提示词删除失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 分镜提示词列表:?videoId=(弹窗展示用:该视频所有分镜提示词,含所属改写版本与生成状态) server.register({ path: "/mcn/api/storyboard/list", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const videoId = Number(q.videoId); const d = initDb(); const rows = videoId > 0 ? d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time FROM storyboard_log WHERE video_id=? AND status='done' AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId) : d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time FROM storyboard_log ORDER BY id DESC LIMIT 100`).all(); json(res, 200, { ok: true, items: rows }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 结合 MCP 判断账号是否内部签约账号:GET ?name= 或 ?douyinId=(匹配 account_type='signed' 的内部列表) server.register({ path: "/mcn/api/account/check-internal", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const d = initDb(); const hit = isInternalAccount(d, q.name || "", q.douyinId || ""); json(res, 200, { ok: true, internal: !!hit, match: hit ? { id: hit.id, account_name: hit.account_name, douyin_id: hit.douyin_id } : null }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 抖音榜单查询(四榜:week 账号周榜 / day 账号日榜 / hot 视频热榜 / likes 点赞榜;同步 V1.0 09-03~09-04 语义: // 新分目录结构 + 根目录旧平铺兼容、赛道白名单、默认排序键随榜、账号行 inDb 关联) server.register({ path: "/mcn/api/ranking", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const board = ["week", "day", "hot", "likes"].includes(q.board) ? q.board : "week"; const result = getRanking({ dir: resolveRankingDir(), board, date: String(q.date || ""), category: String(q.category || ""), sortBy: String(q.sortBy || ""), order: String(q.order || ""), page: Number(q.page) || 1, pageSize: Number(q.pageSize) || 20, db: initDb(), }); json(res, 200, { ok: true, board, ...result }); } catch (e) { console.error(`[dsh-plugin-mcn] 榜单查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 榜单更新状态/提交(同步 V1.0 09-03 机制:30min 防重复更新窗口,可 force 跳过;红狐 API 按次计费) // GET → { running, freshness, remainingMs, canUpdate, lastAt, lastOk, lastError } // POST {force} → 校验后创建榜单更新会话(dsh_rank-update 固定会话 followup 触发) server.register({ path: "/mcn/api/ranking/update", exact: true, handler: async (req, res) => { try { if (req.method === "GET") { const meta = healRankMeta(ctx); const freshness = rankingFreshness(resolveRankingDir()); const lastAt = Number(meta.lastAt || freshness.latestMtime || 0); const remainingMs = Math.max(0, RANK_UPDATE_COOLDOWN_MS - (Date.now() - Math.max(lastAt, freshness.latestMtime))); const running = rankTaskRunning(ctx, meta); json(res, 200, { ok: true, running, freshness: { latestMtime: freshness.latestMtime, latestDate: freshness.latestDate, files: freshness.files }, remainingMs, canUpdate: !running && remainingMs <= 0, lastAt: lastAt || null, lastOk: meta.lastOk ?? null, lastError: meta.lastError || null, }); return; } const body = await readBody(req); const meta = healRankMeta(ctx); if (rankTaskRunning(ctx, meta)) { json(res, 409, { ok: false, error: "榜单更新任务正在执行中(四榜抓取约 10-20 分钟),请等待完成后再试" }); return; } const freshness = rankingFreshness(resolveRankingDir()); const lastAt = Number(meta.lastAt || freshness.latestMtime || 0); const remainingMs = Math.max(0, RANK_UPDATE_COOLDOWN_MS - (Date.now() - Math.max(lastAt, freshness.latestMtime))); if (remainingMs > 0 && !body.force) { json(res, 409, { ok: false, error: `榜单数据 ${Math.ceil(remainingMs / 60000)} 分钟前刚更新过,${Math.ceil(remainingMs / 60000)} 分钟内重复抓取会浪费红狐 API 配额;确需强制刷新请点「仍要更新」`, remainingMs, }); return; } const dir = resolveRankingDir(); try { mkdirSync(dir, { recursive: true }); } catch (e) {} const { agent } = await getOrCreateRankAgent(ctx); if (agent.status === "running") { json(res, 409, { ok: false, error: "榜单更新会话正在执行中,请等待完成后再试" }); return; } const prompt = rankUpdatePrompt(dir, !!body.force); agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } })); writeRankMeta({ lastAt: Date.now(), running: true, lastOk: null, lastError: null }); json(res, 200, { ok: true, message: "已提交榜单更新:账号日榜(全品类 1 次)+ 账号周榜(保留 5 赛道)+ 视频热榜/点赞榜,AI 正在抓取落盘(红狐 API 按次计费,约 10-20 分钟)", }); } catch (e) { console.error(`[dsh-plugin-mcn] 榜单更新提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 提炼账号设定:调用 AI 会话(每账号独立会话 dsh_persona-{id}),使用 mcn-dou-analysis 技能功能三执行「筛选TOP6→MCP解析→生成人设卡」并写库。 // 与导入/刷新(dsh_external-import)、视频解析(dsh_video-analyze-*)完全解耦:互不阻塞,多账号可并发。 server.register({ path: "/mcn/api/account/persona/extract", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const accountId = Number(body.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const d = initDb(); const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId); if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; } const { agent } = await getOrCreatePersonaAgent(ctx, accountId); // 同账号保护:会话运行中或刚提交(60s 窗口)→ 409;不同账号互不影响 const lastSubmit = personaTasks.get(accountId) || 0; if (agent.status === "running" || Date.now() - lastSubmit < 60000) { json(res, 409, { ok: false, error: "该账号正在提炼账号设定中(约 10-20 分钟),可先处理其他账号" }); return; } personaTasks.set(accountId, Date.now()); const prompt = `用户请求为达人「${acc.account_name}」提炼账号设定(生成人设卡)。\n` + `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能三:提炼账号设定** 的完整流程执行:\n` + `1. 数据准备:检查该达人的视频数据 ` + "`{产出根目录}/{达人昵称}/短视频表格.xlsx`" + `(产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务)。Excel 不存在或数据不足时:可调用 GET /mcn/api/account/videos?accountId=${accountId}&pageSize=100 从数据库获取视频列表构建数据(或按技能功能二流程先获取视频列表);\n` + `2. 按技能 Step 3 筛选 TOP6:最近三个月 + 时长≤15分钟 + 点赞+分享降序(scripts/excel_tool.py select-top6 默认取6条,数量以技能 SKILL.md「视频筛选标准」唯一权威参数为准);\n` + `3. Step 4 解析:逐个 upload_douyin_video(share_text = "https://www.douyin.com/video/{aweme_id}")提交解析,等待 3-5 分钟后 short_video_detail 查询(content + analysis);一条条处理,禁止并行;\n` + `4. Step 5 保存:用技能 scripts/json_tool.py 保存 content.json / analysis.json 到「{产出根目录}/${acc.account_name}/视频对标/{视频文件夹}/」(视频文件夹按 SKILL.md 命名规则);\n` + `5. Step 6 生成人设卡:调用源技能(脚本创作技能 D:\\AgentSkill\\MCNVideo AI\\project\\短视频脚本创作\\V1.0\\脚本创作技能 + 女娲造人术)综合生成,**按技能 12 段结构**(账号核心定位→内容基因→类型库→情绪结构→视听风格→内容打法→内容规则→系列内容规划→差异化壁垒→红线清单→当前需解决的问题→人设底线),基准《王微斯账号设定参考》,聚焦内容创作维度、不含商业化变现分析;\n` + `6. 保存与写库:人设卡保存到「{产出根目录}/${acc.account_name}/」下(如「${acc.account_name}账号设定.md」),并调用 POST /mcn/api/external/import 的 data 模式写入数据库:{"data":{"analyses":[{"type":"persona","account_name":"${acc.account_name}","content":"人设卡全文","summary":"人设卡核心摘要"}]}}(脚本层自动去重);\n` + `7. 完善账号信息:\n` + ` a. 「定位/内容」(content):从人设卡「账号核心定位」章节提取六项(赛道/账号类型/核心受众/核心标签/内容路线/情绪锚点),转为纯文本键值段落(键名:值,逐行),写入 hot_accounts.content(账号列表「定位/内容」列);提取不到则留空,**禁止用简介(bio)填充**;\n` + ` b. 「赛道」(track):从人设卡「账号核心定位」的「赛道」键取第一个主赛道(" / " 分隔取首段),**去掉「赛道」后缀**(如「生活vlog赛道」→「生活vlog」),写入 hot_accounts.track;\n` + ` 以上两项通过 POST /mcn/api/account/update 一次写入:{"id":${accountId},"content":"提取的定位内容","track":"提取的赛道"};\n` + `8. 完成后向用户汇报:TOP6 视频、解析状态(content/analysis 是否齐备)、人设卡保存路径、数据库写入结果、定位/内容与赛道更新结果。\n` + `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n` + `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); json(res, 200, { ok: true, accountId, message: `已提交提炼「${acc.account_name}」的账号设定,AI 正在执行(约 10-20 分钟)` }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号设定提炼提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 账号数据分析任务(V1.0 同步:账号详情「数据分析」tab 空态按钮):调用 AI 会话(每账号独立 dsh_acctana-{id}), // 使用 mcn-dou-analysis 技能功能四执行「近4月最好/最差视频逐条拆解 → 8 章汇总报告」并落库(account_analysis)。 server.register({ path: "/mcn/api/account/analysis/start", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const accountId = Number(body.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } const d = initDb(); const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).get(accountId); if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; } const { agent } = await getOrCreateAcctAnalysisAgent(ctx, accountId); // 同账号保护:会话运行中或刚提交(60s 窗口)→ 409;不同账号互不影响 const lastSubmit = acctAnalysisTasks.get(accountId) || 0; if (agent.status === "running" || Date.now() - lastSubmit < 60000) { json(res, 409, { ok: false, error: "该账号的数据分析任务正在执行中(约 10-20 分钟),可先处理其他账号" }); return; } acctAnalysisTasks.set(accountId, Date.now()); const prompt = `用户请求为达人「${acc.account_name}」做账号数据分析(账号详情页「数据分析」入口触发)。\n` + `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能四:分析视频数据(账号数据分析)** 的流程执行:\n` + `1. 先读该技能 SKILL.md「功能总览」定位功能四,再按 references/feature/04_分析视频数据.md 与 references/账号数据分析方法.md 执行(若 Skill 工具无法加载,直接读取该目录下 SKILL.md);\n` + `2. 数据准备:优先读取「{产出根目录}/${acc.account_name}/短视频表格*.xlsx」(13 列标准格式,产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务);xlsx 缺失时以数据库视频数据为准(GET /mcn/api/account/videos?accountId=${accountId}&pageSize=100,可分页拉全),无需重新抓取账号信息/视频列表;\n` + `3. 样本选取:近 4 个月视频数据,每月按 点赞数+分享数 降序取最好 1 条 + 最差 1 条(共最多 8 条;某月不足 2 条则全取);\n` + `4. 两段式强制:先逐条拆解(可复用该账号已有 MCP 解析 analysis,缺解析的按规范提交补齐——复用 upload_douyin_video/short_video_detail 流程,一条条处理禁止并行)→ 拆解全部完成后再进入汇总分析(禁止跳过拆解直接写报告),按 3.1-3.6 维度产出 8 章结构报告;\n` + `5. 红线:所有分析结论必须具体到视频标题 + 数值(点赞/评论/分享/收藏等);私有数据(完播率/GMV 等)拿不到时标注「无数据」禁止推断;无 MCP 解析的视频只做数据层分析,禁止作为内容层结论论据(注明「无解析,植入方式待验证」);\n` + `6. 保存与写库:报告保存到「{产出根目录}/${acc.account_name}/${acc.account_name}账号数据分析.md」,并调用 POST /mcn/api/external/import 的 data 模式写入数据库:{"data":{"analyses":[{"type":"account","account_name":"${acc.account_name}","content":"报告全文","summary":"报告核心摘要"}]}}(脚本层自动去重);\n` + `7. 完成后向用户汇报:样本视频清单、拆解完成数、报告保存路径、数据库写入结果。\n` + `技能边界:只做账号数据分析报告,不生成/改写脚本(属「短视频工作台」技能 S1-S11 流程),不提炼账号设定,不触发账号信息/视频列表抓取流程。\n` + `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n` + `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); json(res, 200, { ok: true, accountId, message: `已提交「${acc.account_name}」的账号数据分析,AI 正在执行(约 10-20 分钟)` }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号数据分析提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 视频详情(视频分析详情页):视频信息 + 最新一条拆解分析 markdown(来源:拆解分析.md 导入) server.register({ path: "/mcn/api/video/detail", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const d = initDb(); const videoId = Number(q.videoId); let video = null; if (Number.isFinite(videoId) && videoId > 0) { video = d.prepare(` SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=? `).get(videoId); } else if (q.awemeId) { // 支持按视频ID(aweme_id,业务主键)查询 video = d.prepare(` SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.aweme_id=? `).get(String(q.awemeId)); } if (!video) { json(res, 404, { ok: false, error: "视频不存在" }); return; } const analysis = d.prepare( `SELECT content_json AS content, summary, analysis_time FROM account_video_analysis WHERE aweme_id=? OR video_id=? ORDER BY id DESC LIMIT 1` ).get(String(video.aweme_id), video.id); // MCP 解析内容(视频脚本 content + 视频拆解 analysis),从视频对标目录读取 const parse = readVideoParse(video.id); json(res, 200, { ok: true, video, analysis: analysis || null, parse }); } catch (e) { console.error(`[dsh-plugin-mcn] 视频详情查询失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 视频原始解析内容(对标创作选题素材):视频对标目录下的 原视频解析.json / content.json server.register({ path: "/mcn/api/video/source", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const videoId = Number(q.videoId); if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; } const r = readVideoSource(videoId); json(res, 200, { ok: true, content: r.found ? r.content : null, fileName: r.found ? r.fileName : null }); } catch (e) { console.error(`[dsh-plugin-mcn] 视频原始解析读取失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 批量补录原视频解析到数据库(扫描所有外部账号视频对标目录,幂等) server.register({ path: "/mcn/api/video/source/backfill", exact: true, handler: async (_req, res) => { try { const added = backfillVideoSource(); json(res, 200, { ok: true, added, message: `已补录 ${added} 条原视频解析数据` }); } catch (e) { console.error(`[dsh-plugin-mcn] 原视频解析补录失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 账号刷新:调用 AI 会话(每账号独立会话 dsh_refresh-{id}),使用 mcn-dou-analysis 技能重新获取账号数据并更新数据库。 // 不同账号可并发刷新(与导入/提炼/解析互不阻塞);同账号 60s 防抖保护。 server.register({ path: "/mcn/api/account/refresh-web", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const d = initDb(); const acc = d.prepare(`SELECT id, account_name, douyin_id, source_dir FROM hot_accounts WHERE id=?`).get(id); if (!acc) { json(res, 404, { ok: false, error: "账号不存在" }); return; } // 浏览器类任务串行(全局互斥,避免多会话抢同一调试 Chrome;后续云浏览器再放开并发) if (browserTaskRunning(ctx)) { json(res, 409, { ok: false, error: "浏览器任务(导入/刷新)正在执行中,为串行执行避免抢浏览器,请等待完成后再试" }); return; } const { agent } = await getOrCreateRefreshAgent(ctx, id); const lastSubmit = importTasks.get("refresh:" + id) || 0; if (agent.status === "running" || Date.now() - lastSubmit < 60000) { json(res, 409, { ok: false, error: "该账号正在刷新中(不同账号可同时刷新),请等待完成后再试" }); return; } importTasks.set("refresh:" + id, Date.now()); const accInfo = [ acc.account_name ? `账号名称:${acc.account_name}` : "", acc.douyin_id ? `抖音号:${acc.douyin_id}` : "", ].filter(Boolean).join(";"); const prompt = `用户请求刷新账号「${acc.account_name}」的数据(${accInfo})。\n` + `请调用「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)通过抖音网页重新获取该账号的最新数据:\n` + `1. 用 browser-harness 打开抖音网页版(优先用抖音号${acc.douyin_id ? `(${acc.douyin_id})` : ""},其次账号名称),进入达人主页;\n` + `2. 提取账号信息:昵称、抖音号、粉丝数、获赞数、作品数、简介等;**「定位/内容」(content)只在提炼账号设定后才有,网页获取不到,不要用简介填充,保持数据库原值/留空**;\n` + `3. 获取视频列表(**按数据库现有视频数决定是否滚动**):\n` + ` - 先查询该账号数据库现有视频数:GET /mcn/api/account/videos?accountId=${id}&pageSize=1 的 total 字段;\n` + ` - **若现有视频数 > 60:不滚动页面,只采集首屏可见的最新视频(约前 20-30 条)**,用于获取最新数据(新视频入库、已有视频更新数据);\n` + ` - 若现有视频数 ≤ 60:按技能 SKILL.md「视频获取数量」参数滚动加载(达到数量或加载不出更多即停);\n` + ` - 保存 13 列 Excel 到「{工作空间}/解析任务/{达人昵称}/短视频表格.xlsx」(工作空间 = D:\\dshworkspace,产出路径以技能 references/路径配置.md 为准);\n` + `4. 将最新账号信息与视频列表**更新到数据库**:调用 POST /mcn/api/external/import 的 data 模式(账号 upsert 到 hot_accounts、视频 upsert 到 account_videos,脚本层自动去重/更新,账号识别键 account_name/douyin_id 保持不变);\n` + `5. 完成后向用户汇报:账号信息变化、视频数量、保存路径、数据库更新结果。\n` + chromeHint(REFRESH_PREFIX + id) + `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n` + `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); json(res, 200, { ok: true, id, accountName: acc.account_name, message: `已提交刷新「${acc.account_name}」,AI 正在重新获取数据` }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号刷新提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 解析视频:调用 AI 会话(每视频独立会话 dsh_video-analyze-{id}),使用 mcn-dou-analysis 技能功能五对单个视频进行 MCP 解析。 // 与导入/刷新(dsh_external-import)完全解耦:互不阻塞,多个视频可同时并发解析(每个视频一个会话)。 server.register({ path: "/mcn/api/video/analyze", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const videoId = Number(body.videoId); if (!Number.isFinite(videoId) || videoId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 videoId 参数" }); return; } const d = initDb(); const v = d.prepare(` SELECT v.id, v.aweme_id, v.video_title, v.account_id, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=? `).get(videoId); if (!v) { json(res, 404, { ok: false, error: "视频不存在" }); return; } // 并发上限:最多 ${VIDEO_ANALYZE_MAX_CONCURRENT} 条同时解析 if (runningVideoAnalyzeCount(ctx) >= VIDEO_ANALYZE_MAX_CONCURRENT) { json(res, 409, { ok: false, error: `视频解析任务已达并发上限(${VIDEO_ANALYZE_MAX_CONCURRENT} 条),请等待部分完成后再提交` }); return; } const { agent } = await getOrCreateVideoAnalyzeAgent(ctx, videoId); // 同视频保护:会话运行中或刚提交(60s 窗口,防 followup 排队期重复提交)→ 409;不同视频互不影响 const lastSubmit = videoAnalyzeTasks.get(videoId) || 0; if (agent.status === "running" || Date.now() - lastSubmit < 60000) { json(res, 409, { ok: false, error: "该视频正在解析中(约 3-5 分钟),可同时提交其他视频解析" }); return; } videoAnalyzeTasks.set(videoId, Date.now()); const prompt = `用户请求解析视频「${v.video_title}」(aweme_id:${v.aweme_id},达人:${v.account_name || "-"})。\n` + `请按「mcn-dou-analysis」技能(路径 ~/.dsh/skills/mcn-dou-analysis/,已全局注册)**功能五:解析视频** 的流程执行:\n` + `1. 调用 MCP 工具 upload_douyin_video(share_text = "https://www.douyin.com/video/${v.aweme_id}")提交解析,得到 detailId;\n` + `2. 等待 3-5 分钟后调用 short_video_detail(id = detailId)查询,获取 content(分镜头表)与 analysis(结构化JSON);content 已出而 analysis 未出时稍后重试;\n` + `3. 用技能 scripts/json_tool.py 保存 content.json / analysis.json 到「{产出根目录}/${v.account_name || "未知达人"}/视频对标/{视频文件夹}/」(产出根目录以技能 references/路径配置.md 为准,当前为 D:\\dshworkspace\\解析任务;视频文件夹按 SKILL.md 命名规则);\n` + `4. 若环境可用,调用 POST /mcn/api/video/source/backfill 将解析结果补录进数据库(account_video_source + 提取选题);接口不可用则跳过;\n` + `5. 完成后向用户汇报:detailId、解析状态(content/analysis 是否齐备)、保存路径、数据库写入结果。\n` + `本会话没有 HTTP 工具,调用本地接口请用 pwsh 工具执行 PowerShell。接口基地址用 $env:DSH_WEB_URL(如为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080)。\n` + `若 pwsh 命令被沙箱拒绝(输出含 [sandbox: file access denied] 标记),请立即给同一命令加 sandbox_permissions 参数(如 danger-full-access)和 justification 参数重试——系统会弹出授权窗口,用户批准后命令才会执行。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); json(res, 200, { ok: true, videoId, message: `已提交解析「${v.video_title}」,AI 正在解析(约 3-5 分钟)` }); } catch (e) { console.error(`[dsh-plugin-mcn] 视频解析提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); server.register({ path: "/mcn/api/tracks", exact: true, handler: async (_req, res) => { try { json(res, 200, { ok: true, tracks: listTracks() }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); server.register({ path: "/mcn/api/refresh", exact: true, handler: async (_req, res) => { try { const result = await refreshData(); json(res, 200, { ok: true, ...result }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); server.register({ path: "/mcn/api/status", exact: true, handler: async (_req, res) => { try { const d = initDb(); const row = d.prepare("SELECT COUNT(*) AS c FROM hot_accounts").get(); json(res, 200, { ok: true, count: row.c }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 任务状态:供前端轮询禁用按钮(creative=创作会话、import=导入分析会话、analyze=视频解析任务列表[可并发]、persona=账号设定提炼任务列表[可并发]) server.register({ path: "/mcn/api/task/status", exact: true, handler: async (_req, res) => { try { const creative = ctx.agents.get(CREATIVE_SESSION_ID); const imports = listImportTasks(ctx); json(res, 200, { ok: true, creative: creative !== undefined ? creative.status : "idle", // 兼容字段:任一导入/刷新任务 running 即为 running import: imports.some((t) => t.status === "running") ? "running" : "idle", imports, analyze: listVideoAnalyzeTasks(ctx), persona: listPersonaTasks(ctx), accountAnalysis: listAcctAnalysisTasks(ctx), // 改写任务列表(与 persona/analyze 交互一致,AI写脚本 页面轮询运行状态) rewrite: listRewriteTasks(ctx), // 复盘任务列表(与 rewrite/persona 交互一致,AI写作复盘 页面轮询运行状态) review: listReviewTasks(ctx), }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 编辑账号信息:除 account_name 外均可更新(account_type/id/del_flag 等系统字段不可改) server.register({ path: "/mcn/api/account/update", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const id = Number(body.id); if (!Number.isFinite(id)) { json(res, 400, { ok: false, error: "缺少 id 参数" }); return; } const d = initDb(); const ex = d.prepare(`SELECT id FROM hot_accounts WHERE id=?`).get(id); if (!ex) { json(res, 404, { ok: false, error: "账号不存在" }); return; } const allowed = ["track", "content", "tags", "masterpiece", "douyin_id", "followers", "total_likes", "works_count", "ip_location", "location", "age", "bio", "is_ai"]; const sets = []; const params = []; for (const k of allowed) { if (body[k] === undefined) continue; if (k === "works_count" || k === "age") { const v = body[k]; params.push(v === null || v === "" ? null : (Number.isFinite(Number(v)) ? Number(v) : null)); } else { params.push(body[k] === null ? null : String(body[k])); } sets.push(`${k} = ?`); } if (!sets.length) { json(res, 400, { ok: false, error: "没有可更新的字段" }); return; } sets.push("updated_time = ?"); params.push(new Date().toISOString(), id); d.prepare(`UPDATE hot_accounts SET ${sets.join(", ")} WHERE id=?`).run(...params); json(res, 200, { ok: true, message: "账号信息已更新" }); } catch (e) { console.error(`[dsh-plugin-mcn] 账号更新失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 创作入口:向创作会话注入创作请求,触发短视频脚本创作技能 // 默认复用固定会话(dsh_short-video-script);body.sessionId 可指定独立会话(如对标创作 dsh_benchmark-) server.register({ path: "/mcn/api/creative", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const prompt = typeof body.prompt === "string" && body.prompt.trim() ? body.prompt.trim() : "请帮我创作一个短视频脚本(使用短视频脚本创作技能)"; const sid = typeof body.sessionId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(body.sessionId) ? body.sessionId : CREATIVE_SESSION_ID; const { agent, created } = await getOrCreateCreativeAgent(ctx, sid); // 同一功能会话执行中禁止重复触发 if (agent.status === "running") { json(res, 409, { ok: false, error: `会话 ${sid} 的创作任务仍在执行中,请等待完成后再试` }); return; } // followup:向 agent 提交用户消息,触发一轮执行(空闲驱动会启动 turn) agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" }, })); // 改写脚本计数(数据预览统计用) try { initDb().prepare(`INSERT INTO creative_log (created_time, source) VALUES (?, ?)`).run(new Date().toISOString(), sid); statsCache = { ts: 0, data: null }; // 失效统计缓存 } catch (e) { /* 计数失败不阻塞创作 */ } json(res, 200, { ok: true, sessionId: sid, created, message: created ? `已创建会话 ${sid} 并开始创作` : `已复用会话 ${sid} 并开始创作`, }); } catch (e) { console.error(`[dsh-plugin-mcn] 创作触发失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 数据预览统计:账号数/设定提炼数/视频数/视频解析数/改写脚本数(60s 内存缓存) server.register({ path: "/mcn/api/stats", exact: true, handler: async (req, res) => { try { const now = Date.now(); if (statsCache.data && now - statsCache.ts < STATS_TTL) { json(res, 200, { ok: true, ...statsCache.data, cached: true }); return; } const d = initDb(); const c = (sql) => d.prepare(sql).get().c; const accounts = c(`SELECT COUNT(*) c FROM hot_accounts WHERE del_flag=0`); // 爆款视频 = 各账号点赞 TOP10(赛道间量级差异大,不用全局阈值) const videos = c(`SELECT COUNT(*) c FROM account_videos WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY like_count DESC) rn FROM account_videos) t WHERE rn <= 10)`); const analyses = c(`SELECT COUNT(*) c FROM account_video_analysis`); // AI改写脚本 = rewrite_log 中已完成的脚本数(done 且脚本非空;creative_log 为旧「AI写作」计数,不再用于此处) const rewrites = c(`SELECT COUNT(*) c FROM rewrite_log WHERE status='done' AND script_text IS NOT NULL AND script_text <> ''`); // AI写作复盘 = 已完成的 AI 脚本复盘数(script_review type='ai') let reviews = 0; try { reviews = c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`); } catch (e) { /* 复盘统计失败不影响其他 */ } // writingScore:AI 写作评分 = 最新 20 条 AI 复盘总分的平均(script_review type='ai' 且已完成) let writingScore = null; try { const scoreRows = d.prepare(`SELECT total FROM script_review WHERE type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY updated_time DESC, id DESC LIMIT 20`).all(); const nums = scoreRows.map((r) => Number(r.total)).filter((v) => Number.isFinite(v)); if (nums.length > 0) writingScore = Math.round((nums.reduce((s, v) => s + v, 0) / nums.length) * 10) / 10; } catch (e) { /* 评分统计失败不影响其他统计 */ } statsCache = { ts: now, data: { accounts, videos, analyses, rewrites, reviews, writingScore } }; json(res, 200, { ok: true, ...statsCache.data, cached: false }); } catch (e) { console.error(`[dsh-plugin-mcn] 数据统计失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 外部账号导入:暂存文件并通知 AI 分析确认(AI 确认后调 /external/import) server.register({ path: "/mcn/api/external/stage", exact: true, handler: async (req, res) => { try { const body = await readBody(req); // 浏览器类任务串行(全局互斥):账号模式导入走浏览器采集,刷新进行中时 409;目录导入不涉及浏览器可并行 const hasAcc = !!(body.account && (body.account.name || body.account.douyinId || body.account.url)); if (hasAcc && browserTaskRunning(ctx)) { json(res, 409, { ok: false, error: "浏览器任务(导入/刷新)正在执行中,为串行执行避免抢浏览器,请等待完成后再试" }); return; } // 并发:每任务独立会话,同任务 409 保护在 stageExternalImport 内部(不同任务互不阻塞) const result = await stageExternalImport(ctx, body); json(res, 200, { ok: true, ...result, message: "已提交,AI 正在分析文件结构,请到对话中确认导入" }); } catch (e) { console.error(`[dsh-plugin-mcn] 外部导入暂存失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 导入预览:AI 确认前调用,扫描比对不写库,展示 新增/更新/已存在 server.register({ path: "/mcn/api/external/preview", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : ""; if (!dir) { json(res, 400, { ok: false, error: "缺少 dir 参数" }); return; } const result = previewExternalDir(dir); json(res, 200, { ok: true, ...result }); } catch (e) { console.error(`[dsh-plugin-mcn] 导入预览失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 外部账号导入:AI 确认后调用 // 支持两种模式:{dir} 脚本扫描解析;{data} AI 归一化后的标准结构数据 server.register({ path: "/mcn/api/external/import", exact: true, handler: async (req, res) => { try { const body = await readBody(req); if (body.data) { const d = initDb(); const result = importExternalData(d, body.data); json(res, 200, { ok: true, ...result, message: `导入完成:账号 ${result.accounts},视频 ${result.videos},分析 ${result.analyses}` }); return; } const dir = typeof body.dir === "string" && body.dir.trim() ? body.dir.trim() : ""; if (!dir) { json(res, 400, { ok: false, error: "缺少 dir 或 data 参数" }); return; } const result = importExternalDir(dir); json(res, 200, { ok: true, ...result, message: `导入完成:账号 ${result.accounts},视频 ${result.videos},分析 ${result.analyses}` }); } catch (e) { console.error(`[dsh-plugin-mcn] 外部导入失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // ---- V1.0 同步增量路由(P1:批量删除 / 原创选题脚本中心 / 原创创作提交) ---- // 列表批量删除(连坐):{kind: accounts|videos|rewrites, ids} // accounts=软删账号(del_flag=1)+ 连坐清 persona/analysis/videos 及全部关联;videos|rewrites=物理删视频行+连坐 // (rewrites 为 AI写脚本 ref 列表批量口径,V1.0 语义=删除该视频载体及其 AI 脚本/复盘/分镜) server.register({ path: "/mcn/api/batch/delete", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const kind = String(body.kind || ""); if (!["accounts", "videos", "rewrites"].includes(kind)) { json(res, 400, { ok: false, error: "kind 必须为 accounts / videos / rewrites" }); return; } const ids = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter((n) => Number.isFinite(n) && n > 0); if (ids.length === 0) { json(res, 400, { ok: false, error: "缺少有效的 ids 参数" }); return; } const d = initDb(); const result = batchDeleteCascade(d, kind, ids); // 联动清理对应任务会话目录(防 logId 复用后旧会话上下文污染新任务) try { const sessionsRoot = join(homedir(), ".dsh", "sessions"); if (existsSync(sessionsRoot) && (result.rewriteIds.length || result.storyboardIds.length)) { const sids = []; for (const id of result.rewriteIds) { sids.push(REWRITE_PREFIX + id, reviewSessionId("ai", id), reviewSessionId("compare", id)); } for (const sbId of result.storyboardIds) sids.push(STORYBOARD_PREFIX + sbId); for (const grp of readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())) { const gdir = join(sessionsRoot, grp.name); for (const sid of sids) { const sdir = join(gdir, sid); if (existsSync(sdir)) rmSync(sdir, { recursive: true, force: true }); } } } } catch (e) { /* 会话清理失败不影响删除结果 */ } json(res, 200, { ok: true, ...result, message: `已删除 ${result.deleted} 条(含关联的解析/复盘/分镜数据)` }); } catch (e) { console.error(`[dsh-plugin-mcn] 批量删除失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); // 原创选题脚本列表(无视频载体,同步 V1.0 v25:AI写脚本 custom tab 中心) server.register({ path: "/mcn/api/custom-scripts", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const result = listCustomScripts(initDb(), { page: Number(q.page) || 1, pageSize: Number(q.pageSize) || 20, search: String(q.search || ""), days: String(q.days || ""), account: String(q.account || ""), }); json(res, 200, { ok: true, ...result }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 原创选题脚本全文(查看弹窗) server.register({ path: "/mcn/api/custom-script", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const id = Number(q.id); if (!Number.isFinite(id) || id <= 0) { json(res, 400, { ok: false, error: "缺少有效的 id 参数" }); return; } const row = getCustomScriptRow(initDb(), id); if (!row) { json(res, 404, { ok: false, error: "脚本不存在或非原创选题脚本" }); return; } json(res, 200, { ok: true, script: row }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // AI写脚本 三 tab 徽标计数(参考选题/原创选题,同步 V1.0 v25) server.register({ path: "/mcn/api/rewrite-stats", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const stats = countRewriteStats(initDb(), String(q.account || "")); json(res, 200, { ok: true, ...stats }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 首页「最近 AI 脚本」(UI 复刻 V1.0):rewrite_log done 前 N 条 + 账号名 + 复盘评分(latest ai done total) server.register({ path: "/mcn/api/recent-scripts", exact: true, handler: async (req, res) => { try { const q = parseQuery(req.url); const limit = Math.max(1, Math.min(Number(q.limit) || 8, 50)); const d = initDb(); const items = d.prepare(` SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.created_time, h.account_name, (SELECT total FROM script_review WHERE rewrite_id = r.id AND type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY id DESC LIMIT 1) AS score FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id = r.account_id WHERE r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '' ORDER BY r.id DESC LIMIT ?`).all(limit); json(res, 200, { ok: true, items }); } catch (e) { json(res, 500, { ok: false, error: e.message }); } }, }); // 原创选题创作提交:{accountId, reqText?, attachText?} // 服务器补全技能声明/人设/保存回调(与 /mcn/api/video/rewrite 同模式),落 rewrite_log(video_id 空 + gen_method='new') server.register({ path: "/mcn/api/rewrite/original", exact: true, handler: async (req, res) => { try { const body = await readBody(req); const accountId = Number(body.accountId); if (!Number.isFinite(accountId) || accountId <= 0) { json(res, 400, { ok: false, error: "缺少有效的 accountId 参数" }); return; } if (runningRewriteCount(ctx) >= REWRITE_MAX_CONCURRENT) { json(res, 409, { ok: false, error: `创作任务已达并发上限(${REWRITE_MAX_CONCURRENT} 条),请等待部分完成后再提交` }); return; } const d = initDb(); const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId); if (!acc || (acc.del_flag ?? 0) === 1) { json(res, 404, { ok: false, error: "账号不存在" }); return; } // 账号设定:优先完整人设卡(剔除尾部非创作章节),无则回退「定位/内容」摘要 let setting = ""; try { const persona = d.prepare(`SELECT content_json FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId); if (persona && typeof persona.content_json === "string" && persona.content_json.trim()) { setting = trimPersonaForRewrite(persona.content_json); } } catch (e) {} if (!setting && typeof acc.content === "string" && acc.content.trim()) setting = acc.content.trim(); const reqText = String(body.reqText || "").trim(); const attachText = String(body.attachText || "").trim(); const now = new Date().toISOString(); const logId = nextTaskIdAvoidingArchived(d, ctx, REWRITE_PREFIX, "rewrite_log"); const ins = d.prepare(`INSERT INTO rewrite_log (id, video_id, aweme_id, account_id, topic, gen_method, status, created_time, updated_time) VALUES (?, NULL, NULL, ?, ?, 'new', 'pending', ?, ?)`); ins.run(logId, accountId, reqText || null, now, now); rewriteTasks.set(logId, Date.now()); const { agent } = await getOrCreateRewriteAgent(ctx, logId); if (agent.status === "running") { // 会话已存在且在跑(极少数同 id 复用场景)→ 回滚行 try { d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(logId); } catch (e) {} json(res, 409, { ok: false, error: "该创作任务正在执行中,请等待完成后再试" }); return; } const settingBlock = setting ? `【账号设定(人设卡)】\n${setting}` : `该账号暂无账号设定,请先基于账号库中已有解析数据提炼生成人设卡(按「mcn-dou-analysis」技能功能三,路径 ~/.dsh/skills/mcn-dou-analysis/),再基于账号设定与【创作需求】创作完整脚本;未填写的维度由你按人设与选题自主决策。`; const prompt = `用户请求为达人「${acc.account_name}」创作一条全新的短视频故事脚本(原创选题:无原视频载体)。\n` + `请调用「short-video-script」技能(短视频脚本创作技能,已全局注册,路径 ~/.dsh/skills/short-video-script/),按 ⚡自动模式 严格依其 SKILL.md 的 S1-S11 步骤顺序完整走创作流程(选题→框架→大纲→情绪→结构→脚本),中间步骤后台静默(各步产物在对话上下文中按序完整产出但不逐个展示),禁止跳过或合并中间步骤一步直出成品;逐条严格执行 SKILL.md「开场钩子铁律」与下方【账号设定】中的「内容规则」(如果…则…)——二者为硬约束,不得自由发挥、不得跳过。\n` + `${settingBlock}\n` + (reqText ? `【创作需求】\n${reqText}\n` : "") + (attachText ? `【附件素材】(达人上传的需求文档/资料原文,其中的诉求与约束同【创作需求】一样必须满足)\n${attachText}\n` : "") + `要求:\n` + `1. 严格按技能流程最终产出 S9 标准故事脚本(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),最终只输出脚本正文,禁止输出分镜头表格;\n` + `2. 完成后调用 POST /mcn/api/rewrite/save 保存:body {"id": ${logId}, "script": ""}(接口基地址用 $env:DSH_WEB_URL,为空则依次试 http://127.0.0.1:3081、http://127.0.0.1:3080);\n` + `3. 保存方式(重要,避免中文编码问题):先把脚本全文写入工作区文件(如 save_${logId}.json,用文件工具写 UTF-8 JSON,注意字符串转义),再用 curl.exe --data-binary "@save_${logId}.json" -H "Content-Type: application/json" 发送;或使用可用的 HTTP 工具。切勿用 PowerShell 的 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码发送,导致服务端解析失败或乱码);\n` + `4. 保存接口返回 ok 才算完成;若返回 400/500,检查 JSON 转义与编码后重试,不要用测试文本占位。`; agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } })); d.prepare(`UPDATE rewrite_log SET status='running', updated_time=? WHERE id=?`).run(now, logId); json(res, 200, { ok: true, id: logId, message: `已提交「${acc.account_name}」原创选题创作,AI 正在按 S1-S11 流程生成(当前并发 ${runningRewriteCount(ctx)}/${REWRITE_MAX_CONCURRENT})` }); } catch (e) { console.error(`[dsh-plugin-mcn] 原创选题创作提交失败: ${e.message}`); json(res, 500, { ok: false, error: e.message }); } }, }); } //#region 榜单更新任务辅助(同步 V1.0 09-03:30min 冷却 / 在跑防重 / force 覆盖 / meta 跨重启持久化) /** 榜单目录解析:环境变量 MCN_RANKING_DIR 显式覆盖(与 V1.0 dsh-data.js 同源分层),否则主工作区/抖音榜单 */ function resolveRankingDir() { const env = process.env.MCN_RANKING_DIR; if (env && existsSync(env)) return env; return join(resolveCreativeCwd(), "抖音榜单"); } function readRankMeta() { try { const m = JSON.parse(readFileSync(RANK_META_FILE, "utf8")); return m && typeof m === "object" ? m : {}; } catch (e) { return {}; } } function writeRankMeta(m) { try { mkdirSync(dirname(RANK_META_FILE), { recursive: true }); writeFileSync(RANK_META_FILE, JSON.stringify(m, null, 2), "utf8"); } catch (e) { console.warn(`[dsh-plugin-mcn] 榜单更新元数据写入失败: ${e.message}`); } } /** 运行中判定:会话 live 且 running,或 meta 标记 running 且提交未超 10 分钟(重启后会话未恢复的宽限窗) */ function rankTaskRunning(ctx, meta) { const a = ctx.agents.get(RANK_SESSION_ID); if (a !== undefined && a.status === "running") return true; return !!(meta.running && Date.now() - Number(meta.lastAt || 0) < 10 * 60 * 1000); } /** 自愈:meta 标记 running 但会话已不在跑且超宽限窗 → 依据落盘新鲜度判定完成/中断,置回空闲 */ function healRankMeta(ctx) { const meta = readRankMeta(); if (!meta.running) return meta; const a = ctx.agents.get(RANK_SESSION_ID); if (a !== undefined && a.status === "running") return meta; if (Date.now() - Number(meta.lastAt || 0) < 10 * 60 * 1000) return meta; const freshness = rankingFreshness(resolveRankingDir()); if (freshness.latestMtime >= Number(meta.lastAt || 0)) { meta.running = false; meta.lastOk = true; meta.lastError = null; } else { meta.running = false; meta.lastOk = false; meta.lastError = "任务中断或未完成(无新落盘文件,可能因 DSH 重启)"; } writeRankMeta(meta); return meta; } /** 获取(或创建)榜单更新专用会话:dsh_rank-update(固定命名复用,与创作会话同模式) */ async function getOrCreateRankAgent(ctx) { const sessionId = RANK_SESSION_ID; const existing = ctx.agents.get(sessionId); if (existing !== undefined) return { agent: existing, created: false }; const agentOptions = { provider: CREATIVE_PROVIDER, model: CREATIVE_MODEL }; const compose = await composeDefaultPreset(ctx); const cwd = resolveRankingDir(); // 会话工作目录必须存在(agent 工具以 cwd spawn,缺失会 ENOENT) try { mkdirSync(cwd, { recursive: true }); } catch (e) {} try { const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, setup: compose.setup }); return { agent: handle.agent, created: false }; } catch (e) { const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd, agentPreset: compose.agentPreset }, setup: compose.setup }); return { agent: handle.agent, created: true }; } } /** 榜单更新任务 prompt(同步 V1.0 SKILL_HINT_RANKING 语义:四榜落盘规范 + 查询幂等铁律 + 技能边界;force=覆盖刷新段) */ function rankUpdatePrompt(dir, force) { return `用户请求更新本地抖音榜单数据(热点数据页「更新榜单」按钮触发,调用范围与次数已获用户确认:账号日榜 全品类 1 次 + 账号周榜 保留 5 赛道 + 视频热榜/点赞榜,红狐 API 按次计费)。\n` + `请调用「mcn-data-insight」技能(已全局注册,路径 ~/.dsh/skills/mcn-data-insight/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下 SKILL.md)。本任务按该技能「榜单数据落盘规范」将热点数据四榜最新一期落盘到本地目录 ${dir}:\n` + `① 视频热榜 + 点赞榜:运行技能 scripts/fetch_week_ranks.py --board both(全品类单次调用,禁止逐赛道/逐日循环);\n` + `② 账号日榜:按技能 subskills/douyin-top-account 流程抓取最新一期——用其脚本 --period day 直接获取全品类(不传赛道,单次调用),写入 ${dir}/账号日榜/抖音账号日榜_{榜单日期}.json;\n` + `③ 账号周榜:按 douyin-top-account 流程抓取保留 5 赛道(生活vlog/小剧场/亲子/美食/旅行)最新一期账号周榜,逐赛道写入 ${dir}/账号周榜/抖音周榜_{赛道}_{周一}.json;\n` + `④ 产物一律落入 账号日榜/账号周榜/视频热榜/点赞榜 四个子目录,文件名保持 抖音账号日榜_/抖音周榜_ 前缀;${force ? "本次为**强制覆盖刷新**(用户明确要求):对同榜名+赛道+数据日期已存在的文件也重新抓取覆盖,不得因文件已存在而跳过。\n" : ""}` + `⑤ 查询幂等铁律:每次调用前先 ls 目标目录,同榜名+赛道+数据日期文件已存在则直接跳过对应 API 调用(红狐 API 按次计费,禁止浪费配额);\n` + `⑥ 数据日期取 API 返回的榜单对应日期(最新一期),非获取时刻;文件 JSON 结构保持红狐原始 list 数组格式(勿改字段名),与既有 抖音榜单 文件一致。\n` + `完成后汇报:四榜各写入的文件路径与数据期、缓存命中跳过项、实际 API 调用次数。技能边界:只做榜单数据抓取与落盘,不做账号分析/脚本创作等其他能力。`; } //#endregion const inject = ["webServer", "agents", "workspaceRegistry"]; export { apply, inject };