No description
Find a file
Thor Galle d2d2a547cf
Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:25:16 +02:00
localized-chromium.py Initial commit 2026-07-29 12:25:16 +02:00
README.md Initial commit 2026-07-29 12:25:16 +02:00

localized-chromium

Launch a throwaway Chromium/Helium instance pinned to another country's locale, for manual localisation testing. The profile is created fresh on every run and deleted when the browser quits.

localized-chromium Europe/Paris
localized-chromium Europe/Warsaw https://welcometomygarden.org
localized-chromium --vpn Europe/Berlin 'https://www.google.com/search?q=camping'

Single file, Python 3.8+, standard library only. Docker is needed only for --vpn.

Install

install -Dm755 localized-chromium ~/.local/bin/localized-chromium

The browser is found in this order: --browser, $LOCALIZED_BROWSER, then helium, chromium, brave-browser, google-chrome on PATH, then /opt/helium/helium, /Applications/Helium.app, ~/Applications/*Helium*.AppImage and the equivalent Chromium/Chrome bundles.

Usage

localized-chromium [options] <IANA-TIMEZONE|xx-XX> [URL...]

The positional argument is either a timezone (Europe/Berlin) or a bare locale (pt-PT, which sets the language but leaves the host timezone alone).

Option Effect
-l, --lang TAG content language; default derived from the timezone's country
--accept-lang LIST full Accept-Language list, e.g. "fr-FR,fr,en"
-g, --geo LAT,LON geolocation override; default the timezone's principal city
--no-geo leave geolocation unset
-u, --ui-lang TAG browser UI language (default en-US)
--no-cdp skip the DevTools overrides and the debugging port
-p, --proxy URL socks5://127.0.0.1:1080, http://host:8888, …
-v, --vpn bring up a tunnel in the target country (opt-in, see below)
--vpn-country NAME use this country instead of the locale's
-b, --browser PATH browser binary
-q, --quiet discard the browser's own stdout/stderr
--verify report what the first tab actually sees
-k, --keep keep the profile and print its path
-n, --dry-run print the plan and exit

Europe/Berlin resolves to DEde-DE,de and Berlin's coordinates via /usr/share/zoneinfo/zone.tab. The country→language table is a dict at the top of the script; BE defaults to fr-BE. No English fallback is appended to Accept-Language on purpose — a real local user usually has none, and its presence changes what servers return.

How each property is set

There is no single mechanism for this, which is why the DevTools Sensors panel feels like the only option:

Property Mechanism
Accept-Language, navigator.languages intl.accept_languages written into $PROFILE/Default/Preferences before launch
Timezone TZ environment variable, plus Emulation.setTimezoneOverride
ICU locale (Intl.*) Emulation.setLocaleOverride
Geolocation Emulation.setGeolocationOverride — no non-CDP equivalent exists
UI language --lang (Linux/Windows only; macOS follows System Settings)
Ephemerality --user-data-dir in a temp dir, removed on exit

Emulation.* overrides only live as long as a DevTools client stays attached, so the script holds a CDP session open for the whole browser session. It attaches at the browser target with Target.setAutoAttach{flatten, waitForDebuggerOnStart}, so every new tab is paused, overridden, then resumed — a fresh tab never observes the real timezone. The WebSocket client is ~150 lines of RFC 6455 in the script itself.

The profile also gets: translate prompts off, notification prompts blocked, password manager off, DNS-over-HTTPS off, and webrtc.ip_handling_policy=disable_non_proxied_udp whenever a proxy is in use (without it a proxied browser still leaks the real IP over STUN).

Verifying

$ localized-chromium --verify Europe/Berlin
as the page sees it, before overrides: tz=Europe/Brussels locale=en-US langs=de-DE,de utc+120min
as the page sees it, after overrides:  tz=Europe/Berlin locale=de-DE langs=de-DE,de utc+120min

--verify evaluates Intl.DateTimeFormat().resolvedOptions() and navigator.languages in the first tab, once before the overrides and once after. If the "before" line already shows the target timezone, the TZ variable is doing the work and the CDP override is redundant; if it shows your host timezone, TZ is being ignored. Only an already-open tab is probed, since a paused new tab would not answer until after the overrides are in.

VPN (opt-in)

--vpn runs a throwaway gluetun container with its HTTP proxy bound to an ephemeral port on 127.0.0.1, and points the browser at it. Browser-only by construction: nothing else on the host is routed through the tunnel. The container is removed when the browser quits. Without --vpn nothing touches Docker.

Credentials live in ~/.config/localized-chromium/vpn.env, passed to the container as a --env-file so they never appear in ps output:

# NordVPN over OpenVPN
VPN_SERVICE_PROVIDER=nordvpn
VPN_TYPE=openvpn
OPENVPN_USER=<service credential username>      # Nord Account → Manual setup,
OPENVPN_PASSWORD=<service credential password>  # not your login email/password
# NordVPN over NordLynx (WireGuard)
VPN_SERVICE_PROVIDER=nordvpn
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=<from the dashboard's manual setup section>

For NordLynx you can omit the key and instead put a Nord access token in ~/.config/localized-chromium/nord-token (chmod 600). The script then fetches the key from /v1/users/services/credentials at launch and passes it in a 0600 env file that is deleted with the profile — the key is never written to your config and never appears in argv. That endpoint is undocumented, so if Nord changes it you get a message pointing you back at the dashboard.

Any other gluetun-supported provider works too; those keep a plain SERVER_COUNTRIES=<name> filter derived from the locale's country.

Endpoint selection

For NordVPN the script picks the server itself:

  1. /v1/servers/countries → country id by ISO code, so no country-name guessing.
  2. /v1/servers/recommendations filtered by that country, by openvpn_udp or wireguard_udp to match VPN_TYPE, and by group legacy_standard — the technology filter is silently ignored without the matching group.
  3. Prefer a server in the timezone's own city (Europe/Berlin → a Berlin server), otherwise the lowest-load one in the country.
localized-chromium: starting gluetun (de2222.nordvpn.com — Berlin, Germany, load 12%)

The choice is pinned with SERVER_HOSTNAMES, which is gluetun's narrowest filter and fails outright if that hostname is not in gluetun's bundled server list yet. A failed start is retried once with SERVER_COUNTRIES; the real fix is docker pull qmcgaw/gluetun. If vpn.env already pins its own SERVER_* filters, the script leaves them alone. --vpn-country overrides everything and suppresses the city preference.

Notes and caveats

  • Google localises mostly on egress IP, secondarily on hl/gl/uule, and barely at all on Accept-Language. For SERP work the VPN does most of the job. Commercial VPN ranges are heavily flagged, so expect more captchas than from a residential IP.
  • --remote-debugging-port means any local process can drive that browser for its lifetime. It is a throwaway profile on loopback, but don't sign into anything you care about; --no-cdp drops the port and keeps only TZ + Accept-Language.
  • Helium is ungoogled-chromium-derived, so it ships no Google API key and network geolocation would fail even without an override — the CDP fix is the only way it resolves at all.
  • Helium logs certificate-parse, ublock schema and trk: blocking errors on startup. They are internal and harmless; -q hides them.
  • SingletonLock in the profile is used to detect a browser that is still running when the launcher forks and returns early (AppImage wrappers, macOS bundles), so the profile is never deleted from under a live browser. This is POSIX-only.