diff --git a/scripts/nodejs/publish_browser.js b/scripts/nodejs/publish_browser.js index 0e78b71..11301aa 100644 --- a/scripts/nodejs/publish_browser.js +++ b/scripts/nodejs/publish_browser.js @@ -2,10 +2,14 @@ /** * 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir) + * + * Windows:Chrome 必须以 detached 子进程启动,否则 Node 被 Tauri 结束后会连带关掉浏览器。 */ const fs = require("fs"); const path = require("path"); const os = require("os"); +const http = require("http"); +const { spawn } = require("child_process"); const { COMMON_BROWSER_ARGS, @@ -55,33 +59,115 @@ function userDataDir(platform, accountId) { return getRuntimeDataPath("browser-data", "publish", sub); } +function debugPortForKey(key) { + let h = 5381; + for (let i = 0; i < key.length; i++) { + h = ((h << 5) + h + key.charCodeAt(i)) >>> 0; + } + return 9300 + (h % 700); +} + function delay(ms) { return new Promise((r) => setTimeout(r, ms)); } +function fetchDebugVersion(port) { + return new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }); + req.on("error", reject); + req.setTimeout(2500, () => { + req.destroy(); + reject(new Error("timeout")); + }); + }); +} + +async function isDebugPortOpen(port) { + try { + await fetchDebugVersion(port); + return true; + } catch { + return false; + } +} + +async function waitForDebugPort(port, timeoutMs = 25000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isDebugPortOpen(port)) return; + await delay(400); + } + throw new Error(`Chrome 调试端口 ${port} 未就绪,请检查是否已安装 Chrome/Edge`); +} + +/** + * 独立进程启动 Chrome(与 Node 生命周期解耦) + */ +async function spawnDetachedChrome(executablePath, dir, port) { + if (await isDebugPortOpen(port)) return; + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${dir}`, + "--remote-allow-origins=*", + ...COMMON_BROWSER_ARGS.filter((a) => !a.startsWith("--user-data-dir")), + "about:blank", + ]; + + const child = spawn(executablePath, args, { + detached: true, + stdio: "ignore", + windowsHide: false, + }); + child.unref(); + await waitForDebugPort(port); +} + +async function connectPublishBrowser(puppeteer, port) { + return puppeteer.connect({ + browserURL: `http://127.0.0.1:${port}`, + defaultViewport: { width: 1280, height: 900 }, + }); +} + async function getPublishBrowser(platform, accountId) { const key = contextKey(platform, accountId); const existing = browsers.get(key); if (existing && existing.isConnected()) { return existing; } + const puppeteer = loadPuppeteer(); const dir = userDataDir(platform, accountId); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const port = debugPortForKey(key); const executablePath = getChromiumExecutablePath(); if (!executablePath) { throw new Error( "未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH", ); } - const browser = await puppeteer.launch({ - executablePath, - headless: false, - userDataDir: dir, - args: COMMON_BROWSER_ARGS, - ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS, - defaultViewport: { width: 1280, height: 900 }, - }); + + let browser; + if (await isDebugPortOpen(port)) { + browser = await connectPublishBrowser(puppeteer, port); + } else { + await spawnDetachedChrome(executablePath, dir, port); + browser = await connectPublishBrowser(puppeteer, port); + } + browser.on("disconnected", () => browsers.delete(key)); browsers.set(key, browser); return browser; @@ -112,7 +198,8 @@ async function newPublishPage(platform, accountId, cookies) { let page = pages.find((p) => { try { - return p.url() === "about:blank"; + const u = p.url(); + return !u || u === "about:blank"; } catch { return false; } @@ -137,16 +224,37 @@ async function newPublishPage(platform, accountId, cookies) { async function closePublishBrowser(platform, accountId) { const key = contextKey(platform, accountId); - const browser = browsers.get(key); - if (!browser) return; + const port = debugPortForKey(key); + let browser = browsers.get(key); browsers.delete(key); try { + const puppeteer = loadPuppeteer(); + if (!browser || !browser.isConnected()) { + if (!(await isDebugPortOpen(port))) return; + browser = await connectPublishBrowser(puppeteer, port); + } await browser.close(); } catch { /* ignore */ } } +/** + * 断开 Puppeteer 连接,Chrome 以 detached 进程继续运行 + */ +async function detachPublishBrowser(platform, accountId) { + const key = contextKey(platform, accountId); + const browser = browsers.get(key); + browsers.delete(key); + try { + if (browser && browser.isConnected()) { + await browser.disconnect(); + } + } catch (e) { + console.warn("detachPublishBrowser:", e.message); + } +} + const LOGIN_URLS = { douyin: "https://creator.douyin.com/", kuaishou: "https://cp.kuaishou.com/", @@ -162,4 +270,7 @@ module.exports = { LOGIN_URLS, getChromiumExecutablePath, closePublishBrowser, + detachPublishBrowser, + debugPortForKey, + contextKey, }; diff --git a/scripts/nodejs/publish_cover_adapt.js b/scripts/nodejs/publish_cover_adapt.js new file mode 100644 index 0000000..05512d4 --- /dev/null +++ b/scripts/nodejs/publish_cover_adapt.js @@ -0,0 +1,64 @@ +"use strict"; + +/** + * 发布前封面适配(对齐 Electron CoverAdapter / PLATFORM_REQUIREMENTS.douyin) + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawnSync } = require("child_process"); +const pipelineNative = require("./pipeline_native.js"); + +const PLATFORM_COVER = { + douyin: { width: 1080, height: 1920, format: "jpg" }, +}; + +function ensurePublishCoverDir() { + const dir = path.join(os.tmpdir(), "aiclient-publish-cover"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * @param {string} coverPath + * @param {'douyin'|'kuaishou'|'shipin'|'xiaohongshu'} [platform] + * @returns {string} + */ +function adaptCoverForPublish(coverPath, platform = "douyin") { + const req = PLATFORM_COVER[platform]; + if (!req) return coverPath; + if (!fs.existsSync(coverPath)) { + throw new Error(`封面文件不存在: ${coverPath}`); + } + + const ffmpeg = pipelineNative.locateFfmpeg(); + if (!ffmpeg) { + console.warn("[publish_cover_adapt] 未找到 ffmpeg,使用原始封面"); + return coverPath; + } + + const outPath = path.join( + ensurePublishCoverDir(), + `${platform}_${Date.now()}.jpg`, + ); + const { width, height } = req; + const vf = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; + + const r = spawnSync( + ffmpeg, + ["-y", "-i", coverPath, "-vf", vf, "-q:v", "2", outPath], + { encoding: "utf8", windowsHide: true }, + ); + + if (r.status !== 0 || !fs.existsSync(outPath)) { + console.warn( + "[publish_cover_adapt] ffmpeg 适配失败,使用原始封面:", + (r.stderr || "").slice(0, 200), + ); + return coverPath; + } + + return outPath; +} + +module.exports = { adaptCoverForPublish, PLATFORM_COVER }; diff --git a/scripts/nodejs/publish_platform.js b/scripts/nodejs/publish_platform.js index 4ee4052..fa8e055 100644 --- a/scripts/nodejs/publish_platform.js +++ b/scripts/nodejs/publish_platform.js @@ -1,6 +1,10 @@ "use strict"; -const { newPublishPage } = require("./publish_browser.js"); +const { + newPublishPage, + detachPublishBrowser, + closePublishBrowser, +} = require("./publish_browser.js"); const { publishToDouyin } = require("./publish_to_douyin.js"); const PLATFORM_LABELS = { @@ -22,13 +26,20 @@ async function publishToPlatform(platform, accountId, cookies, params) { const keepOpen = Boolean(params.keepPageOpen) || Boolean(result?.keepPageOpen) || - result?.status === "manual_pending"; - if (!keepOpen) { + result?.status === "manual_pending" || + result?.status === "verification_required"; + if (keepOpen) { + globalThis.__native?.emitProgress?.( + "浏览器已保留(独立进程),请在页面中检查并手动点击发布", + ); + await detachPublishBrowser(platform, accountId); + } else { try { await page.close(); } catch { /* ignore */ } + await closePublishBrowser(platform, accountId); } } } diff --git a/scripts/nodejs/publish_to_douyin.js b/scripts/nodejs/publish_to_douyin.js index 6d05657..590f108 100644 --- a/scripts/nodejs/publish_to_douyin.js +++ b/scripts/nodejs/publish_to_douyin.js @@ -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 }; diff --git a/scripts/nodejs/video_publish.js b/scripts/nodejs/video_publish.js index 54d2157..27ae1d6 100644 --- a/scripts/nodejs/video_publish.js +++ b/scripts/nodejs/video_publish.js @@ -40,6 +40,7 @@ globalThis.__nodejsMain = async function main(params) { description, tags, autoPublish, + keepPageOpen: !autoPublish, }); results.push({ platform: label, @@ -70,8 +71,16 @@ globalThis.__nodejsMain = async function main(params) { const pending = results.filter((r) => r.pending).length; const failed = results.length - ok - pending; + const keepBrowserOpen = results.some( + (r) => + r.pending || + r.status === "manual_pending" || + r.status === "verification_required", + ); + return { success: ok > 0 || pending > 0, + keepBrowserOpen, results, summary: { ok, pending, failed, total: results.length }, message: