cdp-driver.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /**
  2. * 零依赖 Chrome DevTools Protocol 驱动 —— `puppeteer-core` 缺失时的走查替代。
  3. *
  4. * ── 为什么需要它 ──
  5. * 本仓所有真机走查都 import `puppeteer-core`(在 `~/.dsh/profiles/desktop/node_modules` 下,
  6. * 属于工具层而非仓库依赖)。该模块在本机已消失,导致 `t28-fill-container.mjs` 等直接
  7. * `ERR_MODULE_NOT_FOUND`。Chrome 本身在(`C:\Program Files\Google\Chrome\Application\chrome.exe`),
  8. * 而 Node 24 自带全局 `WebSocket` 与 `fetch`,所以"驱动浏览器"这件事**不需要任何依赖**。
  9. *
  10. * ── 它是什么、不是什么 ──
  11. * 只实现走查真正用到的那一小撮 puppeteer API(`launch`/`newPage`/`goto`/`evaluate`/
  12. * `evaluateOnNewDocument`/`waitForSelector`/`waitForFunction`/`$`/`click`/`on`/`close`),
  13. * 用 CDP 原生命令实现。**不是** puppeteer 的通用替代:没有选择器语法、没有元素句柄、
  14. * 没有网络拦截。要那些能力请恢复 `puppeteer-core`。
  15. *
  16. * ── 用法 ──
  17. * const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
  18. * const page = await browser.newPage()
  19. * await page.goto(url, { timeout: 60000 })
  20. *
  21. * 注意:Chrome 启动需要放宽沙箱(受限模式下 crashpad 会 `OpenProcess: 拒绝访问 (0x5)` 自杀)。
  22. */
  23. import { spawn } from 'node:child_process'
  24. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  25. import { tmpdir } from 'node:os'
  26. import { join } from 'node:path'
  27. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  28. /** 与 CDP 的一条 WebSocket 连接:发命令、按 id 收响应、把事件分发给订阅者。 */
  29. class Connection {
  30. constructor(ws) {
  31. this.ws = ws
  32. this.seq = 0
  33. this.pending = new Map()
  34. this.handlers = new Set()
  35. ws.addEventListener('message', (event) => {
  36. let message = null
  37. try {
  38. message = JSON.parse(event.data)
  39. } catch {
  40. return
  41. }
  42. if (message.id !== undefined) {
  43. const slot = this.pending.get(message.id)
  44. if (!slot) return
  45. this.pending.delete(message.id)
  46. if (message.error) slot.reject(new Error(`${slot.method}: ${message.error.message}`))
  47. else slot.resolve(message.result)
  48. return
  49. }
  50. for (const handler of this.handlers) handler(message)
  51. })
  52. }
  53. send(method, params = {}, sessionId) {
  54. const id = ++this.seq
  55. const payload = { id, method, params }
  56. if (sessionId) payload.sessionId = sessionId
  57. this.ws.send(JSON.stringify(payload))
  58. return new Promise((resolve, reject) => {
  59. this.pending.set(id, { resolve, reject, method })
  60. })
  61. }
  62. onMessage(handler) {
  63. this.handlers.add(handler)
  64. return () => this.handlers.delete(handler)
  65. }
  66. }
  67. class Page {
  68. constructor(conn, sessionId) {
  69. this.conn = conn
  70. this.sessionId = sessionId
  71. this.listeners = { pageerror: [], console: [] }
  72. this._onMessage = (message) => {
  73. if (message.sessionId !== this.sessionId) return
  74. if (message.method === 'Runtime.exceptionThrown') {
  75. const details = message.params.exceptionDetails || {}
  76. const text = (details.exception && (details.exception.description || details.exception.value)) || details.text || 'unknown'
  77. for (const cb of this.listeners.pageerror) cb(text)
  78. }
  79. if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
  80. const text = (message.params.args || [])
  81. .map((a) => (a.value !== undefined ? a.value : a.description || ''))
  82. .join(' ')
  83. // 形状对齐 puppeteer(`m.type()` / `m.text()`):走查脚本按那套写,不该为驱动改姿势。
  84. for (const cb of this.listeners.console) cb({ type: () => 'error', text: () => text })
  85. }
  86. }
  87. }
  88. async _init() {
  89. this.conn.onMessage(this._onMessage)
  90. await this.conn.send('Page.enable', {}, this.sessionId)
  91. await this.conn.send('Runtime.enable', {}, this.sessionId)
  92. }
  93. on(event, cb) {
  94. if (this.listeners[event]) this.listeners[event].push(cb)
  95. return this
  96. }
  97. /** 在**页面上下文**里跑一段函数;参数会被 JSON 序列化后代入。 */
  98. async evaluate(fn, ...args) {
  99. const expression = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
  100. const { result, exceptionDetails } = await this.conn.send(
  101. 'Runtime.evaluate',
  102. { expression, awaitPromise: true, returnByValue: true },
  103. this.sessionId,
  104. )
  105. if (exceptionDetails) {
  106. const text = (exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text
  107. throw new Error(`evaluate 失败:${text}`)
  108. }
  109. return result.value
  110. }
  111. /** 注册"每次新文档都先跑一遍"的脚本(注入 mock 场景等)。 */
  112. async evaluateOnNewDocument(fn, ...args) {
  113. const source = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
  114. await this.conn.send('Page.addScriptToEvaluateOnNewDocument', { source }, this.sessionId)
  115. }
  116. /** 导航并等到 `readyState === 'complete'`(puppeteer 的 networkidle2 由调用方补 sleep 近似)。 */
  117. async goto(url, options = {}) {
  118. const timeout = options.timeout || 60000
  119. await this.conn.send('Page.navigate', { url }, this.sessionId)
  120. const deadline = Date.now() + timeout
  121. for (;;) {
  122. try {
  123. const ready = await this.evaluate(() => document.readyState)
  124. if (ready === 'complete') return
  125. } catch {
  126. /* 导航中途上下文会被销毁,重试即可 */
  127. }
  128. if (Date.now() > deadline) throw new Error(`goto 超时:${url}`)
  129. await sleep(100)
  130. }
  131. }
  132. async waitForSelector(selector, options = {}) {
  133. const timeout = options.timeout || 20000
  134. const deadline = Date.now() + timeout
  135. const hidden = options.hidden === true
  136. for (;;) {
  137. const state = await this.evaluate((sel) => {
  138. const el = document.querySelector(sel)
  139. if (!el) return 'missing'
  140. const rect = el.getBoundingClientRect()
  141. const styled = window.getComputedStyle(el)
  142. const invisible = styled.display === 'none' || styled.visibility === 'hidden' || rect.width + rect.height === 0
  143. return invisible ? 'hidden' : 'visible'
  144. }, selector).catch(() => 'missing')
  145. if (hidden ? state !== 'visible' : state === 'visible') return
  146. if (Date.now() > deadline) throw new Error(`waitForSelector 超时:${selector}`)
  147. await sleep(80)
  148. }
  149. }
  150. async waitForFunction(fn, options = {}, ...args) {
  151. const timeout = options.timeout || 20000
  152. const deadline = Date.now() + timeout
  153. for (;;) {
  154. const ok = await this.evaluate(fn, ...args).catch(() => false)
  155. if (ok) return
  156. if (Date.now() > deadline) throw new Error('waitForFunction 超时')
  157. await sleep(80)
  158. }
  159. }
  160. /** 与 puppeteer 一致:命中回一个真值对象,未命中回 null。 */
  161. async $(selector) {
  162. const found = await this.evaluate((sel) => !!document.querySelector(sel), selector).catch(() => false)
  163. return found ? { selector } : null
  164. }
  165. async click(selector) {
  166. const clicked = await this.evaluate((sel) => {
  167. const el = document.querySelector(sel)
  168. if (!el) return false
  169. el.click()
  170. return true
  171. }, selector)
  172. if (!clicked) throw new Error(`click 找不到元素:${selector}`)
  173. }
  174. async close() {
  175. this.conn.onMessage(() => {})
  176. this.conn.handlers.delete(this._onMessage)
  177. await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {})
  178. }
  179. }
  180. class Browser {
  181. constructor(conn, info) {
  182. this.conn = conn
  183. this.child = info.child
  184. this.userDataDir = info.userDataDir
  185. this.versionInfo = info.version
  186. }
  187. async version() {
  188. return this.versionInfo.Browser || 'chrome'
  189. }
  190. async newPage() {
  191. const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' })
  192. const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true })
  193. const page = new Page(this.conn, sessionId)
  194. page.targetId = targetId
  195. await page._init()
  196. return page
  197. }
  198. async close() {
  199. try {
  200. this.conn.ws.close()
  201. } catch {
  202. /* 已经断了就算了 */
  203. }
  204. try {
  205. this.child.kill()
  206. } catch {
  207. /* 同上 */
  208. }
  209. await sleep(200)
  210. try {
  211. rmSync(this.userDataDir, { recursive: true, force: true })
  212. } catch {
  213. /* 临时目录清不掉不影响结论 */
  214. }
  215. }
  216. }
  217. /**
  218. * 启动 Chrome 并连上它的浏览器级 WebSocket。
  219. * `--remote-debugging-port=0` 让系统分配端口,Chrome 会把真实端口写进
  220. * `<user-data-dir>/DevToolsActivePort`,从而不会和其它 Chrome 抢 9222。
  221. */
  222. export async function cdpLaunch(options = {}) {
  223. const width = options.width || 1680
  224. const height = options.height || 950
  225. const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-cockpit-'))
  226. const args = [
  227. '--headless=new',
  228. '--remote-debugging-port=0',
  229. `--user-data-dir=${userDataDir}`,
  230. `--window-size=${width},${height}`,
  231. '--no-first-run',
  232. '--no-default-browser-check',
  233. '--disable-gpu',
  234. '--no-sandbox',
  235. '--disable-dev-shm-usage',
  236. ...(options.args || []),
  237. 'about:blank',
  238. ]
  239. // stdio 用 ignore:受限模式下"捕获子进程输出"会 EPERM,而 Chrome 的日志对结论无用。
  240. const child = spawn(options.executablePath, args, { stdio: 'ignore' })
  241. const portFile = join(userDataDir, 'DevToolsActivePort')
  242. let raw = ''
  243. for (let i = 0; i < 200; i += 1) {
  244. try {
  245. raw = readFileSync(portFile, 'utf8')
  246. if (raw.trim()) break
  247. } catch {
  248. /* 还没写出来 */
  249. }
  250. await sleep(100)
  251. }
  252. const port = Number(String(raw).split('\n')[0].trim())
  253. if (!port) {
  254. child.kill()
  255. throw new Error('Chrome 没写出 DevToolsActivePort —— 大概率是沙箱把它的 IPC 拦掉了')
  256. }
  257. const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json())
  258. const ws = new WebSocket(version.webSocketDebuggerUrl)
  259. await new Promise((resolve, reject) => {
  260. ws.addEventListener('open', resolve, { once: true })
  261. ws.addEventListener('error', () => reject(new Error('连不上 Chrome 的调试 WebSocket')), { once: true })
  262. })
  263. return new Browser(new Connection(ws), { child, userDataDir, version })
  264. }