feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""Identifica los botones de un banner de cookies/consentimiento usando un LLM.
|
||||
|
||||
En lugar de depender de selectores hardcodeados por CMP (que rompen cuando el
|
||||
banner usa marcas/textos distintos), esta funcion recolecta los controles
|
||||
clicables visibles de la pagina via CDP, los marca con un atributo estable
|
||||
`data-fnllm="N"` en el DOM, y pregunta a un modelo (haiku via ask_llm) cual es
|
||||
el boton de "ACEPTAR TODO", cual el de "RECHAZAR" y cual el enlace de
|
||||
"VER SOCIOS / configurar / mas opciones / finalidades".
|
||||
|
||||
Resuelve los CMP cuyos botones no encajan con selectores fijos (los casos
|
||||
"no-button" del scanner de databrokers). El caller usa los selectores
|
||||
`[data-fnllm="N"]` devueltos para clicar el control elegido con cdp_eval.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from browser.cdp_eval import cdp_eval # noqa: E402
|
||||
from core.ask_llm import ask_llm # noqa: E402
|
||||
|
||||
# JS que recolecta controles clicables visibles y los marca con data-fnllm="N".
|
||||
# {MAXC} se sustituye en Python por max_candidates.
|
||||
_COLLECT_JS = """
|
||||
(function(){
|
||||
var nodes=[].slice.call(document.querySelectorAll('button, a[role=button], [role=button], input[type=button], input[type=submit], a'));
|
||||
var out=[],n=0;
|
||||
for(var i=0;i<nodes.length && n<{MAXC};i++){
|
||||
var el=nodes[i];
|
||||
if(!el.getClientRects().length) continue;
|
||||
var txt=((el.innerText||el.textContent||el.value||'').trim()).slice(0,60);
|
||||
if(!txt) continue;
|
||||
el.setAttribute('data-fnllm', String(n));
|
||||
out.push({idx:n, tag:el.tagName.toLowerCase(), text:txt,
|
||||
aria:(el.getAttribute('aria-label')||'').slice(0,60),
|
||||
id:(el.id||'').slice(0,40), cls:((el.className||'').toString().split(' ')[0]||'').slice(0,40)});
|
||||
n++;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
_SYSTEM = (
|
||||
"Eres un clasificador de banners de consentimiento de cookies (espanol/ingles). "
|
||||
"Respondes SOLO con JSON valido, sin texto extra."
|
||||
)
|
||||
|
||||
|
||||
def _selector(idx):
|
||||
"""Construye el selector estable `[data-fnllm="N"]` o None si idx es None."""
|
||||
if idx is None:
|
||||
return None
|
||||
return '[data-fnllm="{}"]'.format(idx)
|
||||
|
||||
|
||||
def _extract_json_block(raw: str):
|
||||
"""Extrae el primer bloque {...} de la respuesta del LLM y lo parsea.
|
||||
|
||||
El modelo puede envolver en ```json o anadir texto; nos quedamos con el
|
||||
primer objeto JSON balanceado. Devuelve dict o lanza ValueError.
|
||||
"""
|
||||
# Buscar el primer '{' y emparejar llaves para soportar objetos anidados.
|
||||
start = raw.find("{")
|
||||
if start == -1:
|
||||
raise ValueError("no json object found")
|
||||
depth = 0
|
||||
for i in range(start, len(raw)):
|
||||
c = raw[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return json.loads(raw[start : i + 1])
|
||||
raise ValueError("unbalanced json object")
|
||||
|
||||
|
||||
def _coerce_idx(val, n_candidates):
|
||||
"""Normaliza un indice del LLM: int valido en rango o None."""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
i = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if 0 <= i < n_candidates:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def find_consent_controls_llm(
|
||||
*,
|
||||
port: int = 9222,
|
||||
max_candidates: int = 40,
|
||||
model: str = "claude-haiku-4-5-20251001",
|
||||
) -> dict:
|
||||
"""Identifica accept/reject/vendors de un banner de cookies via LLM.
|
||||
|
||||
Recolecta los controles clicables visibles de la pagina (marcandolos en el
|
||||
DOM con `data-fnllm="N"`) y pregunta al modelo cual es cada uno. Util para
|
||||
CMP cuyos botones no encajan con selectores hardcodeados.
|
||||
|
||||
Args:
|
||||
port: Puerto de remote debugging de Chrome. Default 9222.
|
||||
max_candidates: Maximo de controles a recolectar. Default 40.
|
||||
model: Modelo Anthropic a usar via ask_llm. Default haiku.
|
||||
|
||||
Returns:
|
||||
dict con claves:
|
||||
status: "ok" | "error".
|
||||
candidates: lista de {idx, tag, text, aria, id, cls}.
|
||||
accept_idx / reject_idx / vendors_idx: int|None elegidos por el LLM.
|
||||
accept_selector / reject_selector / vendors_selector: str|None,
|
||||
formato `[data-fnllm="N"]` para clicar via cdp_eval.
|
||||
reason: str — explicacion breve del LLM.
|
||||
error: str — presente solo si status=="error".
|
||||
Nunca lanza: cualquier fallo de CDP/eval/LLM se devuelve en el dict.
|
||||
"""
|
||||
# 1. Recolectar controles clicables visibles y marcarlos en el DOM.
|
||||
expr = _COLLECT_JS.replace("{MAXC}", str(int(max_candidates)))
|
||||
res = cdp_eval(expr, port=port)
|
||||
if not res.get("ok"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "cdp_eval: " + (res.get("error") or "fallo evaluando JS"),
|
||||
}
|
||||
|
||||
raw_list = res.get("value")
|
||||
try:
|
||||
candidates = json.loads(raw_list) if isinstance(raw_list, str) else (raw_list or [])
|
||||
except (TypeError, ValueError) as e:
|
||||
return {"status": "error", "error": "candidates_parse: " + str(e)}
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"status": "ok",
|
||||
"candidates": [],
|
||||
"accept_idx": None,
|
||||
"reject_idx": None,
|
||||
"vendors_idx": None,
|
||||
"accept_selector": None,
|
||||
"reject_selector": None,
|
||||
"vendors_selector": None,
|
||||
"reason": "sin controles visibles",
|
||||
}
|
||||
|
||||
# 2. Construir el prompt para el LLM.
|
||||
listing = json.dumps(candidates, ensure_ascii=False)
|
||||
prompt = (
|
||||
"Recibes la lista de controles clicables de un banner de cookies / "
|
||||
"consentimiento de una pagina web. Cada control tiene un 'idx' numerico "
|
||||
"y su texto/atributos. Identifica:\n"
|
||||
' - accept_idx: el boton para ACEPTAR / CONSENTIR TODO ("Aceptar", '
|
||||
'"Aceptar todo", "Accept all", "Consentir", "De acuerdo", "Estoy de acuerdo").\n'
|
||||
' - reject_idx: el boton para RECHAZAR TODO ("Rechazar", "Rechazar todo", '
|
||||
'"Reject all", "No acepto", "Continuar sin aceptar").\n'
|
||||
' - vendors_idx: el enlace para VER SOCIOS / partners / proveedores / '
|
||||
'configurar / mas opciones / finalidades / "Ver socios", "Configurar", '
|
||||
'"Mas informacion", "Gestionar opciones", "Personalizar".\n'
|
||||
"Si alguno no existe en la lista, usa null para ese campo.\n\n"
|
||||
"Responde EXACTAMENTE con este JSON (sin markdown, sin texto extra):\n"
|
||||
'{"accept_idx": N|null, "reject_idx": N|null, "vendors_idx": N|null, "reason": "..."}\n\n'
|
||||
"Controles:\n" + listing
|
||||
)
|
||||
|
||||
# 3. Preguntar al modelo (sin stream a stdout, respuesta corta).
|
||||
answer = ask_llm(prompt, model=model, system=_SYSTEM, max_tokens=300, echo=False)
|
||||
if not answer:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "llm_empty",
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
# 4. Parsear el JSON de la respuesta de forma robusta.
|
||||
try:
|
||||
parsed = _extract_json_block(answer)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "llm_parse",
|
||||
"raw": answer[:500],
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
n = len(candidates)
|
||||
accept_idx = _coerce_idx(parsed.get("accept_idx"), n)
|
||||
reject_idx = _coerce_idx(parsed.get("reject_idx"), n)
|
||||
vendors_idx = _coerce_idx(parsed.get("vendors_idx"), n)
|
||||
reason = str(parsed.get("reason", ""))
|
||||
|
||||
# 5. Devolver con selectores estables construidos a partir de los idx.
|
||||
return {
|
||||
"status": "ok",
|
||||
"candidates": candidates,
|
||||
"accept_idx": accept_idx,
|
||||
"reject_idx": reject_idx,
|
||||
"vendors_idx": vendors_idx,
|
||||
"accept_selector": _selector(accept_idx),
|
||||
"reject_selector": _selector(reject_idx),
|
||||
"vendors_selector": _selector(vendors_idx),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_port = int(sys.argv[1]) if len(sys.argv) > 1 else 9222
|
||||
out = find_consent_controls_llm(port=_port)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
Reference in New Issue
Block a user