This commit is contained in:
949036910@qq.com
2026-06-03 18:49:37 +08:00
parent 9d9a6f62c9
commit c81c4e31e0
5 changed files with 944 additions and 179 deletions

View File

@@ -2,10 +2,14 @@
/**
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir
*
* WindowsChrome 必须以 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,
};

View 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 };

View File

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

View File

@@ -1,102 +1,160 @@
"use strict";
/**
* 抖音创作者中心发布(对齐 Electron publishToDouyinPuppeteer 版)
* 抖音创作者中心发布(对齐 zhenqianba Electron publishToDouyinPuppeteer 版)
*/
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 waitForPublishConfirmation15 分钟)
* @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 };

View File

@@ -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: