#!/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 ` StoryForge Douyin Browser Assist
${isWorkbenchMode ? "StoryForge / Douyin Workbench" : "StoryForge / Douyin Browser Assist"}

${isWorkbenchMode ? "用业务工作台直接查看账号结论、作品榜单和运营分析" : "用网页点按钮,驱动真实浏览器采集抖音账号"}

${isWorkbenchMode ? "这是面向日常运营的业务页。登录后即可查看账号列表、Agent 结论、完整作品工作台、快照和对标结果。只有在需要抓取新账号时,再切到“采集控制台”。" : "这不是无头绕反爬,而是一个可控的半自动流程。你点击“开始采集”后,脚本会打开真实 Chromium,会话沿用同一份登录态。你在浏览器里登录或过滑块后,回到这里点“已完成登录,继续采集”,系统就会继续抓取主页、creator-center,并按安全规则同步进 StoryForge。"}

开始新采集

默认会导入 StoryForge;如果只是想先抓本地 bundle,可以勾选“仅采集不导入”。

已登录工作台后会自动复用当前会话 Token。通常只需要填账号密码,不需要手动维护 Token。
高级认证 未填写 Token
只有在你已经拿到 Bearer Token,或者不希望页面保存密码时,再手动填写这里。
1. 点击“开始采集”,脚本会在本机打开 Chromium。
2. 在打开的浏览器里完成登录、滑块或验证码,并确认已进入目标主页。
3. 回到这里点击“已完成登录,继续采集”。
4. 等待脚本自动抓取、写出 summary.json,并可选同步到 StoryForge。

当前任务

当前没有进行中的采集任务。

实时日志

等待任务启动…

最近运行

这里展示的是控制台模式启动过的采集任务。

Douyin Workbench

采集完成后的结构化数据、Agent 结论、快照与对标结果,都在这块工作台里查看。

登录 StoryForge 后,这里会展示抖音账号列表和分析工作台。

账号列表

0 个账号

先登录并在左侧选择一个账号,或者从“最近运行”里直接打开同步成功的账号。

如果你主要是在做账号分析和作品运营,建议直接使用上面的业务工作台;只有在补采新账号时再回到采集控制台。

`; } 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; });