| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- /**
- * 零依赖 Chrome DevTools Protocol 驱动 —— `puppeteer-core` 缺失时的走查替代。
- *
- * ── 为什么需要它 ──
- * 本仓所有真机走查都 import `puppeteer-core`(在 `~/.dsh/profiles/desktop/node_modules` 下,
- * 属于工具层而非仓库依赖)。该模块在本机已消失,导致 `t28-fill-container.mjs` 等直接
- * `ERR_MODULE_NOT_FOUND`。Chrome 本身在(`C:\Program Files\Google\Chrome\Application\chrome.exe`),
- * 而 Node 24 自带全局 `WebSocket` 与 `fetch`,所以"驱动浏览器"这件事**不需要任何依赖**。
- *
- * ── 它是什么、不是什么 ──
- * 只实现走查真正用到的那一小撮 puppeteer API(`launch`/`newPage`/`goto`/`evaluate`/
- * `evaluateOnNewDocument`/`waitForSelector`/`waitForFunction`/`$`/`click`/`on`/`close`),
- * 用 CDP 原生命令实现。**不是** puppeteer 的通用替代:没有选择器语法、没有元素句柄、
- * 没有网络拦截。要那些能力请恢复 `puppeteer-core`。
- *
- * ── 用法 ──
- * const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
- * const page = await browser.newPage()
- * await page.goto(url, { timeout: 60000 })
- *
- * 注意:Chrome 启动需要放宽沙箱(受限模式下 crashpad 会 `OpenProcess: 拒绝访问 (0x5)` 自杀)。
- */
- import { spawn } from 'node:child_process'
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
- /** 与 CDP 的一条 WebSocket 连接:发命令、按 id 收响应、把事件分发给订阅者。 */
- class Connection {
- constructor(ws) {
- this.ws = ws
- this.seq = 0
- this.pending = new Map()
- this.handlers = new Set()
- ws.addEventListener('message', (event) => {
- let message = null
- try {
- message = JSON.parse(event.data)
- } catch {
- return
- }
- if (message.id !== undefined) {
- const slot = this.pending.get(message.id)
- if (!slot) return
- this.pending.delete(message.id)
- if (message.error) slot.reject(new Error(`${slot.method}: ${message.error.message}`))
- else slot.resolve(message.result)
- return
- }
- for (const handler of this.handlers) handler(message)
- })
- }
- send(method, params = {}, sessionId) {
- const id = ++this.seq
- const payload = { id, method, params }
- if (sessionId) payload.sessionId = sessionId
- this.ws.send(JSON.stringify(payload))
- return new Promise((resolve, reject) => {
- this.pending.set(id, { resolve, reject, method })
- })
- }
- onMessage(handler) {
- this.handlers.add(handler)
- return () => this.handlers.delete(handler)
- }
- }
- class Page {
- constructor(conn, sessionId) {
- this.conn = conn
- this.sessionId = sessionId
- this.listeners = { pageerror: [], console: [] }
- this.mouse = {
- move: async (x, y) => this._dispatchMouse('mouseMoved', x, y),
- click: async (x, y, options = {}) => {
- const button = options.button || 'left'
- await this._dispatchMouse('mouseMoved', x, y)
- await this._dispatchMouse('mousePressed', x, y, { button, clickCount: options.clickCount || 1 })
- await this._dispatchMouse('mouseReleased', x, y, { button, clickCount: options.clickCount || 1 })
- },
- }
- this._onMessage = (message) => {
- if (message.sessionId !== this.sessionId) return
- if (message.method === 'Runtime.exceptionThrown') {
- const details = message.params.exceptionDetails || {}
- const text = (details.exception && (details.exception.description || details.exception.value)) || details.text || 'unknown'
- for (const cb of this.listeners.pageerror) cb(text)
- }
- if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
- const text = (message.params.args || [])
- .map((a) => (a.value !== undefined ? a.value : a.description || ''))
- .join(' ')
- // 形状对齐 puppeteer(`m.type()` / `m.text()`):走查脚本按那套写,不该为驱动改姿势。
- for (const cb of this.listeners.console) cb({ type: () => 'error', text: () => text })
- }
- }
- }
- async _init() {
- this.conn.onMessage(this._onMessage)
- await this.conn.send('Page.enable', {}, this.sessionId)
- await this.conn.send('Runtime.enable', {}, this.sessionId)
- }
- async _dispatchMouse(type, x, y, extra = {}) {
- return this.conn.send('Input.dispatchMouseEvent', {
- type,
- x: Number(x),
- y: Number(y),
- button: extra.button || 'none',
- buttons: extra.buttons === undefined ? (type === 'mousePressed' ? 1 : 0) : extra.buttons,
- clickCount: extra.clickCount || 0,
- pointerType: 'mouse',
- }, this.sessionId)
- }
- on(event, cb) {
- if (this.listeners[event]) this.listeners[event].push(cb)
- return this
- }
- /** 在**页面上下文**里跑一段函数;参数会被 JSON 序列化后代入。 */
- async evaluate(fn, ...args) {
- const expression = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
- const { result, exceptionDetails } = await this.conn.send(
- 'Runtime.evaluate',
- { expression, awaitPromise: true, returnByValue: true },
- this.sessionId,
- )
- if (exceptionDetails) {
- const text = (exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text
- throw new Error(`evaluate 失败:${text}`)
- }
- return result.value
- }
- /** 注册"每次新文档都先跑一遍"的脚本(注入 mock 场景等)。 */
- async evaluateOnNewDocument(fn, ...args) {
- const source = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
- await this.conn.send('Page.addScriptToEvaluateOnNewDocument', { source }, this.sessionId)
- }
- /** 导航并等到 `readyState === 'complete'`(puppeteer 的 networkidle2 由调用方补 sleep 近似)。 */
- async goto(url, options = {}) {
- const timeout = options.timeout || 60000
- await this.conn.send('Page.navigate', { url }, this.sessionId)
- const deadline = Date.now() + timeout
- for (;;) {
- try {
- const ready = await this.evaluate(() => document.readyState)
- if (ready === 'complete') return
- } catch {
- /* 导航中途上下文会被销毁,重试即可 */
- }
- if (Date.now() > deadline) throw new Error(`goto 超时:${url}`)
- await sleep(100)
- }
- }
- async waitForSelector(selector, options = {}) {
- const timeout = options.timeout || 20000
- const deadline = Date.now() + timeout
- const hidden = options.hidden === true
- for (;;) {
- const state = await this.evaluate((sel) => {
- const el = document.querySelector(sel)
- if (!el) return 'missing'
- const rect = el.getBoundingClientRect()
- const styled = window.getComputedStyle(el)
- const invisible = styled.display === 'none' || styled.visibility === 'hidden' || rect.width + rect.height === 0
- return invisible ? 'hidden' : 'visible'
- }, selector).catch(() => 'missing')
- if (hidden ? state !== 'visible' : state === 'visible') return
- if (Date.now() > deadline) throw new Error(`waitForSelector 超时:${selector}`)
- await sleep(80)
- }
- }
- async waitForFunction(fn, options = {}, ...args) {
- const timeout = options.timeout || 20000
- const deadline = Date.now() + timeout
- for (;;) {
- const ok = await this.evaluate(fn, ...args).catch(() => false)
- if (ok) return
- if (Date.now() > deadline) throw new Error('waitForFunction 超时')
- await sleep(80)
- }
- }
- /** 与 puppeteer 一致:命中回一个真值对象,未命中回 null。 */
- async $(selector) {
- const found = await this.evaluate((sel) => !!document.querySelector(sel), selector).catch(() => false)
- return found ? { selector } : null
- }
- async click(selector) {
- const clicked = await this.evaluate((sel) => {
- const el = document.querySelector(sel)
- if (!el) return false
- el.click()
- return true
- }, selector)
- if (!clicked) throw new Error(`click 找不到元素:${selector}`)
- }
- async close() {
- this.conn.onMessage(() => {})
- this.conn.handlers.delete(this._onMessage)
- await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {})
- }
- }
- class Browser {
- constructor(conn, info) {
- this.conn = conn
- this.child = info.child
- this.userDataDir = info.userDataDir
- this.versionInfo = info.version
- }
- async version() {
- return this.versionInfo.Browser || 'chrome'
- }
- async newPage() {
- const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' })
- const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true })
- const page = new Page(this.conn, sessionId)
- page.targetId = targetId
- await page._init()
- return page
- }
- async close() {
- try {
- this.conn.ws.close()
- } catch {
- /* 已经断了就算了 */
- }
- try {
- this.child.kill()
- } catch {
- /* 同上 */
- }
- await sleep(200)
- try {
- rmSync(this.userDataDir, { recursive: true, force: true })
- } catch {
- /* 临时目录清不掉不影响结论 */
- }
- }
- }
- /**
- * 启动 Chrome 并连上它的浏览器级 WebSocket。
- * `--remote-debugging-port=0` 让系统分配端口,Chrome 会把真实端口写进
- * `<user-data-dir>/DevToolsActivePort`,从而不会和其它 Chrome 抢 9222。
- */
- export async function cdpLaunch(options = {}) {
- const width = options.width || 1680
- const height = options.height || 950
- const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-cockpit-'))
- const args = [
- '--headless=new',
- '--remote-debugging-port=0',
- `--user-data-dir=${userDataDir}`,
- `--window-size=${width},${height}`,
- '--no-first-run',
- '--no-default-browser-check',
- '--disable-gpu',
- '--no-sandbox',
- '--disable-dev-shm-usage',
- ...(options.args || []),
- 'about:blank',
- ]
- // stdio 用 ignore:受限模式下"捕获子进程输出"会 EPERM,而 Chrome 的日志对结论无用。
- const child = spawn(options.executablePath, args, { stdio: 'ignore' })
- const portFile = join(userDataDir, 'DevToolsActivePort')
- let raw = ''
- for (let i = 0; i < 200; i += 1) {
- try {
- raw = readFileSync(portFile, 'utf8')
- if (raw.trim()) break
- } catch {
- /* 还没写出来 */
- }
- await sleep(100)
- }
- const port = Number(String(raw).split('\n')[0].trim())
- if (!port) {
- child.kill()
- throw new Error('Chrome 没写出 DevToolsActivePort —— 大概率是沙箱把它的 IPC 拦掉了')
- }
- const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json())
- const ws = new WebSocket(version.webSocketDebuggerUrl)
- await new Promise((resolve, reject) => {
- ws.addEventListener('open', resolve, { once: true })
- ws.addEventListener('error', () => reject(new Error('连不上 Chrome 的调试 WebSocket')), { once: true })
- })
- return new Browser(new Connection(ws), { child, userDataDir, version })
- }
|