webscope-mcp 0.2.0__py3-none-any.whl
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.
- webscope.py +695 -0
- webscope_adaptadores/__init__.py +1 -0
- webscope_adaptadores/costo_sesion.py +187 -0
- webscope_adaptadores/e2e_chrome.py +259 -0
- webscope_adaptadores/laravel.py +215 -0
- webscope_adaptadores/limpiar_zz.py +176 -0
- webscope_adaptadores/payload_check.py +272 -0
- webscope_adaptadores/proyecto.py +62 -0
- webscope_adaptadores/uso_agentes.py +68 -0
- webscope_adaptadores/uso_webscope.py +85 -0
- webscope_adaptadores/vue_ts.py +207 -0
- webscope_adaptadores/web/chequeo-ui.js +45 -0
- webscope_adaptadores/web/e2e-rutas.js +124 -0
- webscope_adaptadores/web/form.js +333 -0
- webscope_mcp-0.2.0.dist-info/METADATA +249 -0
- webscope_mcp-0.2.0.dist-info/RECORD +20 -0
- webscope_mcp-0.2.0.dist-info/WHEEL +5 -0
- webscope_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- webscope_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- webscope_mcp-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/* Ayudante de formularios para `javascript_tool`: llena por etiqueta, lee de vuelta cada campo y guarda en la captura lo que había antes y después.
|
|
2
|
+
* `adaptadores/e2e_chrome.py --form` lo entrega listo para pegar; requiere la captura (`--captura`) ya instalada en la página. */
|
|
3
|
+
(() => {
|
|
4
|
+
const est = window.__e2e;
|
|
5
|
+
if (!est || !est.detalle) return 'sin captura: pega primero e2e_chrome --captura';
|
|
6
|
+
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
const esperar = async (fn, ms = 2500) => {
|
|
8
|
+
const fin = performance.now() + ms;
|
|
9
|
+
while (performance.now() < fin) { const v = fn(); if (v) return v; await w(80); }
|
|
10
|
+
return null;
|
|
11
|
+
};
|
|
12
|
+
const base = () => document.querySelector('main') || document.body;
|
|
13
|
+
const dlg = () => [...document.querySelectorAll('.v-overlay--active')].reverse().find((o) => o.querySelector('.v-card .v-field, .v-card button'))
|
|
14
|
+
|| [...document.querySelectorAll('[role=dialog],[aria-modal=true],.modal.show')].reverse().find((o) => o.offsetParent || o.getClientRects().length)
|
|
15
|
+
|| base();
|
|
16
|
+
const etiqueta = (f) => (f.querySelector('label')?.textContent || '').trim().replace(/\*$/, '').trim();
|
|
17
|
+
const esSelect = (f) => !!f.closest('.v-select,.v-autocomplete,.v-combobox');
|
|
18
|
+
/** Campo marcado obligatorio por `*` en la etiqueta o por `required`/`aria-required` en su entrada. */
|
|
19
|
+
const obligatorio = (f) => /\*\s*$/.test(f.querySelector('label')?.textContent || '') || !!f.querySelector('input[required],textarea[required],[aria-required=true]');
|
|
20
|
+
const valor = (f) => {
|
|
21
|
+
const i = f.querySelector('input,textarea');
|
|
22
|
+
const visto = esSelect(f) ? [...f.querySelectorAll('.v-select__selection-text,.v-chip__content,.v-autocomplete__selection:not(:has(.v-select__selection-text,.v-chip__content))')].map((s) => s.textContent.trim()).join('|') : '';
|
|
23
|
+
return { v: i?.value ?? '', visto };
|
|
24
|
+
};
|
|
25
|
+
const visibles = () => [...dlg().querySelectorAll('.v-field')].filter((f) => f.offsetParent && etiqueta(f));
|
|
26
|
+
/** Etiqueta única del campo: la repetida lleva `#n` (`Unidad#2`). */
|
|
27
|
+
const nombre = (f, todos) => {
|
|
28
|
+
const igual = todos.filter((x) => etiqueta(x) === etiqueta(f));
|
|
29
|
+
return igual.length > 1 && igual.indexOf(f) > 0 ? `${etiqueta(f)}#${igual.indexOf(f) + 1}` : etiqueta(f);
|
|
30
|
+
};
|
|
31
|
+
const campos = () => { const t = visibles(); return Object.fromEntries(t.map((f) => [nombre(f, t), valor(f)])); };
|
|
32
|
+
/** Campo por etiqueta exacta; si no la hay, el único cuya etiqueta contiene `l`. */
|
|
33
|
+
const campo = (l) => {
|
|
34
|
+
const t = visibles();
|
|
35
|
+
const parecidos = t.filter((f) => nombre(f, t).toLowerCase().includes(String(l).toLowerCase()));
|
|
36
|
+
return t.find((f) => nombre(f, t) === l) || (parecidos.length === 1 ? parecidos[0] : undefined);
|
|
37
|
+
};
|
|
38
|
+
const menu = () => [...document.querySelectorAll('.v-overlay--active.v-menu .v-list')].pop();
|
|
39
|
+
const texto = (e) => (e.innerText || e.textContent || '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
const vivos = (sel, raiz = document) => [...raiz.querySelectorAll(sel)].filter((e) => e.offsetParent || e.getClientRects().length);
|
|
41
|
+
const PULSABLE = 'button,a,[role=button],[role=tab],[role=menuitem],[role=option],.v-tab,.v-list-item,.v-chip,input[type=checkbox],input[type=radio]';
|
|
42
|
+
/** Espera a que el DOM pase 400 ms sin cambios y sin peticiones pendientes, hasta `ms`; devuelve los ms que tardó o -1. */
|
|
43
|
+
const calma = async (ms = 6000) => {
|
|
44
|
+
const t0 = performance.now();
|
|
45
|
+
let ultimo = t0;
|
|
46
|
+
const obs = new MutationObserver(() => { ultimo = performance.now(); });
|
|
47
|
+
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
48
|
+
while (performance.now() - t0 < ms) {
|
|
49
|
+
await w(100);
|
|
50
|
+
if ((est.pendientes ?? 0) <= 0 && performance.now() - ultimo > 400) break;
|
|
51
|
+
}
|
|
52
|
+
obs.disconnect();
|
|
53
|
+
const t = Math.round(performance.now() - t0);
|
|
54
|
+
return t < ms ? t : -1;
|
|
55
|
+
};
|
|
56
|
+
/** Elemento pulsable visible por texto (exacto, luego contenido) o por selector CSS si empieza por `#`, `.` o `[`. */
|
|
57
|
+
const buscarPulsable = (q, raiz) => {
|
|
58
|
+
if (/^[#.[]/.test(q)) return vivos(q, raiz)[0];
|
|
59
|
+
const t = q.toLowerCase();
|
|
60
|
+
const todos = vivos(PULSABLE, raiz);
|
|
61
|
+
return todos.find((e) => texto(e).toLowerCase() === t || e.getAttribute('aria-label')?.toLowerCase() === t)
|
|
62
|
+
|| todos.find((e) => texto(e).toLowerCase().includes(t));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Nombre estable de un pulsable: su texto, su aria-label o su icono `mdi-*`. */
|
|
66
|
+
const nombrePulsable = (e) => texto(e) || e.getAttribute('aria-label') || (e.innerHTML.match(/mdi-[\w-]+/) || [''])[0];
|
|
67
|
+
/** Vacía el v-model del VSelect/VAutocomplete/VCombobox que contiene `c`; false si Vue no expone el componente (build de producción). */
|
|
68
|
+
const vaciarModelo = (c) => {
|
|
69
|
+
let i = c?.__vueParentComponent;
|
|
70
|
+
while (i && !/^V(Select|Autocomplete|Combobox)$/.test(i.type?.name || '')) i = i.parent;
|
|
71
|
+
const emitir = i?.vnode.props?.['onUpdate:modelValue'];
|
|
72
|
+
if (!emitir) return false;
|
|
73
|
+
[].concat(emitir).forEach((f) => f(i.props.multiple ? [] : null));
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
let refs = [];
|
|
77
|
+
/** Lista numerada de lo pulsable visible en `raiz` («3 Guardar»); `__f.clic(3)` pulsa ese número. */
|
|
78
|
+
const numerar = (raiz) => {
|
|
79
|
+
refs = [null, ...vivos(PULSABLE, raiz).filter((e) => nombrePulsable(e))].slice(0, 41);
|
|
80
|
+
return refs.slice(1).map((e, i) => `${i + 1} ${nombrePulsable(e).slice(0, 40)}`);
|
|
81
|
+
};
|
|
82
|
+
est.pasos ||= [];
|
|
83
|
+
let grabar = true;
|
|
84
|
+
/** Anota un paso para exportarlo luego como test repetible. */
|
|
85
|
+
const anotar = (m, ...args) => grabar && est.pasos.push([m, ...args]);
|
|
86
|
+
|
|
87
|
+
window.__f = {
|
|
88
|
+
inicial: null,
|
|
89
|
+
/** Foto de los campos del formulario abierto; se compara al pulsar el botón de guardar. */
|
|
90
|
+
abrir() { this.inicial = campos(); return Object.keys(this.inicial).length; },
|
|
91
|
+
campos,
|
|
92
|
+
/** Etiquetas de los campos obligatorios del formulario abierto. */
|
|
93
|
+
obligatorios() { const t = visibles(); return t.filter(obligatorio).map((f) => nombre(f, t)); },
|
|
94
|
+
/** Valor fijo por campo obligatorio según su tipo, para que todas las pruebas usen los mismos datos; texto con `prefijo`.
|
|
95
|
+
* shortcut: solo ve campos `.v-field` con etiqueta (no interruptores ni selectores propios); fecha sin `type=date` va en dd/mm/aaaa. */
|
|
96
|
+
valores(prefijo = 'ZZ_E2E') {
|
|
97
|
+
const t = visibles();
|
|
98
|
+
const hoy = new Date();
|
|
99
|
+
const dos = (n) => String(n).padStart(2, '0');
|
|
100
|
+
return Object.fromEntries(t.filter(obligatorio).map((f) => {
|
|
101
|
+
const l = nombre(f, t);
|
|
102
|
+
const i = f.querySelector('input,textarea');
|
|
103
|
+
if (esSelect(f)) return [l, ''];
|
|
104
|
+
if (i?.type === 'number') return [l, '1'];
|
|
105
|
+
if (i?.type === 'date') return [l, `${hoy.getFullYear()}-${dos(hoy.getMonth() + 1)}-${dos(hoy.getDate())}`];
|
|
106
|
+
if (i?.type === 'email') return [l, `${prefijo.toLowerCase()}@ejemplo.test`];
|
|
107
|
+
if (/fecha|date/i.test(l)) return [l, `${dos(hoy.getDate())}/${dos(hoy.getMonth() + 1)}/${hoy.getFullYear()}`];
|
|
108
|
+
const v = `${prefijo}_${l.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\w]+/g, '_').toUpperCase()}`;
|
|
109
|
+
return [l, i?.maxLength > 0 ? v.slice(0, i.maxLength) : v];
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
/** Intenta guardar con cada obligatorio vacío, uno por vez (desplegables con su botón de borrar), y dice si la app lo rechazó;
|
|
113
|
+
* nunca vacía `clave` (por defecto el primer obligatorio de texto), que lleva el prefijo con el que se limpia la fila. */
|
|
114
|
+
async probarObligatorios({ valores = this.valores(), guardar = 'Guardar', clave } = {}) {
|
|
115
|
+
const t = visibles();
|
|
116
|
+
const todos = t.filter(obligatorio).map((f) => nombre(f, t));
|
|
117
|
+
clave ??= t.filter((f) => obligatorio(f) && !esSelect(f)).map((f) => nombre(f, t))[0];
|
|
118
|
+
const r = [];
|
|
119
|
+
grabar = false;
|
|
120
|
+
try {
|
|
121
|
+
for (const l of todos) {
|
|
122
|
+
if (l === clave) { r.push({ campo: l, resultado: 'no probado: campo clave' }); continue; }
|
|
123
|
+
for (const [k, v] of Object.entries(valores)) await this.poner(k, v);
|
|
124
|
+
if (esSelect(campo(l))) {
|
|
125
|
+
const x = campo(l).querySelector('.v-field__clearable i,.v-field__clearable button,[aria-label*="clear" i],[aria-label*="borrar" i]');
|
|
126
|
+
if (x) x.click();
|
|
127
|
+
else if (!vaciarModelo(campo(l))) { r.push({ campo: l, resultado: 'no probado: desplegable sin botón de borrar' }); continue; }
|
|
128
|
+
await w(200);
|
|
129
|
+
if (valor(campo(l)).v || valor(campo(l)).visto) { r.push({ campo: l, resultado: 'no probado: el desplegable no se vació' }); continue; }
|
|
130
|
+
} else await this.poner(l, '');
|
|
131
|
+
const p = await this.pulsar(guardar);
|
|
132
|
+
const guardo = (p.red || []).find((x) => /^(POST|PUT|PATCH) 2/.test(x));
|
|
133
|
+
if (guardo) {
|
|
134
|
+
const d = est.detalle.filter((x) => x.m !== 'GET').pop();
|
|
135
|
+
r.push({ campo: l, resultado: `❌ guardó sin el campo (${guardo}${d?.r?.id ? `, id ${d.r.id}` : ''}): límpialo` });
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
const motivo = p.errores?.join('; ') || (p.red || []).find((x) => / 4\d\d /.test(x)) || (p.deshabilitado || p.error ? 'botón sin efecto o deshabilitado' : '');
|
|
139
|
+
r.push({ campo: l, resultado: motivo ? `rechazado: ${motivo}` : '¿? sin red, sin error y sin guardar' });
|
|
140
|
+
}
|
|
141
|
+
} finally { grabar = true; }
|
|
142
|
+
return r;
|
|
143
|
+
},
|
|
144
|
+
/** Pone `v` en el campo de etiqueta `l` y lo lee de vuelta; devuelve '' si quedó o el motivo si no. */
|
|
145
|
+
async poner(l, v) {
|
|
146
|
+
const c = campo(l);
|
|
147
|
+
if (!c) return `❌ «${l}»: no hay campo con esa etiqueta`;
|
|
148
|
+
const i = c.querySelector('input,textarea');
|
|
149
|
+
if (!esSelect(c)) {
|
|
150
|
+
i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); i.dispatchEvent(new Event('change', { bubbles: true }));
|
|
151
|
+
anotar('poner', l, v);
|
|
152
|
+
await w(150);
|
|
153
|
+
return String(campo(l).querySelector('input,textarea').value) === String(v) ? '' : `❌ «${l}»: pedí ${v}, quedó ${campo(l).querySelector('input,textarea').value}`;
|
|
154
|
+
}
|
|
155
|
+
document.body.click(); await w(150);
|
|
156
|
+
let lista = null;
|
|
157
|
+
for (let intento = 0; intento < 2 && !lista; intento++) {
|
|
158
|
+
i.focus(); (c.querySelector('.v-field__input') || c).dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
159
|
+
if (c.closest('.v-autocomplete,.v-combobox') && v) { i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
160
|
+
if (intento) i.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
161
|
+
lista = await esperar(() => menu()?.querySelector('.v-list-item') && menu());
|
|
162
|
+
}
|
|
163
|
+
if (!lista) return `❌ «${l}»: el menú no abrió`;
|
|
164
|
+
const items = [...lista.querySelectorAll('.v-list-item')];
|
|
165
|
+
const x = v ? items.find((e) => e.textContent.trim() === v) || items.find((e) => e.textContent.includes(v)) : items[0];
|
|
166
|
+
if (!x) { document.body.click(); return `❌ «${l}»: no hay opción ${v}; hay ${items.map((e) => e.textContent.trim()).slice(0, 8).join(', ')}`; }
|
|
167
|
+
const texto = x.textContent.trim();
|
|
168
|
+
const buscado = texto.split(/\s{2,}|Documento:/)[0].trim();
|
|
169
|
+
anotar('elegir', l, texto);
|
|
170
|
+
x.click(); await w(250); document.body.click();
|
|
171
|
+
const quedo = (await esperar(() => { const q = campo(l) && valor(campo(l)).visto; return q && q.includes(buscado) && q; }, 1500)) || valor(campo(l)).visto;
|
|
172
|
+
return quedo.includes(buscado) ? '' : `❌ «${l}»: elegí ${texto}, se ve ${quedo || 'vacío'}`;
|
|
173
|
+
},
|
|
174
|
+
/** Llena varios campos; devuelve los que no quedaron y los obligatorios que siguen vacíos. */
|
|
175
|
+
async llenar(o) {
|
|
176
|
+
const m = [];
|
|
177
|
+
for (const [l, v] of Object.entries(o)) { const r = await this.poner(l, v); if (r) m.push(r); }
|
|
178
|
+
const ahora = campos();
|
|
179
|
+
for (const l of this.obligatorios()) if (!(l in o) && ahora[l] && !ahora[l].v && !ahora[l].visto) m.push(`❌ «${l}»: obligatorio y vacío; llénalo antes de guardar`);
|
|
180
|
+
for (const l of Object.keys(o)) if ((o[l] !== '' || (campo(l) && esSelect(campo(l)))) && !m.some((x) => x.includes(`«${l}»`)) && ahora[l] && !ahora[l].v && !ahora[l].visto) m.push(`❌ «${l}»: quedó y luego se vació (otro campo lo reinició)`);
|
|
181
|
+
return m;
|
|
182
|
+
},
|
|
183
|
+
/** Compara el formulario reabierto con lo guardado `o` ({etiqueta: valor}); devuelve los campos que no persistieron. */
|
|
184
|
+
verificar(o) {
|
|
185
|
+
anotar('verificar', o);
|
|
186
|
+
const ahora = campos();
|
|
187
|
+
return Object.entries(o).filter(([l, v]) => !ahora[l] || (String(ahora[l].v) !== String(v) && !ahora[l].visto.includes(v)))
|
|
188
|
+
.map(([l, v]) => `❌ «${l}»: guardé ${v}, al reabrir ${ahora[l] ? ahora[l].visto || ahora[l].v || 'vacío' : 'no está'}`);
|
|
189
|
+
},
|
|
190
|
+
/** Pulsa el botón `txt` del diálogo de arriba (o de la página) y espera a que se calme; avisa si el clic no cambió nada.
|
|
191
|
+
* La siguiente escritura capturada lleva {etiqueta: [antes, después]} de cada campo. */
|
|
192
|
+
async pulsar(txt, ms = 6000) {
|
|
193
|
+
const tiene = (o) => vivos('button', o).some((e) => texto(e).toLowerCase() === txt.toLowerCase());
|
|
194
|
+
const capas = [...document.querySelectorAll('.v-overlay--active,[role=dialog]')].reverse();
|
|
195
|
+
const raiz = [dlg(), ...capas].find(tiene) || dlg();
|
|
196
|
+
const avisoAntes = vivos('.Toastify__toast,.v-snackbar__content', document).map((t) => texto(t)).join('|');
|
|
197
|
+
const botones = vivos('button', raiz);
|
|
198
|
+
const b = botones.find((e) => texto(e).toLowerCase() === txt.toLowerCase());
|
|
199
|
+
if (!b) return { error: `sin botón ${txt}`, hay: botones.map((e) => e.textContent.trim()).filter(Boolean) };
|
|
200
|
+
const ahora = campos();
|
|
201
|
+
const ob = new Set(this.obligatorios());
|
|
202
|
+
est.formPend = Object.keys(ahora).length
|
|
203
|
+
? Object.fromEntries(Object.entries(ahora).map(([k, x]) => [k, [this.inicial?.[k]?.v ?? null, x.v, x.visto, ob.has(k) ? 1 : 0]]))
|
|
204
|
+
: null;
|
|
205
|
+
const desde = est.detalle.length;
|
|
206
|
+
const deshabilitado = b.disabled;
|
|
207
|
+
await w(200);
|
|
208
|
+
b.click(); await w(150); await calma(ms);
|
|
209
|
+
this.inicial = null;
|
|
210
|
+
const errores = vivos('.v-messages__message', dlg()).map(texto).filter(Boolean);
|
|
211
|
+
const red = est.detalle.slice(desde).filter((d) => d.m !== 'GET').map((d) => `${d.m} ${d.s} ${d.u.replace(/^.*\/api\//, '')}`);
|
|
212
|
+
if (!red.length) est.formPend = null;
|
|
213
|
+
anotar('pulsar', txt, red.map((x) => x.replace(/ \d+ /, ' ').replace(/\/\d+(?=\/|$)/g, '/{}')));
|
|
214
|
+
const avisos = vivos('.Toastify__toast,.v-snackbar__content', document).map((t) => texto(t));
|
|
215
|
+
const aviso = avisos.map((t) => t.slice(0, 110)).pop();
|
|
216
|
+
const sinEfecto = !red.length && avisos.join('|') === avisoAntes && !errores.length && b.isConnected && (b.offsetParent || b.getClientRects().length);
|
|
217
|
+
const otro = !red.length && !b.isConnected && vivos('button', dlg()).some((e) => texto(e).toLowerCase() === txt.toLowerCase());
|
|
218
|
+
return { ...(deshabilitado ? { deshabilitado } : {}), red, aviso, ...(errores.length ? { errores } : {}),
|
|
219
|
+
...(otro ? { otroDialogo: `se abrió otro diálogo con «${txt}» (doble confirmación): vuelve a llamar __f.pulsar('${txt}')` } : {}),
|
|
220
|
+
...(sinEfecto ? { error: `pulsé «${txt}» y no pasó nada: sin red, sin aviso y el botón sigue ahí; prueba __f.clic('${txt}')` } : {}) };
|
|
221
|
+
},
|
|
222
|
+
/** Abre el menú ⋮ de la fila que cumple `re`; sin `txt` devuelve sus opciones, con `txt` pulsa esa y devuelve el diálogo que abrió. */
|
|
223
|
+
async menu(re, txt, ms = 1500) {
|
|
224
|
+
const items = () => [...document.querySelectorAll('.v-overlay--active.v-menu .v-list-item')];
|
|
225
|
+
if (!items().length) {
|
|
226
|
+
const tr = [...document.querySelectorAll('tbody tr')].find((t) => re.test(t.textContent));
|
|
227
|
+
if (!tr) return 'sin fila';
|
|
228
|
+
tr.querySelector('.mdi-dots-vertical')?.closest('button')?.click(); await w(600);
|
|
229
|
+
}
|
|
230
|
+
if (!txt) return items().map((e) => e.textContent.trim());
|
|
231
|
+
const it = items().find((e) => e.textContent.trim() === txt);
|
|
232
|
+
if (!it) return `sin ${txt}; hay ${items().map((e) => e.textContent.trim()).join(', ')}`;
|
|
233
|
+
it.click(); await w(150); await calma(ms);
|
|
234
|
+
return [...document.querySelectorAll('.v-overlay--active:not(.v-menu)')].map((o) => o.innerText.replace(/\s+/g, ' ').slice(0, 200));
|
|
235
|
+
},
|
|
236
|
+
/** Pulsa el icono de la fila que cumple `re`; `icono` es parte de su clase (p. ej. pencil, trash). Si la fila no está en la
|
|
237
|
+
* página, escribe el texto de `re` en el buscador de la lista y vuelve a mirar. */
|
|
238
|
+
async fila(re, icono, ms = 2500) {
|
|
239
|
+
if (dlg() !== base()) return 'diálogo abierto: ciérralo antes';
|
|
240
|
+
const buscar = () => [...document.querySelectorAll('tbody tr')].find((t) => re.test(t.textContent));
|
|
241
|
+
let tr = buscar();
|
|
242
|
+
const caja = !tr && visibles().find((f) => /buscar|search/i.test(etiqueta(f)) && !esSelect(f));
|
|
243
|
+
if (caja) {
|
|
244
|
+
const i = caja.querySelector('input');
|
|
245
|
+
i.value = re.source.replace(/[\\^$.*+?()[\]{}|]/g, ''); i.dispatchEvent(new Event('input', { bubbles: true }));
|
|
246
|
+
await calma(ms);
|
|
247
|
+
tr = await esperar(buscar, ms);
|
|
248
|
+
}
|
|
249
|
+
if (!tr) return 'sin fila';
|
|
250
|
+
anotar('fila', re.source, icono);
|
|
251
|
+
const b = [...tr.querySelectorAll('button,a')].find((e) => e.innerHTML.includes(icono));
|
|
252
|
+
if (!b) return `sin ${icono}`;
|
|
253
|
+
if (b.disabled) return `${icono} deshabilitado`;
|
|
254
|
+
b.click(); await w(150); await calma(ms);
|
|
255
|
+
return 'ok';
|
|
256
|
+
},
|
|
257
|
+
/** La pantalla en texto corto, en lugar de una captura: ruta, título, diálogo abierto, botones, pestañas, filas, avisos y errores. */
|
|
258
|
+
estado() {
|
|
259
|
+
const d = dlg();
|
|
260
|
+
const enDialogo = d !== base();
|
|
261
|
+
const filas = vivos('tbody tr', d);
|
|
262
|
+
const r = {
|
|
263
|
+
ruta: location.pathname + location.search,
|
|
264
|
+
titulo: texto(vivos('h1,.v-toolbar-title,.v-card-title', d)[0] || document.createElement('i')).slice(0, 80),
|
|
265
|
+
...(enDialogo ? { dialogo: true } : {}),
|
|
266
|
+
botones: [...new Set(vivos('button', d).map(texto).filter(Boolean))].slice(0, 15),
|
|
267
|
+
pulsables: numerar(d),
|
|
268
|
+
pestanas: vivos('.v-tab,[role=tab]', d).map((e) => (e.classList.contains('v-tab--selected') || e.getAttribute('aria-selected') === 'true' ? '*' : '') + texto(e)).slice(0, 10),
|
|
269
|
+
filas: filas.length,
|
|
270
|
+
primeras: filas.slice(0, 3).map((t) => texto(t).slice(0, 100)),
|
|
271
|
+
avisos: vivos('.Toastify__toast,.v-snackbar__content,.v-alert', document).map((e) => texto(e).slice(0, 110)),
|
|
272
|
+
errores: vivos('.v-messages__message', d).map(texto).filter(Boolean).slice(0, 8),
|
|
273
|
+
cargando: vivos('.v-progress-linear--active,.v-progress-circular,.v-skeleton-loader').length,
|
|
274
|
+
};
|
|
275
|
+
return Object.fromEntries(Object.entries(r).filter(([, v]) => v !== '' && v !== 0 && !(Array.isArray(v) && !v.length)));
|
|
276
|
+
},
|
|
277
|
+
/** Pulsa por número de `estado().pulsables`, por texto o por selector (en el diálogo abierto si lo hay) y espera a que la
|
|
278
|
+
* pantalla se calme; devuelve el estado o el error. */
|
|
279
|
+
async clic(q, ms = 6000) {
|
|
280
|
+
const e = typeof q === 'number' ? (refs[q]?.isConnected ? refs[q] : null) : buscarPulsable(q, dlg()) || buscarPulsable(q, document);
|
|
281
|
+
if (!e) return { error: `sin «${q}»`, hay: numerar(dlg()) };
|
|
282
|
+
if (e.disabled || e.getAttribute('aria-disabled') === 'true') return { error: `«${q}» deshabilitado` };
|
|
283
|
+
anotar('clic', nombrePulsable(e));
|
|
284
|
+
e.scrollIntoView({ block: 'center' }); e.click();
|
|
285
|
+
const t = await calma(ms);
|
|
286
|
+
return { ms: t, ...this.estado() };
|
|
287
|
+
},
|
|
288
|
+
/** Espera hasta `ms` a que aparezca un texto o selector visible; devuelve los ms que tardó o un error. */
|
|
289
|
+
async esperar(q, ms = 8000) {
|
|
290
|
+
const t0 = performance.now();
|
|
291
|
+
const hay = () => (/^[#.[]/.test(q) ? vivos(q)[0] : document.body.innerText.toLowerCase().includes(q.toLowerCase()));
|
|
292
|
+
return (await esperar(hay, ms)) ? { ms: Math.round(performance.now() - t0) } : { error: `no apareció «${q}» en ${ms} ms`, ...this.estado() };
|
|
293
|
+
},
|
|
294
|
+
/** Navega sin recargar (los ayudantes siguen instalados): con el router de Vue si lo hay, si no con history y popstate; espera a que se calme. */
|
|
295
|
+
async ir(ruta, ms = 8000) {
|
|
296
|
+
const router = document.querySelector('#app')?.__vue_app__?.config.globalProperties.$router;
|
|
297
|
+
anotar('ir', ruta);
|
|
298
|
+
try {
|
|
299
|
+
if (router) await router.push(ruta);
|
|
300
|
+
else { history.pushState({}, '', ruta); dispatchEvent(new PopStateEvent('popstate', { state: {} })); }
|
|
301
|
+
} catch (e) { return { error: String(e).slice(0, 100) }; }
|
|
302
|
+
const t = await calma(ms);
|
|
303
|
+
return { ms: t, ...this.estado() };
|
|
304
|
+
},
|
|
305
|
+
/** Corre pasos [[metodo, ...args], ...] en una sola llamada y para en el primero que falla; devuelve una línea por paso.
|
|
306
|
+
* Acepta los pasos anotados (`fila` con texto, `elegir`, `pulsar` con la red esperada); `sello: true` añade un sufijo
|
|
307
|
+
* por corrida a los valores ZZ_E2E… para no chocar con filas de corridas anteriores. */
|
|
308
|
+
async pasos(lista, { sello } = {}) {
|
|
309
|
+
const S = sello && Date.now().toString(36).toUpperCase();
|
|
310
|
+
const sellar = (v, fila) => typeof v === 'string' ? (/^ZZ_E2E/.test(v) ? v.replace(/_(?=[A-Z]*\d)[0-9A-Z]{8}$/, '') + (fila ? `.*_${S}` : `_${S}`) : v)
|
|
311
|
+
: v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, sellar(x)])) : v;
|
|
312
|
+
const log = [];
|
|
313
|
+
for (let [m, ...args] of lista) {
|
|
314
|
+
if (S) args = args.map((x, i) => sellar(x, m === 'fila' && i === 0));
|
|
315
|
+
if (m === 'fila' && typeof args[0] === 'string') args[0] = new RegExp(args[0]);
|
|
316
|
+
if (m === 'elegir') m = 'poner';
|
|
317
|
+
const espera = m === 'pulsar' && Array.isArray(args[1]) ? args.splice(1)[0] : null;
|
|
318
|
+
if (typeof this[m] !== 'function') { log.push(`❌ ${m}: no existe`); break; }
|
|
319
|
+
const t0 = performance.now();
|
|
320
|
+
const r = await this[m](...args);
|
|
321
|
+
const ms = Math.round(performance.now() - t0);
|
|
322
|
+
const red = (r?.red || []).map((x) => x.replace(/ \d+ /, ' ').replace(/\/\d+(?=\/|$)/g, '/{}'));
|
|
323
|
+
const falta = espera?.filter((e) => !red.includes(e));
|
|
324
|
+
const mal = (r && r.error) || (Array.isArray(r) && r.length && r) || (typeof r === 'string' && /^(❌|sin |diálogo)/.test(r) && r)
|
|
325
|
+
|| (falta?.length && `sin ${falta.join(', ')} en la red`);
|
|
326
|
+
log.push(`${mal ? '❌' : '✓'} ${m}(${args.map((a) => a instanceof RegExp ? String(a) : JSON.stringify(a)).join(', ').slice(0, 60)}) ${ms} ms${mal ? ': ' + JSON.stringify(mal).slice(0, 200) : ''}`);
|
|
327
|
+
if (mal) break;
|
|
328
|
+
}
|
|
329
|
+
return { log, ...this.estado() };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
return "formularios listos: __f.estado() (pulsables numerados), __f.clic('Texto' o número), __f.esperar('Texto'), __f.ir('/ruta'), __f.pasos([[...]]), __f.abrir(), __f.obligatorios(), __f.llenar({...}), __f.pulsar('Guardar'), __f.verificar({...}), __f.fila(/re/, 'pencil'), __f.menu(/re/, 'Opción')";
|
|
333
|
+
})()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webscope-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server (stdio, standard library only) that gives a coding agent short answers about a project
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/partprogramming09/webscope
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# Webscope
|
|
13
|
+
|
|
14
|
+
<!-- mcp-name: io.github.partprogramming09/webscope -->
|
|
15
|
+
|
|
16
|
+
A small MCP server (stdio, JSON-RPC 2.0, Python standard library only) that gives a coding agent short answers about a
|
|
17
|
+
project: grouped search hits, one symbol instead of a whole file, and the output of the project's own read-only scripts,
|
|
18
|
+
trimmed to head and tail. The goal is fewer tokens per question.
|
|
19
|
+
|
|
20
|
+
Tool names, parameters and messages are in Spanish.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Python 3.9 or newer (no third-party packages)
|
|
25
|
+
- Optional: [uv](https://docs.astral.sh/uv/), to run it with `uvx` without cloning
|
|
26
|
+
- `grep` on `PATH` (used by `buscar`)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
1. In your project's root, create its manifest and list the folders and scripts it may use
|
|
31
|
+
(see [Configuration](#configuration-webscopejson) and [`webscope.example.json`](webscope.example.json)).
|
|
32
|
+
|
|
33
|
+
2. Register the server. With Claude Code, from the project's root, pick one:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
# with uv, no clone needed (like npx)
|
|
37
|
+
claude mcp add --scope project webscope -- uvx --from git+https://github.com/partprogramming09/webscope webscope-mcp --manifest "$PWD/webscope.json"
|
|
38
|
+
|
|
39
|
+
# with a clone and plain Python
|
|
40
|
+
git clone https://github.com/partprogramming09/webscope.git ~/webscope
|
|
41
|
+
claude mcp add --scope project webscope -- python3 -I ~/webscope/webscope.py --manifest "$PWD/webscope.json"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--scope project` writes `.mcp.json` so the whole team gets it; use `--scope user` for all your projects. Add
|
|
45
|
+
`-e WEBSCOPE_ESCRITURA=1` before `--` only if a script declares `escribe` and you want to allow it.
|
|
46
|
+
|
|
47
|
+
3. Start `claude` in the project, approve the server, and check it with `/mcp`: `webscope` should show as connected
|
|
48
|
+
with the tools `buscar`, `leer` and `correr`.
|
|
49
|
+
|
|
50
|
+
Other MCP clients: add the same command to their configuration, for example a `.mcp.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"webscope": {
|
|
56
|
+
"command": "python3",
|
|
57
|
+
"args": ["-I", "/path/to/webscope/webscope.py", "--manifest", "/path/to/your/project/webscope.json"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Instead of `--manifest` you can set the environment variable `WEBSCOPE_MANIFEST`. Without either, the server uses
|
|
64
|
+
`./webscope.json` if it exists; otherwise it serves the current folder with `buscar` and `leer` and no scripts.
|
|
65
|
+
|
|
66
|
+
### Docker
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
docker build -t webscope .
|
|
70
|
+
docker run -i --rm -v "$PWD:/project:ro" -e WEBSCOPE_MANIFEST=/project/webscope.json webscope
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Without `WEBSCOPE_MANIFEST` the image serves the bundled `webscope.example.json`.
|
|
74
|
+
|
|
75
|
+
## Tools
|
|
76
|
+
|
|
77
|
+
| Tool | What it does |
|
|
78
|
+
| :--- | :--- |
|
|
79
|
+
| `buscar(texto, donde?, tope?, formato?, contexto?, regex?)` | Case-insensitive search, literal unless `regex=true` (extended regex). Hits grouped by file, most-referenced files first, capped at `tope` lines. `donde` limits it to a folder or file; `contexto` (0-5) adds lines around each hit. `formato="conciso"` returns only the top 5 files with counts. |
|
|
80
|
+
| `leer(archivo, simbolo?, lineas?, max_lineas?, cola?, contiene?, formato?)` | Returns only one function, method or class (PHP, TS, JS, Vue, Python) — or several, if `simbolo` is a list —, one Markdown section by heading, a line range (`lineas="120-180"`), or for `.log`/`.txt`/`.out` files the last `cola` lines and/or the lines containing `contiene`. Without `simbolo` or `lineas` it returns the file's index: headings or definitions with their line numbers. |
|
|
81
|
+
| `correr(nombre?, args?, detalle?)` | Runs a script declared in the manifest and returns `exit N` plus trimmed output. Without `nombre` it lists the scripts; `detalle="esquema"` shows one script's usage. Read-only. |
|
|
82
|
+
| `correr_escritura(nombre, args)` | Only listed when a script declares `escribe`. Runs a write argument; requires `WEBSCOPE_ESCRITURA=1` in the server environment and is marked `destructiveHint` and `anthropic/requiresUserInteraction`. |
|
|
83
|
+
|
|
84
|
+
Every answer starts with one short line saying what it is and what to do next, and is capped at `tope_tokens`
|
|
85
|
+
(about 4 characters per token).
|
|
86
|
+
|
|
87
|
+
A malformed request, a non-UTF-8 script output or an unexpected error comes back as an error message; the server keeps
|
|
88
|
+
running. `buscar` skips files that are not valid UTF-8 when the locale is UTF-8 (grep treats them as binary).
|
|
89
|
+
|
|
90
|
+
## Security model
|
|
91
|
+
|
|
92
|
+
- **Project root only.** `buscar` and `leer` resolve paths against the manifest's `root` and reject anything outside it.
|
|
93
|
+
- **Secret files are invisible.** `buscar` and `leer` skip `.env`, `.env.*`, `*.pem`, `*.key`, `*.p12`, `id_rsa*`,
|
|
94
|
+
`id_ed25519*`, `auth.json` and `credentials*` (templates ending in `.example` stay visible).
|
|
95
|
+
- **Fixed commands, no shell.** Scripts run as a fixed argument list from the manifest. Arguments that are absolute paths
|
|
96
|
+
or contain `..` are rejected (unless the script sets `acepta_barra`, which allows URL-like `/a/b` but still not `..`).
|
|
97
|
+
Flags listed in `rechaza` are rejected.
|
|
98
|
+
- **Read-only by default.** `correr` refuses any argument listed in a script's `escribe`. Those go through
|
|
99
|
+
`correr_escritura`, which only exists when declared and only runs when the user sets `WEBSCOPE_ESCRITURA=1`.
|
|
100
|
+
- **Minimal environment.** Scripts do not inherit the server's environment. They receive only `PATH`, `HOME`, `LANG`,
|
|
101
|
+
`LC_ALL`, `TMPDIR`, the variables named in the manifest's `env_permitidas`, plus `WEBSCOPE_RAIZ` (project root) and
|
|
102
|
+
`WEBSCOPE_MANIFIESTO` (manifest path).
|
|
103
|
+
- **Bounded output.** Script output is trimmed to head and tail lines, and each response to `tope_tokens`.
|
|
104
|
+
|
|
105
|
+
The scripts themselves are trusted code: Webscope limits how they are called, not what they do. Only list scripts you
|
|
106
|
+
would run yourself.
|
|
107
|
+
|
|
108
|
+
## Configuration (`webscope.json`)
|
|
109
|
+
|
|
110
|
+
See [`webscope.example.json`](webscope.example.json).
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"root": ".",
|
|
115
|
+
"recorte": { "cabeza": 10, "cola": 70 },
|
|
116
|
+
"tope_tokens": 5000,
|
|
117
|
+
"buscar": { "dirs": ["src"], "extensiones": ["py", "ts"] },
|
|
118
|
+
"env_permitidas": ["DATABASE_URL"],
|
|
119
|
+
"scripts": {
|
|
120
|
+
"report": {
|
|
121
|
+
"cmd": ["python3", "-I", "scripts/report.py"],
|
|
122
|
+
"descripcion": "Summarises a table",
|
|
123
|
+
"uso": "<table>",
|
|
124
|
+
"rechaza": ["--out"],
|
|
125
|
+
"escribe": ["--fix"],
|
|
126
|
+
"timeout": 120
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Key | Meaning |
|
|
133
|
+
| :--- | :--- |
|
|
134
|
+
| `root` | Project root, relative to the manifest's folder. |
|
|
135
|
+
| `recorte` | Default head (`cabeza`) and tail (`cola`) lines kept from script output. |
|
|
136
|
+
| `tope_tokens` | Maximum size of any response, in approximate tokens. |
|
|
137
|
+
| `buscar.dirs`, `buscar.extensiones` | Folders and file extensions searched by default. |
|
|
138
|
+
| `env_permitidas` | Extra environment variables passed to scripts. |
|
|
139
|
+
| `scripts.<name>.cmd` | Fixed command as a list; `python3` is replaced by the server's interpreter. |
|
|
140
|
+
| `descripcion`, `uso` | Shown by `correr()` listings. |
|
|
141
|
+
| `rechaza` | Flags that would write files; always refused. |
|
|
142
|
+
| `escribe` | Arguments that write data; only via `correr_escritura` with `WEBSCOPE_ESCRITURA=1`. |
|
|
143
|
+
| `acepta_barra` | Allow arguments starting with `/` (URL paths). |
|
|
144
|
+
| `timeout`, `cabeza`, `cola` | Per-script overrides. |
|
|
145
|
+
|
|
146
|
+
## Included scripts
|
|
147
|
+
|
|
148
|
+
The package ships ready-made scripts in `webscope_adaptadores/`. A manifest names them with `{adaptadores}` in `cmd`
|
|
149
|
+
(see [`webscope.example.json`](webscope.example.json)); copy the entries you need and adjust the folders.
|
|
150
|
+
|
|
151
|
+
| Script | What it answers | Stack |
|
|
152
|
+
| :--- | :--- | :--- |
|
|
153
|
+
| `vue_ts.py` (`mapa`) | Routes, views, API calls, composables and stores of a frontend module; `--recorrido` lists what can be clicked (actions with their `v-if` and permission, links, tabs with their API, form fields); `--cobertura` says which calls an e2e capture exercised | Vue 3 + TS |
|
|
154
|
+
| `laravel.py` (`mapa_back`) | Endpoint, controller, service, model and table behind a frontend module or API path | Laravel |
|
|
155
|
+
| `e2e_chrome.py` | JavaScript for **one** `javascript_tool` call of Claude in Chrome: walks routes and returns per route time, rows, failed requests, console errors and UI problems, without screenshots. `--llamar` reuses the walk already installed in the page; `--captura`/`--detalle` record what the page sends and receives; `--form` installs `__f` to fill a form by label, press Save and read it back, plus screen helpers that replace screenshots and coordinate clicks: `estado()` (the screen as short text), `clic(text)` (click by visible text, then wait until the page settles), `esperar`, `ir(route)` (router navigation without reload) and `pasos([...])` (several steps in one call). With a local receiver: `--servir [port folder]` starts it, `--cargar [port]` installs capture and `__f` from it, `--guardar <name> [port]` sends the whole capture to `<folder>/<name>.txt` (with one `ms <ms> <method> <status> <path>` line per API call; the receiver answers with the 5 slowest) and the steps `__f` recorded to `.claude/webscope/recorridos/<name>.json`, and `--repetir <name>` replays those steps in one call (with a fresh stamp on `ZZ_E2E` values) while the frontend does not change; `--exportar <capture> <url>` writes a Playwright test with the same steps | Any SPA with a router |
|
|
156
|
+
| `payload_check.py` | Checks a capture against the FormRequest rules, the Resource and the table columns; flags keys without a rule, values outside `in:`, `''` sent to a nullable column | Laravel |
|
|
157
|
+
| `limpiar_zz.py` | Lists, and with `--borrar` deletes after a backup, test rows whose code starts with `ZZ_` | Laravel |
|
|
158
|
+
| `costo_sesion.py` | Cost of the latest (or a given) Claude Code session in the project: USD per model at list prices (subagents and advisor included), calls and re-read context per tool, large outputs, repeated calls, and which tool loops would have been cheaper in a subagent | Claude Code |
|
|
159
|
+
| `uso_agentes.py` | Subagent usage: turns, context, models, agents over the turn limit | Claude Code |
|
|
160
|
+
| `uso_webscope.py` | Share of file reads and searches that went through `leer` and `buscar` in sessions where Webscope was available, and the `Read`/`sed`/`grep` calls used instead | Claude Code |
|
|
161
|
+
|
|
162
|
+
Project-specific settings go in the manifest's `proyecto` section:
|
|
163
|
+
|
|
164
|
+
| Key | Used by | Meaning |
|
|
165
|
+
| :--- | :--- | :--- |
|
|
166
|
+
| `modulos` | `payload_check` | Backend modules folder (default `app/Modules`) |
|
|
167
|
+
| `prefijo_rutas` | `e2e_chrome` | Prefix the app lives under, e.g. `/demo`, so routes work with or without it |
|
|
168
|
+
| `contenedor` | `mapa_back` | Docker container where `php artisan route:list --json` runs; empty reads the route files |
|
|
169
|
+
| `sql` | `payload_check`, `limpiar_zz` | Script that runs a read-only SQL query and prints TSV with a header |
|
|
170
|
+
| `tinker` | `limpiar_zz` | Script that runs a PHP file against the database (`<script> <file.php> --commit`) |
|
|
171
|
+
| `php_db` | `limpiar_zz` | PHP expression for the connection (default `DB::connection()`) |
|
|
172
|
+
| `capturas` | MCP resources | Captures folder (the one given to `--servir`); its `.txt` files are listed as `webscope://capturas/<file>` next to the saved walks `webscope://recorridos/<file>` |
|
|
173
|
+
|
|
174
|
+
The Vue index cache and `limpiar_zz` backups go to `~/.cache/webscope/`. The `e2e_chrome` receiver writes captures to the
|
|
175
|
+
folder given to `--servir` (default `capturas`, relative to where it starts). `--servir` does not return: start it from a
|
|
176
|
+
shell in the background (the example manifest lists it in `rechaza`, so `correr` refuses it).
|
|
177
|
+
|
|
178
|
+
### Using the scripts from a subagent
|
|
179
|
+
|
|
180
|
+
A subagent sees Webscope only if its definition lists the tools, for example in Claude Code's `.claude/agents/<name>.md`:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
tools: Read, Grep, Glob, Bash, mcp__webscope__buscar, mcp__webscope__leer, mcp__webscope__correr
|
|
184
|
+
model: sonnet
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Add `mcp__webscope__correr_escritura` only if the agent must run write arguments, plus the browser tools it needs.
|
|
188
|
+
[`agents/recorrido.example.md`](agents/recorrido.example.md) is a ready-made agent that walks one frontend module in
|
|
189
|
+
Chrome with `mapa`, `e2e_chrome` and `limpiar_zz` and returns a summary of at most 25 lines: copy it to
|
|
190
|
+
`.claude/agents/recorrido.md` and replace `<webscope>` (the clone's folder; the receiver runs from the clone, not from
|
|
191
|
+
`uvx`) and the captures folder.
|
|
192
|
+
|
|
193
|
+
One measurement (a browser walk of one module, one project, one run per condition) found:
|
|
194
|
+
|
|
195
|
+
- With the walk in the main session, Webscope did not change the cost (+7 % and −5 % in two runs). It changed accuracy:
|
|
196
|
+
the order of steps (`mapa --recorrido`), the fields that were not saved (`__f.verificar`) and the measured coverage.
|
|
197
|
+
- The same walk in a sonnet subagent that returns a summary of at most 25 lines cost 1.05 USD instead of 6.40, with the
|
|
198
|
+
same coverage. About 2× came from isolating the context; the rest from the sonnet price.
|
|
199
|
+
- Page reads and screenshots were about 80 % of the re-read context, so the agent reads state with `javascript_tool` and
|
|
200
|
+
takes screenshots only to understand an error.
|
|
201
|
+
- Subagents that ran for more than about 40 turns grew their context like a main session. Give one module or one bounded
|
|
202
|
+
task per agent, and tell it to stop at about 40 tool calls and return what is done and what is pending.
|
|
203
|
+
|
|
204
|
+
`costo_sesion.py --raiz <project>` and `uso_agentes.py --raiz <project>` measure the same in another project. The USD
|
|
205
|
+
come from the price table at the top of `costo_sesion.py` (list prices, October 2026), which only lists models checked
|
|
206
|
+
against the cost reported by `claude -p` (Opus 5.5, Sonnet 5.5, Fable 5.1: within 0.01 USD on three runs); any other
|
|
207
|
+
model is shown as `sin precio` until it is added.
|
|
208
|
+
|
|
209
|
+
## Hook: suggest Webscope
|
|
210
|
+
|
|
211
|
+
Agents often keep using `Read`, `cat`, `sed -n` and `grep` even when Webscope is available. `hooks/sugerir_webscope.py`
|
|
212
|
+
is a Claude Code `PostToolUse` hook that, after such a call, adds one line to the model's context with the equivalent
|
|
213
|
+
Webscope call (for example `leer(archivo, lineas="120-180")`). It never blocks and speaks at most 3 times per session.
|
|
214
|
+
Add it to the project's `.claude/settings.json` or `.claude/settings.local.json`:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{"hooks": {"PostToolUse": [{"matcher": "Read|Bash", "hooks": [{"type": "command",
|
|
218
|
+
"command": "python3 -I /path/to/webscope/hooks/sugerir_webscope.py"}]}]}}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`uso_webscope.py` measures whether it works: the share of reads and searches that go through Webscope.
|
|
222
|
+
|
|
223
|
+
## Browser UI check
|
|
224
|
+
|
|
225
|
+
`webscope_adaptadores/web/chequeo-ui.js` is a standalone snippet for a browser automation tool (`page.evaluate` in Playwright, or a
|
|
226
|
+
JavaScript tool in a browser extension). It returns only the problems on the current page: horizontal overflow, empty
|
|
227
|
+
chips or badges, untranslated i18n keys, raw values (`undefined`, `NaN`…), overlapping text and unnamed buttons.
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
PLAYWRIGHT_DIR=<folder containing node_modules/playwright> node webscope_adaptadores/web/test_chequeo_ui.mjs
|
|
231
|
+
PLAYWRIGHT_DIR=<folder containing node_modules/playwright> PW_CHANNEL=chrome node webscope_adaptadores/web/test_form.mjs
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`PW_CHANNEL=chrome` uses the installed Chrome when Playwright's own browser is missing.
|
|
235
|
+
|
|
236
|
+
## Tests
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
python3 -I test_webscope.py
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Out of scope
|
|
243
|
+
|
|
244
|
+
Webscope does not replace a language server or a browser automation server, does not run code written by the agent, and
|
|
245
|
+
is not a test framework.
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
webscope.py,sha256=NkCblcsmVGPD_GjAvp3uwygbTDf41RQnGuEq6jqf_p4,40050
|
|
2
|
+
webscope_adaptadores/__init__.py,sha256=dcymvVh5mx0vEXA9CK7JNlmqE8ffTLB1YPx1Rv4RP80,105
|
|
3
|
+
webscope_adaptadores/costo_sesion.py,sha256=8E7R5sHZgqjbLcI4eCnaevwg7pyYQez0W-MVM7wPJP0,9667
|
|
4
|
+
webscope_adaptadores/e2e_chrome.py,sha256=CkWzwwSWQHTWQNFhmvXBKRfvm4Q1owTdsgqiLpI_API,15522
|
|
5
|
+
webscope_adaptadores/laravel.py,sha256=yrxEyq5k6YaokHCK_bLsSMkH8LZ6aCYjDNPlxaI54Tw,12034
|
|
6
|
+
webscope_adaptadores/limpiar_zz.py,sha256=RceZdwRHd99mX2b8CUu_1qal8_XAHi2sDJYWVBL-dyc,8444
|
|
7
|
+
webscope_adaptadores/payload_check.py,sha256=HgLRvjP3CIn1r5QM5_stI183YMHFCboJ3ys3DHsFb28,14346
|
|
8
|
+
webscope_adaptadores/proyecto.py,sha256=9i0K0493DvQjafYREoD2H1AtzCZOYxZjj0AveAHX5lI,2782
|
|
9
|
+
webscope_adaptadores/uso_agentes.py,sha256=TSREIoPu-BGF9GVN73Nzt-clh8wDuuM8mgcwXCjP-lk,3096
|
|
10
|
+
webscope_adaptadores/uso_webscope.py,sha256=yoM3gGZtSYGa_O416g_t8TGJ5TIaNSExCPKKNiUY57U,4009
|
|
11
|
+
webscope_adaptadores/vue_ts.py,sha256=21T7LJey0HyAAWZO8ZKNIDrhkW0dGqYBVa38f9Yqw9Q,11575
|
|
12
|
+
webscope_adaptadores/web/chequeo-ui.js,sha256=NgZEywYZYa8laKcOcaY7p-3TyXhl7iv6P72-M8ZPN9k,2664
|
|
13
|
+
webscope_adaptadores/web/e2e-rutas.js,sha256=G_ukkmukHXzDnKH-FI7ZnvHphMpItCSLUQ-ofkMFsyE,6669
|
|
14
|
+
webscope_adaptadores/web/form.js,sha256=3xUmVgECLbo2X0LK-HkjZzOSCUFre3-MWZFoQVlIO00,22688
|
|
15
|
+
webscope_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=x0GFTawl3jC5g4RFDzTMMbpvKaf4-wezJjXHuSSmKp8,1074
|
|
16
|
+
webscope_mcp-0.2.0.dist-info/METADATA,sha256=SnMQTqiPGi49ZGBZ68TDTqya9HO8SIdhAxwh3aohMEg,15339
|
|
17
|
+
webscope_mcp-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
webscope_mcp-0.2.0.dist-info/entry_points.txt,sha256=-F-tg_FEBd3xWopfdBKymlmHl-hoBzaYYwSgSiif0mY,47
|
|
19
|
+
webscope_mcp-0.2.0.dist-info/top_level.txt,sha256=Awg4e86U58NouqwsP1cMtKmyi0rNVEdZZEN4Ka8Sico,30
|
|
20
|
+
webscope_mcp-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 partprogramming09
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|