#!/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,可以勾选“仅采集不导入”。
1. 点击“开始采集”,脚本会在本机打开 Chromium。
2. 在打开的浏览器里完成登录、滑块或验证码,并确认已进入目标主页。
3. 回到这里点击“已完成登录,继续采集”。
4. 等待脚本自动抓取、写出 summary.json,并可选同步到 StoryForge。
最近运行
这里展示的是控制台模式启动过的采集任务。
Douyin Workbench
采集完成后的结构化数据、Agent 结论、快照与对标结果,都在这块工作台里查看。
登录 StoryForge 后,这里会展示抖音账号列表和分析工作台。
先登录并在左侧选择一个账号,或者从“最近运行”里直接打开同步成功的账号。
对标与相似账号
相似搜索详情
选择一次相似搜索后,这里会展示候选账号和推荐理由。
`;
}
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;
});