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