Files
storyforge/scripts/douyin-browser-capture/control_panel.mjs
2026-03-21 04:57:18 +08:00

2280 lines
97 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CAPTURE_SCRIPT = path.join(SCRIPT_DIR, "capture_and_sync.mjs");
const DEFAULT_PORT = Number.parseInt(process.env.PORT || "3618", 10);
const DEFAULT_BACKEND_URL = "http://127.0.0.1:8081";
const DEFAULT_OUTPUT_ROOT = "/Users/kris/code/StoryForge-gitea/output/playwright/douyin/control-panel";
const DEFAULT_STATE_DIR = path.join(os.homedir(), ".storyforge", "douyin-playwright");
const MAX_LOG_LINES = 240;
const MAX_RECENT_RUNS = 8;
const runs = new Map();
function nowIso() {
return new Date().toISOString();
}
function createRunId() {
return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
function trimLogBuffer(logs) {
if (logs.length > MAX_LOG_LINES) {
logs.splice(0, logs.length - MAX_LOG_LINES);
}
}
function appendLog(run, source, chunk) {
const lines = String(chunk || "")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean);
for (const line of lines) {
run.logs.push(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${source}] ${line}`);
}
trimLogBuffer(run.logs);
}
async function readJsonBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 512 * 1024) {
throw new Error("Request body too large");
}
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
return raw ? JSON.parse(raw) : {};
}
async function readJsonIfExists(filePath) {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
async function findLatestCaptureDir(runBaseDir) {
try {
const entries = await fs.readdir(runBaseDir, { withFileTypes: true });
const dirs = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const fullPath = path.join(runBaseDir, entry.name);
const stat = await fs.stat(fullPath);
dirs.push({ fullPath, mtimeMs: stat.mtimeMs });
}
dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
return dirs[0]?.fullPath || "";
} catch {
return "";
}
}
async function loadArtifacts(runBaseDir) {
const outputDir = await findLatestCaptureDir(runBaseDir);
if (!outputDir) {
return null;
}
const [summary, syncResponse, syncError, login] = await Promise.all([
readJsonIfExists(path.join(outputDir, "summary.json")),
readJsonIfExists(path.join(outputDir, "storyforge-sync-response.json")),
readJsonIfExists(path.join(outputDir, "storyforge-sync-error.json")),
readJsonIfExists(path.join(outputDir, "storyforge-login.json"))
]);
return {
outputDir,
summary,
syncResponse,
syncError,
login
};
}
async function refreshRunArtifacts(run) {
const artifacts = await loadArtifacts(run.runBaseDir);
if (!artifacts) {
return;
}
run.outputDir = artifacts.outputDir;
run.summary = artifacts.summary;
run.syncResponse = artifacts.syncResponse;
run.syncError = artifacts.syncError;
run.login = artifacts.login;
}
function serializeRun(run) {
if (!run) {
return null;
}
return {
id: run.id,
status: run.status,
profileUrl: run.profileUrl,
backendUrl: run.backendUrl,
syncEnabled: run.syncEnabled,
headless: run.headless,
startedAt: run.startedAt,
continuedAt: run.continuedAt || "",
finishedAt: run.finishedAt || "",
outputDir: run.outputDir || "",
exitCode: run.exitCode,
signal: run.signal || "",
summary: run.summary || null,
syncResponse: run.syncResponse || null,
syncError: run.syncError || null,
logs: run.logs.slice(-80)
};
}
function getActiveRun() {
return Array.from(runs.values()).find((run) => !["completed", "failed", "terminated"].includes(run.status)) || null;
}
function buildCaptureArgs(payload, runBaseDir, readyFile) {
const token = String(payload.token || payload.storyforgeToken || "").trim();
const username = String(payload.username || payload.storyforgeUsername || "").trim();
const password = String(payload.password || payload.storyforgePassword || "");
const parsedMaxVideos = Number.parseInt(String(payload.maxVideos ?? "4"), 10);
const parsedWaitMs = Number.parseInt(String(payload.waitMs ?? "4000"), 10);
const args = [
CAPTURE_SCRIPT,
"--profile-url",
String(payload.profileUrl || "").trim(),
"--backend-url",
String(payload.backendUrl || DEFAULT_BACKEND_URL).trim(),
"--output-dir",
runBaseDir,
"--state-dir",
String(payload.stateDir || DEFAULT_STATE_DIR).trim(),
"--max-videos",
String(Number.isFinite(parsedMaxVideos) ? Math.max(0, parsedMaxVideos) : 4),
"--wait-ms",
String(Number.isFinite(parsedWaitMs) ? Math.max(800, parsedWaitMs) : 4000),
"--ready-file",
readyFile
];
if (payload.note) {
args.push("--note", String(payload.note).trim());
}
if (payload.headless) {
args.push("--headless");
}
if (!payload.syncEnabled) {
args.push("--no-sync");
}
if (payload.skipCreatorCenter) {
args.push("--no-creator-center");
}
if (payload.allowCreatorCenterFallback) {
args.push("--allow-creator-center-fallback");
}
if (token) {
args.push("--storyforge-token", token);
} else if (payload.syncEnabled) {
args.push("--storyforge-username", username);
args.push("--storyforge-password", password);
}
return args;
}
async function startRun(payload) {
const profileUrl = String(payload.profileUrl || "").trim();
const token = String(payload.token || payload.storyforgeToken || "").trim();
const username = String(payload.username || payload.storyforgeUsername || "").trim();
const password = String(payload.password || payload.storyforgePassword || "");
if (!profileUrl) {
throw new Error("请先填写抖音主页链接");
}
const syncEnabled = payload.syncEnabled !== false;
if (syncEnabled && !token) {
if (!username || !password) {
throw new Error("导入 StoryForge 时需要账号密码,或者直接提供 Token");
}
}
if (getActiveRun()) {
throw new Error("当前已有进行中的采集任务,请先完成或等待结束");
}
const id = createRunId();
const runBaseDir = path.join(DEFAULT_OUTPUT_ROOT, id);
const readyFile = path.join(runBaseDir, "manual-ready.signal");
await ensureDir(runBaseDir);
const args = buildCaptureArgs(
{
...payload,
profileUrl,
syncEnabled
},
runBaseDir,
readyFile
);
const child = spawn(process.execPath, args, {
cwd: SCRIPT_DIR,
env: process.env,
stdio: ["ignore", "pipe", "pipe"]
});
const run = {
id,
status: "awaiting_continue",
profileUrl,
backendUrl: String(payload.backendUrl || DEFAULT_BACKEND_URL).trim(),
syncEnabled,
headless: Boolean(payload.headless),
startedAt: nowIso(),
continuedAt: "",
finishedAt: "",
runBaseDir,
readyFile,
child,
logs: [],
outputDir: "",
summary: null,
syncResponse: null,
syncError: null,
exitCode: null,
signal: ""
};
const visibleArgs = args.map((arg, index) => {
if (args[index - 1] === "--storyforge-password") {
return "******";
}
return arg;
});
appendLog(run, "system", `Started ${process.execPath} ${visibleArgs.join(" ")}`);
child.stdout.on("data", (chunk) => appendLog(run, "stdout", chunk));
child.stderr.on("data", (chunk) => appendLog(run, "stderr", chunk));
child.on("exit", async (code, signal) => {
run.exitCode = code;
run.signal = signal || "";
run.finishedAt = nowIso();
await refreshRunArtifacts(run);
run.status = signal ? "terminated" : code === 0 ? "completed" : "failed";
appendLog(run, "system", `Process exited with status ${run.status}${code !== null ? ` (${code})` : ""}`);
});
runs.set(id, run);
return run;
}
async function continueRun(runId) {
const run = runs.get(runId);
if (!run) {
throw new Error("采集任务不存在");
}
if (["completed", "failed", "terminated"].includes(run.status)) {
throw new Error("这个采集任务已经结束了");
}
await ensureDir(path.dirname(run.readyFile));
await fs.writeFile(run.readyFile, `${nowIso()}\n`, "utf8");
run.continuedAt = nowIso();
run.status = "capturing";
appendLog(run, "system", "Manual ready signal sent");
return run;
}
async function listRecentRuns() {
await ensureDir(DEFAULT_OUTPUT_ROOT);
const entries = await fs.readdir(DEFAULT_OUTPUT_ROOT, { withFileTypes: true });
const dirs = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const runBaseDir = path.join(DEFAULT_OUTPUT_ROOT, entry.name);
const stat = await fs.stat(runBaseDir);
dirs.push({ id: entry.name, runBaseDir, mtimeMs: stat.mtimeMs });
}
dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
const recent = [];
for (const item of dirs.slice(0, MAX_RECENT_RUNS)) {
const artifacts = await loadArtifacts(item.runBaseDir);
recent.push({
id: item.id,
outputDir: artifacts?.outputDir || "",
summary: artifacts?.summary || null,
syncResponse: artifacts?.syncResponse || null,
syncError: artifacts?.syncError || null
});
}
return recent;
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
res.end(body);
}
function sendHtml(res, html) {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store"
});
res.end(html);
}
function renderPage(mode = "full") {
const isWorkbenchMode = mode === "workbench";
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StoryForge Douyin Browser Assist</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe6;
--ink: #16313d;
--muted: #577182;
--accent: #1f6e5f;
--accent-2: #b97524;
--card: rgba(255, 255, 255, 0.86);
--border: rgba(22, 49, 61, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
background:
radial-gradient(circle at top left, rgba(31, 110, 95, 0.16), transparent 30%),
linear-gradient(135deg, #f6efe3, #eff7f4 55%, #fdf8ef);
color: var(--ink);
}
main {
max-width: 1080px;
margin: 0 auto;
padding: 32px 20px 48px;
}
h1, h2, h3 { margin: 0; }
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.topbar-links {
display: inline-flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-link {
display: inline-flex;
align-items: center;
padding: 9px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(22, 49, 61, 0.1);
color: var(--ink);
text-decoration: none;
font-size: 13px;
font-weight: 600;
}
.nav-link.active {
background: rgba(31, 110, 95, 0.12);
color: var(--accent);
border-color: rgba(31, 110, 95, 0.24);
}
.hero {
background: linear-gradient(135deg, #0b3c5d, #1f6e5f 58%, #b97524);
color: white;
border-radius: 28px;
padding: 28px;
box-shadow: 0 18px 42px rgba(11, 60, 93, 0.18);
}
.hero p {
margin: 12px 0 0;
max-width: 760px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.88);
}
.grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
margin-top: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 22px;
padding: 20px;
box-shadow: 0 12px 30px rgba(22, 49, 61, 0.08);
backdrop-filter: blur(8px);
}
.stack { display: grid; gap: 12px; }
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
input, textarea {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(22, 49, 61, 0.12);
padding: 12px 14px;
font: inherit;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
}
textarea { min-height: 88px; resize: vertical; }
.field-note {
font-size: 12px;
color: rgba(74, 96, 107, 0.9);
line-height: 1.5;
}
.advanced-box {
border: 1px solid rgba(22, 49, 61, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.66);
padding: 0 14px;
}
.advanced-box[open] {
padding-bottom: 14px;
}
.advanced-toggle {
list-style: none;
cursor: pointer;
padding: 14px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--ink);
font-weight: 600;
}
.advanced-toggle::-webkit-details-marker { display: none; }
.advanced-toggle::after {
content: "展开";
font-size: 12px;
font-weight: 500;
color: var(--muted);
}
.advanced-box[open] .advanced-toggle::after {
content: "收起";
}
.token-summary {
font-size: 12px;
font-weight: 500;
color: var(--muted);
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.checks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.check {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.75);
color: var(--ink);
}
button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
}
button:hover { transform: translateY(-1px); }
.primary { background: var(--accent); color: white; }
.secondary { background: rgba(22, 49, 61, 0.08); color: var(--ink); }
.warning { background: var(--accent-2); color: white; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.steps {
display: grid;
gap: 10px;
margin-top: 16px;
}
.step {
border-left: 3px solid var(--accent);
padding-left: 12px;
color: var(--muted);
line-height: 1.55;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(31, 110, 95, 0.1);
color: var(--accent);
font-size: 13px;
font-weight: 600;
}
.status-box {
display: grid;
gap: 12px;
margin-top: 14px;
}
.status-line {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
color: var(--muted);
}
pre {
margin: 0;
padding: 14px;
border-radius: 18px;
background: #12222c;
color: #d7efe8;
min-height: 220px;
max-height: 380px;
overflow: auto;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.recent-list { display: grid; gap: 12px; }
.recent-item {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.72);
}
.meta { color: var(--muted); font-size: 13px; line-height: 1.55; }
.path {
font-family: "SF Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--muted);
word-break: break-all;
}
.hint {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.subpanel {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.72);
}
.workbench-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 18px;
margin-top: 18px;
}
.account-list,
.snapshot-list,
.similar-list {
display: grid;
gap: 10px;
}
.account-item,
.snapshot-item,
.similar-item,
.link-item,
.video-item,
.report-item {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.8);
}
.account-item {
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease;
width: 100%;
text-align: left;
}
.account-item:hover {
transform: translateY(-1px);
border-color: rgba(31, 110, 95, 0.28);
}
.account-item.active,
.snapshot-item.active,
.similar-item.active {
border-color: rgba(31, 110, 95, 0.55);
background: rgba(31, 110, 95, 0.08);
}
.profile-hero {
display: grid;
grid-template-columns: 86px 1fr;
gap: 16px;
align-items: center;
}
.avatar {
width: 86px;
height: 86px;
border-radius: 22px;
object-fit: cover;
background: rgba(22, 49, 61, 0.08);
border: 1px solid rgba(22, 49, 61, 0.12);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric-card {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.84);
}
.metric-label {
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
}
.metric-value {
font-size: 18px;
font-weight: 700;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(22, 49, 61, 0.08);
color: var(--ink);
font-size: 12px;
}
.two-col {
display: grid;
grid-template-columns: 0.9fr 1.1fr;
gap: 16px;
}
.detail-box {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.78);
min-height: 180px;
}
.inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mono {
font-family: "SF Mono", ui-monospace, monospace;
font-size: 12px;
word-break: break-all;
}
select {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(22, 49, 61, 0.12);
padding: 12px 14px;
font: inherit;
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
}
.report-suggestion {
border-left: 3px solid rgba(31, 110, 95, 0.55);
padding-left: 12px;
margin-top: 12px;
white-space: pre-wrap;
line-height: 1.6;
}
.analysis-grid,
.video-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-callout {
border-radius: 16px;
padding: 14px;
background: linear-gradient(135deg, rgba(31, 110, 95, 0.1), rgba(185, 117, 36, 0.08));
border: 1px solid rgba(31, 110, 95, 0.12);
line-height: 1.65;
}
.bullet-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 8px;
color: var(--ink);
line-height: 1.6;
}
.analysis-block {
display: grid;
gap: 12px;
}
.video-card {
border: 1px solid rgba(22, 49, 61, 0.1);
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.85);
display: grid;
gap: 14px;
}
.video-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.video-card-title {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 10px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(11, 60, 93, 0.95), rgba(31, 110, 95, 0.9));
color: white;
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 18px rgba(11, 60, 93, 0.16);
}
.work-link {
color: var(--ink);
text-decoration: none;
line-height: 1.45;
}
.work-link:hover {
color: var(--accent);
text-decoration: underline;
}
.work-type-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(185, 117, 36, 0.12);
color: #8a5517;
font-size: 12px;
font-weight: 600;
}
.video-layout {
display: grid;
grid-template-columns: 120px 1fr;
gap: 14px;
}
.cover-thumb {
width: 100%;
aspect-ratio: 3 / 4;
border-radius: 16px;
object-fit: cover;
background: rgba(22, 49, 61, 0.08);
border: 1px solid rgba(22, 49, 61, 0.1);
}
.toolbar-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.score-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.score-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(31, 110, 95, 0.08);
color: var(--ink);
font-size: 12px;
}
.score-badge strong {
color: var(--accent);
}
.link-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.link-row a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.link-row a:hover {
text-decoration: underline;
}
details {
border-top: 1px solid rgba(22, 49, 61, 0.1);
padding-top: 10px;
}
details summary {
cursor: pointer;
color: var(--muted);
}
.empty-state {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.section-divider {
height: 1px;
background: rgba(22, 49, 61, 0.1);
margin: 4px 0;
}
.business-mode .capture-grid,
.business-mode .recent-runs-section,
.business-mode .capture-footnote {
display: none;
}
@media (max-width: 900px) {
.grid, .row, .checks, .workbench-layout, .metric-grid, .two-col, .analysis-grid, .video-grid, .video-layout, .toolbar-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="${isWorkbenchMode ? "business-mode" : ""}">
<main>
<div class="topbar">
<span class="pill">${isWorkbenchMode ? "StoryForge / Douyin Workbench" : "StoryForge / Douyin Browser Assist"}</span>
<div class="topbar-links">
<a class="nav-link ${isWorkbenchMode ? "active" : ""}" href="/workbench">业务工作台</a>
<a class="nav-link ${isWorkbenchMode ? "" : "active"}" href="/">采集控制台</a>
</div>
</div>
<section class="hero">
<h1 style="margin-top: 2px;">${isWorkbenchMode ? "用业务工作台直接查看账号结论、作品榜单和运营分析" : "用网页点按钮,驱动真实浏览器采集抖音账号"}</h1>
<p>${isWorkbenchMode ? "这是面向日常运营的业务页。登录后即可查看账号列表、Agent 结论、完整作品工作台、快照和对标结果。只有在需要抓取新账号时,再切到“采集控制台”。" : "这不是无头绕反爬,而是一个可控的半自动流程。你点击“开始采集”后,脚本会打开真实 Chromium会话沿用同一份登录态。你在浏览器里登录或过滑块后回到这里点“已完成登录继续采集”系统就会继续抓取主页、creator-center并按安全规则同步进 StoryForge。"}</p>
</section>
<div class="grid capture-grid">
<section class="card stack">
<div>
<h2>开始新采集</h2>
<p class="hint">默认会导入 StoryForge如果只是想先抓本地 bundle可以勾选“仅采集不导入”。</p>
</div>
<form id="capture-form" class="stack">
<label>
抖音主页链接
<input id="profile-url" name="profileUrl" placeholder="https://www.douyin.com/user/..." autocomplete="url" required />
</label>
<div class="row">
<label>
StoryForge 地址
<input id="backend-url" name="backendUrl" value="${DEFAULT_BACKEND_URL}" autocomplete="url" />
</label>
<label>
备注
<input id="note" name="note" placeholder="例如:浏览器辅助采集" />
</label>
</div>
<div class="row">
<label>
StoryForge 用户名
<input id="username" name="username" placeholder="kris" autocomplete="username" />
</label>
<label>
StoryForge 密码
<input id="password" name="password" type="password" placeholder="用于自动导入时登录" autocomplete="current-password" />
</label>
</div>
<div class="row">
<label>
最大作品页抓取数
<input id="max-videos" name="maxVideos" type="number" min="0" max="10" value="4" />
</label>
<div class="field-note" style="display:flex;align-items:end;">
已登录工作台后会自动复用当前会话 Token。通常只需要填账号密码不需要手动维护 Token。
</div>
</div>
<details class="advanced-box" id="token-details">
<summary class="advanced-toggle">
<span>高级认证</span>
<span class="token-summary" id="token-summary">未填写 Token</span>
</summary>
<div class="stack">
<label>
已有 Token可选
<input id="token" name="token" placeholder="Bearer token可替代账号密码" autocomplete="off" />
</label>
<div class="field-note">只有在你已经拿到 Bearer Token或者不希望页面保存密码时再手动填写这里。</div>
</div>
</details>
<div class="checks">
<label class="check"><input id="sync-enabled" type="checkbox" checked /> 导入 StoryForge</label>
<label class="check"><input id="headless" type="checkbox" /> Headless</label>
<label class="check"><input id="skip-creator-center" type="checkbox" /> 跳过 creator-center</label>
<label class="check"><input id="allow-fallback" type="checkbox" /> 允许 creator-center 兜底</label>
</div>
<div class="actions">
<button class="primary" type="submit">开始采集</button>
<button class="warning" id="continue-button" type="button" disabled>已完成登录,继续采集</button>
<button class="secondary" id="refresh-button" type="button">刷新状态</button>
</div>
</form>
<div class="steps">
<div class="step">1. 点击“开始采集”,脚本会在本机打开 Chromium。</div>
<div class="step">2. 在打开的浏览器里完成登录、滑块或验证码,并确认已进入目标主页。</div>
<div class="step">3. 回到这里点击“已完成登录,继续采集”。</div>
<div class="step">4. 等待脚本自动抓取、写出 <code>summary.json</code>,并可选同步到 StoryForge。</div>
</div>
</section>
<section class="card">
<h2>当前任务</h2>
<div id="active-status" class="status-box">
<p class="hint">当前没有进行中的采集任务。</p>
</div>
<h3 style="margin-top: 18px;">实时日志</h3>
<pre id="logs">等待任务启动…</pre>
</section>
</div>
<section class="card recent-runs-section" style="margin-top: 18px;">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
<div>
<h2>最近运行</h2>
<p class="hint">这里展示的是控制台模式启动过的采集任务。</p>
</div>
</div>
<div id="recent-runs" class="recent-list" style="margin-top: 14px;"></div>
</section>
<section class="card" style="margin-top: 18px;">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;">
<div>
<h2>Douyin Workbench</h2>
<p class="hint">采集完成后的结构化数据、Agent 结论、快照与对标结果,都在这块工作台里查看。</p>
</div>
<div class="actions">
<button class="secondary" id="workbench-login-button" type="button">登录并加载</button>
<button class="secondary" id="workbench-refresh-button" type="button">刷新工作台</button>
<button class="secondary" id="workbench-logout-button" type="button">清除会话</button>
</div>
</div>
<div id="workbench-session" class="status-box" style="margin-top: 14px;">
<p class="hint">登录 StoryForge 后,这里会展示抖音账号列表和分析工作台。</p>
</div>
<div class="workbench-layout">
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">
<h3>账号列表</h3>
<span class="pill" id="accounts-count-pill">0 个账号</span>
</div>
<input id="accounts-filter" placeholder="按昵称、抖音号、标签搜索" autocomplete="off" />
<div id="accounts-list" class="account-list">
<p class="empty-state">登录后将显示可用账号。</p>
</div>
</section>
<section class="stack">
<div id="workspace-empty" class="subpanel">
<p class="empty-state">先登录并在左侧选择一个账号,或者从“最近运行”里直接打开同步成功的账号。</p>
</div>
<div id="workspace-content" class="stack" hidden>
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
<h3>账号总览</h3>
<div class="inline-actions">
<button class="secondary" id="reload-selected-account-button" type="button">刷新当前账号</button>
</div>
</div>
<div id="account-overview"></div>
</section>
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
<h3>Agent 结论</h3>
<div class="inline-actions">
<button class="primary" id="run-analysis-button" type="button">运行分析</button>
</div>
</div>
<div class="row">
<label>
分析重点
<textarea id="analysis-focus" placeholder="例如:请重点总结选题结构、钩子设计、镜头节奏和二创模板。"></textarea>
</label>
<div class="stack">
<label>
分析模型
<select id="analysis-model-select"></select>
</label>
<label>
最大视频数
<input id="analysis-max-videos" type="number" min="1" max="20" value="12" />
</label>
</div>
</div>
<div id="analysis-feedback" class="hint">这里会显示分析执行状态和最近报告。</div>
<div id="analysis-reports" class="stack"></div>
</section>
<section class="subpanel stack">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;">
<div>
<h3>作品工作台</h3>
<p class="hint" style="margin:6px 0 0;">这里展示完整作品列表可按发布时间、AI 打分、受欢迎程度与作品类型筛选排序,每条作品下都带分析与跳转链接。</p>
</div>
<div class="inline-actions">
<button class="secondary" id="analyze-top-videos-button" type="button">自动分析高分作品</button>
</div>
</div>
<div class="toolbar-grid">
<label>
作品范围
<select id="videos-scope-select">
<option value="all">全部作品</option>
<option value="top">高分作品</option>
<option value="latest">最新作品</option>
</select>
</label>
<label>
排序方式
<select id="videos-sort-select">
<option value="score">AI 综合分</option>
<option value="popular">受欢迎程度</option>
<option value="commercial">商业价值</option>
<option value="latest">最新发布时间</option>
<option value="play">播放量</option>
<option value="like">点赞量</option>
<option value="share">分享量</option>
<option value="comment">评论量</option>
</select>
</label>
<label>
作品类型
<select id="videos-type-filter">
<option value="all">全部类型</option>
<option value="video">视频作品</option>
<option value="image_text">图文作品</option>
</select>
</label>
<label>
标签筛选
<input id="videos-tag-filter" placeholder="例如:创业 / 文案" autocomplete="off" />
</label>
<label>
关键词搜索
<input id="videos-query-filter" placeholder="按标题或描述搜索" autocomplete="off" />
</label>
</div>
<div id="videos-summary"></div>
<div id="videos-list" class="stack"></div>
</section>
<section class="subpanel stack">
<h3>快照与原始采集</h3>
<div id="snapshot-summary"></div>
<div class="two-col">
<div class="stack">
<h4 style="margin:0;">快照列表</h4>
<div id="snapshot-list" class="snapshot-list"></div>
</div>
<div class="stack">
<h4 style="margin:0;">快照详情</h4>
<div id="snapshot-detail" class="detail-box">
<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>
</div>
</div>
</div>
</section>
<section class="subpanel stack">
<h3>对标与相似账号</h3>
<div id="linked-accounts" class="stack"></div>
<div class="section-divider"></div>
<div class="two-col">
<div class="stack">
<h4 style="margin:0;">最近相似搜索</h4>
<div id="similar-search-list" class="similar-list"></div>
</div>
<div class="stack">
<h4 style="margin:0;">相似搜索详情</h4>
<div id="similar-search-detail" class="detail-box">
<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
</section>
<p class="hint capture-footnote" style="margin-top:14px;">如果你主要是在做账号分析和作品运营,建议直接使用上面的业务工作台;只有在补采新账号时再回到采集控制台。</p>
</main>
<script>
const activeStatusEl = document.getElementById("active-status");
const logsEl = document.getElementById("logs");
const recentRunsEl = document.getElementById("recent-runs");
const continueButton = document.getElementById("continue-button");
const refreshButton = document.getElementById("refresh-button");
const form = document.getElementById("capture-form");
const storageKey = "storyforge-douyin-control-panel";
const sessionStorageKey = "storyforge-douyin-workbench-session";
const workbenchSessionEl = document.getElementById("workbench-session");
const tokenDetailsEl = document.getElementById("token-details");
const tokenInputEl = document.getElementById("token");
const tokenSummaryEl = document.getElementById("token-summary");
const accountsCountPillEl = document.getElementById("accounts-count-pill");
const accountsListEl = document.getElementById("accounts-list");
const accountsFilterEl = document.getElementById("accounts-filter");
const workspaceEmptyEl = document.getElementById("workspace-empty");
const workspaceContentEl = document.getElementById("workspace-content");
const accountOverviewEl = document.getElementById("account-overview");
const analysisFeedbackEl = document.getElementById("analysis-feedback");
const analysisReportsEl = document.getElementById("analysis-reports");
const analysisFocusEl = document.getElementById("analysis-focus");
const analysisModelSelectEl = document.getElementById("analysis-model-select");
const analysisMaxVideosEl = document.getElementById("analysis-max-videos");
const analyzeTopVideosButton = document.getElementById("analyze-top-videos-button");
const videosScopeEl = document.getElementById("videos-scope-select");
const videosSortEl = document.getElementById("videos-sort-select");
const videosTypeFilterEl = document.getElementById("videos-type-filter");
const videosTagFilterEl = document.getElementById("videos-tag-filter");
const videosQueryFilterEl = document.getElementById("videos-query-filter");
const videosSummaryEl = document.getElementById("videos-summary");
const videosListEl = document.getElementById("videos-list");
const snapshotSummaryEl = document.getElementById("snapshot-summary");
const snapshotListEl = document.getElementById("snapshot-list");
const snapshotDetailEl = document.getElementById("snapshot-detail");
const linkedAccountsEl = document.getElementById("linked-accounts");
const similarSearchListEl = document.getElementById("similar-search-list");
const similarSearchDetailEl = document.getElementById("similar-search-detail");
const workbenchLoginButton = document.getElementById("workbench-login-button");
const workbenchRefreshButton = document.getElementById("workbench-refresh-button");
const workbenchLogoutButton = document.getElementById("workbench-logout-button");
const reloadSelectedAccountButton = document.getElementById("reload-selected-account-button");
const runAnalysisButton = document.getElementById("run-analysis-button");
let activeRunId = "";
const workbenchState = {
session: null,
accounts: [],
selectedAccountId: "",
selectedWorkspace: null,
videoItems: [],
videoMeta: null,
topScoredVideoIds: [],
latestVideoIds: [],
highScoreThreshold: 60,
snapshots: [],
selectedSnapshotId: "",
selectedSnapshotDetail: null,
similarSearchDetail: null,
loadingAccountId: "",
lastAnalysisMessage: ""
};
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeHtmlWithBreaks(value) {
return escapeHtml(value).replaceAll("\\n", "<br>");
}
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
function formatNumber(value) {
const num = Number(value || 0);
if (!Number.isFinite(num)) {
return "-";
}
if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(1) + "w";
}
return num.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
function formatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatPercent(value) {
const num = Number(value || 0);
if (!Number.isFinite(num)) {
return "-";
}
let text = (num * 100).toFixed(2);
if (text.endsWith(".00")) {
text = text.slice(0, -3);
}
return text + "%";
}
function normalizeBackendUrl(value) {
let normalized = String(value || "").trim();
while (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function getFormAuthPayload() {
const backendUrl = normalizeBackendUrl(document.getElementById("backend-url").value);
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
const token = tokenInputEl.value.trim();
return {
backendUrl,
username,
password,
token
};
}
function updateTokenUi() {
const token = tokenInputEl.value.trim();
tokenSummaryEl.textContent = token ? "已填写 Token" : "未填写 Token";
if (!token && document.activeElement !== tokenInputEl) {
tokenDetailsEl.open = false;
}
}
function persistWorkbenchSession(session) {
workbenchState.session = session;
localStorage.setItem(sessionStorageKey, JSON.stringify(session));
renderWorkbenchSession();
}
function clearWorkbenchSession() {
workbenchState.session = null;
workbenchState.accounts = [];
workbenchState.selectedAccountId = "";
workbenchState.selectedWorkspace = null;
workbenchState.videoItems = [];
workbenchState.videoMeta = null;
workbenchState.topScoredVideoIds = [];
workbenchState.latestVideoIds = [];
workbenchState.highScoreThreshold = 60;
workbenchState.snapshots = [];
workbenchState.selectedSnapshotId = "";
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
workbenchState.lastAnalysisMessage = "";
localStorage.removeItem(sessionStorageKey);
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
}
function loadWorkbenchSession() {
try {
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || "null");
if (saved && saved.token && saved.backendUrl) {
workbenchState.session = saved;
}
} catch {}
}
async function storyforgeFetch(pathname, options = {}) {
const session = workbenchState.session;
const backendUrl = normalizeBackendUrl(options.backendUrl || session?.backendUrl || document.getElementById("backend-url").value);
const token = String(options.token || session?.token || "").trim();
const headers = {
"content-type": "application/json"
};
if (token) {
headers.Authorization = "Bearer " + token;
}
const response = await fetch(backendUrl + pathname, {
method: options.method || "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
const raw = await response.text();
let payload = null;
try {
payload = raw ? JSON.parse(raw) : null;
} catch {
payload = { raw };
}
if (!response.ok) {
const detail = payload?.detail;
const message =
(typeof detail === "string" && detail) ||
detail?.message ||
payload?.message ||
payload?.raw ||
("HTTP " + response.status);
throw new Error(message);
}
return payload;
}
function renderBulletList(items, emptyText) {
const safeItems = safeArray(items).filter(Boolean);
if (!safeItems.length) {
return '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
}
return '<ul class="bullet-list">' + safeItems.map((item) => '<li>' + escapeHtml(typeof item === "string" ? item : JSON.stringify(item)) + '</li>').join("") + '</ul>';
}
function renderObjectBulletList(items, renderFn, emptyText) {
const safeItems = safeArray(items);
if (!safeItems.length) {
return '<p class="empty-state">' + escapeHtml(emptyText || "暂无内容。") + '</p>';
}
return '<div class="stack">' + safeItems.map(renderFn).join("") + '</div>';
}
function getVideoContentType(video) {
return String(video?.content_type || "video").trim().toLowerCase() === "image_text" ? "image_text" : "video";
}
function getVideoContentTypeLabel(video) {
return getVideoContentType(video) === "image_text" ? "图文作品" : "视频作品";
}
function buildFallbackVideoAnalysis(video) {
const score = video.score || {};
const stats = video.stats || {};
const tags = safeArray(video.tags).slice(0, 4);
const headline = [
Number(score.performance_score || 0) >= 65 ? "AI 判断这条作品具备稳定复用价值" : "这条作品更适合先做轻量复刻验证",
Number(score.popularity_score || 0) >= 70 ? "当前受欢迎程度较高" : "当前热度中性,可从题材切口继续优化",
].join("") + "。";
const reasons = [
"发布时间:" + (formatDateTime(video.published_at) || "-"),
"综合指标:播放 " + formatNumber(stats.play) + " / 点赞 " + formatNumber(stats.like) + " / 评论 " + formatNumber(stats.comment),
"核心信号:" + safeArray(score.signals).join("")
];
const hookBreakdown = [];
if (video.title) {
hookBreakdown.push("标题直接暴露主题词,适合保留主关键词:" + String(video.title).slice(0, 28));
}
if (tags.length) {
hookBreakdown.push("标签集中在 " + tags.join(" / ") + ",说明题材识别度已经形成。");
}
if (!hookBreakdown.length) {
hookBreakdown.push("建议先从封面标题和前 3 秒钩子补强识别度。");
}
const structureBreakdown = [
getVideoContentType(video) === "image_text"
? "图文作品优先优化首图信息密度、第二屏承接和最后一屏 CTA。"
: "视频作品优先拆前 3 秒钩子、中段论点推进和结尾 CTA。",
"优先复刻已有高互动题材,再替换场景和对象人群。"
];
const operatorActions = [
"把这条作品纳入可复刻题材库,优先测试 1 个同主题新切口。",
"评论区补一句明确动作指令,承接收藏、私信或咨询。",
];
const riskNotes = [
Number(score.age_days || 0) > 45 ? "发布时间较久,复刻时需要更新案例和场景。" : "仍可参考当前结构,但要避免直接照搬表达。",
Number(score.comment_rate || 0) < 0.005 ? "评论互动偏弱,复刻时要补问题式结尾。": "评论反馈不错,可重点利用评论区做二次选题。"
];
return {
headline_summary: headline,
commercial_angle: {
judgement: Number(score.commercial_score || 0) >= 60 ? "具备较好的内容转化潜力" : "更适合先做流量验证,再逐步补承接",
suitable_for: [
"选题模板复刻",
"账号栏目化运营",
Number(score.collect_rate || 0) >= 0.008 ? "知识产品/清单承接" : "私信或评论区动作优化"
]
},
scores: {
hook: Number(score.performance_score || 0),
retention: Number(score.popularity_score || 0),
conversion: Number(score.comment_rate || 0) * 10000,
commercial: Number(score.commercial_score || 0)
},
replication_plan: reasons,
operator_actions: operatorActions,
hook_breakdown: hookBreakdown,
structure_breakdown: structureBreakdown,
risk_notes: riskNotes
};
}
function getSortedVideos() {
const scope = videosScopeEl.value || "all";
const sortBy = videosSortEl.value || "score";
const typeFilter = videosTypeFilterEl.value || "all";
const query = videosQueryFilterEl.value.trim().toLowerCase();
const tag = videosTagFilterEl.value.trim().toLowerCase();
const topSet = new Set(safeArray(workbenchState.topScoredVideoIds));
const latestSet = new Set(safeArray(workbenchState.latestVideoIds));
let items = safeArray(workbenchState.videoItems).filter((video) => {
if (scope === "top" && !topSet.has(video.id)) {
return false;
}
if (scope === "latest" && !latestSet.has(video.id)) {
return false;
}
if (typeFilter !== "all" && getVideoContentType(video) !== typeFilter) {
return false;
}
if (query) {
const haystack = [video.title, video.description, video.aweme_id, ...safeArray(video.tags)].join(" ").toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
if (tag) {
const tags = safeArray(video.tags).map((item) => String(item).toLowerCase());
if (!tags.some((item) => item.includes(tag))) {
return false;
}
}
return true;
});
const getValue = (video) => {
if (sortBy === "popular") return Number(video.score?.popularity_score || 0);
if (sortBy === "commercial") return Number(video.score?.commercial_score || 0);
if (sortBy === "latest") return new Date(video.published_at || 0).getTime();
if (sortBy === "play") return Number(video.stats?.play || 0);
if (sortBy === "like") return Number(video.stats?.like || 0);
if (sortBy === "share") return Number(video.stats?.share || 0);
if (sortBy === "comment") return Number(video.stats?.comment || 0);
return Number(video.score?.performance_score || 0);
};
items.sort((left, right) => getValue(right) - getValue(left));
return items;
}
function renderAccountSuggestion(suggestion) {
const parsed = suggestion.parsed_json || {};
if (!parsed.executive_summary) {
return [
'<div class="report-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
'</div>',
'<div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text || "暂无结论") + '</div>',
'</div>'
].join("");
}
const positioning = parsed.commercial_positioning || {};
const engine = parsed.content_engine || {};
return [
'<div class="report-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<strong>' + escapeHtml(suggestion.model_label || "模型") + '</strong>',
'<span class="pill">' + escapeHtml(suggestion.status || "-") + '</span>',
'</div>',
'<div class="summary-callout" style="margin-top:12px;">' + escapeHtml(parsed.executive_summary || "暂无总结") + '</div>',
'<div class="analysis-grid" style="margin-top:12px;">',
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业定位</h4><div class="meta">受众:' + escapeHtml(positioning.audience || "-") + '</div><div class="meta" style="margin-top:8px;">核心承诺:' + escapeHtml(positioning.core_promise || "-") + '</div><div class="meta" style="margin-top:8px;">商业化准备度:' + escapeHtml(formatNumber(positioning.monetization_readiness_score)) + '</div><div style="margin-top:12px;">' + renderBulletList(positioning.offer_directions, "暂无产品方向建议") + '</div></div>',
'<div class="detail-box"><h4 style="margin:0 0 10px;">内容引擎</h4><div class="meta">内容支柱</div>' + renderBulletList(engine.pillars, "暂无内容支柱") + '<div class="meta" style="margin-top:10px;">开头模式</div>' + renderBulletList(engine.hook_patterns, "暂无开头模式") + '</div>',
'</div>',
'<div class="analysis-grid" style="margin-top:12px;">',
'<div class="detail-box"><h4 style="margin:0 0 10px;">结构与 CTA</h4><div class="meta">结构模式</div>' + renderBulletList(engine.structure_patterns, "暂无结构结论") + '<div class="meta" style="margin-top:10px;">CTA 模式</div>' + renderBulletList(engine.cta_patterns, "暂无 CTA 建议") + '</div>',
'<div class="detail-box"><h4 style="margin:0 0 10px;">商业化与运营动作</h4><div class="meta">承接路径</div>' + renderBulletList(parsed.monetization_plan, "暂无商业化承接建议") + '<div class="meta" style="margin-top:10px;">30 天动作</div>' + renderBulletList(parsed.next_30_day_actions, "暂无 30 天动作建议") + '</div>',
'</div>',
'<div class="analysis-grid" style="margin-top:12px;">',
'<div class="detail-box"><h4 style="margin:0 0 10px;">高分作品规律</h4>' + renderObjectBulletList(parsed.winning_patterns, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "高分作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.why || "-") + '</div><div class="meta" style="margin-top:6px;">复刻建议:' + escapeHtml(item.replication_angle || "-") + '</div></div>', "暂无高分作品分析") + '</div>',
'<div class="detail-box"><h4 style="margin:0 0 10px;">最近内容信号</h4>' + renderObjectBulletList(parsed.latest_signal, (item) => '<div class="link-item"><strong>' + escapeHtml(item.video_title || "最近作品") + '</strong><div class="meta" style="margin-top:6px;">' + escapeHtml(item.signal || "-") + '</div><div class="meta" style="margin-top:6px;">动作:' + escapeHtml(item.action || "-") + '</div></div>', "暂无最新作品信号") + '</div>',
'</div>',
'<div class="analysis-grid" style="margin-top:12px;">',
'<div class="detail-box"><h4 style="margin:0 0 10px;">对标与风险</h4><div class="meta">对标洞察</div>' + renderBulletList(parsed.benchmark_insights, "暂无对标洞察") + '<div class="meta" style="margin-top:10px;">风险观察</div>' + renderBulletList(parsed.risk_watchlist, "暂无风险提示") + '</div>',
'<div class="detail-box"><h4 style="margin:0 0 10px;">当前缺口</h4>' + renderBulletList(parsed.operational_gaps, "暂无明显缺口") + '</div>',
'</div>',
suggestion.suggestion_text ? '<details style="margin-top:12px;"><summary>查看模型原始输出</summary><div class="report-suggestion">' + escapeHtmlWithBreaks(suggestion.suggestion_text) + '</div></details>' : '',
'</div>'
].join("");
}
function renderVideoAnalysisCard(video, index) {
const analysis = video.latest_analysis || {};
const parsed = analysis.parsed_json && Object.keys(analysis.parsed_json).length
? analysis.parsed_json
: buildFallbackVideoAnalysis(video);
const score = video.score || {};
const stats = video.stats || {};
const workTitle = video.title || video.description || video.aweme_id || "未命名作品";
const titleHtml = video.share_url
? '<a class="work-link" href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">' + escapeHtml(workTitle) + '</a>'
: '<span>' + escapeHtml(workTitle) + '</span>';
const analysisLabel = video.latest_analysis ? "AI 深度分析" : "系统速评";
return [
'<article class="video-card">',
'<div class="video-layout">',
video.cover_url ? '<img class="cover-thumb" src="' + escapeHtml(video.cover_url) + '" alt="cover" />' : '<div class="cover-thumb"></div>',
'<div class="stack">',
'<div class="video-card-header">',
'<div class="video-card-title"><span class="rank-badge">#' + escapeHtml(String(index + 1)) + '</span><strong>' + titleHtml + '</strong><span class="work-type-pill">' + escapeHtml(getVideoContentTypeLabel(video)) + '</span></div>',
'<span class="pill">' + escapeHtml(video.aweme_id || "-") + '</span>',
'</div>',
'<div class="meta">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + (Number(video.image_count || 0) > 0 && getVideoContentType(video) === "image_text" ? ',共 ' + escapeHtml(formatNumber(video.image_count)) + ' 张图' : '') + '</div>',
'<div class="score-badges">',
'<span class="score-badge"><strong>综合</strong>' + escapeHtml(formatNumber(score.performance_score)) + '</span>',
'<span class="score-badge"><strong>受欢迎度</strong>' + escapeHtml(formatNumber(score.popularity_score)) + '</span>',
'<span class="score-badge"><strong>商业</strong>' + escapeHtml(formatNumber(score.commercial_score)) + '</span>',
'<span class="score-badge"><strong>播放</strong>' + escapeHtml(formatNumber(stats.play)) + '</span>',
'<span class="score-badge"><strong>点赞</strong>' + escapeHtml(formatNumber(stats.like)) + '</span>',
'<span class="score-badge"><strong>评论</strong>' + escapeHtml(formatNumber(stats.comment)) + '</span>',
'<span class="score-badge"><strong>分享</strong>' + escapeHtml(formatNumber(stats.share)) + '</span>',
'</div>',
safeArray(video.tags).length ? '<div class="chips">' + safeArray(video.tags).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
'<div class="meta">互动率:' + escapeHtml(formatPercent(score.engagement_rate)) + ',评论率:' + escapeHtml(formatPercent(score.comment_rate)) + ',收藏率:' + escapeHtml(formatPercent(score.collect_rate)) + ',发布时间距今:' + escapeHtml(score.age_days == null ? "-" : score.age_days + " 天") + '</div>',
'<div class="chips">' + safeArray(score.signals).map((item) => '<span class="chip">' + escapeHtml(item) + '</span>').join("") + '</div>',
'<div class="link-row"><span class="meta">' + analysisLabel + '</span>' + (video.share_url ? '<a href="' + escapeHtml(video.share_url) + '" target="_blank" rel="noreferrer">打开作品</a>' : '') + '</div>',
'</div>',
'</div>',
parsed.headline_summary ? '<div class="analysis-block"><div class="summary-callout">' + escapeHtml(parsed.headline_summary || analysis.summary_text || "暂无分析结论") + '</div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">为什么值得做</h4><div class="meta">商业判断:' + escapeHtml((parsed.commercial_angle || {}).judgement || "-") + '</div><div class="meta" style="margin-top:8px;">可承接方向</div>' + renderBulletList((parsed.commercial_angle || {}).suitable_for, "暂无承接方向") + '<div class="meta" style="margin-top:10px;">分项评分</div>' + renderBulletList(['钩子 ' + formatNumber((parsed.scores || {}).hook), '留存 ' + formatNumber((parsed.scores || {}).retention), '转化 ' + formatNumber((parsed.scores || {}).conversion), '商业 ' + formatNumber((parsed.scores || {}).commercial)], "暂无分项评分") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">复刻与运营动作</h4><div class="meta">复刻计划</div>' + renderBulletList(parsed.replication_plan, "暂无复刻计划") + '<div class="meta" style="margin-top:10px;">运营动作</div>' + renderBulletList(parsed.operator_actions, "暂无运营动作") + '</div></div><div class="analysis-grid"><div class="detail-box"><h4 style="margin:0 0 10px;">钩子与结构</h4><div class="meta">钩子拆解</div>' + renderBulletList(parsed.hook_breakdown, "暂无钩子拆解") + '<div class="meta" style="margin-top:10px;">结构拆解</div>' + renderBulletList(parsed.structure_breakdown, "暂无结构拆解") + '</div><div class="detail-box"><h4 style="margin:0 0 10px;">风险提醒</h4>' + renderBulletList(parsed.risk_notes, "暂无风险提醒") + '</div></div></div>' : '<div class="detail-box"><p class="empty-state">这条作品还没有自动分析。点击上面的“自动分析高分作品”即可补齐。</p></div>',
'</article>'
].join("");
}
function renderVideos() {
const items = getSortedVideos();
const meta = workbenchState.videoMeta || {};
videosSummaryEl.innerHTML = [
'<div class="metric-grid">',
'<div class="metric-card"><div class="metric-label">作品总数</div><div class="metric-value">' + escapeHtml(formatNumber(meta.total_count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">已分析作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.analyzed_count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">视频作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.video_count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">图文作品</div><div class="metric-value">' + escapeHtml(formatNumber(meta.image_text_count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">当前显示</div><div class="metric-value">' + escapeHtml(formatNumber(items.length)) + '</div></div>',
'</div>',
'<p class="hint" style="margin-top:12px;">高分阈值:' + escapeHtml(formatNumber(workbenchState.highScoreThreshold)) + '。受欢迎程度会综合播放、点赞、评论、分享与收藏信号;点击作品标题或“打开作品”即可跳到原链接。</p>'
].join("");
if (!items.length) {
videosListEl.innerHTML = '<p class="empty-state">当前筛选条件下没有作品。</p>';
return;
}
videosListEl.innerHTML = '<div class="video-grid">' + items.map((video, index) => renderVideoAnalysisCard(video, index)).join("") + '</div>';
}
function renderWorkbenchSession() {
const session = workbenchState.session;
if (!session) {
workbenchSessionEl.innerHTML = '<p class="hint">还没有 StoryForge 会话。你可以直接用上面的账号密码登录,然后加载抖音工作台。</p>';
return;
}
workbenchSessionEl.innerHTML = [
'<div class="status-line"><strong>当前账号</strong><span>' + escapeHtml(session.account?.display_name || session.account?.username || "未知账号") + '</span></div>',
'<div class="status-line"><strong>角色</strong><span>' + escapeHtml(session.account?.role || "-") + '</span></div>',
'<div class="status-line"><strong>后端地址</strong><span class="path">' + escapeHtml(session.backendUrl) + '</span></div>',
'<div class="status-line"><strong>认证状态</strong><span>Token 已缓存,可直接刷新工作台或复用到采集任务</span></div>'
].join("");
}
function renderAccountList() {
const filterText = accountsFilterEl.value.trim().toLowerCase();
const accounts = safeArray(workbenchState.accounts).filter((account) => {
if (!filterText) {
return true;
}
const haystack = [
account.nickname,
account.douyin_id,
account.signature,
...safeArray(account.tags),
...safeArray(account.keywords)
].join(" ").toLowerCase();
return haystack.includes(filterText);
});
accountsCountPillEl.textContent = accounts.length + " 个账号";
if (!accounts.length) {
accountsListEl.innerHTML = '<p class="empty-state">当前没有符合条件的账号。</p>';
return;
}
accountsListEl.innerHTML = accounts.map((account) => {
const selected = workbenchState.selectedAccountId === account.id;
return [
'<button type="button" class="account-item ' + (selected ? "active" : "") + '" data-account-id="' + escapeHtml(account.id) + '">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
'<strong>' + escapeHtml(account.nickname || "未命名账号") + '</strong>',
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
'</div>',
'<p class="meta" style="margin:10px 0 0;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</p>',
'<p class="meta" style="margin:6px 0 0;">最近视频:' + escapeHtml(account.video_summary?.count ?? "-") + ' 条</p>',
safeArray(account.tags).length ? '<div class="chips" style="margin-top:10px;">' + safeArray(account.tags).slice(0, 6).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
'</button>'
].join("");
}).join("");
}
function renderSnapshotDetail() {
const detail = workbenchState.selectedSnapshotDetail;
if (!detail) {
snapshotDetailEl.innerHTML = '<p class="empty-state">选择左侧快照后,这里会展示字段摘要和原始内容。</p>';
return;
}
const fields = safeArray(detail.fields).slice(0, 40).map((field) => {
return '<div class="link-item"><div><strong>' + escapeHtml(field.field_path || "-") + '</strong></div><div class="meta" style="margin-top:6px;">类型:' + escapeHtml(field.field_type || "-") + '</div><div class="meta" style="margin-top:6px;">值:' + escapeHtml(String(field.field_value || "")).slice(0, 220) + '</div></div>';
}).join("");
snapshotDetailEl.innerHTML = [
'<div class="stack">',
'<div class="meta">快照类型:' + escapeHtml(detail.snapshot_type || "-") + '</div>',
'<div class="meta">来源:' + escapeHtml(detail.source_url || "-") + '</div>',
detail.summary ? '<pre>' + escapeHtml(JSON.stringify(detail.summary, null, 2)) + '</pre>' : '',
fields || '<p class="empty-state">这个快照当前没有可展示的字段。</p>',
'</div>'
].join("");
}
function renderSimilarSearchDetail() {
const detail = workbenchState.similarSearchDetail;
if (!detail) {
similarSearchDetailEl.innerHTML = '<p class="empty-state">选择一次相似搜索后,这里会展示候选账号和推荐理由。</p>';
return;
}
const candidates = safeArray(detail.candidates);
similarSearchDetailEl.innerHTML = [
'<div class="stack">',
'<div class="meta">关键词:' + escapeHtml(safeArray(detail.keywords).join(" / ")) + '</div>',
candidates.length ? candidates.map((candidate) => {
return [
'<div class="link-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;">',
'<strong>' + escapeHtml(candidate.candidate_nickname || candidate.candidate_profile_url || "候选账号") + '</strong>',
'<span class="pill">综合 ' + escapeHtml(formatNumber(candidate.agent_score || candidate.heuristic_score)) + '</span>',
'</div>',
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.candidate_profile_url || "-") + '</div>',
'<div class="meta" style="margin-top:8px;">' + escapeHtml(candidate.rationale_text || "暂无理由说明") + '</div>',
'</div>'
].join("");
}).join("") : '<p class="empty-state">这次搜索还没有候选账号。</p>',
'</div>'
].join("");
}
function renderWorkspace() {
const workspace = workbenchState.selectedWorkspace;
if (!workspace || !workspace.account) {
workspaceEmptyEl.hidden = false;
workspaceContentEl.hidden = true;
accountOverviewEl.innerHTML = "";
analysisReportsEl.innerHTML = "";
snapshotSummaryEl.innerHTML = "";
snapshotListEl.innerHTML = "";
linkedAccountsEl.innerHTML = "";
similarSearchListEl.innerHTML = "";
videosSummaryEl.innerHTML = "";
videosListEl.innerHTML = "";
renderSnapshotDetail();
renderSimilarSearchDetail();
return;
}
const account = workspace.account;
const videoMeta = workbenchState.videoMeta || {};
const reports = safeArray(workspace.recent_reports);
const linkedAccounts = safeArray(workspace.linked_accounts);
const similarSearches = safeArray(workspace.recent_similarity_searches);
const models = safeArray(workspace.available_model_profiles);
const syncErrors = safeArray(workspace.sync_errors);
workspaceEmptyEl.hidden = true;
workspaceContentEl.hidden = false;
if (!analysisFocusEl.value.trim() && reports[0]?.focus_text) {
analysisFocusEl.value = reports[0].focus_text;
}
const selectedModelId = analysisModelSelectEl.value;
analysisModelSelectEl.innerHTML = models.length ? models.map((model) => {
const selected = selectedModelId ? selectedModelId === model.id : Boolean(model.is_default);
return '<option value="' + escapeHtml(model.id) + '"' + (selected ? " selected" : "") + '>' + escapeHtml(model.name + " / " + model.model_name) + '</option>';
}).join("") : '<option value="">暂无可用模型</option>';
accountOverviewEl.innerHTML = [
'<div class="profile-hero">',
account.avatar_url ? '<img class="avatar" src="' + escapeHtml(account.avatar_url) + '" alt="avatar" />' : '<div class="avatar"></div>',
'<div class="stack">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<div><h3 style="margin:0;">' + escapeHtml(account.nickname || "未命名账号") + '</h3><div class="meta" style="margin-top:8px;">抖音号:' + escapeHtml(account.douyin_id || "-") + '</div></div>',
'<span class="pill">' + escapeHtml(account.sync_status || "unknown") + '</span>',
'</div>',
'<div class="meta">' + escapeHtmlWithBreaks(account.signature || "暂无签名") + '</div>',
'<div class="path">' + escapeHtml(account.profile_url || "-") + '</div>',
'</div>',
'</div>',
'<div class="metric-grid" style="margin-top: 16px;">',
'<div class="metric-card"><div class="metric-label">作品数</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.count)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均播放</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_play)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均点赞</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_like)) + '</div></div>',
'<div class="metric-card"><div class="metric-label">平均分享</div><div class="metric-value">' + escapeHtml(formatNumber(account.video_summary?.avg_share)) + '</div></div>',
'</div>',
safeArray(account.tags).length ? '<div class="chips" style="margin-top:16px;">' + safeArray(account.tags).slice(0, 18).map((tag) => '<span class="chip">' + escapeHtml(tag) + '</span>').join("") + '</div>' : '',
syncErrors.length ? '<div class="link-item" style="margin-top:16px;"><strong>同步提示</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(syncErrors.join(" / ")) + '</div></div>' : '',
'<div class="link-item" style="margin-top:16px;"><strong>完整作品库已载入</strong><div class="meta" style="margin-top:8px;">当前账号共有 ' + escapeHtml(formatNumber(videoMeta.total_count || account.video_summary?.count)) + ' 条作品,其中视频 ' + escapeHtml(formatNumber(videoMeta.video_count)) + ' 条、图文 ' + escapeHtml(formatNumber(videoMeta.image_text_count)) + ' 条。</div><div class="meta" style="margin-top:6px;">请直接使用下方“作品工作台”按发布时间、AI 打分、受欢迎程度和作品类型查看完整列表,并可逐条打开原作品链接。</div></div>'
].join("");
analysisReportsEl.innerHTML = reports.length ? reports.map((report) => {
return [
'<div class="report-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">',
'<strong>' + escapeHtml(report.focus_text || "默认分析") + '</strong>',
'<span class="pill">' + escapeHtml(formatDateTime(report.created_at)) + '</span>',
'</div>',
Number(report.duplicate_count || 1) > 1 ? '<div class="meta" style="margin-top:8px;">已折叠 ' + escapeHtml(String(Number(report.duplicate_count) - 1)) + ' 条同主题历史</div>' : '',
safeArray(report.suggestions).length ? safeArray(report.suggestions).map(renderAccountSuggestion).join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
'</div>'
].join("");
}).join("") : '<p class="empty-state">这个账号还没有分析报告。你可以直接点上面的“运行分析”。</p>';
snapshotSummaryEl.innerHTML = [
workspace.latest_public_snapshot ? '<div class="link-item"><strong>最新 public 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_public_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_public_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_public_snapshot.field_count) + '</div></div>' : '',
workspace.latest_creator_snapshot ? '<div class="link-item"><strong>最新 creator 快照</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(workspace.latest_creator_snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">采集时间:' + escapeHtml(formatDateTime(workspace.latest_creator_snapshot.collected_at)) + ',字段数 ' + escapeHtml(workspace.latest_creator_snapshot.field_count) + '</div></div>' : '',
(!workspace.latest_public_snapshot && !workspace.latest_creator_snapshot) ? '<p class="empty-state">当前没有可展示的最新快照。</p>' : ''
].join("");
snapshotListEl.innerHTML = workbenchState.snapshots.length ? workbenchState.snapshots.map((snapshot) => {
const selected = workbenchState.selectedSnapshotId === snapshot.id;
return '<button type="button" class="snapshot-item ' + (selected ? "active" : "") + '" data-snapshot-id="' + escapeHtml(snapshot.id) + '"><strong>' + escapeHtml(snapshot.snapshot_type || "snapshot") + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(snapshot.source_url || "-") + '</div><div class="meta" style="margin-top:6px;">字段 ' + escapeHtml(snapshot.field_count) + ' / ' + escapeHtml(formatDateTime(snapshot.collected_at)) + '</div></button>';
}).join("") : '<p class="empty-state">这个账号当前没有快照记录。</p>';
renderSnapshotDetail();
linkedAccountsEl.innerHTML = linkedAccounts.length ? [
'<h4 style="margin:0;">已保存对标账号</h4>',
linkedAccounts.map((link) => '<div class="link-item"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;"><strong>' + escapeHtml(link.target_nickname || link.target_profile_url || "未命名对标") + '</strong><span class="pill">' + escapeHtml(link.relation_type || "benchmark") + '</span></div><div class="meta" style="margin-top:8px;">' + escapeHtml(link.target_profile_url || "-") + '</div><div class="meta" style="margin-top:6px;">' + escapeHtml(link.note || "无备注") + '</div></div>').join("")
].join("") : '<p class="empty-state">这个账号还没有保存对标关系。</p>';
similarSearchListEl.innerHTML = similarSearches.length ? similarSearches.map((search) => {
const selected = workbenchState.similarSearchDetail?.id === search.id;
return '<button type="button" class="similar-item ' + (selected ? "active" : "") + '" data-search-id="' + escapeHtml(search.id) + '"><strong>' + escapeHtml(safeArray(search.keywords).slice(0, 4).join(" / ") || search.id) + '</strong><div class="meta" style="margin-top:8px;">' + escapeHtml(formatDateTime(search.created_at)) + '</div></button>';
}).join("") : '<p class="empty-state">这个账号还没有相似搜索记录。</p>';
renderSimilarSearchDetail();
renderVideos();
}
async function loadSnapshotDetail(snapshotId) {
if (!snapshotId || !workbenchState.selectedAccountId) {
workbenchState.selectedSnapshotId = "";
workbenchState.selectedSnapshotDetail = null;
renderSnapshotDetail();
return;
}
workbenchState.selectedSnapshotId = snapshotId;
renderWorkspace();
try {
workbenchState.selectedSnapshotDetail = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/snapshots/" + encodeURIComponent(snapshotId));
} catch (error) {
workbenchState.selectedSnapshotDetail = {
snapshot_type: "error",
source_url: "",
summary: { error: error.message },
fields: []
};
}
renderSnapshotDetail();
renderAccountList();
}
async function loadSimilarSearchDetail(searchId) {
if (!searchId) {
workbenchState.similarSearchDetail = null;
renderSimilarSearchDetail();
renderWorkspace();
return;
}
try {
workbenchState.similarSearchDetail = await storyforgeFetch("/v2/douyin/similar-searches/" + encodeURIComponent(searchId));
} catch (error) {
workbenchState.similarSearchDetail = {
id: searchId,
keywords: [],
candidates: [],
context: { error: error.message }
};
}
renderWorkspace();
}
async function selectAccount(accountId, options = {}) {
if (!accountId) {
return;
}
const preserveFeedback = options.preserveFeedback === true;
workbenchState.selectedAccountId = accountId;
workbenchState.loadingAccountId = accountId;
if (!preserveFeedback) {
workbenchState.lastAnalysisMessage = "";
}
analysisFeedbackEl.textContent = "正在加载账号工作台...";
renderAccountList();
try {
const results = await Promise.all([
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/workspace"),
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/snapshots").catch(() => []),
storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(accountId) + "/videos?limit=1000").catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }))
]);
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
workbenchState.videoItems = safeArray(results[2]?.items);
workbenchState.videoMeta = results[2]?.meta || {};
workbenchState.topScoredVideoIds = safeArray(results[2]?.top_scored_video_ids);
workbenchState.latestVideoIds = safeArray(results[2]?.latest_video_ids);
workbenchState.highScoreThreshold = Number(results[2]?.high_score_threshold || 60);
workbenchState.selectedSnapshotId = options.snapshotId || workbenchState.snapshots[0]?.id || "";
workbenchState.selectedSnapshotDetail = null;
workbenchState.similarSearchDetail = null;
renderWorkspace();
if (workbenchState.selectedSnapshotId) {
await loadSnapshotDetail(workbenchState.selectedSnapshotId);
}
const firstSearchId = safeArray(workbenchState.selectedWorkspace?.recent_similarity_searches)[0]?.id;
if (firstSearchId) {
await loadSimilarSearchDetail(firstSearchId);
}
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage || "工作台已加载。";
} catch (error) {
analysisFeedbackEl.textContent = "加载工作台失败: " + error.message;
} finally {
workbenchState.loadingAccountId = "";
renderAccountList();
}
}
async function loadWorkbenchAccounts() {
if (!workbenchState.session) {
renderWorkbenchSession();
return;
}
accountsCountPillEl.textContent = "加载中...";
try {
workbenchState.accounts = safeArray(await storyforgeFetch("/v2/douyin/accounts"));
renderAccountList();
const selectedExists = workbenchState.accounts.some((account) => account.id === workbenchState.selectedAccountId);
const nextId = selectedExists ? workbenchState.selectedAccountId : workbenchState.accounts[0]?.id;
if (nextId) {
await selectAccount(nextId);
} else {
renderWorkspace();
analysisFeedbackEl.textContent = "当前还没有抖音账号数据。";
}
} catch (error) {
accountsCountPillEl.textContent = "加载失败";
analysisFeedbackEl.textContent = "加载账号列表失败: " + error.message;
}
}
async function loginWorkbench() {
const auth = getFormAuthPayload();
if (!auth.backendUrl) {
alert("请先填写 StoryForge 地址。");
return;
}
try {
let session = null;
if (auth.token) {
const account = await storyforgeFetch("/v2/me", {
backendUrl: auth.backendUrl,
token: auth.token
});
session = { backendUrl: auth.backendUrl, token: auth.token, account };
} else {
if (!auth.username || !auth.password) {
alert("登录工作台需要账号密码,或者直接提供 Token。");
return;
}
const loginPayload = await storyforgeFetch("/v2/auth/login", {
backendUrl: auth.backendUrl,
method: "POST",
body: {
username: auth.username,
password: auth.password
}
});
session = {
backendUrl: auth.backendUrl,
token: loginPayload.token,
account: loginPayload.account
};
}
persistWorkbenchSession(session);
await loadWorkbenchAccounts();
} catch (error) {
alert("工作台登录失败: " + error.message);
}
}
function renderActiveRun(run) {
activeRunId = run?.id || "";
continueButton.disabled = !run || run.status !== "awaiting_continue";
if (!run) {
activeStatusEl.innerHTML = '<p class="hint">当前没有进行中的采集任务。</p>';
logsEl.textContent = "等待任务启动…";
return;
}
const summary = run.summary || {};
const syncErrors = (summary.sync_result?.sync_errors || run.syncResponse?.sync_errors || []).join("、");
activeStatusEl.innerHTML = [
'<div class="status-line"><strong>状态</strong><span>' + escapeHtml(run.status) + '</span></div>',
'<div class="status-line"><strong>主页</strong><span>' + escapeHtml(run.profileUrl) + '</span></div>',
'<div class="status-line"><strong>开始时间</strong><span>' + escapeHtml(run.startedAt) + '</span></div>',
run.outputDir ? '<div class="status-line"><strong>输出目录</strong><span class="path">' + escapeHtml(run.outputDir) + '</span></div>' : '',
summary.status ? '<div class="status-line"><strong>采集结果</strong><span>' + escapeHtml(summary.status) + '</span></div>' : '',
summary.video_link_count !== undefined ? '<div class="status-line"><strong>作品链接数</strong><span>' + escapeHtml(summary.video_link_count) + '</span></div>' : '',
summary.captured_creator_pages !== undefined ? '<div class="status-line"><strong>creator 页面数</strong><span>' + escapeHtml(summary.captured_creator_pages) + '</span></div>' : '',
syncErrors ? '<div class="status-line"><strong>同步提示</strong><span>' + escapeHtml(syncErrors) + '</span></div>' : ''
].filter(Boolean).join("");
logsEl.textContent = (run.logs || []).join("\\n") || "任务已启动,等待日志…";
}
function renderRecentRuns(items) {
if (!items.length) {
recentRunsEl.innerHTML = '<p class="hint">还没有控制台模式的历史运行记录。</p>';
return;
}
recentRunsEl.innerHTML = items.map((item) => {
const summary = item.summary || {};
const syncResult = item.syncResponse?.account || summary.sync_result || {};
const accountId = item.syncResponse?.account?.id || summary.sync_result?.account_id || "";
return [
'<article class="recent-item">',
'<div style="display:flex;justify-content:space-between;gap:12px;align-items:center;">',
'<strong>' + escapeHtml(summary.profile_url || item.id) + '</strong>',
'<span class="pill">' + escapeHtml(summary.status || "unknown") + '</span>',
'</div>',
'<p class="meta" style="margin:10px 0 0;">作品链接 ' + escapeHtml(summary.video_link_count ?? "-") + 'creator 页面 ' + escapeHtml(summary.captured_creator_pages ?? "-") + '</p>',
syncResult.nickname ? '<p class="meta" style="margin:8px 0 0;">同步账号:' + escapeHtml(syncResult.nickname) + '</p>' : '',
item.outputDir ? '<div class="path" style="margin-top:8px;">' + escapeHtml(item.outputDir) + '</div>' : '',
accountId ? '<div class="inline-actions" style="margin-top:10px;"><button class="secondary" type="button" data-open-account-id="' + escapeHtml(accountId) + '">打开工作台</button></div>' : '',
'</article>'
].join("");
}).join("");
}
async function refreshStatus() {
const response = await fetch("/api/status", { cache: "no-store" });
const payload = await response.json();
renderActiveRun(payload.activeRun);
renderRecentRuns(payload.recentRuns || []);
}
function loadSavedValues() {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || "{}");
if (saved.profileUrl) document.getElementById("profile-url").value = saved.profileUrl;
if (saved.backendUrl) document.getElementById("backend-url").value = saved.backendUrl;
if (saved.username) document.getElementById("username").value = saved.username;
if (saved.token) tokenInputEl.value = saved.token;
if (saved.note) document.getElementById("note").value = saved.note;
if (saved.maxVideos !== undefined) document.getElementById("max-videos").value = saved.maxVideos;
if (saved.syncEnabled !== undefined) document.getElementById("sync-enabled").checked = Boolean(saved.syncEnabled);
if (saved.headless !== undefined) document.getElementById("headless").checked = Boolean(saved.headless);
if (saved.skipCreatorCenter !== undefined) document.getElementById("skip-creator-center").checked = Boolean(saved.skipCreatorCenter);
if (saved.allowCreatorCenterFallback !== undefined) document.getElementById("allow-fallback").checked = Boolean(saved.allowCreatorCenterFallback);
} catch {}
updateTokenUi();
}
function saveValues(payload) {
const saved = {
profileUrl: payload.profileUrl,
backendUrl: payload.backendUrl,
username: payload.username,
token: payload.token,
note: payload.note,
maxVideos: payload.maxVideos,
syncEnabled: payload.syncEnabled,
headless: payload.headless,
skipCreatorCenter: payload.skipCreatorCenter,
allowCreatorCenterFallback: payload.allowCreatorCenterFallback
};
localStorage.setItem(storageKey, JSON.stringify(saved));
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = {
profileUrl: document.getElementById("profile-url").value.trim(),
backendUrl: document.getElementById("backend-url").value.trim(),
username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value,
storyforgeUsername: document.getElementById("username").value.trim(),
storyforgePassword: document.getElementById("password").value,
token: tokenInputEl.value.trim(),
storyforgeToken: tokenInputEl.value.trim(),
note: document.getElementById("note").value.trim(),
maxVideos: document.getElementById("max-videos").value,
syncEnabled: document.getElementById("sync-enabled").checked,
headless: document.getElementById("headless").checked,
skipCreatorCenter: document.getElementById("skip-creator-center").checked,
allowCreatorCenterFallback: document.getElementById("allow-fallback").checked
};
if (payload.syncEnabled && !payload.token && workbenchState.session?.token && workbenchState.session.backendUrl === payload.backendUrl) {
payload.token = workbenchState.session.token;
payload.storyforgeToken = workbenchState.session.token;
}
if (payload.syncEnabled && !payload.token && (!payload.username || !payload.password)) {
alert("当前页面没有读到 StoryForge 账号或密码。请重新输入,或直接填 Token。");
return;
}
saveValues(payload);
const response = await fetch("/api/start", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) {
alert(result.error || "启动失败");
return;
}
await refreshStatus();
});
tokenInputEl.addEventListener("focus", () => {
tokenDetailsEl.open = true;
});
tokenInputEl.addEventListener("input", updateTokenUi);
tokenDetailsEl.addEventListener("toggle", () => {
if (tokenDetailsEl.open) {
tokenSummaryEl.textContent = tokenInputEl.value.trim() ? "已填写 Token" : "填写后可跳过密码";
} else {
updateTokenUi();
}
});
workbenchLoginButton.addEventListener("click", loginWorkbench);
workbenchRefreshButton.addEventListener("click", async () => {
if (!workbenchState.session) {
await loginWorkbench();
return;
}
await loadWorkbenchAccounts();
});
workbenchLogoutButton.addEventListener("click", () => {
clearWorkbenchSession();
analysisFeedbackEl.textContent = "会话已清除。";
});
reloadSelectedAccountButton.addEventListener("click", async () => {
if (!workbenchState.selectedAccountId) {
return;
}
await selectAccount(workbenchState.selectedAccountId);
});
runAnalysisButton.addEventListener("click", async () => {
if (!workbenchState.selectedAccountId) {
alert("请先选择一个账号。");
return;
}
const modelId = analysisModelSelectEl.value;
analysisFeedbackEl.textContent = "正在调用 Agent 生成分析结论...";
runAnalysisButton.disabled = true;
try {
const workspace = workbenchState.selectedWorkspace || {};
const linkedIds = safeArray(workspace.linked_accounts)
.map((item) => item.target_account_id)
.filter(Boolean);
const result = await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/analysis", {
method: "POST",
body: {
model_profile_ids: modelId ? [modelId] : [],
linked_account_ids: linkedIds,
include_linked_accounts: true,
include_recent_similar_candidates: true,
max_videos: Math.max(1, Math.min(20, Number.parseInt(analysisMaxVideosEl.value || "12", 10) || 12)),
extra_focus: analysisFocusEl.value.trim(),
temperature: 0.35
}
});
workbenchState.lastAnalysisMessage = "分析完成,已生成 " + safeArray(result.suggestions).length + " 条建议。";
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
} catch (error) {
workbenchState.lastAnalysisMessage = "分析失败: " + error.message;
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
} finally {
runAnalysisButton.disabled = false;
}
});
analyzeTopVideosButton.addEventListener("click", async () => {
if (!workbenchState.selectedAccountId) {
alert("请先选择一个账号。");
return;
}
analyzeTopVideosButton.disabled = true;
analysisFeedbackEl.textContent = "正在自动分析高分作品...";
try {
await storyforgeFetch("/v2/douyin/accounts/" + encodeURIComponent(workbenchState.selectedAccountId) + "/videos/analyze-top", {
method: "POST",
body: {
model_profile_id: analysisModelSelectEl.value || null,
top_video_count: Math.max(2, Math.min(8, Number.parseInt(analysisMaxVideosEl.value || "6", 10) || 6)),
min_score: Number(workbenchState.highScoreThreshold || 45),
temperature: 0.2
}
});
workbenchState.lastAnalysisMessage = "高分作品自动分析已更新。";
analysisFeedbackEl.textContent = workbenchState.lastAnalysisMessage;
await selectAccount(workbenchState.selectedAccountId, { preserveFeedback: true });
} catch (error) {
analysisFeedbackEl.textContent = "高分作品自动分析失败: " + error.message;
} finally {
analyzeTopVideosButton.disabled = false;
}
});
accountsFilterEl.addEventListener("input", renderAccountList);
videosScopeEl.addEventListener("change", renderVideos);
videosSortEl.addEventListener("change", renderVideos);
videosTypeFilterEl.addEventListener("change", renderVideos);
videosTagFilterEl.addEventListener("input", renderVideos);
videosQueryFilterEl.addEventListener("input", renderVideos);
document.addEventListener("click", async (event) => {
const accountButton = event.target.closest("[data-account-id]");
if (accountButton) {
await selectAccount(accountButton.getAttribute("data-account-id"));
return;
}
const snapshotButton = event.target.closest("[data-snapshot-id]");
if (snapshotButton) {
await loadSnapshotDetail(snapshotButton.getAttribute("data-snapshot-id"));
return;
}
const searchButton = event.target.closest("[data-search-id]");
if (searchButton) {
await loadSimilarSearchDetail(searchButton.getAttribute("data-search-id"));
return;
}
const openAccountButton = event.target.closest("[data-open-account-id]");
if (openAccountButton) {
if (!workbenchState.session) {
await loginWorkbench();
}
const targetId = openAccountButton.getAttribute("data-open-account-id");
if (targetId) {
if (!workbenchState.accounts.length) {
await loadWorkbenchAccounts();
}
await selectAccount(targetId);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
}
});
continueButton.addEventListener("click", async () => {
if (!activeRunId) {
return;
}
const response = await fetch("/api/runs/" + encodeURIComponent(activeRunId) + "/continue", {
method: "POST"
});
const result = await response.json();
if (!response.ok) {
alert(result.error || "继续失败");
return;
}
await refreshStatus();
});
refreshButton.addEventListener("click", refreshStatus);
loadSavedValues();
loadWorkbenchSession();
renderWorkbenchSession();
renderAccountList();
renderWorkspace();
if (workbenchState.session) {
loadWorkbenchAccounts();
}
refreshStatus();
setInterval(refreshStatus, 1500);
</script>
</body>
</html>`;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", "http://127.0.0.1");
try {
if (req.method === "GET" && url.pathname === "/") {
sendHtml(res, renderPage("full"));
return;
}
if (req.method === "GET" && url.pathname === "/workbench") {
sendHtml(res, renderPage("workbench"));
return;
}
if (req.method === "GET" && url.pathname === "/api/status") {
const activeRun = getActiveRun();
if (activeRun) {
await refreshRunArtifacts(activeRun);
}
sendJson(res, 200, {
activeRun: serializeRun(activeRun),
recentRuns: await listRecentRuns()
});
return;
}
if (req.method === "POST" && url.pathname === "/api/start") {
const payload = await readJsonBody(req);
const run = await startRun(payload);
sendJson(res, 200, { run: serializeRun(run) });
return;
}
if (req.method === "POST" && /^\/api\/runs\/[^/]+\/continue$/.test(url.pathname)) {
const runId = decodeURIComponent(url.pathname.split("/")[3] || "");
const run = await continueRun(runId);
sendJson(res, 200, { run: serializeRun(run) });
return;
}
sendJson(res, 404, { error: "Not found" });
} catch (error) {
sendJson(res, 500, { error: error?.message || String(error) });
}
});
ensureDir(DEFAULT_OUTPUT_ROOT)
.then(() => {
server.listen(DEFAULT_PORT, "127.0.0.1", () => {
console.log(`StoryForge Douyin control panel: http://127.0.0.1:${DEFAULT_PORT}`);
});
})
.catch((error) => {
console.error(error?.stack || String(error));
process.exitCode = 1;
});