primocode 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/browser.js CHANGED
@@ -1,492 +1,507 @@
1
- // Controlador do navegador do usuário via CDP (Chrome DevTools Protocol).
2
- // Sem dependências externas — usa apenas o módulo 'http' e um cliente WebSocket
3
- // mínimo escrito à mão em cima de 'net'/'tls'.
4
- //
5
- // Como funciona:
6
- // 1) launch() abre o Chrome do usuário com --remote-debugging-port=9222
7
- // e um --user-data-dir dedicado (~/.primocode/chrome-profile) — assim
8
- // o usuário logado nesse profile persiste (login GitHub, Vercel, etc).
9
- // 2) attach() descobre a aba ativa em /json/list e abre um WebSocket CDP.
10
- // 3) send(method, params) envia comandos CDP (Page.navigate, Input.dispatchMouseEvent...).
11
- // 4) close() encerra a conexão (o Chrome continua aberto para o usuário).
12
- //
13
- // Não usamos 'puppeteer' ou 'playwright' — a CLI continua com ZERO deps.
14
-
15
- 'use strict';
16
-
17
- const fs = require('fs');
18
- const os = require('os');
19
- const net = require('net');
20
- const path = require('path');
21
- const http = require('http');
22
- const crypto = require('crypto');
23
- const { spawn, execSync } = require('child_process');
24
-
25
- const PROFILE_DIR = path.join(os.homedir(), '.primocode', 'chrome-profile');
26
- const DEBUG_PORT = 9222;
27
-
28
- // ---- Helpers ---------------------------------------------------
29
- function findChrome() {
30
- const platform = process.platform;
31
- const candidates = platform === 'darwin' ? [
32
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
33
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
34
- ] : platform === 'win32' ? [
35
- `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`,
36
- `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
37
- `${process.env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`,
38
- ] : [
39
- '/usr/bin/google-chrome',
40
- '/usr/bin/google-chrome-stable',
41
- '/usr/bin/chromium',
42
- '/usr/bin/chromium-browser',
43
- '/snap/bin/chromium',
44
- ];
45
- for (const p of candidates) {
46
- try { if (p && fs.existsSync(p)) return p; } catch {}
47
- }
48
- return null;
49
- }
50
-
51
- function httpGetJson(url, timeoutMs = 3000) {
52
- return new Promise((resolve, reject) => {
53
- const req = http.get(url, (res) => {
54
- let body = '';
55
- res.on('data', (d) => (body += d));
56
- res.on('end', () => {
57
- try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('JSON inválido: ' + body.slice(0, 100))); }
58
- });
59
- });
60
- req.on('error', reject);
61
- req.setTimeout(timeoutMs, () => { req.destroy(new Error('timeout')); });
62
- });
63
- }
64
-
65
- function httpPutJson(url, timeoutMs = 3000) {
66
- return new Promise((resolve, reject) => {
67
- const u = new URL(url);
68
- const req = http.request({
69
- method: 'PUT', hostname: u.hostname, port: u.port, path: u.pathname + u.search,
70
- }, (res) => {
71
- let body = '';
72
- res.on('data', (d) => (body += d));
73
- res.on('end', () => {
74
- try { resolve(JSON.parse(body)); } catch { resolve({ raw: body }); }
75
- });
76
- });
77
- req.on('error', reject);
78
- req.setTimeout(timeoutMs, () => { req.destroy(new Error('timeout')); });
79
- req.end();
80
- });
81
- }
82
-
83
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
84
-
85
- // ---- WebSocket client mínimo (RFC 6455) para CDP ----------------
86
- // Suficiente pra CDP (mensagens curtas, texto UTF-8, um cliente por página).
87
- class MiniWebSocket {
88
- constructor() {
89
- this.socket = null;
90
- this.buffer = Buffer.alloc(0);
91
- this.onMessage = () => {};
92
- this.onClose = () => {};
93
- this.closed = false;
94
- }
95
- connect(url) {
96
- return new Promise((resolve, reject) => {
97
- const u = new URL(url);
98
- const isSecure = u.protocol === 'wss:';
99
- if (isSecure) return reject(new Error('wss:// não suportado (CDP local usa ws://)'));
100
- const key = crypto.randomBytes(16).toString('base64');
101
- const port = u.port || 80;
102
- const path = u.pathname + (u.search || '');
103
- const sock = net.connect(Number(port), u.hostname, () => {
104
- const req =
105
- `GET ${path} HTTP/1.1\r\n` +
106
- `Host: ${u.hostname}:${port}\r\n` +
107
- `Upgrade: websocket\r\n` +
108
- `Connection: Upgrade\r\n` +
109
- `Sec-WebSocket-Key: ${key}\r\n` +
110
- `Sec-WebSocket-Version: 13\r\n\r\n`;
111
- sock.write(req);
112
- });
113
- let handshaked = false;
114
- let headerBuf = Buffer.alloc(0);
115
- sock.on('data', (data) => {
116
- if (!handshaked) {
117
- headerBuf = Buffer.concat([headerBuf, data]);
118
- const sep = headerBuf.indexOf('\r\n\r\n');
119
- if (sep === -1) return;
120
- const header = headerBuf.slice(0, sep).toString();
121
- if (!/HTTP\/1\.1 101/.test(header)) return reject(new Error('handshake WS falhou: ' + header.split('\r\n')[0]));
122
- handshaked = true;
123
- this.socket = sock;
124
- const rest = headerBuf.slice(sep + 4);
125
- if (rest.length) this._onData(rest);
126
- resolve();
127
- } else {
128
- this._onData(data);
129
- }
130
- });
131
- sock.on('error', (err) => {
132
- if (!handshaked) reject(err);
133
- this.closed = true; this.onClose(err);
134
- });
135
- sock.on('close', () => { this.closed = true; this.onClose(); });
136
- });
137
- }
138
- _onData(chunk) {
139
- this.buffer = Buffer.concat([this.buffer, chunk]);
140
- while (this.buffer.length >= 2) {
141
- const b0 = this.buffer[0];
142
- const b1 = this.buffer[1];
143
- const fin = (b0 & 0x80) !== 0;
144
- const opcode = b0 & 0x0f;
145
- const masked = (b1 & 0x80) !== 0;
146
- let len = b1 & 0x7f;
147
- let offset = 2;
148
- if (len === 126) {
149
- if (this.buffer.length < 4) return;
150
- len = this.buffer.readUInt16BE(2);
151
- offset = 4;
152
- } else if (len === 127) {
153
- if (this.buffer.length < 10) return;
154
- // ignora high 32 bits (mensagens CDP < 4GB)
155
- len = this.buffer.readUInt32BE(6);
156
- offset = 10;
157
- }
158
- let maskKey;
159
- if (masked) {
160
- if (this.buffer.length < offset + 4) return;
161
- maskKey = this.buffer.slice(offset, offset + 4);
162
- offset += 4;
163
- }
164
- if (this.buffer.length < offset + len) return;
165
- let payload = this.buffer.slice(offset, offset + len);
166
- if (masked && maskKey) {
167
- const un = Buffer.alloc(payload.length);
168
- for (let i = 0; i < payload.length; i++) un[i] = payload[i] ^ maskKey[i % 4];
169
- payload = un;
170
- }
171
- this.buffer = this.buffer.slice(offset + len);
172
- if (opcode === 0x8) { this._close(); return; }
173
- if (opcode === 0x9) { this._sendFrame(0xa, payload); continue; } // ping -> pong
174
- if (opcode === 0xa) continue; // pong
175
- if (opcode === 0x1 || opcode === 0x0) {
176
- // texto (não tratamos fragmentação — CDP não fragmenta)
177
- if (fin) this.onMessage(payload.toString('utf8'));
178
- }
179
- }
180
- }
181
- _sendFrame(opcode, payload) {
182
- if (!this.socket || this.closed) return;
183
- const len = payload.length;
184
- let header;
185
- // cliente DEVE mascarar
186
- const mask = crypto.randomBytes(4);
187
- if (len < 126) {
188
- header = Buffer.alloc(2);
189
- header[0] = 0x80 | opcode;
190
- header[1] = 0x80 | len;
191
- } else if (len < 65536) {
192
- header = Buffer.alloc(4);
193
- header[0] = 0x80 | opcode;
194
- header[1] = 0x80 | 126;
195
- header.writeUInt16BE(len, 2);
196
- } else {
197
- header = Buffer.alloc(10);
198
- header[0] = 0x80 | opcode;
199
- header[1] = 0x80 | 127;
200
- header.writeUInt32BE(0, 2);
201
- header.writeUInt32BE(len, 6);
202
- }
203
- const masked = Buffer.alloc(len);
204
- for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
205
- this.socket.write(Buffer.concat([header, mask, masked]));
206
- }
207
- send(str) { this._sendFrame(0x1, Buffer.from(str, 'utf8')); }
208
- _close() { if (this.socket) { try { this.socket.end(); } catch {} } this.closed = true; }
209
- close() { this._sendFrame(0x8, Buffer.alloc(0)); this._close(); }
210
- }
211
-
212
- // ---- Sessão CDP -------------------------------------------------
213
- class CDPSession {
214
- constructor() {
215
- this.ws = null;
216
- this.msgId = 0;
217
- this.pending = new Map(); // id -> { resolve, reject }
218
- this.targetId = null;
219
- }
220
- async attach(wsUrl) {
221
- this.ws = new MiniWebSocket();
222
- this.ws.onMessage = (raw) => this._onMessage(raw);
223
- this.ws.onClose = () => { this.pending.forEach(({ reject }) => reject(new Error('ws fechado'))); };
224
- await this.ws.connect(wsUrl);
225
- }
226
- _onMessage(raw) {
227
- let msg;
228
- try { msg = JSON.parse(raw); } catch { return; }
229
- if (msg.id !== undefined && this.pending.has(msg.id)) {
230
- const { resolve, reject } = this.pending.get(msg.id);
231
- this.pending.delete(msg.id);
232
- if (msg.error) reject(new Error(msg.error.message || 'erro CDP'));
233
- else resolve(msg.result || {});
234
- }
235
- // eventos são ignorados (não usamos aqui)
236
- }
237
- send(method, params = {}, timeoutMs = 15000) {
238
- const id = ++this.msgId;
239
- const payload = JSON.stringify({ id, method, params });
240
- return new Promise((resolve, reject) => {
241
- const timer = setTimeout(() => {
242
- if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`timeout ${method}`)); }
243
- }, timeoutMs);
244
- this.pending.set(id, {
245
- resolve: (r) => { clearTimeout(timer); resolve(r); },
246
- reject: (e) => { clearTimeout(timer); reject(e); },
247
- });
248
- this.ws.send(payload);
249
- });
250
- }
251
- close() { if (this.ws) this.ws.close(); }
252
- }
253
-
254
- // ---- Estado do módulo -------------------------------------------
255
- let currentSession = null;
256
- let chromeChild = null;
257
-
258
- async function ensureChromeRunning() {
259
- // Já responde na porta?
260
- try {
261
- const v = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/version`, 800);
262
- if (v && v.webSocketDebuggerUrl) return { launched: false, version: v.Browser || 'chrome' };
263
- } catch {}
264
-
265
- const chrome = findChrome();
266
- if (!chrome) throw new Error('Google Chrome não encontrado no sistema. Instale o Chrome para usar o controle de navegador.');
267
-
268
- fs.mkdirSync(PROFILE_DIR, { recursive: true });
269
-
270
- const args = [
271
- `--remote-debugging-port=${DEBUG_PORT}`,
272
- `--user-data-dir=${PROFILE_DIR}`,
273
- '--no-first-run',
274
- '--no-default-browser-check',
275
- '--disable-features=Translate',
276
- 'about:blank',
277
- ];
278
- chromeChild = spawn(chrome, args, { detached: true, stdio: 'ignore' });
279
- chromeChild.on('error', () => {});
280
- chromeChild.unref();
281
-
282
- // Espera a porta responder
283
- const deadline = Date.now() + 12000;
284
- let lastErr;
285
- while (Date.now() < deadline) {
286
- try {
287
- const v = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/version`, 500);
288
- if (v && v.webSocketDebuggerUrl) return { launched: true, version: v.Browser || 'chrome' };
289
- } catch (e) { lastErr = e; }
290
- await sleep(250);
291
- }
292
- throw new Error('Chrome iniciou mas não expôs a porta de debug: ' + (lastErr ? lastErr.message : ''));
293
- }
294
-
295
- async function findOrCreatePageTarget(url) {
296
- const list = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/list`, 2000);
297
- // 1) tenta reaproveitar uma aba já ativa
298
- const existing = Array.isArray(list) ? list.find(t => t.type === 'page' && !t.url.startsWith('devtools://')) : null;
299
- if (existing) return existing;
300
- // 2) senão, cria nova
301
- const created = await httpPutJson(`http://127.0.0.1:${DEBUG_PORT}/json/new?${encodeURIComponent(url || 'about:blank')}`, 3000);
302
- return created;
303
- }
304
-
305
- async function connect(url) {
306
- await ensureChromeRunning();
307
- const target = await findOrCreatePageTarget(url);
308
- if (!target || !target.webSocketDebuggerUrl) throw new Error('Não foi possível obter alvo de página do Chrome.');
309
- if (currentSession) { try { currentSession.close(); } catch {} }
310
- currentSession = new CDPSession();
311
- currentSession.targetId = target.id;
312
- await currentSession.attach(target.webSocketDebuggerUrl);
313
- await currentSession.send('Page.enable');
314
- await currentSession.send('DOM.enable');
315
- await currentSession.send('Runtime.enable');
316
- return { targetId: target.id, url: target.url };
317
- }
318
-
319
- async function ensure(url) {
320
- if (!currentSession || currentSession.ws?.closed) return connect(url);
321
- return { targetId: currentSession.targetId, url: '(reused)' };
322
- }
323
-
324
- // ---- Ações ------------------------------------------------------
325
- async function open(url) {
326
- await ensure(url);
327
- // Navega até URL solicitada
328
- await currentSession.send('Page.navigate', { url });
329
- // Espera "load" simples via polling do document.readyState
330
- const deadline = Date.now() + 20000;
331
- while (Date.now() < deadline) {
332
- const r = await currentSession.send('Runtime.evaluate', {
333
- expression: 'document.readyState', returnByValue: true,
334
- });
335
- if (r.result && r.result.value === 'complete') break;
336
- await sleep(200);
337
- }
338
- return { ok: true, url };
339
- }
340
-
341
- async function navigate(url) { return open(url); }
342
-
343
- async function evaluate(js) {
344
- await ensure();
345
- const r = await currentSession.send('Runtime.evaluate', {
346
- expression: js, returnByValue: true, awaitPromise: true,
347
- });
348
- if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || r.exceptionDetails.exception?.description || 'JS error');
349
- return r.result ? r.result.value : undefined;
350
- }
351
-
352
- async function click(x, y) {
353
- await ensure();
354
- await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
355
- await currentSession.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
356
- await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
357
- return { ok: true, x, y };
358
- }
359
-
360
- async function clickText(text) {
361
- await ensure();
362
- // procura elemento clicável cujo texto contenha o alvo e retorna centro do bounding box
363
- const js = `(function(){
364
- const t = ${JSON.stringify(text)}.toLowerCase();
365
- const nodes = Array.from(document.querySelectorAll('a,button,[role="button"],input[type="submit"],input[type="button"],label'));
366
- for (const el of nodes) {
367
- const s = (el.innerText || el.value || el.getAttribute('aria-label') || '').toLowerCase().trim();
368
- if (s && s.includes(t)) {
369
- const r = el.getBoundingClientRect();
370
- if (r.width && r.height) {
371
- el.scrollIntoView({block:'center'});
372
- const r2 = el.getBoundingClientRect();
373
- return { x: r2.left + r2.width/2, y: r2.top + r2.height/2, tag: el.tagName, text: s.slice(0,80) };
374
- }
375
- }
376
- }
377
- return null;
378
- })()`;
379
- const found = await evaluate(js);
380
- if (!found) throw new Error(`nenhum elemento clicável encontrado com texto: ${text}`);
381
- await sleep(200);
382
- await click(found.x, found.y);
383
- return { ok: true, ...found };
384
- }
385
-
386
- async function clickSelector(selector) {
387
- await ensure();
388
- const js = `(function(){
389
- const el = document.querySelector(${JSON.stringify(selector)});
390
- if (!el) return null;
391
- el.scrollIntoView({block:'center'});
392
- const r = el.getBoundingClientRect();
393
- return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName };
394
- })()`;
395
- const found = await evaluate(js);
396
- if (!found) throw new Error(`seletor não encontrado: ${selector}`);
397
- await sleep(200);
398
- await click(found.x, found.y);
399
- return { ok: true, selector, ...found };
400
- }
401
-
402
- async function type(text) {
403
- await ensure();
404
- // Insere via CDP
405
- for (const ch of text) {
406
- await currentSession.send('Input.insertText', { text: ch });
407
- }
408
- return { ok: true, len: text.length };
409
- }
410
-
411
- async function typeInSelector(selector, text, clear = true) {
412
- await ensure();
413
- const focused = await evaluate(`(function(){
414
- const el = document.querySelector(${JSON.stringify(selector)});
415
- if (!el) return false;
416
- el.focus();
417
- ${clear ? "if ('value' in el) el.value = '';" : ''}
418
- return true;
419
- })()`);
420
- if (!focused) throw new Error(`seletor não encontrado: ${selector}`);
421
- await type(text);
422
- // dispara evento input para frameworks reativos
423
- await evaluate(`(function(){
424
- const el = document.querySelector(${JSON.stringify(selector)});
425
- if (el) { el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); }
426
- })()`);
427
- return { ok: true, selector, len: text.length };
428
- }
429
-
430
- async function key(name) {
431
- // 'Enter', 'Tab', 'Escape', 'Backspace', 'ArrowDown'...
432
- const map = {
433
- Enter: { code: 'Enter', key: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
434
- Tab: { code: 'Tab', key: 'Tab', windowsVirtualKeyCode: 9 },
435
- Escape: { code: 'Escape', key: 'Escape', windowsVirtualKeyCode: 27 },
436
- Backspace: { code: 'Backspace', key: 'Backspace', windowsVirtualKeyCode: 8 },
437
- ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', windowsVirtualKeyCode: 40 },
438
- ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', windowsVirtualKeyCode: 38 },
439
- ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', windowsVirtualKeyCode: 37 },
440
- ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', windowsVirtualKeyCode: 39 },
441
- };
442
- const k = map[name];
443
- if (!k) throw new Error(`tecla desconhecida: ${name}`);
444
- await ensure();
445
- await currentSession.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k });
446
- await currentSession.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k });
447
- return { ok: true, key: name };
448
- }
449
-
450
- async function screenshot(saveTo) {
451
- await ensure();
452
- const r = await currentSession.send('Page.captureScreenshot', { format: 'png' });
453
- if (!r.data) throw new Error('captura vazia');
454
- const filename = saveTo || `screenshot-${Date.now()}.png`;
455
- fs.writeFileSync(filename, Buffer.from(r.data, 'base64'));
456
- return { ok: true, path: filename, bytes: Buffer.byteLength(r.data, 'base64') };
457
- }
458
-
459
- async function currentUrl() {
460
- await ensure();
461
- return evaluate('location.href');
462
- }
463
-
464
- async function waitFor(selector, timeoutMs = 15000) {
465
- await ensure();
466
- const deadline = Date.now() + timeoutMs;
467
- while (Date.now() < deadline) {
468
- const found = await evaluate(`!!document.querySelector(${JSON.stringify(selector)})`);
469
- if (found) return { ok: true, selector };
470
- await sleep(200);
471
- }
472
- throw new Error(`timeout esperando seletor: ${selector}`);
473
- }
474
-
475
- async function wait(ms) { await sleep(ms); return { ok: true, ms }; }
476
-
477
- async function scroll(dy) {
478
- await ensure();
479
- await evaluate(`window.scrollBy(0, ${Number(dy)||0})`);
480
- return { ok: true, dy };
481
- }
482
-
483
- function close() {
484
- if (currentSession) { try { currentSession.close(); } catch {} currentSession = null; }
485
- return { ok: true };
486
- }
487
-
488
- module.exports = {
489
- open, navigate, click, clickText, clickSelector, type, typeInSelector,
490
- key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
491
- _internal: { findChrome, PROFILE_DIR, DEBUG_PORT },
492
- };
1
+ // Controlador do navegador do usuário via CDP (Chrome DevTools Protocol).
2
+ // Sem dependências externas — usa apenas o módulo 'http' e um cliente WebSocket
3
+ // mínimo escrito à mão em cima de 'net'/'tls'.
4
+ //
5
+ // Como funciona:
6
+ // 1) launch() abre o Chrome do usuário com --remote-debugging-port=9222
7
+ // e um --user-data-dir dedicado (~/.primocode/chrome-profile) — assim
8
+ // o usuário logado nesse profile persiste (login GitHub, Vercel, etc).
9
+ // 2) attach() descobre a aba ativa em /json/list e abre um WebSocket CDP.
10
+ // 3) send(method, params) envia comandos CDP (Page.navigate, Input.dispatchMouseEvent...).
11
+ // 4) close() encerra a conexão (o Chrome continua aberto para o usuário).
12
+ //
13
+ // Não usamos 'puppeteer' ou 'playwright' — a CLI continua com ZERO deps.
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const net = require('net');
20
+ const path = require('path');
21
+ const http = require('http');
22
+ const crypto = require('crypto');
23
+ const { spawn, execSync } = require('child_process');
24
+
25
+ const PROFILE_DIR = path.join(os.homedir(), '.primocode', 'chrome-profile');
26
+ const DEBUG_PORT = 9222;
27
+
28
+ // ---- Helpers ---------------------------------------------------
29
+ function findChrome() {
30
+ const platform = process.platform;
31
+ // 1) Respeita a variável de ambiente — aponta para qualquer Chrome/Chromium.
32
+ const env = [process.env.PRIMOCODE_CHROME, process.env.CHROME_PATH, process.env.CHROMIUM_PATH].filter(Boolean);
33
+ const candidates = env.concat(platform === 'darwin' ? [
34
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
35
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
36
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
37
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
38
+ ] : platform === 'win32' ? [
39
+ `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`,
40
+ `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
41
+ `${process.env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`,
42
+ `${process.env['PROGRAMFILES(X86)']}\\Microsoft\\Edge\\Application\\msedge.exe`,
43
+ `${process.env['PROGRAMFILES']}\\Microsoft\\Edge\\Application\\msedge.exe`,
44
+ ] : [
45
+ '/usr/bin/google-chrome',
46
+ '/usr/bin/google-chrome-stable',
47
+ '/usr/bin/chromium',
48
+ '/usr/bin/chromium-browser',
49
+ '/snap/bin/chromium',
50
+ '/usr/bin/microsoft-edge',
51
+ ]);
52
+ for (const p of candidates) {
53
+ try { if (p && fs.existsSync(p)) return p; } catch {}
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function httpGetJson(url, timeoutMs = 3000) {
59
+ return new Promise((resolve, reject) => {
60
+ const req = http.get(url, (res) => {
61
+ let body = '';
62
+ res.on('data', (d) => (body += d));
63
+ res.on('end', () => {
64
+ try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('JSON inválido: ' + body.slice(0, 100))); }
65
+ });
66
+ });
67
+ req.on('error', reject);
68
+ req.setTimeout(timeoutMs, () => { req.destroy(new Error('timeout')); });
69
+ });
70
+ }
71
+
72
+ function httpPutJson(url, timeoutMs = 3000) {
73
+ return new Promise((resolve, reject) => {
74
+ const u = new URL(url);
75
+ const req = http.request({
76
+ method: 'PUT', hostname: u.hostname, port: u.port, path: u.pathname + u.search,
77
+ }, (res) => {
78
+ let body = '';
79
+ res.on('data', (d) => (body += d));
80
+ res.on('end', () => {
81
+ try { resolve(JSON.parse(body)); } catch { resolve({ raw: body }); }
82
+ });
83
+ });
84
+ req.on('error', reject);
85
+ req.setTimeout(timeoutMs, () => { req.destroy(new Error('timeout')); });
86
+ req.end();
87
+ });
88
+ }
89
+
90
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
91
+
92
+ // ---- WebSocket client mínimo (RFC 6455) para CDP ----------------
93
+ // Suficiente pra CDP (mensagens curtas, texto UTF-8, um cliente por página).
94
+ class MiniWebSocket {
95
+ constructor() {
96
+ this.socket = null;
97
+ this.buffer = Buffer.alloc(0);
98
+ this.onMessage = () => {};
99
+ this.onClose = () => {};
100
+ this.closed = false;
101
+ }
102
+ connect(url) {
103
+ return new Promise((resolve, reject) => {
104
+ const u = new URL(url);
105
+ const isSecure = u.protocol === 'wss:';
106
+ if (isSecure) return reject(new Error('wss:// não suportado (CDP local usa ws://)'));
107
+ const key = crypto.randomBytes(16).toString('base64');
108
+ const port = u.port || 80;
109
+ const path = u.pathname + (u.search || '');
110
+ const sock = net.connect(Number(port), u.hostname, () => {
111
+ const req =
112
+ `GET ${path} HTTP/1.1\r\n` +
113
+ `Host: ${u.hostname}:${port}\r\n` +
114
+ `Upgrade: websocket\r\n` +
115
+ `Connection: Upgrade\r\n` +
116
+ `Sec-WebSocket-Key: ${key}\r\n` +
117
+ `Sec-WebSocket-Version: 13\r\n\r\n`;
118
+ sock.write(req);
119
+ });
120
+ let handshaked = false;
121
+ let headerBuf = Buffer.alloc(0);
122
+ sock.on('data', (data) => {
123
+ if (!handshaked) {
124
+ headerBuf = Buffer.concat([headerBuf, data]);
125
+ const sep = headerBuf.indexOf('\r\n\r\n');
126
+ if (sep === -1) return;
127
+ const header = headerBuf.slice(0, sep).toString();
128
+ if (!/HTTP\/1\.1 101/.test(header)) return reject(new Error('handshake WS falhou: ' + header.split('\r\n')[0]));
129
+ handshaked = true;
130
+ this.socket = sock;
131
+ const rest = headerBuf.slice(sep + 4);
132
+ if (rest.length) this._onData(rest);
133
+ resolve();
134
+ } else {
135
+ this._onData(data);
136
+ }
137
+ });
138
+ sock.on('error', (err) => {
139
+ if (!handshaked) reject(err);
140
+ this.closed = true; this.onClose(err);
141
+ });
142
+ sock.on('close', () => { this.closed = true; this.onClose(); });
143
+ });
144
+ }
145
+ _onData(chunk) {
146
+ this.buffer = Buffer.concat([this.buffer, chunk]);
147
+ while (this.buffer.length >= 2) {
148
+ const b0 = this.buffer[0];
149
+ const b1 = this.buffer[1];
150
+ const fin = (b0 & 0x80) !== 0;
151
+ const opcode = b0 & 0x0f;
152
+ const masked = (b1 & 0x80) !== 0;
153
+ let len = b1 & 0x7f;
154
+ let offset = 2;
155
+ if (len === 126) {
156
+ if (this.buffer.length < 4) return;
157
+ len = this.buffer.readUInt16BE(2);
158
+ offset = 4;
159
+ } else if (len === 127) {
160
+ if (this.buffer.length < 10) return;
161
+ // ignora high 32 bits (mensagens CDP < 4GB)
162
+ len = this.buffer.readUInt32BE(6);
163
+ offset = 10;
164
+ }
165
+ let maskKey;
166
+ if (masked) {
167
+ if (this.buffer.length < offset + 4) return;
168
+ maskKey = this.buffer.slice(offset, offset + 4);
169
+ offset += 4;
170
+ }
171
+ if (this.buffer.length < offset + len) return;
172
+ let payload = this.buffer.slice(offset, offset + len);
173
+ if (masked && maskKey) {
174
+ const un = Buffer.alloc(payload.length);
175
+ for (let i = 0; i < payload.length; i++) un[i] = payload[i] ^ maskKey[i % 4];
176
+ payload = un;
177
+ }
178
+ this.buffer = this.buffer.slice(offset + len);
179
+ if (opcode === 0x8) { this._close(); return; }
180
+ if (opcode === 0x9) { this._sendFrame(0xa, payload); continue; } // ping -> pong
181
+ if (opcode === 0xa) continue; // pong
182
+ if (opcode === 0x1 || opcode === 0x0) {
183
+ // texto (não tratamos fragmentação — CDP não fragmenta)
184
+ if (fin) this.onMessage(payload.toString('utf8'));
185
+ }
186
+ }
187
+ }
188
+ _sendFrame(opcode, payload) {
189
+ if (!this.socket || this.closed) return;
190
+ const len = payload.length;
191
+ let header;
192
+ // cliente DEVE mascarar
193
+ const mask = crypto.randomBytes(4);
194
+ if (len < 126) {
195
+ header = Buffer.alloc(2);
196
+ header[0] = 0x80 | opcode;
197
+ header[1] = 0x80 | len;
198
+ } else if (len < 65536) {
199
+ header = Buffer.alloc(4);
200
+ header[0] = 0x80 | opcode;
201
+ header[1] = 0x80 | 126;
202
+ header.writeUInt16BE(len, 2);
203
+ } else {
204
+ header = Buffer.alloc(10);
205
+ header[0] = 0x80 | opcode;
206
+ header[1] = 0x80 | 127;
207
+ header.writeUInt32BE(0, 2);
208
+ header.writeUInt32BE(len, 6);
209
+ }
210
+ const masked = Buffer.alloc(len);
211
+ for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
212
+ this.socket.write(Buffer.concat([header, mask, masked]));
213
+ }
214
+ send(str) { this._sendFrame(0x1, Buffer.from(str, 'utf8')); }
215
+ _close() { if (this.socket) { try { this.socket.end(); } catch {} } this.closed = true; }
216
+ close() { this._sendFrame(0x8, Buffer.alloc(0)); this._close(); }
217
+ }
218
+
219
+ // ---- Sessão CDP -------------------------------------------------
220
+ class CDPSession {
221
+ constructor() {
222
+ this.ws = null;
223
+ this.msgId = 0;
224
+ this.pending = new Map(); // id -> { resolve, reject }
225
+ this.targetId = null;
226
+ }
227
+ async attach(wsUrl) {
228
+ this.ws = new MiniWebSocket();
229
+ this.ws.onMessage = (raw) => this._onMessage(raw);
230
+ this.ws.onClose = () => { this.pending.forEach(({ reject }) => reject(new Error('ws fechado'))); };
231
+ await this.ws.connect(wsUrl);
232
+ }
233
+ _onMessage(raw) {
234
+ let msg;
235
+ try { msg = JSON.parse(raw); } catch { return; }
236
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
237
+ const { resolve, reject } = this.pending.get(msg.id);
238
+ this.pending.delete(msg.id);
239
+ if (msg.error) reject(new Error(msg.error.message || 'erro CDP'));
240
+ else resolve(msg.result || {});
241
+ }
242
+ // eventos são ignorados (não usamos aqui)
243
+ }
244
+ send(method, params = {}, timeoutMs = 15000) {
245
+ const id = ++this.msgId;
246
+ const payload = JSON.stringify({ id, method, params });
247
+ return new Promise((resolve, reject) => {
248
+ const timer = setTimeout(() => {
249
+ if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`timeout ${method}`)); }
250
+ }, timeoutMs);
251
+ this.pending.set(id, {
252
+ resolve: (r) => { clearTimeout(timer); resolve(r); },
253
+ reject: (e) => { clearTimeout(timer); reject(e); },
254
+ });
255
+ this.ws.send(payload);
256
+ });
257
+ }
258
+ close() { if (this.ws) this.ws.close(); }
259
+ }
260
+
261
+ // ---- Estado do módulo -------------------------------------------
262
+ let currentSession = null;
263
+ let chromeChild = null;
264
+
265
+ async function ensureChromeRunning() {
266
+ // Já responde na porta?
267
+ try {
268
+ const v = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/version`, 800);
269
+ if (v && v.webSocketDebuggerUrl) return { launched: false, version: v.Browser || 'chrome' };
270
+ } catch {}
271
+
272
+ const chrome = findChrome();
273
+ if (!chrome) throw new Error('Google Chrome não encontrado no sistema. Instale o Chrome para usar o controle de navegador.');
274
+
275
+ fs.mkdirSync(PROFILE_DIR, { recursive: true });
276
+
277
+ const args = [
278
+ `--remote-debugging-port=${DEBUG_PORT}`,
279
+ `--user-data-dir=${PROFILE_DIR}`,
280
+ '--no-first-run',
281
+ '--no-default-browser-check',
282
+ '--disable-features=Translate',
283
+ ];
284
+ // Chrome recusa rodar como root com sandbox — comum em containers/servidores.
285
+ if (process.platform === 'linux' && process.getuid && process.getuid() === 0) {
286
+ args.push('--no-sandbox');
287
+ }
288
+ // Sem interface gráfica (CI, servidor): PRIMOCODE_BROWSER_HEADLESS=1.
289
+ if (process.env.PRIMOCODE_BROWSER_HEADLESS === '1' || process.env.PRIMOCODE_BROWSER_HEADLESS === 'true') {
290
+ args.push('--headless=new', '--disable-gpu', '--disable-dev-shm-usage');
291
+ }
292
+ args.push('about:blank');
293
+ chromeChild = spawn(chrome, args, { detached: true, stdio: 'ignore' });
294
+ chromeChild.on('error', () => {});
295
+ chromeChild.unref();
296
+
297
+ // Espera a porta responder
298
+ const deadline = Date.now() + 12000;
299
+ let lastErr;
300
+ while (Date.now() < deadline) {
301
+ try {
302
+ const v = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/version`, 500);
303
+ if (v && v.webSocketDebuggerUrl) return { launched: true, version: v.Browser || 'chrome' };
304
+ } catch (e) { lastErr = e; }
305
+ await sleep(250);
306
+ }
307
+ throw new Error('Chrome iniciou mas não expôs a porta de debug: ' + (lastErr ? lastErr.message : ''));
308
+ }
309
+
310
+ async function findOrCreatePageTarget(url) {
311
+ const list = await httpGetJson(`http://127.0.0.1:${DEBUG_PORT}/json/list`, 2000);
312
+ // 1) tenta reaproveitar uma aba já ativa
313
+ const existing = Array.isArray(list) ? list.find(t => t.type === 'page' && !t.url.startsWith('devtools://')) : null;
314
+ if (existing) return existing;
315
+ // 2) senão, cria nova
316
+ const created = await httpPutJson(`http://127.0.0.1:${DEBUG_PORT}/json/new?${encodeURIComponent(url || 'about:blank')}`, 3000);
317
+ return created;
318
+ }
319
+
320
+ async function connect(url) {
321
+ await ensureChromeRunning();
322
+ const target = await findOrCreatePageTarget(url);
323
+ if (!target || !target.webSocketDebuggerUrl) throw new Error('Não foi possível obter alvo de página do Chrome.');
324
+ if (currentSession) { try { currentSession.close(); } catch {} }
325
+ currentSession = new CDPSession();
326
+ currentSession.targetId = target.id;
327
+ await currentSession.attach(target.webSocketDebuggerUrl);
328
+ await currentSession.send('Page.enable');
329
+ await currentSession.send('DOM.enable');
330
+ await currentSession.send('Runtime.enable');
331
+ return { targetId: target.id, url: target.url };
332
+ }
333
+
334
+ async function ensure(url) {
335
+ if (!currentSession || currentSession.ws?.closed) return connect(url);
336
+ return { targetId: currentSession.targetId, url: '(reused)' };
337
+ }
338
+
339
+ // ---- Ações ------------------------------------------------------
340
+ async function open(url) {
341
+ await ensure(url);
342
+ // Navega até URL solicitada
343
+ await currentSession.send('Page.navigate', { url });
344
+ // Espera "load" simples via polling do document.readyState
345
+ const deadline = Date.now() + 20000;
346
+ while (Date.now() < deadline) {
347
+ const r = await currentSession.send('Runtime.evaluate', {
348
+ expression: 'document.readyState', returnByValue: true,
349
+ });
350
+ if (r.result && r.result.value === 'complete') break;
351
+ await sleep(200);
352
+ }
353
+ return { ok: true, url };
354
+ }
355
+
356
+ async function navigate(url) { return open(url); }
357
+
358
+ async function evaluate(js) {
359
+ await ensure();
360
+ const r = await currentSession.send('Runtime.evaluate', {
361
+ expression: js, returnByValue: true, awaitPromise: true,
362
+ });
363
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || r.exceptionDetails.exception?.description || 'JS error');
364
+ return r.result ? r.result.value : undefined;
365
+ }
366
+
367
+ async function click(x, y) {
368
+ await ensure();
369
+ await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
370
+ await currentSession.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
371
+ await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
372
+ return { ok: true, x, y };
373
+ }
374
+
375
+ async function clickText(text) {
376
+ await ensure();
377
+ // procura elemento clicável cujo texto contenha o alvo e retorna centro do bounding box
378
+ const js = `(function(){
379
+ const t = ${JSON.stringify(text)}.toLowerCase();
380
+ const nodes = Array.from(document.querySelectorAll('a,button,[role="button"],input[type="submit"],input[type="button"],label'));
381
+ for (const el of nodes) {
382
+ const s = (el.innerText || el.value || el.getAttribute('aria-label') || '').toLowerCase().trim();
383
+ if (s && s.includes(t)) {
384
+ const r = el.getBoundingClientRect();
385
+ if (r.width && r.height) {
386
+ el.scrollIntoView({block:'center'});
387
+ const r2 = el.getBoundingClientRect();
388
+ return { x: r2.left + r2.width/2, y: r2.top + r2.height/2, tag: el.tagName, text: s.slice(0,80) };
389
+ }
390
+ }
391
+ }
392
+ return null;
393
+ })()`;
394
+ const found = await evaluate(js);
395
+ if (!found) throw new Error(`nenhum elemento clicável encontrado com texto: ${text}`);
396
+ await sleep(200);
397
+ await click(found.x, found.y);
398
+ return { ok: true, ...found };
399
+ }
400
+
401
+ async function clickSelector(selector) {
402
+ await ensure();
403
+ const js = `(function(){
404
+ const el = document.querySelector(${JSON.stringify(selector)});
405
+ if (!el) return null;
406
+ el.scrollIntoView({block:'center'});
407
+ const r = el.getBoundingClientRect();
408
+ return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName };
409
+ })()`;
410
+ const found = await evaluate(js);
411
+ if (!found) throw new Error(`seletor não encontrado: ${selector}`);
412
+ await sleep(200);
413
+ await click(found.x, found.y);
414
+ return { ok: true, selector, ...found };
415
+ }
416
+
417
+ async function type(text) {
418
+ await ensure();
419
+ // Insere via CDP
420
+ for (const ch of text) {
421
+ await currentSession.send('Input.insertText', { text: ch });
422
+ }
423
+ return { ok: true, len: text.length };
424
+ }
425
+
426
+ async function typeInSelector(selector, text, clear = true) {
427
+ await ensure();
428
+ const focused = await evaluate(`(function(){
429
+ const el = document.querySelector(${JSON.stringify(selector)});
430
+ if (!el) return false;
431
+ el.focus();
432
+ ${clear ? "if ('value' in el) el.value = '';" : ''}
433
+ return true;
434
+ })()`);
435
+ if (!focused) throw new Error(`seletor não encontrado: ${selector}`);
436
+ await type(text);
437
+ // dispara evento input para frameworks reativos
438
+ await evaluate(`(function(){
439
+ const el = document.querySelector(${JSON.stringify(selector)});
440
+ if (el) { el.dispatchEvent(new Event('input', {bubbles:true})); el.dispatchEvent(new Event('change', {bubbles:true})); }
441
+ })()`);
442
+ return { ok: true, selector, len: text.length };
443
+ }
444
+
445
+ async function key(name) {
446
+ // 'Enter', 'Tab', 'Escape', 'Backspace', 'ArrowDown'...
447
+ const map = {
448
+ Enter: { code: 'Enter', key: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
449
+ Tab: { code: 'Tab', key: 'Tab', windowsVirtualKeyCode: 9 },
450
+ Escape: { code: 'Escape', key: 'Escape', windowsVirtualKeyCode: 27 },
451
+ Backspace: { code: 'Backspace', key: 'Backspace', windowsVirtualKeyCode: 8 },
452
+ ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', windowsVirtualKeyCode: 40 },
453
+ ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', windowsVirtualKeyCode: 38 },
454
+ ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', windowsVirtualKeyCode: 37 },
455
+ ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', windowsVirtualKeyCode: 39 },
456
+ };
457
+ const k = map[name];
458
+ if (!k) throw new Error(`tecla desconhecida: ${name}`);
459
+ await ensure();
460
+ await currentSession.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k });
461
+ await currentSession.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k });
462
+ return { ok: true, key: name };
463
+ }
464
+
465
+ async function screenshot(saveTo) {
466
+ await ensure();
467
+ const r = await currentSession.send('Page.captureScreenshot', { format: 'png' });
468
+ if (!r.data) throw new Error('captura vazia');
469
+ const filename = saveTo || `screenshot-${Date.now()}.png`;
470
+ fs.writeFileSync(filename, Buffer.from(r.data, 'base64'));
471
+ return { ok: true, path: filename, bytes: Buffer.byteLength(r.data, 'base64') };
472
+ }
473
+
474
+ async function currentUrl() {
475
+ await ensure();
476
+ return evaluate('location.href');
477
+ }
478
+
479
+ async function waitFor(selector, timeoutMs = 15000) {
480
+ await ensure();
481
+ const deadline = Date.now() + timeoutMs;
482
+ while (Date.now() < deadline) {
483
+ const found = await evaluate(`!!document.querySelector(${JSON.stringify(selector)})`);
484
+ if (found) return { ok: true, selector };
485
+ await sleep(200);
486
+ }
487
+ throw new Error(`timeout esperando seletor: ${selector}`);
488
+ }
489
+
490
+ async function wait(ms) { await sleep(ms); return { ok: true, ms }; }
491
+
492
+ async function scroll(dy) {
493
+ await ensure();
494
+ await evaluate(`window.scrollBy(0, ${Number(dy)||0})`);
495
+ return { ok: true, dy };
496
+ }
497
+
498
+ function close() {
499
+ if (currentSession) { try { currentSession.close(); } catch {} currentSession = null; }
500
+ return { ok: true };
501
+ }
502
+
503
+ module.exports = {
504
+ open, navigate, click, clickText, clickSelector, type, typeInSelector,
505
+ key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
506
+ _internal: { findChrome, PROFILE_DIR, DEBUG_PORT },
507
+ };