Files
storyforge/scripts/douyin-browser-capture/control_panel.mjs
2026-03-21 00:52:23 +08:00

1659 lines
66 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() {
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; }
.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; }
.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;
}
.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;
}
@media (max-width: 900px) {
.grid, .row, .checks, .workbench-layout, .metric-grid, .two-col { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<span class="pill">StoryForge / Douyin Browser Assist</span>
<h1 style="margin-top: 14px;">用网页点按钮,驱动真实浏览器采集抖音账号</h1>
<p>这不是无头绕反爬,而是一个可控的半自动流程。你点击“开始采集”后,脚本会打开真实 Chromium会话沿用同一份登录态。你在浏览器里登录或过滑块后回到这里点“已完成登录继续采集”系统就会继续抓取主页、creator-center并按安全规则同步进 StoryForge。</p>
</section>
<div class="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>
已有 Token可选
<input id="token" name="token" placeholder="Bearer token可替代账号密码" />
</label>
<label>
最大作品页抓取数
<input id="max-videos" name="maxVideos" type="number" min="0" max="10" value="4" />
</label>
</div>
<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" 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">
<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>
</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 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 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,
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 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 = document.getElementById("token").value.trim();
return {
backendUrl,
username,
password,
token
};
}
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.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 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>Token</strong><span class="mono">' + escapeHtml((session.token || "").slice(0, 12)) + "..." + '</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 = "";
renderSnapshotDetail();
renderSimilarSearchDetail();
return;
}
const account = workspace.account;
const videos = safeArray(account.video_summary?.videos);
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>' : '',
videos.length ? '<div class="stack" style="margin-top:16px;"><h4 style="margin:0;">最近作品</h4>' + videos.slice(0, 8).map((video) => '<div class="video-item"><strong>' + escapeHtml(video.title || video.description || video.aweme_id) + '</strong><div class="meta" style="margin-top:8px;">发布时间:' + escapeHtml(formatDateTime(video.published_at)) + '</div><div class="meta" style="margin-top:6px;">播放 ' + escapeHtml(formatNumber(video.stats?.play)) + ' / 点赞 ' + escapeHtml(formatNumber(video.stats?.like)) + ' / 评论 ' + escapeHtml(formatNumber(video.stats?.comment)) + '</div></div>').join("") + '</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>',
safeArray(report.suggestions).length ? safeArray(report.suggestions).map((suggestion) => '<div class="report-suggestion"><div class="meta">' + escapeHtml(suggestion.model_label || "模型") + ' / ' + escapeHtml(suggestion.status || "-") + '</div><div style="margin-top:8px;">' + escapeHtml(suggestion.suggestion_text || "暂无结论") + '</div></div>').join("") : '<p class="empty-state" style="margin-top:10px;">这份报告还没有 suggestion。</p>',
'</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();
}
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(() => [])
]);
workbenchState.selectedWorkspace = results[0];
workbenchState.snapshots = safeArray(results[1]);
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) document.getElementById("token").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 {}
}
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: document.getElementById("token").value.trim(),
storyforgeToken: document.getElementById("token").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();
});
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;
}
});
accountsFilterEl.addEventListener("input", renderAccountList);
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());
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;
});