cdp-driver.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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.mouse = {
  73. move: async (x, y) => this._dispatchMouse('mouseMoved', x, y),
  74. click: async (x, y, options = {}) => {
  75. const button = options.button || 'left'
  76. await this._dispatchMouse('mouseMoved', x, y)
  77. await this._dispatchMouse('mousePressed', x, y, { button, clickCount: options.clickCount || 1 })
  78. await this._dispatchMouse('mouseReleased', x, y, { button, clickCount: options.clickCount || 1 })
  79. },
  80. }
  81. this._onMessage = (message) => {
  82. if (message.sessionId !== this.sessionId) return
  83. if (message.method === 'Runtime.exceptionThrown') {
  84. const details = message.params.exceptionDetails || {}
  85. const text = (details.exception && (details.exception.description || details.exception.value)) || details.text || 'unknown'
  86. for (const cb of this.listeners.pageerror) cb(text)
  87. }
  88. if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
  89. const text = (message.params.args || [])
  90. .map((a) => (a.value !== undefined ? a.value : a.description || ''))
  91. .join(' ')
  92. // 形状对齐 puppeteer(`m.type()` / `m.text()`):走查脚本按那套写,不该为驱动改姿势。
  93. for (const cb of this.listeners.console) cb({ type: () => 'error', text: () => text })
  94. }
  95. }
  96. }
  97. async _init() {
  98. this.conn.onMessage(this._onMessage)
  99. await this.conn.send('Page.enable', {}, this.sessionId)
  100. await this.conn.send('Runtime.enable', {}, this.sessionId)
  101. }
  102. async _dispatchMouse(type, x, y, extra = {}) {
  103. return this.conn.send('Input.dispatchMouseEvent', {
  104. type,
  105. x: Number(x),
  106. y: Number(y),
  107. button: extra.button || 'none',
  108. buttons: extra.buttons === undefined ? (type === 'mousePressed' ? 1 : 0) : extra.buttons,
  109. clickCount: extra.clickCount || 0,
  110. pointerType: 'mouse',
  111. }, this.sessionId)
  112. }
  113. on(event, cb) {
  114. if (this.listeners[event]) this.listeners[event].push(cb)
  115. return this
  116. }
  117. /** 在**页面上下文**里跑一段函数;参数会被 JSON 序列化后代入。 */
  118. async evaluate(fn, ...args) {
  119. const expression = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
  120. const { result, exceptionDetails } = await this.conn.send(
  121. 'Runtime.evaluate',
  122. { expression, awaitPromise: true, returnByValue: true },
  123. this.sessionId,
  124. )
  125. if (exceptionDetails) {
  126. const text = (exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text
  127. throw new Error(`evaluate 失败:${text}`)
  128. }
  129. return result.value
  130. }
  131. /** 注册"每次新文档都先跑一遍"的脚本(注入 mock 场景等)。 */
  132. async evaluateOnNewDocument(fn, ...args) {
  133. const source = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})`
  134. await this.conn.send('Page.addScriptToEvaluateOnNewDocument', { source }, this.sessionId)
  135. }
  136. /** 导航并等到 `readyState === 'complete'`(puppeteer 的 networkidle2 由调用方补 sleep 近似)。 */
  137. async goto(url, options = {}) {
  138. const timeout = options.timeout || 60000
  139. await this.conn.send('Page.navigate', { url }, this.sessionId)
  140. const deadline = Date.now() + timeout
  141. for (;;) {
  142. try {
  143. const ready = await this.evaluate(() => document.readyState)
  144. if (ready === 'complete') return
  145. } catch {
  146. /* 导航中途上下文会被销毁,重试即可 */
  147. }
  148. if (Date.now() > deadline) throw new Error(`goto 超时:${url}`)
  149. await sleep(100)
  150. }
  151. }
  152. async waitForSelector(selector, options = {}) {
  153. const timeout = options.timeout || 20000
  154. const deadline = Date.now() + timeout
  155. const hidden = options.hidden === true
  156. for (;;) {
  157. const state = await this.evaluate((sel) => {
  158. const el = document.querySelector(sel)
  159. if (!el) return 'missing'
  160. const rect = el.getBoundingClientRect()
  161. const styled = window.getComputedStyle(el)
  162. const invisible = styled.display === 'none' || styled.visibility === 'hidden' || rect.width + rect.height === 0
  163. return invisible ? 'hidden' : 'visible'
  164. }, selector).catch(() => 'missing')
  165. if (hidden ? state !== 'visible' : state === 'visible') return
  166. if (Date.now() > deadline) throw new Error(`waitForSelector 超时:${selector}`)
  167. await sleep(80)
  168. }
  169. }
  170. async waitForFunction(fn, options = {}, ...args) {
  171. const timeout = options.timeout || 20000
  172. const deadline = Date.now() + timeout
  173. for (;;) {
  174. const ok = await this.evaluate(fn, ...args).catch(() => false)
  175. if (ok) return
  176. if (Date.now() > deadline) throw new Error('waitForFunction 超时')
  177. await sleep(80)
  178. }
  179. }
  180. /** 与 puppeteer 一致:命中回一个真值对象,未命中回 null。 */
  181. async $(selector) {
  182. const found = await this.evaluate((sel) => !!document.querySelector(sel), selector).catch(() => false)
  183. return found ? { selector } : null
  184. }
  185. async click(selector) {
  186. const clicked = await this.evaluate((sel) => {
  187. const el = document.querySelector(sel)
  188. if (!el) return false
  189. el.click()
  190. return true
  191. }, selector)
  192. if (!clicked) throw new Error(`click 找不到元素:${selector}`)
  193. }
  194. async close() {
  195. this.conn.onMessage(() => {})
  196. this.conn.handlers.delete(this._onMessage)
  197. await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {})
  198. }
  199. }
  200. class Browser {
  201. constructor(conn, info) {
  202. this.conn = conn
  203. this.child = info.child
  204. this.userDataDir = info.userDataDir
  205. this.versionInfo = info.version
  206. }
  207. async version() {
  208. return this.versionInfo.Browser || 'chrome'
  209. }
  210. async newPage() {
  211. const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' })
  212. const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true })
  213. const page = new Page(this.conn, sessionId)
  214. page.targetId = targetId
  215. await page._init()
  216. return page
  217. }
  218. async close() {
  219. try {
  220. this.conn.ws.close()
  221. } catch {
  222. /* 已经断了就算了 */
  223. }
  224. try {
  225. this.child.kill()
  226. } catch {
  227. /* 同上 */
  228. }
  229. await sleep(200)
  230. try {
  231. rmSync(this.userDataDir, { recursive: true, force: true })
  232. } catch {
  233. /* 临时目录清不掉不影响结论 */
  234. }
  235. }
  236. }
  237. /**
  238. * 启动 Chrome 并连上它的浏览器级 WebSocket。
  239. * `--remote-debugging-port=0` 让系统分配端口,Chrome 会把真实端口写进
  240. * `<user-data-dir>/DevToolsActivePort`,从而不会和其它 Chrome 抢 9222。
  241. */
  242. export async function cdpLaunch(options = {}) {
  243. const width = options.width || 1680
  244. const height = options.height || 950
  245. const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-cockpit-'))
  246. const args = [
  247. '--headless=new',
  248. '--remote-debugging-port=0',
  249. `--user-data-dir=${userDataDir}`,
  250. `--window-size=${width},${height}`,
  251. '--no-first-run',
  252. '--no-default-browser-check',
  253. '--disable-gpu',
  254. '--no-sandbox',
  255. '--disable-dev-shm-usage',
  256. ...(options.args || []),
  257. 'about:blank',
  258. ]
  259. // stdio 用 ignore:受限模式下"捕获子进程输出"会 EPERM,而 Chrome 的日志对结论无用。
  260. const child = spawn(options.executablePath, args, { stdio: 'ignore' })
  261. const portFile = join(userDataDir, 'DevToolsActivePort')
  262. let raw = ''
  263. for (let i = 0; i < 200; i += 1) {
  264. try {
  265. raw = readFileSync(portFile, 'utf8')
  266. if (raw.trim()) break
  267. } catch {
  268. /* 还没写出来 */
  269. }
  270. await sleep(100)
  271. }
  272. const port = Number(String(raw).split('\n')[0].trim())
  273. if (!port) {
  274. child.kill()
  275. throw new Error('Chrome 没写出 DevToolsActivePort —— 大概率是沙箱把它的 IPC 拦掉了')
  276. }
  277. const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json())
  278. const ws = new WebSocket(version.webSocketDebuggerUrl)
  279. await new Promise((resolve, reject) => {
  280. ws.addEventListener('open', resolve, { once: true })
  281. ws.addEventListener('error', () => reject(new Error('连不上 Chrome 的调试 WebSocket')), { once: true })
  282. })
  283. return new Browser(new Connection(ws), { child, userDataDir, version })
  284. }