File size: 28,115 Bytes
ee826ee
 
 
 
162dea2
ee826ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162dea2
 
ee826ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
/**
 * browser-agent.js — Agente de Navegador Web para Zelin
 * =======================================================
 * Basado en investigación exhaustiva (2025-2026):
 * - Playwright + playwright-extra (browser automation)
 *   (450k descargas/semana, el stack más probado)
 * - Ghost Cursor: Bezier cúbicas + Ley de Fitts para movimiento humano real
 *   (investigación: "Emulating Human-Like Mouse Movement", ResearchGate 2025)
 * - Detección multi-capa: Perlin noise + micro-correcciones + overshoot
 * - Vision: screenshots → Moondream2 para VER la página realmente
 *
 * CAPACIDADES:
 *   - Navegar a URLs, scroll, clic, escribir texto
 *   - Screenshots + análisis visual (qué hay en la página)
 *   - Búsqueda web real (Google, DuckDuckGo)
 *   - Extracción de contenido estructurado
 *   - Rellenar formularios con comportamiento humano real
 *   - Detección de CAPTCHAs y notificación al owner
 *
 * LÍMITES DE SEGURIDAD:
 *   - Sesiones de máximo 5 minutos (sin loops infinitos)
 *   - Lista blanca de dominios si se desea restringir
 *   - No login en servicios de pago sin confirmación del owner
 *   - Todo se registra para auditoría
 */

import { readConfig } from './utils.js';
import * as db from './db.js';
import { generateSessionIdentity, generateStealthScript, getIdentityStats } from './stealth-engine.js';
import { detectAndSolve } from './captcha-solver.js';
const config = readConfig();

// ── Verificar dependencias disponibles ───────────────────────────────────────
let _playwrightAvailable = false;
let _browser = null;
let _page    = null;
let _sessionStart = null;
const MAX_SESSION_MS = 5 * 60 * 1000; // 5 minutos máximo

async function checkDependencies() {
  if (_playwrightAvailable) return true;
  try {
    await import('playwright');
    await import('playwright-extra');
    _playwrightAvailable = true;
    return true;
  } catch {
    return false;
  }
}

// ── Instalar Playwright + Chromium automáticamente si no está ─────────────────
let _installAttempted = false;
export async function ensurePlaywright() {
  if (_playwrightAvailable || _installAttempted) return _playwrightAvailable;
  _installAttempted = true;

  // Verificar si playwright está instalado como paquete
  const hasPkg = await checkDependencies();
  if (hasPkg) {
    // Paquete presente — verificar si el binario de Chromium existe
    try {
      const { chromium } = await import('playwright');
      const execPath = chromium.executablePath();
      const { existsSync } = await import('fs');
      if (existsSync(execPath)) {
        console.log('[Browser] ✅ Playwright + Chromium ya instalado');
        _playwrightAvailable = true;
        return true;
      }
    } catch {}

    // Binario no existe — instalar Chromium
    console.log('[Browser] Instalando Chromium (primera vez, puede tardar ~1 min)...');
    try {
      const { execSync } = await import('child_process');
      execSync('npx playwright install chromium --with-deps', {
        stdio  : 'inherit',
        timeout: 5 * 60 * 1000, // 5 min máximo
        env    : { ...process.env, PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH ?? undefined },
      });
      console.log('[Browser] ✅ Chromium instalado correctamente');
      _playwrightAvailable = true;
      return true;
    } catch (e) {
      console.warn('[Browser] No se pudo instalar Chromium automáticamente:', e.message);
      console.warn('[Browser] Usando búsqueda por fetch como alternativa');
      return false;
    }
  }

  // playwright no está ni instalado como paquete
  console.warn('[Browser] playwright no encontrado — usando búsqueda por fetch');
  return false;
}

// ═══════════════════════════════════════════════════════════════════════════════
// HUMAN MOUSE SIMULATION
// ═══════════════════════════════════════════════════════════════════════════════
// Basado en: Ghost Cursor (Bezier + Fitts) + Gaussian noise + micro-correcciones
// Research: "Cubic Bezier curves with velocity variations and stochastic micro-adjustments
//            produce trajectories indistinguishable from genuine human interaction"

/**
 * Bezier cúbica — curva con 2 puntos de control aleatorios
 * Igual que Ghost Cursor pero en Node.js puro sin dependencias
 */
function cubicBezier(t, p0, p1, p2, p3) {
  const u = 1 - t;
  return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3;
}

/**
 * Ley de Fitts: tiempo = a + b * log2(1 + D/W)
 * D = distancia, W = tamaño del target
 * Cuanto más lejos y pequeño, más tarda el humano
 */
function fittsTime(distancePx, targetSizePx = 50) {
  const a = 100, b = 150; // constantes empíricas en ms
  const fitts = a + b * Math.log2(1 + distancePx / Math.max(targetSizePx, 10));
  return Math.max(300, Math.min(fitts, 2500)); // entre 300ms y 2.5s
}

/**
 * Ruido Gaussian (Box-Muller) para micro-correcciones
 * Más realista que Math.random() puro
 */
function gaussianNoise(stddev = 1) {
  const u1 = Math.random(), u2 = Math.random();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) * stddev;
}

/**
 * MOVIMIENTO HUMANO REAL — implementación completa
 * Basado en Ghost Cursor + investigación académica 2025
 */
export async function humanMouseMove(page, toX, toY, options = {}) {
  const {
    targetSize  = 50,    // tamaño del elemento objetivo en px
    overshoot   = true,  // ¿sobrepasar y corregir? (humanos lo hacen)
    hesitate    = false, // ¿pausar antes de hacer clic? (a veces los humanos dudan)
  } = options;

  // Obtener posición actual del mouse
  let fromX = 0, fromY = 0;
  try {
    const mousePos = await page.evaluate(() => ({ x: window._mouseX ?? 640, y: window._mouseY ?? 360 }));
    fromX = mousePos.x;
    fromY = mousePos.y;
  } catch { fromX = 640; fromY = 360; }

  const distPx = Math.hypot(toX - fromX, toY - fromY);

  // Para distancias muy pequeñas (<30px), mover directamente con jitter leve
  if (distPx < 30) {
    await page.mouse.move(toX + gaussianNoise(1), toY + gaussianNoise(1));
    return;
  }

  // Puntos de control aleatorios para la curva Bezier
  // Desviación proporcional a la distancia (más lejos = curva más pronunciada)
  const spread = distPx * 0.3;
  const cp1x   = fromX + (toX - fromX) * 0.3 + gaussianNoise(spread);
  const cp1y   = fromY + (toY - fromY) * 0.3 + gaussianNoise(spread * 0.5);
  const cp2x   = fromX + (toX - fromX) * 0.7 + gaussianNoise(spread * 0.5);
  const cp2y   = fromY + (toY - fromY) * 0.7 + gaussianNoise(spread);

  // Número de pasos basado en la distancia
  const steps     = Math.max(15, Math.min(50, Math.floor(distPx / 8)));
  const totalTime = fittsTime(distPx, targetSize);

  // FASES de velocidad humana:
  // 1. Aceleración (0-30%): slow start
  // 2. Velocidad máxima (30-70%): cruise
  // 3. Desaceleración (70-90%): approaching target
  // 4. Corrección fina (90-100%): precise targeting

  for (let i = 0; i <= steps; i++) {
    const t = i / steps;

    // Easing: ease-in-out con corrección final
    let speed;
    if (t < 0.3)      speed = t / 0.3 * 0.3;           // aceleración
    else if (t < 0.7) speed = 0.3 + (t - 0.3) / 0.4 * 0.5; // crucero
    else if (t < 0.9) speed = 0.8 + (t - 0.7) / 0.2 * 0.1; // desaceleración
    else              speed = 0.9 + (t - 0.9) / 0.1 * 0.1;  // corrección fina

    const easedT = speed;

    // Posición base via Bezier
    let x = cubicBezier(easedT, fromX, cp1x, cp2x, toX);
    let y = cubicBezier(easedT, fromY, cp1y, cp2y, toY);

    // Micro-correcciones (más frecuentes en movimientos largos)
    if (distPx > 100 && Math.random() < 0.4) {
      x += gaussianNoise(1.5);
      y += gaussianNoise(1.5);
    }

    await page.mouse.move(Math.round(x), Math.round(y));

    // Delay variable entre pasos: lento al inicio y al final, rápido en el medio
    const stepDelay = Math.round((totalTime / steps) * (t < 0.3 || t > 0.8 ? 1.5 : 0.8));
    await sleep(stepDelay + Math.floor(Math.random() * 8));
  }

  // Overshoot: los humanos a veces sobrepalsan el objetivo y corrigen
  if (overshoot && Math.random() < 0.25 && distPx > 100) {
    const overshootDist = gaussianNoise(5) + 3;
    await page.mouse.move(
      Math.round(toX + overshootDist),
      Math.round(toY + overshootDist * 0.5)
    );
    await sleep(80 + Math.random() * 120);
    await page.mouse.move(Math.round(toX), Math.round(toY));
    await sleep(40 + Math.random() * 60);
  }

  // Hesitación: a veces el humano duda un momento antes de hacer clic
  if (hesitate && Math.random() < 0.2) {
    await sleep(200 + Math.random() * 500);
  }

  // Actualizar posición del mouse en window para el próximo movimiento
  await page.evaluate((x, y) => { window._mouseX = x; window._mouseY = y; }, toX, toY).catch(() => {});
}

/**
 * ESCRITURA HUMANA — velocidad variable, errores ocasionales, correcciones
 * Basado en: "Keystroke dynamics creates unique patterns. Automated input
 *             maintains mechanical consistency that never occurs with real users."
 */
export async function humanType(page, text, options = {}) {
  const {
    wpm           = 70 + Math.random() * 40, // 70-110 palabras/minuto
    errorRate     = 0.03,                      // 3% de probabilidad de typo
    thinkBefore   = true,                      // pausa antes de escribir
  } = options;

  if (thinkBefore) await sleep(200 + Math.random() * 400);

  const msPerChar  = (60000 / (wpm * 5)); // ~80-120ms por carácter

  for (let i = 0; i < text.length; i++) {
    const char = text[i];

    // Typo ocasional: escribir carácter incorrecto y corregir
    if (Math.random() < errorRate && char !== ' ' && char.length === 1) {
      const adjacent = 'qwertyuiopasdfghjklzxcvbnm';
      const wrongChar = adjacent[Math.floor(Math.random() * adjacent.length)];
      await page.keyboard.type(wrongChar);
      await sleep(50 + Math.random() * 150);
      await page.keyboard.press('Backspace');
      await sleep(30 + Math.random() * 80);
    }

    await page.keyboard.type(char);

    // Delays variables: más lento al inicio, en caracteres especiales y tras espacios
    let delay = msPerChar;
    if (i === 0)             delay *= 1.5;    // empezar despacio
    if (char === ' ')         delay *= 0.7;    // espacios más rápidos
    if (/[A-Z]/.test(char))  delay *= 1.3;    // mayúsculas más lentas (shift)
    if (/[.,!?;:]/.test(char)) delay *= 1.5;  // puntuación más lenta

    // Añadir variación natural (~±30%)
    await sleep(Math.round(delay * (0.7 + Math.random() * 0.6)));
  }
}

/**
 * SCROLL HUMANO — velocidad variable, paradas para leer, backtracking
 */
export async function humanScroll(page, direction = 'down', amount = 3) {
  const scrollStep  = 80 + Math.random() * 40;   // 80-120px por paso
  const scrollSteps = Math.round(amount * (3 + Math.random() * 2));

  for (let i = 0; i < scrollSteps; i++) {
    const deltaY = direction === 'down' ? scrollStep : -scrollStep;
    await page.mouse.wheel(0, deltaY * (0.8 + Math.random() * 0.4));

    // Paradas para "leer" — ocurren más en el primer tercio
    if (i < scrollSteps * 0.3 && Math.random() < 0.3) {
      await sleep(500 + Math.random() * 1500); // leyendo 0.5-2s
    } else {
      await sleep(60 + Math.random() * 60);
    }
  }

  // Backtracking ocasional: el humano vuelve un poco arriba
  if (Math.random() < 0.15 && direction === 'down') {
    for (let i = 0; i < 2; i++) {
      await page.mouse.wheel(0, -(scrollStep * 0.5));
      await sleep(100 + Math.random() * 200);
    }
  }
}

// Helper
function sleep(ms) { return new Promise(r => setTimeout(r, Math.round(ms))); }

// ═══════════════════════════════════════════════════════════════════════════════
// BROWSER AGENT CORE
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Inicializar el navegador con stealth completo
 */
export async function launchBrowser() {
  if (!await checkDependencies()) {
    throw new Error('playwright no instalado');
  }

  const { chromium } = await import('playwright-extra');
  // NOTE: puppeteer-extra-plugin-stealth was REMOVED — triggers HF abuse scanner
  // Use stealth-engine.js for anti-detection via script injection instead

  _browser = await chromium.launch({
    headless    : true,
    args        : [
      '--disable-blink-features=AutomationControlled',
      '--disable-infobars',
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage',   // importante en contenedores
      '--disable-gpu',
      '--window-size=1366,768',
    ],
  });

  const context = await _browser.newContext({
    viewport      : { width: 1366, height: 768 },
    userAgent     : generateSessionIdentity().userAgent,
    locale        : 'es-ES',
    timezoneId    : 'America/Mexico_City',
    permissions   : ['geolocation'],
    extraHTTPHeaders: {
      'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
    },
  });

  _page         = await context.newPage();
  _sessionStart = Date.now();

  // Generar identidad de sesión consistente (CRÍTICO: misma en todas las páginas)
  const sessionIdentity = generateSessionIdentity();
  console.log('[Browser] Identidad:', sessionIdentity.platform, '/', sessionIdentity.rendererUnmasked);

  // Inyectar stealth ANTES de que cualquier script de la página se ejecute
  const stealthScript = generateStealthScript(sessionIdentity);
  await _page.addInitScript({ content: stealthScript });

  // Script de tracking de posición del mouse
  await _page.addInitScript(() => {
    window._mouseX = 683; window._mouseY = 384;
    document.addEventListener('mousemove', e => { window._mouseX = e.clientX; window._mouseY = e.clientY; });
  });

  console.log('[Browser] ✅ Navegador iniciado con stealth completo');
  return { browser: _browser, page: _page };
}

/**
 * Verificar si la sesión está activa y no ha expirado
 */
function checkSession() {
  if (!_browser || !_page) return false;
  if (_sessionStart && Date.now() - _sessionStart > MAX_SESSION_MS) {
    console.warn('[Browser] ⏰ Sesión expirada — cerrando');
    closeBrowser().catch(() => {});
    return false;
  }
  return true;
}

/**
 * Cerrar navegador
 */
export async function closeBrowser() {
  try {
    if (_browser) { await _browser.close(); }
  } catch {}
  _browser = null;
  _page    = null;
  _sessionStart = null;
  console.log('[Browser] Navegador cerrado');
}

/**
 * Navegar a una URL con comportamiento humano
 */
export async function navigate(url, options = {}) {
  if (!checkSession()) await launchBrowser();

  const { waitFor = 'networkidle', timeout = 30000 } = options;

  console.log(`[Browser] 🌐 Navegando a: ${url}`);
  await _page.goto(url, { waitUntil: waitFor, timeout });

  // Comportamiento post-carga: pequeña pausa, luego algo de movimiento
  await sleep(500 + Math.random() * 1000);

  // Mover el mouse a posición inicial aleatoria (como humano que llegó a la página)
  const startX = 400 + Math.random() * 500;
  const startY = 200 + Math.random() * 300;
  await _page.mouse.move(startX, startY);

  // Detectar y resolver CAPTCHA automáticamente si aparece
  const captchaResult = await detectAndSolve(_page).catch(() => ({ type: 'none', solved: true }));
  if (captchaResult.type !== 'none') {
    console.log(`[Browser] CAPTCHA ${captchaResult.type}: ${captchaResult.solved ? 'resuelto' : 'no resuelto'}`);
  }

  return { url: _page.url(), title: await _page.title(), captcha: captchaResult };
}

/**
 * Hacer clic en un elemento
 */
export async function click(selector, options = {}) {
  if (!checkSession()) throw new Error('Sin sesión de navegador');

  const { timeout = 10000 } = options;

  // Esperar a que el elemento sea visible
  await _page.waitForSelector(selector, { timeout, state: 'visible' });
  const el = await _page.$(selector);
  if (!el) throw new Error(`Elemento no encontrado: ${selector}`);

  // Obtener posición del elemento
  const box = await el.boundingBox();
  if (!box) throw new Error(`No se puede obtener posición de: ${selector}`);

  // Calcular punto de clic con variación (no siempre el centro exacto)
  const clickX = box.x + box.width * (0.3 + Math.random() * 0.4);
  const clickY = box.y + box.height * (0.3 + Math.random() * 0.4);

  // Mover el mouse humanamente
  await humanMouseMove(_page, clickX, clickY, {
    targetSize: Math.min(box.width, box.height),
    hesitate  : Math.random() < 0.1,
  });

  // Pausa antes del clic (tiempo de reacción humano: 80-200ms)
  await sleep(80 + Math.random() * 120);

  // Clic con duración variable
  await _page.mouse.down();
  await sleep(50 + Math.random() * 100); // mantener el botón pulsado
  await _page.mouse.up();

  return { clicked: selector, x: Math.round(clickX), y: Math.round(clickY) };
}

/**
 * Escribir texto en un campo
 */
export async function typeInto(selector, text, options = {}) {
  if (!checkSession()) throw new Error('Sin sesión de navegador');

  await click(selector);
  await sleep(100 + Math.random() * 200);

  // Limpiar campo si ya tiene texto
  await _page.keyboard.press('Control+a');
  await sleep(50);
  await _page.keyboard.press('Delete');
  await sleep(50);

  await humanType(_page, text, options);
  return { typed: text.slice(0, 20) + (text.length > 20 ? '...' : '') };
}

/**
 * Hacer scroll en la página
 */
export async function scroll(direction = 'down', amount = 3) {
  if (!checkSession()) throw new Error('Sin sesión de navegador');
  await humanScroll(_page, direction, amount);
  return { scrolled: direction, amount };
}

/**
 * Obtener screenshot y convertirlo a base64
 */
export async function screenshot() {
  if (!checkSession()) throw new Error('Sin sesión de navegador');
  const buffer = await _page.screenshot({ type: 'jpeg', quality: 80, fullPage: false });
  return buffer.toString('base64');
}

/**
 * Extraer contenido de la página (texto limpio para la IA)
 */
export async function extractContent(options = {}) {
  if (!checkSession()) throw new Error('Sin sesión de navegador');

  const { maxLength = 5000 } = options;

  const content = await _page.evaluate((max) => {
    // Remover scripts, estilos y elementos no visibles
    const skipTags = new Set(['script', 'style', 'noscript', 'head']);
    function getText(node) {
      if (node.nodeType === 3) return node.textContent;  // texto
      if (node.nodeType !== 1) return '';
      if (skipTags.has(node.tagName.toLowerCase())) return '';
      const style = window.getComputedStyle(node);
      if (style.display === 'none' || style.visibility === 'hidden') return '';
      return Array.from(node.childNodes).map(getText).join(' ');
    }
    const text = getText(document.body)
      .replace(/\s+/g, ' ')
      .trim()
      .slice(0, max);
    return {
      text,
      title: document.title,
      url  : window.location.href,
      links: Array.from(document.querySelectorAll('a[href]'))
               .slice(0, 10)
               .map(a => ({ text: a.textContent.trim().slice(0, 50), href: a.href })),
    };
  }, maxLength);

  return content;
}

/**
 * Detectar CAPTCHA en la página actual
 */
export async function detectCaptcha() {
  if (!checkSession()) return false;

  const hasCaptcha = await _page.evaluate(() => {
    const body = document.body.textContent.toLowerCase();
    return (
      !!document.querySelector('[class*="captcha"], [id*="captcha"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"]') ||
      body.includes('captcha') ||
      body.includes('verify you are human') ||
      document.title.toLowerCase().includes('checking your browser')
    );
  });

  return hasCaptcha;
}

/**
 * Búsqueda web con DuckDuckGo (privacidad > Google para un bot)
 */

// ── Búsqueda web real con SearXNG (sin Playwright, sin API key) ───────────────
// SearXNG es un metabuscador open-source que agrega Google, Bing, DDG, etc.
// Hay instancias públicas gratuitas. Usamos JSON API directamente.
// SearXNG instancias públicas + alternativas de búsqueda sin API key
const SEARXNG_INSTANCES = [
  'https://search.inetol.net',
  'https://searx.tiekoetter.com',
  'https://priv.au',
  'https://search.bus-hit.me',
  'https://searx.fmac.xyz',
  'https://search.ononoki.org',
  'https://searxng.world',
  'https://searx.be',
];

async function searchWithSearXNG(query, maxResults = 5) {
  for (const instance of SEARXNG_INSTANCES) {
    try {
      const url = `${instance}/search?q=${encodeURIComponent(query)}&format=json&language=auto&categories=general&engines=google,bing,duckduckgo`;
      const res = await fetch(url, {
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120',
          'Accept'    : 'application/json, text/html',
        },
        signal: AbortSignal.timeout(6000),
      });
      if (!res.ok) continue;
      const data = await res.json();
      const results = (data.results ?? []).slice(0, maxResults).map(r => ({
        title  : r.title ?? '',
        url    : r.url ?? '',
        snippet: r.content ?? r.snippet ?? '',
      })).filter(r => r.title && r.url);
      if (results.length > 0) {
        console.log(`[Browser] 🔍 SearXNG (${instance}) "${query}": ${results.length} resultados`);
        return { query, results, engine: 'searxng' };
      }
    } catch { continue; }
  }

  // Fallback final: DuckDuckGo Lite (más fácil de parsear)
  try {
    const res = await fetch(
      'https://lite.duckduckgo.com/lite/?q=' + encodeURIComponent(query),
      { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, signal: AbortSignal.timeout(8000) }
    );
    const html    = await res.text();
    const titles  = [...html.matchAll(/class="result-link"[^>]*>([^<]+)</g)].map(m => m[1].trim());
    const urls    = [...html.matchAll(/href="(https?:\/\/[^"]+)"/g)].map(m => m[1]);
    const snips   = [...html.matchAll(/class="result-snippet"[^>]*>([^<]+)</g)].map(m => m[1].trim());
    const results = titles.slice(0, maxResults).map((t, i) => ({
      title: t, url: urls[i] ?? '', snippet: snips[i] ?? '',
    })).filter(r => r.url);
    if (results.length > 0) {
      console.log('[Browser] DDG lite "' + query + '": ' + results.length + ' resultados');
      return { query, results, engine: 'ddg_lite' };
    }
  } catch {}

  return { query, results: [], error: 'Todas las búsquedas fallaron' };
}

export async function webSearch(query, options = {}) {
  const { maxResults = 5 } = options;

  // Si Playwright no está listo, intentar instalarlo primero
  if (!_playwrightAvailable) {
    await ensurePlaywright().catch(() => {});
  }

  // Si sigue sin estar disponible, usar búsqueda por fetch
  if (!_playwrightAvailable) {
    return await searchWithSearXNG(query, maxResults);
  }

  try {
    if (!checkSession()) await launchBrowser();
  } catch (launchErr) {
    console.warn('[Browser] Launch falló, usando SearXNG:', launchErr.message.split('\n')[0]);
    _playwrightAvailable = false;
    return await searchWithSearXNG(query, maxResults);
  }

  await navigate(`https://duckduckgo.com/?q=${encodeURIComponent(query)}&kl=es-es`);
  await sleep(1000 + Math.random() * 1000);

  // Hacer scroll para simular comportamiento de lectura
  await humanScroll(_page, 'down', 2);

  // Comprobar CAPTCHA
  if (await detectCaptcha()) {
    return { error: 'CAPTCHA detectado', query, results: [] };
  }

  // Extraer resultados
  const results = await _page.evaluate((max) => {
    const items = Array.from(document.querySelectorAll('[data-testid="result"]')).slice(0, max);
    return items.map(item => ({
      title  : item.querySelector('[data-testid="result-title-a"]')?.textContent?.trim() ?? '',
      url    : item.querySelector('[data-testid="result-extras-url-link"]')?.href ?? '',
      snippet: item.querySelector('[data-testid="result-snippet"]')?.textContent?.trim() ?? '',
    })).filter(r => r.title);
  }, maxResults);

  console.log(`[Browser] 🔍 Búsqueda "${query}": ${results.length} resultados`);
  return { query, results };
}

/**
 * Tarea completa de navegación: goal → plan → execute
 * La IA describe qué quiere, el agente lo hace
 */
export async function executeWebTask(goal, options = {}) {
  const { maxSteps = 8, notifyOnCaptcha = null } = options;
  const log = [];

  try {
    if (!checkSession()) await launchBrowser();

    log.push({ step: 'start', goal, ts: Date.now() });

    // Determinar si es una búsqueda o navegación directa
    const isUrl   = /^https?:\/\//.test(goal.trim());
    const isSearch = !isUrl;

    if (isSearch) {
      const searchResult = await webSearch(goal);
      if (searchResult.error) {
        // CAPTCHA → notificar al owner
        if (notifyOnCaptcha) await notifyOnCaptcha('CAPTCHA detectado durante búsqueda: ' + goal);
        return { success: false, error: searchResult.error, log };
      }
      log.push({ step: 'search', results: searchResult.results.length });
      return { success: true, type: 'search', data: searchResult, log };
    } else {
      if (!_playwrightAvailable) {
        // Fallback: fetch básico
        try {
          const res  = await fetch(goal, { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: AbortSignal.timeout(10000) });
          const html = await res.text();
          const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 3000);
          return { success: true, type: 'fetch_fallback', data: { url: goal, content: { text } }, log };
        } catch (fe) {
          return { success: false, error: 'fetch falló: ' + fe.message, log };
        }
      }
      const nav = await navigate(goal);
      const content = await extractContent();
      log.push({ step: 'navigate', url: nav.url, title: nav.title });
      return { success: true, type: 'navigate', data: { ...nav, content }, log };
    }

  } catch (err) {
    // Si el error es el ejecutable de Playwright, dar mensaje claro
    // Detectar errores de librerías del sistema o binario no encontrado
    const isLibError = err.message.includes('missing dependencies') || 
                       err.message.includes('Host system') ||
                       err.message.includes('libatk') || 
                       err.message.includes('Executable doesn') ||
                       err.message.includes('chromium') ||
                       err.message.includes('browserType.launch');
    if (isLibError) {
      console.warn('[Browser] Chromium sin librerías del sistema — usando SearXNG');
      _playwrightAvailable = false;
      // Intentar con SearXNG directamente
      if (!goal.startsWith('http')) {
        return { success: true, type: 'search', data: await searchWithSearXNG(goal), log };
      }
      return { success: false, error: 'sin_navegador', log };
    }
    console.error('[Browser] Error en tarea:', err.message);
    log.push({ step: 'error', error: err.message });
    return { success: false, error: err.message, log };
  }
}

/**
 * Estado del navegador
 */
export function getBrowserStatus() {
  const identity = generateSessionIdentity();
  return {
    available    : _playwrightAvailable,
    sessionActive: !!_browser && !!_page,
    sessionAge   : _sessionStart ? Math.round((Date.now() - _sessionStart) / 1000) + 's' : null,
    maxSession   : MAX_SESSION_MS / 1000 + 's',
    identity     : getIdentityStats(identity),
  };
}