11
This commit is contained in:
@@ -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);
|
||||
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 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: 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 };
|
||||
|
||||
Reference in New Issue
Block a user