vfa-mcp 1.0.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.
- app/__init__.py +1 -0
- app/adapters/__init__.py +1 -0
- app/adapters/browser/__init__.py +30 -0
- app/adapters/browser/capture.py +102 -0
- app/adapters/browser/network_guard.py +229 -0
- app/adapters/browser/playwright_adapter.py +329 -0
- app/adapters/browser/session_pool.py +242 -0
- app/adapters/browser/visual_primitives.py +89 -0
- app/adapters/llm/__init__.py +10 -0
- app/adapters/llm/agent_tools.py +118 -0
- app/adapters/llm/deep_agent.py +331 -0
- app/adapters/llm/litellm_adapter.py +345 -0
- app/adapters/llm/rate_limiter.py +60 -0
- app/adapters/llm/vision_grounding.py +134 -0
- app/adapters/llm/visual_fallback.py +171 -0
- app/adapters/llm/visual_step.py +59 -0
- app/config.py +265 -0
- app/domain/__init__.py +22 -0
- app/domain/models.py +95 -0
- app/domain/parser.py +230 -0
- app/domain/semantic.py +199 -0
- app/domain/vision.py +167 -0
- app/domain/visual_inference.py +172 -0
- app/domain/visual_parser.py +154 -0
- app/ports/__init__.py +11 -0
- app/ports/browser.py +62 -0
- app/ports/license.py +27 -0
- app/ports/llm.py +41 -0
- app/tools/__init__.py +25 -0
- app/tools/qa_tools.py +222 -0
- server_mcp.py +90 -0
- vfa_mcp-1.0.0.dist-info/METADATA +470 -0
- vfa_mcp-1.0.0.dist-info/RECORD +37 -0
- vfa_mcp-1.0.0.dist-info/WHEEL +5 -0
- vfa_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- vfa_mcp-1.0.0.dist-info/licenses/LICENSE +437 -0
- vfa_mcp-1.0.0.dist-info/top_level.txt +2 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agente Visual & Functional Auditor (VFA): modulos de configuracion, navegador, captura, vision, semantica y tools MCP."""
|
app/adapters/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adaptadores de infraestructura (navegador y LLM)."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Adaptadores de navegador basados en Playwright."""
|
|
2
|
+
|
|
3
|
+
from app.adapters.browser.capture import ErrorCapture, attach_capture
|
|
4
|
+
from app.adapters.browser.playwright_adapter import (
|
|
5
|
+
PlaywrightBrowserAdapter,
|
|
6
|
+
PlaywrightSession,
|
|
7
|
+
is_session_dead,
|
|
8
|
+
)
|
|
9
|
+
from app.adapters.browser.session_pool import (
|
|
10
|
+
SessionPool,
|
|
11
|
+
get_current_session_id,
|
|
12
|
+
get_session_pool,
|
|
13
|
+
new_session_id,
|
|
14
|
+
reset_current_session_id,
|
|
15
|
+
set_current_session_id,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ErrorCapture",
|
|
20
|
+
"PlaywrightBrowserAdapter",
|
|
21
|
+
"PlaywrightSession",
|
|
22
|
+
"SessionPool",
|
|
23
|
+
"attach_capture",
|
|
24
|
+
"get_current_session_id",
|
|
25
|
+
"get_session_pool",
|
|
26
|
+
"is_session_dead",
|
|
27
|
+
"new_session_id",
|
|
28
|
+
"reset_current_session_id",
|
|
29
|
+
"set_current_session_id",
|
|
30
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Capturador de errores de runtime: console.error, pageerror y HTTP >= 400."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, List
|
|
6
|
+
|
|
7
|
+
from app.domain.models import CONSOLE, HTTP, PAGE_ERROR, RuntimeErrorEvent
|
|
8
|
+
|
|
9
|
+
_MAX_EVENTS = 200
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ErrorCapture:
|
|
13
|
+
"""Acumula `RuntimeErrorEvent` de una pagina Playwright.
|
|
14
|
+
|
|
15
|
+
Es deliberadamente agnostico del driver: solo necesita objetos con
|
|
16
|
+
atributos `type`, `text` o `message`, lo que permite usar dobles en los
|
|
17
|
+
tests sin levantar un navegador real.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, max_events: int = _MAX_EVENTS) -> None:
|
|
21
|
+
self._events: List[RuntimeErrorEvent] = []
|
|
22
|
+
self._max_events = max_events
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------- registro
|
|
25
|
+
def _push(self, event: RuntimeErrorEvent) -> None:
|
|
26
|
+
if len(self._events) < self._max_events:
|
|
27
|
+
self._events.append(event)
|
|
28
|
+
|
|
29
|
+
def record_console(self, message: Any) -> None:
|
|
30
|
+
"""Registra un mensaje de consola solo si es un error."""
|
|
31
|
+
msg_type = str(getattr(message, "type", "") or "").lower()
|
|
32
|
+
if msg_type not in ("error", "warning", "assert"):
|
|
33
|
+
return
|
|
34
|
+
if msg_type != "error":
|
|
35
|
+
return
|
|
36
|
+
text = str(getattr(message, "text", "") or getattr(message, "message", ""))
|
|
37
|
+
location = getattr(message, "location", None) or {}
|
|
38
|
+
url = ""
|
|
39
|
+
if isinstance(location, dict):
|
|
40
|
+
url = str(location.get("url") or "")
|
|
41
|
+
self._push(RuntimeErrorEvent(kind=CONSOLE, message=text, url=url))
|
|
42
|
+
|
|
43
|
+
def record_page_error(self, error: Any) -> None:
|
|
44
|
+
"""Registra una excepcion JavaScript no capturada."""
|
|
45
|
+
text = str(getattr(error, "message", None) or error)
|
|
46
|
+
self._push(RuntimeErrorEvent(kind=PAGE_ERROR, message=text))
|
|
47
|
+
|
|
48
|
+
def record_response(self, response: Any) -> None:
|
|
49
|
+
"""Registra respuestas HTTP con status >= 400."""
|
|
50
|
+
status = getattr(response, "status", None)
|
|
51
|
+
try:
|
|
52
|
+
status_int = int(status)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return
|
|
55
|
+
if status_int < 400:
|
|
56
|
+
return
|
|
57
|
+
url = str(getattr(response, "url", "") or "")
|
|
58
|
+
self._push(
|
|
59
|
+
RuntimeErrorEvent(
|
|
60
|
+
kind=HTTP,
|
|
61
|
+
message=f"HTTP {status_int} {url}".strip(),
|
|
62
|
+
url=url,
|
|
63
|
+
status=status_int,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------- consulta
|
|
68
|
+
def events(self) -> List[RuntimeErrorEvent]:
|
|
69
|
+
return list(self._events)
|
|
70
|
+
|
|
71
|
+
def as_dicts(self) -> List[dict]:
|
|
72
|
+
return [event.to_dict() for event in self._events]
|
|
73
|
+
|
|
74
|
+
def drain(self) -> List[RuntimeErrorEvent]:
|
|
75
|
+
"""Devuelve y limpia los eventos acumulados."""
|
|
76
|
+
pending = self._events
|
|
77
|
+
self._events = []
|
|
78
|
+
return pending
|
|
79
|
+
|
|
80
|
+
def clear(self) -> None:
|
|
81
|
+
self._events = []
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
return len(self._events)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def attach_capture(page: Any, capture: ErrorCapture) -> ErrorCapture:
|
|
88
|
+
"""Engancha los listeners de Playwright a un `ErrorCapture`."""
|
|
89
|
+
on = getattr(page, "on", None)
|
|
90
|
+
if callable(on):
|
|
91
|
+
on("console", capture.record_console)
|
|
92
|
+
on("pageerror", capture.record_page_error)
|
|
93
|
+
on("response", capture.record_response)
|
|
94
|
+
return capture
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merge_events(*groups: Iterable[RuntimeErrorEvent]) -> List[RuntimeErrorEvent]:
|
|
98
|
+
"""Fusiona varios grupos de eventos preservando el orden de llegada."""
|
|
99
|
+
merged: List[RuntimeErrorEvent] = []
|
|
100
|
+
for group in groups:
|
|
101
|
+
merged.extend(group or [])
|
|
102
|
+
return merged
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Firewall Anti-SSRF para contextos efimeros de Playwright.
|
|
2
|
+
|
|
3
|
+
El dominio de este modulo es puro: recibe un host y decide si es enrutable en
|
|
4
|
+
Internet o si pertenece a loopback / rangos privados / metadata de nube. El
|
|
5
|
+
adaptador de navegador instala el interceptor ``context.route("**/*")``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Iterable, Optional
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
import inspect
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
BLOCKED_ABORT_REASON = "accessdenied"
|
|
19
|
+
|
|
20
|
+
#: Hostnames bloqueados de forma explicita (loopback, wildcards y metadata cloud).
|
|
21
|
+
BLOCKED_HOSTNAMES: frozenset[str] = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"localhost",
|
|
24
|
+
"localhost.localdomain",
|
|
25
|
+
"ip6-localhost",
|
|
26
|
+
"ip6-loopback",
|
|
27
|
+
"metadata",
|
|
28
|
+
"metadata.google.internal",
|
|
29
|
+
"metadata.goog",
|
|
30
|
+
"instance-data",
|
|
31
|
+
"0.0.0.0",
|
|
32
|
+
"::",
|
|
33
|
+
"::1",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
#: Hostnames de loopback/local permitidos en modo desarrollador local.
|
|
37
|
+
LOCAL_HOSTNAMES: frozenset[str] = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"localhost",
|
|
40
|
+
"localhost.localdomain",
|
|
41
|
+
"ip6-localhost",
|
|
42
|
+
"ip6-loopback",
|
|
43
|
+
"0.0.0.0",
|
|
44
|
+
"::",
|
|
45
|
+
"::1",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
#: Hostnames de metadata cloud: bloqueo incondicional (nunca es un sitio web).
|
|
50
|
+
METADATA_HOSTNAMES: frozenset[str] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"metadata",
|
|
53
|
+
"metadata.google.internal",
|
|
54
|
+
"metadata.goog",
|
|
55
|
+
"instance-data",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#: Redes privadas, de loopback, link-local y de metadata (RFC 1918/5735/6890).
|
|
60
|
+
BLOCKED_NETWORKS: tuple[Any, ...] = tuple(
|
|
61
|
+
ipaddress.ip_network(cidr)
|
|
62
|
+
for cidr in (
|
|
63
|
+
"0.0.0.0/8",
|
|
64
|
+
"10.0.0.0/8",
|
|
65
|
+
"100.64.0.0/10",
|
|
66
|
+
"127.0.0.0/8",
|
|
67
|
+
"169.254.0.0/16",
|
|
68
|
+
"172.16.0.0/12",
|
|
69
|
+
"192.0.0.0/24",
|
|
70
|
+
"192.168.0.0/16",
|
|
71
|
+
"198.18.0.0/15",
|
|
72
|
+
"::1/128",
|
|
73
|
+
"fc00::/7",
|
|
74
|
+
"fe80::/10",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
#: Redes de infraestructura privada permitidas cuando VFA_ALLOW_LOCAL_HOST=true.
|
|
79
|
+
LOCAL_INFRA_NETWORKS: tuple[Any, ...] = tuple(
|
|
80
|
+
ipaddress.ip_network(cidr)
|
|
81
|
+
for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
#: Endpoints de metadata cloud: SIEMPRE bloqueados, incluso en modo local.
|
|
85
|
+
METADATA_NETWORKS: tuple[Any, ...] = (ipaddress.ip_network("169.254.0.0/16"),)
|
|
86
|
+
|
|
87
|
+
#: Esquemas sin host enrutable que jamas deben salir por la red.
|
|
88
|
+
BLOCKED_SCHEMES: frozenset[str] = frozenset({"file", "about", "data", "blob", "chrome"})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_host(host: str) -> str:
|
|
92
|
+
"""Normaliza un host: minusculas, sin corchetes IPv6 ni punto final."""
|
|
93
|
+
value = (host or "").strip().lower()
|
|
94
|
+
if value.startswith("[") and value.endswith("]"):
|
|
95
|
+
value = value[1:-1]
|
|
96
|
+
return value.rstrip(".")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_address(value: str) -> Optional[Any]:
|
|
100
|
+
"""Parsea ``value`` como direccion IP o devuelve None si es un dominio."""
|
|
101
|
+
try:
|
|
102
|
+
return ipaddress.ip_address(value)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_local_host(host: str) -> bool:
|
|
108
|
+
"""Indica si el host es loopback, ``localhost`` o un dominio ``*.local``."""
|
|
109
|
+
value = _normalize_host(host)
|
|
110
|
+
if not value:
|
|
111
|
+
return False
|
|
112
|
+
if value in LOCAL_HOSTNAMES:
|
|
113
|
+
return True
|
|
114
|
+
if value.endswith(".localhost") or value.endswith(".local"):
|
|
115
|
+
return True
|
|
116
|
+
address = _as_address(value)
|
|
117
|
+
if address is None:
|
|
118
|
+
return False
|
|
119
|
+
return bool(address.is_loopback or address.is_unspecified)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_metadata_host(host: str) -> bool:
|
|
123
|
+
"""Indica si el host apunta a metadata cloud (bloqueo incondicional)."""
|
|
124
|
+
value = _normalize_host(host)
|
|
125
|
+
if not value:
|
|
126
|
+
return False
|
|
127
|
+
if value in METADATA_HOSTNAMES:
|
|
128
|
+
return True
|
|
129
|
+
address = _as_address(value)
|
|
130
|
+
if address is None:
|
|
131
|
+
return False
|
|
132
|
+
return any(address in network for network in METADATA_NETWORKS)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_private_infra_host(host: str) -> bool:
|
|
136
|
+
"""Indica si el host pertenece a una red privada RFC 1918 (intranet)."""
|
|
137
|
+
address = _as_address(_normalize_host(host))
|
|
138
|
+
if address is None:
|
|
139
|
+
return False
|
|
140
|
+
return any(address in network for network in LOCAL_INFRA_NETWORKS)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_blocked_host(host: str, allow_local: bool = False) -> bool:
|
|
144
|
+
"""Indica si ``host`` (nombre o IP) apunta a la intranet o al propio host.
|
|
145
|
+
|
|
146
|
+
Con ``allow_local=True`` (modo desarrollador local) se permite loopback,
|
|
147
|
+
``localhost``, dominios ``*.local`` y redes privadas RFC 1918, pero se
|
|
148
|
+
mantiene el bloqueo de los endpoints de metadata cloud.
|
|
149
|
+
"""
|
|
150
|
+
value = _normalize_host(host)
|
|
151
|
+
if not value:
|
|
152
|
+
# Un host vacio no es enrutable: se trata como bloqueado.
|
|
153
|
+
return True
|
|
154
|
+
if is_metadata_host(value):
|
|
155
|
+
return True
|
|
156
|
+
if allow_local and (is_local_host(value) or is_private_infra_host(value)):
|
|
157
|
+
return False
|
|
158
|
+
if value in BLOCKED_HOSTNAMES:
|
|
159
|
+
return True
|
|
160
|
+
if value.endswith(".localhost") or value.endswith(".internal"):
|
|
161
|
+
return True
|
|
162
|
+
address = _as_address(value)
|
|
163
|
+
if address is None:
|
|
164
|
+
return False # Dominio publico: se permite el intento.
|
|
165
|
+
if any(address in network for network in BLOCKED_NETWORKS):
|
|
166
|
+
return True
|
|
167
|
+
return bool(
|
|
168
|
+
address.is_private
|
|
169
|
+
or address.is_loopback
|
|
170
|
+
or address.is_link_local
|
|
171
|
+
or address.is_reserved
|
|
172
|
+
or address.is_multicast
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_blocked_url(url: str, allow_local: bool = False) -> bool:
|
|
177
|
+
"""Indica si la URL es insegura (SSRF) y debe abortarse antes de salir."""
|
|
178
|
+
try:
|
|
179
|
+
parsed = urlparse(url or "")
|
|
180
|
+
except ValueError:
|
|
181
|
+
return True
|
|
182
|
+
scheme = (parsed.scheme or "").lower()
|
|
183
|
+
if scheme in BLOCKED_SCHEMES:
|
|
184
|
+
return True
|
|
185
|
+
if scheme not in ("http", "https", "ws", "wss"):
|
|
186
|
+
return True
|
|
187
|
+
return is_blocked_host(parsed.hostname or "", allow_local=allow_local)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def blocked_urls(urls: Iterable[str], allow_local: bool = False) -> list[str]:
|
|
191
|
+
"""Utilidad de diagnostico: retorna el subconjunto de URLs bloqueadas."""
|
|
192
|
+
return [url for url in urls if is_blocked_url(url, allow_local=allow_local)]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _guard_route(allow_local: bool = False) -> Any:
|
|
196
|
+
"""Construye el handler de ``context.route`` con la politica Anti-SSRF dada."""
|
|
197
|
+
|
|
198
|
+
async def handler(route: Any, request: Any) -> None:
|
|
199
|
+
try:
|
|
200
|
+
url = str(getattr(request, "url", "") or "")
|
|
201
|
+
except Exception: # pragma: no cover - driver opcional
|
|
202
|
+
url = ""
|
|
203
|
+
if is_blocked_url(url, allow_local=allow_local):
|
|
204
|
+
logger.warning("SSRF bloqueado: %s", url or "<url desconocida>")
|
|
205
|
+
await route.abort(BLOCKED_ABORT_REASON)
|
|
206
|
+
return
|
|
207
|
+
await route.continue_()
|
|
208
|
+
|
|
209
|
+
return handler
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def attach_ssrf_guard(context: Any, allow_local: bool = False) -> Optional[Any]:
|
|
213
|
+
"""Instala el interceptor Anti-SSRF en el contexto (si soporta routing)."""
|
|
214
|
+
registrar = getattr(context, "route", None)
|
|
215
|
+
if not callable(registrar):
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
res = registrar("**/*", _guard_route(allow_local=allow_local))
|
|
219
|
+
if inspect.isawaitable(res):
|
|
220
|
+
await res
|
|
221
|
+
return res
|
|
222
|
+
except Exception: # pragma: no cover - contexto ya cerrado
|
|
223
|
+
logger.debug("No se pudo instalar el firewall Anti-SSRF", exc_info=True)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def route_handler(allow_local: bool = False) -> Any:
|
|
228
|
+
"""Expone el handler para pruebas unitarias sin I/O real."""
|
|
229
|
+
return _guard_route(allow_local=allow_local)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Adaptador Playwright: Chromium local sandboxed (motor unico, sin Docker).
|
|
2
|
+
|
|
3
|
+
Cada sesion se ejecuta en un ``BrowserContext`` efimero en memoria con firewall
|
|
4
|
+
Anti-SSRF instalado; no se persiste cache, cookies ni almacenamiento en disco.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from app import config
|
|
13
|
+
from app.adapters.browser.capture import ErrorCapture, attach_capture
|
|
14
|
+
from app.adapters.browser.network_guard import attach_ssrf_guard
|
|
15
|
+
from app.adapters.browser.visual_primitives import VisualPrimitivesMixin
|
|
16
|
+
from app.domain.models import RuntimeErrorEvent
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_VIEWPORT = {"width": 1280, "height": 720}
|
|
21
|
+
|
|
22
|
+
TIMEOUT_ACTIONS = config.get_action_timeout_ms()
|
|
23
|
+
|
|
24
|
+
#: Flags de aislamiento estricto aplicadas al arranque de Chromium.
|
|
25
|
+
SECURE_LAUNCH_FLAGS: Tuple[str, ...] = (
|
|
26
|
+
"--disable-background-networking",
|
|
27
|
+
"--disable-default-apps",
|
|
28
|
+
"--disable-sync",
|
|
29
|
+
"--no-first-run",
|
|
30
|
+
"--hide-scrollbars",
|
|
31
|
+
"--disable-component-update",
|
|
32
|
+
"--disable-domain-reliability",
|
|
33
|
+
"--no-default-browser-check",
|
|
34
|
+
"--disable-dev-shm-usage",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Opciones del contexto efimero: sin persistencia ni permisos implicitos.
|
|
38
|
+
EPHEMERAL_CONTEXT_OPTIONS: Dict[str, Any] = {
|
|
39
|
+
"viewport": dict(_VIEWPORT),
|
|
40
|
+
"ignore_https_errors": False,
|
|
41
|
+
"bypass_csp": False,
|
|
42
|
+
"accept_downloads": False,
|
|
43
|
+
"java_script_enabled": True,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrowserLaunchError(RuntimeError):
|
|
48
|
+
"""Error irrecuperable al inicializar el navegador."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_session_dead(browser: Any, page: Any) -> bool:
|
|
52
|
+
"""Detecta si la pagina o el navegador se cerraron (defensivo ante fakes)."""
|
|
53
|
+
try:
|
|
54
|
+
is_closed = getattr(page, "is_closed", None)
|
|
55
|
+
if callable(is_closed) and is_closed():
|
|
56
|
+
return True
|
|
57
|
+
is_connected = getattr(browser, "is_connected", None)
|
|
58
|
+
if callable(is_connected) and not is_connected():
|
|
59
|
+
return True
|
|
60
|
+
except Exception: # pragma: no cover - defensivo
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PlaywrightSession(VisualPrimitivesMixin):
|
|
66
|
+
"""Sesion activa que implementa `BrowserSessionPort` sobre Playwright."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
browser: Any,
|
|
71
|
+
page: Any,
|
|
72
|
+
playwright: Any = None,
|
|
73
|
+
capture: Optional[ErrorCapture] = None,
|
|
74
|
+
mode: str = "local",
|
|
75
|
+
context: Any = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self.browser = browser
|
|
78
|
+
self.page = page
|
|
79
|
+
self.playwright = playwright
|
|
80
|
+
self.mode = mode
|
|
81
|
+
self.context = context
|
|
82
|
+
self.capture = attach_capture(page, capture or ErrorCapture())
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------- navegacion
|
|
85
|
+
async def goto(self, url: str, timeout: int = 30000) -> None:
|
|
86
|
+
await self.page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
|
87
|
+
|
|
88
|
+
async def url(self) -> str:
|
|
89
|
+
value = getattr(self.page, "url", "")
|
|
90
|
+
return value() if callable(value) else str(value or "")
|
|
91
|
+
|
|
92
|
+
async def title(self) -> str:
|
|
93
|
+
getter = getattr(self.page, "title", None)
|
|
94
|
+
if callable(getter):
|
|
95
|
+
return str(await getter())
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
async def screenshot_b64(self, full_page: bool = True) -> str:
|
|
99
|
+
import base64
|
|
100
|
+
|
|
101
|
+
data = await self.page.screenshot(full_page=full_page)
|
|
102
|
+
if isinstance(data, str):
|
|
103
|
+
return data
|
|
104
|
+
return base64.b64encode(data).decode("utf-8")
|
|
105
|
+
|
|
106
|
+
async def accessibility_snapshot(self) -> Any:
|
|
107
|
+
snapshot = getattr(self.page, "accessibility", None)
|
|
108
|
+
if snapshot is not None and hasattr(snapshot, "snapshot"):
|
|
109
|
+
try:
|
|
110
|
+
return await snapshot.snapshot()
|
|
111
|
+
except Exception: # pragma: no cover - driver opcional
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return await self.page.locator("body").aria_snapshot()
|
|
115
|
+
except Exception: # pragma: no cover - driver opcional
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------- acciones
|
|
119
|
+
async def click(
|
|
120
|
+
self, selector: str, timeout: int = TIMEOUT_ACTIONS
|
|
121
|
+
) -> Dict[str, Any]:
|
|
122
|
+
await self.page.click(selector, timeout=timeout)
|
|
123
|
+
return {"action": "click", "selector": selector, "ok": True}
|
|
124
|
+
|
|
125
|
+
async def fill(
|
|
126
|
+
self, selector: str, value: str, timeout: int = TIMEOUT_ACTIONS
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
await self.page.fill(selector, value, timeout=timeout)
|
|
129
|
+
return {"action": "fill", "selector": selector, "value": value, "ok": True}
|
|
130
|
+
|
|
131
|
+
async def press(self, key: str) -> Dict[str, Any]:
|
|
132
|
+
await self.page.keyboard.press(key)
|
|
133
|
+
return {"action": "press", "value": key, "ok": True}
|
|
134
|
+
|
|
135
|
+
async def scroll(self, direction: str = "down") -> Dict[str, Any]:
|
|
136
|
+
delta = 900 if str(direction).lower() != "up" else -900
|
|
137
|
+
await self.page.mouse.wheel(0, delta)
|
|
138
|
+
return {"action": "scroll", "value": direction, "ok": True}
|
|
139
|
+
|
|
140
|
+
async def wait(self, seconds: float) -> Dict[str, Any]:
|
|
141
|
+
await self.page.wait_for_timeout(max(int(float(seconds) * 1000), 0))
|
|
142
|
+
return {"action": "wait", "value": seconds, "ok": True}
|
|
143
|
+
|
|
144
|
+
async def text_content(self) -> str:
|
|
145
|
+
try:
|
|
146
|
+
return str(await self.page.inner_text("body"))
|
|
147
|
+
except Exception: # pragma: no cover - pagina sin body
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------- cookies
|
|
151
|
+
async def cookies(self) -> List[Dict[str, Any]]:
|
|
152
|
+
for holder in (self.context, self.browser):
|
|
153
|
+
provider = getattr(holder, "cookies", None)
|
|
154
|
+
if callable(provider):
|
|
155
|
+
try:
|
|
156
|
+
return list(await provider())
|
|
157
|
+
except Exception: # pragma: no cover
|
|
158
|
+
return []
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
async def restore(self, cookies: List[Dict[str, Any]], url: str = "") -> None:
|
|
162
|
+
for holder in (self.context, self.browser):
|
|
163
|
+
adder = getattr(holder, "add_cookies", None)
|
|
164
|
+
if callable(adder) and cookies:
|
|
165
|
+
try:
|
|
166
|
+
await adder(cookies)
|
|
167
|
+
except Exception: # pragma: no cover
|
|
168
|
+
logger.debug("No se pudieron restaurar cookies", exc_info=True)
|
|
169
|
+
break
|
|
170
|
+
if url:
|
|
171
|
+
await self.goto(url)
|
|
172
|
+
|
|
173
|
+
async def close(self) -> None:
|
|
174
|
+
"""Destruye la pagina y el contexto efimero (nada persiste en disco)."""
|
|
175
|
+
closer = getattr(self.page, "close", None)
|
|
176
|
+
if callable(closer):
|
|
177
|
+
try:
|
|
178
|
+
await closer()
|
|
179
|
+
except Exception: # pragma: no cover - pagina ya cerrada
|
|
180
|
+
pass
|
|
181
|
+
context_closer = getattr(self.context, "close", None)
|
|
182
|
+
if callable(context_closer):
|
|
183
|
+
try:
|
|
184
|
+
await context_closer()
|
|
185
|
+
except Exception: # pragma: no cover - contexto ya cerrado
|
|
186
|
+
pass
|
|
187
|
+
self.context = None
|
|
188
|
+
|
|
189
|
+
def is_dead(self) -> bool:
|
|
190
|
+
return is_session_dead(self.browser, self.page)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PlaywrightBrowserAdapter:
|
|
194
|
+
"""Gestor del navegador: Chromium local sandboxed y CDP opcional generico."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, playwright_manager: Any = None) -> None:
|
|
197
|
+
self._manager = playwright_manager
|
|
198
|
+
self._playwright: Any = None
|
|
199
|
+
self._browser: Any = None
|
|
200
|
+
self._capture = ErrorCapture()
|
|
201
|
+
self._orphan_contexts: List[Any] = []
|
|
202
|
+
self.mode = "local"
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------- internos
|
|
205
|
+
async def _ensure_playwright(self) -> Any:
|
|
206
|
+
if self._playwright is None:
|
|
207
|
+
if self._manager is None:
|
|
208
|
+
from playwright.async_api import async_playwright
|
|
209
|
+
|
|
210
|
+
self._manager = async_playwright()
|
|
211
|
+
self._playwright = await self._manager.start()
|
|
212
|
+
return self._playwright
|
|
213
|
+
|
|
214
|
+
def _launch_args(self) -> Dict[str, Any]:
|
|
215
|
+
"""Argumentos de arranque: siempre con flags de aislamiento estricto."""
|
|
216
|
+
headless = config.get_headless()
|
|
217
|
+
args = {"headless": headless, "args": list(SECURE_LAUNCH_FLAGS)}
|
|
218
|
+
if not headless:
|
|
219
|
+
args["args"].append("--start-maximized")
|
|
220
|
+
return args
|
|
221
|
+
|
|
222
|
+
async def connect(self) -> Any:
|
|
223
|
+
"""Arranca Chromium local o intenta un CDP generico configurado."""
|
|
224
|
+
playwright = await self._ensure_playwright()
|
|
225
|
+
remote_url = config.get_browser_cdp_url()
|
|
226
|
+
if remote_url:
|
|
227
|
+
timeout = config.get_cdp_timeout_ms()
|
|
228
|
+
try:
|
|
229
|
+
self._browser = await playwright.chromium.connect_over_cdp(
|
|
230
|
+
remote_url, timeout=timeout
|
|
231
|
+
)
|
|
232
|
+
self.mode = "cdp"
|
|
233
|
+
logger.info("Navegador conectado via CDP: %s", remote_url)
|
|
234
|
+
return self._browser
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Conexion CDP fallida (%s); fallback a Chromium local", exc
|
|
238
|
+
)
|
|
239
|
+
self._browser = await playwright.chromium.launch(**self._launch_args())
|
|
240
|
+
self.mode = "local"
|
|
241
|
+
return self._browser
|
|
242
|
+
|
|
243
|
+
async def _ensure_browser(self) -> None:
|
|
244
|
+
if self._browser is None or is_session_dead(self._browser, None):
|
|
245
|
+
await self.connect()
|
|
246
|
+
|
|
247
|
+
async def _new_context(self) -> Any:
|
|
248
|
+
"""Crea un contexto efimero en memoria con firewall Anti-SSRF.
|
|
249
|
+
|
|
250
|
+
La politica Anti-SSRF respeta ``VFA_ALLOW_LOCAL_HOST``: en modo
|
|
251
|
+
desarrollador local se permite auditar ``localhost`` y ``*.local``.
|
|
252
|
+
"""
|
|
253
|
+
await self._ensure_browser()
|
|
254
|
+
creator = getattr(self._browser, "new_context", None)
|
|
255
|
+
if not callable(creator):
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
context = await creator(**EPHEMERAL_CONTEXT_OPTIONS)
|
|
259
|
+
except TypeError: # pragma: no cover - implementaciones parciales
|
|
260
|
+
context = await creator()
|
|
261
|
+
await attach_ssrf_guard(context, allow_local=config.get_allow_local_host())
|
|
262
|
+
return context
|
|
263
|
+
|
|
264
|
+
async def _new_page(self) -> Any:
|
|
265
|
+
context = await self._new_context()
|
|
266
|
+
if context is not None:
|
|
267
|
+
self._orphan_contexts.append(context)
|
|
268
|
+
return await context.new_page()
|
|
269
|
+
await self._ensure_browser()
|
|
270
|
+
return await self._browser.new_page(viewport=dict(_VIEWPORT))
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------- publicos
|
|
273
|
+
async def new_page(self) -> Any:
|
|
274
|
+
"""Pagina suelta en su propio contexto efimero (cerrado en `reset`)."""
|
|
275
|
+
return await self._new_page()
|
|
276
|
+
|
|
277
|
+
async def open_session(self) -> PlaywrightSession:
|
|
278
|
+
context = await self._new_context()
|
|
279
|
+
if context is not None:
|
|
280
|
+
page = await context.new_page()
|
|
281
|
+
else: # pragma: no cover - fallback defensivo
|
|
282
|
+
await self._ensure_browser()
|
|
283
|
+
page = await self._browser.new_page(viewport=dict(_VIEWPORT))
|
|
284
|
+
return PlaywrightSession(
|
|
285
|
+
browser=self._browser,
|
|
286
|
+
page=page,
|
|
287
|
+
playwright=self._playwright,
|
|
288
|
+
capture=self._capture,
|
|
289
|
+
mode=self.mode,
|
|
290
|
+
context=context,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def close_session(self, session: Optional[PlaywrightSession] = None) -> None:
|
|
294
|
+
if session is not None:
|
|
295
|
+
await session.close()
|
|
296
|
+
|
|
297
|
+
def drain_errors(self) -> List[RuntimeErrorEvent]:
|
|
298
|
+
return self._capture.drain()
|
|
299
|
+
|
|
300
|
+
def peek_errors(self) -> List[RuntimeErrorEvent]:
|
|
301
|
+
return self._capture.events()
|
|
302
|
+
|
|
303
|
+
async def reset(self) -> None:
|
|
304
|
+
"""Fuerza la reconexion en el siguiente `open_session`."""
|
|
305
|
+
for context in self._orphan_contexts:
|
|
306
|
+
closer = getattr(context, "close", None)
|
|
307
|
+
if callable(closer):
|
|
308
|
+
try:
|
|
309
|
+
await closer()
|
|
310
|
+
except Exception: # pragma: no cover - contexto ya cerrado
|
|
311
|
+
pass
|
|
312
|
+
self._orphan_contexts.clear()
|
|
313
|
+
if self._browser is not None:
|
|
314
|
+
try:
|
|
315
|
+
await self._browser.close()
|
|
316
|
+
except Exception: # pragma: no cover - navegador ya muerto
|
|
317
|
+
pass
|
|
318
|
+
self._browser = None
|
|
319
|
+
|
|
320
|
+
async def shutdown(self) -> None:
|
|
321
|
+
await self.reset()
|
|
322
|
+
if self._playwright is not None:
|
|
323
|
+
stopper = getattr(self._playwright, "stop", None)
|
|
324
|
+
if callable(stopper):
|
|
325
|
+
try:
|
|
326
|
+
await stopper()
|
|
327
|
+
except Exception: # pragma: no cover
|
|
328
|
+
pass
|
|
329
|
+
self._playwright = None
|