phasmid-profile-gen 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. phasmid_profile_gen-0.1.1/.gitignore +9 -0
  2. phasmid_profile_gen-0.1.1/.pre-commit-config.yaml +8 -0
  3. phasmid_profile_gen-0.1.1/.python-version +1 -0
  4. phasmid_profile_gen-0.1.1/.ruff.toml +63 -0
  5. phasmid_profile_gen-0.1.1/CHANGELOG.md +34 -0
  6. phasmid_profile_gen-0.1.1/LICENSE +30 -0
  7. phasmid_profile_gen-0.1.1/PKG-INFO +219 -0
  8. phasmid_profile_gen-0.1.1/README.md +192 -0
  9. phasmid_profile_gen-0.1.1/pyproject.toml +62 -0
  10. phasmid_profile_gen-0.1.1/scripts/build_data.py +489 -0
  11. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/__init__.py +165 -0
  12. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/__main__.py +5 -0
  13. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/_data.py +25 -0
  14. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/_rng.py +17 -0
  15. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/captures.py +78 -0
  16. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/cli.py +168 -0
  17. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/CLDR-LICENSE +41 -0
  18. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/LICENSES.md +16 -0
  19. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/captures.json.xz +0 -0
  20. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/dbip-city-lite.json +11 -0
  21. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/locale.json.xz +0 -0
  22. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/manifest.json +148 -0
  23. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/data/schema.json.xz +0 -0
  24. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/generate.py +205 -0
  25. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/geoip.py +194 -0
  26. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/locales.py +125 -0
  27. phasmid_profile_gen-0.1.1/src/phasmid_profile_gen/location.py +218 -0
  28. phasmid_profile_gen-0.1.1/tests/conftest.py +46 -0
  29. phasmid_profile_gen-0.1.1/tests/fixtures/geoip-rows.json +30 -0
  30. phasmid_profile_gen-0.1.1/tests/test_build_data.py +176 -0
  31. phasmid_profile_gen-0.1.1/tests/test_data.py +83 -0
  32. phasmid_profile_gen-0.1.1/tests/test_generate.py +230 -0
  33. phasmid_profile_gen-0.1.1/tests/test_location.py +332 -0
  34. phasmid_profile_gen-0.1.1/uv.lock +720 -0
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .claude-scratch/
@@ -0,0 +1,8 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.13.0
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,63 @@
1
+ # Exclude a variety of commonly ignored directories.
2
+ exclude = [
3
+ ".bzr",
4
+ ".direnv",
5
+ ".eggs",
6
+ ".git",
7
+ ".git-rewrite",
8
+ ".hg",
9
+ ".ipynb_checkpoints",
10
+ ".mypy_cache",
11
+ ".nox",
12
+ ".pants.d",
13
+ ".pyenv",
14
+ ".pytest_cache",
15
+ ".pytype",
16
+ ".ruff_cache",
17
+ ".svn",
18
+ ".tox",
19
+ ".venv",
20
+ ".vscode",
21
+ "__pypackages__",
22
+ "_build",
23
+ "buck-out",
24
+ "build",
25
+ "dist",
26
+ "node_modules",
27
+ "site-packages",
28
+ "venv",
29
+ "*/migrations/*",
30
+ ]
31
+
32
+ line-length = 100
33
+ indent-width = 4
34
+
35
+ [lint]
36
+ select = [
37
+ "E", # pycodestyle errors
38
+ "W", # pycodestyle warnings
39
+ "F", # pyflakes
40
+ "I", # isort
41
+ "B", # flake8-bugbear
42
+ "C4", # flake8-comprehensions
43
+ "UP", # pyupgrade
44
+ ]
45
+ ignore = [
46
+ ]
47
+
48
+ # Per-file ignores for common patterns
49
+ [lint.per-file-ignores]
50
+ "**/tests/**" = [
51
+ "F841", # Local variable is assigned to but never used
52
+ ]
53
+
54
+ [format]
55
+ # Like Black, use double quotes for strings.
56
+ quote-style = "double"
57
+
58
+ # Like Black, indent with spaces, rather than tabs.
59
+ indent-style = "space"
60
+
61
+ # Like Black, respect magic trailing commas.
62
+ skip-magic-trailing-comma = false
63
+
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Profiles come from the bundled captures only. BrowserForge's network and the
6
+ fpgen GPU-claim extract are gone (with `forge.json.xz`, `fpgen.json.xz` and
7
+ the `build-data` dependency group): every device field is one capture's,
8
+ and `gpu=` selects a capture with that GPU.
9
+ - `ProfileGenerator` protocol for choosing the capture, passed as
10
+ `generate_profile(generator=)`. `RandomCapture` (the default) draws one of
11
+ the OS's captures uniformly from the seed; `from_capture` builds the profile.
12
+ - `list_gpus()` reports `captures` per GPU instead of `weight`.
13
+ - `MissingCaptures` no longer carries a draft; `generate --draft` is removed.
14
+
15
+ First release: Phasmid's `tools/genprofile` as a package, with its data bundled.
16
+
17
+ - Generates the same profiles as `tools/genprofile` from the same captures,
18
+ byte for byte except for the generator's name in `captured.note`.
19
+ - Data built from Phasmid at the commit in `data/manifest.json`, BrowserForge's
20
+ fingerprint network (`apify_fingerprint_datapoints` 0.15.0) and further
21
+ captures: seven in all (one Linux, six Windows).
22
+ - Captures are named by OS family and PCI ids (`windows-0x8086-0x0412`). The
23
+ manifest lists refused dumps by hash only.
24
+ - A Windows capture backs only its own marketing version (10 or 11), so the
25
+ claimed `platformVersion` agrees with the capture's fonts and colours.
26
+ - One capture (`0x5916`) reports a heap limit that no whole-MB RAM gives
27
+ exactly, so its RAM is the nearest whole MB (8053) and its heap limit the one
28
+ the browser computes for it; `manifest.json` lists both as `assumed`.
29
+ - A Windows sample that drew 640 CPU threads (over the schema's 256; about 1 seed
30
+ in 500) fails validation in `tools/genprofile`. It is drawn again here; every
31
+ other seed's profile is the same as there.
32
+ - `PHASMID_GEOIP_MMDB` / `--geoip-db` use a DB-IP City Lite build other than
33
+ the pinned one once DB-IP stops serving it.
34
+ - CLI subcommands: `generate`, `locate`, `gpus`, `captures`, `geoip`, `validate`.
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, The Phasmid Authors
4
+ Copyright (c) 2015-2026, The ungoogled-chromium Authors
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.5
2
+ Name: phasmid-profile-gen
3
+ Version: 0.1.1
4
+ Summary: Generate Phasmid browser profiles from captured real devices
5
+ Project-URL: Homepage, https://github.com/tintty1/phasmid-profile-gen
6
+ Project-URL: Repository, https://github.com/tintty1/phasmid-profile-gen
7
+ Project-URL: Issues, https://github.com/tintty1/phasmid-profile-gen/issues
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ License-File: src/phasmid_profile_gen/data/LICENSES.md
11
+ Keywords: browser fingerprint,browser profile,chromium,phasmid
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: jsonschema>=4.23
22
+ Provides-Extra: location
23
+ Requires-Dist: maxminddb==3.2.0; extra == 'location'
24
+ Requires-Dist: timezonefinder-data==4.2026.5; extra == 'location'
25
+ Requires-Dist: timezonefinder==9.2.0; extra == 'location'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # phasmid-profile-gen
29
+
30
+ [![Test](https://github.com/tintty1/phasmid-profile-gen/actions/workflows/test.yml/badge.svg)](https://github.com/tintty1/phasmid-profile-gen/actions/workflows/test.yml)
31
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
32
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
33
+
34
+ Generates device profiles for [Phasmid](https://github.com/tintty1/phasmid), the
35
+ Chromium fork that reports the device described by one JSON profile instead of
36
+ the machine it runs on.
37
+
38
+ Every device field of a profile comes from one capture of a real device: no
39
+ population model is sampled and no GPU identity is derived. The captures, the
40
+ schema and the locale tables are bundled in compressed form, so it runs without
41
+ a Phasmid checkout.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install phasmid-profile-gen # profiles with a given time zone
47
+ pip install 'phasmid-profile-gen[location]' # also place profiles at a proxy exit IP
48
+ ```
49
+
50
+ Python 3.12 or newer. The `location` extra adds `timezonefinder` (with its
51
+ boundary data, about 33 MB) and `maxminddb`, pinned exactly because they decide
52
+ which zone and position a seed gets.
53
+
54
+ ## Use
55
+
56
+ ```python
57
+ from phasmid_profile_gen import dumps, generate_profile
58
+
59
+ # Placed where the proxy comes out: zone, geolocation, WebRTC address, languages.
60
+ profile = generate_profile("windows", exit_ip=proxy_exit_ip)
61
+
62
+ # Or with an explicit locale, reproducibly.
63
+ profile = generate_profile(
64
+ "linux",
65
+ seed="0a1b2c3d4e5f60718293a4b5c6d7e8f9",
66
+ timezone="Europe/Berlin",
67
+ languages=["de-DE", "de", "en-US", "en"],
68
+ )
69
+ open("profile.json", "w").write(dumps(profile))
70
+ ```
71
+
72
+ ```bash
73
+ phasmid-profile-gen generate --os windows --exit-ip <proxy exit IP> -o profile.json
74
+ phasmid-profile-gen generate --os linux --seed 0a1b2c3d4e5f60718293a4b5c6d7e8f9 \
75
+ --timezone Europe/Berlin --languages de-DE,de,en-US,en -o profile.json
76
+ phasmid-profile-gen gpus # GPUs the bundled captures back (for --gpu)
77
+ phasmid-profile-gen captures # the bundled captures
78
+ phasmid-profile-gen locate <proxy exit IP> --seed <seed>
79
+ phasmid-profile-gen validate profile.json
80
+ ```
81
+
82
+ Give exactly one of an exit IP and a time zone. **Generate Windows profiles
83
+ with an exit IP.** Windows Chrome answers geolocation from the system location
84
+ provider, so a Windows profile with no position is a stronger tell than a Linux
85
+ one. The seed is 32 lowercase hex characters. When omitted, a new one is drawn;
86
+ read it back from `profile["seed"]`. The same seed and inputs always give the
87
+ same bytes (`dumps`).
88
+
89
+ ### Choosing the capture
90
+
91
+ Which capture backs a profile is a `ProfileGenerator`'s choice: any object
92
+ with `generate(request: Request) -> dict`. The default, `RandomCapture()`,
93
+ draws one of the OS's captures (those with the requested GPU, when `gpu=` is
94
+ given) uniformly from the seed. Pass another strategy as `generator=`;
95
+ `from_capture(capture, request)` builds the profile once a capture is chosen:
96
+
97
+ ```python
98
+ from phasmid_profile_gen import Request, catalog, from_capture, generate_profile
99
+
100
+ class Newest:
101
+ def generate(self, request: Request) -> dict:
102
+ captures = [c for c in catalog() if c.family == request.family]
103
+ newest = max(captures, key=lambda c: c.date)
104
+ return from_capture(newest, request, how="the newest capture")
105
+
106
+ profile = generate_profile("windows", timezone="UTC", generator=Newest())
107
+ ```
108
+
109
+ A generator draws only from `request.seed` (`_rng.decision_rng(seed,
110
+ decision)`), so a seed keeps giving the same profile. `generate_profile`
111
+ validates whatever it returns.
112
+
113
+ Other functions: `locate(ip, seed)`, `list_gpus()`, `list_captures()`,
114
+ `validate(profile)` (against the bundled Phasmid schema) and `new_seed()`.
115
+ Errors are `GenerationError`, `LocationError` and `geoip.GeoipError`.
116
+
117
+ ## What a profile is made of
118
+
119
+ | Part | From |
120
+ | --- | --- |
121
+ | OS identity and version, CPU threads, memory, touch points, screen and window geometry, GPU with its WebGL and WebGPU surface, fonts, voices, audio, codecs, system colours, pointer, preferences, RAM, disk, media devices, battery, connection | One capture of a real device, taken whole. |
122
+ | Time zone, geolocation, WebRTC public IP, languages | The exit IP: DB-IP City Lite, timezonefinder, CLDR, and Chrome's default accept-languages for the country's UI locale. Otherwise the time zone and languages you pass. |
123
+
124
+ No user-agent or version string is written. Phasmid builds those from its own
125
+ version. Nothing is invented: a field only a capture can supply is never
126
+ filled in by guessing, and an OS without a capture fails with
127
+ `MissingCaptures`. `captured.note` records where every part came from.
128
+
129
+ ### Bundled captures
130
+
131
+ Run `phasmid-profile-gen captures` for the current list. It is also in
132
+ [`data/manifest.json`](src/phasmid_profile_gen/data/manifest.json), which lists
133
+ the dumps refused and why.
134
+
135
+ Each is named by its OS family and PCI ids (`windows-0x8086-0x0412`). Where a
136
+ capture has no media device labels (a page visit grants no media permission),
137
+ profiles built from it claim no microphone, speaker or camera.
138
+
139
+ A profile's OS version is its capture's, so a Windows 11 capture gives
140
+ Windows 11 profiles with Windows 11 fonts and colours.
141
+
142
+ ## The location database
143
+
144
+ The exit IP is looked up in DB-IP IP to City Lite (CC BY 4.0, "IP Geolocation
145
+ by DB-IP"). It is about 127 MB, so it is not bundled. The build pinned in
146
+ `data/dbip-city-lite.json` is downloaded on first use, checked against the
147
+ pin, and kept in `$XDG_CACHE_HOME/phasmid/geoip/`. That is the cache Phasmid's
148
+ tools use, so an existing download is reused.
149
+
150
+ ```bash
151
+ phasmid-profile-gen geoip # download and verify now
152
+ phasmid-profile-gen geoip --archive dbip-city-lite-2026-10.mmdb.gz
153
+ ```
154
+
155
+ DB-IP serves only the last two monthly builds. Once the pinned one is gone,
156
+ either install a saved copy with `--archive`, or use any other DB-IP City Lite
157
+ build with `PHASMID_GEOIP_MMDB=/path/to/dbip-city-lite.mmdb`, `--geoip-db` or
158
+ `geoip_db=`. Another build is not checked against the pin; the profile's note
159
+ names its build date and sha256 instead.
160
+
161
+ ## Rebuilding the data
162
+
163
+ `src/phasmid_profile_gen/data/` is generated, compressed and committed. It is
164
+ rebuilt from a Phasmid checkout and further harness dumps by
165
+ [`scripts/build_data.py`](scripts/build_data.py):
166
+
167
+ ```bash
168
+ uv run python scripts/build_data.py \
169
+ --phasmid ../phasmid \
170
+ --dumps /path/to/dumps
171
+ ```
172
+
173
+ The build:
174
+
175
+ - maps every dump with the checkout's own `tests/consistency/capture.py` (the
176
+ code `consistency capture` runs), and checks Phasmid's captures against their
177
+ records as `tools/genprofile` does;
178
+ - keeps a dump only if it is stock desktop Google Chrome on Windows
179
+ or Linux, with a hardware GPU, a consistent UA and UA-CH, and a window inside
180
+ its screen, and only if it maps to a valid profile. Each refusal is listed
181
+ with its reason in `manifest.json`;
182
+ - takes the nearest whole-MB RAM when a dump's heap limit sits within 1 MiB of
183
+ one but matches none exactly (a fraction of a MB of RAM), sets the heap limit
184
+ to the one the browser computes for it, and lists both as `assumed` in
185
+ `manifest.json`; a limit further off is refused;
186
+ - stores only what generation reads, under a neutral name (OS family and PCI
187
+ ids). Raw dumps, file names, notes and the device's locale are never written,
188
+ and a refused dump is listed by hash only.
189
+
190
+ The output is deterministic, so a rebuild from the same sources gives the same
191
+ bytes. A Linux dump also needs the operator's OS version (`operator.os_version`),
192
+ which a page cannot read.
193
+
194
+ ## Develop
195
+
196
+ ```bash
197
+ uv sync --all-extras
198
+ uv run pytest
199
+ uv run pre-commit run --all-files
200
+ PHASMID_GEOIP_NETWORK=1 uv run pytest -m network # reads the real DB-IP build
201
+ ```
202
+
203
+ Releases are published to PyPI by `.github/workflows/publish-pypi.yaml` when a
204
+ GitHub release is published. The version comes from the release tag
205
+ (`uv-dynamic-versioning`, e.g. `v0.1.0`). Publishing uses PyPI trusted
206
+ publishing: register the repository and that workflow as a trusted publisher
207
+ of `phasmid-profile-gen` on PyPI once.
208
+
209
+ ## Licence
210
+
211
+ The code is BSD 3-Clause, as Phasmid is ([`LICENSE`](LICENSE)). The bundled
212
+ data keeps its sources' licences:
213
+
214
+ - Unicode CLDR: Unicode License v3.
215
+ - Chromium's locale table: BSD-style.
216
+
217
+ DB-IP (CC BY 4.0) and timezone-boundary-builder (ODbL 1.0) are used at runtime
218
+ but not bundled. See
219
+ [`data/LICENSES.md`](src/phasmid_profile_gen/data/LICENSES.md).
@@ -0,0 +1,192 @@
1
+ # phasmid-profile-gen
2
+
3
+ [![Test](https://github.com/tintty1/phasmid-profile-gen/actions/workflows/test.yml/badge.svg)](https://github.com/tintty1/phasmid-profile-gen/actions/workflows/test.yml)
4
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
5
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
6
+
7
+ Generates device profiles for [Phasmid](https://github.com/tintty1/phasmid), the
8
+ Chromium fork that reports the device described by one JSON profile instead of
9
+ the machine it runs on.
10
+
11
+ Every device field of a profile comes from one capture of a real device: no
12
+ population model is sampled and no GPU identity is derived. The captures, the
13
+ schema and the locale tables are bundled in compressed form, so it runs without
14
+ a Phasmid checkout.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install phasmid-profile-gen # profiles with a given time zone
20
+ pip install 'phasmid-profile-gen[location]' # also place profiles at a proxy exit IP
21
+ ```
22
+
23
+ Python 3.12 or newer. The `location` extra adds `timezonefinder` (with its
24
+ boundary data, about 33 MB) and `maxminddb`, pinned exactly because they decide
25
+ which zone and position a seed gets.
26
+
27
+ ## Use
28
+
29
+ ```python
30
+ from phasmid_profile_gen import dumps, generate_profile
31
+
32
+ # Placed where the proxy comes out: zone, geolocation, WebRTC address, languages.
33
+ profile = generate_profile("windows", exit_ip=proxy_exit_ip)
34
+
35
+ # Or with an explicit locale, reproducibly.
36
+ profile = generate_profile(
37
+ "linux",
38
+ seed="0a1b2c3d4e5f60718293a4b5c6d7e8f9",
39
+ timezone="Europe/Berlin",
40
+ languages=["de-DE", "de", "en-US", "en"],
41
+ )
42
+ open("profile.json", "w").write(dumps(profile))
43
+ ```
44
+
45
+ ```bash
46
+ phasmid-profile-gen generate --os windows --exit-ip <proxy exit IP> -o profile.json
47
+ phasmid-profile-gen generate --os linux --seed 0a1b2c3d4e5f60718293a4b5c6d7e8f9 \
48
+ --timezone Europe/Berlin --languages de-DE,de,en-US,en -o profile.json
49
+ phasmid-profile-gen gpus # GPUs the bundled captures back (for --gpu)
50
+ phasmid-profile-gen captures # the bundled captures
51
+ phasmid-profile-gen locate <proxy exit IP> --seed <seed>
52
+ phasmid-profile-gen validate profile.json
53
+ ```
54
+
55
+ Give exactly one of an exit IP and a time zone. **Generate Windows profiles
56
+ with an exit IP.** Windows Chrome answers geolocation from the system location
57
+ provider, so a Windows profile with no position is a stronger tell than a Linux
58
+ one. The seed is 32 lowercase hex characters. When omitted, a new one is drawn;
59
+ read it back from `profile["seed"]`. The same seed and inputs always give the
60
+ same bytes (`dumps`).
61
+
62
+ ### Choosing the capture
63
+
64
+ Which capture backs a profile is a `ProfileGenerator`'s choice: any object
65
+ with `generate(request: Request) -> dict`. The default, `RandomCapture()`,
66
+ draws one of the OS's captures (those with the requested GPU, when `gpu=` is
67
+ given) uniformly from the seed. Pass another strategy as `generator=`;
68
+ `from_capture(capture, request)` builds the profile once a capture is chosen:
69
+
70
+ ```python
71
+ from phasmid_profile_gen import Request, catalog, from_capture, generate_profile
72
+
73
+ class Newest:
74
+ def generate(self, request: Request) -> dict:
75
+ captures = [c for c in catalog() if c.family == request.family]
76
+ newest = max(captures, key=lambda c: c.date)
77
+ return from_capture(newest, request, how="the newest capture")
78
+
79
+ profile = generate_profile("windows", timezone="UTC", generator=Newest())
80
+ ```
81
+
82
+ A generator draws only from `request.seed` (`_rng.decision_rng(seed,
83
+ decision)`), so a seed keeps giving the same profile. `generate_profile`
84
+ validates whatever it returns.
85
+
86
+ Other functions: `locate(ip, seed)`, `list_gpus()`, `list_captures()`,
87
+ `validate(profile)` (against the bundled Phasmid schema) and `new_seed()`.
88
+ Errors are `GenerationError`, `LocationError` and `geoip.GeoipError`.
89
+
90
+ ## What a profile is made of
91
+
92
+ | Part | From |
93
+ | --- | --- |
94
+ | OS identity and version, CPU threads, memory, touch points, screen and window geometry, GPU with its WebGL and WebGPU surface, fonts, voices, audio, codecs, system colours, pointer, preferences, RAM, disk, media devices, battery, connection | One capture of a real device, taken whole. |
95
+ | Time zone, geolocation, WebRTC public IP, languages | The exit IP: DB-IP City Lite, timezonefinder, CLDR, and Chrome's default accept-languages for the country's UI locale. Otherwise the time zone and languages you pass. |
96
+
97
+ No user-agent or version string is written. Phasmid builds those from its own
98
+ version. Nothing is invented: a field only a capture can supply is never
99
+ filled in by guessing, and an OS without a capture fails with
100
+ `MissingCaptures`. `captured.note` records where every part came from.
101
+
102
+ ### Bundled captures
103
+
104
+ Run `phasmid-profile-gen captures` for the current list. It is also in
105
+ [`data/manifest.json`](src/phasmid_profile_gen/data/manifest.json), which lists
106
+ the dumps refused and why.
107
+
108
+ Each is named by its OS family and PCI ids (`windows-0x8086-0x0412`). Where a
109
+ capture has no media device labels (a page visit grants no media permission),
110
+ profiles built from it claim no microphone, speaker or camera.
111
+
112
+ A profile's OS version is its capture's, so a Windows 11 capture gives
113
+ Windows 11 profiles with Windows 11 fonts and colours.
114
+
115
+ ## The location database
116
+
117
+ The exit IP is looked up in DB-IP IP to City Lite (CC BY 4.0, "IP Geolocation
118
+ by DB-IP"). It is about 127 MB, so it is not bundled. The build pinned in
119
+ `data/dbip-city-lite.json` is downloaded on first use, checked against the
120
+ pin, and kept in `$XDG_CACHE_HOME/phasmid/geoip/`. That is the cache Phasmid's
121
+ tools use, so an existing download is reused.
122
+
123
+ ```bash
124
+ phasmid-profile-gen geoip # download and verify now
125
+ phasmid-profile-gen geoip --archive dbip-city-lite-2026-10.mmdb.gz
126
+ ```
127
+
128
+ DB-IP serves only the last two monthly builds. Once the pinned one is gone,
129
+ either install a saved copy with `--archive`, or use any other DB-IP City Lite
130
+ build with `PHASMID_GEOIP_MMDB=/path/to/dbip-city-lite.mmdb`, `--geoip-db` or
131
+ `geoip_db=`. Another build is not checked against the pin; the profile's note
132
+ names its build date and sha256 instead.
133
+
134
+ ## Rebuilding the data
135
+
136
+ `src/phasmid_profile_gen/data/` is generated, compressed and committed. It is
137
+ rebuilt from a Phasmid checkout and further harness dumps by
138
+ [`scripts/build_data.py`](scripts/build_data.py):
139
+
140
+ ```bash
141
+ uv run python scripts/build_data.py \
142
+ --phasmid ../phasmid \
143
+ --dumps /path/to/dumps
144
+ ```
145
+
146
+ The build:
147
+
148
+ - maps every dump with the checkout's own `tests/consistency/capture.py` (the
149
+ code `consistency capture` runs), and checks Phasmid's captures against their
150
+ records as `tools/genprofile` does;
151
+ - keeps a dump only if it is stock desktop Google Chrome on Windows
152
+ or Linux, with a hardware GPU, a consistent UA and UA-CH, and a window inside
153
+ its screen, and only if it maps to a valid profile. Each refusal is listed
154
+ with its reason in `manifest.json`;
155
+ - takes the nearest whole-MB RAM when a dump's heap limit sits within 1 MiB of
156
+ one but matches none exactly (a fraction of a MB of RAM), sets the heap limit
157
+ to the one the browser computes for it, and lists both as `assumed` in
158
+ `manifest.json`; a limit further off is refused;
159
+ - stores only what generation reads, under a neutral name (OS family and PCI
160
+ ids). Raw dumps, file names, notes and the device's locale are never written,
161
+ and a refused dump is listed by hash only.
162
+
163
+ The output is deterministic, so a rebuild from the same sources gives the same
164
+ bytes. A Linux dump also needs the operator's OS version (`operator.os_version`),
165
+ which a page cannot read.
166
+
167
+ ## Develop
168
+
169
+ ```bash
170
+ uv sync --all-extras
171
+ uv run pytest
172
+ uv run pre-commit run --all-files
173
+ PHASMID_GEOIP_NETWORK=1 uv run pytest -m network # reads the real DB-IP build
174
+ ```
175
+
176
+ Releases are published to PyPI by `.github/workflows/publish-pypi.yaml` when a
177
+ GitHub release is published. The version comes from the release tag
178
+ (`uv-dynamic-versioning`, e.g. `v0.1.0`). Publishing uses PyPI trusted
179
+ publishing: register the repository and that workflow as a trusted publisher
180
+ of `phasmid-profile-gen` on PyPI once.
181
+
182
+ ## Licence
183
+
184
+ The code is BSD 3-Clause, as Phasmid is ([`LICENSE`](LICENSE)). The bundled
185
+ data keeps its sources' licences:
186
+
187
+ - Unicode CLDR: Unicode License v3.
188
+ - Chromium's locale table: BSD-style.
189
+
190
+ DB-IP (CC BY 4.0) and timezone-boundary-builder (ODbL 1.0) are used at runtime
191
+ but not bundled. See
192
+ [`data/LICENSES.md`](src/phasmid_profile_gen/data/LICENSES.md).
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "phasmid-profile-gen"
3
+ dynamic = ["version"]
4
+ description = "Generate Phasmid browser profiles from captured real devices"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "BSD-3-Clause"
8
+ license-files = ["LICENSE", "src/phasmid_profile_gen/data/LICENSES.md"]
9
+ keywords = ["phasmid", "chromium", "browser fingerprint", "browser profile"]
10
+ classifiers = [
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: OS Independent",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Internet :: WWW/HTTP :: Browsers",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ dependencies = [
21
+ "jsonschema>=4.23",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ # Placing a profile at a proxy exit IP. Pinned exactly: the zone polygons decide
26
+ # which zone and position a seed gives, so another data release changes profiles.
27
+ location = [
28
+ "maxminddb==3.2.0",
29
+ "timezonefinder==9.2.0",
30
+ "timezonefinder-data==4.2026.5",
31
+ ]
32
+
33
+ [project.scripts]
34
+ phasmid-profile-gen = "phasmid_profile_gen.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/tintty1/phasmid-profile-gen"
38
+ Repository = "https://github.com/tintty1/phasmid-profile-gen"
39
+ Issues = "https://github.com/tintty1/phasmid-profile-gen/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling", "uv-dynamic-versioning"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.version]
46
+ source = "uv-dynamic-versioning"
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ exclude = [".github", ".claude-scratch"]
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=8",
54
+ "ruff",
55
+ "pre-commit>=4.3.0",
56
+ ]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ markers = [
61
+ "network: downloads the pinned DB-IP database (run with PHASMID_GEOIP_NETWORK=1)",
62
+ ]