cdpfleet Docs GitHub Dashboard

Cases / #37 · 2026-10-12 · Medium

Cookie banners by country: the same five sites from Germany and from the US

The Guardian, BBC, AP News, The Verge and HuffPost through a German and a US exit: which show a consent banner, which consent API they expose, what loads before anyone clicks, and what "reject" and "accept" change.

Chromium

Run on production on 2026-10-12: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Your scraper's exit country decides which version of a site it gets. From the EU most news and media sites put a consent banner over the page, run IAB Europe's TCF consent API and are supposed to hold back ad and analytics tags until you choose; from the US the same sites often show nothing and load everything. That changes what you see (an overlay hiding the content), how much bandwidth a page costs and which cookies your session carries afterwards. How big is the difference, and what do "reject" and "accept" actually do?

What we used, and why

WhatWhy
PROXY_URL_DE and PROXY_URL_USOne headful Chromium per country; every site is loaded in a fresh context from each.
A probe in page.evaluateDetects the consent platform (OneTrust, Sourcepoint, Didomi, Cookiebot, Usercentrics), whether a OneTrust or Sourcepoint banner is visible, and the consent APIs the page exposes: __tcfapi (IAB Europe's TCF) and __gpp (IAB Tech Lab's GPP, used for US state privacy laws).
locator.waitFor({ state: "visible" })Waits up to 20 s for the banner and records how long after domcontentloaded it appeared.
page.on("request")Counts the hosts on other domains the page contacted — ad, analytics and CDN hosts — up to each moment.
context.cookies()All cookies in the context, including third-party ones a page script can't see.
frameLocator('iframe[id^="sp_message_iframe"]')Sourcepoint draws its banner in an iframe; OneTrust's buttons (#onetrust-reject-all-handler, #onetrust-accept-btn-handler) are in the page.

How it works

  1. Launch Chromium through a US proxy and load each site in a fresh context; wait for a banner (up to 20 s, at least 6 s in all) and record the platform, banner, consent APIs, cookies and other-domain hosts. Then close that session and launch one through a German proxy (an idle session on shared threads ends after 60 s, so the countries run one after the other).
  2. From Germany, load each site twice in fresh contexts: record the same before any choice, then click the first-layer "reject" in one and "accept" in the other — clicking once more if the banner is still up 5 s later — and measure again 6 s after that.
  3. A free "reject" counts only if the first layer offers one — "Reject all and subscribe" isn't clicked. A page that answers with an error status is loaded once more (the rotating proxy gives the retry another IP).

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL_DE (a German proxy), PROXY_URL_US (a US proxy)
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const SITES = ['https://www.theguardian.com/international', 'https://www.bbc.com/', 'https://apnews.com/', 'https://www.theverge.com/', 'https://www.huffpost.com/'];
// First-layer buttons of the two consent platforms these sites use. OneTrust renders in the
// page; Sourcepoint in an iframe. Only a free "reject" counts: "Reject all and subscribe" doesn't.
const REJECT = { OneTrust: '#onetrust-reject-all-handler', Sourcepoint: 'button[title="Reject all"], button[title="I do not agree"]' };
const ACCEPT = { OneTrust: '#onetrust-accept-btn-handler', Sourcepoint: 'button[title="Accept all"], button[title="I agree"]' };
const SP_FRAME = 'iframe[id^="sp_message_iframe"]';
const BANNER = '#onetrust-banner-sdk, iframe[id^="sp_message_iframe"]';

// Which consent platform the page runs, whether its banner is showing, and which consent
// APIs it exposes (__tcfapi: IAB Europe's TCF; __gpp: IAB Tech Lab's GPP, used for US state
// privacy laws).
const PROBE = `() => {
  const shown = (s) => [...document.querySelectorAll(s)].some((e) => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden');
  const cmp = window.OneTrust || document.querySelector('#onetrust-consent-sdk') ? 'OneTrust'
    : window._sp_ || document.querySelector('iframe[id^="sp_message_iframe"]') ? 'Sourcepoint'
    : window.Didomi ? 'Didomi' : window.Cookiebot ? 'Cookiebot' : window.UC_UI ? 'Usercentrics' : null;
  return { cmp, banner: shown('#onetrust-banner-sdk, iframe[id^="sp_message_iframe"]'),
    tcf: typeof window.__tcfapi === 'function', gpp: typeof window.__gpp === 'function' };
}`;

async function launch(proxy) {
  const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ proxy, headless: false }),
  });
  if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
  const { wsUrl } = await res.json();
  return chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
}

const onDomain = (host, domain) => host === domain || host.endsWith(`.${domain}`);

// Clicks a consent button; true if the click went through (one retry: a consent iframe that
// re-renders can swallow a click).
async function press(button) {
  for (let attempt = 1; attempt <= 2; attempt++) {
    try { await button.click({ timeout: 10000 }); return true; } catch { /* retry once */ }
  }
  return false;
}

// Load a site in a fresh context, then optionally click a consent button. Counts cookies and
// the hosts on other domains the page contacted (ads, analytics, CDNs) up to each moment.
async function visit(browser, url, choice) {
  const domain = new URL(url).hostname.split('.').slice(-2).join('.');
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  const page = await ctx.newPage();
  const hosts = new Set();
  page.on('request', (r) => { const h = new URL(r.url()).hostname; if (h && !onDomain(h, domain)) hosts.add(h); });
  const measure = async () => {
    const cookies = await ctx.cookies();
    return { cookies: cookies.length, third_party_cookies: cookies.filter((c) => !onDomain(c.domain.replace(/^\./, ''), domain)).length, other_hosts: hosts.size };
  };
  try {
    // Proxies drop a connection now and then: one retry.
    const open = () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    const res = await open().catch(open);
    // Consent scripts load late on busy pages: wait up to 20 s for the banner, and give
    // every page at least 6 s before measuring.
    const t0 = Date.now();
    const seen = await page.locator(BANNER).first().waitFor({ state: 'visible', timeout: 20000 }).then(() => true, () => false);
    const bannerAfterMs = seen ? Date.now() - t0 : null;
    if (Date.now() - t0 < 6000) await page.waitForTimeout(6000 - (Date.now() - t0));
    const probe = await page.evaluate(`(${PROBE})()`);
    const before = { status: res?.status() ?? null, ...probe, banner_after_ms: bannerAfterMs, ...(await measure()) };
    if (!choice) return { before };
    const selector = (choice === 'reject' ? REJECT : ACCEPT)[probe.cmp];
    const scope = probe.cmp === 'Sourcepoint' ? page.frameLocator(SP_FRAME) : page;
    const button = selector && scope.locator(selector).first();
    if (!button || !(await button.waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false))) {
      return { before, after: null, missing: probe.banner ? `no ${choice} button on the first layer` : 'no banner' };
    }
    const label = (await button.getAttribute('title')) || (await button.innerText()).trim();
    await page.waitForTimeout(2000); // let the banner finish animating in, or the click can be lost
    if (!(await press(button))) return { before, after: null, missing: 'click failed' };
    // If the banner is still up, the click was lost: click once more.
    let clicks = 1;
    if (!(await page.locator(BANNER).first().waitFor({ state: 'hidden', timeout: 5000 }).then(() => true, () => false))) {
      clicks = 2;
      await press(button);
    }
    await page.waitForTimeout(6000);
    return { before, after: { button: label, clicks, ...(await measure()) } };
  } finally {
    await ctx.close();
  }
}

// A site may refuse one exit IP (an error status): visit again in a fresh context, whose new
// proxy connection gets another IP from the rotating proxy.
async function visitOnce(browser, url, choice) {
  const r = await visit(browser, url, choice);
  return r.before.status >= 200 && r.before.status < 300 ? r : visit(browser, url, choice);
}

// Sessions on shared threads end after 60 s without traffic, so the countries run one after the other.
const fromUs = [];
const us = await launch(process.env.PROXY_URL_US);
try {
  for (const url of SITES) fromUs.push(await visitOnce(us, url));
} finally {
  await us.close();
}
const sites = [];
const de = await launch(process.env.PROXY_URL_DE);
try {
  for (const [i, url] of SITES.entries()) {
    const rejected = await visitOnce(de, url, 'reject');
    const accepted = await visitOnce(de, url, 'accept');
    sites.push({
      site: new URL(url).hostname.replace(/^www\./, ''), us: fromUs[i].before, de: rejected.before,
      de_reject: rejected.after, de_reject_missing: rejected.missing ?? null, de_accept: accepted.after, de_accept_missing: accepted.missing ?? null,
    });
  }
} finally {
  await de.close();
}
console.log(JSON.stringify({ sites }, null, 2));

What we got

SiteExit / choiceBannerCookiesThird-party cookiesOther-domain hosts
theguardian.comUS · Sourcepoint, GPPyes, after 9.3 s331666
theguardian.comDE · before a choice · Sourcepoint, TCFyes, after 4.1 s705
theguardian.comDE · reject: no reject button on the first layer———
theguardian.comDE · "Accept all"11390148
bbc.comUS · Sourcepoint, GPP, TCFno511769
bbc.comDE · before a choice · Sourcepoint, TCF, GPPyes, after 2.5 s18216
bbc.comDE · "I do not agree"30433
bbc.comDE · "I agree"441548
apnews.comUS · OneTrust, GPPyes, after 9.0 s11175195
apnews.comDE · before a choice · OneTrust, TCFyes, after 0.4 s26372
apnews.comDE · "I Reject All (except Strictly Necessary)"35379
apnews.comDE · "I Accept All"174142254
theverge.comUS · OneTrust, GPPno247223219
theverge.comDE · before a choice · OneTrust, TCFyes, after 1.9 s8034
theverge.comDE · "Reject Non-Essential"12040
theverge.comDE · "Accept"481487
huffpost.comUS · no platform, GPPno228218294
huffpost.comDE · before a choice · OneTrust, TCFyes, after 0.7 s8062
huffpost.comDE · "Reject All"11065
huffpost.comDE · "I Accept"186154252

From the Node.js run on 2026-10-12. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "sites": [
    {
      "site": "theguardian.com",
      "us": {
        "status": 200,
        "cmp": "Sourcepoint",
        "banner": true,
        "tcf": false,
        "gpp": true,
        "banner_after_ms": 9289,
        "cookies": 33,
        "third_party_cookies": 16,
        "other_hosts": 66
      },
      "de": {
        "status": 200,
        "cmp": "Sourcepoint",
        "banner": true,
        "tcf": true,
        "gpp": false,
        "banner_after_ms": 4100,
        "cookies": 7,
        "third_party_cookies": 0,
        "other_hosts": 5
      },
      "de_reject": null,
      "de_reject_missing": "no reject button on the first layer",
      "de_accept": {
        "button": "Accept all",
        "clicks": 1,
        "cookies": 113,
        "third_party_cookies": 90,
        "other_hosts": 148
      },
      "de_accept_missing": null
    },
    {
      "site": "bbc.com",
      "us": {
        "status": 200,
        "cmp": "Sourcepoint",
        "banner": false,
        "tcf": true,
        "gpp": true,
        "banner_after_ms": null,
        "cookies": 51,
        "third_party_cookies": 17,
        "other_hosts": 69
      },
      "de": {
        "status": 200,
        "cmp": "Sourcepoint",
        "banner": true,
        "tcf": true,
        "gpp": true,
        "banner_after_ms": 2527,
        "cookies": 18,
        "third_party_cookies": 2,
        "other_hosts": 16
      },
      "de_reject": {
        "button": "I do not agree",
        "clicks": 1,
        "cookies": 30,
        "third_party_cookies": 4,
        "other_hosts": 33
      },
      "de_reject_missing": null,
      "de_accept": {
        "button": "I agree",
        "clicks": 1,
        "cookies": 44,
        "third_party_cookies": 15,
        "other_hosts": 48
      },
      "de_accept_missing": null
    },
    {
      "site": "apnews.com",
      "us": {
        "status": 200,
        "cmp": "OneTrust",
        "banner": true,
        "tcf": false,
        "gpp": true,
        "banner_after_ms": 9002,
        "cookies": 111,
        "third_party_cookies": 75,
        "other_hosts": 195
      },
      "de": {
        "status": 200,
        "cmp": "OneTrust",
        "banner": true,
        "tcf": true,
        "gpp": false,
        "banner_after_ms": 373,
        "cookies": 26,
        "third_party_cookies": 3,
        "other_hosts": 72
      },
      "de_reject": {
        "button": "I Reject All (except Strictly Necessary)",
        "clicks": 1,
        "cookies": 35,
        "third_party_cookies": 3,
        "other_hosts": 79
      },
      "de_reject_missing": null,
      "de_accept": {
        "button": "I Accept All",
        "clicks": 1,
        "cookies": 174,
        "third_party_cookies": 142,
        "other_hosts": 254
      },
      "de_accept_missing": null
    },
    {
      "site": "theverge.com",
      "us": {
        "status": 200,
        "cmp": "OneTrust",
        "banner": false,
        "tcf": false,
        "gpp": true,
        "banner_after_ms": null,
        "cookies": 247,
        "third_party_cookies": 223,
        "other_hosts": 219
      },
      "de": {
        "status": 200,
        "cmp": "OneTrust",
        "banner": true,
        "tcf": true,
        "gpp": false,
        "banner_after_ms": 1903,
        "cookies": 8,
        "third_party_cookies": 0,
        "other_hosts": 34
      },
      "de_reject": {
        "button": "Reject Non-Essential",
        "clicks": 1,
        "cookies": 12,
        "third_party_cookies": 0,
        "other_hosts": 40
      },
      "de_reject_missing": null,
      "de_accept": {
        "button": "Accept",
        "clicks": 1,
        "cookies": 48,
        "third_party_cookies": 14,
        "other_hosts": 87
      },
      "de_accept_missing": null
    },
    {
      "site": "huffpost.com",
      "us": {
        "status": 200,
        "cmp": null,
        "banner": false,
        "tcf": false,
        "gpp": true,
        "banner_after_ms": null,
        "cookies": 228,
        "third_party_cookies": 218,
        "other_hosts": 294
      },
      "de": {
        "status": 200,
        "cmp": "OneTrust",
        "banner": true,
        "tcf": true,
        "gpp": false,
        "banner_after_ms": 703,
        "cookies": 8,
        "third_party_cookies": 0,
        "other_hosts": 62
      },
      "de_reject": {
        "button": "Reject All",
        "clicks": 1,
        "cookies": 11,
        "third_party_cookies": 0,
        "other_hosts": 65
      },
      "de_reject_missing": null,
      "de_accept": {
        "button": "I Accept",
        "clicks": 1,
        "cookies": 186,
        "third_party_cookies": 154,
        "other_hosts": 252
      },
      "de_accept_missing": null
    }
  ]
}

Takeaways