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.
@@ -0,0 +1 @@
1
+ """Scripts incluidos con Webscope; un manifiesto los nombra con `{adaptadores}/<script>.py` en `cmd`."""
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """Costo de una sesión de Claude Code: llamadas por herramienta, contexto releído, salidas grandes, repeticiones y si convenía delegar.
3
+
4
+ Uso: python3 -I costo_sesion.py [<sesión.jsonl>] [--desde HH:MM] [--raiz <carpeta del proyecto>]
5
+ Sin archivo toma la sesión más reciente del proyecto (`~/.claude/projects/<raíz con / y espacios como ->`).
6
+ El contexto releído de una llamada es entrada + caché creado + caché leído del mensaje que la pidió: es lo que se
7
+ paga en cada vuelta. Un subagente arranca con ~30 k de contexto; con eso se estima cuánto costaría delegar.
8
+ """
9
+ import json
10
+ import re
11
+ import sys
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+
15
+ ARRANQUE_SUBAGENTE = 30_000
16
+ # USD por millón de tokens (entrada, salida, caché leído), lista de oct 2026; escribir en caché: 1,25× (5 min) o 2× (1 h)
17
+ PRECIOS = {
18
+ "claude-fable-5-1": (10, 50, 0.25),
19
+ "claude-opus-5-5": (4, 20, 0.20),
20
+ "claude-sonnet-5-5": (2, 10, 0.10),
21
+ # shortcut: precio de haiku para prompts de hasta 100 k; por encima cobra 0,50/2,50/0,05, separar si un subagente haiku pasa de 100 k
22
+ "claude-haiku-5-5": (0.10, 0.50, 0.01),
23
+ }
24
+
25
+
26
+ def sesion(argv):
27
+ """Ruta del .jsonl pedido o el más reciente del proyecto."""
28
+ archivo = next((a for a in argv if a.endswith(".jsonl")), None)
29
+ if archivo:
30
+ return Path(archivo)
31
+ raiz = argv[argv.index("--raiz") + 1] if "--raiz" in argv else str(Path.cwd())
32
+ carpeta = Path.home() / ".claude/projects" / re.sub(r"[^A-Za-z0-9]", "-", raiz)
33
+ sesiones = list(carpeta.glob("*.jsonl"))
34
+ if not sesiones:
35
+ sys.exit(f"sin sesiones de Claude Code para {raiz} (se buscan en {carpeta}); usa --raiz <carpeta del proyecto>")
36
+ return max(sesiones, key=lambda f: f.stat().st_mtime)
37
+
38
+
39
+ def subagentes(archivo):
40
+ """Transcripts de los subagentes de la sesión (`<sesión>/subagents/agent-*.jsonl`)."""
41
+ return sorted((archivo.parent / archivo.stem / "subagents").glob("agent-*.jsonl"))
42
+
43
+
44
+ def analizar(lineas, desde=None, lateral=False):
45
+ """Devuelve llamadas [(herramienta, contexto, entrada, tamaño de salida)] y el contexto total releído; lateral lee un subagente."""
46
+ nombres, llamadas, vistos, total = {}, {}, set(), 0
47
+ for linea in lineas:
48
+ d = json.loads(linea)
49
+ if (d.get("isSidechain") and not lateral) or (desde and d.get("timestamp", "")[11:16] < desde):
50
+ continue
51
+ m = d.get("message") or {}
52
+ if d.get("type") == "assistant":
53
+ u = m.get("usage") or {}
54
+ ctx = u.get("input_tokens", 0) + u.get("cache_creation_input_tokens", 0) + u.get("cache_read_input_tokens", 0)
55
+ if m.get("id") not in vistos:
56
+ vistos.add(m.get("id"))
57
+ total += ctx
58
+ for c in m.get("content", []):
59
+ if c.get("type") == "tool_use":
60
+ nombres[c["id"]] = c["name"]
61
+ llamadas[c["id"]] = [c["name"], ctx, json.dumps(c.get("input"), sort_keys=True, ensure_ascii=False), 0]
62
+ elif d.get("type") == "user" and isinstance(m.get("content"), list):
63
+ for c in m["content"]:
64
+ if c.get("type") == "tool_result" and c.get("tool_use_id") in llamadas:
65
+ llamadas[c["tool_use_id"]][3] = len(json.dumps(c.get("content"), ensure_ascii=False))
66
+ return list(llamadas.values()), total
67
+
68
+
69
+ def tipos(lineas, lateral=False):
70
+ """Tokens por modelo {modelo: [entrada, escrito 5 min, escrito 1 h, leído, salida]}; el escrito sin desglose cuenta como 5 min.
71
+
72
+ Un mensaje ocupa varias líneas; cuenta el uso de la última, que trae la salida completa.
73
+ """
74
+ por, ultimo = defaultdict(lambda: [0, 0, 0, 0, 0]), {}
75
+ for linea in lineas:
76
+ d = json.loads(linea)
77
+ m = d.get("message") or {}
78
+ if d.get("type") == "assistant" and not (d.get("isSidechain") and not lateral):
79
+ ultimo[m.get("id")] = m
80
+ for m in ultimo.values():
81
+ u, t = m.get("usage") or {}, por[m.get("model", "?")]
82
+ hora = (u.get("cache_creation") or {}).get("ephemeral_1h_input_tokens", 0)
83
+ t[0] += u.get("input_tokens", 0)
84
+ t[1] += u.get("cache_creation_input_tokens", 0) - hora
85
+ t[2] += hora
86
+ t[3] += u.get("cache_read_input_tokens", 0)
87
+ t[4] += u.get("output_tokens", 0)
88
+ return por
89
+
90
+
91
+ def advisor(lineas):
92
+ """Llamadas al advisor de la sesión: [llamadas, entrada, salida, modelos], de las iteraciones `advisor_message` del uso."""
93
+ t, vistos = [0, 0, 0, set()], set()
94
+ for linea in lineas:
95
+ d = json.loads(linea)
96
+ m = d.get("message") or {}
97
+ if d.get("type") != "assistant" or m.get("id") in vistos:
98
+ continue
99
+ vistos.add(m.get("id"))
100
+ for it in (m.get("usage") or {}).get("iterations") or []:
101
+ if it.get("type") == "advisor_message":
102
+ t[0] += 1
103
+ t[1] += it.get("input_tokens", 0) + it.get("cache_creation_input_tokens", 0) + it.get("cache_read_input_tokens", 0)
104
+ t[2] += it.get("output_tokens", 0)
105
+ t[3].add(it.get("model", "?"))
106
+ return t
107
+
108
+
109
+ def peso(t):
110
+ """Tokens de entrada equivalentes según la tarifa: escrito ×1,25 (5 min) o ×2 (1 h), leído ×0,1, salida ×5."""
111
+ return int(t[0] + t[1] * 1.25 + t[2] * 2 + t[3] * 0.1 + t[4] * 5)
112
+
113
+
114
+ def usd(modelo, t):
115
+ """USD de los tokens t = [entrada, escrito 5 min, escrito 1 h, leído, salida] con PRECIOS; None si el modelo no está."""
116
+ p = PRECIOS.get(modelo.removesuffix(" (subagente)"))
117
+ if not p:
118
+ return None
119
+ return (t[0] * p[0] + t[1] * p[0] * 1.25 + t[2] * p[0] * 2 + t[3] * p[2] + t[4] * p[1]) / 1_000_000
120
+
121
+
122
+ def informe_tipos(por, extra=0.0):
123
+ """Una línea por modelo con lo escrito, lo leído, el peso y los USD, más el total en USD (con `extra`, el advisor)."""
124
+ lineas, total = [], extra
125
+ for m, t in sorted(por.items()):
126
+ dinero = usd(m, t)
127
+ total += dinero or 0
128
+ lineas.append(f"modelo {m}: escrito {(t[1] + t[2]) // 1000} k (1 h: {t[2] // 1000} k), leído {t[3] // 1000} k, "
129
+ f"salida {t[4] // 1000} k; peso {peso(t) // 1000} k de entrada equivalente; "
130
+ + (f"{dinero:.2f} USD" if dinero is not None else "sin precio"))
131
+ return lineas + [f"total {total:.2f} USD (precios de lista, con el advisor; sin lo que sale «sin precio»)"]
132
+
133
+
134
+ def informe(llamadas, total, sub=(0, 0, 0)):
135
+ """Texto con el resumen por herramienta, las salidas grandes, las repeticiones y los candidatos a subagente.
136
+
137
+ sub = (subagentes, llamadas, contexto releído) de los subagentes de la sesión.
138
+ """
139
+ por = defaultdict(lambda: [0, 0, 0])
140
+ for nombre, ctx, _, tam in llamadas:
141
+ por[nombre][0] += 1
142
+ por[nombre][1] += ctx
143
+ por[nombre][2] += tam
144
+ salida = [f"Costo de la sesión: {len(llamadas)} llamadas, {total // 1000} k tokens de contexto releído en total. "
145
+ "Sigue: pasar a script lo repetido y delegar lo marcado como subagente."]
146
+ if sub[0]:
147
+ salida.append(f"subagentes: {sub[0]}, {sub[1]} llamadas, {sub[2] // 1000} k releídos; "
148
+ f"sesión + subagentes: {(total + sub[2]) // 1000} k")
149
+ for nombre, (n, ctx, tam) in sorted(por.items(), key=lambda x: -x[1][1])[:12]:
150
+ salida.append(f"herramienta {nombre}: {n} llamadas, {ctx // 1000} k releídos (media {ctx // n // 1000} k), salida {tam // 1000} k caracteres")
151
+ for nombre, ctx, entrada, tam in sorted(llamadas, key=lambda x: -x[3])[:5]:
152
+ salida.append(f"salida grande {tam // 1000} k caracteres: {nombre} {entrada[:90]}")
153
+ for (nombre, entrada), n in Counter((x[0], x[2]) for x in llamadas).most_common(5):
154
+ if n > 1:
155
+ salida.append(f"repetida {n} veces: {nombre} {entrada[:90]}")
156
+ for nombre, (n, ctx, _) in por.items():
157
+ if nombre in ("Edit", "Write") or n < 10:
158
+ continue
159
+ # el subagente arranca en ARRANQUE_SUBAGENTE y suma cada salida (≈4 caracteres por token) y ~500 tokens por vuelta
160
+ acumulado, estimado = ARRANQUE_SUBAGENTE, 0
161
+ for x in (x for x in llamadas if x[0] == nombre):
162
+ estimado += acumulado
163
+ acumulado += x[3] // 4 + 500
164
+ if estimado * 2 < ctx:
165
+ salida.append(f"subagente conviene para {nombre}: {ctx // 1000} k aquí contra ~{estimado // 1000} k en un subagente "
166
+ f"({n} llamadas), si vuelve solo la conclusión")
167
+ return "\n".join(salida)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ argv = sys.argv[1:]
172
+ desde = argv[argv.index("--desde") + 1] if "--desde" in argv else None
173
+ archivo = sesion(argv)
174
+ laterales = [analizar(f.read_text().splitlines(), desde, lateral=True) for f in subagentes(archivo)]
175
+ sub = (len(laterales), sum(len(l) for l, _ in laterales), sum(t for _, t in laterales))
176
+ print(informe(*analizar(archivo.read_text().splitlines(), desde), sub))
177
+ por = tipos(archivo.read_text().splitlines())
178
+ for f in subagentes(archivo):
179
+ for m, t in tipos(f.read_text().splitlines(), lateral=True).items():
180
+ por[f"{m} (subagente)"] = [a + b for a, b in zip(por[f"{m} (subagente)"], t)]
181
+ a = advisor(archivo.read_text().splitlines())
182
+ dinero = usd(next(iter(a[3])), [a[1], 0, 0, 0, a[2]]) if len(a[3]) == 1 else None
183
+ print("\n".join(informe_tipos(por, dinero or 0.0)))
184
+ if a[0]:
185
+ print(f"advisor: {a[0]} llamadas, entrada {a[1] // 1000} k (sin caché), salida {a[2] // 1000} k; "
186
+ f"peso {(a[1] + a[2] * 5) // 1000} k de entrada equivalente; modelo {','.join(sorted(a[3]))}"
187
+ + (f"; {dinero:.2f} USD" if dinero is not None else "; sin precio"))
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """Script listo para pegar en `javascript_tool` de Claude in Chrome: recorre rutas del frontend en una sola llamada.
3
+
4
+ Uso: python3 -I e2e_chrome.py <ruta> [<ruta> ...]
5
+ Cada ruta es la del router (`/crm/quotes`); si la app vive bajo un prefijo (`proyecto.prefijo_rutas` del manifiesto, p. ej.
6
+ `/demo`), se acepta con o sin él delante. Las rutas con `:param` se omiten.
7
+ Imprime una línea de instrucciones y el JavaScript; la pestaña debe tener la app abierta y con sesión iniciada.
8
+ `--captura` solo instala el registro de peticiones a /api/ (método, cuerpo y forma de la respuesta, sin claves secretas);
9
+ tras hacer un flujo en la pantalla, `--detalle [desde]` imprime el JavaScript corto que lista lo capturado.
10
+ La primera llamada deja el recorrido instalado en la página como `window.__e2e.run`; `--llamar <ruta> ...` imprime la
11
+ llamada corta que lo reutiliza (si la página se recargó y el recorrido ya no está, la respuesta lo dice).
12
+ `--servir [puerto carpeta]` arranca el receptor local (no termina); `--cargar [puerto]` da la línea que instala captura y
13
+ formularios desde él, y `--guardar <nombre> [puerto]` la que le manda la captura entera para escribirla a archivo.
14
+ `--exportar <captura> <url del módulo> [salida.spec.js]` escribe un test de Playwright que repite sin modelo los pasos que
15
+ `__f` anotó en la captura.
16
+ Al guardar, el receptor deja también los pasos en `.claude/webscope/recorridos/<nombre>.json` (desde la raíz del proyecto);
17
+ `--repetir <nombre>` da la línea que los repite en una sola llamada mientras el front no cambie.
18
+ """
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+ from urllib.parse import urlparse
25
+
26
+ WEB = Path(__file__).resolve().parent / "web"
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+ import proyecto # noqa: E402
29
+
30
+
31
+ def validas(rutas):
32
+ """Rutas del router sin `:param` y sin el prefijo de la app (`proyecto.prefijo_rutas`)."""
33
+ prefijo = proyecto.config()["prefijo_rutas"].strip("/")
34
+ return ["/" + (r.strip("/").removeprefix(prefijo + "/") if prefijo else r.strip("/")) for r in rutas if ":" not in r]
35
+
36
+
37
+ LINEAS = (
38
+ "const t=v=>v===null?'null':Array.isArray(v)?'array'+v.length:"
39
+ "v&&typeof v.n==='number'&&Object.keys(v).every(k=>k==='n'||k==='claves')?'array'+v.n+(v.claves?'{'+v.claves.join(',')+'}':''):"
40
+ "typeof v==='object'?'obj{'+Object.keys(v).join(',')+'}':typeof v+'('+String(v).slice(0,60)+')';"
41
+ "window.__e2e.detalle.slice({desde}).map(d=>[d.m,d.s,d.u.replace(/^.*\\/api\\//,''),'q',d.q&&typeof d.q==='object'?Object.entries(d.q).map(([k,v])=>k+':'+t(v)).join(' '):'-',"
42
+ "'r',d.r&&typeof d.r==='object'?Object.entries(d.r).map(([k,v])=>k+':'+t(v)).join(' '):'-'].concat(d.f?['f',JSON.stringify(d.f)]:[]).join(' ')).join('\\n')"
43
+ )
44
+
45
+
46
+ def formulario():
47
+ """JavaScript que instala `window.__f` (llenar por etiqueta y leer de vuelta, pulsar con foto del formulario)."""
48
+ js = (WEB / "form.js").read_text()
49
+ return js[js.index("(() =>"):].strip()
50
+
51
+
52
+ def detalle(desde=0):
53
+ """JavaScript corto que lista los intercambios capturados desde el índice `desde`, una línea cada uno, sin vaciar el registro."""
54
+ return LINEAS.replace("{desde}", str(int(desde)))
55
+
56
+
57
+ def llamar(rutas):
58
+ """Llamada corta a `window.__e2e.run` con las rutas válidas (sin `:param`); avisa si el recorrido no está instalado."""
59
+ return f"window.__e2e&&window.__e2e.run?await window.__e2e.run({json.dumps(validas(rutas))}):'sin recorrido instalado: pega el script completo'"
60
+
61
+
62
+ def cargar(puerto=5199):
63
+ """JavaScript corto que carga la captura y `window.__f` desde el receptor local, sin pegar los scripts enteros."""
64
+ return (f"const carga=s=>new Promise((ok,no)=>{{const e=document.createElement('script');e.src='http://127.0.0.1:{int(puerto)}/'+s+'?'+Date.now();"
65
+ "e.onload=ok;e.onerror=no;document.head.appendChild(e);});"
66
+ "await carga('c.js');await new Promise(r=>setTimeout(r,800));await carga('f.js');({captura:!!window.__e2e?.detalle,form:typeof window.__f})")
67
+
68
+
69
+ def guardar(nombre, puerto=5199):
70
+ """JavaScript corto que manda todo lo capturado, más una línea `consola …` por error de consola, al receptor, que lo escribe en `<carpeta>/<nombre>.txt`."""
71
+ return (f"await (await fetch('http://127.0.0.1:{int(puerto)}/guardar?nombre={nombre}',{{method:'POST',body:(()=>{{{detalle(0).replace(';window', ';return window', 1)}}})()"
72
+ "+(window.__e2e.consola||[]).map(c=>'\\nconsola '+c).join('')"
73
+ "+(window.__e2e.pasos||[]).map(p=>'\\npaso '+JSON.stringify(p)).join('')"
74
+ "+window.__e2e.detalle.filter(d=>d.ms!=null).map(d=>'\\nms '+d.ms+' '+d.m+' '+d.s+' '+d.u.replace(/^.*\\/api\\//,'')).join('')})).text()")
75
+
76
+
77
+ RECORRIDOS = Path(".claude/webscope/recorridos")
78
+
79
+
80
+ def pasos_de(texto):
81
+ """Pasos anotados por `__f` (líneas `paso [...]`) de una captura."""
82
+ return [json.loads(l[5:]) for l in texto.splitlines() if l.startswith("paso ")]
83
+
84
+
85
+ def lentas(texto, n=5):
86
+ """Las `n` llamadas más lentas de una captura (líneas `ms <ms> <método> <estado> <ruta>`), de la más lenta a la menos."""
87
+ tiempos = [l.split(" ", 4)[1:] for l in texto.splitlines() if re.match(r"ms \d+ ", l)]
88
+ return [f"{ms} ms {m} {s} {u}" for ms, m, s, u in sorted(tiempos, key=lambda t: -int(t[0]))[:n]]
89
+
90
+
91
+ def repetir(nombre):
92
+ """JavaScript que repite en una llamada los pasos guardados de `nombre`, con sello por corrida en los valores ZZ_E2E…."""
93
+ if not re.fullmatch(r"[\w.-]+", nombre):
94
+ raise ValueError("nombre inválido")
95
+ pasos = json.loads((RECORRIDOS / f"{nombre}.json").read_text())
96
+ return f"await __f.pasos({json.dumps(pasos, ensure_ascii=False)}, {{ sello: true }})"
97
+
98
+
99
+ PRUEBA = """// Test generado por webscope desde {captura}: repite el recorrido sin modelo.
100
+ // Uso: BASE=<url de la app> [STORAGE=<sesión guardada con `npx playwright codegen --save-storage`>] npx playwright test {archivo}
101
+ const {{ test, expect }} = require('@playwright/test');
102
+ const PASOS = {pasos};
103
+ test.use({{ storageState: process.env.STORAGE || undefined }});
104
+ // Sello por corrida en los valores ZZ_E2E…, para no chocar con filas (p. ej. borradas lógicamente) de corridas anteriores.
105
+ const S = Date.now().toString(36).toUpperCase();
106
+ const sellar = (v, fila) => typeof v === 'string' ? (/^ZZ_E2E/.test(v) ? v.replace(/_(?=[A-Z]*\\d)[0-9A-Z]{{8}}$/, '') + (fila ? `.*_${{S}}` : `_${{S}}`) : v)
107
+ : v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, sellar(x)])) : v;
108
+ test('recorrido {nombre}', async ({{ page }}) => {{
109
+ await page.addInitScript({{ path: require('path').join(__dirname, '{ayudantes}') }});
110
+ await page.goto((process.env.BASE || '') + {ruta});
111
+ await page.waitForLoadState('networkidle');
112
+ expect(new URL(page.url()).pathname, 'la app redirigió: guarda la sesión y pásala en STORAGE').toBe({ruta}.split('?')[0]);
113
+ for (const [m, ...a] of PASOS) {{
114
+ const paso = [m, ...a.map((x, i) => sellar(x, m === 'fila' && i === 0))];
115
+ const r = await page.evaluate(async ([m, ...a]) => {{
116
+ if (m === 'fila') a[0] = new RegExp(a[0]);
117
+ if (m === 'elegir') m = 'poner';
118
+ if (m === 'pulsar') a = a.slice(0, 1);
119
+ return window.__f[m](...a);
120
+ }}, paso);
121
+ const mal = (r && r.error) || (Array.isArray(r) && r.length && r) || (typeof r === 'string' && /^(❌|sin |diálogo)/.test(r) && r);
122
+ expect(mal, JSON.stringify(paso)).toBeFalsy();
123
+ if (paso[0] === 'pulsar') for (const e of paso[2]) expect((r.red || []).map((x) => x.replace(/ \\d+ /, ' ').replace(/\\/\\d+(?=\\/|$)/g, '/{{}}')), `${{e}} tras ${{paso[1]}}`).toContain(e);
124
+ }}
125
+ const malas = await page.evaluate(() => window.__e2e.detalle.filter((d) => d.s >= 400 || d.s === 'ERR').map((d) => `${{d.m}} ${{d.s}} ${{d.u}}`));
126
+ expect(malas).toEqual([]);
127
+ expect(await page.evaluate(() => window.__e2e.consola)).toEqual([]);
128
+ }});
129
+ """
130
+
131
+
132
+ def exportar(captura, url, salida=None):
133
+ """Escribe un test de Playwright que repite los pasos anotados en la captura, más el JavaScript de captura y `__f` que inyecta.
134
+
135
+ Devuelve las rutas escritas. Los clics en iconos sin texto (`mdi-*`) se repiten por selector."""
136
+ captura = Path(captura)
137
+ pasos = pasos_de(captura.read_text(errors="ignore"))
138
+ for p in pasos:
139
+ if p[0] == "clic" and str(p[1]).startswith("mdi-"):
140
+ p[1] = "." + p[1]
141
+ if not pasos:
142
+ raise ValueError(f"{captura} no tiene líneas `paso`: guárdala con un __f que anote pasos")
143
+ salida = Path(salida) if salida else captura.with_suffix(".spec.js")
144
+ nombre = salida.name.removesuffix(".spec.js")
145
+ ayudantes = salida.with_name(f"{nombre}.ayudantes.js")
146
+ ayudantes.write_text(construir(["--captura"])[0].removeprefix("await ") + ";\n" + formulario() + "\n")
147
+ u = urlparse(url)
148
+ ruta = (u.path or "/") + (f"?{u.query}" if u.query else "") if u.scheme else url
149
+ salida.write_text(PRUEBA.format(captura=captura.name, archivo=salida.name, pasos=json.dumps(pasos, ensure_ascii=False, indent=1),
150
+ nombre=nombre, ayudantes=ayudantes.name, ruta=json.dumps(ruta)))
151
+ return salida, ayudantes
152
+
153
+
154
+ def servir(puerto, carpeta):
155
+ """Receptor local: GET /c.js (captura) y /f.js (formularios) generados al vuelo; POST /guardar?nombre= escribe la captura."""
156
+ import http.server
157
+ from urllib.parse import parse_qs, urlparse
158
+
159
+ carpeta = Path(carpeta).resolve()
160
+ carpeta.mkdir(parents=True, exist_ok=True)
161
+
162
+ class Manejador(http.server.BaseHTTPRequestHandler):
163
+ """Sirve los ayudantes y guarda capturas; solo escucha en 127.0.0.1."""
164
+
165
+ def responder(self, codigo, cuerpo, tipo="text/plain"):
166
+ self.send_response(codigo)
167
+ self.send_header("Content-Type", tipo + "; charset=utf-8")
168
+ self.send_header("Access-Control-Allow-Origin", "*")
169
+ self.end_headers()
170
+ self.wfile.write(cuerpo.encode())
171
+
172
+ def do_GET(self):
173
+ ruta = urlparse(self.path).path
174
+ js = {"/c.js": lambda: construir(["--captura"])[0].removeprefix("await "), "/f.js": formulario}.get(ruta)
175
+ if ruta == "/":
176
+ return self.responder(200, "receptor e2e en marcha")
177
+ self.responder(200, js(), "application/javascript") if js else self.responder(404, "no existe")
178
+
179
+ def do_POST(self):
180
+ nombre = parse_qs(urlparse(self.path).query).get("nombre", [""])[0]
181
+ if not re.fullmatch(r"[\w.-]+", nombre):
182
+ return self.responder(400, "nombre inválido")
183
+ cuerpo = self.rfile.read(int(self.headers.get("Content-Length", 0)))
184
+ (carpeta / f"{nombre}.txt").write_bytes(cuerpo)
185
+ consola = cuerpo.count(b"\nconsola ")
186
+ ruta = os.path.relpath(carpeta / f"{nombre}.txt")
187
+ texto = cuerpo.decode(errors="ignore")
188
+ pasos, lento = pasos_de(texto), lentas(texto)
189
+ if pasos:
190
+ RECORRIDOS.mkdir(parents=True, exist_ok=True)
191
+ (RECORRIDOS / f"{nombre}.json").write_text(json.dumps(pasos, ensure_ascii=False, indent=1) + "\n")
192
+ self.responder(200, f"{len(cuerpo)} bytes; errores de consola: {consola}. Captura: {ruta} "
193
+ "(pásala tal cual a --exportar, mapa --cobertura y mapa_back --obligatorios)"
194
+ + (f". Recorrido: {RECORRIDOS / nombre}.json, {len(pasos)} pasos (--repetir {nombre})" if pasos else "")
195
+ + (f". Más lentas: {'; '.join(lento)}" if lento else ""))
196
+
197
+ def log_message(self, *a):
198
+ pass
199
+
200
+ print(f"receptor en http://127.0.0.1:{puerto} -> {carpeta}", flush=True)
201
+ http.server.ThreadingHTTPServer(("127.0.0.1", int(puerto)), Manejador).serve_forever()
202
+
203
+
204
+ def construir(rutas):
205
+ """JavaScript con RUTAS y el chequeo de UI antepuestos al cuerpo del recorrido, más las rutas omitidas."""
206
+ if rutas == ["--captura"]:
207
+ buenas, omitidas = [], []
208
+ else:
209
+ buenas, omitidas = validas(rutas), [r for r in rutas if ":" in r]
210
+ chequeo = (WEB / "chequeo-ui.js").read_text().rstrip().removesuffix("();")
211
+ chequeo = chequeo[chequeo.index("(() =>"):]
212
+ cuerpo = (WEB / "e2e-rutas.js").read_text()
213
+ cuerpo = cuerpo[cuerpo.index("(async"):].strip()
214
+ return f"await (async()=>{{const RUTAS={json.dumps(buenas)};\nconst chequeo={chequeo};\nreturn await {cuerpo};\n}})()", omitidas
215
+
216
+
217
+ if __name__ == "__main__":
218
+ if len(sys.argv) < 2:
219
+ sys.exit(__doc__)
220
+ if sys.argv[1] == "--detalle":
221
+ print("Pega el JavaScript en una llamada a javascript_tool; guarda la salida con Write en un archivo DENTRO del proyecto (p. ej. capturas/<flujo>.txt; correr rechaza rutas fuera de la raíz) y pásalo a payload_check. "
222
+ "Una línea por petición: método estado ruta q <campo:tipo(valor)> r <campo:tipo(valor)>.\n" + detalle(*sys.argv[2:3] or [0]))
223
+ sys.exit(0)
224
+ if sys.argv[1] == "--form":
225
+ print("Pega en javascript_tool tras --captura. Pantalla sin capturas: `__f.estado()`, `await __f.clic('Texto')`, "
226
+ "`await __f.ir('/ruta')`, `await __f.pasos([['clic','Nuevo'],['llenar',{...}],['pulsar','Guardar']])`. "
227
+ "Abre el formulario, `__f.abrir()`, `await __f.llenar({Etiqueta: valor})` "
228
+ "(devuelve solo lo que no quedó), `await __f.pulsar('Guardar')` (red, aviso y errores). --detalle trae la sección f.\n" + formulario())
229
+ sys.exit(0)
230
+ if sys.argv[1] == "--servir":
231
+ servir(*(sys.argv[2:4] if len(sys.argv) > 3 else [5199, "capturas"]))
232
+ if sys.argv[1] == "--cargar":
233
+ print("Pega en javascript_tool con el receptor en marcha (--servir); instala captura y formularios.\n" + cargar(*sys.argv[2:3]))
234
+ sys.exit(0)
235
+ if sys.argv[1] == "--guardar":
236
+ print("Pega en javascript_tool; el receptor escribe la captura completa y responde la ruta. Luego payload_check o vue_ts --cobertura.\n"
237
+ + guardar(*sys.argv[2:4]))
238
+ sys.exit(0)
239
+ if sys.argv[1] == "--llamar":
240
+ print("Pega esta línea en javascript_tool (sin capturas); lee el mismo JSON que el script completo.\n" + llamar(sys.argv[2:]))
241
+ sys.exit(0)
242
+ if sys.argv[1] == "--repetir":
243
+ try:
244
+ js = repetir(sys.argv[2])
245
+ except (OSError, ValueError, IndexError) as e:
246
+ sys.exit(f"ERR {e}: guarda antes la captura con --guardar <nombre> (el receptor escribe {RECORRIDOS}/<nombre>.json)")
247
+ print("Pega en javascript_tool tras --cargar, en la pantalla del módulo: repite los pasos en una llamada y para en el "
248
+ "primero que falla (si falla, el front cambió: sigue a mano desde ahí y vuelve a guardar).\n" + js)
249
+ sys.exit(0)
250
+ if sys.argv[1] == "--exportar":
251
+ try:
252
+ spec, js = exportar(*sys.argv[2:5])
253
+ except (OSError, ValueError, TypeError) as e:
254
+ sys.exit(f"ERR {e}")
255
+ print(f"Test escrito: {spec} (inyecta {js.name}). Córrelo con BASE=<url de la app> npx playwright test {spec.name}; no gasta modelo.")
256
+ sys.exit(0)
257
+ js, omitidas = construir(sys.argv[1:])
258
+ aviso = f" Omitidas por llevar :param: {', '.join(omitidas)}." if omitidas else ""
259
+ print(f"Pega el JavaScript en una sola llamada a javascript_tool (sin capturas) y lee el JSON: ruta, ms, filas, malas (red >=400), consola, ui. Si dice «navigated», repite la misma llamada: continúa donde quedó. Para repetir el recorrido sin volver a pegar esto usa `--llamar` (~100 tokens).{aviso}\n{js}")