---
name: extract_cmp_tcf
kind: function
lang: py
domain: browser
version: "1.3.0"
purity: impure
signature: "def extract_cmp_tcf(url: str, *, port: int = 9222, wait_load_s: float = 7.0, settle_s: float = 5.0, timeout_s: float = 30.0, accept_first: bool = False, settle_accept_s: float = 4.0, llm_fallback: bool = False) -> dict"
description: "Navega por CDP a un Chrome con remote debugging, detecta el CMP (Consent Management Platform: Didomi, OneTrust, Sourcepoint, Quantcast u otro TCF) de un sitio web y lee su objeto IAB TCF v2 para contar vendors (data brokers) y propositos declarados, mas detectar muro pago-o-consientes. Pensado para escanear masivamente periodicos espanoles y cruzar vendor IDs contra la GVL."
tags: [cdp, browser, consent, cmp, tcf, iab, privacy, data-broker, python, navegator]
uses_functions: [cdp_eval_py_browser, find_consent_controls_llm_py_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "sys", "time"]
params_schema:
  params:
    - name: url
      desc: "URL del sitio a escanear. Se navega la pestana activa del Chrome con remote debugging hacia esta URL."
    - name: port
      desc: "Puerto de remote debugging de Chrome. Default 9222. Usar 9333 para el Chrome aislado del MCP (NO 9222 si es el navegador personal del usuario)."
    - name: wait_load_s
      desc: "Segundos a esperar tras navegar para que la pagina cargue. Default 7.0."
    - name: settle_s
      desc: "Segundos extra para que el CMP termine de inicializar antes de arrancar el volcado del TCF. Default 5.0. Subir (8-10) para CMPs lentos que inyectan __tcfapi de forma diferida."
    - name: timeout_s
      desc: "Timeout (segundos) para cada evaluacion CDP. Default 30.0."
    - name: accept_first
      desc: "Si True, antes de leer el TCData definitivo intenta ACEPTAR el banner de consentimiento (clic en 'aceptar todo': selectores conocidos de Didomi/OneTrust/Quantcast + fallback por texto del boton), espera settle_accept_s y re-ejecuta el volcado del TCF. Necesario para CMPs (Quantcast) que no exponen vendors pre-consent: devuelven consents/legitimateInterests vacios hasta que el usuario interactua. Default False (no toca el banner, comportamiento identico al historico)."
    - name: settle_accept_s
      desc: "Segundos a esperar tras aceptar el banner para que el CMP re-emita el TCData poblado. Default 4.0. Solo aplica si accept_first=True."
    - name: llm_fallback
      desc: "Si True (y accept_first=True), SOLO cuando el intento normal de aceptar deja vendor_ids vacio tras leer el TCData recurre a find_consent_controls_llm (haiku, max_candidates=80) para localizar el control 'aceptar todo' que los selectores hardcodeados no encontraron, lo clica via cdp_eval, espera settle_accept_s y re-ejecuta el volcado del TCF. Default False (nunca llama al LLM, comportamiento identico). El LLM solo se invoca cuando hace falta de verdad: si el flujo de selectores/texto ya recupero vendors, NO gasta la llamada a ask_llm (ni siquiera cuando el clic salio 'no-button' pero habia vendors, p.ej. Didomi que expone getRequiredVendorIds sin consentir). Gotcha: cada sitio que dispare el fallback consume una llamada a ask_llm (rate limits)."
  output: "dict plano. Caso ok: {status:'ok', url, final_url, title, cmp:'didomi'|'onetrust'|'sourcepoint'|'quantcast'|'otro_tcf'|'ninguno', cmp_id:int|None, tcf_policy:int|None, gdpr_applies:bool|None, n_vendors:int, n_vendors_total:int|None, n_vendors_required:int|None, n_purposes:int|None, tcstring_len:int, paywall_consent:bool, vendor_ids:[int]}. Cuando accept_first=True se anade ademas accept_method (str): lo que devolvio el JS de clic ('sel:<selector>', 'text:<texto>' o 'no-button'); si se dispara el fallback LLM pasa a 'llm:<selector>' (clic LLM exitoso) o 'llm:no-control' (el LLM no encontro control). Cuando se dispara el fallback LLM se anaden llm_used:True y llm_reason:str (la explicacion del locator); si llm_fallback=False o el flujo normal ya dio vendors, esos campos NO aparecen. vendor_ids es generico para cualquier CMP TCF v2: si Didomi expone los required ids se usan esos (lo que el sitio solicita); si no (Quantcast, Sourcepoint, otro_tcf) se usa la union de claves de tcData.vendor.consents + legitimateInterests. n_vendors = len(vendor_ids) cuando hay lista. Caso fallo: {status:'error', url, error:str}. Nunca lanza."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/browser/extract_cmp_tcf.py"
---

## Ejemplo

```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.extract_cmp_tcf import extract_cmp_tcf

# Requiere un Chrome lanzado con --remote-debugging-port=9333 (el aislado del MCP).
res = extract_cmp_tcf("https://www.lavanguardia.com", port=9333)
print(res["status"], res["cmp"], res["n_vendors"], res["paywall_consent"])
# -> ok didomi 700 True   (recuentos reales varian por sitio/fecha)
```

Para CMPs que NO exponen vendors pre-consent (Quantcast), aceptar el banner primero:

```python
# bolsamania.com / confilegal.com usan Quantcast: pre-consent dan 0 vendors.
res = extract_cmp_tcf("https://www.bolsamania.com", port=9335, accept_first=True)
print(res["cmp"], len(res["vendor_ids"]), res["accept_method"])
# -> quantcast 1613 sel:.qc-cmp2-summary-buttons button[mode=primary]
```

Para CMP con clases dinamicas / texto no estandar donde el clic por selectores
sale `no-button`, activar el fallback LLM (haiku localiza el "aceptar todo"):

```python
# Solo gasta ask_llm si el flujo de selectores fallo de verdad.
res = extract_cmp_tcf("https://www.periodistadigital.com", port=9335,
                      accept_first=True, llm_fallback=True)
print(res["accept_method"], res.get("llm_used"), len(res["vendor_ids"]))
# -> llm:[data-fnllm="3"] True 812   (si el LLM localizo el control)
# En sitios que ya dieron vendors por selector, llm_used NO aparece.
```

O directo por CLI: `python3 python/functions/browser/extract_cmp_tcf.py "https://www.lavanguardia.com" 9333`
(tercer arg `1`/`accept` activa `accept_first`; cuarto arg `1`/`llm` activa `llm_fallback`).

## Cuando usarla

Cuando necesites auditar de forma masiva que data brokers (vendors IAB TCF) y
propositos declara el banner de cookies de un sitio: escaneo de periodicos, paneles
de prensa, o cualquier corpus de webs con muro de consentimiento. Devuelve un dict
plano listo para volcar a una tabla (DuckDB / Excel) y cruzar `vendor_ids` contra la
Global Vendor List. Usala como paso de captura dentro de un pipeline de escaneo; los
`vendor_ids` enriquecidos con la GVL dan el nombre de cada data broker.

## Gotchas

- Requiere un Chrome lanzado con `--remote-debugging-port=<port>` y al menos una
  pestana de tipo `page`. Sin remote debugging, la navegacion falla y devuelve
  `{"status":"error", ...}`. **NO usar el puerto 9222 si es el navegador personal**
  (tiene sesiones del usuario abiertas): usar 9333, el Chrome aislado del MCP.
- Navega la **pestana activa** (`location.href = url`) — reusa el target que elija
  `cdp_eval` (primer `page`). No abre pestana nueva; si necesitas aislar, abre una
  pestana dedicada antes.
- El CMP puede tardar en inicializar. Si `n_vendors` sale 0 o `cmp` sale `otro_tcf`
  cuando esperabas Didomi, sube `wait_load_s` / `settle_s` — algunos sitios cargan
  el SDK del CMP de forma diferida.
- El stub `__tcfapi('getTCData', 2, cb)` **encola** el callback hasta que el CMP real
  carga; por eso hay dos pasadas (arrancar volcado, luego leer `window.__tcdump`). Si
  el usuario aun no acepto el banner, los recuentos de `vendor.consents` pueden ser 0
  pero `vendor.legitimateInterests` y el recuento de Didomi suelen estar poblados.
- Headless puede ser **detectado** por algunos CMP (cambian comportamiento o no
  cargan). Para resultados fiables usar un Chrome con UI (el del MCP, 9333).
- `vendor_ids` se obtiene de forma **generica** para cualquier CMP TCF v2: con Didomi
  se usan los `getRequiredVendorIds()` (lo que el sitio realmente solicita); con
  cualquier otro CMP (Quantcast, Sourcepoint, `otro_tcf`) se usa la **union de claves**
  de `tcData.vendor.consents` + `tcData.vendor.legitimateInterests` (los IDs del
  universo GVL que el CMP tiene configurado). Antes de v1.1.0 solo Didomi rellenaba
  `vendor_ids`; los demas CMP TCF quedaban con la lista vacia y `n_vendors=0`.
- `n_vendors` = `len(vendor_ids)` cuando hay lista resuelta; si no, cae a la mejor
  estimacion `didomi_required` > `didomi_total_vendors` > `n_vendor_li`.
- Si un sitio TCF sigue devolviendo `vendor_ids` vacio, casi siempre es porque el CMP
  inyecta `__tcfapi` de forma muy diferida: sube `settle_s` a 8-10 en esa llamada.
- **Quantcast (cmp_id 10) pre-consent devuelve TCData vacio**: mientras el banner solo
  esta mostrado (`eventStatus:"cmpuishown"`, `tcString` vacio), `vendor.consents`,
  `vendor.legitimateInterests` y `vendor.disclosedVendors` estan TODOS a 0 — no hay
  forma de leer vendors sin que el usuario interactue con el banner. En cuanto se
  acepta (o se rechaza) el banner, el TCData se puebla y la funcion extrae cientos/miles
  de vendor_ids correctamente (verificado: bolsamania.com pasa de 0 a 1613 vendors tras
  cerrar el banner). Didomi NO sufre esto: expone `getRequiredVendorIds()` aunque no
  haya consentimiento. Para escaneo masivo de sitios Quantcast, pasar `accept_first=True`
  (desde v1.2.0): la funcion acepta el banner por selector/texto antes de leer el TCF.
- **`accept_first=True` clica desde el documento PRINCIPAL**: los selectores conocidos
  (`#didomi-notice-agree-button`, `#onetrust-accept-btn-handler`,
  `.qc-cmp2-summary-buttons button[mode=primary]`, `button[aria-label*=Aceptar/Accept]`)
  y el fallback por texto del boton funcionan para Didomi, OneTrust y Quantcast porque
  renderizan el banner en el DOM de la pagina. **Sourcepoint mete el banner dentro de un
  `<iframe>` (`sp_message_container_*`)**: el clic desde el documento principal NO
  alcanza el boton dentro del iframe, asi que `accept_method` saldra `no-button` para
  Sourcepoint y los vendors seguiran sin poblarse. No esta resuelto (no hay sitios
  Sourcepoint en el set actual); resolverlo requeriria evaluar el JS dentro del frame
  del iframe (otro target CDP). El parametro nunca lanza por esto: simplemente reporta
  `no-button`.
- **`llm_fallback=True` gasta una llamada a `ask_llm` (haiku) por cada sitio que lo
  dispare** (rate limits de la API Anthropic). El fallback solo se invoca cuando el
  flujo normal de selectores fallo de verdad (`vendor_ids` vacio tras leer el TCData):
  los sitios cuyo CMP estandar (Didomi/OneTrust/Quantcast por selector o texto) ya
  recupera vendors NO gastan la llamada. Caso clave: Didomi expone
  `getRequiredVendorIds()` sin necesidad de consentir, asi que aunque el clic salga
  `no-button` el `vendor_ids` ya viene poblado y el LLM **no** se dispara. Para un escaneo masivo
  esto acota el gasto a los CMP con clases dinamicas / texto no estandar. El fallback
  marca `accept_method='llm:<selector>'` (clic LLM exitoso), o `'llm:no-control'` si el
  LLM no encontro un boton aceptable / el clic fallo, y siempre anade `llm_used:True` +
  `llm_reason`. NO resuelve banners dentro de iframes (Sourcepoint): el LLM recolecta
  controles del documento principal, igual que el flujo de selectores.
- Nunca lanza: cualquier error de red, CDP o parseo JSON se reporta en `error` con
  `status="error"`.

## Capability growth log

- v1.3.0 (2026-06-18) — llm_fallback: si el clic por selectores falla (no-button), usa find_consent_controls_llm (haiku) para localizar y clicar 'aceptar todo' antes de leer el TCF. Gotcha: el fallback gasta una llamada a ask_llm (rate limits) por sitio que lo necesite.
- v1.2.0 (2026-06-18) — accept_first: acepta el banner (Didomi/OneTrust/Quantcast por selector + fallback por texto) antes de leer el TCF, para CMPs que no exponen vendors pre-consent (Quantcast). Gotcha: Sourcepoint mete el banner en un iframe, el clic desde el documento principal no lo alcanza (sale 'no-button').
- v1.1.0 (2026-06-18) — vendor_ids genericos desde tcData.vendor.consents/legitimateInterests para CMPs no-Didomi (Quantcast, otro_tcf); +settle para CMPs lentos.
