[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #87 · 2026-11-08 · Example · Easy · for [geo testing](https://cdpfleet.com/geo-testing), [web scraping](https://cdpfleet.com/web-scraping)

# Geo compare: the same pages from five countries in one call, and reading the diff

One \`POST /v1/functions/geo\` per page loads it from the US, Germany, the UK, Japan and Brazil at once and returns each country's Markdown and HTML plus a diff against the first. A Steam store page, Google's home page, Nike's home page and example.com: prices and currency, language, cookie banners, country redirects — and what to check before you trust the diff. In five languages.

[Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

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

## The problem

Prices, currency, language, cookie banners and even which page you get depend on where the visitor is. Checking that usually means one browser or proxy per country, the same page loaded in each, and a diff tool at the end. The [geo compare Function](https://cdpfleet.com/docs/functions/geo) does all of it in one HTTP request: it opens the page in up to five countries at once, each in its own tab with that country's exit IP, locale, timezone and `Accept-Language`, and returns every country's result plus a unified diff of each text format against the first country. This case runs it on four pages and reads what came back — including what the diff doesn't tell you.

## What we used, and why

| What | Why |
| --- | --- |
| `POST https://cdpfleet.com/v1/functions/geo` | `countries: ["us", "de", "gb", "jp", "br"]` (2–5, the first is the baseline), `formats: ["markdown", "html"]` (any [scrape](https://cdpfleet.com/docs/functions/scrape) formats), `max_wait: 45`. No `proxy`: each country runs through the Community proxy in that country. |
| `results.<country>` | `{status, partial?, url, markdown, html}` for a country that loaded — `url` is the final URL after redirects — and `{status: null, error}` for one that didn't. |
| `diff.markdown.<country>`, `diff.html.<country>` | A unified diff (`--- us`, `+++ de`, `@@` hunks) of each text format against the first country; `""` when the two are identical, `null` when this country or the baseline failed. The programs count its hunks and added/removed lines and pair the prices on removed and added lines (`$9.99 → £8.19`). |
| The HTML | `<html lang>` for the language and an "accept all" button label (`Accept all`, `Alle akzeptieren`, `Aceitar todos`, `すべて同意`, …) for a cookie banner; prices in the Markdown by currency symbol. |
| `x-call-id` + `GET /v1/me/sessions` | The call's record in the account's history: engine `function:geo`, its weight (one thread per country) and the thread-seconds billed. |
| One retry | When the baseline country failed, the call is made once more: every diff is against it, and then every diff comes back `null`. |
| A failure on purpose | The Steam page from `["us", "de"]` with `max_wait: 1`, Markdown only: no country can load it in a second, so the programs see what a failed call looks like. |

## How it works

1.  For each page (a Steam store page, Google's home page, Nike's home page, example.com as the control), one geo call with the five countries — one page after another.
2.  If the first country (`us`) didn't load, call once more and keep what the first attempt said.
3.  Per country: status or error, `lang`, final URL, title, prices and their currencies, whether a cookie banner is in the HTML, sizes, and whether the Markdown is identical to the baseline's.
4.  Per diff: bytes, hunks, lines added and removed, and the first price changes; per call: the time, the response size and how much of it is diff.
5.  Make the call that is meant to fail (`max_wait: 1`) and read its errors and its diff.
6.  Look each call up in the session history by its `x-call-id`: engine, weight and billed thread-seconds.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/87-geo-compare), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// Node.js 18+, no packages. env: CDPFLEET_API_KEY (five countries in one call hold five threads: a paid plan)
const KEY = process.env.CDPFLEET_API_KEY;
const API = 'https://cdpfleet.com';
const COUNTRIES = ['us', 'de', 'gb', 'jp', 'br']; // the first one is the baseline the diff compares with
const FORMATS = ['markdown', 'html'];
const MAX_WAIT = 45;
const PAGES = [
  ['Steam store page', 'https://store.steampowered.com/app/620/Portal_2/'],
  ['Google home page', 'https://www.google.com/'],
  ['Nike home page', 'https://www.nike.com/'],
  ['example.com (control)', 'https://example.com/'],
];
// Prices in the five countries' currencies: R$ before $, the symbol before or after the number.
// Plain [0-9] and [ \u00a0] rather than \d and \s, so the pattern means the same in all five languages.
const PRICE = /R\$[ \u00a0]?[0-9][0-9.,]*|US\$[ \u00a0]?[0-9][0-9.,]*|£[ \u00a0]?[0-9][0-9.,]*|€[ \u00a0]?[0-9][0-9.,]*|[0-9][0-9.,]*[ \u00a0]?€|[¥￥][ \u00a0]?[0-9][0-9,]*|[0-9][0-9,]*[ \u00a0]?円|\$[ \u00a0]?[0-9][0-9.,]*/g;
// The "accept all" button of a cookie banner, in the five countries' languages.
const CONSENT = /Accept all|Alle akzeptieren|Aceitar todos|Aceitar tudo|すべて同意|すべて受け入れる|すべて許可/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const bytes = (s) => (typeof s === 'string' ? Buffer.byteLength(s) : 0);
const since = new Date(Date.now() - 60000).toISOString();

const prices = (text) => (text.match(PRICE) || []).map((p) => p.replace(/[.,]$/, '').replace('\u00a0', ' '));
function currency(p) {
  if (p.startsWith('R$')) return 'BRL';
  if (p.includes('£')) return 'GBP';
  if (p.includes('€')) return 'EUR';
  if (/[¥￥円]/.test(p)) return 'JPY';
  return 'USD';
}
const attr = (html, re) => (re.exec(html) || [])[1] ?? null;

// What the Function's unified diff (one country against the baseline) says.
function readDiff(d) {
  if (typeof d !== 'string') return null;
  const lines = d.split('\n');
  const added = lines.filter((l) => l.startsWith('+') && !l.startsWith('+++'));
  const removed = lines.filter((l) => l.startsWith('-') && !l.startsWith('---'));
  const was = removed.flatMap(prices);
  const now = added.flatMap(prices);
  const changes = [];
  for (let i = 0; i < Math.min(was.length, now.length) && changes.length < 3; i++) if (was[i] !== now[i]) changes.push(`${was[i]} → ${now[i]}`);
  return { bytes: bytes(d), hunks: lines.filter((l) => l.startsWith('@@')).length, added: added.length, removed: removed.length, price_changes: changes };
}

// The call in the account's history (its x-call-id is the session id there): weight and billed thread-seconds.
async function billed(id) {
  for (let i = 0; i < 30 && id; i++) {
    const r = await fetch(`${API}/v1/me/sessions?from=${encodeURIComponent(since)}&limit=200`, { headers: { 'x-api-key': KEY } });
    const s = (await r.json()).sessions?.find((x) => x.session_id === id);
    if (s?.ended_at) return { engine: s.engine, weight: s.weight, thread_seconds: Math.round((Date.parse(s.ended_at) - Date.parse(s.started_at)) * s.weight / 100) / 10 };
    await sleep(2000);
  }
  return null;
}

async function geo(url, countries = COUNTRIES, maxWait = MAX_WAIT, formats = FORMATS) {
  const t = Date.now();
  const res = await fetch(`${API}/v1/functions/geo`, {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ url, countries, formats, max_wait: maxWait }),
    signal: AbortSignal.timeout((maxWait + 30) * 1000),
  });
  const text = await res.text();
  let body = {};
  try { body = JSON.parse(text); } catch { /* not JSON */ }
  return { res, text, body, ms: Date.now() - t };
}
const loaded = (r) => r?.status >= 200 && r?.status < 400;

const pages = [];
for (const [name, url] of PAGES) { // one call per page, one after another
  let call = await geo(url);
  // Every diff is against the first country: if that one failed, the call is retried once.
  const first = call;
  if (call.res.status === 200 && !loaded(call.body.results?.[COUNTRIES[0]])) call = await geo(url);
  const { res, text, body, ms } = call;
  const results = body.results || {};
  const base = results[COUNTRIES[0]] || {};
  const countries = {};
  for (const c of COUNTRIES) {
    const r = results[c] || {};
    const html = r.html || '';
    const md = r.markdown || '';
    const found = prices(md);
    countries[c] = {
      status: r.status ?? null,
      error: r.error ?? null,
      partial: r.partial === true,
      lang: attr(html, /<html[^>]*\slang="([^"]+)"/i),
      url: r.url ?? null, // the final URL after redirects (only for a country that loaded)
      title: attr(html, /<title[^>]*>([^<]*)</i)?.trim() ?? null,
      currencies: [...new Set(found.map(currency))],
      prices: found.slice(0, 4),
      consent_banner: html ? CONSENT.test(html) : null,
      markdown_bytes: bytes(md),
      html_bytes: bytes(html),
      same_markdown_as_baseline: c === COUNTRIES[0] || !md || !base.markdown ? null : md === base.markdown,
      // A diff is a string ("" = identical), or null when this country or the baseline failed.
      diff: c === COUNTRIES[0] ? null : { markdown: readDiff(body.diff?.markdown?.[c]), html_bytes: body.diff?.html?.[c] === null ? null : bytes(body.diff?.html?.[c]) },
    };
  }
  pages.push({
    page: name,
    url,
    attempts: call === first ? 1 : 2,
    first_attempt: call === first ? null : {
      call_id: first.res.headers.get('x-call-id'),
      errors: Object.fromEntries(COUNTRIES.filter((c) => !loaded(first.body.results?.[c])).map((c) => [c, first.body.results?.[c]?.error ?? null])),
      diffs: Object.fromEntries(Object.entries(first.body.diff?.markdown || {}).map(([c, d]) => [c, d === null ? null : bytes(d)])),
    },
    status: res.status,
    error: body.error ?? null,
    seconds: Math.round(ms / 100) / 10,
    call_id: res.headers.get('x-call-id'),
    warning: res.headers.get('x-cdpfleet-warning'),
    response_bytes: bytes(text),
    diff_bytes: { markdown: Object.values(body.diff?.markdown || {}).reduce((a, d) => a + bytes(d), 0), html: Object.values(body.diff?.html || {}).reduce((a, d) => a + bytes(d), 0) },
    ok_countries: COUNTRIES.filter((c) => loaded(results[c])),
    countries,
  });
}
// What a failure looks like: the same Steam page with max_wait 1, so no country can load it in time.
const failed = await geo('https://store.steampowered.com/app/620/Portal_2/', ['us', 'de'], 1, ['markdown']);
const failure = {
  status: failed.res.status, max_wait: 1,
  results: Object.fromEntries(Object.entries(failed.body.results || {}).map(([c, r]) => [c, { status: r.status ?? null, error: r.error ?? null }])),
  diff: failed.body.diff?.markdown ?? null,
};

for (const p of pages) {
  p.billed = await billed(p.call_id);
  if (p.first_attempt) p.first_attempt.billed = await billed(p.first_attempt.call_id);
}

console.log(JSON.stringify({ language: 'node', countries: COUNTRIES, formats: FORMATS, max_wait: MAX_WAIT, pages, failure, measured_at: new Date().toISOString().slice(0, 10) }, null, 2));
```

```python
# pip install requests
# env: CDPFLEET_API_KEY (five countries in one call hold five threads: a paid plan)
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone

import requests

KEY = os.environ["CDPFLEET_API_KEY"]
API = "https://cdpfleet.com"
COUNTRIES = ["us", "de", "gb", "jp", "br"]  # the first one is the baseline the diff compares with
FORMATS = ["markdown", "html"]
MAX_WAIT = 45
PAGES = [
    ("Steam store page", "https://store.steampowered.com/app/620/Portal_2/"),
    ("Google home page", "https://www.google.com/"),
    ("Nike home page", "https://www.nike.com/"),
    ("example.com (control)", "https://example.com/"),
]
# Prices in the five countries' currencies: R$ before $, the symbol before or after the number.
# Plain [0-9] and [ \u00a0] rather than \d and \s, so the pattern means the same in all five languages.
PRICE = re.compile(
    "R\\$[ \u00a0]?[0-9][0-9.,]*|US\\$[ \u00a0]?[0-9][0-9.,]*|£[ \u00a0]?[0-9][0-9.,]*|€[ \u00a0]?[0-9][0-9.,]*"
    "|[0-9][0-9.,]*[ \u00a0]?€|[¥￥][ \u00a0]?[0-9][0-9,]*|[0-9][0-9,]*[ \u00a0]?円|\\$[ \u00a0]?[0-9][0-9.,]*"
)
# The "accept all" button of a cookie banner, in the five countries' languages.
CONSENT = re.compile("Accept all|Alle akzeptieren|Aceitar todos|Aceitar tudo|すべて同意|すべて受け入れる|すべて許可", re.I)
SINCE = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat().replace("+00:00", "Z")
HEADERS = {"x-api-key": KEY}

def nbytes(s):
    return len(s.encode()) if isinstance(s, str) else 0

def prices(text):
    return [re.sub("[.,]$", "", p).replace("\u00a0", " ", 1) for p in PRICE.findall(text)]

def currency(p):
    if p.startswith("R$"):
        return "BRL"
    if "£" in p:
        return "GBP"
    if "€" in p:
        return "EUR"
    if re.search("[¥￥円]", p):
        return "JPY"
    return "USD"

def attr(html, pattern):
    m = re.search(pattern, html, re.I)
    return m.group(1) if m else None

def read_diff(d):  # what the Function's unified diff (one country against the baseline) says
    if not isinstance(d, str):
        return None
    lines = d.split("\n")
    added = [l for l in lines if l.startswith("+") and not l.startswith("+++")]
    removed = [l for l in lines if l.startswith("-") and not l.startswith("---")]
    was = [p for l in removed for p in prices(l)]
    now = [p for l in added for p in prices(l)]
    changes = []
    for a, b in zip(was, now):
        if len(changes) == 3:
            break
        if a != b:
            changes.append(f"{a} → {b}")
    return {"bytes": nbytes(d), "hunks": sum(l.startswith("@@") for l in lines), "added": len(added),
            "removed": len(removed), "price_changes": changes}

def billed(call_id):
    # The call in the account's history (its x-call-id is the session id there): weight and billed thread-seconds.
    for _ in range(30 if call_id else 0):
        r = requests.get(f"{API}/v1/me/sessions", params={"from": SINCE, "limit": 200}, headers=HEADERS, timeout=30)
        s = next((x for x in r.json().get("sessions", []) if x["session_id"] == call_id), None)
        if s and s.get("ended_at"):
            t = lambda v: datetime.fromisoformat(v.replace("Z", "+00:00")).timestamp()
            return {"engine": s["engine"], "weight": s["weight"],
                    "thread_seconds": round((t(s["ended_at"]) - t(s["started_at"])) * s["weight"], 1)}
        time.sleep(2)
    return None

def geo(url, countries=COUNTRIES, max_wait=MAX_WAIT, formats=FORMATS):
    t = time.time()
    res = requests.post(f"{API}/v1/functions/geo", headers=HEADERS, timeout=max_wait + 30,
                        json={"url": url, "countries": countries, "formats": formats, "max_wait": max_wait})
    try:
        body = res.json()
    except ValueError:
        body = {}
    return {"res": res, "body": body if isinstance(body, dict) else {}, "ms": (time.time() - t) * 1000}

def loaded(r):
    return isinstance(r, dict) and isinstance(r.get("status"), int) and 200 <= r["status"] < 400

pages = []
for name, url in PAGES:  # one call per page, one after another
    call = first = geo(url)
    # Every diff is against the first country: if that one failed, the call is retried once.
    if call["res"].status_code == 200 and not loaded(call["body"].get("results", {}).get(COUNTRIES[0])):
        call = geo(url)
    res, body = call["res"], call["body"]
    results = body.get("results") or {}
    diff = body.get("diff") or {}
    base = results.get(COUNTRIES[0]) or {}
    countries = {}
    for c in COUNTRIES:
        r = results.get(c) or {}
        html = r.get("html") or ""
        md = r.get("markdown") or ""
        found = prices(md)
        title = attr(html, r"<title[^>]*>([^<]*)<")
        countries[c] = {
            "status": r.get("status"),
            "error": r.get("error"),
            "partial": r.get("partial") is True,
            "lang": attr(html, r'<html[^>]*\slang="([^"]+)"'),
            "url": r.get("url"),  # the final URL after redirects (only for a country that loaded)
            "title": title.strip() if title is not None else None,
            "currencies": list(dict.fromkeys(currency(p) for p in found)),
            "prices": found[:4],
            "consent_banner": bool(CONSENT.search(html)) if html else None,
            "markdown_bytes": nbytes(md),
            "html_bytes": nbytes(html),
            "same_markdown_as_baseline": None if c == COUNTRIES[0] or not md or not base.get("markdown") else md == base["markdown"],
            # A diff is a string ("" = identical), or None (null) when this country or the baseline failed.
            "diff": None if c == COUNTRIES[0] else {
                "markdown": read_diff((diff.get("markdown") or {}).get(c)),
                "html_bytes": None if (diff.get("html") or {}).get(c) is None else nbytes(diff["html"][c]),
            },
        }
    fresults = first["body"].get("results") or {}
    pages.append({
        "page": name,
        "url": url,
        "attempts": 1 if call is first else 2,
        "first_attempt": None if call is first else {
            "call_id": first["res"].headers.get("x-call-id"),
            "errors": {c: (fresults.get(c) or {}).get("error") for c in COUNTRIES if not loaded(fresults.get(c))},
            "diffs": {c: None if d is None else nbytes(d) for c, d in ((first["body"].get("diff") or {}).get("markdown") or {}).items()},
        },
        "status": res.status_code,
        "error": body.get("error"),
        "seconds": round(call["ms"] / 1000, 1),
        "call_id": res.headers.get("x-call-id"),
        "warning": res.headers.get("x-cdpfleet-warning"),
        "response_bytes": len(res.content),
        "diff_bytes": {f: sum(nbytes(d) for d in (diff.get(f) or {}).values()) for f in ("markdown", "html")},
        "ok_countries": [c for c in COUNTRIES if loaded(results.get(c))],
        "countries": countries,
    })

# What a failure looks like: the same Steam page with max_wait 1, so no country can load it in time.
failed = geo("https://store.steampowered.com/app/620/Portal_2/", ["us", "de"], 1, ["markdown"])
failure = {
    "status": failed["res"].status_code, "max_wait": 1,
    "results": {c: {"status": r.get("status"), "error": r.get("error")} for c, r in (failed["body"].get("results") or {}).items()},
    "diff": (failed["body"].get("diff") or {}).get("markdown"),
}

for p in pages:
    p["billed"] = billed(p["call_id"])
    if p["first_attempt"]:
        p["first_attempt"]["billed"] = billed(p["first_attempt"]["call_id"])

print(json.dumps({"language": "python", "countries": COUNTRIES, "formats": FORMATS, "max_wait": MAX_WAIT, "pages": pages, "failure": failure,
                  "measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d")}, indent=2, ensure_ascii=False))
```

```java
// Java 21. Maven: com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY (five countries in one call hold five threads: a paid plan)
import com.google.gson.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import java.util.regex.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String API = "https://cdpfleet.com";
  static final List<String> COUNTRIES = List.of("us", "de", "gb", "jp", "br"); // the first one is the baseline the diff compares with
  static final List<String> FORMATS = List.of("markdown", "html");
  static final int MAX_WAIT = 45;
  static final String[][] PAGES = {
    {"Steam store page", "https://store.steampowered.com/app/620/Portal_2/"},
    {"Google home page", "https://www.google.com/"},
    {"Nike home page", "https://www.nike.com/"},
    {"example.com (control)", "https://example.com/"},
  };
  // Prices in the five countries' currencies: R$ before $, the symbol before or after the number.
  // Plain [0-9] and [ \u00a0] rather than \d and \s, so the pattern means the same in all five languages.
  static final Pattern PRICE = Pattern.compile(
    "R\\$[ \u00a0]?[0-9][0-9.,]*|US\\$[ \u00a0]?[0-9][0-9.,]*|£[ \u00a0]?[0-9][0-9.,]*|€[ \u00a0]?[0-9][0-9.,]*"
    + "|[0-9][0-9.,]*[ \u00a0]?€|[¥￥][ \u00a0]?[0-9][0-9,]*|[0-9][0-9,]*[ \u00a0]?円|\\$[ \u00a0]?[0-9][0-9.,]*");
  // The "accept all" button of a cookie banner, in the five countries' languages.
  static final Pattern CONSENT = Pattern.compile("Accept all|Alle akzeptieren|Aceitar todos|Aceitar tudo|すべて同意|すべて受け入れる|すべて許可",
    Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
  static final String SINCE = Instant.now().minusSeconds(60).toString();
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();

  record Call(HttpResponse<byte[]> res, JsonObject body, long ms) {}

  static int bytes(JsonElement e) { return e != null && e.isJsonPrimitive() ? e.getAsString().getBytes(StandardCharsets.UTF_8).length : 0; }
  // A diff's size, or null when the diff is null (this country or the baseline failed).
  static JsonElement diffBytes(JsonElement e) { return e == null || e.isJsonNull() ? JsonNull.INSTANCE : new JsonPrimitive(bytes(e)); }
  static String str(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null; }
  static JsonObject obj(JsonObject o, String k) { return o != null && o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : new JsonObject(); }
  static JsonArray arr(Collection<String> xs) { JsonArray a = new JsonArray(); xs.forEach(a::add); return a; }

  static List<String> prices(String text) {
    List<String> out = new ArrayList<>();
    Matcher m = PRICE.matcher(text);
    while (m.find()) out.add(m.group().replaceAll("[.,]$", "").replaceFirst("\u00a0", " "));
    return out;
  }
  static String currency(String p) {
    if (p.startsWith("R$")) return "BRL";
    if (p.contains("£")) return "GBP";
    if (p.contains("€")) return "EUR";
    if (p.matches(".*[¥￥円].*")) return "JPY";
    return "USD";
  }
  static String attr(String html, String regex) {
    Matcher m = Pattern.compile(regex, Pattern.CASE_INSENSITIVE).matcher(html);
    return m.find() ? m.group(1) : null;
  }

  // What the Function's unified diff (one country against the baseline) says.
  static JsonElement readDiff(JsonElement d) {
    if (d == null || !d.isJsonPrimitive()) return JsonNull.INSTANCE;
    String[] lines = d.getAsString().split("\n", -1);
    List<String> was = new ArrayList<>(), now = new ArrayList<>();
    int added = 0, removed = 0, hunks = 0;
    for (String l : lines) {
      if (l.startsWith("@@")) hunks++;
      else if (l.startsWith("+") && !l.startsWith("+++")) { added++; now.addAll(prices(l)); }
      else if (l.startsWith("-") && !l.startsWith("---")) { removed++; was.addAll(prices(l)); }
    }
    List<String> changes = new ArrayList<>();
    for (int i = 0; i < Math.min(was.size(), now.size()) && changes.size() < 3; i++) if (!was.get(i).equals(now.get(i))) changes.add(was.get(i) + " → " + now.get(i));
    JsonObject o = new JsonObject();
    o.addProperty("bytes", bytes(d));
    o.addProperty("hunks", hunks);
    o.addProperty("added", added);
    o.addProperty("removed", removed);
    o.add("price_changes", arr(changes));
    return o;
  }

  // The call in the account's history (its x-call-id is the session id there): weight and billed thread-seconds.
  static JsonElement billed(String id) throws Exception {
    for (int i = 0; i < 30 && id != null; i++) {
      HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/v1/me/sessions?limit=200&from=" + URLEncoder.encode(SINCE, StandardCharsets.UTF_8)))
        .header("x-api-key", KEY).timeout(Duration.ofSeconds(30)).build();
      JsonObject r = JsonParser.parseString(HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()).getAsJsonObject();
      for (JsonElement e : r.getAsJsonArray("sessions")) {
        JsonObject s = e.getAsJsonObject();
        if (!id.equals(str(s, "session_id")) || str(s, "ended_at") == null) continue;
        int weight = s.get("weight").getAsInt();
        long ms = Duration.between(Instant.parse(str(s, "started_at")), Instant.parse(str(s, "ended_at"))).toMillis();
        JsonObject o = new JsonObject();
        o.addProperty("engine", str(s, "engine"));
        o.addProperty("weight", weight);
        o.addProperty("thread_seconds", Math.round(ms * weight / 100.0) / 10.0);
        return o;
      }
      Thread.sleep(2000);
    }
    return JsonNull.INSTANCE;
  }

  static Call geo(String url, List<String> countries, int maxWait, List<String> formats) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("url", url);
    body.add("countries", arr(countries));
    body.add("formats", arr(formats));
    body.addProperty("max_wait", maxWait);
    long t = System.currentTimeMillis();
    HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/v1/functions/geo")).timeout(Duration.ofSeconds(maxWait + 30))
      .header("x-api-key", KEY).header("content-type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
    HttpResponse<byte[]> res = HTTP.send(req, HttpResponse.BodyHandlers.ofByteArray());
    JsonObject json;
    try { json = JsonParser.parseString(new String(res.body(), StandardCharsets.UTF_8)).getAsJsonObject(); } catch (Exception e) { json = new JsonObject(); }
    return new Call(res, json, System.currentTimeMillis() - t);
  }
  static boolean loaded(JsonObject r) {
    if (r == null || !r.has("status") || r.get("status").isJsonNull()) return false;
    int s = r.get("status").getAsInt();
    return s >= 200 && s < 400;
  }

  public static void main(String[] args) throws Exception {
    JsonArray pages = new JsonArray();
    List<String[]> ids = new ArrayList<>(); // call ids to look up: [this one, the first attempt's or null]
    for (String[] p : PAGES) { // one call per page, one after another
      Call first = geo(p[1], COUNTRIES, MAX_WAIT, FORMATS), call = first;
      // Every diff is against the first country: if that one failed, the call is retried once.
      if (call.res().statusCode() == 200 && !loaded(obj(obj(call.body(), "results"), COUNTRIES.get(0)))) call = geo(p[1], COUNTRIES, MAX_WAIT, FORMATS);
      JsonObject results = obj(call.body(), "results"), diff = obj(call.body(), "diff");
      JsonObject base = obj(results, COUNTRIES.get(0));
      JsonObject countries = new JsonObject();
      for (String c : COUNTRIES) {
        JsonObject r = obj(results, c);
        String html = Objects.requireNonNullElse(str(r, "html"), "");
        String md = Objects.requireNonNullElse(str(r, "markdown"), "");
        List<String> found = prices(md);
        String title = attr(html, "<title[^>]*>([^<]*)<");
        JsonObject o = new JsonObject();
        o.add("status", r.has("status") ? r.get("status") : JsonNull.INSTANCE);
        o.addProperty("error", str(r, "error"));
        o.addProperty("partial", r.has("partial") && r.get("partial").isJsonPrimitive() && r.get("partial").getAsBoolean());
        o.addProperty("lang", attr(html, "<html[^>]*\\slang=\"([^\"]+)\""));
        o.addProperty("url", str(r, "url")); // the final URL after redirects (only for a country that loaded)
        o.addProperty("title", title == null ? null : title.trim());
        o.add("currencies", arr(new LinkedHashSet<>(found.stream().map(Main::currency).toList())));
        o.add("prices", arr(found.subList(0, Math.min(4, found.size()))));
        o.add("consent_banner", html.isEmpty() ? JsonNull.INSTANCE : new JsonPrimitive(CONSENT.matcher(html).find()));
        o.addProperty("markdown_bytes", md.getBytes(StandardCharsets.UTF_8).length);
        o.addProperty("html_bytes", html.getBytes(StandardCharsets.UTF_8).length);
        String baseMd = str(base, "markdown");
        o.add("same_markdown_as_baseline", c.equals(COUNTRIES.get(0)) || md.isEmpty() || baseMd == null || baseMd.isEmpty() ? JsonNull.INSTANCE : new JsonPrimitive(md.equals(baseMd)));
        if (c.equals(COUNTRIES.get(0))) o.add("diff", JsonNull.INSTANCE);
        else { // a diff is a string ("" = identical), or null when this country or the baseline failed
          JsonObject d = new JsonObject();
          d.add("markdown", readDiff(obj(diff, "markdown").get(c)));
          d.add("html_bytes", diffBytes(obj(diff, "html").get(c)));
          o.add("diff", d);
        }
        countries.add(c, o);
      }
      JsonObject page = new JsonObject();
      page.addProperty("page", p[0]);
      page.addProperty("url", p[1]);
      page.addProperty("attempts", call == first ? 1 : 2);
      if (call == first) page.add("first_attempt", JsonNull.INSTANCE);
      else {
        JsonObject fa = new JsonObject(), errors = new JsonObject(), diffs = new JsonObject();
        JsonObject fresults = obj(first.body(), "results");
        for (String c : COUNTRIES) if (!loaded(obj(fresults, c))) errors.addProperty(c, str(obj(fresults, c), "error"));
        for (Map.Entry<String, JsonElement> e : obj(obj(first.body(), "diff"), "markdown").entrySet()) diffs.add(e.getKey(), diffBytes(e.getValue()));
        fa.addProperty("call_id", first.res().headers().firstValue("x-call-id").orElse(null));
        fa.add("errors", errors);
        fa.add("diffs", diffs);
        page.add("first_attempt", fa);
      }
      page.addProperty("status", call.res().statusCode());
      page.addProperty("error", str(call.body(), "error"));
      page.addProperty("seconds", Math.round(call.ms() / 100.0) / 10.0);
      page.addProperty("call_id", call.res().headers().firstValue("x-call-id").orElse(null));
      page.addProperty("warning", call.res().headers().firstValue("x-cdpfleet-warning").orElse(null));
      page.addProperty("response_bytes", call.res().body().length);
      JsonObject diffBytes = new JsonObject();
      for (String f : List.of("markdown", "html")) diffBytes.addProperty(f, obj(diff, f).entrySet().stream().mapToInt((e) -> bytes(e.getValue())).sum());
      page.add("diff_bytes", diffBytes);
      page.add("ok_countries", arr(COUNTRIES.stream().filter((c) -> loaded(obj(results, c))).toList()));
      page.add("countries", countries);
      pages.add(page);
      ids.add(new String[] {page.get("call_id").isJsonNull() ? null : page.get("call_id").getAsString(), call == first ? null : first.res().headers().firstValue("x-call-id").orElse(null)});
    }
    // What a failure looks like: the same Steam page with max_wait 1, so no country can load it in time.
    Call failed = geo("https://store.steampowered.com/app/620/Portal_2/", List.of("us", "de"), 1, List.of("markdown"));
    JsonObject failure = new JsonObject(), failedResults = new JsonObject();
    failure.addProperty("status", failed.res().statusCode());
    failure.addProperty("max_wait", 1);
    for (Map.Entry<String, JsonElement> e : obj(failed.body(), "results").entrySet()) {
      JsonObject r = new JsonObject();
      r.add("status", e.getValue().getAsJsonObject().has("status") ? e.getValue().getAsJsonObject().get("status") : JsonNull.INSTANCE);
      r.addProperty("error", str(e.getValue().getAsJsonObject(), "error"));
      failedResults.add(e.getKey(), r);
    }
    failure.add("results", failedResults);
    JsonObject failedDiff = obj(failed.body(), "diff");
    failure.add("diff", failedDiff.has("markdown") ? failedDiff.get("markdown") : JsonNull.INSTANCE);

    for (int i = 0; i < pages.size(); i++) {
      JsonObject page = pages.get(i).getAsJsonObject();
      page.add("billed", billed(ids.get(i)[0]));
      if (ids.get(i)[1] != null) page.getAsJsonObject("first_attempt").add("billed", billed(ids.get(i)[1]));
    }
    JsonObject out = new JsonObject();
    out.addProperty("language", "java");
    out.add("countries", arr(COUNTRIES));
    out.add("formats", arr(FORMATS));
    out.addProperty("max_wait", MAX_WAIT);
    out.add("pages", pages);
    out.add("failure", failure);
    out.addProperty("measured_at", LocalDate.now(ZoneOffset.UTC).toString());
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// .NET 8+, no packages. env: CDPFLEET_API_KEY (five countries in one call hold five threads: a paid plan)
using System.Diagnostics;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;

var KEY = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string API = "https://cdpfleet.com";
string[] COUNTRIES = ["us", "de", "gb", "jp", "br"]; // the first one is the baseline the diff compares with
string[] FORMATS = ["markdown", "html"];
const int MAX_WAIT = 45;
(string Name, string Url)[] PAGES = [
    ("Steam store page", "https://store.steampowered.com/app/620/Portal_2/"),
    ("Google home page", "https://www.google.com/"),
    ("Nike home page", "https://www.nike.com/"),
    ("example.com (control)", "https://example.com/"),
];
// Prices in the five countries' currencies: R$ before $, the symbol before or after the number.
// Plain [0-9] and [ \u00a0] rather than \d and \s, so the pattern means the same in all five languages.
var PRICE = new Regex(@"R\$[ \u00a0]?[0-9][0-9.,]*|US\$[ \u00a0]?[0-9][0-9.,]*|£[ \u00a0]?[0-9][0-9.,]*|€[ \u00a0]?[0-9][0-9.,]*"
    + @"|[0-9][0-9.,]*[ \u00a0]?€|[¥￥][ \u00a0]?[0-9][0-9,]*|[0-9][0-9,]*[ \u00a0]?円|\$[ \u00a0]?[0-9][0-9.,]*");
// The "accept all" button of a cookie banner, in the five countries' languages.
var CONSENT = new Regex("Accept all|Alle akzeptieren|Aceitar todos|Aceitar tudo|すべて同意|すべて受け入れる|すべて許可", RegexOptions.IgnoreCase);
var since = DateTime.UtcNow.AddSeconds(-60).ToString("o");
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(MAX_WAIT + 30) };
http.DefaultRequestHeaders.Add("x-api-key", KEY);

int Bytes(JsonNode? n) => n is JsonValue v && v.TryGetValue<string>(out var s) ? Encoding.UTF8.GetByteCount(s) : 0;
// A diff's size, or null when the diff is null (this country or the baseline failed).
int? DiffBytes(JsonNode? n) => n == null ? null : Bytes(n);
string? Str(JsonNode? o, string k) => o?[k] is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
List<string> Prices(string text) => PRICE.Matches(text).Select(m => Regex.Replace(m.Value, "[.,]$", "").Replace('\u00a0', ' ')).ToList();
string Currency(string p) => p.StartsWith("R$") ? "BRL" : p.Contains('£') ? "GBP" : p.Contains('€') ? "EUR" : Regex.IsMatch(p, "[¥￥円]") ? "JPY" : "USD";
string? Attr(string html, string pattern) { var m = Regex.Match(html, pattern, RegexOptions.IgnoreCase); return m.Success ? m.Groups[1].Value : null; }
JsonArray Arr(IEnumerable<string> xs) => new(xs.Select(x => (JsonNode)JsonValue.Create(x)!).ToArray());
bool Loaded(JsonNode? r) => r?["status"] is JsonValue v && v.TryGetValue<int>(out var s) && s >= 200 && s < 400;

// What the Function's unified diff (one country against the baseline) says.
JsonNode? ReadDiff(JsonNode? d)
{
    if (d is not JsonValue v || !v.TryGetValue<string>(out var s)) return null;
    var lines = s.Split('\n');
    var added = lines.Where(l => l.StartsWith('+') && !l.StartsWith("+++")).ToList();
    var removed = lines.Where(l => l.StartsWith('-') && !l.StartsWith("---")).ToList();
    var was = removed.SelectMany(Prices).ToList();
    var now = added.SelectMany(Prices).ToList();
    var changes = new List<string>();
    for (var i = 0; i < Math.Min(was.Count, now.Count) && changes.Count < 3; i++) if (was[i] != now[i]) changes.Add($"{was[i]} → {now[i]}");
    return new JsonObject { ["bytes"] = Bytes(d), ["hunks"] = lines.Count(l => l.StartsWith("@@")), ["added"] = added.Count, ["removed"] = removed.Count, ["price_changes"] = Arr(changes) };
}

// The call in the account's history (its x-call-id is the session id there): weight and billed thread-seconds.
async Task<JsonNode?> Billed(string? id)
{
    for (var i = 0; i < 30 && id != null; i++)
    {
        var r = JsonNode.Parse(await http.GetStringAsync($"{API}/v1/me/sessions?limit=200&from={Uri.EscapeDataString(since)}"));
        var s = r?["sessions"]?.AsArray().FirstOrDefault(x => Str(x, "session_id") == id);
        if (Str(s, "ended_at") is string ended)
        {
            var weight = (int)s!["weight"]!;
            var ms = (DateTimeOffset.Parse(ended) - DateTimeOffset.Parse(Str(s, "started_at")!)).TotalMilliseconds;
            return new JsonObject { ["engine"] = Str(s, "engine"), ["weight"] = weight, ["thread_seconds"] = Math.Round(ms * weight / 100) / 10 };
        }
        await Task.Delay(2000);
    }
    return null;
}

async Task<(HttpResponseMessage Res, byte[] Raw, JsonObject Body, long Ms)> Geo(string url, string[]? countries = null, int maxWait = MAX_WAIT, string[]? formats = null)
{
    var sw = Stopwatch.StartNew();
    var payload = new JsonObject { ["url"] = url, ["countries"] = Arr(countries ?? COUNTRIES), ["formats"] = Arr(formats ?? FORMATS), ["max_wait"] = maxWait };
    var res = await http.PostAsync($"{API}/v1/functions/geo", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
    var raw = await res.Content.ReadAsByteArrayAsync();
    JsonObject body;
    try { body = JsonNode.Parse(raw) as JsonObject ?? new JsonObject(); } catch (JsonException) { body = new JsonObject(); }
    return (res, raw, body, sw.ElapsedMilliseconds);
}
string? Header(HttpResponseMessage r, string name) => r.Headers.TryGetValues(name, out var v) ? v.First() : null;

var pages = new JsonArray();
var ids = new List<(string? Call, string? First)>();
foreach (var (name, url) in PAGES) // one call per page, one after another
{
    var first = await Geo(url);
    var call = first;
    // Every diff is against the first country: if that one failed, the call is retried once.
    if ((int)call.Res.StatusCode == 200 && !Loaded(call.Body["results"]?[COUNTRIES[0]])) call = await Geo(url);
    var retried = !ReferenceEquals(call.Body, first.Body);
    var results = call.Body["results"];
    var diff = call.Body["diff"];
    var baseMd = Str(results?[COUNTRIES[0]], "markdown");
    var countries = new JsonObject();
    foreach (var c in COUNTRIES)
    {
        var r = results?[c];
        var html = Str(r, "html") ?? "";
        var md = Str(r, "markdown") ?? "";
        var found = Prices(md);
        countries[c] = new JsonObject
        {
            ["status"] = r?["status"]?.DeepClone(),
            ["error"] = Str(r, "error"),
            ["partial"] = r?["partial"] is JsonValue p && p.TryGetValue<bool>(out var pb) && pb,
            ["lang"] = Attr(html, "<html[^>]*\\slang=\"([^\"]+)\""),
            ["url"] = Str(r, "url"), // the final URL after redirects (only for a country that loaded)
            ["title"] = Attr(html, "<title[^>]*>([^<]*)<")?.Trim(),
            ["currencies"] = Arr(found.Select(Currency).Distinct()),
            ["prices"] = Arr(found.Take(4)),
            ["consent_banner"] = html.Length == 0 ? null : CONSENT.IsMatch(html),
            ["markdown_bytes"] = Encoding.UTF8.GetByteCount(md),
            ["html_bytes"] = Encoding.UTF8.GetByteCount(html),
            ["same_markdown_as_baseline"] = c == COUNTRIES[0] || md.Length == 0 || string.IsNullOrEmpty(baseMd) ? null : md == baseMd,
            // A diff is a string ("" = identical), or null when this country or the baseline failed.
            ["diff"] = c == COUNTRIES[0] ? null : new JsonObject { ["markdown"] = ReadDiff(diff?["markdown"]?[c]), ["html_bytes"] = DiffBytes(diff?["html"]?[c]) },
        };
    }
    JsonObject? firstAttempt = null;
    if (retried)
    {
        var errors = new JsonObject();
        foreach (var c in COUNTRIES) if (!Loaded(first.Body["results"]?[c])) errors[c] = Str(first.Body["results"]?[c], "error");
        var diffs = new JsonObject();
        foreach (var (c, d) in first.Body["diff"]?["markdown"]?.AsObject() ?? new JsonObject()) diffs[c] = DiffBytes(d);
        firstAttempt = new JsonObject { ["call_id"] = Header(first.Res, "x-call-id"), ["errors"] = errors, ["diffs"] = diffs };
    }
    var diffBytes = new JsonObject();
    foreach (var f in new[] { "markdown", "html" }) diffBytes[f] = diff?[f]?.AsObject().Sum(e => Bytes(e.Value)) ?? 0;
    pages.Add(new JsonObject
    {
        ["page"] = name,
        ["url"] = url,
        ["attempts"] = retried ? 2 : 1,
        ["first_attempt"] = firstAttempt,
        ["status"] = (int)call.Res.StatusCode,
        ["error"] = Str(call.Body, "error"),
        ["seconds"] = Math.Round(call.Ms / 100.0) / 10,
        ["call_id"] = Header(call.Res, "x-call-id"),
        ["warning"] = Header(call.Res, "x-cdpfleet-warning"),
        ["response_bytes"] = call.Raw.Length,
        ["diff_bytes"] = diffBytes,
        ["ok_countries"] = Arr(COUNTRIES.Where(c => Loaded(results?[c]))),
        ["countries"] = countries,
    });
    ids.Add((Header(call.Res, "x-call-id"), retried ? Header(first.Res, "x-call-id") : null));
}
// What a failure looks like: the same Steam page with max_wait 1, so no country can load it in time.
var failed = await Geo("https://store.steampowered.com/app/620/Portal_2/", new[] { "us", "de" }, 1, new[] { "markdown" });
var failedResults = new JsonObject();
foreach (var (c, r) in failed.Body["results"]?.AsObject() ?? new JsonObject())
    failedResults[c] = new JsonObject { ["status"] = r?["status"]?.DeepClone(), ["error"] = Str(r, "error") };
var failure = new JsonObject
{
    ["status"] = (int)failed.Res.StatusCode, ["max_wait"] = 1, ["results"] = failedResults,
    ["diff"] = failed.Body["diff"]?["markdown"]?.DeepClone(),
};

for (var i = 0; i < pages.Count; i++)
{
    pages[i]!["billed"] = await Billed(ids[i].Call);
    if (ids[i].First != null) pages[i]!["first_attempt"]!["billed"] = await Billed(ids[i].First);
}

var output = new JsonObject
{
    ["language"] = "csharp",
    ["countries"] = Arr(COUNTRIES),
    ["formats"] = Arr(FORMATS),
    ["max_wait"] = MAX_WAIT,
    ["pages"] = pages,
    ["failure"] = failure,
    ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
};
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// Go 1.22, standard library only. env: CDPFLEET_API_KEY (five countries in one call hold five threads: a paid plan)
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strings"
	"time"
)

const api = "https://cdpfleet.com"
const maxWait = 45

var (
	key       = os.Getenv("CDPFLEET_API_KEY")
	countries = []string{"us", "de", "gb", "jp", "br"} // the first one is the baseline the diff compares with
	formats   = []string{"markdown", "html"}
	pagesList = [][2]string{
		{"Steam store page", "https://store.steampowered.com/app/620/Portal_2/"},
		{"Google home page", "https://www.google.com/"},
		{"Nike home page", "https://www.nike.com/"},
		{"example.com (control)", "https://example.com/"},
	}
	// Prices in the five countries' currencies: R$ before $, the symbol before or after the number.
	// Plain [0-9] and [ \x{00a0}] rather than \d and \s, so the pattern means the same in all five languages.
	price = regexp.MustCompile(`R\$[ \x{00a0}]?[0-9][0-9.,]*|US\$[ \x{00a0}]?[0-9][0-9.,]*|£[ \x{00a0}]?[0-9][0-9.,]*|€[ \x{00a0}]?[0-9][0-9.,]*` +
		`|[0-9][0-9.,]*[ \x{00a0}]?€|[¥￥][ \x{00a0}]?[0-9][0-9,]*|[0-9][0-9,]*[ \x{00a0}]?円|\$[ \x{00a0}]?[0-9][0-9.,]*`)
	// The "accept all" button of a cookie banner, in the five countries' languages.
	consent  = regexp.MustCompile(`(?i)Accept all|Alle akzeptieren|Aceitar todos|Aceitar tudo|すべて同意|すべて受け入れる|すべて許可`)
	trailing = regexp.MustCompile(`[.,]$`)
	langRe   = regexp.MustCompile(`(?i)<html[^>]*\slang="([^"]+)"`)
	titleRe  = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)<`)
	since    = time.Now().UTC().Add(-60 * time.Second).Format(time.RFC3339)
	client   = &http.Client{Timeout: (maxWait + 30) * time.Second}
)

type M = map[string]any

// Keys in insertion order, so every language prints the same JSON layout.
type obj struct {
	keys []string
	vals map[string]any
}

func newObj() *obj { return &obj{vals: map[string]any{}} }
func (o *obj) set(k string, v any) *obj {
	if _, ok := o.vals[k]; !ok {
		o.keys = append(o.keys, k)
	}
	o.vals[k] = v
	return o
}
func (o *obj) MarshalJSON() ([]byte, error) {
	var b bytes.Buffer
	b.WriteByte('{')
	for i, k := range o.keys {
		if i > 0 {
			b.WriteByte(',')
		}
		kb, _ := json.Marshal(k)
		b.Write(kb)
		b.WriteByte(':')
		vb, err := marshal(o.vals[k])
		if err != nil {
			return nil, err
		}
		b.Write(vb)
	}
	b.WriteByte('}')
	return b.Bytes(), nil
}
func marshal(v any) ([]byte, error) {
	var b bytes.Buffer
	enc := json.NewEncoder(&b)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		return nil, err
	}
	return bytes.TrimRight(b.Bytes(), "\n"), nil
}

func str(m M, k string) any {
	if s, ok := m[k].(string); ok {
		return s
	}
	return nil
}
func text(m M, k string) string { s, _ := m[k].(string); return s }
func sub(m M, k string) M       { s, _ := m[k].(M); return s }
func nbytes(v any) int          { s, _ := v.(string); return len(s) }

// A diff's size, or nil when the diff is null (this country or the baseline failed).
func diffBytes(v any) any {
	if v == nil {
		return nil
	}
	return nbytes(v)
}
func attr(re *regexp.Regexp, html string) any {
	if m := re.FindStringSubmatch(html); m != nil {
		return m[1]
	}
	return nil
}
func round1(v float64) float64 { return math.Round(v*10) / 10 }

func prices(t string) []string {
	out := []string{}
	for _, p := range price.FindAllString(t, -1) {
		out = append(out, strings.Replace(trailing.ReplaceAllString(p, ""), "\u00a0", " ", 1))
	}
	return out
}
func currency(p string) string {
	switch {
	case strings.HasPrefix(p, "R$"):
		return "BRL"
	case strings.Contains(p, "£"):
		return "GBP"
	case strings.Contains(p, "€"):
		return "EUR"
	case strings.ContainsAny(p, "¥￥円"):
		return "JPY"
	}
	return "USD"
}

// What the Function's unified diff (one country against the baseline) says.
func readDiff(v any) any {
	d, ok := v.(string)
	if !ok {
		return nil
	}
	lines := strings.Split(d, "\n")
	was, now := []string{}, []string{}
	added, removed, hunks := 0, 0, 0
	for _, l := range lines {
		switch {
		case strings.HasPrefix(l, "@@"):
			hunks++
		case strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++"):
			added++
			now = append(now, prices(l)...)
		case strings.HasPrefix(l, "-") && !strings.HasPrefix(l, "---"):
			removed++
			was = append(was, prices(l)...)
		}
	}
	changes := []string{}
	for i := 0; i < len(was) && i < len(now) && len(changes) < 3; i++ {
		if was[i] != now[i] {
			changes = append(changes, was[i]+" → "+now[i])
		}
	}
	return newObj().set("bytes", len(d)).set("hunks", hunks).set("added", added).set("removed", removed).set("price_changes", changes)
}

func get(path string) M {
	req, _ := http.NewRequest("GET", api+path, nil)
	req.Header.Set("x-api-key", key)
	res, err := client.Do(req)
	if err != nil {
		return M{}
	}
	defer res.Body.Close()
	var m M
	json.NewDecoder(res.Body).Decode(&m)
	return m
}

// The call in the account's history (its x-call-id is the session id there): weight and billed thread-seconds.
func billed(id string) any {
	for i := 0; i < 30 && id != ""; i++ {
		sessions, _ := get("/v1/me/sessions?limit=200&from=" + url.QueryEscape(since))["sessions"].([]any)
		for _, x := range sessions {
			s, _ := x.(M)
			if text(s, "session_id") != id || text(s, "ended_at") == "" {
				continue
			}
			start, _ := time.Parse(time.RFC3339Nano, text(s, "started_at"))
			end, _ := time.Parse(time.RFC3339Nano, text(s, "ended_at"))
			weight, _ := s["weight"].(float64)
			return newObj().set("engine", str(s, "engine")).set("weight", int(weight)).
				set("thread_seconds", math.Round(float64(end.Sub(start).Milliseconds())*weight/100)/10)
		}
		time.Sleep(2 * time.Second)
	}
	return nil
}

type call struct {
	status int
	header http.Header
	raw    []byte
	body   M
	ms     int64
}

func geo(u string, cs []string, wait int, fs []string) call {
	payload, _ := json.Marshal(M{"url": u, "countries": cs, "formats": fs, "max_wait": wait})
	t := time.Now()
	req, _ := http.NewRequest("POST", api+"/v1/functions/geo", bytes.NewReader(payload))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)
	body := M{}
	json.Unmarshal(raw, &body)
	return call{res.StatusCode, res.Header, raw, body, time.Since(t).Milliseconds()}
}
func loaded(r M) bool {
	s, ok := r["status"].(float64)
	return ok && s >= 200 && s < 400
}
func header(h http.Header, k string) any {
	if v := h.Get(k); v != "" {
		return v
	}
	return nil
}

func main() {
	pages := []*obj{}
	type ids struct{ call, first string }
	lookups := []ids{}
	for _, p := range pagesList { // one call per page, one after another
		first := geo(p[1], countries, maxWait, formats)
		c := first
		// Every diff is against the first country: if that one failed, the call is retried once.
		retried := false
		if c.status == 200 && !loaded(sub(sub(c.body, "results"), countries[0])) {
			c = geo(p[1], countries, maxWait, formats)
			retried = true
		}
		results, diff := sub(c.body, "results"), sub(c.body, "diff")
		baseMd := text(sub(results, countries[0]), "markdown")
		perCountry := newObj()
		for _, cc := range countries {
			r := sub(results, cc)
			html, md := text(r, "html"), text(r, "markdown")
			found := prices(md)
			curr := []string{}
			for _, x := range found {
				cur := currency(x)
				seen := false
				for _, y := range curr {
					seen = seen || y == cur
				}
				if !seen {
					curr = append(curr, cur)
				}
			}
			var title any
			if t := attr(titleRe, html); t != nil {
				title = strings.TrimSpace(t.(string))
			}
			var consentBanner, same, d any
			if html != "" {
				consentBanner = consent.MatchString(html)
			}
			if cc != countries[0] && md != "" && baseMd != "" {
				same = md == baseMd
			}
			// A diff is a string ("" = identical), or null when this country or the baseline failed.
			if cc != countries[0] {
				d = newObj().set("markdown", readDiff(sub(diff, "markdown")[cc])).set("html_bytes", diffBytes(sub(diff, "html")[cc]))
			}
			partial, _ := r["partial"].(bool)
			var status any
			if s, ok := r["status"].(float64); ok {
				status = int(s)
			}
			perCountry.set(cc, newObj().set("status", status).set("error", str(r, "error")).set("partial", partial).
				set("lang", attr(langRe, html)).set("url", str(r, "url")).set("title", title).
				set("currencies", curr).set("prices", found[:min(4, len(found))]).set("consent_banner", consentBanner).
				set("markdown_bytes", len(md)).set("html_bytes", len(html)).set("same_markdown_as_baseline", same).set("diff", d))
		}
		var firstAttempt any
		if retried {
			errors, diffs := newObj(), newObj()
			fresults := sub(first.body, "results")
			for _, cc := range countries {
				if !loaded(sub(fresults, cc)) {
					errors.set(cc, str(sub(fresults, cc), "error"))
				}
			}
			for _, cc := range countries[1:] {
				if v, ok := sub(sub(first.body, "diff"), "markdown")[cc]; ok {
					diffs.set(cc, diffBytes(v))
				}
			}
			firstAttempt = newObj().set("call_id", header(first.header, "x-call-id")).set("errors", errors).set("diffs", diffs)
		}
		diffBytes := newObj()
		for _, f := range []string{"markdown", "html"} {
			n := 0
			for _, v := range sub(diff, f) {
				n += nbytes(v)
			}
			diffBytes.set(f, n)
		}
		ok := []string{}
		for _, cc := range countries {
			if loaded(sub(results, cc)) {
				ok = append(ok, cc)
			}
		}
		attempts := 1
		if retried {
			attempts = 2
		}
		pages = append(pages, newObj().set("page", p[0]).set("url", p[1]).set("attempts", attempts).set("first_attempt", firstAttempt).
			set("status", c.status).set("error", str(c.body, "error")).set("seconds", round1(float64(c.ms)/1000)).
			set("call_id", header(c.header, "x-call-id")).set("warning", header(c.header, "x-cdpfleet-warning")).
			set("response_bytes", len(c.raw)).set("diff_bytes", diffBytes).set("ok_countries", ok).set("countries", perCountry))
		l := ids{call: c.header.Get("x-call-id")}
		if retried {
			l.first = first.header.Get("x-call-id")
		}
		lookups = append(lookups, l)
	}
	// What a failure looks like: the same Steam page with max_wait 1, so no country can load it in time.
	failed := geo("https://store.steampowered.com/app/620/Portal_2/", []string{"us", "de"}, 1, []string{"markdown"})
	failedResults := newObj()
	for _, cc := range []string{"us", "de"} {
		if r, ok := sub(failed.body, "results")[cc].(M); ok {
			var status any
			if s, ok := r["status"].(float64); ok {
				status = int(s)
			}
			failedResults.set(cc, newObj().set("status", status).set("error", str(r, "error")))
		}
	}
	var failedDiff any
	if v, ok := sub(sub(failed.body, "diff"), "markdown")["de"]; ok {
		failedDiff = newObj().set("de", v)
	}
	failure := newObj().set("status", failed.status).set("max_wait", 1).set("results", failedResults).set("diff", failedDiff)

	for i, p := range pages {
		p.set("billed", billed(lookups[i].call))
		if lookups[i].first != "" {
			p.vals["first_attempt"].(*obj).set("billed", billed(lookups[i].first))
		}
	}
	out, err := marshal(newObj().set("language", "go").set("countries", countries).set("formats", formats).set("max_wait", maxWait).
		set("pages", pages).set("failure", failure).set("measured_at", time.Now().UTC().Format("2006-01-02")))
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	var pretty bytes.Buffer
	json.Indent(&pretty, out, "", "  ")
	fmt.Println(pretty.String())
}
```

## What we got

| Page | Country | Result | lang | Final URL | Prices | Cookie banner | Markdown diff vs us | Call | Measured |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Steam store page | us | 200 | en | https://store.steampowered.com/app/620/Portal\_2/ | $9.99, $1.99 (USD) | no | baseline | 35.4 s · 175.9 thread-s · 3.0 MB, diffs 140 KB Markdown + 1.4 MB HTML | 2026-11-10 |
| Steam store page | de | 200 (partial) | de | https://store.steampowered.com/app/620/Portal\_2/ | 9,69€, 1,59€ (EUR) | yes | 46 KB: +226 −224 lines; $9.99 → 9,69€, $1.99 → 1,59€, $19.98 → 19,38€ | — | 2026-11-10 |
| Steam store page | gb | 200 | en | https://store.steampowered.com/app/620/Portal\_2/ | £8.19, £1.69 (GBP) | yes | 3.3 KB: +20 −12 lines; $9.99 → £8.19, $1.99 → £1.69, $19.98 → £16.38 | — | 2026-11-10 |
| Steam store page | jp | 200 | ja | https://store.steampowered.com/app/620/Portal\_2/ | ¥ 1,400, ¥ 205 (JPY) | no | 48 KB: +179 −179 lines; $9.99 → ¥ 1,400, $1.99 → ¥ 205, $19.98 → ¥ 2,800 | — | 2026-11-10 |
| Steam store page | br | 200 | pt-br | https://store.steampowered.com/app/620/Portal\_2/ | R$ 33,99, R$ 4,49 (BRL) | no | 42 KB: +171 −171 lines; $9.99 → R$ 33,99, $1.99 → R$ 4,49, $19.98 → R$ 67,98 | — | 2026-11-10 |
| Google home page | us | 200 | en | https://www.google.com/ | — | no | baseline | 33 s · 164.1 thread-s · 3.2 MB, diffs 22 KB Markdown + 1.7 MB HTML | 2026-11-10 |
| Google home page | de | 200 (partial) | de | https://www.google.com/ | — | yes | 8.5 KB: +141 −16 lines | — | 2026-11-10 |
| Google home page | gb | 200 | en-GB | https://www.google.com/ | — | yes | 6.8 KB: +137 −13 lines | — | 2026-11-10 |
| Google home page | jp | 200 | ja | https://www.google.com/ | — | no | 3.7 KB: +18 −16 lines | — | 2026-11-10 |
| Google home page | br | 200 | pt-BR | https://www.google.com/ | — | no | 3.5 KB: +17 −17 lines | — | 2026-11-10 |
| Nike home page | us | 200 | en-US | https://www.nike.com/ | — | no | baseline | 46.8 s · 231.9 thread-s · 13.6 MB, diffs 176 KB Markdown + 7.7 MB HTML | 2026-11-10 |
| Nike home page | de | 200 (partial) | de-DE | https://www.nike.com/de/ | 50 €, £50 (EUR, GBP) | no | 53 KB: +494 −810 lines | — | 2026-11-10 |
| Nike home page | gb | page\_timeout | — | — | — | — | null (no comparison) | — | 2026-11-10 |
| Nike home page | jp | 200 (partial) | ja-JP | https://www.nike.com/jp/ | — | no | 56 KB: +606 −792 lines | — | 2026-11-10 |
| Nike home page | br | 200 | en-US | https://www.nike.com/ | — | no | 67 KB: +596 −592 lines | — | 2026-11-10 |
| example.com (control) | us | 200 | en | https://example.com/ | — | no | baseline | 7.1 s · 35.1 thread-s · 19 KB, diffs 0 bytes Markdown + 0 bytes HTML | 2026-11-10 |
| example.com (control) | de | 200 | en | https://example.com/ | — | no | "" (identical) | — | 2026-11-10 |
| example.com (control) | gb | 200 | en | https://example.com/ | — | no | "" (identical) | — | 2026-11-10 |
| example.com (control) | jp | 200 | en | https://example.com/ | — | no | "" (identical) | — | 2026-11-10 |
| example.com (control) | br | 200 | en | https://example.com/ | — | no | "" (identical) | — | 2026-11-10 |

From the Node.js run on 2026-11-10. 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)

```
{
  "language": "node",
  "countries": [
    "us",
    "de",
    "gb",
    "jp",
    "br"
  ],
  "formats": [
    "markdown",
    "html"
  ],
  "max_wait": 45,
  "pages": [
    {
      "page": "Steam store page",
      "url": "https://store.steampowered.com/app/620/Portal_2/",
      "attempts": 1,
      "first_attempt": null,
      "status": 200,
      "error": null,
      "seconds": 35.4,
      "call_id": "c27880d3-30ae-4993-a3f6-15a89cfd2d45",
      "warning": null,
      "response_bytes": 3096123,
      "diff_bytes": {
        "markdown": 143433,
        "html": 1477835
      },
      "ok_countries": [
        "us",
        "de",
        "gb",
        "jp",
        "br"
      ],
      "countries": {
        "us": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://store.steampowered.com/app/620/Portal_2/",
          "title": "Portal 2 on Steam",
          "currencies": [
            "USD"
          ],
          "prices": [
            "$9.99",
            "$1.99",
            "$19.98",
            "$14.98"
          ],
          "consent_banner": false,
          "markdown_bytes": 22994,
          "html_bytes": 223766,
          "same_markdown_as_baseline": null,
          "diff": null
        },
        "de": {
          "status": 200,
          "error": null,
          "partial": true,
          "lang": "de",
          "url": "https://store.steampowered.com/app/620/Portal_2/",
          "title": "Portal 2 bei Steam",
          "currencies": [
            "EUR"
          ],
          "prices": [
            "9,69€",
            "1,59€",
            "19,38€",
            "14,54€"
          ],
          "consent_banner": true,
          "markdown_bytes": 23898,
          "html_bytes": 185916,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 47265,
              "hunks": 1,
              "added": 226,
              "removed": 224,
              "price_changes": [
                "$9.99 → 9,69€",
                "$1.99 → 1,59€",
                "$19.98 → 19,38€"
              ]
            },
            "html_bytes": 399478
          }
        },
        "gb": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://store.steampowered.com/app/620/Portal_2/",
          "title": "Portal 2 on Steam",
          "currencies": [
            "GBP"
          ],
          "prices": [
            "£8.19",
            "£1.69",
            "£16.38",
            "£12.28"
          ],
          "consent_banner": true,
          "markdown_bytes": 23472,
          "html_bytes": 226029,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 3365,
              "hunks": 9,
              "added": 20,
              "removed": 12,
              "price_changes": [
                "$9.99 → £8.19",
                "$1.99 → £1.69",
                "$19.98 → £16.38"
              ]
            },
            "html_bytes": 334452
          }
        },
        "jp": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "ja",
          "url": "https://store.steampowered.com/app/620/Portal_2/",
          "title": "Steam：Portal 2",
          "currencies": [
            "JPY"
          ],
          "prices": [
            "¥ 1,400",
            "¥ 205",
            "¥ 2,800",
            "¥ 2,100"
          ],
          "consent_banner": false,
          "markdown_bytes": 26593,
          "html_bytes": 233295,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 49372,
              "hunks": 1,
              "added": 179,
              "removed": 179,
              "price_changes": [
                "$9.99 → ¥ 1,400",
                "$1.99 → ¥ 205",
                "$19.98 → ¥ 2,800"
              ]
            },
            "html_bytes": 397752
          }
        },
        "br": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "pt-br",
          "url": "https://store.steampowered.com/app/620/Portal_2/",
          "title": "Portal 2 no Steam",
          "currencies": [
            "BRL"
          ],
          "prices": [
            "R$ 33,99",
            "R$ 4,49",
            "R$ 67,98",
            "R$ 50,98"
          ],
          "consent_banner": false,
          "markdown_bytes": 24260,
          "html_bytes": 225064,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 43431,
              "hunks": 2,
              "added": 171,
              "removed": 171,
              "price_changes": [
                "$9.99 → R$ 33,99",
                "$1.99 → R$ 4,49",
                "$19.98 → R$ 67,98"
              ]
            },
            "html_bytes": 346153
          }
        }
      },
      "billed": {
        "engine": "function:geo",
        "weight": 5,
        "thread_seconds": 175.9
      }
    },
    {
      "page": "Google home page",
      "url": "https://www.google.com/",
      "attempts": 1,
      "first_attempt": null,
      "status": 200,
      "error": null,
      "seconds": 33,
      "call_id": "99126d18-87e8-4bd0-9a52-ab97c6876ccc",
      "warning": null,
      "response_bytes": 3353632,
      "diff_bytes": {
        "markdown": 22951,
        "html": 1751591
      },
      "ok_countries": [
        "us",
        "de",
        "gb",
        "jp",
        "br"
      ],
      "countries": {
        "us": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://www.google.com/",
          "title": "Google",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1612,
          "html_bytes": 240533,
          "same_markdown_as_baseline": null,
          "diff": null
        },
        "de": {
          "status": 200,
          "error": null,
          "partial": true,
          "lang": "de",
          "url": "https://www.google.com/",
          "title": "Google",
          "currencies": [],
          "prices": [],
          "consent_banner": true,
          "markdown_bytes": 6927,
          "html_bytes": 340188,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 8679,
              "hunks": 1,
              "added": 141,
              "removed": 16,
              "price_changes": []
            },
            "html_bytes": 461245
          }
        },
        "gb": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en-GB",
          "url": "https://www.google.com/",
          "title": "Google",
          "currencies": [],
          "prices": [],
          "consent_banner": true,
          "markdown_bytes": 5585,
          "html_bytes": 321343,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 6956,
              "hunks": 2,
              "added": 137,
              "removed": 13,
              "price_changes": []
            },
            "html_bytes": 442400
          }
        },
        "jp": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "ja",
          "url": "https://www.google.com/",
          "title": "Google",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 2137,
          "html_bytes": 276475,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 3765,
              "hunks": 1,
              "added": 18,
              "removed": 16,
              "price_changes": []
            },
            "html_bytes": 462954
          }
        },
        "br": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "pt-BR",
          "url": "https://www.google.com/",
          "title": "Google",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1880,
          "html_bytes": 263962,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 3551,
              "hunks": 1,
              "added": 17,
              "removed": 17,
              "price_changes": []
            },
            "html_bytes": 384992
          }
        }
      },
      "billed": {
        "engine": "function:geo",
        "weight": 5,
        "thread_seconds": 164.1
      }
    },
    {
      "page": "Nike home page",
      "url": "https://www.nike.com/",
      "attempts": 1,
      "first_attempt": null,
      "status": 200,
      "error": null,
      "seconds": 46.8,
      "call_id": "23dd89ec-6913-408b-b490-2b64a8479cfa",
      "warning": null,
      "response_bytes": 14223084,
      "diff_bytes": {
        "markdown": 180248,
        "html": 8065612
      },
      "ok_countries": [
        "us",
        "de",
        "jp",
        "br"
      ],
      "countries": {
        "us": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en-US",
          "url": "https://www.nike.com/",
          "title": "Nike. Just Do It. Nike.com",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 33868,
          "html_bytes": 1510798,
          "same_markdown_as_baseline": null,
          "diff": null
        },
        "de": {
          "status": 200,
          "error": null,
          "partial": true,
          "lang": "de-DE",
          "url": "https://www.nike.com/de/",
          "title": "Nike. Just Do It. Nike DE",
          "currencies": [
            "EUR",
            "GBP"
          ],
          "prices": [
            "50 €",
            "£50",
            "100 €",
            "£50"
          ],
          "consent_banner": false,
          "markdown_bytes": 19388,
          "html_bytes": 847795,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 54511,
              "hunks": 1,
              "added": 494,
              "removed": 810,
              "price_changes": []
            },
            "html_bytes": 2363409
          }
        },
        "gb": {
          "status": null,
          "error": "page_timeout",
          "partial": false,
          "lang": null,
          "url": null,
          "title": null,
          "currencies": [],
          "prices": [],
          "consent_banner": null,
          "markdown_bytes": 0,
          "html_bytes": 0,
          "same_markdown_as_baseline": null,
          "diff": {
            "markdown": null,
            "html_bytes": null
          }
        },
        "jp": {
          "status": 200,
          "error": null,
          "partial": true,
          "lang": "ja-JP",
          "url": "https://www.nike.com/jp/",
          "title": "Nike. Just Do It. Nike.com (JP).オンラインストア (通販サイト)",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 22074,
          "html_bytes": 1243359,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 57299,
              "hunks": 1,
              "added": 606,
              "removed": 792,
              "price_changes": []
            },
            "html_bytes": 2759200
          }
        },
        "br": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en-US",
          "url": "https://www.nike.com/",
          "title": "Nike. Just Do It. Nike.com",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 33939,
          "html_bytes": 1513445,
          "same_markdown_as_baseline": false,
          "diff": {
            "markdown": {
              "bytes": 68438,
              "hunks": 1,
              "added": 596,
              "removed": 592,
              "price_changes": []
            },
            "html_bytes": 2943003
          }
        }
      },
      "billed": {
        "engine": "function:geo",
        "weight": 5,
        "thread_seconds": 231.9
      }
    },
    {
      "page": "example.com (control)",
      "url": "https://example.com/",
      "attempts": 1,
      "first_attempt": null,
      "status": 200,
      "error": null,
      "seconds": 7.1,
      "call_id": "aaa4766c-4092-42d0-9c48-1f8237c32ae6",
      "warning": null,
      "response_bytes": 19513,
      "diff_bytes": {
        "markdown": 0,
        "html": 0
      },
      "ok_countries": [
        "us",
        "de",
        "gb",
        "jp",
        "br"
      ],
      "countries": {
        "us": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://example.com/",
          "title": "Example Domain",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1360,
          "html_bytes": 2373,
          "same_markdown_as_baseline": null,
          "diff": null
        },
        "de": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://example.com/",
          "title": "Example Domain",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1360,
          "html_bytes": 2373,
          "same_markdown_as_baseline": true,
          "diff": {
            "markdown": {
              "bytes": 0,
              "hunks": 0,
              "added": 0,
              "removed": 0,
              "price_changes": []
            },
            "html_bytes": 0
          }
        },
        "gb": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://example.com/",
          "title": "Example Domain",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1360,
          "html_bytes": 2373,
          "same_markdown_as_baseline": true,
          "diff": {
            "markdown": {
              "bytes": 0,
              "hunks": 0,
              "added": 0,
              "removed": 0,
              "price_changes": []
            },
            "html_bytes": 0
          }
        },
        "jp": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://example.com/",
          "title": "Example Domain",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1360,
          "html_bytes": 2373,
          "same_markdown_as_baseline": true,
          "diff": {
            "markdown": {
              "bytes": 0,
              "hunks": 0,
              "added": 0,
              "removed": 0,
              "price_changes": []
            },
            "html_bytes": 0
          }
        },
        "br": {
          "status": 200,
          "error": null,
          "partial": false,
          "lang": "en",
          "url": "https://example.com/",
          "title": "Example Domain",
          "currencies": [],
          "prices": [],
          "consent_banner": false,
          "markdown_bytes": 1360,
          "html_bytes": 2373,
          "same_markdown_as_baseline": true,
          "diff": {
            "markdown": {
              "bytes": 0,
              "hunks": 0,
              "added": 0,
              "removed": 0,
              "price_changes": []
            },
            "html_bytes": 0
          }
        }
      },
      "billed": {
        "engine": "function:geo",
        "weight": 5,
        "thread_seconds": 35.1
      }
    }
  ],
  "failure": {
    "status": 200,
    "max_wait": 1,
    "results": {
      "us": {
        "status": null,
        "error": "page_timeout"
      },
      "de": {
        "status": null,
        "error": "page_timeout"
      }
    },
    "diff": {
      "de": null
    }
  },
  "measured_at": "2026-11-10"
}
```

## Takeaways

-   **One call, five countries, one bill per country.** On 2026-11-10 (re-run after the Functions fixes of 2026-11-08), across five runs (one per language), every call returned 200 with a result for each country and was in the history as `function:geo` with weight 5: billed 5 × its wall time, never more. A call takes as long as its slowest country — example.com 6.5–24.0 s, Google 17.6–47.1 s, Steam 35.4–46.5 s, Nike 34.2–47.4 s — so the four pages cost 605–675 thread-seconds a run, 10–11 thread-minutes (850 in the run that had to retry Steam).
-   **The control is clean.** example.com came back byte-identical from all five countries in every run, and every diff was `""`: what the diff shows on the other pages comes from the sites, not from the Function.
-   **Prices and currency: the diff hands you the changes.** Steam's page priced Portal 2 at $9.99, 9,69€, £8.19, ¥ 1,400 and R$ 33,99 in every run, in English, German, Japanese and Brazilian Portuguese, and the Markdown diff's removed and added lines paired them directly (`$9.99 → £8.19`, `$1.99 → ¥ 205`).
-   **When the language changes, the diff is the whole page — and sometimes when it doesn't.** The German, Japanese and Portuguese pages gave diffs of 43–66 KB in 1–2 hunks, because every line is translated. The UK page is in English, but its diff depended on which US page came back: Steam served the US exit a 23 KB page in three runs and a 38 KB one in two; against the short one the UK diff was 3.4 KB in 9 hunks with every price change in it, against the long one 52–55 KB in 2 hunks. Read prices from each country's Markdown, and use the diff where both pages have the same layout.
-   **Cookie banners follow where the site places you, not the country you asked for.** Steam showed its "Accept All / Reject All" popup on every page priced in euros or pounds and on none priced in dollars, yen or reais — it is in the HTML, not in the Markdown. Google's "Before you continue" dialog appeared exactly where its `lang` put the visitor in the EU or UK (`de`, `en-GB`) and roughly tripled the Markdown (5,585–6,927 bytes against 1,590–2,030 from the US) in every run. Nike showed its "Data improves your experience" consent from the UK in three of the four runs where the UK loaded, and never from Germany.
-   **Country redirects are in `url` now.** Each country that loaded has its final URL: Nike sent Germany, the UK and Japan to `/de/`, `/gb/` and `/jp/`, and the US and Brazil to the global `www.nike.com/` (in English); Steam, Google and example.com kept the URL that was asked for. Like the banners, the redirect follows where the site places the exit: in one run Nike sent the German exit to `/gb/`, in English and pounds. (Before 2026-11-08 geo returned no URL and the programs read `<link rel="canonical">` from the HTML.) A currency regex finds what is on the page, not what you pay: Nike's German page also carried "£50" next to "50 €".
-   **A failed country's diff is `null`, not `""`.** Five of the 105 country loads ended in `page_timeout` (Nike from Germany and the UK, Steam from the UK and the US, Google from Brazil); the call was still 200, the country came back as `{status: null, error: "page_timeout"}`, and its diff was `null` — distinct from `""` for an identical page. When the baseline failed (Steam from the US, once), every diff in that call was `null`, and the retry got all five. The call made to fail with `max_wait: 1` gave `page_timeout` for both countries and `diff: {de: null}` in every run.
-   **Ask only for the formats you need.** With `html`, Nike's response was 14.2–19.2 MB, 8.1–11.2 MB of it HTML diffs, against 180–260 KB of Markdown diffs; over the four pages the HTML diffs were 28–33× the Markdown ones, and mostly markup. `markdown` (or `text`) gives a diff you can read; add `html` only for what Markdown drops — `lang`, a banner.

---
Source: https://cdpfleet.com/docs/cases/geo-compare · Markdown: https://cdpfleet.com/docs/cases/geo-compare.md · All docs for AI tools: https://cdpfleet.com/llms.txt
