808 lines
23 KiB
JavaScript
808 lines
23 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* 抖音创作者中心发布(对齐 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");
|
||
|
||
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 {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** @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;
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** @param {import('puppeteer').Page} page */
|
||
async function ensureLoggedIn(page) {
|
||
const currentUrl = page.url();
|
||
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
|
||
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||
}
|
||
|
||
let avatar = await page.$("#header-avatar");
|
||
if (!avatar) {
|
||
await delay(5000);
|
||
avatar = await page.$("#header-avatar");
|
||
}
|
||
if (!avatar) {
|
||
if (page.url().includes("login") || page.url().includes("passport")) {
|
||
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||
}
|
||
throw new Error("抖音登录已过期,请重新扫码登录");
|
||
}
|
||
log("已检测到 #header-avatar,登录有效");
|
||
}
|
||
|
||
/** @param {import('puppeteer').Page} page */
|
||
async function uploadVideo(page, videoPath) {
|
||
progress("正在上传视频…");
|
||
try {
|
||
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;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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) {
|
||
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);
|
||
}
|
||
|
||
/** @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 */
|
||
}
|
||
}
|
||
|
||
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: "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 };
|