#!/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 ` StoryForge Douyin Browser Assist
StoryForge / Douyin Browser Assist

用网页点按钮,驱动真实浏览器采集抖音账号

这不是无头绕反爬,而是一个可控的半自动流程。你点击“开始采集”后,脚本会打开真实 Chromium,会话沿用同一份登录态。你在浏览器里登录或过滑块后,回到这里点“已完成登录,继续采集”,系统就会继续抓取主页、creator-center,并按安全规则同步进 StoryForge。

开始新采集

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

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