763e06c127
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
7.8 KiB
Python
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))
|