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