Files
fn_registry/python/functions/browser/find_consent_controls_llm.py
T
egutierrez 763e06c127 feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-20 18:22:23 +02:00

213 lines
7.8 KiB
Python

"""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))