cdp-capture.mjs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { spawn } from 'node:child_process';
  2. import { createHash, randomBytes } from 'node:crypto';
  3. import { mkdirSync, writeFileSync } from 'node:fs';
  4. import http from 'node:http';
  5. import net from 'node:net';
  6. import { dirname, resolve } from 'node:path';
  7. const chromePath = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
  8. const url = process.argv[2] || 'http://localhost:7456/';
  9. const out = resolve(process.argv[3] || 'cocos-preview.png');
  10. const port = Number(process.env.CDP_PORT || 9223);
  11. const userDataDir = process.env.CDP_USER_DATA_DIR || resolve('tmp/chrome-cdp');
  12. const waitMs = Number(process.env.CDP_WAIT_MS || 10000);
  13. const holdRightMs = Number(process.env.CDP_HOLD_RIGHT_MS || 0);
  14. const holdFireMs = Number(process.env.CDP_HOLD_FIRE_MS || 0);
  15. const evalExpression = process.env.CDP_EVAL || '';
  16. const windowSize = process.env.CDP_WINDOW_SIZE || '1280,720';
  17. function getJson(path) {
  18. return new Promise((resolvePromise, reject) => {
  19. const req = http.get({ hostname: '127.0.0.1', port, path }, (res) => {
  20. let data = '';
  21. res.on('data', (chunk) => (data += chunk));
  22. res.on('end', () => {
  23. try {
  24. resolvePromise(JSON.parse(data));
  25. } catch (error) {
  26. reject(error);
  27. }
  28. });
  29. });
  30. req.on('error', reject);
  31. });
  32. }
  33. async function waitForChrome() {
  34. for (let i = 0; i < 80; i++) {
  35. try {
  36. return await getJson('/json/version');
  37. } catch {
  38. await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
  39. }
  40. }
  41. throw new Error('Chrome DevTools endpoint did not start');
  42. }
  43. class CdpSocket {
  44. constructor(wsUrl) {
  45. const { hostname, port: wsPort, pathname } = new URL(wsUrl);
  46. this.host = hostname;
  47. this.port = Number(wsPort);
  48. this.path = pathname;
  49. this.nextId = 1;
  50. this.pending = new Map();
  51. this.buffer = Buffer.alloc(0);
  52. this.events = [];
  53. }
  54. connect() {
  55. return new Promise((resolvePromise, reject) => {
  56. const key = randomBytes(16).toString('base64');
  57. const expected = createHash('sha1')
  58. .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
  59. .digest('base64');
  60. this.socket = net.createConnection(this.port, this.host, () => {
  61. this.socket.write([
  62. `GET ${this.path} HTTP/1.1`,
  63. `Host: ${this.host}:${this.port}`,
  64. 'Upgrade: websocket',
  65. 'Connection: Upgrade',
  66. `Sec-WebSocket-Key: ${key}`,
  67. 'Sec-WebSocket-Version: 13',
  68. '',
  69. ''
  70. ].join('\r\n'));
  71. });
  72. let header = Buffer.alloc(0);
  73. const onData = (chunk) => {
  74. header = Buffer.concat([header, chunk]);
  75. const idx = header.indexOf('\r\n\r\n');
  76. if (idx === -1) return;
  77. const head = header.subarray(0, idx).toString('utf8');
  78. if (!head.includes('101') || !head.includes(expected)) {
  79. reject(new Error(`WebSocket handshake failed: ${head}`));
  80. return;
  81. }
  82. this.socket.off('data', onData);
  83. const rest = header.subarray(idx + 4);
  84. if (rest.length) this.onData(rest);
  85. this.socket.on('data', (data) => this.onData(data));
  86. resolvePromise();
  87. };
  88. this.socket.on('data', onData);
  89. this.socket.on('error', reject);
  90. });
  91. }
  92. onData(chunk) {
  93. this.buffer = Buffer.concat([this.buffer, chunk]);
  94. while (this.buffer.length >= 2) {
  95. const b1 = this.buffer[0];
  96. const b2 = this.buffer[1];
  97. let len = b2 & 0x7f;
  98. let offset = 2;
  99. if (len === 126) {
  100. if (this.buffer.length < 4) return;
  101. len = this.buffer.readUInt16BE(2);
  102. offset = 4;
  103. } else if (len === 127) {
  104. if (this.buffer.length < 10) return;
  105. len = Number(this.buffer.readBigUInt64BE(2));
  106. offset = 10;
  107. }
  108. const masked = Boolean(b2 & 0x80);
  109. const maskOffset = masked ? 4 : 0;
  110. if (this.buffer.length < offset + maskOffset + len) return;
  111. let payload = this.buffer.subarray(offset + maskOffset, offset + maskOffset + len);
  112. if (masked) {
  113. const mask = this.buffer.subarray(offset, offset + 4);
  114. payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]));
  115. }
  116. this.buffer = this.buffer.subarray(offset + maskOffset + len);
  117. if ((b1 & 0x0f) === 1) {
  118. const msg = JSON.parse(payload.toString('utf8'));
  119. if (msg.id && this.pending.has(msg.id)) {
  120. this.pending.get(msg.id)(msg);
  121. this.pending.delete(msg.id);
  122. } else if (msg.method) {
  123. this.events.push(msg);
  124. }
  125. }
  126. }
  127. }
  128. send(method, params = {}) {
  129. const id = this.nextId++;
  130. const payload = Buffer.from(JSON.stringify({ id, method, params }), 'utf8');
  131. let header;
  132. if (payload.length < 126) {
  133. header = Buffer.alloc(2);
  134. header[1] = 0x80 | payload.length;
  135. } else {
  136. header = Buffer.alloc(4);
  137. header[1] = 0x80 | 126;
  138. header.writeUInt16BE(payload.length, 2);
  139. }
  140. header[0] = 0x81;
  141. const mask = randomBytes(4);
  142. const masked = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]));
  143. const frame = Buffer.concat([header, mask, masked]);
  144. return new Promise((resolvePromise) => {
  145. this.pending.set(id, resolvePromise);
  146. this.socket.write(frame);
  147. }).then((msg) => {
  148. if (msg.error) throw new Error(`${method}: ${msg.error.message}`);
  149. return msg.result;
  150. });
  151. }
  152. close() {
  153. this.socket?.end();
  154. }
  155. }
  156. mkdirSync(dirname(out), { recursive: true });
  157. const chrome = spawn(chromePath, [
  158. '--headless',
  159. '--disable-gpu',
  160. '--no-sandbox',
  161. '--disable-dev-shm-usage',
  162. `--remote-debugging-port=${port}`,
  163. `--user-data-dir=${resolve(userDataDir)}`,
  164. `--window-size=${windowSize}`,
  165. url
  166. ], { stdio: 'ignore' });
  167. try {
  168. await waitForChrome();
  169. let page;
  170. for (let i = 0; i < 40; i++) {
  171. const pages = await getJson('/json/list');
  172. page = pages.find((item) => item.type === 'page' && item.url.includes('localhost:7456')) || pages.find((item) => item.type === 'page');
  173. if (page?.webSocketDebuggerUrl) break;
  174. await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
  175. }
  176. if (!page?.webSocketDebuggerUrl) throw new Error('No debuggable page found');
  177. const cdp = new CdpSocket(page.webSocketDebuggerUrl);
  178. await cdp.connect();
  179. await cdp.send('Page.enable');
  180. await cdp.send('Runtime.enable');
  181. if (holdRightMs > 0) {
  182. await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 });
  183. await new Promise((resolvePromise) => setTimeout(resolvePromise, holdRightMs));
  184. await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 });
  185. }
  186. if (holdFireMs > 0) {
  187. await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'j', code: 'KeyJ', windowsVirtualKeyCode: 74 });
  188. await new Promise((resolvePromise) => setTimeout(resolvePromise, holdFireMs));
  189. await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'j', code: 'KeyJ', windowsVirtualKeyCode: 74 });
  190. }
  191. await new Promise((resolvePromise) => setTimeout(resolvePromise, waitMs));
  192. let evalResult = null;
  193. if (evalExpression) {
  194. evalResult = await cdp.send('Runtime.evaluate', { expression: evalExpression, returnByValue: true });
  195. await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
  196. }
  197. const title = await cdp.send('Runtime.evaluate', { expression: 'document.title', returnByValue: true });
  198. const canvasInfo = await cdp.send('Runtime.evaluate', {
  199. expression: `(() => {
  200. const c = document.querySelector('canvas');
  201. return c ? { width: c.width, height: c.height, clientWidth: c.clientWidth, clientHeight: c.clientHeight } : null;
  202. })()`,
  203. returnByValue: true
  204. });
  205. const screenshot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
  206. writeFileSync(out, Buffer.from(screenshot.data, 'base64'));
  207. cdp.close();
  208. console.log(JSON.stringify({
  209. ok: true,
  210. url,
  211. output: out,
  212. title: title.result.value,
  213. canvas: canvasInfo.result.value,
  214. evalResult: evalResult?.result?.value ?? null,
  215. recentEvents: cdp.events.slice(-20).map((event) => ({
  216. method: event.method,
  217. type: event.params?.type,
  218. text: event.params?.args?.map((arg) => arg.value || arg.description).filter(Boolean).join(' ')
  219. || event.params?.exceptionDetails?.text
  220. || ''
  221. }))
  222. }, null, 2));
  223. } finally {
  224. chrome.kill();
  225. }