cdpfleet Docs GitHub Dashboard

Cases / #15 · 2026-10-01 · Hard

Three countries, one script — and reading values the way the site does

Camoufox personas for the US, Germany and Japan: timezone, language, local dates and numbers, geolocation. Plus the Playwright trap that reports UTC for all of them.

Camoufox

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

The problem

Prices, search results and availability change by country, so you run the same flow as a visitor from several countries. Each visit has to be consistent — IP, timezone, language and formats from the same country — or the site notices. And when you check that from your script, you have to read the values the way the site does.

What we used, and why

WhatWhy
PROXY_URL_US, _DE, _JPExits in each country (most providers take a country in the proxy username).
localePer country: en-US, de-DE, ja-JP — languages, Accept-Language, and number/date formats.
geoip: trueCamoufox sets the timezone and geolocation from the proxy's exit IP at launch.
main_world_eval: true + "mw:"Runs the check in the page's own JavaScript world — the world the site's scripts see.
context.grantPermissions(['geolocation'])As if the visitor allowed location access.

How it works

  1. Launch one Camoufox session per country with that country's exit and locale, all at once.
  2. Look up the exit IP's city, timezone and coordinates.
  3. In the page's main world, read languages, timezone, a fixed UTC instant formatted locally, a number, a price and the geolocation.
  4. For contrast, read the timezone again with a default page.evaluate.

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_US, PROXY_URL_DE, PROXY_URL_JP (exits in each country)
import { firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const COUNTRIES = [
  { country: 'United States', locale: 'en-US', proxy: process.env.PROXY_URL_US },
  { country: 'Germany', locale: 'de-DE', proxy: process.env.PROXY_URL_DE },
  { country: 'Japan', locale: 'ja-JP', proxy: process.env.PROXY_URL_JP },
];

// What a localizing site reads in the page. Run in the PAGE's own JavaScript world
// ("mw:" prefix, needs main_world_eval): Playwright's default isolated world isn't patched
// the same way and can report the server's UTC timezone instead of the persona's.
const LOCAL_VIEW = `(async () => {
  const position = await new Promise((ok) => navigator.geolocation.getCurrentPosition(
    (p) => ok({ lat: p.coords.latitude, lon: p.coords.longitude }), () => ok(null), { timeout: 10000 }));
  return {
    languages: navigator.languages,
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    date: new Date('2026-10-01T15:30:00Z').toLocaleString(),
    number: (1234567.891).toLocaleString(),
    price: new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(49.9),
    position,
  };
})()`;

// Great-circle distance in km.
const km = (a, b) => {
  const r = (d) => (d * Math.PI) / 180;
  const h = Math.sin(r(b.lat - a.lat) / 2) ** 2 + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(r(b.lon - a.lon) / 2) ** 2;
  return Math.round(12742 * Math.asin(Math.sqrt(h)));
};

async function persona({ country, locale, proxy }) {
  const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    // geoip: Camoufox sets timezone and geolocation from the proxy's exit IP at launch.
    body: JSON.stringify({ proxy, headless: true, os: 'windows', locale, geoip: true, main_world_eval: true }),
  });
  if (!res.ok) return { country, error: `launch ${res.status} ${await res.text()}` };
  const { wsUrl } = await res.json();
  const browser = await firefox.connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const context = await browser.newContext();
    await context.grantPermissions(['geolocation']); // as if the visitor clicked "Allow"
    const page = await context.newPage();
    const exit = await (await page.goto('http://ip-api.com/json/?fields=country,city,timezone,lat,lon', { timeout: 60000 })).json();
    await page.goto('https://httpbin.org/html', { timeout: 60000 });
    const seen = await page.evaluate(`mw:${LOCAL_VIEW}`);
    const isolatedTimezone = await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
    return {
      country, locale,
      exit: `${exit.city}, ${exit.country}`,
      exit_timezone: exit.timezone,
      ...seen,
      position_km_from_exit: seen.position ? km(seen.position, exit) : null,
      isolated_world_timezone: isolatedTimezone, // what a default page.evaluate would have reported
    };
  } finally {
    await browser.close();
  }
}

console.log(JSON.stringify(await Promise.all(COUNTRIES.map(persona)), null, 2));

What we got

PersonaExitTimezone (page)Default page.evaluate15:30 UTC shown asNumberPriceGeolocation vs exit (km)
United StatesBirmingham, United StatesAmerica/New_YorkUTC10/1/2026, 11:30:00 AM1,234,567.891€49.90573
GermanyBerlin, GermanyEurope/BerlinUTC1.10.2026, 17:30:001.234.567,89149,90 €446
JapanNagoya, JapanAsia/TokyoUTC2026/10/2 0:30:001,234,567.891€49.90138

From the Node.js run on 2026-10-01. 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)
[
  {
    "country": "United States",
    "locale": "en-US",
    "exit": "Birmingham, United States",
    "exit_timezone": "America/Chicago",
    "languages": [
      "en-US",
      "en"
    ],
    "timezone": "America/New_York",
    "date": "10/1/2026, 11:30:00 AM",
    "number": "1,234,567.891",
    "price": "€49.90",
    "position": {
      "lat": 35.1947,
      "lon": -80.744
    },
    "position_km_from_exit": 573,
    "isolated_world_timezone": "UTC"
  },
  {
    "country": "Germany",
    "locale": "de-DE",
    "exit": "Berlin, Germany",
    "exit_timezone": "Europe/Berlin",
    "languages": [
      "de-DE",
      "de"
    ],
    "timezone": "Europe/Berlin",
    "date": "1.10.2026, 17:30:00",
    "number": "1.234.567,891",
    "price": "49,90 €",
    "position": {
      "lat": 51.5791,
      "lon": 7.0599
    },
    "position_km_from_exit": 446,
    "isolated_world_timezone": "UTC"
  },
  {
    "country": "Japan",
    "locale": "ja-JP",
    "exit": "Nagoya, Japan",
    "exit_timezone": "Asia/Tokyo",
    "languages": [
      "ja-JP",
      "ja"
    ],
    "timezone": "Asia/Tokyo",
    "date": "2026/10/2 0:30:00",
    "number": "1,234,567.891",
    "price": "€49.90",
    "position": {
      "lat": 34.6967,
      "lon": 135.5154
    },
    "position_km_from_exit": 138,
    "isolated_world_timezone": "UTC"
  }
]

Takeaways