theus-cli-linux-x64 1.2.0 → 1.2.2
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/README.md +16 -1
- package/RELEASE.json +6 -6
- package/bin/theus +0 -0
- package/package.json +1 -1
- package/vendor/chrome-extension/README.md +142 -0
- package/vendor/chrome-extension/background.js +540 -0
- package/vendor/chrome-extension/cdp-core.js +400 -0
- package/vendor/chrome-extension/cdp-core.test.ts +321 -0
- package/vendor/chrome-extension/cdp.js +672 -0
- package/vendor/chrome-extension/icons/icon128.png +0 -0
- package/vendor/chrome-extension/icons/icon16.png +0 -0
- package/vendor/chrome-extension/icons/icon32.png +0 -0
- package/vendor/chrome-extension/icons/icon48.png +0 -0
- package/vendor/chrome-extension/manifest.json +36 -0
- package/vendor/chrome-extension/popup.html +78 -0
- package/vendor/chrome-extension/popup.js +47 -0
- package/vendor/chrome-extension/recorder.js +316 -0
- package/vendor/theus-arsenal/.theus-plugin/plugin.json +1 -1
- package/vendor/theus-arsenal/README.md +1 -1
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
// Theus en Chrome — fase 2: control total vía chrome.debugger (CDP).
|
|
2
|
+
//
|
|
3
|
+
// Este archivo se carga con importScripts('cdp-core.js', 'cdp.js') desde
|
|
4
|
+
// background.js. Aporta:
|
|
5
|
+
// - Gestor de sesiones CDP por pestaña: attach perezoso (la primera tool
|
|
6
|
+
// que lo exige), detach al cerrar la pestaña, reattach transparente si
|
|
7
|
+
// Chrome suelta el debugger (con aviso honesto de que el buffer se cortó).
|
|
8
|
+
// - Buffers circulares de consola y red que arrancan en el attach.
|
|
9
|
+
// - Handlers de: computer, read_page, form_input, resize_window,
|
|
10
|
+
// read_console_messages y read_network_requests.
|
|
11
|
+
//
|
|
12
|
+
// Nota inevitable: mientras una pestaña está instrumentada, Chrome muestra la
|
|
13
|
+
// barra «Theus en Chrome ha empezado a depurar este navegador». Es el precio
|
|
14
|
+
// de chrome.debugger y no se puede ocultar.
|
|
15
|
+
|
|
16
|
+
const CDP_VERSION = '1.3'
|
|
17
|
+
const MAX_ENTRADAS_CONSOLA = 1000
|
|
18
|
+
const MAX_ENTRADAS_RED = 1000
|
|
19
|
+
const MAX_ELEMENTOS_PAGINA = 150
|
|
20
|
+
const MAX_ELEMENTOS_CON_BOUNDS = 100
|
|
21
|
+
const MAX_ESPERA_S = 15
|
|
22
|
+
// Debe casar con MAX_CHANNEL_MESSAGE_BYTES del CLI
|
|
23
|
+
// (vendor/theus-packages/theus-for-chrome-mcp/constants.ts).
|
|
24
|
+
const LIMITE_CANAL_BYTES = 16 * 1024 * 1024
|
|
25
|
+
// Margen para el JSON envolvente: si el base64 excede esto, se recomprime.
|
|
26
|
+
const MAX_CAPTURA_BASE64 = 12 * 1024 * 1024
|
|
27
|
+
const MAX_ANCHO_CAPTURA = 1600
|
|
28
|
+
|
|
29
|
+
const Core = globalThis.TheusCdpCore
|
|
30
|
+
|
|
31
|
+
// tabId → sesión
|
|
32
|
+
const sesionesCdp = new Map()
|
|
33
|
+
|
|
34
|
+
function crearSesion(tabId) {
|
|
35
|
+
return {
|
|
36
|
+
tabId,
|
|
37
|
+
adjunta: false,
|
|
38
|
+
adjuntadaDesdeMs: 0,
|
|
39
|
+
cortes: 0, // reattaches: el buffer tuvo huecos
|
|
40
|
+
consola: [],
|
|
41
|
+
red: Core.crearEstadoRed(),
|
|
42
|
+
refs: new Map(), // ref (string) → backendDOMNodeId, de la última read_page
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function enviarComando(tabId, metodo, parametros) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
chrome.debugger.sendCommand({ tabId }, metodo, parametros || {}, resultado => {
|
|
49
|
+
if (chrome.runtime.lastError) {
|
|
50
|
+
reject(new Error(chrome.runtime.lastError.message))
|
|
51
|
+
} else {
|
|
52
|
+
resolve(resultado)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function adjuntarDebugger(tabId) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
chrome.debugger.attach({ tabId }, CDP_VERSION, () => {
|
|
61
|
+
if (chrome.runtime.lastError) {
|
|
62
|
+
reject(new Error(chrome.runtime.lastError.message))
|
|
63
|
+
} else {
|
|
64
|
+
resolve()
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function soltarDebugger(tabId) {
|
|
71
|
+
return new Promise(resolve => {
|
|
72
|
+
chrome.debugger.detach({ tabId }, () => {
|
|
73
|
+
// Ignorar lastError: puede no estar adjunto.
|
|
74
|
+
void chrome.runtime.lastError
|
|
75
|
+
resolve()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Garantiza una sesión CDP viva sobre la pestaña (attach perezoso + reattach
|
|
82
|
+
* transparente). Devuelve la sesión.
|
|
83
|
+
*/
|
|
84
|
+
async function asegurarSesion(tabId) {
|
|
85
|
+
let sesion = sesionesCdp.get(tabId)
|
|
86
|
+
if (sesion && sesion.adjunta) {
|
|
87
|
+
return sesion
|
|
88
|
+
}
|
|
89
|
+
if (!sesion) {
|
|
90
|
+
sesion = crearSesion(tabId)
|
|
91
|
+
sesionesCdp.set(tabId, sesion)
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await adjuntarDebugger(tabId)
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const msg = String(e && e.message ? e.message : e)
|
|
97
|
+
if (/already attached/i.test(msg)) {
|
|
98
|
+
// El service worker se reinició dejando el debugger adjunto sin estado:
|
|
99
|
+
// soltar y volver a adjuntar para reinstrumentar limpio.
|
|
100
|
+
await soltarDebugger(tabId)
|
|
101
|
+
await adjuntarDebugger(tabId)
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`No se pudo instrumentar la pestaña ${tabId}: ${msg}. ` +
|
|
105
|
+
'Las páginas del navegador (chrome://…) y la Web Store no se pueden depurar.',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
sesion.adjunta = true
|
|
110
|
+
if (sesion.adjuntadaDesdeMs === 0) {
|
|
111
|
+
sesion.adjuntadaDesdeMs = Date.now()
|
|
112
|
+
}
|
|
113
|
+
// Instrumentación: los buffers de consola/red graban desde este momento.
|
|
114
|
+
await enviarComando(tabId, 'Runtime.enable')
|
|
115
|
+
await enviarComando(tabId, 'Log.enable')
|
|
116
|
+
await enviarComando(tabId, 'Network.enable')
|
|
117
|
+
await enviarComando(tabId, 'Page.enable')
|
|
118
|
+
await enviarComando(tabId, 'DOM.enable')
|
|
119
|
+
await enviarComando(tabId, 'Accessibility.enable').catch(() => {})
|
|
120
|
+
// Que el agente DOM conozca el documento (necesario para backendNodeIds).
|
|
121
|
+
await enviarComando(tabId, 'DOM.getDocument', { depth: 0 }).catch(() => {})
|
|
122
|
+
return sesion
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
chrome.debugger.onEvent.addListener((origen, metodo, parametros) => {
|
|
126
|
+
const sesion = origen && typeof origen.tabId === 'number' ? sesionesCdp.get(origen.tabId) : null
|
|
127
|
+
if (!sesion) return
|
|
128
|
+
const params = parametros || {}
|
|
129
|
+
// Kuti fase 1: navegaciones del frame principal → paso `navegar`. CDP
|
|
130
|
+
// complementa al content script, que muere al cambiar de página y no puede
|
|
131
|
+
// reportar la navegación por sí mismo.
|
|
132
|
+
if (metodo === 'Page.frameNavigated') {
|
|
133
|
+
const frame = params.frame
|
|
134
|
+
const grab = globalThis.__kuti
|
|
135
|
+
if (
|
|
136
|
+
frame &&
|
|
137
|
+
!frame.parentId &&
|
|
138
|
+
grab &&
|
|
139
|
+
grab.grabando &&
|
|
140
|
+
origen.tabId === grab.tabGrabada &&
|
|
141
|
+
typeof globalThis.kutiEmitirNavegacion === 'function'
|
|
142
|
+
) {
|
|
143
|
+
const url = frame.url || ''
|
|
144
|
+
if (grab.capturas) {
|
|
145
|
+
capturarBase64(sesion).then(
|
|
146
|
+
b64 => globalThis.kutiEmitirNavegacion(origen.tabId, url, b64),
|
|
147
|
+
() => globalThis.kutiEmitirNavegacion(origen.tabId, url),
|
|
148
|
+
)
|
|
149
|
+
} else {
|
|
150
|
+
globalThis.kutiEmitirNavegacion(origen.tabId, url)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
metodo === 'Runtime.consoleAPICalled' ||
|
|
157
|
+
metodo === 'Runtime.exceptionThrown' ||
|
|
158
|
+
metodo === 'Log.entryAdded'
|
|
159
|
+
) {
|
|
160
|
+
const entrada = Core.formatearEventoConsola(metodo, params)
|
|
161
|
+
if (entrada) Core.pushCircular(sesion.consola, entrada, MAX_ENTRADAS_CONSOLA)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (metodo.startsWith('Network.')) {
|
|
165
|
+
Core.plegarEventoRed(sesion.red, metodo, params, MAX_ENTRADAS_RED)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
chrome.debugger.onDetach.addListener(origen => {
|
|
170
|
+
const sesion = origen && typeof origen.tabId === 'number' ? sesionesCdp.get(origen.tabId) : null
|
|
171
|
+
if (!sesion) return
|
|
172
|
+
// Chrome soltó el debugger (usuario pulsó «Cancelar», DevTools, etc.):
|
|
173
|
+
// marcamos el corte; el próximo tool re-adjunta y lo cuenta honesto.
|
|
174
|
+
sesion.adjunta = false
|
|
175
|
+
sesion.cortes += 1
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
chrome.tabs.onRemoved.addListener(tabId => {
|
|
179
|
+
sesionesCdp.delete(tabId)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
function notaBuffer(sesion) {
|
|
183
|
+
let nota = `Buffer grabando desde las ${Core.horaLocal(sesion.adjuntadaDesdeMs)} (la pestaña no estaba instrumentada antes).`
|
|
184
|
+
if (sesion.cortes > 0) {
|
|
185
|
+
nota += ` Ojo: la instrumentación se cortó ${sesion.cortes} ${sesion.cortes === 1 ? 'vez' : 'veces'}; hay huecos.`
|
|
186
|
+
}
|
|
187
|
+
return nota
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// computer
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async function resolverPunto(sesion, args) {
|
|
195
|
+
if (Array.isArray(args.coordinate) && args.coordinate.length === 2) {
|
|
196
|
+
return { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
|
|
197
|
+
}
|
|
198
|
+
if (typeof args.ref === 'string') {
|
|
199
|
+
return centroDeRef(sesion, args.ref)
|
|
200
|
+
}
|
|
201
|
+
throw new Error('Indica coordinate [x, y] o un ref de read_page.')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function centroDeRef(sesion, ref) {
|
|
205
|
+
const backendNodeId = sesion.refs.get(ref)
|
|
206
|
+
if (!backendNodeId) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Ref desconocido: ${ref}. Llama a read_page para obtener refs vigentes (se invalidan al navegar).`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
await enviarComando(sesion.tabId, 'DOM.scrollIntoViewIfNeeded', { backendNodeId })
|
|
212
|
+
const { quads } = await enviarComando(sesion.tabId, 'DOM.getContentQuads', {
|
|
213
|
+
backendNodeId,
|
|
214
|
+
})
|
|
215
|
+
if (!quads || quads.length === 0) {
|
|
216
|
+
throw new Error(`El elemento ${ref} no tiene geometría visible (¿oculto?).`)
|
|
217
|
+
}
|
|
218
|
+
const q = quads[0] // [x1,y1, x2,y2, x3,y3, x4,y4]
|
|
219
|
+
return {
|
|
220
|
+
x: (q[0] + q[2] + q[4] + q[6]) / 4,
|
|
221
|
+
y: (q[1] + q[3] + q[5] + q[7]) / 4,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function eventoRaton(tabId, tipo, x, y, boton, clicks) {
|
|
226
|
+
return enviarComando(tabId, 'Input.dispatchMouseEvent', {
|
|
227
|
+
type: tipo,
|
|
228
|
+
x,
|
|
229
|
+
y,
|
|
230
|
+
button: boton,
|
|
231
|
+
clickCount: clicks || 0,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function clic(sesion, args, boton, dobles) {
|
|
236
|
+
const { x, y } = await resolverPunto(sesion, args)
|
|
237
|
+
await eventoRaton(sesion.tabId, 'mouseMoved', x, y, 'none', 0)
|
|
238
|
+
await eventoRaton(sesion.tabId, 'mousePressed', x, y, boton, 1)
|
|
239
|
+
await eventoRaton(sesion.tabId, 'mouseReleased', x, y, boton, 1)
|
|
240
|
+
if (dobles) {
|
|
241
|
+
await eventoRaton(sesion.tabId, 'mousePressed', x, y, boton, 2)
|
|
242
|
+
await eventoRaton(sesion.tabId, 'mouseReleased', x, y, boton, 2)
|
|
243
|
+
}
|
|
244
|
+
return { x, y }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function centroViewport(tabId) {
|
|
248
|
+
const metricas = await enviarComando(tabId, 'Page.getLayoutMetrics')
|
|
249
|
+
const vv = metricas.cssVisualViewport || metricas.visualViewport || {}
|
|
250
|
+
return {
|
|
251
|
+
x: (vv.clientWidth || 800) / 2,
|
|
252
|
+
y: (vv.clientHeight || 600) / 2,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function capturarPantalla(sesion) {
|
|
257
|
+
const res = await enviarComando(sesion.tabId, 'Page.captureScreenshot', {
|
|
258
|
+
format: 'jpeg',
|
|
259
|
+
quality: 80,
|
|
260
|
+
})
|
|
261
|
+
let base64 = res.data
|
|
262
|
+
// Recompresión/escala si la captura amenaza el límite del canal o es
|
|
263
|
+
// más ancha que MAX_ANCHO_CAPTURA px.
|
|
264
|
+
try {
|
|
265
|
+
base64 = await recomprimirCaptura(base64)
|
|
266
|
+
} catch (_e) {
|
|
267
|
+
// Si la recompresión falla y aun así cabe en el canal, se envía tal cual.
|
|
268
|
+
if (base64.length > MAX_CAPTURA_BASE64) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
'La captura excede el límite del canal y no se pudo recomprimir.',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: 'image', data: base64, mimeType: 'image/jpeg' }],
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Kuti: base64 JPEG (recomprimido/escalado) de la pestaña, para la captura
|
|
280
|
+
// por paso opcional del grabador. Devuelve solo la cadena base64.
|
|
281
|
+
async function capturarBase64(sesion) {
|
|
282
|
+
const res = await enviarComando(sesion.tabId, 'Page.captureScreenshot', {
|
|
283
|
+
format: 'jpeg',
|
|
284
|
+
quality: 60,
|
|
285
|
+
})
|
|
286
|
+
let base64 = res.data
|
|
287
|
+
try {
|
|
288
|
+
base64 = await recomprimirCaptura(base64)
|
|
289
|
+
} catch (_e) {
|
|
290
|
+
// Si no se pudo recomprimir se devuelve tal cual (el llamador acota tamaño).
|
|
291
|
+
}
|
|
292
|
+
return base64
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function recomprimirCaptura(base64) {
|
|
296
|
+
const respuesta = await fetch(`data:image/jpeg;base64,${base64}`)
|
|
297
|
+
const blob = await respuesta.blob()
|
|
298
|
+
const bitmap = await createImageBitmap(blob)
|
|
299
|
+
const plan = Core.planRecompresionCaptura(base64.length, bitmap.width, {
|
|
300
|
+
maxAncho: MAX_ANCHO_CAPTURA,
|
|
301
|
+
maxBase64: MAX_CAPTURA_BASE64,
|
|
302
|
+
})
|
|
303
|
+
if (!plan.reprocesar) {
|
|
304
|
+
bitmap.close()
|
|
305
|
+
return base64
|
|
306
|
+
}
|
|
307
|
+
const ancho = Math.max(1, Math.round(bitmap.width * plan.escala))
|
|
308
|
+
const alto = Math.max(1, Math.round(bitmap.height * plan.escala))
|
|
309
|
+
const lienzo = new OffscreenCanvas(ancho, alto)
|
|
310
|
+
const ctx = lienzo.getContext('2d')
|
|
311
|
+
ctx.drawImage(bitmap, 0, 0, ancho, alto)
|
|
312
|
+
bitmap.close()
|
|
313
|
+
const salida = await lienzo.convertToBlob({
|
|
314
|
+
type: 'image/jpeg',
|
|
315
|
+
quality: plan.calidad,
|
|
316
|
+
})
|
|
317
|
+
const bytes = new Uint8Array(await salida.arrayBuffer())
|
|
318
|
+
let binario = ''
|
|
319
|
+
const TROZO = 0x8000
|
|
320
|
+
for (let i = 0; i < bytes.length; i += TROZO) {
|
|
321
|
+
binario += String.fromCharCode.apply(null, bytes.subarray(i, i + TROZO))
|
|
322
|
+
}
|
|
323
|
+
return btoa(binario)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function accionComputer(args) {
|
|
327
|
+
const tabId = requireTabId(args)
|
|
328
|
+
const accion = args.action
|
|
329
|
+
if (typeof accion !== 'string') {
|
|
330
|
+
throw new Error('Falta el parámetro action.')
|
|
331
|
+
}
|
|
332
|
+
const sesion = await asegurarSesion(tabId)
|
|
333
|
+
|
|
334
|
+
switch (accion) {
|
|
335
|
+
case 'screenshot':
|
|
336
|
+
return capturarPantalla(sesion)
|
|
337
|
+
|
|
338
|
+
case 'left_click': {
|
|
339
|
+
const p = await clic(sesion, args, 'left', false)
|
|
340
|
+
return textResult(`Clic izquierdo en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
|
|
341
|
+
}
|
|
342
|
+
case 'right_click': {
|
|
343
|
+
const p = await clic(sesion, args, 'right', false)
|
|
344
|
+
return textResult(`Clic derecho en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
|
|
345
|
+
}
|
|
346
|
+
case 'middle_click': {
|
|
347
|
+
const p = await clic(sesion, args, 'middle', false)
|
|
348
|
+
return textResult(`Clic central en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
|
|
349
|
+
}
|
|
350
|
+
case 'double_click': {
|
|
351
|
+
const p = await clic(sesion, args, 'left', true)
|
|
352
|
+
return textResult(`Doble clic en (${Math.round(p.x)}, ${Math.round(p.y)}).`)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case 'left_click_drag': {
|
|
356
|
+
if (!Array.isArray(args.coordinate) || args.coordinate.length !== 2) {
|
|
357
|
+
throw new Error('left_click_drag exige coordinate [x, y] como destino.')
|
|
358
|
+
}
|
|
359
|
+
let inicio
|
|
360
|
+
if (Array.isArray(args.start_coordinate) && args.start_coordinate.length === 2) {
|
|
361
|
+
inicio = {
|
|
362
|
+
x: Number(args.start_coordinate[0]),
|
|
363
|
+
y: Number(args.start_coordinate[1]),
|
|
364
|
+
}
|
|
365
|
+
} else if (typeof args.ref === 'string') {
|
|
366
|
+
inicio = await centroDeRef(sesion, args.ref)
|
|
367
|
+
} else {
|
|
368
|
+
throw new Error(
|
|
369
|
+
'left_click_drag exige el origen: start_coordinate [x, y] o un ref.',
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
const fin = { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
|
|
373
|
+
await eventoRaton(tabId, 'mouseMoved', inicio.x, inicio.y, 'none', 0)
|
|
374
|
+
await eventoRaton(tabId, 'mousePressed', inicio.x, inicio.y, 'left', 1)
|
|
375
|
+
const PASOS = 8
|
|
376
|
+
for (let i = 1; i <= PASOS; i++) {
|
|
377
|
+
const x = inicio.x + ((fin.x - inicio.x) * i) / PASOS
|
|
378
|
+
const y = inicio.y + ((fin.y - inicio.y) * i) / PASOS
|
|
379
|
+
await eventoRaton(tabId, 'mouseMoved', x, y, 'left', 0)
|
|
380
|
+
}
|
|
381
|
+
await eventoRaton(tabId, 'mouseReleased', fin.x, fin.y, 'left', 1)
|
|
382
|
+
return textResult(
|
|
383
|
+
`Arrastre de (${Math.round(inicio.x)}, ${Math.round(inicio.y)}) a (${Math.round(fin.x)}, ${Math.round(fin.y)}).`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case 'type': {
|
|
388
|
+
if (typeof args.text !== 'string' || args.text.length === 0) {
|
|
389
|
+
throw new Error('type exige el parámetro text.')
|
|
390
|
+
}
|
|
391
|
+
await enviarComando(tabId, 'Input.insertText', { text: args.text })
|
|
392
|
+
return textResult(
|
|
393
|
+
`Texto tecleado (${args.text.length} caracteres) en el elemento con foco.`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
case 'key': {
|
|
398
|
+
if (typeof args.text !== 'string' || args.text.length === 0) {
|
|
399
|
+
throw new Error('key exige el parámetro text (p. ej. "ctrl+a" o "Enter").')
|
|
400
|
+
}
|
|
401
|
+
const def = Core.parsearCombinacionTeclas(args.text)
|
|
402
|
+
if (!def) {
|
|
403
|
+
throw new Error(`Combinación de teclas no reconocida: «${args.text}».`)
|
|
404
|
+
}
|
|
405
|
+
const comun = {
|
|
406
|
+
modifiers: def.modifiers,
|
|
407
|
+
key: def.key,
|
|
408
|
+
code: def.code,
|
|
409
|
+
windowsVirtualKeyCode: def.keyCode,
|
|
410
|
+
nativeVirtualKeyCode: def.keyCode,
|
|
411
|
+
}
|
|
412
|
+
await enviarComando(tabId, 'Input.dispatchKeyEvent', {
|
|
413
|
+
type: def.text ? 'keyDown' : 'rawKeyDown',
|
|
414
|
+
...comun,
|
|
415
|
+
...(def.text ? { text: def.text } : {}),
|
|
416
|
+
})
|
|
417
|
+
await enviarComando(tabId, 'Input.dispatchKeyEvent', {
|
|
418
|
+
type: 'keyUp',
|
|
419
|
+
...comun,
|
|
420
|
+
})
|
|
421
|
+
return textResult(`Pulsado: ${args.text}.`)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case 'scroll': {
|
|
425
|
+
const direccion = args.scroll_direction
|
|
426
|
+
if (!['up', 'down', 'left', 'right'].includes(direccion)) {
|
|
427
|
+
throw new Error('scroll exige scroll_direction: up, down, left o right.')
|
|
428
|
+
}
|
|
429
|
+
const punto =
|
|
430
|
+
Array.isArray(args.coordinate) && args.coordinate.length === 2
|
|
431
|
+
? { x: Number(args.coordinate[0]), y: Number(args.coordinate[1]) }
|
|
432
|
+
: typeof args.ref === 'string'
|
|
433
|
+
? await centroDeRef(sesion, args.ref)
|
|
434
|
+
: await centroViewport(tabId)
|
|
435
|
+
const PASO = 600
|
|
436
|
+
const deltaX = direccion === 'left' ? -PASO : direccion === 'right' ? PASO : 0
|
|
437
|
+
const deltaY = direccion === 'up' ? -PASO : direccion === 'down' ? PASO : 0
|
|
438
|
+
await enviarComando(tabId, 'Input.dispatchMouseEvent', {
|
|
439
|
+
type: 'mouseWheel',
|
|
440
|
+
x: punto.x,
|
|
441
|
+
y: punto.y,
|
|
442
|
+
deltaX,
|
|
443
|
+
deltaY,
|
|
444
|
+
})
|
|
445
|
+
return textResult(`Scroll ${direccion} (${PASO}px).`)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
case 'wait': {
|
|
449
|
+
const segundos = Math.min(
|
|
450
|
+
Math.max(typeof args.duration === 'number' ? args.duration : 1, 0),
|
|
451
|
+
MAX_ESPERA_S,
|
|
452
|
+
)
|
|
453
|
+
await new Promise(resolve => setTimeout(resolve, segundos * 1000))
|
|
454
|
+
return textResult(`Esperados ${segundos} s.`)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
default:
|
|
458
|
+
throw new Error(`Acción de computer no soportada: ${accion}.`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// read_page
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
async function leerPagina(args) {
|
|
467
|
+
const tabId = requireTabId(args)
|
|
468
|
+
const sesion = await asegurarSesion(tabId)
|
|
469
|
+
const { nodes } = await enviarComando(tabId, 'Accessibility.getFullAXTree')
|
|
470
|
+
const resumen = Core.resumirArbolAX(nodes, args.filter, MAX_ELEMENTOS_PAGINA)
|
|
471
|
+
|
|
472
|
+
// Refs nuevos: los anteriores dejan de valer (lectura nueva = mapa nuevo).
|
|
473
|
+
sesion.refs = new Map()
|
|
474
|
+
const filas = []
|
|
475
|
+
for (const el of resumen.elementos) {
|
|
476
|
+
sesion.refs.set(el.ref, el.backendDOMNodeId)
|
|
477
|
+
const fila = { ref: el.ref, rol: el.rol, nombre: el.nombre }
|
|
478
|
+
if (el.valor) fila.valor = el.valor
|
|
479
|
+
filas.push(fila)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Bounds (px CSS del viewport) para los primeros MAX_ELEMENTOS_CON_BOUNDS.
|
|
483
|
+
for (let i = 0; i < filas.length && i < MAX_ELEMENTOS_CON_BOUNDS; i++) {
|
|
484
|
+
try {
|
|
485
|
+
const { quads } = await enviarComando(tabId, 'DOM.getContentQuads', {
|
|
486
|
+
backendNodeId: resumen.elementos[i].backendDOMNodeId,
|
|
487
|
+
})
|
|
488
|
+
if (quads && quads.length > 0) {
|
|
489
|
+
const q = quads[0]
|
|
490
|
+
const xs = [q[0], q[2], q[4], q[6]]
|
|
491
|
+
const ys = [q[1], q[3], q[5], q[7]]
|
|
492
|
+
const x = Math.min(...xs)
|
|
493
|
+
const y = Math.min(...ys)
|
|
494
|
+
filas[i].bounds = [
|
|
495
|
+
Math.round(x),
|
|
496
|
+
Math.round(y),
|
|
497
|
+
Math.round(Math.max(...xs) - x),
|
|
498
|
+
Math.round(Math.max(...ys) - y),
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
} catch (_e) {
|
|
502
|
+
// Sin geometría (oculto/desprendido): la fila va sin bounds.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const cabecera = [
|
|
507
|
+
`Elementos: ${filas.length}` +
|
|
508
|
+
(resumen.truncado
|
|
509
|
+
? ` (truncado: había ${resumen.totalCandidatos} candidatos; usa filter o find para acotar)`
|
|
510
|
+
: ''),
|
|
511
|
+
'Los refs sirven para computer (clic por ref) y form_input; se invalidan al navegar o al volver a llamar a read_page.',
|
|
512
|
+
].join('\n')
|
|
513
|
+
return textResult(
|
|
514
|
+
truncate(`${cabecera}\n${JSON.stringify(filas, null, 1)}`, MAX_TEXT_CHARS),
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// form_input
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
async function fijarCampo(args) {
|
|
523
|
+
const tabId = requireTabId(args)
|
|
524
|
+
if (typeof args.ref !== 'string' || args.ref.length === 0) {
|
|
525
|
+
throw new Error('Falta el parámetro ref (de read_page).')
|
|
526
|
+
}
|
|
527
|
+
if (typeof args.value !== 'string') {
|
|
528
|
+
throw new Error('Falta el parámetro value (string).')
|
|
529
|
+
}
|
|
530
|
+
const sesion = await asegurarSesion(tabId)
|
|
531
|
+
const backendNodeId = sesion.refs.get(args.ref)
|
|
532
|
+
if (!backendNodeId) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`Ref desconocido: ${args.ref}. Llama a read_page para obtener refs vigentes.`,
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
const { object } = await enviarComando(tabId, 'DOM.resolveNode', { backendNodeId })
|
|
538
|
+
const resultado = await enviarComando(tabId, 'Runtime.callFunctionOn', {
|
|
539
|
+
objectId: object.objectId,
|
|
540
|
+
functionDeclaration: `function (valor) {
|
|
541
|
+
const el = this
|
|
542
|
+
const tag = (el.tagName || '').toLowerCase()
|
|
543
|
+
const disparar = () => {
|
|
544
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
545
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
546
|
+
}
|
|
547
|
+
if (tag === 'select') {
|
|
548
|
+
let encontrada = false
|
|
549
|
+
for (const opt of el.options) {
|
|
550
|
+
if (opt.value === valor || opt.textContent.trim() === valor.trim()) {
|
|
551
|
+
el.value = opt.value
|
|
552
|
+
encontrada = true
|
|
553
|
+
break
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!encontrada) return { ok: false, error: 'Opción no encontrada en el select: ' + valor }
|
|
557
|
+
disparar()
|
|
558
|
+
return { ok: true, tipo: 'select', valor: el.value }
|
|
559
|
+
}
|
|
560
|
+
if (tag === 'input' && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
561
|
+
const marcado = valor === 'true' || valor === '1' || valor === 'on'
|
|
562
|
+
// Setter nativo para que React/Vue vean el cambio.
|
|
563
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked').set
|
|
564
|
+
setter.call(el, marcado)
|
|
565
|
+
el.dispatchEvent(new Event('click', { bubbles: true }))
|
|
566
|
+
disparar()
|
|
567
|
+
return { ok: true, tipo: el.type, valor: String(el.checked) }
|
|
568
|
+
}
|
|
569
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
570
|
+
const proto = tag === 'input' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype
|
|
571
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
|
|
572
|
+
el.focus()
|
|
573
|
+
setter.call(el, valor)
|
|
574
|
+
disparar()
|
|
575
|
+
return { ok: true, tipo: tag, valor: el.value }
|
|
576
|
+
}
|
|
577
|
+
if (el.isContentEditable) {
|
|
578
|
+
el.focus()
|
|
579
|
+
el.textContent = valor
|
|
580
|
+
disparar()
|
|
581
|
+
return { ok: true, tipo: 'contenteditable', valor: el.textContent }
|
|
582
|
+
}
|
|
583
|
+
return { ok: false, error: 'El elemento no es un campo de formulario (es <' + tag + '>).' }
|
|
584
|
+
}`,
|
|
585
|
+
arguments: [{ value: args.value }],
|
|
586
|
+
returnByValue: true,
|
|
587
|
+
})
|
|
588
|
+
const salida = resultado && resultado.result ? resultado.result.value : null
|
|
589
|
+
if (!salida || !salida.ok) {
|
|
590
|
+
throw new Error(salida && salida.error ? salida.error : 'No se pudo fijar el valor.')
|
|
591
|
+
}
|
|
592
|
+
return textResult(
|
|
593
|
+
`Campo ${args.ref} (${salida.tipo}) fijado a «${truncate(String(salida.valor), 200)}» con eventos input/change disparados.`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// resize_window · lectores de consola y red
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
async function redimensionarVentana(args) {
|
|
602
|
+
const tabId = requireTabId(args)
|
|
603
|
+
const width = args.width
|
|
604
|
+
const height = args.height
|
|
605
|
+
if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
|
|
606
|
+
throw new Error('resize_window exige width y height positivos (píxeles).')
|
|
607
|
+
}
|
|
608
|
+
const pestana = await chrome.tabs.get(tabId)
|
|
609
|
+
await chrome.windows.update(pestana.windowId, {
|
|
610
|
+
width: Math.round(width),
|
|
611
|
+
height: Math.round(height),
|
|
612
|
+
state: 'normal',
|
|
613
|
+
})
|
|
614
|
+
return jsonResult({ tabId, windowId: pestana.windowId, width, height })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function leerConsola(args) {
|
|
618
|
+
const tabId = requireTabId(args)
|
|
619
|
+
const sesion = await asegurarSesion(tabId)
|
|
620
|
+
const entradas = Core.filtrarEntradas(sesion.consola, {
|
|
621
|
+
pattern: typeof args.pattern === 'string' ? args.pattern : undefined,
|
|
622
|
+
limit: typeof args.limit === 'number' ? args.limit : 100,
|
|
623
|
+
onlyErrors: args.onlyErrors === true,
|
|
624
|
+
campoTexto: 'texto',
|
|
625
|
+
})
|
|
626
|
+
const lineas = entradas.map(e => {
|
|
627
|
+
const url = e.url ? ` (${e.url})` : ''
|
|
628
|
+
return `[${Core.horaLocal(e.ts)}] [${e.nivel}] ${e.texto}${url}`
|
|
629
|
+
})
|
|
630
|
+
const cuerpo =
|
|
631
|
+
lineas.length > 0 ? lineas.join('\n') : 'Sin mensajes que casen con el filtro.'
|
|
632
|
+
return textResult(
|
|
633
|
+
truncate(`${notaBuffer(sesion)}\nMensajes: ${lineas.length}\n${cuerpo}`, MAX_TEXT_CHARS),
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function leerRed(args) {
|
|
638
|
+
const tabId = requireTabId(args)
|
|
639
|
+
const sesion = await asegurarSesion(tabId)
|
|
640
|
+
const entradas = Core.filtrarEntradas(sesion.red.entradas, {
|
|
641
|
+
pattern: typeof args.pattern === 'string' ? args.pattern : undefined,
|
|
642
|
+
limit: typeof args.limit === 'number' ? args.limit : 100,
|
|
643
|
+
campoTexto: 'url',
|
|
644
|
+
})
|
|
645
|
+
const filas = entradas.map(e => ({
|
|
646
|
+
metodo: e.metodo,
|
|
647
|
+
url: e.url,
|
|
648
|
+
estado: e.error ? `ERROR: ${e.error}` : (e.estado ?? 'pendiente'),
|
|
649
|
+
tipo: e.tipo,
|
|
650
|
+
...(e.mime ? { mime: e.mime } : {}),
|
|
651
|
+
...(e.tam !== null ? { bytes: e.tam } : {}),
|
|
652
|
+
...(e.duracionMs !== null ? { ms: Math.round(e.duracionMs) } : {}),
|
|
653
|
+
}))
|
|
654
|
+
return textResult(
|
|
655
|
+
truncate(
|
|
656
|
+
`${notaBuffer(sesion)}\nPeticiones: ${filas.length}\n${JSON.stringify(filas, null, 1)}`,
|
|
657
|
+
MAX_TEXT_CHARS,
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Handlers de fase 2 que background.js fusiona en TOOL_HANDLERS.
|
|
663
|
+
// requireTabId, textResult, jsonResult, truncate y MAX_TEXT_CHARS los define
|
|
664
|
+
// background.js — importScripts comparte el ámbito global del service worker.
|
|
665
|
+
const CDP_TOOL_HANDLERS = {
|
|
666
|
+
computer: accionComputer,
|
|
667
|
+
read_page: leerPagina,
|
|
668
|
+
form_input: fijarCampo,
|
|
669
|
+
resize_window: redimensionarVentana,
|
|
670
|
+
read_console_messages: leerConsola,
|
|
671
|
+
read_network_requests: leerRed,
|
|
672
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|