Files
yaoyaoai/scripts/nodejs/publish_to_douyin.js
949036910@qq.com c81c4e31e0 11
2026-06-03 18:49:37 +08:00

808 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* 抖音创作者中心发布(对齐 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");
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 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: "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 };