11
This commit is contained in:
@@ -2,10 +2,14 @@
|
||||
|
||||
/**
|
||||
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir)
|
||||
*
|
||||
* Windows:Chrome 必须以 detached 子进程启动,否则 Node 被 Tauri 结束后会连带关掉浏览器。
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const http = require("http");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const {
|
||||
COMMON_BROWSER_ARGS,
|
||||
@@ -55,33 +59,115 @@ function userDataDir(platform, accountId) {
|
||||
return getRuntimeDataPath("browser-data", "publish", sub);
|
||||
}
|
||||
|
||||
function debugPortForKey(key) {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h = ((h << 5) + h + key.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return 9300 + (h % 700);
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function fetchDebugVersion(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(2500, () => {
|
||||
req.destroy();
|
||||
reject(new Error("timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isDebugPortOpen(port) {
|
||||
try {
|
||||
await fetchDebugVersion(port);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDebugPort(port, timeoutMs = 25000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await isDebugPortOpen(port)) return;
|
||||
await delay(400);
|
||||
}
|
||||
throw new Error(`Chrome 调试端口 ${port} 未就绪,请检查是否已安装 Chrome/Edge`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立进程启动 Chrome(与 Node 生命周期解耦)
|
||||
*/
|
||||
async function spawnDetachedChrome(executablePath, dir, port) {
|
||||
if (await isDebugPortOpen(port)) return;
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${dir}`,
|
||||
"--remote-allow-origins=*",
|
||||
...COMMON_BROWSER_ARGS.filter((a) => !a.startsWith("--user-data-dir")),
|
||||
"about:blank",
|
||||
];
|
||||
|
||||
const child = spawn(executablePath, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: false,
|
||||
});
|
||||
child.unref();
|
||||
await waitForDebugPort(port);
|
||||
}
|
||||
|
||||
async function connectPublishBrowser(puppeteer, port) {
|
||||
return puppeteer.connect({
|
||||
browserURL: `http://127.0.0.1:${port}`,
|
||||
defaultViewport: { width: 1280, height: 900 },
|
||||
});
|
||||
}
|
||||
|
||||
async function getPublishBrowser(platform, accountId) {
|
||||
const key = contextKey(platform, accountId);
|
||||
const existing = browsers.get(key);
|
||||
if (existing && existing.isConnected()) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const puppeteer = loadPuppeteer();
|
||||
const dir = userDataDir(platform, accountId);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const port = debugPortForKey(key);
|
||||
const executablePath = getChromiumExecutablePath();
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
"未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
|
||||
);
|
||||
}
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath,
|
||||
headless: false,
|
||||
userDataDir: dir,
|
||||
args: COMMON_BROWSER_ARGS,
|
||||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
defaultViewport: { width: 1280, height: 900 },
|
||||
});
|
||||
|
||||
let browser;
|
||||
if (await isDebugPortOpen(port)) {
|
||||
browser = await connectPublishBrowser(puppeteer, port);
|
||||
} else {
|
||||
await spawnDetachedChrome(executablePath, dir, port);
|
||||
browser = await connectPublishBrowser(puppeteer, port);
|
||||
}
|
||||
|
||||
browser.on("disconnected", () => browsers.delete(key));
|
||||
browsers.set(key, browser);
|
||||
return browser;
|
||||
@@ -112,7 +198,8 @@ async function newPublishPage(platform, accountId, cookies) {
|
||||
let page =
|
||||
pages.find((p) => {
|
||||
try {
|
||||
return p.url() === "about:blank";
|
||||
const u = p.url();
|
||||
return !u || u === "about:blank";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -137,16 +224,37 @@ async function newPublishPage(platform, accountId, cookies) {
|
||||
|
||||
async function closePublishBrowser(platform, accountId) {
|
||||
const key = contextKey(platform, accountId);
|
||||
const browser = browsers.get(key);
|
||||
if (!browser) return;
|
||||
const port = debugPortForKey(key);
|
||||
let browser = browsers.get(key);
|
||||
browsers.delete(key);
|
||||
try {
|
||||
const puppeteer = loadPuppeteer();
|
||||
if (!browser || !browser.isConnected()) {
|
||||
if (!(await isDebugPortOpen(port))) return;
|
||||
browser = await connectPublishBrowser(puppeteer, port);
|
||||
}
|
||||
await browser.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 Puppeteer 连接,Chrome 以 detached 进程继续运行
|
||||
*/
|
||||
async function detachPublishBrowser(platform, accountId) {
|
||||
const key = contextKey(platform, accountId);
|
||||
const browser = browsers.get(key);
|
||||
browsers.delete(key);
|
||||
try {
|
||||
if (browser && browser.isConnected()) {
|
||||
await browser.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("detachPublishBrowser:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const LOGIN_URLS = {
|
||||
douyin: "https://creator.douyin.com/",
|
||||
kuaishou: "https://cp.kuaishou.com/",
|
||||
@@ -162,4 +270,7 @@ module.exports = {
|
||||
LOGIN_URLS,
|
||||
getChromiumExecutablePath,
|
||||
closePublishBrowser,
|
||||
detachPublishBrowser,
|
||||
debugPortForKey,
|
||||
contextKey,
|
||||
};
|
||||
|
||||
64
scripts/nodejs/publish_cover_adapt.js
Normal file
64
scripts/nodejs/publish_cover_adapt.js
Normal file
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 发布前封面适配(对齐 Electron CoverAdapter / PLATFORM_REQUIREMENTS.douyin)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
|
||||
const PLATFORM_COVER = {
|
||||
douyin: { width: 1080, height: 1920, format: "jpg" },
|
||||
};
|
||||
|
||||
function ensurePublishCoverDir() {
|
||||
const dir = path.join(os.tmpdir(), "aiclient-publish-cover");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} coverPath
|
||||
* @param {'douyin'|'kuaishou'|'shipin'|'xiaohongshu'} [platform]
|
||||
* @returns {string}
|
||||
*/
|
||||
function adaptCoverForPublish(coverPath, platform = "douyin") {
|
||||
const req = PLATFORM_COVER[platform];
|
||||
if (!req) return coverPath;
|
||||
if (!fs.existsSync(coverPath)) {
|
||||
throw new Error(`封面文件不存在: ${coverPath}`);
|
||||
}
|
||||
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
console.warn("[publish_cover_adapt] 未找到 ffmpeg,使用原始封面");
|
||||
return coverPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(
|
||||
ensurePublishCoverDir(),
|
||||
`${platform}_${Date.now()}.jpg`,
|
||||
);
|
||||
const { width, height } = req;
|
||||
const vf = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
|
||||
|
||||
const r = spawnSync(
|
||||
ffmpeg,
|
||||
["-y", "-i", coverPath, "-vf", vf, "-q:v", "2", outPath],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
|
||||
if (r.status !== 0 || !fs.existsSync(outPath)) {
|
||||
console.warn(
|
||||
"[publish_cover_adapt] ffmpeg 适配失败,使用原始封面:",
|
||||
(r.stderr || "").slice(0, 200),
|
||||
);
|
||||
return coverPath;
|
||||
}
|
||||
|
||||
return outPath;
|
||||
}
|
||||
|
||||
module.exports = { adaptCoverForPublish, PLATFORM_COVER };
|
||||
@@ -1,6 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
const { newPublishPage } = require("./publish_browser.js");
|
||||
const {
|
||||
newPublishPage,
|
||||
detachPublishBrowser,
|
||||
closePublishBrowser,
|
||||
} = require("./publish_browser.js");
|
||||
const { publishToDouyin } = require("./publish_to_douyin.js");
|
||||
|
||||
const PLATFORM_LABELS = {
|
||||
@@ -22,13 +26,20 @@ async function publishToPlatform(platform, accountId, cookies, params) {
|
||||
const keepOpen =
|
||||
Boolean(params.keepPageOpen) ||
|
||||
Boolean(result?.keepPageOpen) ||
|
||||
result?.status === "manual_pending";
|
||||
if (!keepOpen) {
|
||||
result?.status === "manual_pending" ||
|
||||
result?.status === "verification_required";
|
||||
if (keepOpen) {
|
||||
globalThis.__native?.emitProgress?.(
|
||||
"浏览器已保留(独立进程),请在页面中检查并手动点击发布",
|
||||
);
|
||||
await detachPublishBrowser(platform, accountId);
|
||||
} else {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await closePublishBrowser(platform, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,160 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 抖音创作者中心发布(对齐 Electron publishToDouyin,Puppeteer 版)
|
||||
* 抖音创作者中心发布(对齐 zhenqianba Electron publishToDouyin,Puppeteer 版)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { delay } = require("./publish_browser.js");
|
||||
const { adaptCoverForPublish } = require("./publish_cover_adapt.js");
|
||||
|
||||
async function clickIfVisible(page, selector) {
|
||||
const el = await page.$(selector);
|
||||
if (!el) return false;
|
||||
const UPLOAD_URL = "https://creator.douyin.com/creator-micro/content/upload";
|
||||
const PUBLISH_CONFIRM_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
const PUBLISH_SUCCESS_TEXTS = [
|
||||
"发布成功",
|
||||
"已发布",
|
||||
"作品发布成功",
|
||||
"发布完成",
|
||||
"上传成功",
|
||||
];
|
||||
|
||||
const UPLOAD_PAGE_MARKERS = ["/creator-micro/content/upload"];
|
||||
|
||||
const VERIFICATION_TEXT_RE =
|
||||
/短信验证码|接收短信验证码|请输入验证码|输入验证码|手机验证码|安全验证|验证手机号|获取验证码|发送验证码|发送短信验证码|验证码错误/;
|
||||
|
||||
const VERIFICATION_INPUT_SELECTORS = [
|
||||
'input[placeholder*="验证码"]',
|
||||
'input[placeholder*="输入验证码"]',
|
||||
'input[maxlength="6"]',
|
||||
'input[maxlength="4"]',
|
||||
];
|
||||
|
||||
const VERIFICATION_ACTION_TEXTS = ["获取验证码", "发送验证码", "验证", "确认"];
|
||||
|
||||
function progress(msg) {
|
||||
globalThis.__native?.emitProgress?.(msg);
|
||||
}
|
||||
|
||||
function log(msg, fields) {
|
||||
globalThis.__native?.log?.("info", msg, fields || null);
|
||||
}
|
||||
|
||||
/** @returns {import('puppeteer').Frame[] | import('puppeteer').Page[]} */
|
||||
function getSearchFrames(page) {
|
||||
try {
|
||||
await el.click();
|
||||
return true;
|
||||
return [page.mainFrame(), ...page.frames()];
|
||||
} catch {
|
||||
return false;
|
||||
return [page];
|
||||
}
|
||||
}
|
||||
|
||||
function isPrimaryPublishLabel(text) {
|
||||
const t = String(text || "").replace(/\s+/g, "");
|
||||
if (!t.includes("发布")) return false;
|
||||
if (t.includes("定时") || t.includes("草稿") || t.includes("取消")) return false;
|
||||
return (
|
||||
t === "发布" ||
|
||||
t === "确定发布" ||
|
||||
t === "立即发布" ||
|
||||
t.endsWith("发布")
|
||||
);
|
||||
}
|
||||
|
||||
async function clickPublishButton(page) {
|
||||
const handles = await page.$$('button, [role="button"]');
|
||||
for (const btn of handles) {
|
||||
let text = "";
|
||||
let visible = false;
|
||||
/** @param {import('puppeteer').Frame | import('puppeteer').Page} frame */
|
||||
async function isVisibleElement(frame, el) {
|
||||
if (!el) return false;
|
||||
try {
|
||||
text = await page.evaluate((el) => (el.textContent || "").trim(), btn);
|
||||
visible = await page.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return frame.evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const style = window.getComputedStyle(node);
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
!el.disabled &&
|
||||
el.getAttribute("aria-disabled") !== "true"
|
||||
!node.disabled &&
|
||||
node.getAttribute("aria-disabled") !== "true"
|
||||
);
|
||||
}, btn);
|
||||
}, el);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Frame | import('puppeteer').Page} frame */
|
||||
async function clickElement(frame, el) {
|
||||
await frame.evaluate((node) => {
|
||||
node.scrollIntoView({ block: "center", inline: "center" });
|
||||
}, el);
|
||||
await delay(250);
|
||||
await el.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string[]} texts
|
||||
* @param {{ exact?: boolean }} [opts]
|
||||
*/
|
||||
async function clickButtonByTextsInFrames(page, texts, opts = {}) {
|
||||
const exact = Boolean(opts.exact);
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
let handles = [];
|
||||
try {
|
||||
handles = await frame.$$("button, [role='button']");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!visible || !isPrimaryPublishLabel(text)) continue;
|
||||
for (const btn of handles) {
|
||||
let t = "";
|
||||
try {
|
||||
await btn.evaluate((el) =>
|
||||
el.scrollIntoView({ block: "center", inline: "center" }),
|
||||
);
|
||||
await delay(300);
|
||||
await btn.click();
|
||||
return true;
|
||||
t = await frame.evaluate((el) => (el.textContent || "").trim(), btn);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const hit = texts.some((w) => (exact ? t === w : t.includes(w)));
|
||||
if (!hit) continue;
|
||||
if (!(await isVisibleElement(frame, btn))) continue;
|
||||
try {
|
||||
await clickElement(frame, btn);
|
||||
return { ok: true, text: t, frameUrl: frame.url?.() || "" };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
async function typeIntoFirst(page, selectors, text) {
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function fillFirstSelectorInFrames(page, selectors, text) {
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
for (const sel of selectors) {
|
||||
const el = await page.$(sel);
|
||||
if (!el) continue;
|
||||
let el = null;
|
||||
try {
|
||||
await el.click({ clickCount: 3 });
|
||||
await page.keyboard.down("Control");
|
||||
await page.keyboard.press("a");
|
||||
await page.keyboard.up("Control");
|
||||
await page.keyboard.press("Backspace");
|
||||
await el.type(text, { delay: 30 });
|
||||
if (sel.startsWith("//")) {
|
||||
const found = await frame.$x(sel);
|
||||
el = found[0] || null;
|
||||
} else {
|
||||
el = await frame.$(sel);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!(await isVisibleElement(frame, el))) continue;
|
||||
try {
|
||||
await clickElement(frame, el);
|
||||
await frame.evaluate((node) => {
|
||||
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
|
||||
node.value = "";
|
||||
} else if (node.isContentEditable) {
|
||||
node.textContent = "";
|
||||
}
|
||||
}, el);
|
||||
await el.type(String(text), { delay: 30 });
|
||||
return true;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function publishToDouyin(page, params) {
|
||||
const { videoPath, coverPath, title, tags = [], autoPublish } = params;
|
||||
if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在");
|
||||
if (!fs.existsSync(coverPath)) throw new Error("封面文件不存在");
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在打开抖音上传页…");
|
||||
const uploadUrl = "https://creator.douyin.com/creator-micro/content/upload";
|
||||
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
|
||||
let currentUrl = page.url();
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function ensureLoggedIn(page) {
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
|
||||
throw new Error("未登录抖音,请先在步骤 07 点击「登录平台」");
|
||||
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||||
}
|
||||
|
||||
let avatar = await page.$("#header-avatar");
|
||||
@@ -105,133 +163,645 @@ async function publishToDouyin(page, params) {
|
||||
avatar = await page.$("#header-avatar");
|
||||
}
|
||||
if (!avatar) {
|
||||
throw new Error("抖音登录已过期,请重新登录");
|
||||
if (page.url().includes("login") || page.url().includes("passport")) {
|
||||
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||||
}
|
||||
|
||||
if (!page.url().includes("content/upload")) {
|
||||
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
throw new Error("抖音登录已过期,请重新扫码登录");
|
||||
}
|
||||
log("已检测到 #header-avatar,登录有效");
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在上传视频…");
|
||||
await page.waitForSelector('input[type="file"]', { timeout: 60000 });
|
||||
const fileInputs = await page.$$('input[type="file"]');
|
||||
if (!fileInputs.length) throw new Error("未找到视频上传控件");
|
||||
await fileInputs[0].uploadFile(videoPath);
|
||||
await delay(5000);
|
||||
|
||||
globalThis.__native?.emitProgress?.("等待视频处理…");
|
||||
const start = Date.now();
|
||||
let ready = false;
|
||||
while (Date.now() - start < 300000 && !ready) {
|
||||
const titleBox = await page.$('textarea[placeholder*="标题"], input[placeholder*="标题"]');
|
||||
if (titleBox) {
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function uploadVideo(page, videoPath) {
|
||||
progress("正在上传视频…");
|
||||
try {
|
||||
const disabled = await page.evaluate((el) => el.disabled, titleBox);
|
||||
if (!disabled) ready = true;
|
||||
await page.waitForSelector('input[type="file"]', {
|
||||
timeout: 60000,
|
||||
visible: false,
|
||||
});
|
||||
} catch {
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
if (!ready) await delay(2000);
|
||||
throw new Error("上传页面加载超时,请检查网络或重新登录");
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在上传封面…");
|
||||
const fileInput = await page.$('input[type="file"]');
|
||||
if (!fileInput) throw new Error("未找到文件上传输入框");
|
||||
|
||||
try {
|
||||
await clickIfVisible(page, 'button');
|
||||
const buttons = await page.$$("button");
|
||||
for (const btn of buttons) {
|
||||
const text = await page.evaluate((el) => el.textContent || "", btn);
|
||||
if (String(text).includes("选择封面")) {
|
||||
await btn.click();
|
||||
await delay(2000);
|
||||
break;
|
||||
await fileInput.uploadFile(videoPath);
|
||||
log("视频文件已上传", { videoPath });
|
||||
} catch (e) {
|
||||
throw new Error(`视频上传失败:${e.message || e}`);
|
||||
}
|
||||
}
|
||||
const imgInput = await page.$('input[type="file"][accept*="image"]');
|
||||
if (imgInput) {
|
||||
await imgInput.uploadFile(coverPath);
|
||||
await delay(3000);
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function waitForVideoProcessing(page) {
|
||||
progress("等待视频处理…");
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body?.innerText || "";
|
||||
return body.includes("预览视频") || body.includes("预览");
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
),
|
||||
delay(60000),
|
||||
]);
|
||||
log("检测到预览相关文案");
|
||||
} catch {
|
||||
log("未检测到预览元素,继续等待上传完成");
|
||||
}
|
||||
const doneBtns = await page.$$("button");
|
||||
for (const btn of doneBtns) {
|
||||
const text = await page.evaluate((el) => el.textContent || "", btn);
|
||||
if (String(text).trim() === "完成") {
|
||||
await btn.click();
|
||||
|
||||
await delay(2000);
|
||||
|
||||
const maxWait = 300000;
|
||||
const start = Date.now();
|
||||
let uploadComplete = false;
|
||||
|
||||
while (!uploadComplete && Date.now() - start < maxWait) {
|
||||
uploadComplete = await page.evaluate(() => {
|
||||
const success = document.querySelector(
|
||||
'[data-e2e="upload-success"], .upload-success, [class*="success"]',
|
||||
);
|
||||
if (success) {
|
||||
const r = success.getBoundingClientRect();
|
||||
if (r.width > 2 && r.height > 2) return true;
|
||||
}
|
||||
const titleInput = document.querySelector(
|
||||
'textarea[placeholder*="标题"], input[placeholder*="标题"]',
|
||||
);
|
||||
return Boolean(titleInput && !titleInput.disabled);
|
||||
});
|
||||
|
||||
if (uploadComplete) {
|
||||
log("视频上传/处理完成");
|
||||
break;
|
||||
}
|
||||
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||
if (elapsed > 0 && elapsed % 10 === 0) {
|
||||
progress(`视频处理中… 已等待 ${elapsed} 秒`);
|
||||
}
|
||||
await delay(2000);
|
||||
}
|
||||
|
||||
if (!uploadComplete) {
|
||||
log("视频处理等待超时,继续尝试填写表单");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function uploadCover(page, coverPath) {
|
||||
progress("正在上传封面…");
|
||||
await delay(3000);
|
||||
|
||||
try {
|
||||
const xpaths = [
|
||||
'//div[contains(@class, "coverControl-CjlzqC")][.//div[contains(text(), "竖封面")]]',
|
||||
'//div[contains(@class, "coverControl")][.//div[contains(text(), "竖封面")]]',
|
||||
];
|
||||
|
||||
let coverControl = null;
|
||||
for (const xp of xpaths) {
|
||||
const nodes = await page.$x(xp);
|
||||
if (nodes[0] && (await isVisibleElement(page, nodes[0]))) {
|
||||
coverControl = nodes[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!coverControl) {
|
||||
log('未找到封面区域,尝试点击「选择封面」');
|
||||
await clickButtonByTextsInFrames(page, ["选择封面"]);
|
||||
await delay(3000);
|
||||
for (const xp of xpaths) {
|
||||
const nodes = await page.$x(xp);
|
||||
if (nodes[0] && (await isVisibleElement(page, nodes[0]))) {
|
||||
coverControl = nodes[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (coverControl) {
|
||||
await clickElement(page, coverControl);
|
||||
await delay(2000);
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
await page.waitForSelector(".upload-BvM5FF .semi-upload-hidden-input", {
|
||||
timeout: 10000,
|
||||
visible: false,
|
||||
});
|
||||
const semiInput = await page.$(".upload-BvM5FF .semi-upload-hidden-input");
|
||||
if (semiInput) {
|
||||
await semiInput.uploadFile(coverPath);
|
||||
uploaded = true;
|
||||
log("封面已通过 semi-upload 上传");
|
||||
await delay(3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("封面上传跳过:", e.message);
|
||||
log(`semi-upload 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在填写标题与话题…");
|
||||
const titleText = String(title || "").slice(0, 30);
|
||||
await typeIntoFirst(
|
||||
if (!uploaded) {
|
||||
for (const sel of [
|
||||
'input[type="file"][accept*="image/png"]',
|
||||
'input[type="file"][accept*="image"]',
|
||||
]) {
|
||||
const imageInput = await page.$(sel);
|
||||
if (imageInput) {
|
||||
await imageInput.uploadFile(coverPath);
|
||||
uploaded = true;
|
||||
log("封面已通过通用图片输入框上传");
|
||||
await delay(3000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const completeXPath = '//*[@id="tooltip-container"]//button[1]';
|
||||
const completeNodes = await page.$x(completeXPath);
|
||||
if (completeNodes[0] && (await isVisibleElement(page, completeNodes[0]))) {
|
||||
await delay(2000);
|
||||
await clickElement(page, completeNodes[0]);
|
||||
log("已点击封面弹窗「完成」");
|
||||
} else {
|
||||
await clickButtonByTextsInFrames(page, ["完成"], { exact: true });
|
||||
}
|
||||
await delay(2000);
|
||||
log("封面设置完成");
|
||||
} else {
|
||||
log("未找到封面控制区域,跳过封面上传");
|
||||
}
|
||||
} catch (e) {
|
||||
log(`封面上传失败(继续): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function dismissDialogs(page) {
|
||||
try {
|
||||
await delay(1000);
|
||||
await clickButtonByTextsInFrames(page, ["暂不设置", "暂不", "跳过"]);
|
||||
await delay(500);
|
||||
await page.evaluate(() => {
|
||||
for (const el of document.querySelectorAll(
|
||||
".semi-modal-close, [aria-label='close'], [class*='close-icon'], [class*='CloseBtn']",
|
||||
)) {
|
||||
try {
|
||||
el.click();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
await delay(500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function fillTitle(page, title) {
|
||||
progress("正在填写标题…");
|
||||
const titleText = String(title || "").slice(0, 20);
|
||||
const ok = await fillFirstSelectorInFrames(
|
||||
page,
|
||||
[
|
||||
'input[placeholder*="填写作品标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'input[placeholder*="作品"]',
|
||||
'//input[contains(@placeholder, "填写作品标题")]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
],
|
||||
titleText,
|
||||
);
|
||||
if (ok) log(`标题已填写: "${titleText}"`);
|
||||
else log("未找到标题输入框");
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
const tagList = Array.isArray(tags) ? tags : [];
|
||||
if (tagList.length) {
|
||||
const descOk = await typeIntoFirst(
|
||||
page,
|
||||
[
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function fillTagsAsDescription(page, tags) {
|
||||
const tagList = Array.isArray(tags) ? tags.filter((t) => String(t).trim()) : [];
|
||||
if (!tagList.length) return;
|
||||
|
||||
progress("正在填写话题标签…");
|
||||
const descSelectors = [
|
||||
'textarea[placeholder*="描述"]',
|
||||
'textarea[placeholder*="简介"]',
|
||||
'motion[contenteditable="true"]',
|
||||
'textarea[placeholder*="作品"]',
|
||||
'[class*="desc"] textarea',
|
||||
'[class*="content"] textarea',
|
||||
'div[contenteditable="true"]',
|
||||
],
|
||||
"",
|
||||
);
|
||||
if (descOk) {
|
||||
for (const tag of tagList) {
|
||||
const t = String(tag).trim();
|
||||
if (!t) continue;
|
||||
const clean = t.startsWith("#") ? t : `#${t}`;
|
||||
await page.keyboard.type(clean, { delay: 40 });
|
||||
];
|
||||
|
||||
let target = null;
|
||||
let targetFrame = page.mainFrame();
|
||||
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
for (const sel of descSelectors) {
|
||||
try {
|
||||
const el = await frame.$(sel);
|
||||
if (await isVisibleElement(frame, el)) {
|
||||
target = el;
|
||||
targetFrame = frame;
|
||||
log(`找到描述输入框: ${sel}`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
if (target) break;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
log("未找到描述输入框,跳过话题");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clickElement(targetFrame, target);
|
||||
await delay(300);
|
||||
await targetFrame.evaluate((node) => {
|
||||
if (node.tagName === "TEXTAREA" || node.tagName === "INPUT") {
|
||||
node.value = "";
|
||||
} else {
|
||||
node.textContent = "";
|
||||
}
|
||||
}, target);
|
||||
await delay(300);
|
||||
|
||||
for (let i = 0; i < tagList.length; i++) {
|
||||
const tag = String(tagList[i]).trim();
|
||||
const clean = tag.startsWith("#") ? tag : `#${tag}`;
|
||||
log(`输入话题 ${i + 1}/${tagList.length}: ${clean}`);
|
||||
await page.keyboard.type(clean, { delay: 50 });
|
||||
await delay(300);
|
||||
await page.keyboard.press("Space");
|
||||
await delay(400);
|
||||
await delay(500);
|
||||
|
||||
const hasPopup = await page.evaluate(() => {
|
||||
for (const n of document.querySelectorAll(
|
||||
'[class*="topic"], [class*="hashtag"]',
|
||||
)) {
|
||||
const r = n.getBoundingClientRect();
|
||||
if (r.width > 2 && r.height > 2) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasPopup) {
|
||||
await page.keyboard.press("Enter");
|
||||
await delay(300);
|
||||
}
|
||||
}
|
||||
log("所有话题已输入完成");
|
||||
} catch (e) {
|
||||
log(`填写话题失败: ${e.message}`);
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function detectVerificationInFrames(page) {
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
try {
|
||||
const hit = await frame.evaluate(
|
||||
(reSource, inputSels, actionTexts) => {
|
||||
const re = new RegExp(reSource);
|
||||
const bodyText = document.body?.innerText || "";
|
||||
const hasText = re.test(bodyText);
|
||||
const inputs = [];
|
||||
for (const sel of inputSels) {
|
||||
inputs.push(...document.querySelectorAll(sel));
|
||||
}
|
||||
const visibleInputs = inputs.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 2 && r.height > 2;
|
||||
});
|
||||
const actionBtns = [...document.querySelectorAll("button")].filter(
|
||||
(btn) => {
|
||||
const t = (btn.textContent || "").trim();
|
||||
return actionTexts.includes(t);
|
||||
},
|
||||
);
|
||||
return {
|
||||
detected:
|
||||
(hasText && visibleInputs.length > 0) ||
|
||||
(visibleInputs.length > 0 && actionBtns.length > 0),
|
||||
};
|
||||
},
|
||||
VERIFICATION_TEXT_RE.source,
|
||||
VERIFICATION_INPUT_SELECTORS,
|
||||
VERIFICATION_ACTION_TEXTS,
|
||||
);
|
||||
if (hit.detected) return { detected: true, frameUrl: frame.url?.() || "" };
|
||||
} catch {
|
||||
/* next frame */
|
||||
}
|
||||
}
|
||||
return { detected: false };
|
||||
}
|
||||
|
||||
/** @param {import('puppeteer').Page} page */
|
||||
async function detectPublishSuccessInFrames(page) {
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
try {
|
||||
const textHit = await frame.evaluate((texts) => {
|
||||
const body = document.body?.innerText || "";
|
||||
return texts.some((t) => body.includes(t));
|
||||
}, PUBLISH_SUCCESS_TEXTS);
|
||||
if (textHit) {
|
||||
return { detected: true, reason: "success_text" };
|
||||
}
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
|
||||
if (!autoPublish) {
|
||||
const currentUrl = page.url();
|
||||
const leftUpload =
|
||||
UPLOAD_PAGE_MARKERS.length > 0 &&
|
||||
!UPLOAD_PAGE_MARKERS.some((m) => currentUrl.includes(m));
|
||||
if (
|
||||
leftUpload &&
|
||||
!currentUrl.includes("login") &&
|
||||
!currentUrl.includes("verify") &&
|
||||
!currentUrl.includes("passport")
|
||||
) {
|
||||
return { detected: true, reason: `url_changed:${currentUrl}` };
|
||||
}
|
||||
return { detected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对齐 Electron waitForPublishConfirmation(15 分钟)
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
async function waitForPublishConfirmation(page) {
|
||||
const start = Date.now();
|
||||
let verificationDetected = false;
|
||||
|
||||
while (Date.now() - start < PUBLISH_CONFIRM_TIMEOUT_MS) {
|
||||
if (page.isClosed()) {
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message: "内容已填写,请在浏览器中检查并手动点击发布",
|
||||
videoUrl: page.url(),
|
||||
keepPageOpen: true,
|
||||
status: verificationDetected ? "verification_required" : "failed",
|
||||
message: verificationDetected
|
||||
? "发布页面已关闭,验证码流程未完成"
|
||||
: "发布页面已关闭",
|
||||
videoUrl: "",
|
||||
videoId: "",
|
||||
keepPageOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在点击发布…");
|
||||
const clicked = await clickPublishButton(page);
|
||||
|
||||
if (!clicked) {
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message: "未找到发布按钮,请手动发布",
|
||||
videoUrl: page.url(),
|
||||
keepPageOpen: true,
|
||||
};
|
||||
const verification = await detectVerificationInFrames(page);
|
||||
if (verification.detected) {
|
||||
if (!verificationDetected) {
|
||||
verificationDetected = true;
|
||||
progress("检测到验证码,请在浏览器中完成验证");
|
||||
log("进入验证码等待", { frameUrl: verification.frameUrl });
|
||||
}
|
||||
}
|
||||
|
||||
await delay(5000);
|
||||
const success = await detectPublishSuccessInFrames(page);
|
||||
if (success.detected) {
|
||||
log("检测到发布成功", { reason: success.reason });
|
||||
return {
|
||||
success: true,
|
||||
status: "success",
|
||||
message: "已提交发布",
|
||||
message: "发布成功",
|
||||
videoUrl: page.url(),
|
||||
videoId: "",
|
||||
keepPageOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
await delay(1500);
|
||||
}
|
||||
|
||||
if (verificationDetected) {
|
||||
return {
|
||||
success: false,
|
||||
status: "verification_required",
|
||||
message: "检测到短信验证码,请在浏览器中完成验证码输入并确认发布",
|
||||
videoUrl: page.url(),
|
||||
videoId: "",
|
||||
requiresVerification: true,
|
||||
keepPageOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message:
|
||||
"已点击发布按钮,但未检测到明确的发布成功标识,请在浏览器中确认是否仍需验证码或手动确认",
|
||||
videoUrl: page.url(),
|
||||
videoId: "",
|
||||
keepPageOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 对齐 Electron 发布按钮多选择器策略(跨 frame)
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
async function clickPublishButtonElectron(page) {
|
||||
const cssBuckets = [
|
||||
"button.primary",
|
||||
'button[class*="primary"]',
|
||||
'button[class*="button-"]',
|
||||
"#popover-tip-container button",
|
||||
"button",
|
||||
];
|
||||
|
||||
for (const frame of getSearchFrames(page)) {
|
||||
for (const bucket of cssBuckets) {
|
||||
let handles = [];
|
||||
try {
|
||||
handles = await frame.$$(bucket);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const btn of handles) {
|
||||
let trimmedText = "";
|
||||
try {
|
||||
trimmedText = await frame.evaluate(
|
||||
(el) => (el.textContent || "").trim(),
|
||||
btn,
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
log(`检查发布按钮: bucket=${bucket}, text="${trimmedText}"`);
|
||||
if (trimmedText !== "发布" && trimmedText !== "确定发布") continue;
|
||||
if (!(await isVisibleElement(frame, btn))) continue;
|
||||
try {
|
||||
await clickElement(frame, btn);
|
||||
log(`已点击发布按钮: text="${trimmedText}"`);
|
||||
return { clicked: true, text: trimmedText };
|
||||
} catch (e) {
|
||||
log(`点击发布按钮失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { clicked: false };
|
||||
}
|
||||
|
||||
/** 部分账号点击「发布」后会弹出二次确认 */
|
||||
async function clickSecondaryPublishConfirm(page) {
|
||||
await delay(1500);
|
||||
for (const want of ["确定发布", "确认发布", "发布"]) {
|
||||
const r = await clickButtonByTextsInFrames(page, [want], {
|
||||
exact: want === "发布",
|
||||
});
|
||||
if (r.ok) {
|
||||
log(`已点击二次确认: ${r.text}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function manualPending(message, page) {
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message,
|
||||
videoUrl: page.url(),
|
||||
videoId: "",
|
||||
keepPageOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {object} params
|
||||
*/
|
||||
async function publishToDouyin(page, params) {
|
||||
const {
|
||||
videoPath,
|
||||
coverPath,
|
||||
title,
|
||||
tags = [],
|
||||
autoPublish,
|
||||
description,
|
||||
} = params;
|
||||
|
||||
if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在");
|
||||
if (!fs.existsSync(coverPath)) throw new Error("封面文件不存在");
|
||||
|
||||
page.setDefaultTimeout(60000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
|
||||
let adaptedCoverPath = coverPath;
|
||||
try {
|
||||
progress("正在适配封面尺寸…");
|
||||
adaptedCoverPath = adaptCoverForPublish(coverPath, "douyin");
|
||||
if (adaptedCoverPath !== coverPath) {
|
||||
log("封面已适配为抖音竖版 JPG", { adaptedCoverPath });
|
||||
}
|
||||
} catch (e) {
|
||||
log(`封面适配跳过: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
log("开始发布到抖音");
|
||||
progress("正在打开抖音上传页…");
|
||||
await page.goto(UPLOAD_URL, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
log(`当前 URL: ${page.url()}`);
|
||||
|
||||
await ensureLoggedIn(page);
|
||||
|
||||
if (!page.url().includes("content/upload")) {
|
||||
await page.goto(UPLOAD_URL, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
}
|
||||
|
||||
await uploadVideo(page, videoPath);
|
||||
await waitForVideoProcessing(page);
|
||||
await uploadCover(page, adaptedCoverPath);
|
||||
await dismissDialogs(page);
|
||||
await fillTitle(page, title);
|
||||
|
||||
const tagSource =
|
||||
Array.isArray(tags) && tags.length
|
||||
? tags
|
||||
: String(description || "")
|
||||
.split(/[,,\s#]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await fillTagsAsDescription(page, tagSource);
|
||||
|
||||
progress("表单填写完成");
|
||||
log("=== 所有准备工作已完成 ===");
|
||||
|
||||
if (!autoPublish) {
|
||||
progress("请在浏览器中检查并手动点击发布(窗口将保持打开)");
|
||||
await delay(30000);
|
||||
return manualPending(
|
||||
"准备工作已完成,请在浏览器中手动点击发布按钮",
|
||||
page,
|
||||
);
|
||||
}
|
||||
|
||||
progress("正在查找并点击发布按钮…");
|
||||
await delay(2000);
|
||||
|
||||
let publishTriggered = false;
|
||||
const clickResult = await clickPublishButtonElectron(page);
|
||||
if (clickResult.clicked) {
|
||||
publishTriggered = true;
|
||||
await clickSecondaryPublishConfirm(page);
|
||||
try {
|
||||
await Promise.race([
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body?.innerText || "";
|
||||
return body.includes("发布成功") || body.includes("上传成功");
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
delay(5000),
|
||||
]);
|
||||
log("发布操作已提交");
|
||||
} catch {
|
||||
log("未检测到即时成功提示,进入发布确认等待");
|
||||
}
|
||||
} else {
|
||||
log("未找到发布按钮,请手动点击");
|
||||
}
|
||||
|
||||
if (publishTriggered) {
|
||||
progress("等待发布结果(最长 15 分钟)…");
|
||||
return waitForPublishConfirmation(page);
|
||||
}
|
||||
|
||||
return manualPending(
|
||||
"未找到明确的发布按钮,请在浏览器中手动确认发布",
|
||||
page,
|
||||
);
|
||||
} catch (err) {
|
||||
try {
|
||||
const shotDir =
|
||||
process.env.AICLIENT_DATA_DIR ||
|
||||
path.join(os.homedir(), ".aiclient");
|
||||
const shotPath = path.join(shotDir, "douyin_publish_error.png");
|
||||
await page.screenshot({ path: shotPath, fullPage: false });
|
||||
log(`错误截图已保存: ${shotPath}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publishToDouyin };
|
||||
|
||||
@@ -40,6 +40,7 @@ globalThis.__nodejsMain = async function main(params) {
|
||||
description,
|
||||
tags,
|
||||
autoPublish,
|
||||
keepPageOpen: !autoPublish,
|
||||
});
|
||||
results.push({
|
||||
platform: label,
|
||||
@@ -70,8 +71,16 @@ globalThis.__nodejsMain = async function main(params) {
|
||||
const pending = results.filter((r) => r.pending).length;
|
||||
const failed = results.length - ok - pending;
|
||||
|
||||
const keepBrowserOpen = results.some(
|
||||
(r) =>
|
||||
r.pending ||
|
||||
r.status === "manual_pending" ||
|
||||
r.status === "verification_required",
|
||||
);
|
||||
|
||||
return {
|
||||
success: ok > 0 || pending > 0,
|
||||
keepBrowserOpen,
|
||||
results,
|
||||
summary: { ok, pending, failed, total: results.length },
|
||||
message:
|
||||
|
||||
Reference in New Issue
Block a user