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