Skip to content

Commit 017dd1c

Browse files
authored
fix(fingerprints): enforce the user agent allow list so screen constraints are respected (#2110)
`ScreenOptions` were not respected - with `strict=True` fingerprint generation failed, otherwise the screen constraints were silently dropped and the fingerprint got a screen outside of the requested range. `browserforge` turns the screen constraints into an allow list of user agents and passes it to the header generator, but the generator only derives browser, operating system and device from it and then samples the user agent freely - so the result is often outside the list, and the fingerprint network finds no consistent sample. `apify-fingerprint-datapoints` 0.14.0 made that deterministic for some inputs, hence the cap in #2107. `PatchedHeaderGenerator` now enforces the allow list: a user agent outside it pins the next attempt to an allowed one with the same browser, operating system and device. If nothing is interchangeable or the attempts run out, the wider header is returned, so inputs that worked before keep working. Measured on 0.14.0, before -> after: | Case | Before | After | | --- | --- | --- | | `strict` Firefox + Windows, screen `600-1800 x 400-1200` | 299/300 fail, 480 ms each | 0/500 fail, 90 ms each | | Firefox + Android, screen `300-600 x 500-1200` | ~40% of screens out of range | 0/2000 out of range | Also drops the `apify-fingerprint-datapoints<0.14.0` caps from #2107. `test_fingerprint_generator_respects_screen_options_without_strict` covers the non-strict path; it and `test_fingerprint_generator_all_options` fail on `master` with the 0.14.0 datapoints. Closes: #2108 *✍️ Drafted by Claude Code*
1 parent f23d27f commit 017dd1c

4 files changed

Lines changed: 103 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ adaptive-crawler = [
5656
"jaro-winkler>=2.0.3",
5757
"playwright>=1.27.0",
5858
"scikit-learn>=1.6.0",
59-
# TODO: Remove the upper bound, see #2108.
60-
"apify_fingerprint_datapoints>=0.0.3,<0.14.0",
59+
"apify_fingerprint_datapoints>=0.0.3",
6160
"browserforge>=1.2.4"
6261
]
6362
pydantic-ai = ["pydantic-ai-slim[openai]>=2.1.0", "parsel>=1.10.0", "lxml[html_clean]>=5.2.0"]
@@ -70,10 +69,9 @@ cli = [
7069
]
7170
# TODO: Remove the upper bound, see #2108.
7271
curl-impersonate = ["curl-cffi>=0.9.0,<0.16.0"]
73-
# TODO: Remove the upper bounds, see #2108.
74-
httpx = ["httpx[brotli,http2,zstd]>=0.27.0", "apify_fingerprint_datapoints>=0.0.2,<0.14.0", "browserforge>=1.2.3"]
72+
httpx = ["httpx[brotli,http2,zstd]>=0.27.0", "apify_fingerprint_datapoints>=0.0.2", "browserforge>=1.2.3"]
7573
parsel = ["parsel>=1.10.0"]
76-
playwright = ["playwright>=1.27.0", "apify_fingerprint_datapoints>=0.0.2,<0.14.0", "browserforge>=1.2.3"]
74+
playwright = ["playwright>=1.27.0", "apify_fingerprint_datapoints>=0.0.2", "browserforge>=1.2.3"]
7775
otel = [
7876
"opentelemetry-api>=1.34.1",
7977
"opentelemetry-distro[otlp]>=0.54",
@@ -90,8 +88,7 @@ sql_postgres = [
9088
stagehand = [
9189
"stagehand>=3.19.5",
9290
"playwright>=1.27.0",
93-
# TODO: Remove the upper bound, see #2108.
94-
"apify_fingerprint_datapoints>=0.0.2,<0.14.0",
91+
"apify_fingerprint_datapoints>=0.0.2",
9592
"browserforge>=1.2.3",
9693
]
9794
sql_sqlite = [

src/crawlee/fingerprint_suite/_browserforge_adapter.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import random
44
from collections.abc import Iterable
55
from copy import deepcopy
6-
from functools import reduce
6+
from functools import cache, reduce
77
from operator import or_
88
from typing import TYPE_CHECKING, Any, Literal
99

1010
import apify_fingerprint_datapoints
11-
from browserforge.bayesian_network import extract_json
11+
from browserforge.bayesian_network import extract_json, get_possible_values
1212
from browserforge.fingerprints import Fingerprint as bf_Fingerprint
1313
from browserforge.fingerprints import FingerprintGenerator as bf_FingerprintGenerator
1414
from browserforge.fingerprints import Screen
@@ -87,6 +87,17 @@ def generate(
8787
# headers without `sec-...` headers are valid.
8888
max_attempts += 50
8989

90+
# `browserforge` takes `user_agent` only as a hint - it derives browser, operating system and device from it,
91+
# but samples the generated user agent freely. Keep the allow list to enforce it below.
92+
allowed_user_agents = frozenset([user_agent] if isinstance(user_agent, str) else user_agent or [])
93+
94+
if allowed_user_agents:
95+
# Pinning narrows the sampling to one browser version, but not to one operating system version.
96+
max_attempts += 50
97+
98+
# Header satisfying everything but the allow list, used as a last resort below.
99+
fallback_header: dict[str, str] | None = None
100+
90101
# Use browserforge to generate headers until it satisfies our additional requirements.
91102
for _attempt in range(max_attempts):
92103
generated_header: dict[str, str] = super().generate(
@@ -114,7 +125,24 @@ def generate(
114125
# Accept chromium header only with all sec headers.
115126
continue
116127

128+
if allowed_user_agents and generated_header['User-Agent'] not in allowed_user_agents:
129+
pinned_user_agent = _pick_interchangeable_user_agent(
130+
generated_header['User-Agent'], allowed_user_agents
131+
)
132+
if pinned_user_agent is None:
133+
# Nothing interchangeable in the allow list, so no better header can be generated.
134+
return generated_header
135+
136+
fallback_header = generated_header
137+
user_agent = [pinned_user_agent]
138+
continue
139+
117140
return generated_header
141+
142+
if fallback_header is not None:
143+
# The allow list is only a preference, so a header satisfying everything else beats failing.
144+
return fallback_header
145+
118146
raise RuntimeError('Failed to generate header.')
119147

120148
def _contains_all_sec_headers(self, headers: dict[str, str]) -> bool:
@@ -250,6 +278,44 @@ def generate(self, browser_type: SupportedBrowserType = 'chrome') -> dict[str, s
250278
return self._generator.generate(browser=[browser_type])
251279

252280

281+
def _pick_interchangeable_user_agent(generated_user_agent: str, allowed_user_agents: frozenset[str]) -> str | None:
282+
"""Pick a user agent from the allow list interchangeable with the generated one, `None` if there is none."""
283+
generated_traits = _get_user_agent_traits(generated_user_agent)
284+
if not all(generated_traits):
285+
return None
286+
287+
candidates = [
288+
allowed_user_agent
289+
for allowed_user_agent in allowed_user_agents
290+
if _get_user_agent_traits(allowed_user_agent) == generated_traits
291+
]
292+
return random.choice(candidates) if candidates else None
293+
294+
295+
# Unbounded - only a few hundred user agents exist, and the default 128 entries would thrash on the allow list scan.
296+
@cache
297+
def _get_user_agent_traits(user_agent: str) -> tuple[frozenset[str], frozenset[str], frozenset[str]]:
298+
"""Get browser names, operating systems and devices the header network links to the `user_agent`.
299+
300+
`browserforge` derives the browser name and version, the operating system and the device from its `user_agent`
301+
argument. Only the name is compared here, because the caller constrains the browser by name, so an allowed user
302+
agent differing just in the browser version is a valid substitute.
303+
"""
304+
possible_values: dict[str, Any] = {}
305+
# The header network holds the user agent under a different node name for each HTTP version.
306+
for node_name in ('user-agent', 'User-Agent'):
307+
possible_values.update(
308+
get_possible_values(bf_HeaderGenerator.header_generator_network, {node_name: (user_agent,)})
309+
)
310+
311+
return (
312+
# `*BROWSER` values are `{name}/{version}`, keep just the name.
313+
frozenset(browser.split('/', maxsplit=1)[0] for browser in possible_values.get('*BROWSER', ())),
314+
frozenset(possible_values.get('*OPERATING_SYSTEM', ())),
315+
frozenset(possible_values.get('*DEVICE', ())),
316+
)
317+
318+
253319
def get_available_header_network() -> dict:
254320
"""Get header network that contains possible header values."""
255321
return extract_json(apify_fingerprint_datapoints.get_header_network())

tests/unit/fingerprint_suite/test_adapters.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@ def test_fingerprint_generator_some_options_stress_test() -> None:
3535
assert fingerprint.screen.availWidth > 500
3636

3737

38+
def test_fingerprint_generator_respects_screen_options_without_strict() -> None:
39+
"""Test that screen constraints are respected without `strict`, where `browserforge` silently drops them."""
40+
min_width = 300
41+
max_width = 600
42+
min_height = 500
43+
max_height = 1200
44+
45+
fingerprint_generator = DefaultFingerprintGenerator(
46+
header_options=HeaderGeneratorOptions(browsers=['firefox'], operating_systems=['android']),
47+
screen_options=ScreenOptions(
48+
min_width=min_width,
49+
max_width=max_width,
50+
min_height=min_height,
51+
max_height=max_height,
52+
),
53+
)
54+
55+
for _ in range(20):
56+
fingerprint = fingerprint_generator.generate()
57+
58+
assert min_width <= fingerprint.screen.width <= max_width
59+
assert min_height <= fingerprint.screen.height <= max_height
60+
61+
3862
def test_fingerprint_generator_all_options() -> None:
3963
"""Test that header generator can work with all the options. Some most basic checks of fingerprint.
4064

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)