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

@@ -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);
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 {
return [page.mainFrame(), ...page.frames()];
} catch {
return [page];
}
}
/** @param {import('puppeteer').Frame | import('puppeteer').Page} frame */
async function isVisibleElement(frame, el) {
if (!el) return false;
try {
await el.click();
return true;
return frame.evaluate((node) => {
const rect = node.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return false;
const style = window.getComputedStyle(node);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
!node.disabled &&
node.getAttribute("aria-disabled") !== "true"
);
}, el);
} catch {
return false;
}
}
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("发布")
);
/** @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();
}
async function clickPublishButton(page) {
const handles = await page.$$('button, [role="button"]');
for (const btn of handles) {
let text = "";
let visible = false;
/**
* @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 {
text = await page.evaluate((el) => (el.textContent || "").trim(), btn);
visible = await page.evaluate((el) => {
const rect = el.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return false;
const style = window.getComputedStyle(el);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
!el.disabled &&
el.getAttribute("aria-disabled") !== "true"
);
}, btn);
handles = await frame.$$("button, [role='button']");
} catch {
continue;
}
if (!visible || !isPrimaryPublishLabel(text)) continue;
try {
await btn.evaluate((el) =>
el.scrollIntoView({ block: "center", inline: "center" }),
);
await delay(300);
await btn.click();
return true;
} catch {
/* try next */
for (const btn of handles) {
let t = "";
try {
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 { ok: false };
}
/** @param {import('puppeteer').Page} page */
async function fillFirstSelectorInFrames(page, selectors, text) {
for (const frame of getSearchFrames(page)) {
for (const sel of selectors) {
let el = null;
try {
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 typeIntoFirst(page, selectors, text) {
for (const sel of selectors) {
const el = await page.$(sel);
if (!el) continue;
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 });
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("content/upload")) {
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
await delay(3000);
}
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) {
try {
const disabled = await page.evaluate((el) => el.disabled, titleBox);
if (!disabled) ready = true;
} catch {
ready = true;
}
if (page.url().includes("login") || page.url().includes("passport")) {
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
}
if (!ready) await delay(2000);
throw new Error("抖音登录已过期,请重新扫码登录");
}
log("已检测到 #header-avatar登录有效");
}
globalThis.__native?.emitProgress?.("正在上传封面…");
/** @param {import('puppeteer').Page} page */
async function uploadVideo(page, videoPath) {
progress("正在上传视频…");
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);
await page.waitForSelector('input[type="file"]', {
timeout: 60000,
visible: false,
});
} catch {
throw new Error("上传页面加载超时,请检查网络或重新登录");
}
const fileInput = await page.$('input[type="file"]');
if (!fileInput) throw new Error("未找到文件上传输入框");
try {
await fileInput.uploadFile(videoPath);
log("视频文件已上传", { videoPath });
} catch (e) {
throw new Error(`视频上传失败:${e.message || e}`);
}
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("未检测到预览元素,继续等待上传完成");
}
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;
}
}
const imgInput = await page.$('input[type="file"][accept*="image"]');
if (imgInput) {
await imgInput.uploadFile(coverPath);
if (!coverControl) {
log('未找到封面区域,尝试点击「选择封面」');
await clickButtonByTextsInFrames(page, ["选择封面"]);
await delay(3000);
}
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);
break;
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) {
log(`semi-upload 失败: ${e.message}`);
}
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) {
console.warn("封面上传跳过:", e.message);
log(`封面上传失败(继续): ${e.message}`);
}
}
globalThis.__native?.emitProgress?.("正在填写标题与话题…");
const titleText = String(title || "").slice(0, 30);
await typeIntoFirst(
/** @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,
[
'textarea[placeholder*="描述"]',
'textarea[placeholder*="简介"]',
'motion[contenteditable="true"]',
'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 });
await page.keyboard.press("Space");
await delay(400);
/** @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*="简介"]',
'textarea[placeholder*="作品"]',
'[class*="desc"] textarea',
'[class*="content"] textarea',
'div[contenteditable="true"]',
];
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(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: verificationDetected ? "verification_required" : "failed",
message: verificationDetected
? "发布页面已关闭,验证码流程未完成"
: "发布页面已关闭",
videoUrl: "",
videoId: "",
keepPageOpen: false,
};
}
const verification = await detectVerificationInFrames(page);
if (verification.detected) {
if (!verificationDetected) {
verificationDetected = true;
progress("检测到验证码,请在浏览器中完成验证");
log("进入验证码等待", { frameUrl: verification.frameUrl });
}
}
const success = await detectPublishSuccessInFrames(page);
if (success.detected) {
log("检测到发布成功", { reason: success.reason });
return {
success: true,
status: "success",
message: "发布成功",
videoUrl: page.url(),
videoId: "",
keepPageOpen: false,
};
}
await delay(1500);
}
if (verificationDetected) {
return {
success: false,
status: "manual_pending",
message: "内容已填写,请在浏览器中检查并手动点击发布",
status: "verification_required",
message: "检测到短信验证码,请在浏览器中完成验证码输入并确认发布",
videoUrl: page.url(),
videoId: "",
requiresVerification: true,
keepPageOpen: true,
};
}
globalThis.__native?.emitProgress?.("正在点击发布…");
const clicked = await clickPublishButton(page);
if (!clicked) {
return {
success: false,
status: "manual_pending",
message: "未找到发布按钮,请手动发布",
videoUrl: page.url(),
keepPageOpen: true,
};
}
await delay(5000);
return {
success: true,
status: "success",
message: "已提交发布",
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 };