478 lines
13 KiB
JavaScript
478 lines
13 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* 发布用 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,
|
||
BROWSER_IGNORE_DEFAULT_ARGS,
|
||
STEALTH_INIT_SCRIPT,
|
||
DEFAULT_USER_AGENT,
|
||
} = require("./douyin_browser_constants.js");
|
||
|
||
const browsers = new Map();
|
||
/** 扫码登录 / 打开账号:需可见窗口,使用 puppeteer.launch(Node 等待登录期间保持存活) */
|
||
const loginBrowsers = new Map();
|
||
|
||
function getRuntimeDataPath(...parts) {
|
||
const base = process.env.AICLIENT_DATA_DIR || path.join(os.homedir(), ".aiclient");
|
||
return path.join(base, ...parts);
|
||
}
|
||
|
||
function getChromiumExecutablePath() {
|
||
const fromEnv =
|
||
process.env.AICLIENT_CHROMIUM_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||
const candidates = [];
|
||
if (process.platform === "win32") {
|
||
const pf = process.env.ProgramFiles || "C:\\Program Files";
|
||
const pfx86 =
|
||
process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||
const local =
|
||
process.env.LOCALAPPDATA ||
|
||
path.join(os.homedir(), "AppData", "Local");
|
||
const administrator_appdata="C:\\Users\\Administrator\\AppData\\Local";
|
||
candidates.push(
|
||
path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||
path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
|
||
path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
||
path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||
path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||
path.join(local, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||
path.join(administrator_appdata, "Google", "Chrome", "bin","chrome.exe"),
|
||
);
|
||
}
|
||
return candidates.find((p) => p && fs.existsSync(p));
|
||
}
|
||
|
||
function loadPuppeteer() {
|
||
try {
|
||
return require("puppeteer-core");
|
||
} catch {
|
||
return require("puppeteer");
|
||
}
|
||
}
|
||
|
||
function contextKey(platform, accountId) {
|
||
return accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`;
|
||
}
|
||
|
||
function userDataDir(platform, accountId) {
|
||
const sub = accountId ? `${platform}_${accountId}` : platform;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 关闭占用调试端口的 Chrome,避免复用无窗口/僵尸实例导致扫码登录不弹窗 */
|
||
async function forceCloseChromeOnPort(port) {
|
||
if (!(await isDebugPortOpen(port))) return;
|
||
try {
|
||
const puppeteer = loadPuppeteer();
|
||
const browser = await connectPublishBrowser(puppeteer, port);
|
||
await browser.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
await delay(1500);
|
||
}
|
||
|
||
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 生命周期解耦)
|
||
* @param {string} [startUrl]
|
||
*/
|
||
async function spawnDetachedChrome(executablePath, dir, port, startUrl, options = {}) {
|
||
const force = options.force === true;
|
||
if (force) {
|
||
await forceCloseChromeOnPort(port);
|
||
} else 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=*",
|
||
"--start-maximized",
|
||
"--new-window",
|
||
...COMMON_BROWSER_ARGS.filter((a) => !a.startsWith("--user-data-dir")),
|
||
startUrl && String(startUrl).trim() ? String(startUrl).trim() : "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 },
|
||
});
|
||
}
|
||
|
||
function cleanupProfileLocks(dir) {
|
||
for (const name of [
|
||
"SingletonLock",
|
||
"SingletonSocket",
|
||
"SingletonCookie",
|
||
"Lock",
|
||
]) {
|
||
try {
|
||
const p = path.join(dir, name);
|
||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫码登录:puppeteer.launch 打开可见窗口(Node 在登录等待期间保持存活)
|
||
* @param {string} [_startUrl] 由 newLoginPage 负责导航
|
||
*/
|
||
async function getLoginBrowser(platform, accountId, _startUrl) {
|
||
const key = `login_${contextKey(platform, accountId)}`;
|
||
const existing = loginBrowsers.get(key);
|
||
if (existing && existing.isConnected()) {
|
||
return existing;
|
||
}
|
||
|
||
const publishKey = contextKey(platform, accountId);
|
||
const publishPort = debugPortForKey(publishKey);
|
||
const loginPort = debugPortForKey(key);
|
||
await closeLoginBrowser(platform, accountId);
|
||
await closePublishBrowser(platform, accountId);
|
||
await forceCloseChromeOnPort(loginPort);
|
||
await forceCloseChromeOnPort(publishPort);
|
||
|
||
const puppeteer = loadPuppeteer();
|
||
const dir = userDataDir(platform, accountId);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
cleanupProfileLocks(dir);
|
||
|
||
const executablePath = getChromiumExecutablePath();
|
||
if (!executablePath) {
|
||
throw new Error(
|
||
"未找到 Chrome/Edge,请安装 Google Chrome 或 Microsoft Edge,或设置 AICLIENT_CHROMIUM_PATH",
|
||
);
|
||
}
|
||
|
||
globalThis.__native?.emitProgress?.("正在打开 Chrome 登录窗口…");
|
||
|
||
const browser = await puppeteer.launch({
|
||
executablePath,
|
||
headless: false,
|
||
userDataDir: dir,
|
||
args: [
|
||
"--start-maximized",
|
||
...COMMON_BROWSER_ARGS.filter((a) => !a.startsWith("--user-data-dir")),
|
||
],
|
||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
||
defaultViewport: { width: 1280, height: 900 },
|
||
});
|
||
|
||
browser.on("disconnected", () => loginBrowsers.delete(key));
|
||
loginBrowsers.set(key, browser);
|
||
return browser;
|
||
}
|
||
|
||
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);
|
||
const port = debugPortForKey(key);
|
||
const executablePath = getChromiumExecutablePath();
|
||
if (!executablePath) {
|
||
throw new Error(
|
||
"未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
async function closeBlankPages(browser, keepPage) {
|
||
try {
|
||
const pages = await browser.pages();
|
||
for (const p of pages) {
|
||
if (keepPage && p === keepPage) continue;
|
||
const u = p.url();
|
||
if (!u || u === "about:blank") {
|
||
try {
|
||
await p.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function bringPageToFront(page) {
|
||
try {
|
||
await page.bringToFront();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
const client = await page.createCDPSession();
|
||
await client.send("Page.bringToFront");
|
||
await client.send("Browser.setWindowBounds", {
|
||
windowId: (await client.send("Browser.getWindowForTarget")).windowId,
|
||
bounds: { windowState: "normal" },
|
||
});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} [startUrl] 登录页 URL,spawn 时直接打开
|
||
*/
|
||
async function newLoginPage(platform, accountId, cookies = [], startUrl) {
|
||
const browser = await getLoginBrowser(platform, accountId, startUrl);
|
||
|
||
const pages = await browser.pages();
|
||
let page =
|
||
pages.find((p) => {
|
||
try {
|
||
const u = p.url();
|
||
return u && u !== "about:blank" && u !== "chrome://newtab/";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}) || pages[0] || null;
|
||
|
||
if (!page) {
|
||
page = await browser.newPage();
|
||
if (startUrl) {
|
||
await page.goto(startUrl, {
|
||
waitUntil: "domcontentloaded",
|
||
timeout: 60000,
|
||
});
|
||
}
|
||
}
|
||
|
||
await page.setUserAgent(DEFAULT_USER_AGENT);
|
||
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
|
||
page.setDefaultNavigationTimeout(60000);
|
||
page.setDefaultTimeout(60000);
|
||
|
||
if (Array.isArray(cookies) && cookies.length) {
|
||
try {
|
||
await page.setCookie(...cookies);
|
||
} catch (e) {
|
||
console.warn("setCookie 失败:", e.message);
|
||
}
|
||
}
|
||
|
||
await bringPageToFront(page);
|
||
return page;
|
||
}
|
||
|
||
async function closeLoginBrowser(platform, accountId) {
|
||
const key = `login_${contextKey(platform, accountId)}`;
|
||
const port = debugPortForKey(key);
|
||
let browser = loginBrowsers.get(key);
|
||
loginBrowsers.delete(key);
|
||
try {
|
||
if (browser && browser.isConnected()) {
|
||
await browser.close();
|
||
return;
|
||
}
|
||
const puppeteer = loadPuppeteer();
|
||
if (await isDebugPortOpen(port)) {
|
||
browser = await connectPublishBrowser(puppeteer, port);
|
||
await browser.close();
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开账号页后保留窗口(disconnect,避免 Node 退出时关窗)
|
||
*/
|
||
async function detachLoginBrowser(platform, accountId) {
|
||
const key = `login_${contextKey(platform, accountId)}`;
|
||
const browser = loginBrowsers.get(key);
|
||
if (!browser) return;
|
||
loginBrowsers.delete(key);
|
||
try {
|
||
const proc = typeof browser.process === "function" ? browser.process() : null;
|
||
if (proc && typeof proc.unref === "function") proc.unref();
|
||
if (browser.isConnected()) await browser.disconnect();
|
||
} catch (e) {
|
||
console.warn("detachLoginBrowser:", e.message);
|
||
}
|
||
}
|
||
|
||
async function newPublishPage(platform, accountId, cookies) {
|
||
const browser = await getPublishBrowser(platform, accountId);
|
||
const pages = await browser.pages();
|
||
let page =
|
||
pages.find((p) => {
|
||
try {
|
||
const u = p.url();
|
||
return !u || u === "about:blank";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}) || null;
|
||
if (!page) {
|
||
page = await browser.newPage();
|
||
}
|
||
await page.setUserAgent(DEFAULT_USER_AGENT);
|
||
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
|
||
page.setDefaultNavigationTimeout(60000);
|
||
page.setDefaultTimeout(60000);
|
||
if (Array.isArray(cookies) && cookies.length) {
|
||
try {
|
||
await page.setCookie(...cookies);
|
||
} catch (e) {
|
||
console.warn("setCookie 失败:", e.message);
|
||
}
|
||
}
|
||
await closeBlankPages(browser, page);
|
||
return page;
|
||
}
|
||
|
||
async function closePublishBrowser(platform, accountId) {
|
||
const key = contextKey(platform, accountId);
|
||
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/",
|
||
shipin: "https://channels.weixin.qq.com/",
|
||
xiaohongshu: "https://creator.xiaohongshu.com/",
|
||
};
|
||
|
||
module.exports = {
|
||
delay,
|
||
getPublishBrowser,
|
||
getLoginBrowser,
|
||
newPublishPage,
|
||
newLoginPage,
|
||
userDataDir,
|
||
LOGIN_URLS,
|
||
getChromiumExecutablePath,
|
||
closePublishBrowser,
|
||
closeLoginBrowser,
|
||
detachLoginBrowser,
|
||
detachPublishBrowser,
|
||
debugPortForKey,
|
||
contextKey,
|
||
};
|