import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); return; } const port = address.port; server.close((err) => { if (err) reject(err); else resolve(port); }); }); }); } export function findChromeExecutable(): string | undefined { const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim(); if (override && fs.existsSync(override)) return override; const candidates: string[] = []; switch (process.platform) { case 'darwin': candidates.push( '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', '/Applications/Chromium.app/Contents/MacOS/Chromium', ); break; case 'win32': candidates.push( 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', ); break; default: candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium'); break; } for (const p of candidates) { if (fs.existsSync(p)) return p; } return undefined; } export function getDefaultProfileDir(): string { const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); return path.join(base, 'wechat-browser-profile'); } async function fetchJson(url: string): Promise { const res = await fetch(url, { redirect: 'follow' }); if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); return (await res.json()) as T; } async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { const start = Date.now(); let lastError: unknown = null; while (Date.now() - start < timeoutMs) { try { const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; lastError = new Error('Missing webSocketDebuggerUrl'); } catch (error) { lastError = error; } await sleep(200); } throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); } export class CdpConnection { private ws: WebSocket; private nextId = 0; private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); private eventHandlers = new Map void>>(); private constructor(ws: WebSocket) { this.ws = ws; this.ws.addEventListener('message', (event) => { try { const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } }; if (msg.method) { const handlers = this.eventHandlers.get(msg.method); if (handlers) handlers.forEach((h) => h(msg.params)); } if (msg.id) { const pending = this.pending.get(msg.id); if (pending) { this.pending.delete(msg.id); if (pending.timer) clearTimeout(pending.timer); if (msg.error?.message) pending.reject(new Error(msg.error.message)); else pending.resolve(msg.result); } } } catch {} }); this.ws.addEventListener('close', () => { for (const [id, pending] of this.pending.entries()) { this.pending.delete(id); if (pending.timer) clearTimeout(pending.timer); pending.reject(new Error('CDP connection closed.')); } }); } static async connect(url: string, timeoutMs: number): Promise { const ws = new WebSocket(url); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }); ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); }); }); return new CdpConnection(ws); } on(method: string, handler: (params: unknown) => void): void { if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set()); this.eventHandlers.get(method)!.add(handler); } async send(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { const id = ++this.nextId; const message: Record = { id, method }; if (params) message.params = params; if (options?.sessionId) message.sessionId = options.sessionId; const timeoutMs = options?.timeoutMs ?? 15_000; const result = await new Promise((resolve, reject) => { const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; this.pending.set(id, { resolve, reject, timer }); this.ws.send(JSON.stringify(message)); }); return result as T; } close(): void { try { this.ws.close(); } catch {} } } export interface ChromeSession { cdp: CdpConnection; sessionId: string; targetId: string; } export async function tryConnectExisting(port: number): Promise { try { const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); if (version.webSocketDebuggerUrl) { const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000); return cdp; } } catch {} return null; } export async function findExistingChromeDebugPort(): Promise { if (process.platform !== 'darwin' && process.platform !== 'linux') return null; try { const { execSync } = await import('node:child_process'); const cmd = process.platform === 'darwin' ? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'` : `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`; const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim(); if (!output) return null; const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0); for (const port of ports) { try { const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); if (version.webSocketDebuggerUrl) return port; } catch {} } } catch {} return null; } export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType }> { const chromePath = findChromeExecutable(); if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.'); const profile = profileDir ?? getDefaultProfileDir(); await mkdir(profile, { recursive: true }); const port = await getFreePort(); console.log(`[cdp] Launching Chrome (profile: ${profile})`); const chrome = spawn(chromePath, [ `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-blink-features=AutomationControlled', '--start-maximized', url, ], { stdio: 'ignore' }); const wsUrl = await waitForChromeDebugPort(port, 30_000); const cdp = await CdpConnection.connect(wsUrl, 30_000); return { cdp, chrome }; } export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise { const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes(urlPattern)); if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`); const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); await cdp.send('Page.enable', {}, { sessionId }); await cdp.send('Runtime.enable', {}, { sessionId }); await cdp.send('DOM.enable', {}, { sessionId }); return { cdp, sessionId, targetId: pageTarget.targetId }; } export async function waitForNewTab(cdp: CdpConnection, initialIds: Set, urlPattern: string, timeoutMs = 30_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern)); if (newTab) return newTab.targetId; await sleep(500); } throw new Error(`New tab not found: ${urlPattern}`); } export async function clickElement(session: ChromeSession, selector: string): Promise { const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: ` (function() { const el = document.querySelector('${selector}'); if (!el) return 'null'; el.scrollIntoView({ block: 'center' }); const rect = el.getBoundingClientRect(); return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); })() `, returnByValue: true, }, { sessionId: session.sessionId }); if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`); const pos = JSON.parse(posResult.result.value); await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); await sleep(50); await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); } export async function typeText(session: ChromeSession, text: string): Promise { const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].length > 0) { await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId }); } if (i < lines.length - 1) { await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); } await sleep(30); } } export async function pasteFromClipboard(session: ChromeSession): Promise { const modifiers = process.platform === 'darwin' ? 4 : 2; await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); } export async function evaluate(session: ChromeSession, expression: string): Promise { const result = await session.cdp.send<{ result: { value: T } }>('Runtime.evaluate', { expression, returnByValue: true, }, { sessionId: session.sessionId }); return result.result.value; }