11
This commit is contained in:
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir)
|
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir)
|
||||||
|
*
|
||||||
|
* Windows:Chrome 必须以 detached 子进程启动,否则 Node 被 Tauri 结束后会连带关掉浏览器。
|
||||||
*/
|
*/
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
COMMON_BROWSER_ARGS,
|
COMMON_BROWSER_ARGS,
|
||||||
@@ -55,33 +59,115 @@ function userDataDir(platform, accountId) {
|
|||||||
return getRuntimeDataPath("browser-data", "publish", sub);
|
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) {
|
function delay(ms) {
|
||||||
return new Promise((r) => setTimeout(r, 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) {
|
async function getPublishBrowser(platform, accountId) {
|
||||||
const key = contextKey(platform, accountId);
|
const key = contextKey(platform, accountId);
|
||||||
const existing = browsers.get(key);
|
const existing = browsers.get(key);
|
||||||
if (existing && existing.isConnected()) {
|
if (existing && existing.isConnected()) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const puppeteer = loadPuppeteer();
|
const puppeteer = loadPuppeteer();
|
||||||
const dir = userDataDir(platform, accountId);
|
const dir = userDataDir(platform, accountId);
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
const port = debugPortForKey(key);
|
||||||
const executablePath = getChromiumExecutablePath();
|
const executablePath = getChromiumExecutablePath();
|
||||||
if (!executablePath) {
|
if (!executablePath) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
|
"未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
executablePath,
|
let browser;
|
||||||
headless: false,
|
if (await isDebugPortOpen(port)) {
|
||||||
userDataDir: dir,
|
browser = await connectPublishBrowser(puppeteer, port);
|
||||||
args: COMMON_BROWSER_ARGS,
|
} else {
|
||||||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
await spawnDetachedChrome(executablePath, dir, port);
|
||||||
defaultViewport: { width: 1280, height: 900 },
|
browser = await connectPublishBrowser(puppeteer, port);
|
||||||
});
|
}
|
||||||
|
|
||||||
browser.on("disconnected", () => browsers.delete(key));
|
browser.on("disconnected", () => browsers.delete(key));
|
||||||
browsers.set(key, browser);
|
browsers.set(key, browser);
|
||||||
return browser;
|
return browser;
|
||||||
@@ -112,7 +198,8 @@ async function newPublishPage(platform, accountId, cookies) {
|
|||||||
let page =
|
let page =
|
||||||
pages.find((p) => {
|
pages.find((p) => {
|
||||||
try {
|
try {
|
||||||
return p.url() === "about:blank";
|
const u = p.url();
|
||||||
|
return !u || u === "about:blank";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -137,16 +224,37 @@ async function newPublishPage(platform, accountId, cookies) {
|
|||||||
|
|
||||||
async function closePublishBrowser(platform, accountId) {
|
async function closePublishBrowser(platform, accountId) {
|
||||||
const key = contextKey(platform, accountId);
|
const key = contextKey(platform, accountId);
|
||||||
const browser = browsers.get(key);
|
const port = debugPortForKey(key);
|
||||||
if (!browser) return;
|
let browser = browsers.get(key);
|
||||||
browsers.delete(key);
|
browsers.delete(key);
|
||||||
try {
|
try {
|
||||||
|
const puppeteer = loadPuppeteer();
|
||||||
|
if (!browser || !browser.isConnected()) {
|
||||||
|
if (!(await isDebugPortOpen(port))) return;
|
||||||
|
browser = await connectPublishBrowser(puppeteer, port);
|
||||||
|
}
|
||||||
await browser.close();
|
await browser.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 = {
|
const LOGIN_URLS = {
|
||||||
douyin: "https://creator.douyin.com/",
|
douyin: "https://creator.douyin.com/",
|
||||||
kuaishou: "https://cp.kuaishou.com/",
|
kuaishou: "https://cp.kuaishou.com/",
|
||||||
@@ -162,4 +270,7 @@ module.exports = {
|
|||||||
LOGIN_URLS,
|
LOGIN_URLS,
|
||||||
getChromiumExecutablePath,
|
getChromiumExecutablePath,
|
||||||
closePublishBrowser,
|
closePublishBrowser,
|
||||||
|
detachPublishBrowser,
|
||||||
|
debugPortForKey,
|
||||||
|
contextKey,
|
||||||
};
|
};
|
||||||
|
|||||||
64
scripts/nodejs/publish_cover_adapt.js
Normal file
64
scripts/nodejs/publish_cover_adapt.js
Normal file
@@ -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 };
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { newPublishPage } = require("./publish_browser.js");
|
const {
|
||||||
|
newPublishPage,
|
||||||
|
detachPublishBrowser,
|
||||||
|
closePublishBrowser,
|
||||||
|
} = require("./publish_browser.js");
|
||||||
const { publishToDouyin } = require("./publish_to_douyin.js");
|
const { publishToDouyin } = require("./publish_to_douyin.js");
|
||||||
|
|
||||||
const PLATFORM_LABELS = {
|
const PLATFORM_LABELS = {
|
||||||
@@ -22,13 +26,20 @@ async function publishToPlatform(platform, accountId, cookies, params) {
|
|||||||
const keepOpen =
|
const keepOpen =
|
||||||
Boolean(params.keepPageOpen) ||
|
Boolean(params.keepPageOpen) ||
|
||||||
Boolean(result?.keepPageOpen) ||
|
Boolean(result?.keepPageOpen) ||
|
||||||
result?.status === "manual_pending";
|
result?.status === "manual_pending" ||
|
||||||
if (!keepOpen) {
|
result?.status === "verification_required";
|
||||||
|
if (keepOpen) {
|
||||||
|
globalThis.__native?.emitProgress?.(
|
||||||
|
"浏览器已保留(独立进程),请在页面中检查并手动点击发布",
|
||||||
|
);
|
||||||
|
await detachPublishBrowser(platform, accountId);
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
await page.close();
|
await page.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
await closePublishBrowser(platform, accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,160 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 抖音创作者中心发布(对齐 Electron publishToDouyin,Puppeteer 版)
|
* 抖音创作者中心发布(对齐 zhenqianba Electron publishToDouyin,Puppeteer 版)
|
||||||
*/
|
*/
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
const { delay } = require("./publish_browser.js");
|
const { delay } = require("./publish_browser.js");
|
||||||
|
const { adaptCoverForPublish } = require("./publish_cover_adapt.js");
|
||||||
|
|
||||||
async function clickIfVisible(page, selector) {
|
const UPLOAD_URL = "https://creator.douyin.com/creator-micro/content/upload";
|
||||||
const el = await page.$(selector);
|
const PUBLISH_CONFIRM_TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
if (!el) return false;
|
|
||||||
|
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 {
|
try {
|
||||||
await el.click();
|
return [page.mainFrame(), ...page.frames()];
|
||||||
return true;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return [page];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrimaryPublishLabel(text) {
|
/** @param {import('puppeteer').Frame | import('puppeteer').Page} frame */
|
||||||
const t = String(text || "").replace(/\s+/g, "");
|
async function isVisibleElement(frame, el) {
|
||||||
if (!t.includes("发布")) return false;
|
if (!el) return false;
|
||||||
if (t.includes("定时") || t.includes("草稿") || t.includes("取消")) return false;
|
|
||||||
return (
|
|
||||||
t === "发布" ||
|
|
||||||
t === "确定发布" ||
|
|
||||||
t === "立即发布" ||
|
|
||||||
t.endsWith("发布")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickPublishButton(page) {
|
|
||||||
const handles = await page.$$('button, [role="button"]');
|
|
||||||
for (const btn of handles) {
|
|
||||||
let text = "";
|
|
||||||
let visible = false;
|
|
||||||
try {
|
try {
|
||||||
text = await page.evaluate((el) => (el.textContent || "").trim(), btn);
|
return frame.evaluate((node) => {
|
||||||
visible = await page.evaluate((el) => {
|
const rect = node.getBoundingClientRect();
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.width < 2 || rect.height < 2) return false;
|
if (rect.width < 2 || rect.height < 2) return false;
|
||||||
const style = window.getComputedStyle(el);
|
const style = window.getComputedStyle(node);
|
||||||
return (
|
return (
|
||||||
style.display !== "none" &&
|
style.display !== "none" &&
|
||||||
style.visibility !== "hidden" &&
|
style.visibility !== "hidden" &&
|
||||||
!el.disabled &&
|
!node.disabled &&
|
||||||
el.getAttribute("aria-disabled") !== "true"
|
node.getAttribute("aria-disabled") !== "true"
|
||||||
);
|
);
|
||||||
}, btn);
|
}, 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 {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!visible || !isPrimaryPublishLabel(text)) continue;
|
for (const btn of handles) {
|
||||||
|
let t = "";
|
||||||
try {
|
try {
|
||||||
await btn.evaluate((el) =>
|
t = await frame.evaluate((el) => (el.textContent || "").trim(), btn);
|
||||||
el.scrollIntoView({ block: "center", inline: "center" }),
|
} catch {
|
||||||
);
|
continue;
|
||||||
await delay(300);
|
}
|
||||||
await btn.click();
|
const hit = texts.some((w) => (exact ? t === w : t.includes(w)));
|
||||||
return true;
|
if (!hit) continue;
|
||||||
|
if (!(await isVisibleElement(frame, btn))) continue;
|
||||||
|
try {
|
||||||
|
await clickElement(frame, btn);
|
||||||
|
return { ok: true, text: t, frameUrl: frame.url?.() || "" };
|
||||||
} catch {
|
} catch {
|
||||||
/* try next */
|
/* try next */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
}
|
||||||
|
return { ok: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function typeIntoFirst(page, selectors, text) {
|
/** @param {import('puppeteer').Page} page */
|
||||||
|
async function fillFirstSelectorInFrames(page, selectors, text) {
|
||||||
|
for (const frame of getSearchFrames(page)) {
|
||||||
for (const sel of selectors) {
|
for (const sel of selectors) {
|
||||||
const el = await page.$(sel);
|
let el = null;
|
||||||
if (!el) continue;
|
|
||||||
try {
|
try {
|
||||||
await el.click({ clickCount: 3 });
|
if (sel.startsWith("//")) {
|
||||||
await page.keyboard.down("Control");
|
const found = await frame.$x(sel);
|
||||||
await page.keyboard.press("a");
|
el = found[0] || null;
|
||||||
await page.keyboard.up("Control");
|
} else {
|
||||||
await page.keyboard.press("Backspace");
|
el = await frame.$(sel);
|
||||||
await el.type(text, { delay: 30 });
|
}
|
||||||
|
} 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;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
/* try next */
|
/* try next */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishToDouyin(page, params) {
|
/** @param {import('puppeteer').Page} page */
|
||||||
const { videoPath, coverPath, title, tags = [], autoPublish } = params;
|
async function ensureLoggedIn(page) {
|
||||||
if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在");
|
const currentUrl = page.url();
|
||||||
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();
|
|
||||||
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
|
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
|
||||||
throw new Error("未登录抖音,请先在步骤 07 点击「登录平台」");
|
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
let avatar = await page.$("#header-avatar");
|
let avatar = await page.$("#header-avatar");
|
||||||
@@ -105,133 +163,645 @@ async function publishToDouyin(page, params) {
|
|||||||
avatar = await page.$("#header-avatar");
|
avatar = await page.$("#header-avatar");
|
||||||
}
|
}
|
||||||
if (!avatar) {
|
if (!avatar) {
|
||||||
throw new Error("抖音登录已过期,请重新登录");
|
if (page.url().includes("login") || page.url().includes("passport")) {
|
||||||
|
throw new Error("未登录抖音,请先在步骤 07 账号管理中扫码登录");
|
||||||
}
|
}
|
||||||
|
throw new Error("抖音登录已过期,请重新扫码登录");
|
||||||
if (!page.url().includes("content/upload")) {
|
|
||||||
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
|
|
||||||
await delay(3000);
|
|
||||||
}
|
}
|
||||||
|
log("已检测到 #header-avatar,登录有效");
|
||||||
|
}
|
||||||
|
|
||||||
globalThis.__native?.emitProgress?.("正在上传视频…");
|
/** @param {import('puppeteer').Page} page */
|
||||||
await page.waitForSelector('input[type="file"]', { timeout: 60000 });
|
async function uploadVideo(page, videoPath) {
|
||||||
const fileInputs = await page.$$('input[type="file"]');
|
progress("正在上传视频…");
|
||||||
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 {
|
try {
|
||||||
const disabled = await page.evaluate((el) => el.disabled, titleBox);
|
await page.waitForSelector('input[type="file"]', {
|
||||||
if (!disabled) ready = true;
|
timeout: 60000,
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
ready = true;
|
throw new Error("上传页面加载超时,请检查网络或重新登录");
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ready) await delay(2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.__native?.emitProgress?.("正在上传封面…");
|
const fileInput = await page.$('input[type="file"]');
|
||||||
|
if (!fileInput) throw new Error("未找到文件上传输入框");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await clickIfVisible(page, 'button');
|
await fileInput.uploadFile(videoPath);
|
||||||
const buttons = await page.$$("button");
|
log("视频文件已上传", { videoPath });
|
||||||
for (const btn of buttons) {
|
} catch (e) {
|
||||||
const text = await page.evaluate((el) => el.textContent || "", btn);
|
throw new Error(`视频上传失败:${e.message || e}`);
|
||||||
if (String(text).includes("选择封面")) {
|
|
||||||
await btn.click();
|
|
||||||
await delay(2000);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const imgInput = await page.$('input[type="file"][accept*="image"]');
|
|
||||||
if (imgInput) {
|
|
||||||
await imgInput.uploadFile(coverPath);
|
|
||||||
await delay(3000);
|
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("未检测到预览元素,继续等待上传完成");
|
||||||
}
|
}
|
||||||
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);
|
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;
|
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) {
|
} catch (e) {
|
||||||
console.warn("封面上传跳过:", e.message);
|
log(`semi-upload 失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.__native?.emitProgress?.("正在填写标题与话题…");
|
if (!uploaded) {
|
||||||
const titleText = String(title || "").slice(0, 30);
|
for (const sel of [
|
||||||
await typeIntoFirst(
|
'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,
|
page,
|
||||||
[
|
[
|
||||||
'input[placeholder*="填写作品标题"]',
|
'input[placeholder*="填写作品标题"]',
|
||||||
'input[placeholder*="标题"]',
|
'input[placeholder*="标题"]',
|
||||||
|
'input[placeholder*="作品"]',
|
||||||
|
'//input[contains(@placeholder, "填写作品标题")]',
|
||||||
'textarea[placeholder*="标题"]',
|
'textarea[placeholder*="标题"]',
|
||||||
],
|
],
|
||||||
titleText,
|
titleText,
|
||||||
);
|
);
|
||||||
|
if (ok) log(`标题已填写: "${titleText}"`);
|
||||||
|
else log("未找到标题输入框");
|
||||||
|
await delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
const tagList = Array.isArray(tags) ? tags : [];
|
/** @param {import('puppeteer').Page} page */
|
||||||
if (tagList.length) {
|
async function fillTagsAsDescription(page, tags) {
|
||||||
const descOk = await typeIntoFirst(
|
const tagList = Array.isArray(tags) ? tags.filter((t) => String(t).trim()) : [];
|
||||||
page,
|
if (!tagList.length) return;
|
||||||
[
|
|
||||||
|
progress("正在填写话题标签…");
|
||||||
|
const descSelectors = [
|
||||||
'textarea[placeholder*="描述"]',
|
'textarea[placeholder*="描述"]',
|
||||||
'textarea[placeholder*="简介"]',
|
'textarea[placeholder*="简介"]',
|
||||||
'motion[contenteditable="true"]',
|
'textarea[placeholder*="作品"]',
|
||||||
|
'[class*="desc"] textarea',
|
||||||
|
'[class*="content"] textarea',
|
||||||
'div[contenteditable="true"]',
|
'div[contenteditable="true"]',
|
||||||
],
|
];
|
||||||
"",
|
|
||||||
);
|
let target = null;
|
||||||
if (descOk) {
|
let targetFrame = page.mainFrame();
|
||||||
for (const tag of tagList) {
|
|
||||||
const t = String(tag).trim();
|
for (const frame of getSearchFrames(page)) {
|
||||||
if (!t) continue;
|
for (const sel of descSelectors) {
|
||||||
const clean = t.startsWith("#") ? t : `#${t}`;
|
try {
|
||||||
await page.keyboard.type(clean, { delay: 40 });
|
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 page.keyboard.press("Space");
|
||||||
await delay(400);
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: "manual_pending",
|
status: verificationDetected ? "verification_required" : "failed",
|
||||||
message: "内容已填写,请在浏览器中检查并手动点击发布",
|
message: verificationDetected
|
||||||
videoUrl: page.url(),
|
? "发布页面已关闭,验证码流程未完成"
|
||||||
keepPageOpen: true,
|
: "发布页面已关闭",
|
||||||
|
videoUrl: "",
|
||||||
|
videoId: "",
|
||||||
|
keepPageOpen: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.__native?.emitProgress?.("正在点击发布…");
|
const verification = await detectVerificationInFrames(page);
|
||||||
const clicked = await clickPublishButton(page);
|
if (verification.detected) {
|
||||||
|
if (!verificationDetected) {
|
||||||
if (!clicked) {
|
verificationDetected = true;
|
||||||
return {
|
progress("检测到验证码,请在浏览器中完成验证");
|
||||||
success: false,
|
log("进入验证码等待", { frameUrl: verification.frameUrl });
|
||||||
status: "manual_pending",
|
}
|
||||||
message: "未找到发布按钮,请手动发布",
|
|
||||||
videoUrl: page.url(),
|
|
||||||
keepPageOpen: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await delay(5000);
|
const success = await detectPublishSuccessInFrames(page);
|
||||||
|
if (success.detected) {
|
||||||
|
log("检测到发布成功", { reason: success.reason });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: "success",
|
status: "success",
|
||||||
message: "已提交发布",
|
message: "发布成功",
|
||||||
videoUrl: page.url(),
|
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 };
|
module.exports = { publishToDouyin };
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ globalThis.__nodejsMain = async function main(params) {
|
|||||||
description,
|
description,
|
||||||
tags,
|
tags,
|
||||||
autoPublish,
|
autoPublish,
|
||||||
|
keepPageOpen: !autoPublish,
|
||||||
});
|
});
|
||||||
results.push({
|
results.push({
|
||||||
platform: label,
|
platform: label,
|
||||||
@@ -70,8 +71,16 @@ globalThis.__nodejsMain = async function main(params) {
|
|||||||
const pending = results.filter((r) => r.pending).length;
|
const pending = results.filter((r) => r.pending).length;
|
||||||
const failed = results.length - ok - pending;
|
const failed = results.length - ok - pending;
|
||||||
|
|
||||||
|
const keepBrowserOpen = results.some(
|
||||||
|
(r) =>
|
||||||
|
r.pending ||
|
||||||
|
r.status === "manual_pending" ||
|
||||||
|
r.status === "verification_required",
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: ok > 0 || pending > 0,
|
success: ok > 0 || pending > 0,
|
||||||
|
keepBrowserOpen,
|
||||||
results,
|
results,
|
||||||
summary: { ok, pending, failed, total: results.length },
|
summary: { ok, pending, failed, total: results.length },
|
||||||
message:
|
message:
|
||||||
|
|||||||
Reference in New Issue
Block a user