mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
feat: initial public release
invisible-playwright: a patched Firefox 150.0.1 for browser-fingerprint
stealth, shipped as a Playwright-compatible Python wrapper.
* Sync + async InvisiblePlaywright launcher (firefox_user_prefs, virtual
desktop on Windows, SOCKS5 auth via patched nsProtocolProxyService)
* fpforge: Bayesian fingerprint sampler over GPU / audio / fonts /
screen / ~400 other navigator fields
* WebRTC stealth: srflx address swap, synthetic srflx fallback,
private-LAN host candidates. No real public IP leak via STUN.
* GPU sandbox fix for FF150 alt-desktop regression
* Bezier-curve mouse motion baked into Juggler
Targets Windows x86_64 + Linux x86_64. Binary fetched on first run from
GitHub Release "firefox-1".
This commit is contained in:
commit
7a983e99c5
51 changed files with 10967 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
firefox-source/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 stealthfox contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
226
README.md
Normal file
226
README.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# invisible_playwright
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.mozilla.org/firefox/)
|
||||
[](https://github.com/feder-cr/invisible_playwright/releases)
|
||||
[](https://github.com/feder-cr/invisible_playwright/stargazers)
|
||||
|
||||
A patched Firefox **100% Playwright-compatible** that passes the hardest browser-fingerprint detectors in the wild.
|
||||
|
||||
|
||||
## Results
|
||||
|
||||
These are the "best" outcomes observed across independent runs on residential proxies.
|
||||
|
||||
### Google reCAPTCHA v3 - **0.90 / 1.0**
|
||||
|
||||
Top-tier score. Google classifies the session as "very likely a human". Most anti-detect stacks plateau around 0.3-0.7.
|
||||
|
||||

|
||||
|
||||
### Fingerprint Pro - **bot: not detected, VPN: false, tampering: false, dev tools: not detected**
|
||||
|
||||
FingerprintJS Pro's full Smart Signals battery flips every flag to "Not detected". Browser correctly identified as Firefox 150 on Windows 10. Confidence score 0.9.
|
||||
|
||||

|
||||
|
||||
### CreepJS - **0 lies**, fingerprint is internally coherent
|
||||
|
||||
No contradictions between headless hints, spoofed values, and real rendering output. That "0 lies" is what kills most anti-detect browsers: one inconsistency (e.g. Chrome UA + Firefox WebGL) and the trust score collapses.
|
||||
|
||||

|
||||
|
||||
### BrowserLeaks WebRTC - **no public IP leak**
|
||||
|
||||
WebRTC srflx address is the proxy egress IP; host candidates are private LAN. The real public IP never leaks via STUN, even on pages that configure their own ICE servers. Stock Firefox leaks the real local IP via WebRTC mDNS - invisible_playwright doesn't.
|
||||
|
||||

|
||||
|
||||
### bot.sannysoft.com - **all checks pass**
|
||||
|
||||
Every row green: WebDriver not present, Chrome-only properties absent, plugin/mime/languages arrays coherent, permissions API correct, iframe/source window checks pass.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Why it's powerful
|
||||
|
||||
**Most anti-detect browsers patch Chromium at the JavaScript level** - they override `navigator`, `WebGLRenderingContext.getParameter`, canvas APIs, and so on via injected scripts. This has two fatal problems:
|
||||
|
||||
1. **JS patches are detectable.** Anti-bots enumerate native function `.toString()`, check descriptor configurability, compare property enumeration order, watch for prototype mutations. Every patch leaves a fingerprint of its own. CreepJS has an entire battery of "lies detectors" built around this.
|
||||
2. **Chromium itself is now suspect.** Residential-proxy bot traffic is overwhelmingly Chromium-based, so detectors weight anything Chromium-shaped as risky by default. And the parts that matter (TLS stack, renderer process) are not fully open-source in Chrome proper - forks either inherit all Chromium tells or drift in visible ways.
|
||||
|
||||
**invisible_playwright patches Firefox at the C++ level.** The spoofed values come back out through the normal Gecko paths - there is no JS shim, no override, no `Object.defineProperty`. **From the page's point of view, the browser is just telling the truth.** Anti-bot lie-detectors have nothing to latch onto.
|
||||
|
||||
invisible_playwright spoofs **all the layers that matter, together, coherently**:
|
||||
|
||||
| Layer | What we do | Why it matters |
|
||||
|-------|-----------|-----------------|
|
||||
| Navigator / hardware | C++ overrides: UA, oscpu, languages, hardwareConcurrency, deviceMemory, storage quota | Self-description coherent across every API |
|
||||
| Screen / window / pointer | C++ patch: screen WxH, outerSize bound, media-query device-size, pointer/hover/touch capabilities | `screen.*`, `window.outer*`, CSS `@media (pointer: fine)` all coherent |
|
||||
| CSS system colors | 40 `ui.*` Win32 palette overrides | `getComputedStyle()` on system colors matches real Windows |
|
||||
| GPU / WebGL | C++ patch: vendor, renderer, extensions whitelist, integer/float params, shader precisions, readPixels noise | Matches real Windows ANGLE down to enum values |
|
||||
| Canvas 2D | C++ patch: per-pixel substitution + geometry skip-mask noise + TextMetrics variance | Defeats canvas hashing and text-metrics fingerprinting |
|
||||
| Fonts / DirectWrite | C++ patch: family whitelist + fabricated authoritative list + per-family width scale + DWrite settings | Font enumeration matches real Win10; canvas text hash stable |
|
||||
| Audio | C++ patch: sampleRate + output latency + max channels + AnalyserNode/DynamicsCompressor noise | AudioContext fingerprints bucket users very tightly |
|
||||
| Speech synthesis | C++ patch: fabricated voices list | `navigator.speechSynthesis.getVoices()` matches the spoofed OS |
|
||||
| WebRTC | C++ patch (nICEr): srflx address swap + synthetic srflx fallback + private-LAN host candidates | Real public IP never leaks via STUN |
|
||||
| Timezone | C++ patch: per-Realm TZ via BrowsingContext (no IPC pref races) | `Date.getTimezoneOffset()`, `Intl.DateTimeFormat` match the spoofed location |
|
||||
| DevTools detection | C++ patch: `Debugger.stealthMode` + Juggler `Runtime.js` + thread actor | FP Pro `developer_tools` = Not detected even with debugger attached |
|
||||
| SOCKS5 auth | C++ patch | Stock Playwright+Firefox cannot negotiate it at all |
|
||||
| DNS | Routed through SOCKS proxy by default | No DNS leak when using a residential gateway |
|
||||
| Mouse motion | Bezier curves inside Juggler `PageHandler.js`, ~10 ms per waypoint | Even `page.click(selector)` moves like a human |
|
||||
| GPU on virtual desktop | Pref-driven workaround for FF150 alt-desktop sandbox regression | WebGL renderer populated even in headless / multi-worker mass tests |
|
||||
| Fission navigation | C++ patch: `nsDocShell` + `CanonicalBrowsingContext` Juggler navigation fix | `page.goto()` reliable on FF150 across proxy edge cases |
|
||||
| about:newtab race | Async wrapper sleep around `new_page()` | No "Navigation interrupted by about:newtab" on FF150 |
|
||||
| Proxy reliability | Juggler `PageHandler.equalsExceptRef` split try/catch | No spurious "Invalid url" with proxies like Evomi |
|
||||
|
||||
Everything is driven by preferences - no hardcoded values in the binary. You change one pref, you change the spoofed value.
|
||||
|
||||
---
|
||||
|
||||
## How it compares
|
||||
|
||||
Commercial anti-detect browsers (Multilogin, GoLogin, AdsPower, Kameleo, Dolphin Anty, Browserbase) ship a patched Chromium and override fingerprints at the JavaScript layer. That's the ceiling - and it's a low one.
|
||||
|
||||
| | invisible_playwright | Multilogin / GoLogin | AdsPower / Dolphin | Browserbase |
|
||||
|---|---|---|---|---|
|
||||
| Engine | Firefox (open source) | Chromium fork | Chromium fork | Chromium |
|
||||
| Patch depth | C++ source | JS overrides | JS overrides | JS overrides |
|
||||
| `.toString()` clean | ✅ Native Gecko path | ❌ Detectable shims | ❌ Detectable shims | ❌ Detectable shims |
|
||||
| Canvas / WebGL | ✅ C++ level | ⚠️ JS override | ⚠️ JS override | ⚠️ JS override |
|
||||
| SOCKS5 auth | ✅ Patched | ⚠️ Varies | ⚠️ Varies | ❌ |
|
||||
| Self-hosted | ✅ | ❌ SaaS | ❌ SaaS | ❌ Cloud |
|
||||
| reCAPTCHA v3 score | **0.90** | ~0.3-0.6 | ~0.3-0.5 | ~0.3-0.5 |
|
||||
| FP Pro - bot detected | ✅ Not detected | ❌ Detected | ❌ Detected | ❌ Detected |
|
||||
| FP Pro - tampering | ✅ Not detected | ❌ Detected | ❌ Detected | ❌ Detected |
|
||||
| FP Pro - VPN flag | ✅ false | ❌ true | ❌ true | ❌ true |
|
||||
| CreepJS lies | ✅ 0 | ❌ multiple | ❌ multiple | ❌ multiple |
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install invisible-playwright
|
||||
python -m invisible_playwright fetch # one-time ~100 MB download, SHA256-verified
|
||||
```
|
||||
|
||||
Supported platforms: **Windows x86_64**, **Linux x86_64**.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
### Random fingerprint per session
|
||||
**100% Playwright-compatible** - sync and async, all methods, zero API changes. If you already use Playwright, switching is two lines:
|
||||
|
||||
```diff
|
||||
- from playwright.sync_api import sync_playwright
|
||||
- with sync_playwright() as p:
|
||||
- browser = p.firefox.launch()
|
||||
+ from invisible_playwright import InvisiblePlaywright
|
||||
+ with InvisiblePlaywright() as browser:
|
||||
```
|
||||
|
||||
Every session gets a unique, coherent fingerprint drawn from real-world Firefox telemetry (GPU / audio / fonts / ~400 other fields) and Bezier-curve mouse motion baked into the browser itself.
|
||||
|
||||
**Sync**
|
||||
```python
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(proxy={"server": "socks5://...", "username": "u", "password": "p"}) as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.click("#submit") # mouse arcs to the button on a Bezier curve
|
||||
```
|
||||
|
||||
**Async**
|
||||
```python
|
||||
from invisible_playwright.async_api import InvisiblePlaywright
|
||||
|
||||
async with InvisiblePlaywright(proxy={"server": "socks5://...", "username": "u", "password": "p"}) as browser:
|
||||
page = await browser.new_page()
|
||||
await page.goto("https://example.com")
|
||||
await page.click("#submit")
|
||||
```
|
||||
|
||||
The `browser` object is a `playwright.sync_api.Browser` / `playwright.async_api.Browser` - every Playwright method works as-is.
|
||||
|
||||
---
|
||||
|
||||
### Random fingerprint per session
|
||||
|
||||
```python
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright() as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://creepjs-api.web.app")
|
||||
```
|
||||
|
||||
Every call samples a new coherent profile. Log the seed to reproduce interesting runs:
|
||||
|
||||
```python
|
||||
sf = InvisiblePlaywright()
|
||||
with sf as browser:
|
||||
print("seed =", sf.seed)
|
||||
# ...
|
||||
```
|
||||
|
||||
### Reproducible fingerprint
|
||||
|
||||
```python
|
||||
with InvisiblePlaywright(seed=42) as browser:
|
||||
... # same GPU, same canvas hash, same audio context, every run
|
||||
```
|
||||
|
||||
### Proxies
|
||||
|
||||
```python
|
||||
proxy = {
|
||||
"server": "socks5://gate.example.com:1080",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
}
|
||||
with InvisiblePlaywright(proxy=proxy) as browser:
|
||||
...
|
||||
```
|
||||
|
||||
Schemes supported: `socks5`, `socks4`, `http`, `https`. Auth works on all of them (SOCKS5 via patched `nsProtocolProxyService.cpp`, HTTP/HTTPS via Playwright). DNS is routed through the proxy by default, no local leak.
|
||||
|
||||
### Pinning specific fingerprint fields
|
||||
|
||||
By default everything comes from `seed`. To force specific values while the rest stays seed-derived:
|
||||
|
||||
```python
|
||||
with InvisiblePlaywright(
|
||||
seed=42,
|
||||
pin={
|
||||
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
|
||||
"gpu.vendor": "Google Inc. (NVIDIA)",
|
||||
"screen.width": 2560,
|
||||
"screen.height": 1440,
|
||||
"hardware.concurrency": 16,
|
||||
},
|
||||
) as browser:
|
||||
...
|
||||
```
|
||||
|
||||
Full list of pinnable keys, how pinning interacts with the Bayesian sampler, and common patterns are in **[docs/pinning.md](docs/pinning.md)**.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
invisible_playwright fetch # download the binary if missing
|
||||
invisible_playwright path # print the absolute path to the cached binary
|
||||
invisible_playwright version # wrapper and binary versions
|
||||
invisible_playwright clear-cache # remove all cached binaries
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - see [LICENSE](LICENSE). The patched Firefox binary is distributed under the MPL-2.0 (Firefox upstream license). The C++ patches against mozilla-central that produce that binary are at [feder-cr/firefox-stealth](https://github.com/feder-cr/firefox-stealth).
|
||||
143
docs/pinning.md
Normal file
143
docs/pinning.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Pinning fingerprint fields
|
||||
|
||||
By default, every field of the fingerprint is sampled from a Bayesian network of real-world Firefox telemetry, seeded by an integer. Pass the same `seed` and you get the same fingerprint; omit it and each session is fresh.
|
||||
|
||||
`pin` lets you **force specific fields** while letting the rest stay seed-derived. Useful when you need to replicate a known device (e.g. an NVIDIA 1080p laptop), test a specific GPU/screen combo, or pin just one noisy signal that a target site weighs heavily.
|
||||
|
||||
```python
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(
|
||||
seed=42,
|
||||
pin={
|
||||
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
|
||||
"gpu.vendor": "Google Inc. (NVIDIA)",
|
||||
"screen.width": 2560,
|
||||
"screen.height": 1440,
|
||||
"hardware.concurrency": 16,
|
||||
},
|
||||
) as browser:
|
||||
...
|
||||
```
|
||||
|
||||
## How sampling + pinning interact
|
||||
|
||||
The generator is a Bayesian network: every field has a probability distribution **conditioned on its parents**. For example `gpu_class_tier` conditions `screen.tier`, `hardware.concurrency` and `webgl.msaa_samples`. A high-end GPU will tend to pair with a 2560x1440+ screen and 16+ cores.
|
||||
|
||||
When you pin a field:
|
||||
|
||||
1. The pinned value is written directly, bypassing the sampler.
|
||||
2. **Unpinned children are still sampled from their conditionals** - using the parent's original posterior, not the pinned value.
|
||||
|
||||
That last point is the subtle one: pinning breaks the conditional chain. If you pin `gpu.renderer` to an RTX 4090 string but leave `screen` unpinned, the sampler will pick `screen` from the seed-derived tier (which might be `low_end`), producing a physically implausible "RTX 4090 + 1366x768" pairing.
|
||||
|
||||
**Rule of thumb:** pin correlated fields together, or just trust the sampler.
|
||||
|
||||
## Full list of pinnable keys
|
||||
|
||||
Keys are dotted paths. All values are optional - omitted keys fall back to the sampler.
|
||||
|
||||
### `gpu.*`
|
||||
|
||||
| Key | Type | Example | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `gpu.class_tier` | str | `"high_end"` | The **root** of the Bayesian network. One of `"low_end"`, `"mid_range"`, `"high_end"`, `"integrated_old"`, `"integrated_modern"`. Pin this alone to steer the whole profile (screen, concurrency, MSAA, ...) toward a coherent tier without having to name each sub-field. |
|
||||
| `gpu.vendor` | str | `"Google Inc. (NVIDIA)"` | Must exactly match the renderer vendor prefix, otherwise detectors catch the mismatch. |
|
||||
| `gpu.renderer` | str | `"ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)"` | Windows ANGLE string. Used by WebGL `UNMASKED_RENDERER_WEBGL`. |
|
||||
|
||||
**Why `class_tier` is pinnable separately from `renderer`.** They live at different levels of abstraction:
|
||||
|
||||
- `class_tier` is a **coarse handle** over the whole Bayesian graph. It gates the distribution of `screen`, `hardware.concurrency`, `webgl.msaa_samples`, and storage quota. Pin `{"gpu.class_tier": "low_end"}` and the sampler returns a *coherent* low-end machine - small screen, 2-4 cores, 4x MSAA - without you having to specify each field.
|
||||
- `renderer` is an **exact string** that lands verbatim in WebGL's `UNMASKED_RENDERER_WEBGL`. Useful when you want to imitate a specific GPU the target site has seen before. Does **not** condition other fields - if you pin `renderer` to an RTX 4090 but leave `class_tier` unpinned, `class_tier` is re-sampled from scratch and might disagree with the renderer string (see [How sampling + pinning interact](#how-sampling--pinning-interact)).
|
||||
|
||||
In practice most users should pin `class_tier` alone, or pin `renderer`+`vendor`+`class_tier` together if they want full control.
|
||||
|
||||
### `screen.*`
|
||||
|
||||
| Key | Type | Example |
|
||||
|-----|------|---------|
|
||||
| `screen.width` | int | `2560` |
|
||||
| `screen.height` | int | `1440` |
|
||||
| `screen.avail_width` | int | `2560` |
|
||||
| `screen.avail_height` | int | `1400` |
|
||||
| `screen.dpr` | float | `1.0`, `1.25`, `1.5`, `2.0` |
|
||||
| `screen.tier` | str | `"1080p"`, `"1440p"`, `"4k"`, ... |
|
||||
|
||||
### `hardware.*`
|
||||
|
||||
| Key | Type | Example | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `hardware.concurrency` | int | `16` | `navigator.hardwareConcurrency`. |
|
||||
| `hardware.storage_quota_mb` | int | `10_000` | `navigator.storage.estimate().quota / 1024²`. |
|
||||
|
||||
### `audio.*`
|
||||
|
||||
| Key | Type | Example | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `audio.sample_rate` | int | `48000`, `44100` | `AudioContext.sampleRate`. |
|
||||
| `audio.output_latency_ms` | float | `20.0` | `AudioContext.outputLatency * 1000`. |
|
||||
| `audio.max_channel_count` | int | `2`, `6`, `8` | `AudioDestinationNode.maxChannelCount`. |
|
||||
|
||||
### `codec.*` (booleans)
|
||||
|
||||
| Key | Effect |
|
||||
|-----|--------|
|
||||
| `codec.av1_enabled` | `true` -> `canPlayType('video/av01')` returns `"probably"`. |
|
||||
| `codec.webm_encoder_enabled` | `MediaRecorder` advertises WebM support. |
|
||||
| `codec.mediasource_webm` | `MediaSource.isTypeSupported('video/webm')`. |
|
||||
| `codec.mediasource_mp4` | `MediaSource.isTypeSupported('video/mp4')`. |
|
||||
| `codec.webspeech_synth` | `speechSynthesis.getVoices()` returns a fabricated voice list. |
|
||||
|
||||
### `webgl.*`
|
||||
|
||||
| Key | Type | Example | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `webgl.msaa_samples` | int | `4`, `8`, `16` | `MAX_SAMPLES` WebGL parameter. Conditioned on `gpu.class_tier` when sampled. |
|
||||
|
||||
### Top-level
|
||||
|
||||
| Key | Type | Example | Notes |
|
||||
|-----|------|---------|-------|
|
||||
| `fonts` | list[str] | `["Arial", "Segoe UI", ...]` | Complete font allowlist. **Every other font is hidden**. The sampler usually picks 14-24 system fonts. |
|
||||
| `dark_theme` | bool | `False` | `prefers-color-scheme: dark`. Real traffic is ~85% light, 15% dark. |
|
||||
|
||||
## Reading the chosen values back
|
||||
|
||||
Every sampled (or pinned) value lands in a `zoom.stealth.*` pref inside the browser. Open `about:config` in a launched invisible_playwright session and filter for `zoom.stealth` to see the exact values in effect.
|
||||
|
||||
Alternatively, inspect the instance before the `with` block exits:
|
||||
|
||||
```python
|
||||
sf = InvisiblePlaywright(seed=42)
|
||||
with sf as browser:
|
||||
# sf.seed is set; the full profile is in browser's prefs
|
||||
...
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Mimic a specific real device
|
||||
|
||||
Pin the whole visible tuple - GPU, screen, concurrency, fonts, audio:
|
||||
|
||||
```python
|
||||
pin = {
|
||||
"gpu.vendor": "Google Inc. (Intel)",
|
||||
"gpu.renderer": "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11)",
|
||||
"gpu.class_tier": "mid_range",
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.dpr": 1.0,
|
||||
"hardware.concurrency": 8,
|
||||
"audio.sample_rate": 48000,
|
||||
}
|
||||
```
|
||||
|
||||
### Test the low-end GPU path only
|
||||
|
||||
```python
|
||||
pin = {"gpu.class_tier": "low_end"}
|
||||
# screen, msaa, concurrency re-sample from the seed but conditioned
|
||||
# correctly on the low-end tier.
|
||||
```
|
||||
|
||||
BIN
docs/screenshots/creepjs.png
Normal file
BIN
docs/screenshots/creepjs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/screenshots/fingerprintpro.png
Normal file
BIN
docs/screenshots/fingerprintpro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 501 KiB |
BIN
docs/screenshots/peet_ws.png
Normal file
BIN
docs/screenshots/peet_ws.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 MiB |
BIN
docs/screenshots/recaptcha_score.png
Normal file
BIN
docs/screenshots/recaptcha_score.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 715 KiB |
BIN
docs/screenshots/sannysoft.png
Normal file
BIN
docs/screenshots/sannysoft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/screenshots/webrtc.png
Normal file
BIN
docs/screenshots/webrtc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
13
examples/basic.py
Normal file
13
examples/basic.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Launch a patched Firefox with a random stealth profile and load example.com."""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with InvisiblePlaywright() as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
print("title:", page.title())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
examples/with_proxy.py
Normal file
24
examples/with_proxy.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Same as basic.py but route through a SOCKS5 proxy."""
|
||||
import os
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
|
||||
def main() -> None:
|
||||
proxy = {
|
||||
"server": os.environ.get("STEALTHFOX_PROXY_SERVER", "socks5://127.0.0.1:1080"),
|
||||
}
|
||||
user = os.environ.get("STEALTHFOX_PROXY_USER")
|
||||
password = os.environ.get("STEALTHFOX_PROXY_PASS")
|
||||
if user and password:
|
||||
proxy["username"] = user
|
||||
proxy["password"] = password
|
||||
|
||||
with InvisiblePlaywright(proxy=proxy) as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://httpbin.org/ip")
|
||||
print(page.content()[:500])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
pyproject.toml
Normal file
45
pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "invisible-playwright"
|
||||
version = "0.1.0"
|
||||
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
authors = [{ name = "feder-cr", email = "85809106+feder-cr@users.noreply.github.com" }]
|
||||
keywords = ["playwright", "firefox", "stealth", "anti-detect", "automation"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"playwright>=1.40",
|
||||
"platformdirs>=4",
|
||||
"requests>=2.31",
|
||||
"tqdm>=4.66",
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24"]
|
||||
|
||||
[project.scripts]
|
||||
invisible-playwright = "invisible_playwright.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/feder-cr/invisible_playwright"
|
||||
Issues = "https://github.com/feder-cr/invisible_playwright/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/invisible_playwright"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/invisible_playwright/data" = "invisible_playwright/data"
|
||||
"src/invisible_playwright/_fpforge/data" = "invisible_playwright/_fpforge/data"
|
||||
271
scripts/audit_cpt_realism.py
Normal file
271
scripts/audit_cpt_realism.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Bayesian network realism audit (one-shot regenerator).
|
||||
|
||||
Each (gpu_class, intra_tier) cell maps to a real-world persona.
|
||||
Distributions derived from:
|
||||
- Steam HW Survey March 2026 Windows
|
||||
cores: 2c=3%, 4c=13%, 6c=29%, 8c=27%, 10c=7%, 12c=5%, 14c=5%, 16c=6%, 24c=3%, 32c=0.01%
|
||||
- Intel ARK + AMD spec sheets (CPU release era -> cores)
|
||||
- Tom's Hardware: most users 100-250GB free (= 512GB-1TB drives baseline)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
OUT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "invisible-playwright", "_fpforge", "data",
|
||||
)
|
||||
|
||||
|
||||
def _norm(d):
|
||||
"""Normalize prob dict / list of (val, prob) pairs to sum 1.0."""
|
||||
if isinstance(d, dict):
|
||||
total = sum(d.values())
|
||||
return [{"value": v, "prob": round(p / total, 4)} for v, p in d.items()]
|
||||
total = sum(p for _, p in d)
|
||||
return [{"value": v, "prob": round(p / total, 4)} for v, p in d]
|
||||
|
||||
|
||||
def _scr(w, h, dpr=1.0):
|
||||
return {"w": w, "h": h, "aw": w, "ah": h - 40, "dpr": dpr}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HW concurrency per (gpu_class, intra_tier)
|
||||
# ============================================================
|
||||
HWC = {
|
||||
("integrated_old", "budget"): {2: 0.65, 4: 0.30, 8: 0.05},
|
||||
("integrated_old", "standard"): {2: 0.30, 4: 0.55, 8: 0.15},
|
||||
("integrated_old", "premium"): {4: 0.65, 8: 0.30, 12: 0.05},
|
||||
|
||||
("integrated_modern", "budget"): {4: 0.55, 6: 0.20, 8: 0.20, 12: 0.05},
|
||||
("integrated_modern", "standard"): {6: 0.20, 8: 0.30, 10: 0.20, 12: 0.20, 16: 0.10},
|
||||
("integrated_modern", "premium"): {8: 0.20, 10: 0.20, 12: 0.30, 14: 0.15, 16: 0.15},
|
||||
|
||||
("low_end", "budget"): {4: 0.50, 6: 0.25, 8: 0.20, 12: 0.05},
|
||||
("low_end", "standard"): {4: 0.10, 6: 0.35, 8: 0.30, 12: 0.18, 16: 0.07},
|
||||
("low_end", "premium"): {6: 0.10, 8: 0.30, 12: 0.30, 16: 0.22, 24: 0.08},
|
||||
|
||||
("mid_range", "budget"): {6: 0.55, 8: 0.30, 12: 0.10, 16: 0.05},
|
||||
("mid_range", "standard"): {6: 0.40, 8: 0.30, 12: 0.18, 16: 0.10, 24: 0.02},
|
||||
("mid_range", "premium"): {6: 0.15, 8: 0.45, 12: 0.20, 16: 0.15, 24: 0.05},
|
||||
|
||||
("high_end", "budget"): {6: 0.10, 8: 0.55, 12: 0.20, 16: 0.15},
|
||||
("high_end", "standard"): {8: 0.30, 12: 0.18, 14: 0.15, 16: 0.27, 24: 0.10},
|
||||
("high_end", "premium"): {12: 0.10, 14: 0.15, 16: 0.30, 24: 0.35, 32: 0.10},
|
||||
|
||||
("workstation", "budget"): {8: 0.40, 12: 0.30, 16: 0.20, 24: 0.10},
|
||||
("workstation", "standard"): {12: 0.10, 16: 0.40, 24: 0.30, 32: 0.20},
|
||||
("workstation", "premium"): {24: 0.30, 32: 0.45, 48: 0.15, 64: 0.10},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Screen resolution per (gpu_class, intra_tier)
|
||||
# Each value list is [(screen_obj, prob), ...].
|
||||
# ============================================================
|
||||
SCREEN = {
|
||||
("integrated_old", "budget"): [(_scr(1366, 768), 0.78), (_scr(1280, 800), 0.15), (_scr(1024, 768), 0.07)],
|
||||
("integrated_old", "standard"): [(_scr(1366, 768), 0.55), (_scr(1600, 900), 0.25), (_scr(1280, 800), 0.10), (_scr(1920, 1080), 0.10)],
|
||||
("integrated_old", "premium"): [(_scr(1600, 900), 0.45), (_scr(1920, 1080), 0.40), (_scr(1366, 768), 0.15)],
|
||||
|
||||
("integrated_modern", "budget"): [(_scr(1366, 768), 0.30), (_scr(1920, 1080), 0.65), (_scr(1600, 900), 0.05)],
|
||||
("integrated_modern", "standard"):[(_scr(1920, 1080), 0.70), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.12), (_scr(2560, 1600), 0.08)],
|
||||
("integrated_modern", "premium"): [(_scr(1920, 1080), 0.30), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.25), (_scr(2560, 1600), 0.15), (_scr(3840, 2160), 0.20)],
|
||||
|
||||
("low_end", "budget"): [(_scr(1920, 1080), 0.85), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.05)],
|
||||
("low_end", "standard"): [(_scr(1920, 1080), 0.60), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.25), (_scr(3840, 2160), 0.05)],
|
||||
("low_end", "premium"): [(_scr(1920, 1080), 0.25), (_scr(2560, 1440), 0.45), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.05), (_scr(1920, 1200), 0.05)],
|
||||
|
||||
("mid_range", "budget"): [(_scr(1920, 1080), 0.75), (_scr(2560, 1440), 0.20), (_scr(1920, 1200), 0.05)],
|
||||
("mid_range", "standard"): [(_scr(1920, 1080), 0.45), (_scr(2560, 1440), 0.40), (_scr(3840, 2160), 0.10), (_scr(3440, 1440), 0.05)],
|
||||
("mid_range", "premium"): [(_scr(1920, 1080), 0.15), (_scr(2560, 1440), 0.50), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.10), (_scr(2560, 1080), 0.05)],
|
||||
|
||||
("high_end", "budget"): [(_scr(1920, 1080), 0.20), (_scr(2560, 1440), 0.55), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.05)],
|
||||
("high_end", "standard"): [(_scr(2560, 1440), 0.40), (_scr(3840, 2160), 0.40), (_scr(3440, 1440), 0.15), (_scr(5120, 1440), 0.05)],
|
||||
("high_end", "premium"): [(_scr(3840, 2160), 0.55), (_scr(2560, 1440), 0.15), (_scr(3440, 1440), 0.10), (_scr(5120, 1440), 0.15), (_scr(7680, 2160), 0.05)],
|
||||
|
||||
("workstation", "budget"): [(_scr(2560, 1440), 0.55), (_scr(3840, 2160), 0.30), (_scr(1920, 1200), 0.10), (_scr(2560, 1600), 0.05)],
|
||||
("workstation", "standard"): [(_scr(2560, 1440), 0.30), (_scr(3840, 2160), 0.45), (_scr(2560, 1600), 0.15), (_scr(3440, 1440), 0.10)],
|
||||
("workstation", "premium"): [(_scr(3840, 2160), 0.55), (_scr(2560, 1600), 0.15), (_scr(5120, 2160), 0.15), (_scr(3840, 2400), 0.10), (_scr(7680, 2160), 0.05)],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Storage quota MB per (gpu_class, intra_tier)
|
||||
# ============================================================
|
||||
STORAGE = {
|
||||
("integrated_old", "budget"): {32_000: 0.30, 64_000: 0.40, 128_000: 0.25, 256_000: 0.05},
|
||||
("integrated_old", "standard"): {64_000: 0.20, 128_000: 0.45, 256_000: 0.25, 500_000: 0.10},
|
||||
("integrated_old", "premium"): {128_000: 0.20, 256_000: 0.45, 500_000: 0.30, 1_000_000: 0.05},
|
||||
|
||||
("integrated_modern", "budget"): {64_000: 0.20, 128_000: 0.30, 256_000: 0.30, 500_000: 0.20},
|
||||
("integrated_modern", "standard"):{256_000: 0.25, 500_000: 0.45, 1_000_000: 0.25, 2_000_000: 0.05},
|
||||
("integrated_modern", "premium"): {500_000: 0.25, 1_000_000: 0.50, 2_000_000: 0.20, 4_000_000: 0.05},
|
||||
|
||||
("low_end", "budget"): {128_000: 0.20, 256_000: 0.50, 500_000: 0.25, 1_000_000: 0.05},
|
||||
("low_end", "standard"): {256_000: 0.20, 500_000: 0.50, 1_000_000: 0.25, 2_000_000: 0.05},
|
||||
("low_end", "premium"): {500_000: 0.20, 1_000_000: 0.50, 2_000_000: 0.25, 4_000_000: 0.05},
|
||||
|
||||
("mid_range", "budget"): {256_000: 0.15, 500_000: 0.50, 1_000_000: 0.30, 2_000_000: 0.05},
|
||||
("mid_range", "standard"):{500_000: 0.20, 1_000_000: 0.55, 2_000_000: 0.20, 4_000_000: 0.05},
|
||||
("mid_range", "premium"): {1_000_000: 0.40, 2_000_000: 0.45, 4_000_000: 0.15},
|
||||
|
||||
("high_end", "budget"): {500_000: 0.15, 1_000_000: 0.50, 2_000_000: 0.30, 4_000_000: 0.05},
|
||||
("high_end", "standard"):{1_000_000: 0.30, 2_000_000: 0.50, 4_000_000: 0.20},
|
||||
("high_end", "premium"): {2_000_000: 0.40, 4_000_000: 0.45, 8_000_000: 0.15},
|
||||
|
||||
("workstation", "budget"): {1_000_000: 0.30, 2_000_000: 0.50, 4_000_000: 0.20},
|
||||
("workstation", "standard"):{2_000_000: 0.30, 4_000_000: 0.50, 8_000_000: 0.20},
|
||||
("workstation", "premium"): {4_000_000: 0.35, 8_000_000: 0.45, 16_000_000: 0.20},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Audio profile per gpu_class
|
||||
# ============================================================
|
||||
AUDIO = {
|
||||
"integrated_old": [
|
||||
({"rate": 44100, "latency": 50, "channels": 2}, 0.70),
|
||||
({"rate": 48000, "latency": 50, "channels": 2}, 0.30),
|
||||
],
|
||||
"integrated_modern": [
|
||||
({"rate": 48000, "latency": 30, "channels": 2}, 0.60),
|
||||
({"rate": 44100, "latency": 40, "channels": 2}, 0.25),
|
||||
({"rate": 48000, "latency": 25, "channels": 6}, 0.15),
|
||||
],
|
||||
"low_end": [
|
||||
({"rate": 48000, "latency": 40, "channels": 2}, 0.55),
|
||||
({"rate": 44100, "latency": 50, "channels": 2}, 0.30),
|
||||
({"rate": 48000, "latency": 30, "channels": 6}, 0.15),
|
||||
],
|
||||
"mid_range": [
|
||||
({"rate": 48000, "latency": 25, "channels": 2}, 0.45),
|
||||
({"rate": 48000, "latency": 20, "channels": 6}, 0.30),
|
||||
({"rate": 48000, "latency": 20, "channels": 8}, 0.15),
|
||||
({"rate": 44100, "latency": 30, "channels": 2}, 0.10),
|
||||
],
|
||||
"high_end": [
|
||||
({"rate": 48000, "latency": 15, "channels": 6}, 0.30),
|
||||
({"rate": 48000, "latency": 15, "channels": 8}, 0.30),
|
||||
({"rate": 48000, "latency": 15, "channels": 2}, 0.20),
|
||||
({"rate": 96000, "latency": 15, "channels": 6}, 0.10),
|
||||
({"rate": 96000, "latency": 15, "channels": 8}, 0.10),
|
||||
],
|
||||
"workstation": [
|
||||
({"rate": 48000, "latency": 10, "channels": 8}, 0.25),
|
||||
({"rate": 96000, "latency": 10, "channels": 8}, 0.30),
|
||||
({"rate": 96000, "latency": 10, "channels": 6}, 0.20),
|
||||
({"rate": 192000, "latency": 10, "channels": 8}, 0.15),
|
||||
({"rate": 48000, "latency": 15, "channels": 6}, 0.10),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Codec per gpu_class
|
||||
# ============================================================
|
||||
CODEC = {
|
||||
"integrated_old": [
|
||||
({"av1_enabled": False, "webm_encoder_enabled": False, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 1.0),
|
||||
],
|
||||
"integrated_modern": [
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.55),
|
||||
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.35),
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.10),
|
||||
],
|
||||
"low_end": [
|
||||
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.85),
|
||||
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.15),
|
||||
],
|
||||
"mid_range": [
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.55),
|
||||
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.35),
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.10),
|
||||
],
|
||||
"high_end": [
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.85),
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.15),
|
||||
],
|
||||
"workstation": [
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.70),
|
||||
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.30),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MSAA per (gpu_class, screen_tier)
|
||||
# screen_tier classifier in _sampler.py: "1080p", "1440p", "2160p", "ultrawide"
|
||||
# Note: prefs.py clamps msaa to >=2 (antialias=True always), so msaa=0 is no longer
|
||||
# emitted at the browser layer. CPT here keeps the 2/4/8 distribution matching what
|
||||
# the prefs layer actually exposes.
|
||||
# ============================================================
|
||||
MSAA = {
|
||||
("integrated_old", "1080p"): [(2, 0.75), (4, 0.20), (8, 0.05)],
|
||||
("integrated_old", "1440p"): [(2, 0.85), (4, 0.15)],
|
||||
("integrated_old", "2160p"): [(2, 1.0)],
|
||||
("integrated_old", "ultrawide"): [(2, 1.0)],
|
||||
|
||||
("integrated_modern", "1080p"): [(2, 0.50), (4, 0.40), (8, 0.10)],
|
||||
("integrated_modern", "1440p"): [(2, 0.60), (4, 0.35), (8, 0.05)],
|
||||
("integrated_modern", "2160p"): [(2, 0.85), (4, 0.15)],
|
||||
("integrated_modern", "ultrawide"):[(2, 0.80), (4, 0.20)],
|
||||
|
||||
("low_end", "1080p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
|
||||
("low_end", "1440p"): [(2, 0.55), (4, 0.40), (8, 0.05)],
|
||||
("low_end", "2160p"): [(2, 0.85), (4, 0.15)],
|
||||
("low_end", "ultrawide"): [(2, 0.70), (4, 0.30)],
|
||||
|
||||
("mid_range", "1080p"): [(2, 0.30), (4, 0.50), (8, 0.20)],
|
||||
("mid_range", "1440p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
|
||||
("mid_range", "2160p"): [(2, 0.65), (4, 0.30), (8, 0.05)],
|
||||
("mid_range", "ultrawide"): [(2, 0.55), (4, 0.40), (8, 0.05)],
|
||||
|
||||
("high_end", "1080p"): [(2, 0.20), (4, 0.45), (8, 0.35)],
|
||||
("high_end", "1440p"): [(2, 0.25), (4, 0.50), (8, 0.25)],
|
||||
("high_end", "2160p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
|
||||
("high_end", "ultrawide"): [(2, 0.30), (4, 0.50), (8, 0.20)],
|
||||
|
||||
("workstation", "1080p"): [(2, 0.15), (4, 0.50), (8, 0.35)],
|
||||
("workstation", "1440p"): [(2, 0.15), (4, 0.55), (8, 0.30)],
|
||||
("workstation", "2160p"): [(2, 0.25), (4, 0.55), (8, 0.20)],
|
||||
("workstation", "ultrawide"): [(2, 0.20), (4, 0.55), (8, 0.25)],
|
||||
}
|
||||
|
||||
|
||||
def write_pair_table(table, fname, meta):
|
||||
out = {"_meta": meta, "table": {}}
|
||||
for key_pair, dist in table.items():
|
||||
key = json.dumps(list(key_pair))
|
||||
out["table"][key] = _norm(dist)
|
||||
with open(os.path.join(OUT, fname), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def write_class_table(table, fname, meta):
|
||||
out = {"_meta": meta, "table": {}}
|
||||
for cls, dist in table.items():
|
||||
out["table"][cls] = _norm(dist)
|
||||
with open(os.path.join(OUT, fname), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def main():
|
||||
write_pair_table(HWC, "cpt_hwc_given_class_tier.json",
|
||||
"hardware_concurrency given (gpu_class, intra_tier)")
|
||||
write_pair_table(SCREEN, "cpt_screen_given_class_tier.json",
|
||||
"screen given (gpu_class, intra_tier)")
|
||||
write_pair_table(STORAGE, "cpt_storage_given_class_tier.json",
|
||||
"storage_quota_mb given (gpu_class, intra_tier)")
|
||||
write_pair_table(MSAA, "cpt_msaa_given_class_screen.json",
|
||||
"msaa_samples given (gpu_class, screen_tier)")
|
||||
write_class_table(AUDIO, "cpt_audio_given_class.json",
|
||||
"audio (rate/latency/channels) given gpu_class")
|
||||
write_class_table(CODEC, "cpt_codec_given_class.json",
|
||||
"codec given gpu_class")
|
||||
print("All 6 CPT files updated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
src/invisible_playwright/__init__.py
Normal file
22
src/invisible_playwright/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""invisible_playwright — Playwright wrapper for a patched Firefox with stealth profile.
|
||||
|
||||
Quickstart:
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright() as browser: # random seed
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
|
||||
with InvisiblePlaywright(seed=42) as browser: # deterministic
|
||||
...
|
||||
|
||||
with InvisiblePlaywright(humanize=True) as browser: # human-like cursor motion
|
||||
page = browser.new_page()
|
||||
page.click("#submit") # expanded into a Bezier trajectory
|
||||
"""
|
||||
from .launcher import InvisiblePlaywright
|
||||
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["InvisiblePlaywright", "BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__"]
|
||||
4
src/invisible_playwright/__main__.py
Normal file
4
src/invisible_playwright/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .cli import main
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
26
src/invisible_playwright/_fpforge/__init__.py
Normal file
26
src/invisible_playwright/_fpforge/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Internal Bayesian fingerprint generator used by invisible_playwright.
|
||||
|
||||
Private module — do not import from user code. Use
|
||||
invisible_playwright.InvisiblePlaywright(seed=..., pin=...) instead.
|
||||
"""
|
||||
from .profile import (
|
||||
AudioProfile,
|
||||
CodecProfile,
|
||||
GPUProfile,
|
||||
HardwareProfile,
|
||||
Profile,
|
||||
ScreenProfile,
|
||||
WebGLProfile,
|
||||
generate_profile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"generate_profile",
|
||||
"Profile",
|
||||
"GPUProfile",
|
||||
"ScreenProfile",
|
||||
"HardwareProfile",
|
||||
"AudioProfile",
|
||||
"CodecProfile",
|
||||
"WebGLProfile",
|
||||
]
|
||||
131
src/invisible_playwright/_fpforge/_network.py
Normal file
131
src/invisible_playwright/_fpforge/_network.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Generic Bayesian network for fingerprint sampling.
|
||||
|
||||
A Node has:
|
||||
- name
|
||||
- parents (list of parent node names)
|
||||
- CPT: either
|
||||
* marginal (no parents): flat [{value, prob}, ...]
|
||||
* conditional: {parent_tuple: [{value, prob}, ...]}
|
||||
- OR deterministic: a classifier function `(context) -> value` (no CPT)
|
||||
|
||||
Sampling:
|
||||
- Nodes are topologically sorted
|
||||
- For each node, look up the conditional distribution given parent values
|
||||
already sampled in `context`, then weighted-pick
|
||||
- Deterministic nodes apply their classifier directly
|
||||
|
||||
Values can be ANY JSON-serializable type (int, str, dict, list, bool).
|
||||
Complex values (e.g. screen joint {w, h, dpr}) are stored as dicts in the
|
||||
CPT and returned as-is in the context.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class Node:
|
||||
"""Single Bayesian node."""
|
||||
|
||||
__slots__ = ("name", "parents", "cpt", "classifier", "_marginal")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
parents: Optional[List[str]] = None,
|
||||
cpt: Optional[Any] = None,
|
||||
classifier: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.parents = list(parents or [])
|
||||
self.cpt = cpt
|
||||
self.classifier = classifier
|
||||
# Precompute: for no-parent nodes, cpt is the marginal list
|
||||
self._marginal = cpt if not self.parents and classifier is None else None
|
||||
|
||||
def sample(self, context: Dict[str, Any], rng: random.Random) -> Any:
|
||||
# Deterministic nodes don't sample
|
||||
if self.classifier is not None:
|
||||
return self.classifier(context)
|
||||
|
||||
if not self.parents:
|
||||
# Marginal root
|
||||
return _weighted_pick(self._marginal, rng)
|
||||
|
||||
# Conditional node: build the key from parent values
|
||||
key = _parent_key(self.parents, context)
|
||||
if key not in self.cpt:
|
||||
# Fallback: concatenate all parents' tables (uniform over union)
|
||||
# Keeps sampler from crashing if data doesn't cover some combo.
|
||||
pool = []
|
||||
for v in self.cpt.values():
|
||||
pool.extend(v)
|
||||
if not pool:
|
||||
raise ValueError(
|
||||
f"Node {self.name!r}: no CPT entries for {self.parents}={key}"
|
||||
)
|
||||
return _weighted_pick(pool, rng)
|
||||
return _weighted_pick(self.cpt[key], rng)
|
||||
|
||||
|
||||
class Network:
|
||||
"""Collection of nodes with topological sampling."""
|
||||
|
||||
def __init__(self, nodes: List[Node]):
|
||||
self.nodes = _topsort(nodes)
|
||||
self.by_name = {n.name: n for n in self.nodes}
|
||||
|
||||
def sample(self, rng: random.Random) -> Dict[str, Any]:
|
||||
context: Dict[str, Any] = {}
|
||||
for node in self.nodes:
|
||||
context[node.name] = node.sample(context, rng)
|
||||
return context
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _weighted_pick(table: List[Dict[str, Any]], rng: random.Random) -> Any:
|
||||
"""`table` is a list of {value, prob} dicts. Returns one value."""
|
||||
values = [e["value"] for e in table]
|
||||
probs = [float(e["prob"]) for e in table]
|
||||
if not values:
|
||||
raise ValueError("Empty CPT entry")
|
||||
total = sum(probs)
|
||||
if total <= 0:
|
||||
return rng.choice(values)
|
||||
# Normalize to be safe (CPTs can be unnormalized)
|
||||
probs = [p / total for p in probs]
|
||||
return rng.choices(values, weights=probs, k=1)[0]
|
||||
|
||||
|
||||
def _parent_key(parents: List[str], context: Dict[str, Any]) -> str:
|
||||
"""Build a JSON-stable key from parent values in declared order."""
|
||||
if len(parents) == 1:
|
||||
v = context[parents[0]]
|
||||
return v if isinstance(v, str) else json.dumps(v, sort_keys=True)
|
||||
return json.dumps([context[p] for p in parents], sort_keys=True)
|
||||
|
||||
|
||||
def _topsort(nodes: List[Node]) -> List[Node]:
|
||||
"""Topological sort by parent-before-child."""
|
||||
by_name = {n.name: n for n in nodes}
|
||||
visited: set = set()
|
||||
order: List[Node] = []
|
||||
|
||||
def visit(n: Node, path: set):
|
||||
if n.name in visited:
|
||||
return
|
||||
if n.name in path:
|
||||
raise ValueError(f"Cycle at {n.name}")
|
||||
path.add(n.name)
|
||||
for p in n.parents:
|
||||
if p not in by_name:
|
||||
raise ValueError(f"Node {n.name} has unknown parent {p}")
|
||||
visit(by_name[p], path)
|
||||
path.discard(n.name)
|
||||
visited.add(n.name)
|
||||
order.append(n)
|
||||
|
||||
for n in nodes:
|
||||
visit(n, set())
|
||||
return order
|
||||
358
src/invisible_playwright/_fpforge/_sampler.py
Normal file
358
src/invisible_playwright/_fpforge/_sampler.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""stealth_forge — Bayesian fingerprint generator for Firefox 150 Windows.
|
||||
|
||||
Everything the Firefox build exposes to JS (screen, hardwareConcurrency,
|
||||
WebGL, audio, MSAA, theme, media codecs) is sampled from a Bayesian network
|
||||
with coherent cross-field dependencies. Identity (userAgent, platform,
|
||||
oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build.
|
||||
|
||||
Graph:
|
||||
|
||||
gpu (root, 444 real Windows ANGLE renderers)
|
||||
│
|
||||
└─> gpu_class (deterministic classifier, 6 classes)
|
||||
├─> hw_concurrency (CPT per class)
|
||||
├─> screen (w/h/dpr/av) (CPT per class)
|
||||
└─> msaa_samples (CPT per class)
|
||||
|
||||
audio (root, joint rate+latency+channels — marginal)
|
||||
dark_theme (marginal)
|
||||
av1_enabled (marginal)
|
||||
webm_encoder_enabled (marginal)
|
||||
|
||||
font_exclude ← deterministic hash of stealth_seed (seed-derived)
|
||||
|
||||
CPTs live in `data/*.json` (easy to tune without code changes).
|
||||
Sampling is deterministic per stealth_seed via a private random.Random.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
from ._network import Network, Node
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load(filename: str) -> Any:
|
||||
with open(os.path.join(_HERE, "data", filename), "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# LOCKED IDENTITY (compiled into our Firefox 150 build — never varies)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
_LOCKED: Dict[str, Any] = {
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
|
||||
"Gecko/20100101 Firefox/150.0.1"
|
||||
),
|
||||
"platform": "Win32",
|
||||
"oscpu": "Windows NT 10.0; Win64; x64",
|
||||
"app_code_name": "Mozilla",
|
||||
"app_version": "5.0 (Windows)",
|
||||
"product_sub": "20100101",
|
||||
"webdriver": False,
|
||||
"max_touch_points": 0,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# DATA
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
_GPU_POOL = _load("webgl_renderer_pool.json")["entries"]
|
||||
# hwc/screen/storage now keyed on (gpu_class, intra_tier) for triangulation
|
||||
_CPT_HWC = _load("cpt_hwc_given_class_tier.json")["table"]
|
||||
_CPT_SCREEN = _load("cpt_screen_given_class_tier.json")["table"]
|
||||
_CPT_STORAGE = _load("cpt_storage_given_class_tier.json")["table"]
|
||||
# Hidden tier variable that makes hwc/screen/storage jointly coherent
|
||||
_CPT_INTRA_TIER = _load("cpt_intra_tier_given_class.json")["table"]
|
||||
# MSAA depends on (gpu_class, screen_tier) — 4K gaming → MSAA=0, 1080p+GPU → MSAA=4
|
||||
_CPT_MSAA = _load("cpt_msaa_given_class_screen.json")["table"]
|
||||
# Codec unchanged
|
||||
_CPT_CODEC = _load("cpt_codec_given_class.json")["table"]
|
||||
# Audio now conditional on gpu_class (workstation → pro audio, old → 44.1kHz onboard)
|
||||
_CPT_AUDIO = _load("cpt_audio_given_class.json")["table"]
|
||||
_INDEP = _load("priors_independent.json")
|
||||
_FONT_POOL = _load("font_pool.json")
|
||||
# Each entry is a dict {"name": "<lowercase family>", "factor": float}.
|
||||
# - name: the font family advertised to the page.
|
||||
# - factor: per-family width scale used by the consumer to make the family
|
||||
# detectable by width-diff probes.
|
||||
# Core = always-included; Optional = sampled with P(font | gpu_class).
|
||||
_FONT_CORE: list = _FONT_POOL["core"]
|
||||
_FONT_OPTIONAL: list = _FONT_POOL["optional"]
|
||||
_CPT_FONTS_OPT = _load("cpt_fonts_optional_given_class.json")["table"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# GPU CLASSIFIER (deterministic function of gpu → gpu_class)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
_GPU_CLASSES = (
|
||||
"integrated_old", "integrated_modern", "low_end",
|
||||
"mid_range", "high_end", "workstation",
|
||||
)
|
||||
|
||||
|
||||
def classify_gpu(gpu_value: Dict[str, str]) -> str:
|
||||
"""Deterministic: maps (renderer, vendor) dict to one of 6 classes.
|
||||
|
||||
See data/cpt_*.json — each CPT table has an entry for every class.
|
||||
"""
|
||||
r = gpu_value.get("renderer", "")
|
||||
|
||||
if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r):
|
||||
return "integrated_old"
|
||||
if re.search(
|
||||
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
|
||||
r,
|
||||
):
|
||||
return "integrated_modern"
|
||||
if re.search(
|
||||
r"AMD.*(Radeon(\(TM\))? (Graphics|6\d\dM|7\d\dM|8\d\dM)|Vega [0-9]|"
|
||||
r"Renoir|Rembrandt|TM Graphics)",
|
||||
r, re.IGNORECASE,
|
||||
):
|
||||
return "integrated_modern"
|
||||
|
||||
# NVIDIA: Firefox SanitizeRenderer.cpp collapses every GeForce into one of
|
||||
# 3 vintage buckets (8800 GTX / GTX 480 / GTX 980). The renderer string
|
||||
# exposed to JS is therefore vintage; pairing it with modern cores/screen
|
||||
# creates an internal mismatch that FP Pro's tampering_ml flags. We pick
|
||||
# `low_end` for all 3 buckets so cores stay 4-12 and screen 1080-1440p,
|
||||
# consistent with what a real user with each of those (vintage) cards
|
||||
# would have. Workstation overrides keep their high-tier classification.
|
||||
if re.search(
|
||||
r"(GeForce (8\d\d\d?|9\d\d\d?|GTX 980|GTX 480|GT 1030|GT 710|GT 730|"
|
||||
r"GT 220|GT 240|210|310)|Quadro K\d|Radeon HD [1234]\d\d\d)", r,
|
||||
):
|
||||
return "low_end"
|
||||
|
||||
# NVIDIA discrete (any other GeForce — should be rare after the pool was
|
||||
# collapsed to the 3 sanitize buckets, but kept as a safety net).
|
||||
m = re.search(r"GeForce\s+(?:GTX\s+|RTX\s+)?(\d{3,4})", r)
|
||||
if m:
|
||||
if "Quadro" in r or "Workstation" in r:
|
||||
return "workstation"
|
||||
# Anything that survives the sanitize collapse stays low_end to avoid
|
||||
# the modern-cores/vintage-renderer pairing.
|
||||
return "low_end"
|
||||
|
||||
# AMD discrete
|
||||
m = re.search(r"Radeon[^0-9]*(\d{3,4})", r)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
if "FirePro" in r or "Radeon Pro" in r:
|
||||
return "workstation"
|
||||
if n >= 5700:
|
||||
return "high_end"
|
||||
if 5500 <= n <= 5600 or 580 <= n <= 590:
|
||||
return "mid_range"
|
||||
return "low_end"
|
||||
|
||||
# Fallback
|
||||
return "mid_range"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# NETWORK CONSTRUCTION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Build once at import — the network is stateless, only the RNG varies.
|
||||
|
||||
def _gpu_marginal():
|
||||
"""Build marginal distribution over GPU pool (uniform for now)."""
|
||||
n = len(_GPU_POOL)
|
||||
p = 1.0 / n
|
||||
return [{"value": g, "prob": p} for g in _GPU_POOL]
|
||||
|
||||
|
||||
def _cpt_from_table(table: Dict[str, Any]) -> Dict[str, list]:
|
||||
"""CPT for conditional nodes: `{class_name: [{value, prob}, ...]}`."""
|
||||
return dict(table)
|
||||
|
||||
|
||||
def _screen_tier(ctx):
|
||||
"""Classify screen width into tier for (gpu_class, screen_tier) CPTs."""
|
||||
s = ctx.get("screen", {}) or {}
|
||||
w = int(s.get("w", 1920))
|
||||
h = int(s.get("h", 1080))
|
||||
# Ultrawide: aspect ratio > 2.1 (e.g. 3440x1440, 5120x1440)
|
||||
if h > 0 and (w / h) > 2.1:
|
||||
return "ultrawide"
|
||||
if w <= 1920:
|
||||
return "1080p"
|
||||
if w <= 2560:
|
||||
return "1440p"
|
||||
if w <= 3840:
|
||||
return "2160p"
|
||||
return "ultrawide"
|
||||
|
||||
|
||||
_NETWORK = Network([
|
||||
Node("gpu", parents=[], cpt=_gpu_marginal()),
|
||||
Node("gpu_class", parents=["gpu"], classifier=lambda ctx: classify_gpu(ctx["gpu"])),
|
||||
# Hidden variable: within a gpu_class, user's OTHER components (RAM, SSD,
|
||||
# cores, screen) correlate — a 'premium' mid_range user has more cores,
|
||||
# larger SSD, higher-res screen than a 'budget' mid_range user. Without
|
||||
# this, hwc/screen/storage would be independent given gpu_class (noisy).
|
||||
Node("intra_tier", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_INTRA_TIER)),
|
||||
# hwc/screen/storage now jointly coherent via (gpu_class, intra_tier).
|
||||
Node("hw_concurrency", parents=["gpu_class", "intra_tier"],
|
||||
cpt=_cpt_from_table(_CPT_HWC)),
|
||||
Node("screen", parents=["gpu_class", "intra_tier"],
|
||||
cpt=_cpt_from_table(_CPT_SCREEN)),
|
||||
# Derive screen_tier from screen for msaa parent lookup.
|
||||
Node("screen_tier", parents=["screen"], classifier=_screen_tier),
|
||||
# MSAA: realistic combo (4K + high_end GPU → MSAA=0 due to perf cost;
|
||||
# 1080p + high_end → MSAA=4 common; 1080p + integrated → MSAA=0).
|
||||
Node("msaa_samples", parents=["gpu_class", "screen_tier"],
|
||||
cpt=_cpt_from_table(_CPT_MSAA)),
|
||||
# Joint codec distribution (gpu_class only).
|
||||
Node("codec", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_CODEC)),
|
||||
# Storage quota: coherent within gpu_class × intra_tier (premium workstation
|
||||
# user → 2-3TB SSD; budget workstation user → 512GB; budget integrated_old
|
||||
# → 128GB).
|
||||
Node("storage_quota_mb", parents=["gpu_class", "intra_tier"],
|
||||
cpt=_cpt_from_table(_CPT_STORAGE)),
|
||||
# Audio: pro users (workstation) → 48/96kHz 6-8ch; old onboard → 44.1kHz
|
||||
# 2ch high latency. Workstation GPU + 44.1kHz mono was previously
|
||||
# implausible; now blocked by the CPT.
|
||||
Node("audio", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_AUDIO)),
|
||||
Node("dark_theme", parents=[], cpt=_INDEP["dark_theme"]["table"]),
|
||||
])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# FONT WHITELIST (Bayesian: core ∪ sampled_optional | gpu_class)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Semantic flip: previously exclude-list (block N probed fonts per seed).
|
||||
# Now whitelist (browser sees ONLY these fonts, everything else hidden).
|
||||
# Core (~112): always included — fresh Win11 + Office 2021 English.
|
||||
# Optional (~40): sampled per-session with P(present | gpu_class). Gives
|
||||
# small realistic variance (~3-8 optional fonts differ per session) while
|
||||
# keeping the profile strongly centered on 'typical Windows user'.
|
||||
|
||||
|
||||
def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]:
|
||||
"""Build COHERENT whitelist + metrics strings for the session.
|
||||
|
||||
Sampling:
|
||||
- Core fonts always included.
|
||||
- Optional fonts sampled with P(font | gpu_class) from the CPT table.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"whitelist": "arial,calibri,marlett,...",
|
||||
"metrics": "arial|0.978,calibri|0.934,marlett|0.855,..."
|
||||
}
|
||||
|
||||
The whitelist is the list of font families to advertise. The metrics
|
||||
string encodes per-family width scale factors that the consumer can
|
||||
use to make each family detectable by width-diff font probes.
|
||||
|
||||
Each entry in font_pool.json carries its own {name, factor} pair so the
|
||||
two pref strings are GUARANTEED coherent — no chance of a fabricated
|
||||
font with factor 1.0 (undetectable) or a metrics entry for a font not
|
||||
in the whitelist (useless).
|
||||
|
||||
Markers & add-new-font: simply add an entry to font_pool.json:core (with
|
||||
a factor at least 4% away from 1.0) — no special-case code needed.
|
||||
"""
|
||||
cpt = _CPT_FONTS_OPT.get(gpu_class)
|
||||
if cpt is None:
|
||||
cpt = _CPT_FONTS_OPT["integrated_modern"]
|
||||
included: list = list(_FONT_CORE) # always present
|
||||
for entry in _FONT_OPTIONAL:
|
||||
name = entry["name"]
|
||||
p = cpt.get(name, 0.7) # default 0.7 if CPT has no row for this font
|
||||
if rng.random() < p:
|
||||
included.append(entry)
|
||||
# Deterministic ordering: sort by name
|
||||
included.sort(key=lambda e: e["name"])
|
||||
whitelist = ",".join(e["name"] for e in included)
|
||||
metrics = ",".join(
|
||||
f'{e["name"]}|{e["factor"]:.3f}' for e in included
|
||||
)
|
||||
return {"whitelist": whitelist, "metrics": metrics}
|
||||
|
||||
|
||||
# Back-compat shim: legacy callers still import derive_font_whitelist.
|
||||
def derive_font_whitelist(gpu_class: str, rng) -> str:
|
||||
return derive_font_prefs(gpu_class, rng)["whitelist"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# PUBLIC API: Forge
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
import random
|
||||
|
||||
|
||||
class Forge:
|
||||
"""Fingerprint forge — single seed → coherent bundle."""
|
||||
|
||||
def __init__(self, seed: int):
|
||||
self.seed = int(seed)
|
||||
self._rng = random.Random(self.seed)
|
||||
|
||||
def sample(self) -> Dict[str, Any]:
|
||||
bundle = _NETWORK.sample(self._rng)
|
||||
gpu = bundle["gpu"]
|
||||
screen = bundle["screen"]
|
||||
audio = bundle["audio"]
|
||||
codec = bundle["codec"]
|
||||
return {
|
||||
# Seed tracking
|
||||
"stealth_seed": self.seed,
|
||||
# Locked identity
|
||||
**_LOCKED,
|
||||
# GPU (coherent pair from 444 pool)
|
||||
"webgl_renderer": gpu["renderer"],
|
||||
"webgl_vendor": gpu["vendor"],
|
||||
"gpu_class": bundle["gpu_class"],
|
||||
# Hidden-variable debug metadata (not a Firefox pref, just for
|
||||
# analysis / test result correlation tracking)
|
||||
"intra_tier": bundle["intra_tier"],
|
||||
"screen_tier": bundle["screen_tier"],
|
||||
# Screen (coherent with GPU class)
|
||||
"screen_w": int(screen["w"]),
|
||||
"screen_h": int(screen["h"]),
|
||||
"screen_avail_w": int(screen.get("aw", screen["w"])),
|
||||
"screen_avail_h": int(screen.get("ah", screen["h"] - 40)),
|
||||
"dpr": float(screen["dpr"]),
|
||||
# Hardware (coherent with GPU class)
|
||||
"hw_concurrency": int(bundle["hw_concurrency"]),
|
||||
# WebGL MSAA (coherent with GPU class)
|
||||
"msaa_samples": int(bundle["msaa_samples"]),
|
||||
# Audio (independent joint)
|
||||
"audio_sample_rate": int(audio["rate"]),
|
||||
"audio_output_latency_ms": int(audio["latency"]),
|
||||
"audio_max_channel_count": int(audio["channels"]),
|
||||
# Codec prefs (joint, coherent with GPU class). All 5 are
|
||||
# JS-visible: av1/webm_encoder via canPlayType/MediaRecorder,
|
||||
# mediasource_* via MediaSource.isTypeSupported, webspeech_synth
|
||||
# via 'speechSynthesis' in window (CreepJS voices probe).
|
||||
"av1_enabled": bool(codec["av1_enabled"]),
|
||||
"webm_encoder_enabled": bool(codec["webm_encoder_enabled"]),
|
||||
"mediasource_webm": bool(codec["mediasource_webm"]),
|
||||
"mediasource_mp4": bool(codec["mediasource_mp4"]),
|
||||
"webspeech_synth": bool(codec["webspeech_synth"]),
|
||||
# Storage quota MB (coherent with GPU class — workstation larger SSDs).
|
||||
"storage_quota_mb": int(bundle["storage_quota_mb"]),
|
||||
# Independent marginals
|
||||
"dark_theme": int(bundle["dark_theme"]),
|
||||
# Bayesian font prefs (coherent pair: whitelist + per-family
|
||||
# width scale metrics, both sampled from the same font_pool.json
|
||||
# and conditioned on gpu_class).
|
||||
**{
|
||||
f"font_{k}": v
|
||||
for k, v in derive_font_prefs(
|
||||
bundle["gpu_class"], self._rng
|
||||
).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sample(seed: int) -> Dict[str, Any]:
|
||||
"""Convenience: `Forge(seed).sample()`."""
|
||||
return Forge(seed).sample()
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
{
|
||||
"_meta": "audio (rate/latency/channels) given gpu_class",
|
||||
"table": {
|
||||
"integrated_old": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 50,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.7
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 50,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.3
|
||||
}
|
||||
],
|
||||
"integrated_modern": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 30,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.6
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 40,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 25,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"low_end": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 40,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 50,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 30,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"mid_range": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 25,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 20,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 20,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 44100,
|
||||
"latency": 30,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"high_end": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 2
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 15,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"workstation": [
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 96000,
|
||||
"latency": 10,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 192000,
|
||||
"latency": 10,
|
||||
"channels": 8
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"rate": 48000,
|
||||
"latency": 15,
|
||||
"channels": 6
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
{
|
||||
"_meta": "codec given gpu_class",
|
||||
"table": {
|
||||
"integrated_old": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": false,
|
||||
"webm_encoder_enabled": false,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 1.0
|
||||
}
|
||||
],
|
||||
"integrated_modern": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": false,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": false
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"low_end": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": false,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": false,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": false
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"mid_range": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": false,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": false
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"high_end": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": false
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"workstation": [
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": true
|
||||
},
|
||||
"prob": 0.7
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"av1_enabled": true,
|
||||
"webm_encoder_enabled": true,
|
||||
"mediasource_webm": true,
|
||||
"mediasource_mp4": true,
|
||||
"webspeech_synth": false
|
||||
},
|
||||
"prob": 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "optional_font presence | gpu_class",
|
||||
"parents": [
|
||||
"gpu_class"
|
||||
],
|
||||
"child": "optional_fonts_subset",
|
||||
"description": "Per-optional-font presence probabilities given gpu_class. Each optional font in font_pool.json sampled INDEPENDENTLY with P(present | gpu_class) given here. Integrated_old: fewer language packs (older/cheaper machines). Workstation: more regional/language packs (international users, enterprise deployments).",
|
||||
"rationale": "Near-invariant by design: most optional fonts have baseline P ~ 0.60-0.85 across classes. Per-session variance is small (~3-6 fonts toggling on/off out of 40 optional). Result: fingerprint always looks like 'typical Windows 11 + Office', with small realistic per-user variance in regional support."
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": {
|
||||
"aparajita": 0.45,
|
||||
"calibri": 0.9,
|
||||
"dengxian": 0.3,
|
||||
"dfkai-sb": 0.25,
|
||||
"dokchampa": 0.2,
|
||||
"estrangelo edessa": 0.55,
|
||||
"euphemia": 0.6,
|
||||
"fangsong": 0.35,
|
||||
"gadugi": 0.88,
|
||||
"gautami": 0.55,
|
||||
"helv": 0.7,
|
||||
"iskoola pota": 0.5,
|
||||
"javanese text": 0.85,
|
||||
"kaiti": 0.3,
|
||||
"kalinga": 0.5,
|
||||
"kartika": 0.5,
|
||||
"khmer ui": 0.35,
|
||||
"kokila": 0.55,
|
||||
"lao ui": 0.35,
|
||||
"latha": 0.65,
|
||||
"leelawadee ui": 0.87,
|
||||
"mangal": 0.65,
|
||||
"meiryo": 0.6,
|
||||
"microsoft uighur": 0.4,
|
||||
"ms pmincho": 0.65,
|
||||
"ms reference sans serif": 0.55,
|
||||
"ms reference specialty": 0.5,
|
||||
"ms ui gothic": 0.82,
|
||||
"myanmar text": 0.84,
|
||||
"nyala": 0.55,
|
||||
"plantagenet cherokee": 0.6,
|
||||
"raavi": 0.55,
|
||||
"segoe fluent icons": 0.5,
|
||||
"segoe ui light": 0.75,
|
||||
"shonar bangla": 0.55,
|
||||
"shruti": 0.55,
|
||||
"simkai": 0.25,
|
||||
"small fonts": 0.75,
|
||||
"traditional arabic": 0.55,
|
||||
"tunga": 0.55,
|
||||
"urdu typesetting": 0.45,
|
||||
"utsaah": 0.55,
|
||||
"vani": 0.55,
|
||||
"vijaya": 0.55,
|
||||
"yu mincho": 0.55
|
||||
},
|
||||
"integrated_modern": {
|
||||
"aparajita": 0.65,
|
||||
"calibri": 0.78,
|
||||
"dengxian": 0.5,
|
||||
"dfkai-sb": 0.4,
|
||||
"dokchampa": 0.4,
|
||||
"estrangelo edessa": 0.7,
|
||||
"euphemia": 0.78,
|
||||
"fangsong": 0.55,
|
||||
"gadugi": 0.96,
|
||||
"gautami": 0.75,
|
||||
"helv": 0.6,
|
||||
"iskoola pota": 0.7,
|
||||
"javanese text": 0.94,
|
||||
"kaiti": 0.45,
|
||||
"kalinga": 0.7,
|
||||
"kartika": 0.7,
|
||||
"khmer ui": 0.55,
|
||||
"kokila": 0.75,
|
||||
"lao ui": 0.55,
|
||||
"latha": 0.82,
|
||||
"leelawadee ui": 0.95,
|
||||
"mangal": 0.82,
|
||||
"meiryo": 0.8,
|
||||
"microsoft uighur": 0.55,
|
||||
"ms pmincho": 0.85,
|
||||
"ms reference sans serif": 0.72,
|
||||
"ms reference specialty": 0.68,
|
||||
"ms ui gothic": 0.75,
|
||||
"myanmar text": 0.94,
|
||||
"nyala": 0.72,
|
||||
"plantagenet cherokee": 0.78,
|
||||
"raavi": 0.72,
|
||||
"segoe fluent icons": 0.92,
|
||||
"segoe ui light": 0.72,
|
||||
"shonar bangla": 0.72,
|
||||
"shruti": 0.72,
|
||||
"simkai": 0.4,
|
||||
"small fonts": 0.65,
|
||||
"traditional arabic": 0.72,
|
||||
"tunga": 0.72,
|
||||
"urdu typesetting": 0.65,
|
||||
"utsaah": 0.72,
|
||||
"vani": 0.72,
|
||||
"vijaya": 0.72,
|
||||
"yu mincho": 0.75
|
||||
},
|
||||
"low_end": {
|
||||
"aparajita": 0.55,
|
||||
"calibri": 0.94,
|
||||
"dengxian": 0.4,
|
||||
"dfkai-sb": 0.3,
|
||||
"dokchampa": 0.3,
|
||||
"estrangelo edessa": 0.62,
|
||||
"euphemia": 0.68,
|
||||
"fangsong": 0.45,
|
||||
"gadugi": 0.93,
|
||||
"gautami": 0.65,
|
||||
"helv": 0.78,
|
||||
"iskoola pota": 0.6,
|
||||
"javanese text": 0.91,
|
||||
"kaiti": 0.35,
|
||||
"kalinga": 0.6,
|
||||
"kartika": 0.6,
|
||||
"khmer ui": 0.45,
|
||||
"kokila": 0.65,
|
||||
"lao ui": 0.45,
|
||||
"latha": 0.72,
|
||||
"leelawadee ui": 0.92,
|
||||
"mangal": 0.72,
|
||||
"meiryo": 0.7,
|
||||
"microsoft uighur": 0.48,
|
||||
"ms pmincho": 0.75,
|
||||
"ms reference sans serif": 0.62,
|
||||
"ms reference specialty": 0.58,
|
||||
"ms ui gothic": 0.9,
|
||||
"myanmar text": 0.9,
|
||||
"nyala": 0.62,
|
||||
"plantagenet cherokee": 0.68,
|
||||
"raavi": 0.62,
|
||||
"segoe fluent icons": 0.8,
|
||||
"segoe ui light": 0.88,
|
||||
"shonar bangla": 0.62,
|
||||
"shruti": 0.62,
|
||||
"simkai": 0.3,
|
||||
"small fonts": 0.83,
|
||||
"traditional arabic": 0.62,
|
||||
"tunga": 0.62,
|
||||
"urdu typesetting": 0.55,
|
||||
"utsaah": 0.62,
|
||||
"vani": 0.62,
|
||||
"vijaya": 0.62,
|
||||
"yu mincho": 0.65
|
||||
},
|
||||
"mid_range": {
|
||||
"aparajita": 0.72,
|
||||
"calibri": 0.98,
|
||||
"dengxian": 0.6,
|
||||
"dfkai-sb": 0.5,
|
||||
"dokchampa": 0.5,
|
||||
"estrangelo edessa": 0.78,
|
||||
"euphemia": 0.82,
|
||||
"fangsong": 0.65,
|
||||
"gadugi": 0.97,
|
||||
"gautami": 0.8,
|
||||
"helv": 0.85,
|
||||
"iskoola pota": 0.78,
|
||||
"javanese text": 0.96,
|
||||
"kaiti": 0.55,
|
||||
"kalinga": 0.78,
|
||||
"kartika": 0.78,
|
||||
"khmer ui": 0.65,
|
||||
"kokila": 0.8,
|
||||
"lao ui": 0.65,
|
||||
"latha": 0.85,
|
||||
"leelawadee ui": 0.97,
|
||||
"mangal": 0.85,
|
||||
"meiryo": 0.85,
|
||||
"microsoft uighur": 0.65,
|
||||
"ms pmincho": 0.88,
|
||||
"ms reference sans serif": 0.78,
|
||||
"ms reference specialty": 0.75,
|
||||
"ms ui gothic": 0.96,
|
||||
"myanmar text": 0.96,
|
||||
"nyala": 0.78,
|
||||
"plantagenet cherokee": 0.82,
|
||||
"raavi": 0.78,
|
||||
"segoe fluent icons": 0.94,
|
||||
"segoe ui light": 0.94,
|
||||
"shonar bangla": 0.78,
|
||||
"shruti": 0.78,
|
||||
"simkai": 0.5,
|
||||
"small fonts": 0.9,
|
||||
"traditional arabic": 0.78,
|
||||
"tunga": 0.78,
|
||||
"urdu typesetting": 0.72,
|
||||
"utsaah": 0.78,
|
||||
"vani": 0.78,
|
||||
"vijaya": 0.78,
|
||||
"yu mincho": 0.8
|
||||
},
|
||||
"high_end": {
|
||||
"aparajita": 0.8,
|
||||
"calibri": 0.99,
|
||||
"dengxian": 0.7,
|
||||
"dfkai-sb": 0.6,
|
||||
"dokchampa": 0.6,
|
||||
"estrangelo edessa": 0.85,
|
||||
"euphemia": 0.88,
|
||||
"fangsong": 0.72,
|
||||
"gadugi": 0.98,
|
||||
"gautami": 0.85,
|
||||
"helv": 0.88,
|
||||
"iskoola pota": 0.82,
|
||||
"javanese text": 0.97,
|
||||
"kaiti": 0.65,
|
||||
"kalinga": 0.82,
|
||||
"kartika": 0.82,
|
||||
"khmer ui": 0.72,
|
||||
"kokila": 0.85,
|
||||
"lao ui": 0.72,
|
||||
"latha": 0.9,
|
||||
"leelawadee ui": 0.98,
|
||||
"mangal": 0.9,
|
||||
"meiryo": 0.9,
|
||||
"microsoft uighur": 0.72,
|
||||
"ms pmincho": 0.92,
|
||||
"ms reference sans serif": 0.85,
|
||||
"ms reference specialty": 0.82,
|
||||
"ms ui gothic": 0.98,
|
||||
"myanmar text": 0.97,
|
||||
"nyala": 0.85,
|
||||
"plantagenet cherokee": 0.88,
|
||||
"raavi": 0.85,
|
||||
"segoe fluent icons": 0.97,
|
||||
"segoe ui light": 0.96,
|
||||
"shonar bangla": 0.85,
|
||||
"shruti": 0.85,
|
||||
"simkai": 0.6,
|
||||
"small fonts": 0.92,
|
||||
"traditional arabic": 0.85,
|
||||
"tunga": 0.85,
|
||||
"urdu typesetting": 0.8,
|
||||
"utsaah": 0.85,
|
||||
"vani": 0.85,
|
||||
"vijaya": 0.85,
|
||||
"yu mincho": 0.88
|
||||
},
|
||||
"workstation": {
|
||||
"aparajita": 0.88,
|
||||
"calibri": 0.99,
|
||||
"dengxian": 0.8,
|
||||
"dfkai-sb": 0.75,
|
||||
"dokchampa": 0.72,
|
||||
"estrangelo edessa": 0.9,
|
||||
"euphemia": 0.92,
|
||||
"fangsong": 0.82,
|
||||
"gadugi": 0.99,
|
||||
"gautami": 0.92,
|
||||
"helv": 0.9,
|
||||
"iskoola pota": 0.9,
|
||||
"javanese text": 0.98,
|
||||
"kaiti": 0.78,
|
||||
"kalinga": 0.9,
|
||||
"kartika": 0.9,
|
||||
"khmer ui": 0.8,
|
||||
"kokila": 0.92,
|
||||
"lao ui": 0.8,
|
||||
"latha": 0.95,
|
||||
"leelawadee ui": 0.99,
|
||||
"mangal": 0.95,
|
||||
"meiryo": 0.95,
|
||||
"microsoft uighur": 0.82,
|
||||
"ms pmincho": 0.95,
|
||||
"ms reference sans serif": 0.92,
|
||||
"ms reference specialty": 0.9,
|
||||
"ms ui gothic": 0.98,
|
||||
"myanmar text": 0.98,
|
||||
"nyala": 0.92,
|
||||
"plantagenet cherokee": 0.92,
|
||||
"raavi": 0.92,
|
||||
"segoe fluent icons": 0.98,
|
||||
"segoe ui light": 0.97,
|
||||
"shonar bangla": 0.92,
|
||||
"shruti": 0.92,
|
||||
"simkai": 0.72,
|
||||
"small fonts": 0.94,
|
||||
"traditional arabic": 0.92,
|
||||
"tunga": 0.92,
|
||||
"urdu typesetting": 0.88,
|
||||
"utsaah": 0.92,
|
||||
"vani": 0.92,
|
||||
"vijaya": 0.92,
|
||||
"yu mincho": 0.92
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "hw_concurrency | gpu_class",
|
||||
"parents": ["gpu_class"],
|
||||
"child": "hw_concurrency",
|
||||
"source": "Curated from realistic CPU/GPU pairings (Steam HW Survey + DIY build norms + laptop SKU patterns)"
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": [
|
||||
{"value": 2, "prob": 0.20},
|
||||
{"value": 4, "prob": 0.60},
|
||||
{"value": 8, "prob": 0.20}
|
||||
],
|
||||
"integrated_modern": [
|
||||
{"value": 4, "prob": 0.20},
|
||||
{"value": 6, "prob": 0.15},
|
||||
{"value": 8, "prob": 0.35},
|
||||
{"value": 12, "prob": 0.20},
|
||||
{"value": 16, "prob": 0.10}
|
||||
],
|
||||
"low_end": [
|
||||
{"value": 4, "prob": 0.25},
|
||||
{"value": 6, "prob": 0.25},
|
||||
{"value": 8, "prob": 0.35},
|
||||
{"value": 12, "prob": 0.15}
|
||||
],
|
||||
"mid_range": [
|
||||
{"value": 6, "prob": 0.15},
|
||||
{"value": 8, "prob": 0.30},
|
||||
{"value": 12, "prob": 0.30},
|
||||
{"value": 16, "prob": 0.20},
|
||||
{"value": 24, "prob": 0.05}
|
||||
],
|
||||
"high_end": [
|
||||
{"value": 8, "prob": 0.10},
|
||||
{"value": 12, "prob": 0.25},
|
||||
{"value": 16, "prob": 0.35},
|
||||
{"value": 20, "prob": 0.05},
|
||||
{"value": 24, "prob": 0.20},
|
||||
{"value": 32, "prob": 0.05}
|
||||
],
|
||||
"workstation": [
|
||||
{"value": 8, "prob": 0.15},
|
||||
{"value": 12, "prob": 0.20},
|
||||
{"value": 16, "prob": 0.30},
|
||||
{"value": 24, "prob": 0.20},
|
||||
{"value": 32, "prob": 0.15}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
{
|
||||
"_meta": "hardware_concurrency given (gpu_class, intra_tier)",
|
||||
"table": {
|
||||
"[\"integrated_old\", \"budget\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.65
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"standard\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"premium\"]": [
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.65
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"budget\"]": [
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"standard\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 10,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"premium\"]": [
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 10,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 14,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"budget\"]": [
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"standard\"]": [
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.18
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.07
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"premium\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.22
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.08
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"budget\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"standard\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.18
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.02
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"premium\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"budget\"]": [
|
||||
{
|
||||
"value": 6,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"standard\"]": [
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.18
|
||||
},
|
||||
{
|
||||
"value": 14,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.27
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"premium\"]": [
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 14,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": 32,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"budget\"]": [
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"standard\"]": [
|
||||
{
|
||||
"value": 12,
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": 16,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 32,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"premium\"]": [
|
||||
{
|
||||
"value": 24,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 32,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 48,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 64,
|
||||
"prob": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "intra_tier | gpu_class",
|
||||
"parents": ["gpu_class"],
|
||||
"child": "intra_tier",
|
||||
"value_type": "str (budget|standard|premium)",
|
||||
"rationale": "Hidden variable catching 'intra-class premium-ness'. Within a gpu_class, users vary in how premium their OTHER components are (RAM, SSD, cores). A mid_range GPU paired with 32 cores + 2TB SSD is a 'premium' mid_range user. Without this var, hwc/screen/storage would be statistically independent given gpu_class — unrealistic (real users pick coherent bundles). Tier distribution biased: integrated_old mostly budget/standard, workstation mostly premium."
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": [{"value": "budget", "prob": 0.65}, {"value": "standard", "prob": 0.30}, {"value": "premium", "prob": 0.05}],
|
||||
"integrated_modern": [{"value": "budget", "prob": 0.30}, {"value": "standard", "prob": 0.55}, {"value": "premium", "prob": 0.15}],
|
||||
"low_end": [{"value": "budget", "prob": 0.50}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.10}],
|
||||
"mid_range": [{"value": "budget", "prob": 0.25}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.25}],
|
||||
"high_end": [{"value": "budget", "prob": 0.15}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.35}],
|
||||
"workstation": [{"value": "budget", "prob": 0.10}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.50}]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "webgl.msaa-samples | gpu_class",
|
||||
"parents": ["gpu_class"],
|
||||
"child": "msaa_samples",
|
||||
"source": "High-end GPUs more likely to have MSAA enabled in games; integrated usually off"
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": [{"value": 0, "prob": 0.85}, {"value": 2, "prob": 0.15}],
|
||||
"integrated_modern": [{"value": 0, "prob": 0.70}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.05}],
|
||||
"low_end": [{"value": 0, "prob": 0.55}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.10}],
|
||||
"mid_range": [{"value": 0, "prob": 0.35}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.30}],
|
||||
"high_end": [{"value": 0, "prob": 0.20}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.55}],
|
||||
"workstation": [{"value": 0, "prob": 0.50}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.25}]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
{
|
||||
"_meta": "msaa_samples given (gpu_class, screen_tier)",
|
||||
"table": {
|
||||
"[\"integrated_old\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.75
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 1.0
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 1.0
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.6
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.8
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.7
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.3
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.65
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.35
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.25
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"1080p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.35
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"1440p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.3
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"2160p\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"ultrawide\"]": [
|
||||
{
|
||||
"value": 2,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 8,
|
||||
"prob": 0.25
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "screen | gpu_class",
|
||||
"parents": ["gpu_class"],
|
||||
"child": "screen",
|
||||
"value_shape": {"w": "int css px", "h": "int css px", "dpr": "float", "aw": "availWidth", "ah": "availHeight"},
|
||||
"source": "Curated Windows desktop/laptop resolutions. Restricted to width >= 1920 to avoid FP Pro's server-side screen_resolution normalization (FP Pro sorts non-standard landscape resolutions like 1536x864 into [smaller, larger] order in raw_device_attributes.screen_resolution, appearing flipped vs our spoof). All retained resolutions are common and NOT normalized by FP Pro."
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.90},
|
||||
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
|
||||
],
|
||||
"integrated_modern": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.60},
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.18},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.10},
|
||||
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.08},
|
||||
{"value": {"w": 2560, "h": 1600, "dpr": 1.25, "aw": 2560, "ah": 1560}, "prob": 0.04}
|
||||
],
|
||||
"low_end": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.70},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.20},
|
||||
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
|
||||
],
|
||||
"mid_range": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.50},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.08},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.04},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.04},
|
||||
{"value": {"w": 2560, "h": 1080, "dpr": 1.0, "aw": 2560, "ah": 1040}, "prob": 0.04}
|
||||
],
|
||||
"high_end": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.30},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.15},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.10},
|
||||
{"value": {"w": 3440, "h": 1440, "dpr": 1.0, "aw": 3440, "ah": 1400}, "prob": 0.08},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
|
||||
{"value": {"w": 5120, "h": 1440, "dpr": 1.0, "aw": 5120, "ah": 1400}, "prob": 0.04},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 2.0, "aw": 3840, "ah": 2120}, "prob": 0.03}
|
||||
],
|
||||
"workstation": [
|
||||
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
|
||||
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.25},
|
||||
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.15},
|
||||
{"value": {"w": 5120, "h": 2880, "dpr": 1.0, "aw": 5120, "ah": 2840}, "prob": 0.05},
|
||||
{"value": {"w": 3840, "h": 2400, "dpr": 1.5, "aw": 3840, "ah": 2360}, "prob": 0.05}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,761 @@
|
|||
{
|
||||
"_meta": "screen given (gpu_class, intra_tier)",
|
||||
"table": {
|
||||
"[\"integrated_old\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1366,
|
||||
"h": 768,
|
||||
"aw": 1366,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.78
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"aw": 1280,
|
||||
"ah": 760,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1024,
|
||||
"h": 768,
|
||||
"aw": 1024,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.07
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1366,
|
||||
"h": 768,
|
||||
"aw": 1366,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1600,
|
||||
"h": 900,
|
||||
"aw": 1600,
|
||||
"ah": 860,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"aw": 1280,
|
||||
"ah": 760,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1600,
|
||||
"h": 900,
|
||||
"aw": 1600,
|
||||
"ah": 860,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1366,
|
||||
"h": 768,
|
||||
"aw": 1366,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1366,
|
||||
"h": 768,
|
||||
"aw": 1366,
|
||||
"ah": 728,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.65
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1600,
|
||||
"h": 900,
|
||||
"aw": 1600,
|
||||
"ah": 860,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.7
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.12
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1600,
|
||||
"aw": 2560,
|
||||
"ah": 1560,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.08
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1600,
|
||||
"aw": 2560,
|
||||
"ah": 1560,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.85
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.6
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.75
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1080,
|
||||
"aw": 2560,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1080,
|
||||
"aw": 1920,
|
||||
"ah": 1040,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 5120,
|
||||
"h": 1440,
|
||||
"aw": 5120,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 5120,
|
||||
"h": 1440,
|
||||
"aw": 5120,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 7680,
|
||||
"h": 2160,
|
||||
"aw": 7680,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"budget\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 1920,
|
||||
"h": 1200,
|
||||
"aw": 1920,
|
||||
"ah": 1160,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1600,
|
||||
"aw": 2560,
|
||||
"ah": 1560,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"standard\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1440,
|
||||
"aw": 2560,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1600,
|
||||
"aw": 2560,
|
||||
"ah": 1560,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3440,
|
||||
"h": 1440,
|
||||
"aw": 3440,
|
||||
"ah": 1400,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"premium\"]": [
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2160,
|
||||
"aw": 3840,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 2560,
|
||||
"h": 1600,
|
||||
"aw": 2560,
|
||||
"ah": 1560,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 5120,
|
||||
"h": 2160,
|
||||
"aw": 5120,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 3840,
|
||||
"h": 2400,
|
||||
"aw": 3840,
|
||||
"ah": 2360,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.1
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"w": 7680,
|
||||
"h": 2160,
|
||||
"aw": 7680,
|
||||
"ah": 2120,
|
||||
"dpr": 1.0
|
||||
},
|
||||
"prob": 0.05
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "storage_quota_mb | gpu_class",
|
||||
"parents": ["gpu_class"],
|
||||
"child": "storage_quota_mb",
|
||||
"value_shape": "int megabytes (reported as navigator.storage.estimate().quota / (1024*1024))",
|
||||
"description": "Storage quota override returned by navigator.storage.estimate(). Real Firefox computes quota from disk space, which is a fingerprinting signal (stable per host). We replace it with a realistic per-session value conditioned on gpu_class — modern/high-end users tend to have larger SSDs, integrated_old older/smaller disks.",
|
||||
"source": "Typical Windows 11 disk sizes 2024-2026 per segment: budget laptops 256-512GB SSD, mainstream 512GB-1TB SSD, gaming/workstation 1-4TB SSD. Firefox default storage quota is ~50% of available disk (capped). We simulate that computation per-tier.",
|
||||
"rationale": "Values are pre-quota-limit MB as Firefox would return to navigator.storage.estimate().quota. Probabilities cover realistic ranges centered on the segment median. A single session returns one value; fingerprinters that hash the exact quota get per-session variance, matching cross-user diversity."
|
||||
},
|
||||
"table": {
|
||||
"integrated_old": [
|
||||
{"value": 61440, "prob": 0.30},
|
||||
{"value": 102400, "prob": 0.25},
|
||||
{"value": 122880, "prob": 0.20},
|
||||
{"value": 204800, "prob": 0.15},
|
||||
{"value": 40960, "prob": 0.10}
|
||||
],
|
||||
"integrated_modern": [
|
||||
{"value": 204800, "prob": 0.30},
|
||||
{"value": 307200, "prob": 0.25},
|
||||
{"value": 122880, "prob": 0.15},
|
||||
{"value": 409600, "prob": 0.15},
|
||||
{"value": 102400, "prob": 0.10},
|
||||
{"value": 512000, "prob": 0.05}
|
||||
],
|
||||
"low_end": [
|
||||
{"value": 122880, "prob": 0.30},
|
||||
{"value": 204800, "prob": 0.25},
|
||||
{"value": 102400, "prob": 0.20},
|
||||
{"value": 61440, "prob": 0.15},
|
||||
{"value": 307200, "prob": 0.10}
|
||||
],
|
||||
"mid_range": [
|
||||
{"value": 307200, "prob": 0.30},
|
||||
{"value": 409600, "prob": 0.25},
|
||||
{"value": 204800, "prob": 0.20},
|
||||
{"value": 512000, "prob": 0.15},
|
||||
{"value": 716800, "prob": 0.05},
|
||||
{"value": 122880, "prob": 0.05}
|
||||
],
|
||||
"high_end": [
|
||||
{"value": 512000, "prob": 0.25},
|
||||
{"value": 716800, "prob": 0.25},
|
||||
{"value": 409600, "prob": 0.20},
|
||||
{"value": 1024000, "prob": 0.15},
|
||||
{"value": 307200, "prob": 0.10},
|
||||
{"value": 1536000, "prob": 0.05}
|
||||
],
|
||||
"workstation": [
|
||||
{"value": 1024000, "prob": 0.25},
|
||||
{"value": 1536000, "prob": 0.20},
|
||||
{"value": 716800, "prob": 0.20},
|
||||
{"value": 2048000, "prob": 0.15},
|
||||
{"value": 512000, "prob": 0.10},
|
||||
{"value": 3072000, "prob": 0.05},
|
||||
{"value": 409600, "prob": 0.05}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
{
|
||||
"_meta": "storage_quota_mb given (gpu_class, intra_tier)",
|
||||
"table": {
|
||||
"[\"integrated_old\", \"budget\"]": [
|
||||
{
|
||||
"value": 32000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 64000,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"standard\"]": [
|
||||
{
|
||||
"value": 64000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.1
|
||||
}
|
||||
],
|
||||
"[\"integrated_old\", \"premium\"]": [
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"budget\"]": [
|
||||
{
|
||||
"value": 64000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"standard\"]": [
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"integrated_modern\", \"premium\"]": [
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"budget\"]": [
|
||||
{
|
||||
"value": 128000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"standard\"]": [
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"low_end\", \"premium\"]": [
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.25
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"budget\"]": [
|
||||
{
|
||||
"value": 256000,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"standard\"]": [
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.55
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.2
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"mid_range\", \"premium\"]": [
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"budget\"]": [
|
||||
{
|
||||
"value": 500000,
|
||||
"prob": 0.15
|
||||
},
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.05
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"standard\"]": [
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"high_end\", \"premium\"]": [
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.4
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 8000000,
|
||||
"prob": 0.15
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"budget\"]": [
|
||||
{
|
||||
"value": 1000000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"standard\"]": [
|
||||
{
|
||||
"value": 2000000,
|
||||
"prob": 0.3
|
||||
},
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.5
|
||||
},
|
||||
{
|
||||
"value": 8000000,
|
||||
"prob": 0.2
|
||||
}
|
||||
],
|
||||
"[\"workstation\", \"premium\"]": [
|
||||
{
|
||||
"value": 4000000,
|
||||
"prob": 0.35
|
||||
},
|
||||
{
|
||||
"value": 8000000,
|
||||
"prob": 0.45
|
||||
},
|
||||
{
|
||||
"value": 16000000,
|
||||
"prob": 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
650
src/invisible_playwright/_fpforge/data/ff_win_distributions.json
Normal file
650
src/invisible_playwright/_fpforge/data/ff_win_distributions.json
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
{
|
||||
"_source": "browserforge fingerprint-network.zip, FF/Windows slice, 3 userAgent leaves merged",
|
||||
"_ff_win_uas": [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||
],
|
||||
"_note": "Per-node weighted distributions. Marginal over the 3 Firefox/Windows UAs present in browserforge data.",
|
||||
"distributions": {
|
||||
"appCodeName": [
|
||||
[
|
||||
"Mozilla",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"appVersion": [
|
||||
[
|
||||
"5.0 (Windows)",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"doNotTrack": [
|
||||
[
|
||||
"1",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"extraProperties": [
|
||||
[
|
||||
{
|
||||
"vendorFlavors": [],
|
||||
"globalPrivacyControl": true,
|
||||
"pdfViewerEnabled": true,
|
||||
"installedApps": []
|
||||
},
|
||||
0.6111111111111112
|
||||
],
|
||||
[
|
||||
{
|
||||
"vendorFlavors": [],
|
||||
"globalPrivacyControl": null,
|
||||
"pdfViewerEnabled": null,
|
||||
"installedApps": []
|
||||
},
|
||||
0.3888888888888889
|
||||
]
|
||||
],
|
||||
"maxTouchPoints": [
|
||||
[
|
||||
0,
|
||||
0.5777777777777778
|
||||
],
|
||||
[
|
||||
1,
|
||||
0.3666666666666667
|
||||
],
|
||||
[
|
||||
10,
|
||||
0.05555555555555555
|
||||
]
|
||||
],
|
||||
"oscpu": [
|
||||
[
|
||||
"Windows NT 10.0; Win64; x64",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"webdriver": [
|
||||
[
|
||||
false,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"productSub": [
|
||||
[
|
||||
"20100101",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"hardwareConcurrency": [
|
||||
[
|
||||
12,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
16,
|
||||
0.2777777777777778
|
||||
],
|
||||
[
|
||||
8,
|
||||
0.13333333333333333
|
||||
],
|
||||
[
|
||||
24,
|
||||
0.13333333333333333
|
||||
],
|
||||
[
|
||||
32,
|
||||
0.06666666666666667
|
||||
],
|
||||
[
|
||||
6,
|
||||
0.05555555555555555
|
||||
]
|
||||
],
|
||||
"platform": [
|
||||
[
|
||||
"Win32",
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"screen": [
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 772,
|
||||
"hasHDR": false,
|
||||
"width": 1536,
|
||||
"height": 960,
|
||||
"availWidth": 1536,
|
||||
"availHeight": 960,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 21,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 751,
|
||||
"outerHeight": 886,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1.25
|
||||
},
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 2176,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 2169,
|
||||
"hasHDR": false,
|
||||
"width": 2176,
|
||||
"height": 1224,
|
||||
"availWidth": 2176,
|
||||
"availHeight": 1176,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 19,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 2190,
|
||||
"outerHeight": 1190,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1.7647058823529411
|
||||
},
|
||||
0.13333333333333333
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 318,
|
||||
"hasHDR": false,
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"availWidth": 2560,
|
||||
"availHeight": 1440,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 2121,
|
||||
"outerHeight": 1191,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.1111111111111111
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 652,
|
||||
"hasHDR": false,
|
||||
"width": 2752,
|
||||
"height": 1152,
|
||||
"availWidth": 2752,
|
||||
"availHeight": 1104,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 19,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1806,
|
||||
"outerHeight": 1104,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1.25
|
||||
},
|
||||
0.06666666666666667
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 1912,
|
||||
"hasHDR": false,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1032,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1936,
|
||||
"outerHeight": 1048,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.06666666666666667
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 29,
|
||||
"availLeft": 21,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 15,
|
||||
"hasHDR": false,
|
||||
"width": 1376,
|
||||
"height": 774,
|
||||
"availWidth": 1355,
|
||||
"availHeight": 745,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 21,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1352,
|
||||
"outerHeight": 749,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.05555555555555555
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 58,
|
||||
"hasHDR": false,
|
||||
"width": 1707,
|
||||
"height": 960,
|
||||
"availWidth": 1707,
|
||||
"availHeight": 928,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 19,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1564,
|
||||
"outerHeight": 793,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.05555555555555555
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 34,
|
||||
"hasHDR": false,
|
||||
"width": 5120,
|
||||
"height": 1440,
|
||||
"availWidth": 5120,
|
||||
"availHeight": 1392,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 2462,
|
||||
"outerHeight": 1399,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.05555555555555555
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 1920,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 1912,
|
||||
"hasHDR": false,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"availWidth": 1920,
|
||||
"availHeight": 1032,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1936,
|
||||
"outerHeight": 1048,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.05555555555555555
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 95,
|
||||
"hasHDR": false,
|
||||
"width": 1366,
|
||||
"height": 768,
|
||||
"availWidth": 1366,
|
||||
"availHeight": 768,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1294,
|
||||
"outerHeight": 1280,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.03333333333333333
|
||||
],
|
||||
[
|
||||
{
|
||||
"availTop": 0,
|
||||
"availLeft": 0,
|
||||
"pageXOffset": 0,
|
||||
"pageYOffset": 0,
|
||||
"screenX": 1273,
|
||||
"hasHDR": false,
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"availWidth": 2560,
|
||||
"availHeight": 1400,
|
||||
"clientWidth": 0,
|
||||
"clientHeight": 18,
|
||||
"innerWidth": 0,
|
||||
"innerHeight": 0,
|
||||
"outerWidth": 1294,
|
||||
"outerHeight": 1407,
|
||||
"colorDepth": 24,
|
||||
"pixelDepth": 24,
|
||||
"devicePixelRatio": 1
|
||||
},
|
||||
0.03333333333333333
|
||||
]
|
||||
],
|
||||
"pluginsData": [
|
||||
[
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "PDF Viewer",
|
||||
"description": "Portable Document Format",
|
||||
"filename": "internal-pdf-viewer",
|
||||
"mimeTypes": [
|
||||
{
|
||||
"type": "application/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
},
|
||||
{
|
||||
"type": "text/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Chrome PDF Viewer",
|
||||
"description": "Portable Document Format",
|
||||
"filename": "internal-pdf-viewer",
|
||||
"mimeTypes": [
|
||||
{
|
||||
"type": "application/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
},
|
||||
{
|
||||
"type": "text/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Chromium PDF Viewer",
|
||||
"description": "Portable Document Format",
|
||||
"filename": "internal-pdf-viewer",
|
||||
"mimeTypes": [
|
||||
{
|
||||
"type": "application/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
},
|
||||
{
|
||||
"type": "text/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Edge PDF Viewer",
|
||||
"description": "Portable Document Format",
|
||||
"filename": "internal-pdf-viewer",
|
||||
"mimeTypes": [
|
||||
{
|
||||
"type": "application/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
},
|
||||
{
|
||||
"type": "text/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "WebKit built-in PDF",
|
||||
"description": "Portable Document Format",
|
||||
"filename": "internal-pdf-viewer",
|
||||
"mimeTypes": [
|
||||
{
|
||||
"type": "application/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
},
|
||||
{
|
||||
"type": "text/pdf",
|
||||
"suffixes": "pdf",
|
||||
"description": "Portable Document Format",
|
||||
"enabledPlugin": "PDF Viewer"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mimeTypes": [
|
||||
"Portable Document Format~~application/pdf~~pdf",
|
||||
"Portable Document Format~~text/pdf~~pdf"
|
||||
]
|
||||
},
|
||||
0.6111111111111112
|
||||
],
|
||||
[
|
||||
{
|
||||
"plugins": [],
|
||||
"mimeTypes": []
|
||||
},
|
||||
0.3888888888888889
|
||||
]
|
||||
],
|
||||
"audioCodecs": [
|
||||
[
|
||||
{
|
||||
"ogg": "probably",
|
||||
"mp3": "maybe",
|
||||
"wav": "probably",
|
||||
"m4a": "maybe",
|
||||
"aac": "maybe"
|
||||
},
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"videoCodecs": [
|
||||
[
|
||||
{
|
||||
"ogg": "",
|
||||
"h264": "probably",
|
||||
"webm": "probably"
|
||||
},
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"videoCard": [
|
||||
[
|
||||
{
|
||||
"renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
|
||||
"vendor": "Google Inc. (NVIDIA)"
|
||||
},
|
||||
0.8444444444444444
|
||||
],
|
||||
[
|
||||
{
|
||||
"renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0), or similar",
|
||||
"vendor": "Google Inc. (AMD)"
|
||||
},
|
||||
0.08888888888888889
|
||||
],
|
||||
[
|
||||
{
|
||||
"renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar",
|
||||
"vendor": "Google Inc. (Intel)"
|
||||
},
|
||||
0.06666666666666667
|
||||
]
|
||||
],
|
||||
"multimediaDevices": [
|
||||
[
|
||||
{
|
||||
"speakers": [],
|
||||
"micros": [],
|
||||
"webcams": []
|
||||
},
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"fonts": [
|
||||
[
|
||||
[],
|
||||
0.3888888888888889
|
||||
],
|
||||
[
|
||||
[
|
||||
"Calibri",
|
||||
"HELV",
|
||||
"MS UI Gothic",
|
||||
"Marlett",
|
||||
"Segoe UI Light",
|
||||
"Small Fonts"
|
||||
],
|
||||
0.3222222222222222
|
||||
],
|
||||
[
|
||||
[
|
||||
"Agency FB",
|
||||
"Calibri",
|
||||
"Century",
|
||||
"Century Gothic",
|
||||
"Franklin Gothic",
|
||||
"HELV",
|
||||
"Haettenschweiler",
|
||||
"Leelawadee",
|
||||
"Lucida Bright",
|
||||
"Lucida Sans",
|
||||
"MS Outlook",
|
||||
"MS Reference Specialty",
|
||||
"MS UI Gothic",
|
||||
"MT Extra",
|
||||
"Marlett",
|
||||
"Microsoft Uighur",
|
||||
"Monotype Corsiva",
|
||||
"Pristina",
|
||||
"Segoe UI Light",
|
||||
"Small Fonts"
|
||||
],
|
||||
0.13333333333333333
|
||||
],
|
||||
[
|
||||
[
|
||||
"AvantGarde Bk BT",
|
||||
"Calibri",
|
||||
"Clarendon",
|
||||
"Franklin Gothic",
|
||||
"Futura Bk BT",
|
||||
"Futura Md BT",
|
||||
"GOTHAM",
|
||||
"HELV",
|
||||
"Humanst521 BT",
|
||||
"MS UI Gothic",
|
||||
"MYRIAD PRO",
|
||||
"Marlett",
|
||||
"Minion Pro",
|
||||
"Segoe UI Light",
|
||||
"Small Fonts",
|
||||
"TRAJAN PRO"
|
||||
],
|
||||
0.06666666666666667
|
||||
],
|
||||
[
|
||||
[
|
||||
"Calibri",
|
||||
"MS UI Gothic",
|
||||
"Marlett",
|
||||
"Segoe UI Light"
|
||||
],
|
||||
0.05555555555555555
|
||||
],
|
||||
[
|
||||
[
|
||||
"Agency FB",
|
||||
"Calibri",
|
||||
"Century",
|
||||
"Century Gothic",
|
||||
"Franklin Gothic",
|
||||
"Futura Bk BT",
|
||||
"Futura Md BT",
|
||||
"GOTHAM",
|
||||
"HELV",
|
||||
"Haettenschweiler",
|
||||
"Humanst521 BT",
|
||||
"Lucida Bright",
|
||||
"Lucida Sans",
|
||||
"MS Outlook",
|
||||
"MS Reference Specialty",
|
||||
"MS UI Gothic",
|
||||
"MT Extra",
|
||||
"MYRIAD PRO",
|
||||
"Marlett",
|
||||
"Minion Pro",
|
||||
"Monotype Corsiva",
|
||||
"Pristina",
|
||||
"Segoe UI Light",
|
||||
"Small Fonts"
|
||||
],
|
||||
0.03333333333333333
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
670
src/invisible_playwright/_fpforge/data/font_pool.json
Normal file
670
src/invisible_playwright/_fpforge/data/font_pool.json
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
{
|
||||
"core": [
|
||||
{
|
||||
"name": "arial",
|
||||
"factor": 0.978
|
||||
},
|
||||
{
|
||||
"name": "arial black",
|
||||
"factor": 1.168
|
||||
},
|
||||
{
|
||||
"name": "arial narrow",
|
||||
"factor": 0.854
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift",
|
||||
"factor": 0.951
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift condensed",
|
||||
"factor": 1.179
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift light",
|
||||
"factor": 0.908
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift light condensed",
|
||||
"factor": 1.053
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift light semicondensed",
|
||||
"factor": 1.063
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semibold",
|
||||
"factor": 0.926
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semibold condensed",
|
||||
"factor": 1.131
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semibold semicondensed",
|
||||
"factor": 1.043
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semicondensed",
|
||||
"factor": 1.098
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semilight",
|
||||
"factor": 0.873
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semilight condensed",
|
||||
"factor": 0.905
|
||||
},
|
||||
{
|
||||
"name": "bahnschrift semilight semicondensed",
|
||||
"factor": 1.135
|
||||
},
|
||||
{
|
||||
"name": "calibri light",
|
||||
"factor": 0.901
|
||||
},
|
||||
{
|
||||
"name": "cambria",
|
||||
"factor": 1.063
|
||||
},
|
||||
{
|
||||
"name": "cambria math",
|
||||
"factor": 1.041
|
||||
},
|
||||
{
|
||||
"name": "candara",
|
||||
"factor": 0.927
|
||||
},
|
||||
{
|
||||
"name": "candara light",
|
||||
"factor": 0.892
|
||||
},
|
||||
{
|
||||
"name": "cascadia code",
|
||||
"factor": 1.139
|
||||
},
|
||||
{
|
||||
"name": "cascadia mono",
|
||||
"factor": 0.95
|
||||
},
|
||||
{
|
||||
"name": "comic sans ms",
|
||||
"factor": 1.087
|
||||
},
|
||||
{
|
||||
"name": "consolas",
|
||||
"factor": 1.158
|
||||
},
|
||||
{
|
||||
"name": "constantia",
|
||||
"factor": 1.052
|
||||
},
|
||||
{
|
||||
"name": "corbel",
|
||||
"factor": 0.895
|
||||
},
|
||||
{
|
||||
"name": "corbel light",
|
||||
"factor": 0.876
|
||||
},
|
||||
{
|
||||
"name": "courier new",
|
||||
"factor": 1.187
|
||||
},
|
||||
{
|
||||
"name": "ebrima",
|
||||
"factor": 0.962
|
||||
},
|
||||
{
|
||||
"name": "franklin gothic medium",
|
||||
"factor": 1.031
|
||||
},
|
||||
{
|
||||
"name": "gabriola",
|
||||
"factor": 1.077
|
||||
},
|
||||
{
|
||||
"name": "georgia",
|
||||
"factor": 1.094
|
||||
},
|
||||
{
|
||||
"name": "hololens mdl2 assets",
|
||||
"factor": 1.145
|
||||
},
|
||||
{
|
||||
"name": "impact",
|
||||
"factor": 0.862
|
||||
},
|
||||
{
|
||||
"name": "ink free",
|
||||
"factor": 1.048
|
||||
},
|
||||
{
|
||||
"name": "leelawadee ui semilight",
|
||||
"factor": 1.061
|
||||
},
|
||||
{
|
||||
"name": "lucida console",
|
||||
"factor": 1.131
|
||||
},
|
||||
{
|
||||
"name": "lucida sans unicode",
|
||||
"factor": 1.014
|
||||
},
|
||||
{
|
||||
"name": "malgun gothic",
|
||||
"factor": 1.128
|
||||
},
|
||||
{
|
||||
"name": "malgun gothic semilight",
|
||||
"factor": 1.051
|
||||
},
|
||||
{
|
||||
"name": "marlett",
|
||||
"factor": 0.855
|
||||
},
|
||||
{
|
||||
"name": "microsoft himalaya",
|
||||
"factor": 1.022
|
||||
},
|
||||
{
|
||||
"name": "microsoft jhenghei",
|
||||
"factor": 1.061
|
||||
},
|
||||
{
|
||||
"name": "microsoft jhenghei light",
|
||||
"factor": 0.94
|
||||
},
|
||||
{
|
||||
"name": "microsoft jhenghei ui",
|
||||
"factor": 1.136
|
||||
},
|
||||
{
|
||||
"name": "microsoft jhenghei ui light",
|
||||
"factor": 0.826
|
||||
},
|
||||
{
|
||||
"name": "microsoft new tai lue",
|
||||
"factor": 0.953
|
||||
},
|
||||
{
|
||||
"name": "microsoft phagspa",
|
||||
"factor": 0.988
|
||||
},
|
||||
{
|
||||
"name": "microsoft sans serif",
|
||||
"factor": 0.941
|
||||
},
|
||||
{
|
||||
"name": "microsoft tai le",
|
||||
"factor": 0.957
|
||||
},
|
||||
{
|
||||
"name": "microsoft yahei",
|
||||
"factor": 1.076
|
||||
},
|
||||
{
|
||||
"name": "microsoft yahei light",
|
||||
"factor": 1.043
|
||||
},
|
||||
{
|
||||
"name": "microsoft yahei ui",
|
||||
"factor": 1.076
|
||||
},
|
||||
{
|
||||
"name": "microsoft yahei ui light",
|
||||
"factor": 1.094
|
||||
},
|
||||
{
|
||||
"name": "microsoft yi baiti",
|
||||
"factor": 1.008
|
||||
},
|
||||
{
|
||||
"name": "mingliu-extb",
|
||||
"factor": 1.173
|
||||
},
|
||||
{
|
||||
"name": "mingliu_hkscs-extb",
|
||||
"factor": 1.101
|
||||
},
|
||||
{
|
||||
"name": "mongolian baiti",
|
||||
"factor": 1.056
|
||||
},
|
||||
{
|
||||
"name": "ms gothic",
|
||||
"factor": 1.119
|
||||
},
|
||||
{
|
||||
"name": "ms pgothic",
|
||||
"factor": 1.074
|
||||
},
|
||||
{
|
||||
"name": "mv boli",
|
||||
"factor": 0.932
|
||||
},
|
||||
{
|
||||
"name": "nirmala ui",
|
||||
"factor": 0.916
|
||||
},
|
||||
{
|
||||
"name": "nirmala ui semilight",
|
||||
"factor": 1.15
|
||||
},
|
||||
{
|
||||
"name": "nsimsun",
|
||||
"factor": 1.119
|
||||
},
|
||||
{
|
||||
"name": "palatino linotype",
|
||||
"factor": 1.067
|
||||
},
|
||||
{
|
||||
"name": "pmingliu-extb",
|
||||
"factor": 1.16
|
||||
},
|
||||
{
|
||||
"name": "sans serif collection",
|
||||
"factor": 0.854
|
||||
},
|
||||
{
|
||||
"name": "segoe mdl2 assets",
|
||||
"factor": 0.858
|
||||
},
|
||||
{
|
||||
"name": "segoe print",
|
||||
"factor": 1.033
|
||||
},
|
||||
{
|
||||
"name": "segoe script",
|
||||
"factor": 1.058
|
||||
},
|
||||
{
|
||||
"name": "segoe ui",
|
||||
"factor": 0.948
|
||||
},
|
||||
{
|
||||
"name": "segoe ui black",
|
||||
"factor": 1.148
|
||||
},
|
||||
{
|
||||
"name": "segoe ui emoji",
|
||||
"factor": 0.924
|
||||
},
|
||||
{
|
||||
"name": "segoe ui historic",
|
||||
"factor": 0.969
|
||||
},
|
||||
{
|
||||
"name": "segoe ui semibold",
|
||||
"factor": 0.965
|
||||
},
|
||||
{
|
||||
"name": "segoe ui semilight",
|
||||
"factor": 1.055
|
||||
},
|
||||
{
|
||||
"name": "segoe ui symbol",
|
||||
"factor": 0.972
|
||||
},
|
||||
{
|
||||
"name": "segoe ui variable",
|
||||
"factor": 0.907
|
||||
},
|
||||
{
|
||||
"name": "simsun",
|
||||
"factor": 1.113
|
||||
},
|
||||
{
|
||||
"name": "simsun-extb",
|
||||
"factor": 1.095
|
||||
},
|
||||
{
|
||||
"name": "sitka banner",
|
||||
"factor": 1.078
|
||||
},
|
||||
{
|
||||
"name": "sitka display",
|
||||
"factor": 0.838
|
||||
},
|
||||
{
|
||||
"name": "sitka heading",
|
||||
"factor": 0.843
|
||||
},
|
||||
{
|
||||
"name": "sitka small",
|
||||
"factor": 1.131
|
||||
},
|
||||
{
|
||||
"name": "sitka subheading",
|
||||
"factor": 0.942
|
||||
},
|
||||
{
|
||||
"name": "sitka text",
|
||||
"factor": 1.044
|
||||
},
|
||||
{
|
||||
"name": "sylfaen",
|
||||
"factor": 0.971
|
||||
},
|
||||
{
|
||||
"name": "symbol",
|
||||
"factor": 0.866
|
||||
},
|
||||
{
|
||||
"name": "tahoma",
|
||||
"factor": 0.962
|
||||
},
|
||||
{
|
||||
"name": "times new roman",
|
||||
"factor": 0.926
|
||||
},
|
||||
{
|
||||
"name": "trebuchet ms",
|
||||
"factor": 0.912
|
||||
},
|
||||
{
|
||||
"name": "verdana",
|
||||
"factor": 1.128
|
||||
},
|
||||
{
|
||||
"name": "webdings",
|
||||
"factor": 0.849
|
||||
},
|
||||
{
|
||||
"name": "wingdings",
|
||||
"factor": 0.864
|
||||
},
|
||||
{
|
||||
"name": "wingdings 2",
|
||||
"factor": 1.093
|
||||
},
|
||||
{
|
||||
"name": "wingdings 3",
|
||||
"factor": 0.861
|
||||
},
|
||||
{
|
||||
"name": "yu gothic",
|
||||
"factor": 1.069
|
||||
},
|
||||
{
|
||||
"name": "yu gothic light",
|
||||
"factor": 1.038
|
||||
},
|
||||
{
|
||||
"name": "yu gothic medium",
|
||||
"factor": 1.161
|
||||
},
|
||||
{
|
||||
"name": "yu gothic ui",
|
||||
"factor": 0.886
|
||||
},
|
||||
{
|
||||
"name": "yu gothic ui light",
|
||||
"factor": 1.093
|
||||
},
|
||||
{
|
||||
"name": "yu gothic ui semibold",
|
||||
"factor": 0.942
|
||||
},
|
||||
{
|
||||
"name": "yu gothic ui semilight",
|
||||
"factor": 0.923
|
||||
}
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"name": "aparajita",
|
||||
"factor": 0.94
|
||||
},
|
||||
{
|
||||
"name": "arabic typesetting",
|
||||
"factor": 1.093
|
||||
},
|
||||
{
|
||||
"name": "arial unicode ms",
|
||||
"factor": 1.047
|
||||
},
|
||||
{
|
||||
"name": "batang",
|
||||
"factor": 0.911
|
||||
},
|
||||
{
|
||||
"name": "calibri",
|
||||
"factor": 0.934
|
||||
},
|
||||
{
|
||||
"name": "century",
|
||||
"factor": 1.078
|
||||
},
|
||||
{
|
||||
"name": "century gothic",
|
||||
"factor": 0.886
|
||||
},
|
||||
{
|
||||
"name": "dengxian",
|
||||
"factor": 0.846
|
||||
},
|
||||
{
|
||||
"name": "dfkai-sb",
|
||||
"factor": 0.946
|
||||
},
|
||||
{
|
||||
"name": "dokchampa",
|
||||
"factor": 1.162
|
||||
},
|
||||
{
|
||||
"name": "estrangelo edessa",
|
||||
"factor": 1.095
|
||||
},
|
||||
{
|
||||
"name": "euphemia",
|
||||
"factor": 1.117
|
||||
},
|
||||
{
|
||||
"name": "fangsong",
|
||||
"factor": 0.942
|
||||
},
|
||||
{
|
||||
"name": "franklin gothic",
|
||||
"factor": 0.942
|
||||
},
|
||||
{
|
||||
"name": "gadugi",
|
||||
"factor": 0.945
|
||||
},
|
||||
{
|
||||
"name": "gautami",
|
||||
"factor": 1.134
|
||||
},
|
||||
{
|
||||
"name": "haettenschweiler",
|
||||
"factor": 0.874
|
||||
},
|
||||
{
|
||||
"name": "helv",
|
||||
"factor": 0.923
|
||||
},
|
||||
{
|
||||
"name": "iskoola pota",
|
||||
"factor": 0.857
|
||||
},
|
||||
{
|
||||
"name": "javanese text",
|
||||
"factor": 1.083
|
||||
},
|
||||
{
|
||||
"name": "kaiti",
|
||||
"factor": 1.145
|
||||
},
|
||||
{
|
||||
"name": "kalinga",
|
||||
"factor": 0.953
|
||||
},
|
||||
{
|
||||
"name": "kartika",
|
||||
"factor": 0.882
|
||||
},
|
||||
{
|
||||
"name": "khmer ui",
|
||||
"factor": 1.137
|
||||
},
|
||||
{
|
||||
"name": "kokila",
|
||||
"factor": 0.919
|
||||
},
|
||||
{
|
||||
"name": "lao ui",
|
||||
"factor": 0.904
|
||||
},
|
||||
{
|
||||
"name": "latha",
|
||||
"factor": 0.839
|
||||
},
|
||||
{
|
||||
"name": "leelawadee",
|
||||
"factor": 0.982
|
||||
},
|
||||
{
|
||||
"name": "leelawadee ui",
|
||||
"factor": 0.992
|
||||
},
|
||||
{
|
||||
"name": "levenim mt",
|
||||
"factor": 1.054
|
||||
},
|
||||
{
|
||||
"name": "mangal",
|
||||
"factor": 1.154
|
||||
},
|
||||
{
|
||||
"name": "meiryo",
|
||||
"factor": 1.165
|
||||
},
|
||||
{
|
||||
"name": "meiryo ui",
|
||||
"factor": 1.081
|
||||
},
|
||||
{
|
||||
"name": "microsoft uighur",
|
||||
"factor": 0.969
|
||||
},
|
||||
{
|
||||
"name": "monotype corsiva",
|
||||
"factor": 0.939
|
||||
},
|
||||
{
|
||||
"name": "ms mincho",
|
||||
"factor": 1.112
|
||||
},
|
||||
{
|
||||
"name": "ms outlook",
|
||||
"factor": 0.921
|
||||
},
|
||||
{
|
||||
"name": "ms pmincho",
|
||||
"factor": 0.925
|
||||
},
|
||||
{
|
||||
"name": "ms reference sans serif",
|
||||
"factor": 0.858
|
||||
},
|
||||
{
|
||||
"name": "ms reference specialty",
|
||||
"factor": 0.958
|
||||
},
|
||||
{
|
||||
"name": "ms ui gothic",
|
||||
"factor": 1.097
|
||||
},
|
||||
{
|
||||
"name": "mt extra",
|
||||
"factor": 0.905
|
||||
},
|
||||
{
|
||||
"name": "myanmar text",
|
||||
"factor": 0.961
|
||||
},
|
||||
{
|
||||
"name": "nyala",
|
||||
"factor": 1.108
|
||||
},
|
||||
{
|
||||
"name": "plantagenet cherokee",
|
||||
"factor": 1.115
|
||||
},
|
||||
{
|
||||
"name": "pmingliu",
|
||||
"factor": 1.125
|
||||
},
|
||||
{
|
||||
"name": "pristina",
|
||||
"factor": 1.023
|
||||
},
|
||||
{
|
||||
"name": "raavi",
|
||||
"factor": 0.826
|
||||
},
|
||||
{
|
||||
"name": "segoe fluent icons",
|
||||
"factor": 1.049
|
||||
},
|
||||
{
|
||||
"name": "segoe ui light",
|
||||
"factor": 0.918
|
||||
},
|
||||
{
|
||||
"name": "shonar bangla",
|
||||
"factor": 1.141
|
||||
},
|
||||
{
|
||||
"name": "shruti",
|
||||
"factor": 1.172
|
||||
},
|
||||
{
|
||||
"name": "simhei",
|
||||
"factor": 1.141
|
||||
},
|
||||
{
|
||||
"name": "simkai",
|
||||
"factor": 1.1
|
||||
},
|
||||
{
|
||||
"name": "small fonts",
|
||||
"factor": 0.849
|
||||
},
|
||||
{
|
||||
"name": "traditional arabic",
|
||||
"factor": 0.893
|
||||
},
|
||||
{
|
||||
"name": "tunga",
|
||||
"factor": 0.825
|
||||
},
|
||||
{
|
||||
"name": "urdu typesetting",
|
||||
"factor": 0.869
|
||||
},
|
||||
{
|
||||
"name": "utsaah",
|
||||
"factor": 1.144
|
||||
},
|
||||
{
|
||||
"name": "vani",
|
||||
"factor": 1.078
|
||||
},
|
||||
{
|
||||
"name": "vijaya",
|
||||
"factor": 0.917
|
||||
},
|
||||
{
|
||||
"name": "vrinda",
|
||||
"factor": 0.985
|
||||
},
|
||||
{
|
||||
"name": "yu mincho",
|
||||
"factor": 0.867
|
||||
}
|
||||
]
|
||||
}
|
||||
17
src/invisible_playwright/_fpforge/data/prior_audio.json
Normal file
17
src/invisible_playwright/_fpforge/data/prior_audio.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "audio (joint: sample_rate, output_latency_ms, max_channel_count)",
|
||||
"parents": [],
|
||||
"child": "audio",
|
||||
"source": "Curated from common Windows WASAPI/DirectSound configs on FF desktop"
|
||||
},
|
||||
"table": [
|
||||
{"value": {"rate": 44100, "latency": 40, "channels": 2}, "prob": 0.20},
|
||||
{"value": {"rate": 48000, "latency": 30, "channels": 2}, "prob": 0.25},
|
||||
{"value": {"rate": 48000, "latency": 20, "channels": 2}, "prob": 0.15},
|
||||
{"value": {"rate": 48000, "latency": 40, "channels": 6}, "prob": 0.08},
|
||||
{"value": {"rate": 48000, "latency": 60, "channels": 2}, "prob": 0.12},
|
||||
{"value": {"rate": 44100, "latency": 50, "channels": 2}, "prob": 0.10},
|
||||
{"value": {"rate": 48000, "latency": 25, "channels": 2}, "prob": 0.10}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"_meta": {
|
||||
"name": "Independent marginal priors (no parents)",
|
||||
"source": "StatCounter GlobalStats + Firefox defaults + community data 2025-2026",
|
||||
"note": "Codec prefs (av1_enabled, webm_encoder_enabled, hw_video_decoding, wmf_enabled, ffvpx_enabled) moved to cpt_codec_given_class.json — they correlate with GPU class via Firefox version / user-tier distribution."
|
||||
},
|
||||
"dark_theme": {
|
||||
"_note": "0=light, 1=dark. StatCounter ~40% users report dark theme preference on desktop.",
|
||||
"table": [
|
||||
{"value": 0, "prob": 0.60},
|
||||
{"value": 1, "prob": 0.40}
|
||||
]
|
||||
}
|
||||
}
|
||||
1902
src/invisible_playwright/_fpforge/data/webgl_renderer_pool.json
Normal file
1902
src/invisible_playwright/_fpforge/data/webgl_renderer_pool.json
Normal file
File diff suppressed because it is too large
Load diff
259
src/invisible_playwright/_fpforge/profile.py
Normal file
259
src/invisible_playwright/_fpforge/profile.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Public dataclass surface for fpforge."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace as _dc_replace
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ._sampler import sample as _sample_raw
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GPUProfile:
|
||||
vendor: str
|
||||
renderer: str
|
||||
class_tier: str # "low_end" | "mid_range" | "high_end" | "integrated_old" | "integrated_modern"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenProfile:
|
||||
width: int
|
||||
height: int
|
||||
avail_width: int
|
||||
avail_height: int
|
||||
dpr: float
|
||||
tier: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardwareProfile:
|
||||
concurrency: int
|
||||
storage_quota_mb: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioProfile:
|
||||
sample_rate: int
|
||||
output_latency_ms: int
|
||||
max_channel_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodecProfile:
|
||||
av1_enabled: bool
|
||||
webm_encoder_enabled: bool
|
||||
mediasource_webm: bool
|
||||
mediasource_mp4: bool
|
||||
webspeech_synth: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebGLProfile:
|
||||
msaa_samples: int
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Pin map: flat dotted-path -> value. Set via `pin=` on generate_profile.
|
||||
#
|
||||
# Supported keys:
|
||||
# "gpu.vendor", "gpu.renderer", "gpu.class_tier"
|
||||
# "screen.width", "screen.height", "screen.avail_width",
|
||||
# "screen.avail_height", "screen.dpr", "screen.tier"
|
||||
# "hardware.concurrency", "hardware.storage_quota_mb"
|
||||
# "audio.sample_rate", "audio.output_latency_ms",
|
||||
# "audio.max_channel_count"
|
||||
# "codec.av1_enabled", "codec.webm_encoder_enabled",
|
||||
# "codec.mediasource_webm", "codec.mediasource_mp4",
|
||||
# "codec.webspeech_synth"
|
||||
# "webgl.msaa_samples"
|
||||
# "fonts" (replaces the whole list)
|
||||
# "dark_theme"
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_PIN_GROUPS = {
|
||||
"gpu": {"vendor", "renderer", "class_tier"},
|
||||
"screen": {"width", "height", "avail_width", "avail_height", "dpr", "tier"},
|
||||
"hardware": {"concurrency", "storage_quota_mb"},
|
||||
"audio": {"sample_rate", "output_latency_ms", "max_channel_count"},
|
||||
"codec": {
|
||||
"av1_enabled", "webm_encoder_enabled",
|
||||
"mediasource_webm", "mediasource_mp4", "webspeech_synth",
|
||||
},
|
||||
"webgl": {"msaa_samples"},
|
||||
}
|
||||
_PIN_TOP = {"fonts", "dark_theme"}
|
||||
|
||||
|
||||
def _validate_pin_key(key: str) -> None:
|
||||
if key in _PIN_TOP:
|
||||
return
|
||||
if "." not in key:
|
||||
raise ValueError(
|
||||
f"pin key {key!r} is not valid. "
|
||||
f"Use 'group.field' (e.g. 'screen.width') or one of {sorted(_PIN_TOP)}."
|
||||
)
|
||||
group, field_name = key.split(".", 1)
|
||||
if group not in _PIN_GROUPS:
|
||||
raise ValueError(
|
||||
f"pin key {key!r}: unknown group {group!r}. "
|
||||
f"Known groups: {sorted(_PIN_GROUPS)}."
|
||||
)
|
||||
if field_name not in _PIN_GROUPS[group]:
|
||||
raise ValueError(
|
||||
f"pin key {key!r}: unknown field {field_name!r} in group {group!r}. "
|
||||
f"Known fields: {sorted(_PIN_GROUPS[group])}."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
"""Coherent browser fingerprint profile sampled from a single integer seed.
|
||||
|
||||
Use `generate_profile(seed)` to build one. Pin specific values at build
|
||||
time with `generate_profile(seed, pin={"screen.width": 2560, ...})`.
|
||||
"""
|
||||
seed: int
|
||||
gpu: GPUProfile
|
||||
screen: ScreenProfile
|
||||
hardware: HardwareProfile
|
||||
audio: AudioProfile
|
||||
codec: CodecProfile
|
||||
webgl: WebGLProfile
|
||||
fonts: List[str]
|
||||
dark_theme: bool
|
||||
_raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
def to_prefs_dict(self) -> Dict[str, Any]:
|
||||
"""Return the flat dict of raw sampler fields, as produced by the
|
||||
underlying Bayesian sampler. Stable across releases for a given seed."""
|
||||
return dict(self._raw)
|
||||
|
||||
|
||||
# Mapping from flat pin key -> raw sampler dict key, so `to_prefs_dict()`
|
||||
# and `invisible_playwright.prefs.translate_profile_to_prefs` observe the pinned value.
|
||||
_PIN_TO_RAW = {
|
||||
"gpu.vendor": "webgl_vendor",
|
||||
"gpu.renderer": "webgl_renderer",
|
||||
"gpu.class_tier": "gpu_class",
|
||||
"screen.width": "screen_w",
|
||||
"screen.height": "screen_h",
|
||||
"screen.avail_width": "screen_avail_w",
|
||||
"screen.avail_height": "screen_avail_h",
|
||||
"screen.dpr": "dpr",
|
||||
"screen.tier": "screen_tier",
|
||||
"hardware.concurrency": "hw_concurrency",
|
||||
"hardware.storage_quota_mb": "storage_quota_mb",
|
||||
"audio.sample_rate": "audio_sample_rate",
|
||||
"audio.output_latency_ms": "audio_output_latency_ms",
|
||||
"audio.max_channel_count": "audio_max_channel_count",
|
||||
"codec.av1_enabled": "av1_enabled",
|
||||
"codec.webm_encoder_enabled": "webm_encoder_enabled",
|
||||
"codec.mediasource_webm": "mediasource_webm",
|
||||
"codec.mediasource_mp4": "mediasource_mp4",
|
||||
"codec.webspeech_synth": "webspeech_synth",
|
||||
"webgl.msaa_samples": "msaa_samples",
|
||||
"dark_theme": "dark_theme",
|
||||
# "fonts" is a list — handled specially (joined into font_whitelist).
|
||||
}
|
||||
|
||||
|
||||
def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a copy of `raw` with the pinned sampler-level fields updated."""
|
||||
out = dict(raw)
|
||||
for key, value in pin.items():
|
||||
if key == "fonts":
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise TypeError("pin 'fonts' must be a list/tuple of strings")
|
||||
out["font_whitelist"] = ",".join(value)
|
||||
continue
|
||||
raw_key = _PIN_TO_RAW.get(key)
|
||||
if raw_key is None:
|
||||
# Shouldn't happen after validation, but guard anyway.
|
||||
continue
|
||||
out[raw_key] = value
|
||||
return out
|
||||
|
||||
|
||||
def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile:
|
||||
"""Return a deterministic Profile for the given integer seed.
|
||||
|
||||
pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer")
|
||||
to values that are FORCED in the resulting profile. All other fields
|
||||
are still sampled from the Bayesian network based on `seed`, so the
|
||||
same seed + same pin map always yields the same profile.
|
||||
|
||||
Example — force a specific GPU and screen while letting everything
|
||||
else vary with the seed (via the public invisible_playwright API):
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(
|
||||
seed=42,
|
||||
pin={
|
||||
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
|
||||
"gpu.vendor": "Google Inc. (NVIDIA)",
|
||||
"gpu.class_tier": "high_end",
|
||||
"screen.width": 2560,
|
||||
"screen.height": 1440,
|
||||
},
|
||||
) as browser:
|
||||
...
|
||||
|
||||
Warning: pinning breaks Bayesian coherence across the pinned fields
|
||||
(if you pin a high-end GPU but leave screen unpinned, you may get a
|
||||
1080p screen that would be unusual for that GPU class). Pin related
|
||||
fields together when coherence matters.
|
||||
|
||||
Supported keys: see the module-level _PIN_GROUPS / _PIN_TOP tables
|
||||
or run `help(generate_profile)` after import.
|
||||
"""
|
||||
if pin:
|
||||
for key in pin:
|
||||
_validate_pin_key(key)
|
||||
|
||||
raw = _sample_raw(int(seed))
|
||||
if pin:
|
||||
raw = _apply_pins_to_raw(raw, pin)
|
||||
|
||||
# Font whitelist is stored as a comma-separated string in raw; split it.
|
||||
font_wl = raw.get("font_whitelist", "")
|
||||
if isinstance(font_wl, str):
|
||||
fonts = [f.strip() for f in font_wl.split(",") if f.strip()]
|
||||
else:
|
||||
fonts = list(font_wl) if font_wl else []
|
||||
|
||||
return Profile(
|
||||
seed=int(raw["stealth_seed"]),
|
||||
gpu=GPUProfile(
|
||||
vendor=raw["webgl_vendor"],
|
||||
renderer=raw["webgl_renderer"],
|
||||
class_tier=raw["gpu_class"],
|
||||
),
|
||||
screen=ScreenProfile(
|
||||
width=int(raw["screen_w"]),
|
||||
height=int(raw["screen_h"]),
|
||||
avail_width=int(raw["screen_avail_w"]),
|
||||
avail_height=int(raw["screen_avail_h"]),
|
||||
dpr=float(raw["dpr"]),
|
||||
tier=str(raw.get("screen_tier", "")),
|
||||
),
|
||||
hardware=HardwareProfile(
|
||||
concurrency=int(raw["hw_concurrency"]),
|
||||
storage_quota_mb=int(raw["storage_quota_mb"]),
|
||||
),
|
||||
audio=AudioProfile(
|
||||
sample_rate=int(raw["audio_sample_rate"]),
|
||||
output_latency_ms=int(raw["audio_output_latency_ms"]),
|
||||
max_channel_count=int(raw["audio_max_channel_count"]),
|
||||
),
|
||||
codec=CodecProfile(
|
||||
av1_enabled=bool(raw["av1_enabled"]),
|
||||
webm_encoder_enabled=bool(raw["webm_encoder_enabled"]),
|
||||
mediasource_webm=bool(raw["mediasource_webm"]),
|
||||
mediasource_mp4=bool(raw["mediasource_mp4"]),
|
||||
webspeech_synth=bool(raw["webspeech_synth"]),
|
||||
),
|
||||
webgl=WebGLProfile(msaa_samples=int(raw["msaa_samples"])),
|
||||
fonts=fonts,
|
||||
dark_theme=bool(raw["dark_theme"]),
|
||||
_raw=raw,
|
||||
)
|
||||
228
src/invisible_playwright/_headless.py
Normal file
228
src/invisible_playwright/_headless.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Invisible-but-headed browser windows.
|
||||
|
||||
Playwright's ``headless=True`` flips Firefox onto a different code path —
|
||||
no widget tree, software-only rendering, distinct timing — and anti-bot
|
||||
systems can spot the divergence. Running the browser *headed* on a
|
||||
virtual display gives us the real rendering pipeline while keeping the
|
||||
windows off the user's screen.
|
||||
|
||||
Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it.
|
||||
Windows: creates a hidden desktop via ``CreateDesktop`` and binds the
|
||||
calling thread to it, so Playwright's child processes inherit it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Inherited from WSLg / GNOME / etc. these env vars make Firefox prefer a
|
||||
# Wayland compositor over the X11 DISPLAY we set, so the window leaks onto
|
||||
# the real desktop. Strip them all before starting.
|
||||
_WAYLAND_LEAK_VARS = (
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_SESSION_TYPE",
|
||||
"PULSE_SERVER",
|
||||
"WSL2_GUI_APPS_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
class _LinuxVirtualDisplay:
|
||||
"""Standalone Xvfb instance owned by this InvisiblePlaywright session."""
|
||||
|
||||
def __init__(self, width: int = 1920, height: int = 1080) -> None:
|
||||
self._geometry = f"{width}x{height}x24"
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._display: Optional[str] = None
|
||||
self._saved_env: dict[str, Optional[str]] = {}
|
||||
|
||||
def start(self) -> None:
|
||||
if not _binary_on_path("Xvfb"):
|
||||
raise RuntimeError(
|
||||
"invisible_playwright headless=True requires Xvfb. "
|
||||
"Install it: sudo apt install xvfb"
|
||||
)
|
||||
# Retry: when many workers start in parallel they can pick the same
|
||||
# display number before any has created its lockfile. Xvfb on the
|
||||
# losing side exits immediately — try again with a fresh number.
|
||||
last_err: Optional[Exception] = None
|
||||
for _ in range(10):
|
||||
display = self._pick_display()
|
||||
try:
|
||||
self._spawn(display)
|
||||
self._wait_until_ready(display)
|
||||
self._display = display
|
||||
self._apply_env(display)
|
||||
return
|
||||
except RuntimeError as e:
|
||||
last_err = e
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
self._proc.kill()
|
||||
self._proc = None
|
||||
raise RuntimeError(f"Xvfb failed to start after 10 attempts: {last_err}")
|
||||
|
||||
def _spawn(self, display: str) -> None:
|
||||
self._proc = subprocess.Popen(
|
||||
[
|
||||
"Xvfb", display,
|
||||
"-screen", "0", self._geometry,
|
||||
"+extension", "GLX",
|
||||
"+extension", "RENDER",
|
||||
"-nolisten", "unix",
|
||||
"-listen", "tcp",
|
||||
"-ac",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _pick_display(self) -> str:
|
||||
for n in range(99, 400):
|
||||
if not os.path.exists(f"/tmp/.X{n}-lock"):
|
||||
return f":{n}"
|
||||
raise RuntimeError("no free X display number in :99–:399")
|
||||
|
||||
def _wait_until_ready(self, display: str) -> None:
|
||||
# We start Xvfb with -nolisten unix → no /tmp/.X11-unix socket appears.
|
||||
# Xvfb creates /tmp/.X{n}-lock immediately though — wait for that.
|
||||
lockfile = f"/tmp/.X{display[1:]}-lock"
|
||||
deadline = time.monotonic() + 3.0
|
||||
assert self._proc is not None
|
||||
while time.monotonic() < deadline:
|
||||
if self._proc.poll() is not None:
|
||||
raise RuntimeError(f"Xvfb {display} exited immediately")
|
||||
if os.path.exists(lockfile):
|
||||
return
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"Xvfb {display} did not become ready in 3s")
|
||||
|
||||
def _apply_env(self, display: str) -> None:
|
||||
keys = ("DISPLAY", "MOZ_ENABLE_WAYLAND", "GDK_BACKEND") + _WAYLAND_LEAK_VARS
|
||||
for k in keys:
|
||||
self._saved_env[k] = os.environ.get(k)
|
||||
for k in _WAYLAND_LEAK_VARS:
|
||||
os.environ.pop(k, None)
|
||||
os.environ["DISPLAY"] = display
|
||||
os.environ["MOZ_ENABLE_WAYLAND"] = "0"
|
||||
os.environ["GDK_BACKEND"] = "x11"
|
||||
|
||||
def stop(self) -> None:
|
||||
for k, v in self._saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
self._saved_env.clear()
|
||||
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
try:
|
||||
self._proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
self._proc.wait(timeout=2)
|
||||
self._proc = None
|
||||
self._display = None
|
||||
|
||||
|
||||
class _WindowsVirtualDesktop:
|
||||
"""A hidden Windows desktop the calling thread is bound to.
|
||||
|
||||
Playwright's child processes (node driver → firefox.exe) inherit the
|
||||
desktop because their ``STARTUPINFO.lpDesktop`` is NULL — Windows uses
|
||||
the calling thread's desktop in that case.
|
||||
|
||||
pywin32 ships ``CreateDesktop`` in ``win32service`` but doesn't expose
|
||||
``SetThreadDesktop`` / ``GetThreadDesktop`` as module functions. We
|
||||
call them directly via ctypes against ``user32.dll``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._desktop = None # PyHDESK from win32service.CreateDesktop
|
||||
self._original_handle = 0 # raw HDESK int of the previous desktop
|
||||
|
||||
def start(self) -> None:
|
||||
try:
|
||||
import win32con # type: ignore
|
||||
import win32service # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"invisible_playwright headless=True on Windows requires pywin32. "
|
||||
"Install it: pip install pywin32"
|
||||
) from e
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# Save the current desktop handle so we can restore it on stop().
|
||||
get_thread_desktop = user32.GetThreadDesktop
|
||||
get_thread_desktop.argtypes = [wintypes.DWORD]
|
||||
get_thread_desktop.restype = wintypes.HANDLE
|
||||
self._original_handle = get_thread_desktop(kernel32.GetCurrentThreadId())
|
||||
|
||||
name = f"sf_{secrets.token_hex(4)}"
|
||||
self._desktop = win32service.CreateDesktop(
|
||||
name, 0, win32con.GENERIC_ALL, None
|
||||
)
|
||||
|
||||
# Bind the calling thread to the new desktop. Children spawned
|
||||
# afterwards (Playwright driver → firefox.exe) inherit it because
|
||||
# their STARTUPINFO.lpDesktop is NULL.
|
||||
set_thread_desktop = user32.SetThreadDesktop
|
||||
set_thread_desktop.argtypes = [wintypes.HANDLE]
|
||||
set_thread_desktop.restype = wintypes.BOOL
|
||||
if not set_thread_desktop(int(self._desktop)):
|
||||
err = ctypes.get_last_error()
|
||||
raise RuntimeError(
|
||||
f"SetThreadDesktop failed (GetLastError={err}). "
|
||||
"The thread cannot have any windows or hooks; close them first."
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
if self._original_handle:
|
||||
try:
|
||||
set_thread_desktop = user32.SetThreadDesktop
|
||||
set_thread_desktop.argtypes = [wintypes.HANDLE]
|
||||
set_thread_desktop.restype = wintypes.BOOL
|
||||
set_thread_desktop(self._original_handle)
|
||||
except Exception:
|
||||
pass
|
||||
self._original_handle = 0
|
||||
|
||||
if self._desktop is not None:
|
||||
try:
|
||||
self._desktop.CloseDesktop()
|
||||
except Exception:
|
||||
pass
|
||||
self._desktop = None
|
||||
|
||||
|
||||
def make_virtual_display():
|
||||
"""Return a started/stoppable virtual-display object for this platform.
|
||||
|
||||
InvisiblePlaywright supports Windows x86_64 and Linux x86_64 only.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return _WindowsVirtualDesktop()
|
||||
if sys.platform.startswith("linux"):
|
||||
return _LinuxVirtualDisplay()
|
||||
raise RuntimeError(
|
||||
f"invisible_playwright supports Windows and Linux only (got {sys.platform!r})"
|
||||
)
|
||||
|
||||
|
||||
def _binary_on_path(name: str) -> bool:
|
||||
import shutil
|
||||
return shutil.which(name) is not None
|
||||
56
src/invisible_playwright/_proxy.py
Normal file
56
src/invisible_playwright/_proxy.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Proxy translation shared by sync and async launchers.
|
||||
|
||||
SOCKS proxies are driven entirely by the patched Firefox prefs (the
|
||||
``nsProtocolProxyService`` patch reads ``network.proxy.socks_username``
|
||||
and ``socks_password``). HTTP/HTTPS proxies go through Playwright's own
|
||||
``proxy=`` kwarg so it can negotiate Basic auth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_SOCKS_SCHEMES = ("socks5://", "socks4://", "socks://")
|
||||
|
||||
|
||||
def configure_proxy(
|
||||
proxy: Optional[Dict[str, str]],
|
||||
prefs: Dict[str, Any],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Mutate ``prefs`` for SOCKS auth; return what to pass to Playwright.
|
||||
|
||||
* ``None`` proxy → returns ``None``.
|
||||
* SOCKS proxy → writes the auth prefs and returns ``None`` (Playwright
|
||||
gets nothing; Firefox does the rest).
|
||||
* HTTP / HTTPS proxy → returns the dict unchanged for Playwright.
|
||||
"""
|
||||
if not proxy:
|
||||
return None
|
||||
|
||||
server = (proxy.get("server") or "").strip()
|
||||
if not server or server.lower() == "direct://":
|
||||
return None
|
||||
if not _is_socks_scheme(server):
|
||||
return proxy
|
||||
|
||||
host_port = _strip_scheme(server)
|
||||
if ":" not in host_port:
|
||||
return None # malformed, drop silently
|
||||
|
||||
host, port_str = host_port.rsplit(":", 1)
|
||||
prefs["network.proxy.type"] = 1
|
||||
prefs["network.proxy.socks"] = host
|
||||
prefs["network.proxy.socks_port"] = int(port_str)
|
||||
prefs["network.proxy.socks_version"] = 4 if server.lower().startswith("socks4://") else 5
|
||||
prefs["network.proxy.socks_username"] = proxy.get("username") or ""
|
||||
prefs["network.proxy.socks_password"] = proxy.get("password") or ""
|
||||
prefs["network.proxy.socks_remote_dns"] = True
|
||||
return None
|
||||
|
||||
|
||||
def _is_socks_scheme(server: str) -> bool:
|
||||
return server.lower().startswith(_SOCKS_SCHEMES)
|
||||
|
||||
|
||||
def _strip_scheme(server: str) -> str:
|
||||
return server.split("://", 1)[1] if "://" in server else server
|
||||
178
src/invisible_playwright/async_api.py
Normal file
178
src/invisible_playwright/async_api.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Async Playwright façade — mirrors sync_api but with async/await."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from playwright.async_api import Browser, Playwright, async_playwright
|
||||
|
||||
from ._fpforge import Profile, generate_profile
|
||||
from ._headless import make_virtual_display
|
||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
||||
from .download import ensure_binary
|
||||
from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env
|
||||
from .prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
def _patch_new_page_sleep(ctx: Any) -> None:
|
||||
"""Wrap ctx.new_page() to add a brief settle after tab creation.
|
||||
|
||||
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
|
||||
is created. If goto() is called immediately, it races with that internal
|
||||
navigation and raises "Navigation interrupted by about:newtab". A short
|
||||
sleep breaks the race without requiring every call-site to know about it.
|
||||
"""
|
||||
original_new_page = ctx.new_page
|
||||
|
||||
async def patched_new_page(**kw):
|
||||
page = await original_new_page(**kw)
|
||||
await asyncio.sleep(0.4)
|
||||
return page
|
||||
|
||||
ctx.new_page = patched_new_page # type: ignore[assignment]
|
||||
|
||||
|
||||
class InvisiblePlaywright:
|
||||
"""Async context manager — see invisible_playwright.InvisiblePlaywright for the sync variant."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: Optional[int] = None,
|
||||
*,
|
||||
pin: Optional[Dict[str, Any]] = None,
|
||||
headless: bool = False,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
humanize: Union[bool, float] = True,
|
||||
locale: str = "en-US",
|
||||
timezone: str = "",
|
||||
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||
binary_path: Optional[str] = None,
|
||||
) -> None:
|
||||
# See sync launcher: `zoom.stealth.fpp.hw_seed` is int32_t — clamp.
|
||||
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
|
||||
self._pin = pin
|
||||
self._headless = headless
|
||||
self._proxy = proxy
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._humanize = humanize
|
||||
self._locale = locale
|
||||
self._timezone = timezone
|
||||
self._extra_prefs = extra_prefs
|
||||
self._binary_path = binary_path
|
||||
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
|
||||
self._pw: Optional[Playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._virtual_display: Any = None
|
||||
|
||||
async def __aenter__(self) -> Browser:
|
||||
import sys as _sys
|
||||
executable = self._binary_path or ensure_binary()
|
||||
prefs = translate_profile_to_prefs(
|
||||
self._profile,
|
||||
locale=self._locale,
|
||||
timezone=self._timezone,
|
||||
extra_prefs=self._extra_prefs,
|
||||
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
||||
)
|
||||
prefs["invisible_playwright.humanize"] = bool(self._humanize)
|
||||
if self._humanize:
|
||||
cap = 1.5 if self._humanize is True else float(self._humanize)
|
||||
prefs["invisible_playwright.humanize.maxTime"] = str(cap)
|
||||
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
|
||||
pw_headless = self._resolve_headless()
|
||||
env = self._build_env()
|
||||
try:
|
||||
self._pw = await async_playwright().start()
|
||||
self._browser = await self._pw.firefox.launch(
|
||||
executable_path=str(executable),
|
||||
headless=pw_headless,
|
||||
firefox_user_prefs=prefs,
|
||||
proxy=playwright_proxy,
|
||||
args=self._extra_args,
|
||||
env=env,
|
||||
)
|
||||
except BaseException:
|
||||
await self._teardown()
|
||||
raise
|
||||
self._patch_new_context_defaults(self._browser)
|
||||
return self._browser
|
||||
|
||||
def _patch_new_context_defaults(self, browser: Browser) -> None:
|
||||
original = browser.new_context
|
||||
defaults = self._default_context_kwargs()
|
||||
|
||||
async def patched(**kw):
|
||||
merged = dict(defaults)
|
||||
merged.update(kw)
|
||||
ctx = await original(**merged)
|
||||
_patch_new_page_sleep(ctx)
|
||||
return ctx
|
||||
|
||||
browser.new_context = patched # type: ignore[assignment]
|
||||
|
||||
def _default_context_kwargs(self) -> Dict[str, Any]:
|
||||
p = self._profile
|
||||
kwargs: Dict[str, Any] = {
|
||||
"viewport": {"width": p.screen.width - _CHROME_W,
|
||||
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
|
||||
"screen": {"width": p.screen.width, "height": p.screen.height},
|
||||
"device_scale_factor": p.screen.dpr,
|
||||
"color_scheme": "dark" if p.dark_theme else "light",
|
||||
}
|
||||
# Pass timezone via Playwright per-realm override (works for every
|
||||
# IANA name, including no-DST zones that Windows ICU silently drops
|
||||
# on the global pref path).
|
||||
if self._timezone:
|
||||
kwargs["timezone_id"] = self._timezone
|
||||
if self._locale:
|
||||
kwargs["locale"] = self._locale
|
||||
return kwargs
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
await self._teardown()
|
||||
|
||||
async def _teardown(self) -> None:
|
||||
if self._browser is not None:
|
||||
try:
|
||||
await self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._pw is not None:
|
||||
try:
|
||||
await self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._pw = None
|
||||
if self._virtual_display is not None:
|
||||
try:
|
||||
self._virtual_display.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._virtual_display = None
|
||||
|
||||
def _build_env(self) -> Dict[str, str]:
|
||||
import os as _os
|
||||
env = _os.environ.copy()
|
||||
if self._timezone:
|
||||
env["TZ"] = _tz_env(self._timezone)
|
||||
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
|
||||
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
|
||||
# matching the proxy egress IP. This avoids the StaticPref IPC
|
||||
# propagation timing issue between parent and socket processes.
|
||||
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
|
||||
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
|
||||
return env
|
||||
|
||||
def _resolve_headless(self) -> bool:
|
||||
if not self._headless:
|
||||
return False
|
||||
vd = make_virtual_display()
|
||||
vd.start()
|
||||
self._virtual_display = vd
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["InvisiblePlaywright"]
|
||||
68
src/invisible_playwright/cli.py
Normal file
68
src/invisible_playwright/cli.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Command-line interface for invisible_playwright."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
||||
from .download import cache_root, ensure_binary
|
||||
|
||||
|
||||
def _cmd_fetch(_args: argparse.Namespace) -> int:
|
||||
path = ensure_binary()
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_path(_args: argparse.Namespace) -> int:
|
||||
try:
|
||||
path = ensure_binary()
|
||||
except Exception as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_version(_args: argparse.Namespace) -> int:
|
||||
print(f"invisible_playwright {__version__}")
|
||||
print(f"BINARY_VERSION={BINARY_VERSION} (Firefox {FIREFOX_UPSTREAM_VERSION})")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_clear_cache(_args: argparse.Namespace) -> int:
|
||||
root = cache_root()
|
||||
if root.exists():
|
||||
shutil.rmtree(root)
|
||||
print(f"removed: {root}")
|
||||
else:
|
||||
print(f"nothing to remove: {root}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="invisible-playwright", description="invisible_playwright CLI")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("fetch", help="download the patched Firefox binary")
|
||||
sub.add_parser("path", help="print the absolute path to the cached binary")
|
||||
sub.add_parser("version", help="print wrapper and binary versions")
|
||||
sub.add_parser("clear-cache", help="remove all cached binaries")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
dispatch = {
|
||||
"fetch": _cmd_fetch,
|
||||
"path": _cmd_path,
|
||||
"version": _cmd_version,
|
||||
"clear-cache": _cmd_clear_cache,
|
||||
}
|
||||
return dispatch[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
48
src/invisible_playwright/constants.py
Normal file
48
src/invisible_playwright/constants.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Compile-time constants that pin the wrapper to a specific Firefox build.
|
||||
|
||||
BINARY_VERSION is bumped every time new Firefox patches are released. It is
|
||||
deliberately decoupled from the Python package version so that pure-Python
|
||||
bugfixes don't force a multi-hour Firefox rebuild.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Bump this when a new patched Firefox build is released on GitHub.
|
||||
BINARY_VERSION: str = "firefox-1"
|
||||
|
||||
# Underlying Firefox version (for display only; does not drive downloads).
|
||||
FIREFOX_UPSTREAM_VERSION: str = "150.0.1"
|
||||
|
||||
# The base filename prefix used inside archives.
|
||||
BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth"
|
||||
|
||||
|
||||
def ARCHIVE_NAME(platform_key: str, machine: str) -> str:
|
||||
"""Return the platform-specific archive filename.
|
||||
|
||||
platform_key: sys.platform ("win32", "linux")
|
||||
machine: platform.machine() ("AMD64", "x86_64", ...)
|
||||
"""
|
||||
pk = platform_key.lower()
|
||||
m = machine.lower()
|
||||
if m in {"amd64", "x86_64"}:
|
||||
arch = "x86_64"
|
||||
else:
|
||||
raise NotImplementedError(f"unsupported arch: {machine}")
|
||||
|
||||
if pk == "win32":
|
||||
return f"{BINARY_BASENAME}-win-{arch}.zip"
|
||||
if pk == "linux":
|
||||
return f"{BINARY_BASENAME}-linux-{arch}.tar.gz"
|
||||
raise NotImplementedError(f"unsupported platform: {platform_key}")
|
||||
|
||||
|
||||
# Binary entry point relative path inside the extracted archive root.
|
||||
BINARY_ENTRY_REL = {
|
||||
"win32": "firefox.exe",
|
||||
"linux": "firefox",
|
||||
}
|
||||
|
||||
# GitHub release URL template. The "TODO" owner is resolved at publication time.
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/feder-cr/invisible_playwright/releases/download/{tag}/{asset}"
|
||||
)
|
||||
1846
src/invisible_playwright/data/font-map.json
Normal file
1846
src/invisible_playwright/data/font-map.json
Normal file
File diff suppressed because it is too large
Load diff
151
src/invisible_playwright/download.py
Normal file
151
src/invisible_playwright/download.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Download and cache the patched Firefox binary from GitHub Releases."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import platformdirs
|
||||
import requests
|
||||
|
||||
from .constants import (
|
||||
ARCHIVE_NAME,
|
||||
BINARY_ENTRY_REL,
|
||||
BINARY_VERSION,
|
||||
RELEASE_URL_TEMPLATE,
|
||||
)
|
||||
|
||||
|
||||
def _github_token() -> str | None:
|
||||
return os.environ.get("STEALTHFOX_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
|
||||
|
||||
def _parse_owner_repo(template: str) -> tuple[str, str]:
|
||||
"""Extract (owner, repo) from RELEASE_URL_TEMPLATE."""
|
||||
m = re.match(r"https://github\.com/([^/]+)/([^/]+)/releases/", template)
|
||||
if not m:
|
||||
raise RuntimeError(f"cannot parse owner/repo from {template!r}")
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def cache_root() -> Path:
|
||||
"""Directory where all cached binaries live."""
|
||||
return Path(platformdirs.user_cache_dir("invisible-playwright"))
|
||||
|
||||
|
||||
def cache_dir_for_version(version: str = BINARY_VERSION) -> Path:
|
||||
return cache_root() / version
|
||||
|
||||
|
||||
def _resolve_asset_url(tag: str, asset_name: str) -> str:
|
||||
"""Return a downloadable URL for the asset.
|
||||
|
||||
For private repos the direct `releases/download/<tag>/<asset>` URL returns
|
||||
404 even with a token, so we resolve via the API: list assets for the
|
||||
release tag, find the one matching `asset_name`, and use its API URL with
|
||||
`Accept: application/octet-stream` (which 302-redirects to a signed URL).
|
||||
For public repos the direct URL still works without a token.
|
||||
"""
|
||||
token = _github_token()
|
||||
if not token:
|
||||
return RELEASE_URL_TEMPLATE.format(tag=tag, asset=asset_name)
|
||||
owner, repo = _parse_owner_repo(RELEASE_URL_TEMPLATE)
|
||||
api = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
|
||||
r = requests.get(api, headers={"Authorization": f"token {token}"}, timeout=30)
|
||||
r.raise_for_status()
|
||||
for a in r.json().get("assets", []):
|
||||
if a.get("name") == asset_name:
|
||||
return a["url"]
|
||||
raise RuntimeError(f"asset {asset_name!r} not found in release {tag!r}")
|
||||
|
||||
|
||||
def _download_file(url: str, dst: Path, chunk_size: int = 1 << 16) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
headers: dict[str, str] = {}
|
||||
token = _github_token()
|
||||
if token and url.startswith("https://api.github.com/"):
|
||||
headers["Authorization"] = f"token {token}"
|
||||
headers["Accept"] = "application/octet-stream"
|
||||
with requests.get(url, stream=True, timeout=60, headers=headers) as r:
|
||||
r.raise_for_status()
|
||||
with open(dst, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 16), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _parse_checksums(text: str) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
out[parts[-1]] = parts[0]
|
||||
return out
|
||||
|
||||
|
||||
def _extract(archive: Path, dst: Path) -> None:
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
if archive.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
zf.extractall(dst)
|
||||
elif archive.name.endswith(".tar.gz") or archive.suffix in {".tgz", ".gz"}:
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
tf.extractall(dst)
|
||||
else:
|
||||
raise RuntimeError(f"unknown archive format: {archive}")
|
||||
|
||||
|
||||
def ensure_binary(version: str = BINARY_VERSION) -> Path:
|
||||
"""Return a path to a runnable Firefox executable. Download if needed."""
|
||||
plat = sys.platform
|
||||
mach = platform.machine()
|
||||
asset = ARCHIVE_NAME(plat, mach)
|
||||
entry_rel = BINARY_ENTRY_REL.get(plat)
|
||||
if entry_rel is None:
|
||||
raise NotImplementedError(f"no binary entry for platform {plat}")
|
||||
|
||||
version_dir = cache_dir_for_version(version)
|
||||
entry = version_dir / entry_rel
|
||||
if entry.exists():
|
||||
return entry
|
||||
|
||||
url_archive = _resolve_asset_url(version, asset)
|
||||
url_sums = _resolve_asset_url(version, "checksums.txt")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tmp = Path(td)
|
||||
archive_path = tmp / asset
|
||||
_download_file(url_archive, archive_path)
|
||||
sums_path = tmp / "checksums.txt"
|
||||
_download_file(url_sums, sums_path)
|
||||
sums = _parse_checksums(sums_path.read_text())
|
||||
expected = sums.get(asset)
|
||||
if expected is None:
|
||||
raise RuntimeError(f"no SHA256 for {asset} in checksums.txt")
|
||||
actual = _sha256_file(archive_path)
|
||||
if actual.lower() != expected.lower():
|
||||
raise RuntimeError(
|
||||
f"SHA256 mismatch for {asset}: got {actual}, expected {expected}"
|
||||
)
|
||||
_extract(archive_path, version_dir)
|
||||
|
||||
if not entry.exists():
|
||||
raise RuntimeError(f"binary not found after extraction: {entry}")
|
||||
return entry
|
||||
307
src/invisible_playwright/launcher.py
Normal file
307
src/invisible_playwright/launcher.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""Sync Playwright launcher for invisible_playwright."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from playwright.sync_api import Browser, Playwright, sync_playwright
|
||||
|
||||
from ._fpforge import Profile, generate_profile
|
||||
from ._headless import make_virtual_display
|
||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
||||
from .download import ensure_binary
|
||||
from .prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
def _patch_sync_new_page_sleep(ctx: Any) -> None:
|
||||
"""Wrap ctx.new_page() to add a brief settle after tab creation.
|
||||
|
||||
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
|
||||
is created. If goto() is called immediately, it races with that internal
|
||||
navigation and raises "Navigation interrupted by about:newtab". A short
|
||||
sleep breaks the race without requiring every call-site to know about it.
|
||||
"""
|
||||
import time as _time
|
||||
original_new_page = ctx.new_page
|
||||
|
||||
def patched_new_page(**kw):
|
||||
page = original_new_page(**kw)
|
||||
_time.sleep(0.4)
|
||||
return page
|
||||
|
||||
ctx.new_page = patched_new_page # type: ignore[assignment]
|
||||
|
||||
|
||||
# Window-chrome and taskbar offsets measured empirically on a headed
|
||||
# Firefox 150 (no compositor). Used to derive the default new_context
|
||||
# viewport so it fits inside the spoofed screen without out-of-bounds.
|
||||
_CHROME_W = 14
|
||||
_CHROME_H = 91
|
||||
_TASKBAR_H = 40
|
||||
|
||||
# IANA → POSIX TZ mapping. Linux glibc accepts IANA names directly via
|
||||
# /usr/share/zoneinfo, but Windows MSVCRT only understands the POSIX form
|
||||
# ("EST5EDT") — convert here so ``TZ`` works on both platforms when the
|
||||
# binary runs on Windows. Common US zones cover the vast majority of
|
||||
# residential proxies; everything else falls through to its IANA name.
|
||||
_IANA_TO_POSIX_TZ = {
|
||||
"America/New_York": "EST5EDT",
|
||||
"America/Detroit": "EST5EDT",
|
||||
"America/Indiana/Indianapolis": "EST5EDT",
|
||||
"America/Kentucky/Louisville": "EST5EDT",
|
||||
"America/Chicago": "CST6CDT",
|
||||
"America/Denver": "MST7MDT",
|
||||
"America/Los_Angeles": "PST8PDT",
|
||||
# Arizona (except Navajo Nation) does NOT observe DST. Mapping it to
|
||||
# MST7MDT made libc apply DST → Date.getTimezoneOffset() returned -360
|
||||
# in summer (Denver-like) instead of -420 (true Phoenix), and FP Pro
|
||||
# deduced vpn_origin_timezone="America/Denver" → timezone_mismatch.
|
||||
"America/Phoenix": "MST7",
|
||||
"America/Anchorage": "AKST9AKDT",
|
||||
# Hawaii does not observe DST.
|
||||
"Pacific/Honolulu": "HST10",
|
||||
}
|
||||
|
||||
|
||||
def _tz_env(timezone: str) -> str:
|
||||
"""Return the value to set in ``TZ`` for the given IANA zone."""
|
||||
return _IANA_TO_POSIX_TZ.get(timezone, timezone)
|
||||
|
||||
|
||||
class InvisiblePlaywright:
|
||||
"""Context manager launching a patched Firefox with a deterministic profile.
|
||||
|
||||
Usage:
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
# random seed (different fingerprint each call)
|
||||
with InvisiblePlaywright() as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
|
||||
# explicit seed → same profile every time
|
||||
with InvisiblePlaywright(seed=42) as browser:
|
||||
...
|
||||
|
||||
# human-like cursor motion (Bezier trajectory on every mousemove)
|
||||
with InvisiblePlaywright(humanize=True) as browser:
|
||||
...
|
||||
|
||||
Optional ``pin`` forces specific fingerprint fields while the rest still
|
||||
varies with ``seed``::
|
||||
|
||||
with InvisiblePlaywright(seed=42, pin={"screen.width": 2560}) as browser:
|
||||
...
|
||||
|
||||
After construction, the chosen seed is available as ``self.seed`` — useful
|
||||
to reproduce a random run later.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: Optional[int] = None,
|
||||
*,
|
||||
pin: Optional[Dict[str, Any]] = None,
|
||||
headless: bool = False,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
extra_args: Optional[list[str]] = None,
|
||||
humanize: Union[bool, float] = True,
|
||||
locale: str = "en-US",
|
||||
timezone: str = "",
|
||||
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||
binary_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
seed: Integer seed driving the Bayesian fingerprint sampler.
|
||||
Same seed → same fingerprint. ``None`` = fresh random.
|
||||
pin: Force specific fingerprint fields (see docs/pinning.md).
|
||||
headless: When ``True``, browser renders on a hidden virtual
|
||||
display (Xvfb on Linux, ``CreateDesktop`` on Windows) so
|
||||
Firefox stays in *headed* mode (real rendering pipeline,
|
||||
coherent fingerprint) without showing windows.
|
||||
proxy: ``{"server": "...", "username": "...", "password": "..."}``.
|
||||
``socks5://`` / ``socks4://`` go through the patched
|
||||
``nsProtocolProxyService``; ``http(s)://`` go through
|
||||
Playwright's own ``proxy=`` kwarg.
|
||||
extra_args: Extra command-line args forwarded to Firefox.
|
||||
humanize: Every mouse move is expanded by the patched Juggler
|
||||
into a Bezier trajectory with ~10 ms between waypoints.
|
||||
Default ``True`` (~1.5 s max motion). ``False`` disables;
|
||||
a float caps the motion in seconds.
|
||||
locale: BCP-47 tag (e.g. ``"en-US"``). Drives the
|
||||
``Accept-Language`` header and ``navigator.language``.
|
||||
timezone: IANA timezone (e.g. ``"America/New_York"``). Empty
|
||||
means use the host TZ.
|
||||
extra_prefs: Optional dict of Firefox prefs overlayed on top
|
||||
of the generated profile — useful for niche tweaks
|
||||
without monkey-patching the package.
|
||||
"""
|
||||
# Constrain to int31 — Firefox's `zoom.stealth.fpp.hw_seed` and
|
||||
# related stealth prefs are declared as ``int32_t`` in
|
||||
# ``StaticPrefList.yaml``. A 32-bit seed risks the high bit being
|
||||
# interpreted as negative on the C++ side, where the noise hooks
|
||||
# bail out on ``seed <= 0`` — which produces bit-identical audio
|
||||
# / canvas fingerprints across half the sessions.
|
||||
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
|
||||
self._pin = pin
|
||||
self._headless = headless
|
||||
self._proxy = proxy
|
||||
self._extra_args = list(extra_args or [])
|
||||
self._humanize = humanize
|
||||
self._locale = locale
|
||||
self._timezone = timezone
|
||||
self._extra_prefs = extra_prefs
|
||||
self._binary_path = binary_path
|
||||
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
|
||||
self._pw: Optional[Playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._virtual_display: Any = None
|
||||
|
||||
def __enter__(self) -> Browser:
|
||||
executable = self._binary_path or ensure_binary()
|
||||
prefs = self._build_prefs()
|
||||
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
|
||||
pw_headless = self._resolve_headless()
|
||||
env = self._build_env()
|
||||
|
||||
try:
|
||||
self._pw = sync_playwright().start()
|
||||
self._browser = self._pw.firefox.launch(
|
||||
executable_path=str(executable),
|
||||
headless=pw_headless,
|
||||
firefox_user_prefs=prefs,
|
||||
proxy=playwright_proxy,
|
||||
args=self._extra_args,
|
||||
env=env,
|
||||
)
|
||||
except BaseException:
|
||||
# Python doesn't call __exit__ when __enter__ raises — clean up
|
||||
# the virtual display + Playwright manually so we don't leak Xvfb
|
||||
# / desktop handles into the user's process.
|
||||
self._teardown()
|
||||
raise
|
||||
self._patch_new_context_defaults(self._browser)
|
||||
return self._browser
|
||||
|
||||
def _patch_new_context_defaults(self, browser: Browser) -> None:
|
||||
"""Wrap ``browser.new_context`` so its defaults derive from the
|
||||
profile (viewport, screen, DPR, color-scheme). Users get a
|
||||
coherent context for free; explicit kwargs still override.
|
||||
"""
|
||||
original = browser.new_context
|
||||
defaults = self._default_context_kwargs()
|
||||
|
||||
def patched(**kw):
|
||||
merged = dict(defaults)
|
||||
merged.update(kw) # user-supplied wins
|
||||
ctx = original(**merged)
|
||||
_patch_sync_new_page_sleep(ctx)
|
||||
return ctx
|
||||
|
||||
browser.new_context = patched # type: ignore[assignment]
|
||||
|
||||
def _default_context_kwargs(self) -> Dict[str, Any]:
|
||||
p = self._profile
|
||||
kwargs: Dict[str, Any] = {
|
||||
"viewport": {"width": p.screen.width - _CHROME_W,
|
||||
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
|
||||
"screen": {"width": p.screen.width, "height": p.screen.height},
|
||||
"device_scale_factor": p.screen.dpr,
|
||||
"color_scheme": "dark" if p.dark_theme else "light",
|
||||
}
|
||||
# Pass timezone via Playwright's per-realm override (docShell.overrideTimezone
|
||||
# → JS::SetRealmTimezoneOverride). The juggler.timezone.override pref path
|
||||
# uses JS::SetTimeZoneOverride globally, which is broken on Windows ICU for
|
||||
# no-DST IANA names (America/Phoenix, Pacific/Honolulu, ...) — those silently
|
||||
# fall back to the host system TZ. The per-realm path works for every zone.
|
||||
if self._timezone:
|
||||
kwargs["timezone_id"] = self._timezone
|
||||
if self._locale:
|
||||
kwargs["locale"] = self._locale
|
||||
return kwargs
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self._teardown()
|
||||
|
||||
def _teardown(self) -> None:
|
||||
if self._browser is not None:
|
||||
try:
|
||||
self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._pw is not None:
|
||||
try:
|
||||
self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._pw = None
|
||||
if self._virtual_display is not None:
|
||||
try:
|
||||
self._virtual_display.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._virtual_display = None
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_prefs(self) -> Dict[str, Any]:
|
||||
"""Fingerprint prefs plus humanize toggle (always set explicitly)."""
|
||||
import sys as _sys
|
||||
prefs = translate_profile_to_prefs(
|
||||
self._profile,
|
||||
locale=self._locale,
|
||||
timezone=self._timezone,
|
||||
extra_prefs=self._extra_prefs,
|
||||
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
||||
)
|
||||
prefs["invisible_playwright.humanize"] = bool(self._humanize)
|
||||
if self._humanize:
|
||||
prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds())
|
||||
return prefs
|
||||
|
||||
def _build_env(self) -> Dict[str, str]:
|
||||
"""Env vars passed to the Firefox subprocess.
|
||||
|
||||
``TZ`` tunes the libc clock the content process reads for
|
||||
``Date`` / ``Intl.DateTimeFormat`` so the JS-visible timezone
|
||||
matches ``self._timezone`` regardless of the host TZ.
|
||||
``STEALTHFOX_WEBRTC_PUBLIC_IP`` is propagated when the calling
|
||||
process has set it — read by nICEr's nr_stealth_bridge to inject
|
||||
a synthetic srflx candidate matching the proxy egress IP, avoiding
|
||||
the StaticPref IPC propagation timing issue between parent and
|
||||
socket processes.
|
||||
"""
|
||||
import os as _os
|
||||
env = _os.environ.copy()
|
||||
if self._timezone:
|
||||
env["TZ"] = _tz_env(self._timezone)
|
||||
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
|
||||
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
|
||||
# matching the proxy egress IP. This avoids the StaticPref IPC
|
||||
# propagation timing issue between parent and socket processes.
|
||||
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
|
||||
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
|
||||
return env
|
||||
|
||||
def _resolve_headless(self) -> bool:
|
||||
"""Translate the user's ``headless`` flag.
|
||||
|
||||
When ``True``, we keep Firefox in headed mode (real rendering
|
||||
pipeline → coherent fingerprint) and hide the windows on a fresh
|
||||
Xvfb (Linux) or hidden Windows desktop.
|
||||
"""
|
||||
if not self._headless:
|
||||
return False
|
||||
vd = make_virtual_display()
|
||||
vd.start()
|
||||
self._virtual_display = vd
|
||||
return False
|
||||
|
||||
def _humanize_max_seconds(self) -> float:
|
||||
if self._humanize is True:
|
||||
return 1.5
|
||||
return float(self._humanize)
|
||||
|
||||
568
src/invisible_playwright/prefs.py
Normal file
568
src/invisible_playwright/prefs.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
"""Translate an internal Profile into the Firefox prefs dict that the
|
||||
patched Firefox binary expects.
|
||||
|
||||
The output dict keys map 1:1 to ``user.js`` preferences. Playwright passes
|
||||
them via ``firefox_user_prefs=``. The patched binary propagates them to all
|
||||
content processes over IPC; C++ patches read the ``zoom.stealth.*``
|
||||
namespace.
|
||||
|
||||
The translation is split into:
|
||||
|
||||
* ``_BASELINE`` — global stealth policy (RFP off, WebRTC leaks blocked,
|
||||
safebrowsing disabled, debugger detach, …) plus Windows-canonical
|
||||
constants that don't depend on the Profile (system colors palette,
|
||||
WebGL extensions whitelist, speech voices, navigator identity).
|
||||
* ``translate_profile_to_prefs`` — overlays the Profile fields plus the
|
||||
user-supplied ``locale`` and ``timezone``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ._fpforge import Profile
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Navigator identity — locked to Firefox 150 Windows so the binary
|
||||
# reports the same UA / platform / oscpu regardless of the host OS.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_NAVIGATOR_OVERRIDES: Dict[str, str] = {
|
||||
"general.useragent.override":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
|
||||
"Gecko/20100101 Firefox/150.0.1",
|
||||
"general.platform.override": "Win32",
|
||||
"general.oscpu.override": "Windows NT 10.0; Win64; x64",
|
||||
# general.buildID.override removed 2026-04-28: the previous value
|
||||
# "20181001000000" was a 2018 buildID stuck on a 2026-built Firefox 150
|
||||
# binary (real BuildID=20260426192818 from application.ini). The 7.5-yr
|
||||
# discrepancy is the kind of internal-consistency check Google reCAPTCHA
|
||||
# can use to flag bot/spoofed browsers. Deleting the override lets
|
||||
# Firefox emit its compiled-in buildID, which auto-tracks the binary.
|
||||
# A/B knockout 2026-04-28 (n=30): F2 delete +0.083 RC vs BASE; n=100
|
||||
# confirm: +0.021; overnight isolated: +0.155 single-variant. Variable
|
||||
# signal, but the underlying data error is unambiguous.
|
||||
"general.appversion.override": "5.0 (Windows)",
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# System colors — FP Pro probes getComputedStyle(div) with CSS system
|
||||
# keywords (ButtonFace, Menu, Highlight, …) and hashes the result into
|
||||
# signal s142. On Linux, Firefox resolves these via GTK theme → GTK
|
||||
# RGB values diverge from Windows Win32 palette → server-side anomaly
|
||||
# even with Windows UA. Pinning the palette to Win10 default closes
|
||||
# the gap (see project_css_system_colors.md memory).
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_WIN_LIGHT_COLORS: Dict[str, str] = {
|
||||
"ui.activeborder": "#B4B4B4",
|
||||
"ui.activecaption": "#99B4D1",
|
||||
"ui.appworkspace": "#ABABAB",
|
||||
"ui.background": "#000000",
|
||||
"ui.buttonface": "#F0F0F0",
|
||||
"ui.buttonhighlight": "#FFFFFF",
|
||||
"ui.buttonshadow": "#A0A0A0",
|
||||
"ui.buttontext": "#000000",
|
||||
"ui.buttonborder": "#000000",
|
||||
"ui.captiontext": "#000000",
|
||||
"ui.graytext": "#6D6D6D",
|
||||
"ui.highlight": "#0078D7",
|
||||
"ui.highlighttext": "#FFFFFF",
|
||||
"ui.inactiveborder": "#F4F7FC",
|
||||
"ui.inactivecaption": "#BFCDDB",
|
||||
"ui.inactivecaptiontext": "#434E54",
|
||||
"ui.infobackground": "#FFFFE1",
|
||||
"ui.infotext": "#000000",
|
||||
"ui.menu": "#F9F9FB",
|
||||
"ui.menutext": "#000000",
|
||||
"ui.scrollbar": "#C8C8C8",
|
||||
"ui.threeddarkshadow": "#696969",
|
||||
"ui.threedface": "#F0F0F0",
|
||||
"ui.threedhighlight": "#FFFFFF",
|
||||
"ui.threedlightshadow": "#E3E3E3",
|
||||
"ui.threedshadow": "#A0A0A0",
|
||||
"ui.window": "#FFFFFF",
|
||||
"ui.windowframe": "#646464",
|
||||
"ui.windowtext": "#000000",
|
||||
"ui.mark": "#FFFF00",
|
||||
"ui.marktext": "#000000",
|
||||
"ui.accentcolor": "#0078D4",
|
||||
"ui.accentcolortext": "#FFFFFF",
|
||||
"ui.selecteditem": "#0078D7",
|
||||
"ui.selecteditemtext": "#FFFFFF",
|
||||
"ui.-moz-hyperlinktext": "#0066CC",
|
||||
"ui.-moz-activehyperlinktext": "#EE0000",
|
||||
"ui.-moz-visitedhyperlinktext": "#551A8B",
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# WebGL extensions — Windows ANGLE canonical lists. Empty string =
|
||||
# fall back to native Mesa/ANGLE; non-empty = `getSupportedExtensions`
|
||||
# returns this list verbatim and `IsSupported()` rejects anything else.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_WEBGL1_EXTENSIONS = ",".join([
|
||||
"ANGLE_instanced_arrays",
|
||||
"EXT_blend_minmax",
|
||||
"EXT_color_buffer_half_float",
|
||||
"EXT_float_blend",
|
||||
"EXT_frag_depth",
|
||||
"EXT_sRGB",
|
||||
"EXT_shader_texture_lod",
|
||||
"EXT_texture_compression_bptc",
|
||||
"EXT_texture_compression_rgtc",
|
||||
"EXT_texture_filter_anisotropic",
|
||||
"OES_element_index_uint",
|
||||
"OES_fbo_render_mipmap",
|
||||
"OES_standard_derivatives",
|
||||
"OES_texture_float",
|
||||
"OES_texture_float_linear",
|
||||
"OES_texture_half_float",
|
||||
"OES_texture_half_float_linear",
|
||||
"OES_vertex_array_object",
|
||||
"WEBGL_color_buffer_float",
|
||||
"WEBGL_compressed_texture_s3tc",
|
||||
"WEBGL_compressed_texture_s3tc_srgb",
|
||||
"WEBGL_debug_renderer_info",
|
||||
"WEBGL_debug_shaders",
|
||||
"WEBGL_depth_texture",
|
||||
"WEBGL_draw_buffers",
|
||||
"WEBGL_lose_context",
|
||||
"WEBGL_provoking_vertex",
|
||||
])
|
||||
|
||||
_WEBGL2_EXTENSIONS = ",".join([
|
||||
"EXT_color_buffer_float",
|
||||
"EXT_color_buffer_half_float",
|
||||
"EXT_float_blend",
|
||||
"EXT_texture_compression_bptc",
|
||||
"EXT_texture_compression_rgtc",
|
||||
"EXT_texture_filter_anisotropic",
|
||||
"OES_draw_buffers_indexed",
|
||||
"OES_texture_float_linear",
|
||||
"OES_texture_half_float_linear",
|
||||
"OVR_multiview2",
|
||||
"WEBGL_compressed_texture_s3tc",
|
||||
"WEBGL_compressed_texture_s3tc_srgb",
|
||||
"WEBGL_debug_renderer_info",
|
||||
"WEBGL_debug_shaders",
|
||||
"WEBGL_lose_context",
|
||||
"WEBGL_provoking_vertex",
|
||||
])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Speech voices — Windows canonical "Microsoft *" set. Format:
|
||||
# "NAME|LANG|DEFAULT|LOCAL,...". Non-empty value drives the
|
||||
# speechSynthesis.getVoices() patch; empty disables it.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_WIN_VOICES = ",".join([
|
||||
"Microsoft David - English (United States)|en-US|1|1",
|
||||
"Microsoft Zira - English (United States)|en-US|0|1",
|
||||
"Microsoft Mark - English (United States)|en-US|0|1",
|
||||
"Microsoft David Desktop - English (United States)|en-US|0|1",
|
||||
"Microsoft Zira Desktop - English (United States)|en-US|0|1",
|
||||
])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Linux font compensation — Linux Firefox uses DejaVu / Liberation
|
||||
# fonts which have wider/narrower glyphs than Windows Arial / Segoe.
|
||||
# These per-generic factors are prepended to ``zoom.stealth.font.metrics``
|
||||
# on Linux only; Windows-native rendering already matches the canonical
|
||||
# widths so we pass an empty string (any factor !=1 would distort real
|
||||
# metrics).
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_LINUX_GENERIC_FONT_FACTORS = (
|
||||
# Calibrated to bring DejaVu/Liberation widths in line with what Windows
|
||||
# FP Pro probes report for native Segoe/Times. Linux base measurements
|
||||
# (font_preferences) and Windows targets:
|
||||
# serif: 162 → 149 factor 0.920
|
||||
# sans: 162 → 144 factor 0.889
|
||||
# monospace:121 → 121 factor 1.000
|
||||
# system: 162 → 147 factor 0.910
|
||||
"serif|0.920,sans-serif|0.889,monospace|1.000,"
|
||||
"system-ui|0.910,cursive|0.932,fantasy|0.812,"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Baseline — applied to every session regardless of Profile.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_BASELINE: Dict[str, Any] = {
|
||||
# Turn off Firefox's own resistFingerprinting; we do our own via patches.
|
||||
"privacy.resistFingerprinting": False,
|
||||
"privacy.resistFingerprinting.letterboxing": False,
|
||||
|
||||
# FF150 fingerprintingProtection — enabled by default (or remotely via
|
||||
# Mozilla webcompat overrides). FP Pro detects the side-effects and
|
||||
# flips `privacy_settings: true`. On FF146 these were all off → False.
|
||||
# Force off so FP Pro reports privacy_settings:false (matches FF146).
|
||||
"privacy.fingerprintingProtection": False,
|
||||
"privacy.fingerprintingProtection.pbmode": False,
|
||||
"privacy.fingerprintingProtection.remoteOverrides.enabled": False,
|
||||
|
||||
# WebRTC: enabled, no public IP leak.
|
||||
# obfuscate_host_addresses=false: our C++ injection handles candidate
|
||||
# selection; mDNS causes mDNS-IPC to hang in sandboxed content processes.
|
||||
# disableIPv6=true keeps IPv6 out of gathering (less entropy, no IPv6 leak).
|
||||
"media.peerconnection.enabled": True,
|
||||
"media.peerconnection.ice.no_host": False,
|
||||
"media.peerconnection.ice.default_address_only": False,
|
||||
"media.peerconnection.ice.obfuscate_host_addresses": False,
|
||||
"media.peerconnection.ice.disableIPv6": True,
|
||||
"media.peerconnection.ice.proxy_only": False,
|
||||
"media.peerconnection.ice.relay_only": False,
|
||||
"media.peerconnection.use_document_iceservers": True,
|
||||
|
||||
# Proxy — route DNS through SOCKS proxies to avoid local DNS leaks.
|
||||
"network.proxy.socks_remote_dns": True,
|
||||
"network.proxy.failover_direct": False,
|
||||
|
||||
# Safebrowsing — chatty and fingerprintable.
|
||||
"browser.safebrowsing.malware.enabled": False,
|
||||
"browser.safebrowsing.phishing.enabled": False,
|
||||
"browser.safebrowsing.downloads.enabled": False,
|
||||
"browser.safebrowsing.downloads.remote.enabled": False,
|
||||
|
||||
# First-run / welcome UI noise.
|
||||
"browser.startup.page": 0,
|
||||
"browser.shell.checkDefaultBrowser": False,
|
||||
"browser.aboutwelcome.enabled": False,
|
||||
"browser.startup.upgradeDialog.enabled": False,
|
||||
"termsofuse.acceptedVersion": 999,
|
||||
|
||||
# Disable about:newtab auto-load — TopSitesFeed.sys.mjs auto-fetches when
|
||||
# a tab opens, triggering a cross-process BC swap that hijacks the first
|
||||
# page.goto() (NS_BINDING_ABORTED on creepjs/peet/sannysoft/fppro).
|
||||
"browser.newtabpage.enabled": False,
|
||||
"browser.newtab.preload": False,
|
||||
"browser.newtabpage.activity-stream.feeds.topsites": False,
|
||||
"browser.newtabpage.activity-stream.feeds.section.topstories": False,
|
||||
"browser.newtabpage.activity-stream.enabled": False,
|
||||
|
||||
# Disable Firefox internal services that hit the network on startup.
|
||||
# Through a residential SOCKS5 proxy these compete with the test
|
||||
# navigation and trigger NS_BINDING_FAILED (server-side rate-limit /
|
||||
# connection drops). Domains observed in MOZ_LOG: push.services,
|
||||
# firefox.settings.services, detectportal, ohttp-gateway, location.
|
||||
"browser.aboutConfig.showWarning": False,
|
||||
"network.captive-portal-service.enabled": False,
|
||||
"network.connectivity-service.enabled": False,
|
||||
"dom.push.enabled": False,
|
||||
"dom.push.connection.enabled": False,
|
||||
"geo.enabled": False,
|
||||
"geo.provider.network.url": "",
|
||||
"browser.region.network.url": "",
|
||||
"browser.region.update.enabled": False,
|
||||
"services.settings.server": "",
|
||||
"browser.search.geoSpecificDefaults": False,
|
||||
"browser.contentblocking.report.lockwise.enabled": False,
|
||||
"browser.contentblocking.report.monitor.enabled": False,
|
||||
"extensions.systemAddon.update.enabled": False,
|
||||
"extensions.update.enabled": False,
|
||||
"extensions.getAddons.cache.enabled": False,
|
||||
"browser.discovery.enabled": False,
|
||||
"browser.ping-centre.telemetry": False,
|
||||
"app.normandy.enabled": False,
|
||||
"dom.private-attribution.submission.enabled": False,
|
||||
"browser.translations.enable": False,
|
||||
"browser.search.update": False,
|
||||
|
||||
# HTTP/3 + speculative + Alt-Svc disabled. SOCKS5 proxy doesn't
|
||||
# support UDP ASSOCIATE so HTTP/3 fails. Speculative connections
|
||||
# under load cause early channel cancel (NS_BINDING_FAILED).
|
||||
"network.http.http3.enable": False,
|
||||
"network.http.http3.enabled": False,
|
||||
"network.http.altsvc.enabled": False,
|
||||
"network.http.altsvc.oe": False,
|
||||
"network.http.speculative-parallel-limit": 0,
|
||||
"network.predictor.enabled": False,
|
||||
"network.dns.disablePrefetch": True,
|
||||
"network.dns.disablePrefetchFromHTTPS": True,
|
||||
"network.dns.echconfig.enabled": False,
|
||||
"network.dns.use_https_rr_as_altsvc": False,
|
||||
|
||||
# === A/B VARIANT B: Fission disabled ===
|
||||
# Force single content-process model (e10s only, no BC outer/inner split).
|
||||
# Diagnostic for the FF150 BC-swap theory: if peet_ws/fppro/sannysoft
|
||||
# work with this off, the Juggler FF146 baseline breaks specifically on
|
||||
# cross-process navigation tracking.
|
||||
"fission.autostart": False,
|
||||
"fission.autostart.session": False,
|
||||
"dom.ipc.processCount.webIsolated": 1,
|
||||
|
||||
|
||||
# Telemetry & data reporting.
|
||||
"datareporting.healthreport.uploadEnabled": False,
|
||||
"datareporting.policy.dataSubmissionEnabled": False,
|
||||
"toolkit.telemetry.enabled": False,
|
||||
"toolkit.telemetry.unified": False,
|
||||
"app.shield.optoutstudies.enabled": False,
|
||||
|
||||
# Update channels.
|
||||
"app.update.enabled": False,
|
||||
"app.update.auto": False,
|
||||
|
||||
# Speech synth: enabled (the C++ patch fabricates voices from the
|
||||
# comma list above) regardless of the host OS.
|
||||
"media.webspeech.synth.enabled": True,
|
||||
"zoom.stealth.voices.list": _WIN_VOICES,
|
||||
|
||||
# WebGL extensions whitelist — non-empty pre-empts native enumeration.
|
||||
"zoom.stealth.webgl.extensions": _WEBGL1_EXTENSIONS,
|
||||
"zoom.stealth.webgl2.extensions": _WEBGL2_EXTENSIONS,
|
||||
# WebGL numeric param overrides — kept empty (A/B test 2026-04-22 showed
|
||||
# mismatches between the values we shipped and ANGLE's real envelope
|
||||
# raised FP Pro's ML tampering score). Slot kept for future experiments.
|
||||
"zoom.stealth.webgl.int_params": "",
|
||||
"zoom.stealth.webgl.int2_params": "",
|
||||
"zoom.stealth.webgl.shader_precisions": "",
|
||||
"zoom.stealth.webgl.float_params": "",
|
||||
|
||||
# DevTools anti-detection.
|
||||
"zoom.stealth.debugger.force_detach": True,
|
||||
|
||||
# Canvas substitution — additive ±1 noise over the OS base pattern;
|
||||
# set to True to replace pixels with hash(seed, idx) instead.
|
||||
"zoom.stealth.canvas.substitute_pixels": False,
|
||||
|
||||
# Navigator identity (locked to Windows Firefox 150).
|
||||
**_NAVIGATOR_OVERRIDES,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Linux-only Xvfb workarounds — the Linux Firefox build under Xvfb
|
||||
# cannot run WebRender (`ConnectToCompositor` retries forever). We
|
||||
# disable WebRender + force WebGL through the GL software path so
|
||||
# webgl_basics / webgl_extensions still report.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_LINUX_XVFB_WORKAROUNDS: Dict[str, Any] = {
|
||||
"gfx.webrender.all": False,
|
||||
"gfx.webrender.force-disabled": True,
|
||||
"webgl.force-enabled": True,
|
||||
# webgl.software-rendering-enabled / webgl.force-layers-readback removed in FF150.
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Windows virtual-desktop workarounds — when headless=True on Windows,
|
||||
# Firefox runs on a CreateDesktop virtual desktop. The hardware GPU is
|
||||
# inaccessible from the virtual desktop, so the GPU process crashes when
|
||||
# it tries to initialize the D3D11 compositor with hardware acceleration.
|
||||
#
|
||||
# Approach: force D3D11 WARP (CPU software renderer) for the GPU process.
|
||||
# layers.d3d11.force-warp=True → compositor uses WARP → GPU process stable.
|
||||
# webgl.angle.force-warp=True → ANGLE uses WARP → WebGL context creates.
|
||||
#
|
||||
# CRITICAL: do NOT set webgl.out-of-process=False. That moves WebGL from the
|
||||
# GPU process to the sandboxed content process. The content process sandbox
|
||||
# blocks D3D11 access entirely → ANGLE crashes the content process →
|
||||
# canvas.getContext('webgl') throws instead of returning null.
|
||||
#
|
||||
# gfx.canvas.accelerated=False: default is true, disabling avoids any
|
||||
# hardware GPU dependency for 2D canvas in the content process.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_WIN_VIRT_DESKTOP_WORKAROUNDS: Dict[str, Any] = {
|
||||
# FF150 regression vs FF146 on CreateDesktop alt-desktop:
|
||||
# The GPU process sandbox (level=1, default since FF110) tries to parent
|
||||
# its compositor window to the parent process's window. Our worker spawns
|
||||
# Firefox on a CreateDesktop-created alt desktop — parent and GPU process
|
||||
# do not share the same desktop/HWND namespace, so window parenting fails
|
||||
# silently. WebRender falls back to "Software D3D11" and OOP-WebGL never
|
||||
# publishes a hardware ANGLE renderer → getContext('webgl') returns a
|
||||
# context but extensions/parameters/$hash all come back null/empty (FF146
|
||||
# had a more permissive sandbox, so the same setup worked there).
|
||||
# Bugzilla refs: 1798091, 1524591, 1229829. Lowering the GPU sandbox to 0
|
||||
# restores hardware compositor + functional WebGL on alt desktops.
|
||||
"security.sandbox.gpu.level": 0,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Public helpers
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _accept_language(locale: str) -> str:
|
||||
lang = locale.replace("_", "-")
|
||||
base = lang.split("-")[0]
|
||||
return f"{lang}, {base}" if base != lang else lang
|
||||
|
||||
|
||||
def _font_metrics_for_platform(profile_metrics: str) -> str:
|
||||
"""Return ``zoom.stealth.font.metrics`` value.
|
||||
|
||||
Windows: empty string. The C++ width-scale hook is a no-op and
|
||||
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical
|
||||
widths. Applying the Bayesian-sampled per-font factors on a Windows
|
||||
build would *distort* real metrics and surface as a font_preferences
|
||||
width anomaly to FP Pro / reCAPTCHA.
|
||||
|
||||
Linux: prepend generic-family compensation factors so DejaVu /
|
||||
Liberation render at the widths Windows JS expects, then append the
|
||||
per-font factors that make each fabricated family detectable by
|
||||
width-diff probes.
|
||||
"""
|
||||
if not profile_metrics:
|
||||
return ""
|
||||
if sys.platform.startswith("linux"):
|
||||
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
|
||||
return "" # Windows: NEVER apply width-scale factors.
|
||||
|
||||
|
||||
def translate_profile_to_prefs(
|
||||
profile: Profile,
|
||||
*,
|
||||
locale: str = "en-US",
|
||||
timezone: str = "",
|
||||
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||
virtual_display: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a complete prefs dict ready for Playwright's firefox_user_prefs=.
|
||||
|
||||
Args:
|
||||
profile: Bayesian-sampled fingerprint (from ``generate_profile``).
|
||||
locale: BCP-47 tag, e.g. ``"en-US"``.
|
||||
timezone: IANA timezone name, e.g. ``"America/New_York"``.
|
||||
extra_prefs: Optional overlay applied LAST.
|
||||
virtual_display: When True on Windows, apply GPU-disabling workarounds
|
||||
to prevent the GPU process from crashing on virtual
|
||||
desktops that have no D3D11 backend.
|
||||
"""
|
||||
prefs: Dict[str, Any] = dict(_BASELINE)
|
||||
|
||||
# GPU / WebGL renderer/vendor.
|
||||
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
|
||||
# so cross-platform sessions report a consistent Windows GPU identity.
|
||||
# On Windows, spoofing a different GPU creates a renderer/parameters hash
|
||||
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including
|
||||
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750
|
||||
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro
|
||||
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine).
|
||||
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware
|
||||
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent.
|
||||
if sys.platform.startswith("linux"):
|
||||
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
|
||||
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
|
||||
_renderer_lo = (profile.gpu.renderer or "").lower()
|
||||
else:
|
||||
prefs["zoom.stealth.webgl.renderer"] = ""
|
||||
prefs["zoom.stealth.webgl.vendor"] = ""
|
||||
_renderer_lo = "intel" # test hardware is Intel Arc A750
|
||||
|
||||
# MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is
|
||||
# constant across all sessions. Different MSAA values cause different CN-set
|
||||
# parameters hashes even with the same renderer → detectable variation.
|
||||
# Vanilla Intel Arc A750 parameters hash (66544db8) verified at msaa=4.
|
||||
_msaa = profile.webgl.msaa_samples if sys.platform.startswith("linux") else 4
|
||||
prefs["zoom.stealth.webgl.msaa"] = _msaa
|
||||
prefs["webgl.msaa-samples"] = _msaa
|
||||
prefs["webgl.msaa-force"] = _msaa > 0
|
||||
|
||||
# Canvas pixel-noise density per vendor. Intel has lower natural
|
||||
# rendering variance than NVIDIA/AMD, so the default 1/8 noise rate
|
||||
# over-amplifies the FP Pro tampering ML signal. Drop to 1/16 for Intel
|
||||
# to keep tampering_ml below the detection threshold while still
|
||||
# breaking the canvas geometry hash.
|
||||
if "intel" in _renderer_lo:
|
||||
prefs["zoom.stealth.canvas.noise_skip_mask"] = 15 # 1/16, ~6.25%
|
||||
else:
|
||||
prefs["zoom.stealth.canvas.noise_skip_mask"] = 7 # 1/8, ~12.5%
|
||||
|
||||
# Screen
|
||||
prefs["zoom.stealth.screen.width"] = profile.screen.width
|
||||
prefs["zoom.stealth.screen.height"] = profile.screen.height
|
||||
prefs["zoom.stealth.screen.avail_width"] = profile.screen.avail_width
|
||||
prefs["zoom.stealth.screen.avail_height"] = profile.screen.avail_height
|
||||
prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr
|
||||
prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr)
|
||||
|
||||
# Hardware
|
||||
prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency
|
||||
prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb
|
||||
|
||||
# Audio
|
||||
prefs["zoom.stealth.audio.sample_rate"] = profile.audio.sample_rate
|
||||
prefs["zoom.stealth.audio.output_latency_ms"] = profile.audio.output_latency_ms
|
||||
prefs["zoom.stealth.audio.max_channel_count"] = profile.audio.max_channel_count
|
||||
|
||||
# Codec
|
||||
prefs["media.av1.enabled"] = profile.codec.av1_enabled
|
||||
prefs["media.encoder.webm.enabled"] = profile.codec.webm_encoder_enabled
|
||||
prefs["media.mediasource.webm.enabled"] = profile.codec.mediasource_webm
|
||||
prefs["media.mediasource.mp4.enabled"] = profile.codec.mediasource_mp4
|
||||
|
||||
# Fonts
|
||||
prefs["zoom.stealth.font.whitelist"] = ",".join(profile.fonts)
|
||||
prefs["zoom.stealth.font.metrics"] = _font_metrics_for_platform(
|
||||
profile._raw.get("font_metrics", "") or ""
|
||||
)
|
||||
|
||||
# UI / dark mode + Windows colors palette (only when light theme).
|
||||
prefs["ui.systemUsesDarkTheme"] = int(profile.dark_theme)
|
||||
if not profile.dark_theme:
|
||||
prefs.update(_WIN_LIGHT_COLORS)
|
||||
|
||||
# Locale prefs.
|
||||
locale = locale or "en-US"
|
||||
lang = locale.replace("_", "-")
|
||||
prefs["intl.accept_languages"] = _accept_language(locale)
|
||||
prefs["general.useragent.locale"] = lang
|
||||
prefs["intl.locale.requested"] = lang
|
||||
prefs["privacy.spoof_english"] = 0
|
||||
|
||||
if timezone:
|
||||
prefs["zoom.stealth.timezone"] = timezone
|
||||
prefs["juggler.timezone.override"] = timezone
|
||||
|
||||
# Cross-process seed (canvas noise + DWrite gamma share this).
|
||||
prefs["zoom.stealth.fpp.hw_seed"] = profile.seed
|
||||
prefs["zoom.stealth.seed"] = profile.seed
|
||||
|
||||
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
|
||||
# proxy suppresses all local addresses so Firefox can't gather host cands).
|
||||
# LAN IP is seed-derived so it's consistent per session and looks like a
|
||||
# real home router assignment (192.168.x.x range).
|
||||
_s = profile.seed
|
||||
_lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}"
|
||||
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
|
||||
|
||||
# On Windows, native ANGLE extension list already matches real Windows users.
|
||||
# The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for
|
||||
# Linux Mesa → clear it so Windows sessions report the native extension set
|
||||
# (hash matches real Intel Arc A750 vanilla captures).
|
||||
if not sys.platform.startswith("linux"):
|
||||
prefs["zoom.stealth.webgl.extensions"] = ""
|
||||
prefs["zoom.stealth.webgl2.extensions"] = ""
|
||||
|
||||
# Linux Xvfb workarounds (no-op on Windows).
|
||||
if sys.platform.startswith("linux"):
|
||||
for k, v in _LINUX_XVFB_WORKAROUNDS.items():
|
||||
prefs.setdefault(k, v)
|
||||
|
||||
# Windows virtual-desktop workarounds (headless=True on Windows).
|
||||
if virtual_display and sys.platform == "win32":
|
||||
for k, v in _WIN_VIRT_DESKTOP_WORKAROUNDS.items():
|
||||
prefs.setdefault(k, v)
|
||||
|
||||
# Caller overlay LAST so users can override anything we set. A value of
|
||||
# None is treated as a sentinel meaning "delete this pref entirely from
|
||||
# the final dict" — useful for A/B harnesses that need to test what
|
||||
# happens when an override is unset (vs set to empty string, which for
|
||||
# some prefs like general.useragent.override means literally empty UA).
|
||||
if extra_prefs:
|
||||
for k, v in extra_prefs.items():
|
||||
if v is None:
|
||||
prefs.pop(k, None)
|
||||
else:
|
||||
prefs[k] = v
|
||||
|
||||
return prefs
|
||||
4
src/invisible_playwright/sync_api.py
Normal file
4
src/invisible_playwright/sync_api.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Synchronous API — re-exports InvisiblePlaywright for parity with async_api."""
|
||||
from .launcher import InvisiblePlaywright
|
||||
|
||||
__all__ = ["InvisiblePlaywright"]
|
||||
22
tests/test_cli.py
Normal file
22
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_version_subcommand():
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "invisible-playwright", "version"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert "firefox-" in r.stdout
|
||||
assert "invisible-playwright" in r.stdout.lower()
|
||||
|
||||
|
||||
def test_help_subcommand():
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "invisible-playwright", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert "fetch" in r.stdout
|
||||
assert "path" in r.stdout
|
||||
assert "clear-cache" in r.stdout
|
||||
29
tests/test_constants.py
Normal file
29
tests/test_constants.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from invisible_playwright.constants import BINARY_VERSION, BINARY_BASENAME, ARCHIVE_NAME
|
||||
|
||||
|
||||
def test_binary_version_format():
|
||||
assert BINARY_VERSION.startswith("firefox-")
|
||||
assert BINARY_VERSION.split("-", 1)[1].isdigit()
|
||||
|
||||
|
||||
def test_archive_name_windows():
|
||||
name = ARCHIVE_NAME("win32", "AMD64")
|
||||
assert name.endswith(".zip")
|
||||
assert "win-x86_64" in name
|
||||
|
||||
|
||||
def test_archive_name_linux():
|
||||
name = ARCHIVE_NAME("linux", "x86_64")
|
||||
assert name.endswith(".tar.gz")
|
||||
assert "linux-x86_64" in name
|
||||
|
||||
|
||||
def test_archive_name_unsupported_raises():
|
||||
import pytest
|
||||
with pytest.raises(NotImplementedError):
|
||||
ARCHIVE_NAME("darwin", "arm64")
|
||||
|
||||
|
||||
def test_binary_basename_format():
|
||||
assert "firefox" in BINARY_BASENAME.lower()
|
||||
assert "stealth" in BINARY_BASENAME.lower()
|
||||
71
tests/test_download.py
Normal file
71
tests/test_download.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from invisible_playwright.download import ensure_binary
|
||||
from invisible_playwright.constants import BINARY_VERSION
|
||||
|
||||
|
||||
def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
|
||||
import io
|
||||
import zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(inner_name, payload)
|
||||
data = buf.getvalue()
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
|
||||
"""Full path: cache miss -> HTTP GET -> SHA256 check -> extract -> return path."""
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
|
||||
archive_path = tmp_path / "archive.zip"
|
||||
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("win32", "AMD64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
|
||||
content_type="application/zip")
|
||||
responses.add(responses.GET, url_sums,
|
||||
body=f"{archive_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
||||
|
||||
path = ensure_binary()
|
||||
assert Path(path).exists()
|
||||
assert Path(path).name == "firefox.exe"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
||||
archive_path = tmp_path / "archive.zip"
|
||||
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
|
||||
wrong_sha = "0" * 64
|
||||
from invisible_playwright.constants import ARCHIVE_NAME
|
||||
asset = ARCHIVE_NAME("win32", "AMD64")
|
||||
|
||||
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
|
||||
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
|
||||
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
|
||||
responses.add(responses.GET, url_sums, body=f"{wrong_sha} {asset}\n", status=200)
|
||||
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
import platform
|
||||
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SHA256"):
|
||||
ensure_binary()
|
||||
35
tests/test_prefs.py
Normal file
35
tests/test_prefs.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from invisible_playwright._fpforge import generate_profile
|
||||
from invisible_playwright.prefs import translate_profile_to_prefs
|
||||
|
||||
|
||||
def test_translate_includes_gpu_renderer():
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
|
||||
|
||||
|
||||
def test_translate_includes_screen():
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.screen.width"] == p.screen.width
|
||||
assert prefs["zoom.stealth.screen.height"] == p.screen.height
|
||||
|
||||
|
||||
def test_translate_is_deterministic_per_seed():
|
||||
a = translate_profile_to_prefs(generate_profile(seed=42))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=42))
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_translate_varies_across_seeds():
|
||||
a = translate_profile_to_prefs(generate_profile(seed=1))
|
||||
b = translate_profile_to_prefs(generate_profile(seed=2))
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_translate_has_stealth_baseline_constants():
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs.get("privacy.resistFingerprinting") is False
|
||||
assert "media.peerconnection.enabled" in prefs
|
||||
Loading…
Add table
Add a link
Reference in a new issue