webskrap 3.2.1__tar.gz → 3.2.2__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 (104) hide show
  1. webskrap-3.2.2/.package-dist.dZyl3m/.gitignore +1 -0
  2. {webskrap-3.2.1 → webskrap-3.2.2}/CHANGELOG.md +27 -1
  3. {webskrap-3.2.1 → webskrap-3.2.2}/PKG-INFO +16 -3
  4. {webskrap-3.2.1 → webskrap-3.2.2}/README.md +15 -2
  5. webskrap-3.2.2/docs/diagnostics/datadome.md +212 -0
  6. {webskrap-3.2.1 → webskrap-3.2.2}/pyproject.toml +1 -1
  7. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_dom.py +10 -2
  8. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_session.py +5 -1
  9. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_setup.py +46 -10
  10. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/challenges.py +61 -10
  11. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/client.py +8 -5
  12. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/consent.py +2 -0
  13. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/guide.md +30 -5
  14. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/models.py +22 -1
  15. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_vision.py +8 -2
  16. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_browser.py +15 -0
  17. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_child_targets.py +126 -4
  18. webskrap-3.2.2/tests/test_cdp_setup.py +33 -0
  19. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_challenges.py +38 -0
  20. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_client_unit.py +19 -1
  21. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_consent.py +13 -0
  22. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_human.py +1 -0
  23. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_models.py +31 -1
  24. {webskrap-3.2.1 → webskrap-3.2.2}/uv.lock +1 -1
  25. {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/SKILL.md +0 -0
  26. {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/cli.md +0 -0
  27. {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/mcp.md +0 -0
  28. {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/python-api.md +0 -0
  29. {webskrap-3.2.1 → webskrap-3.2.2}/.claude/skills/webskrap/SKILL.md +0 -0
  30. {webskrap-3.2.1 → webskrap-3.2.2}/.codex/skills/webskrap/SKILL.md +0 -0
  31. {webskrap-3.2.1 → webskrap-3.2.2}/.gitattributes +0 -0
  32. {webskrap-3.2.1 → webskrap-3.2.2}/.gitignore +0 -0
  33. {webskrap-3.2.1 → webskrap-3.2.2}/CONTRIBUTING.md +0 -0
  34. {webskrap-3.2.1 → webskrap-3.2.2}/LICENSE +0 -0
  35. {webskrap-3.2.1 → webskrap-3.2.2}/SECURITY.md +0 -0
  36. {webskrap-3.2.1 → webskrap-3.2.2}/benchmarks.py +0 -0
  37. {webskrap-3.2.1 → webskrap-3.2.2}/docs/design/cdp-driver.md +0 -0
  38. {webskrap-3.2.1 → webskrap-3.2.2}/docs/migration-3.0.md +0 -0
  39. {webskrap-3.2.1 → webskrap-3.2.2}/scripts/driver_parity.py +0 -0
  40. {webskrap-3.2.1 → webskrap-3.2.2}/scripts/live_stealth_report.py +0 -0
  41. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/__init__.py +0 -0
  42. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/browser_cli.py +0 -0
  43. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/browser_session.py +0 -0
  44. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp.py +0 -0
  45. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cli.py +0 -0
  46. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cli_output.py +0 -0
  47. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/diagnostics.py +0 -0
  48. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/display.py +0 -0
  49. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/errors.py +0 -0
  50. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/fetch_runtime.py +0 -0
  51. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/human.py +0 -0
  52. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/install.py +0 -0
  53. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/mcp_server.py +0 -0
  54. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/parsing.py +0 -0
  55. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/paths.py +0 -0
  56. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/profiles.py +0 -0
  57. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/proxy_auth.py +0 -0
  58. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/py.typed +0 -0
  59. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/search.py +0 -0
  60. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/text_search.py +0 -0
  61. {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/urls.py +0 -0
  62. {webskrap-3.2.1 → webskrap-3.2.2}/tests/conftest.py +0 -0
  63. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/accepted-202.html +0 -0
  64. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
  65. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
  66. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
  67. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/api-403.html +0 -0
  68. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/article-access-denied.html +0 -0
  69. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
  70. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
  71. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/not-found-404.html +0 -0
  72. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing.html +0 -0
  73. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing_challenge.html +0 -0
  74. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing_no_results.html +0 -0
  75. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg.html +0 -0
  76. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg_challenge.html +0 -0
  77. {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg_no_results.html +0 -0
  78. {webskrap-3.2.1 → webskrap-3.2.2}/tests/live_stealth_helpers.py +0 -0
  79. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_bot_detection.py +0 -0
  80. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_bot_detection_headless.py +0 -0
  81. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_cli.py +0 -0
  82. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_integration.py +0 -0
  83. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_session_unit.py +0 -0
  84. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp.py +0 -0
  85. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_session.py +0 -0
  86. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cli.py +0 -0
  87. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_consent_live.py +0 -0
  88. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_diagnostics.py +0 -0
  89. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_display.py +0 -0
  90. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_docs.py +0 -0
  91. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_errors.py +0 -0
  92. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_fetch_runtime.py +0 -0
  93. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_install.py +0 -0
  94. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_live_stealth_helpers.py +0 -0
  95. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_live_stealth_report.py +0 -0
  96. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_mcp.py +0 -0
  97. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_mcp_efficiency.py +0 -0
  98. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_parsing.py +0 -0
  99. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_paths.py +0 -0
  100. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_profiles.py +0 -0
  101. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_search.py +0 -0
  102. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_search_live.py +0 -0
  103. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_text_search.py +0 -0
  104. {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_urls.py +0 -0
@@ -9,6 +9,31 @@ history, so they summarize each release rather than list every change.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [3.2.2] - 2026-10-30
13
+
14
+ ### Fixed
15
+
16
+ - DataDome detection now follows the device check when it navigates its
17
+ frame. It reads the live challenge frame, and it reports a manual
18
+ challenge apart from a hard block. The user-agent probe now uses the
19
+ session proxy and network flags, so it cannot connect directly.
20
+ - Full desktop profiles now set `navigator.platform` to match the user
21
+ agent. They also fit the viewport inside the real browser window before
22
+ the metrics override.
23
+ - Cookie decline now finds the reject button of Commanders Act
24
+ TrustCommander notices. Their markup has no "cookie" or "consent" name, so
25
+ the text search did not look inside them.
26
+ - Full profiles no longer put q-values in `navigator.languages`, and no longer
27
+ send a doubled q-value in `Accept-Language` (`fr;q=0.9;q=0.9`). Chromium
28
+ weights the header itself, so the override now gets a plain list.
29
+ - With `profile_mode="full"` (or `"locale"` off Linux),
30
+ `navigator.serviceWorker.register()` no longer hangs. The page and the
31
+ browser both held the worker paused, and each waited for the other. Now
32
+ only the browser-level setup prepares service and shared workers; the page
33
+ only resumes them.
34
+ - Mouse presses now report `PointerEvent.pressure` 0.5 while the button is
35
+ down, as a real mouse does. CDP's default of 0 was visible to pages.
36
+
12
37
  ## [3.2.1] - 2026-10-26
13
38
 
14
39
  ### Removed
@@ -936,7 +961,8 @@ entries below.
936
961
 
937
962
  Earlier releases (0.1.0 - 0.4.9) are recorded in the git history and tags only.
938
963
 
939
- [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.1...HEAD
964
+ [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.2...HEAD
965
+ [3.2.2]: https://github.com/kacigaya/webskrap/compare/v3.2.1...v3.2.2
940
966
  [3.2.1]: https://github.com/kacigaya/webskrap/compare/v3.2.0...v3.2.1
941
967
  [3.2.0]: https://github.com/kacigaya/webskrap/compare/v3.1.0...v3.2.0
942
968
  [3.1.0]: https://github.com/kacigaya/webskrap/compare/v3.0.1...v3.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webskrap
3
- Version: 3.2.1
3
+ Version: 3.2.2
4
4
  Summary: Async Python scraping on Chromium over its own CDP client, with coherent browser profiles and session controls.
5
5
  Project-URL: Homepage, https://github.com/kacigaya/webskrap
6
6
  Project-URL: Repository, https://github.com/kacigaya/webskrap
@@ -82,8 +82,21 @@ asyncio.run(main())
82
82
  For deferred content, pass `ready_selector="#content"` to a Python fetch or
83
83
  `--ready-selector '#content'` to CLI `fetch`. Both MCP fetch tools accept
84
84
  `ready_selector` too. The selector must become visible after consent dismissal.
85
- Known challenge pages (DataDome, Cloudflare, Akamai, AWS WAF) and provider block pages (Cloudflare, Akamai) raise `blocked`;
86
- WebSkrap does not retry or solve them. Ordinary HTTP errors still return `ok=False`.
85
+ Known challenge pages (DataDome, Cloudflare, Akamai, AWS WAF) and provider block
86
+ pages (DataDome, Cloudflare, Akamai) raise `blocked`. WebSkrap waits for device
87
+ checks that reload the page. It does not retry refusals or solve CAPTCHAs.
88
+ Ordinary HTTP errors still return `ok=False`.
89
+
90
+ For manual CAPTCHA completion, open a persistent browser with
91
+ `webskrap browser open --headed --session manual --proxy http://localhost:8888 URL`.
92
+ Complete the check in that browser. Keep the same session, profile, and proxy
93
+ for later commands. On a remote host, use a desktop connection to its display.
94
+ `virtual_display=True` alone does not provide a remote desktop.
95
+
96
+ Keep `profile_mode="native"` for the browser's own identity. On Linux with Xvfb,
97
+ `virtual_display=True` runs a headed browser without a visible desktop window.
98
+ Full profile emulation is useful for device tests, but cannot replace every
99
+ worker or iframe value. See the [identity limits](src/webskrap/guide.md).
87
100
 
88
101
  ## Search
89
102
 
@@ -48,8 +48,21 @@ asyncio.run(main())
48
48
  For deferred content, pass `ready_selector="#content"` to a Python fetch or
49
49
  `--ready-selector '#content'` to CLI `fetch`. Both MCP fetch tools accept
50
50
  `ready_selector` too. The selector must become visible after consent dismissal.
51
- Known challenge pages (DataDome, Cloudflare, Akamai, AWS WAF) and provider block pages (Cloudflare, Akamai) raise `blocked`;
52
- WebSkrap does not retry or solve them. Ordinary HTTP errors still return `ok=False`.
51
+ Known challenge pages (DataDome, Cloudflare, Akamai, AWS WAF) and provider block
52
+ pages (DataDome, Cloudflare, Akamai) raise `blocked`. WebSkrap waits for device
53
+ checks that reload the page. It does not retry refusals or solve CAPTCHAs.
54
+ Ordinary HTTP errors still return `ok=False`.
55
+
56
+ For manual CAPTCHA completion, open a persistent browser with
57
+ `webskrap browser open --headed --session manual --proxy http://localhost:8888 URL`.
58
+ Complete the check in that browser. Keep the same session, profile, and proxy
59
+ for later commands. On a remote host, use a desktop connection to its display.
60
+ `virtual_display=True` alone does not provide a remote desktop.
61
+
62
+ Keep `profile_mode="native"` for the browser's own identity. On Linux with Xvfb,
63
+ `virtual_display=True` runs a headed browser without a visible desktop window.
64
+ Full profile emulation is useful for device tests, but cannot replace every
65
+ worker or iframe value. See the [identity limits](src/webskrap/guide.md).
53
66
 
54
67
  ## Search
55
68
 
@@ -0,0 +1,212 @@
1
+ # DataDome website navigation diagnosis
2
+
3
+ Checked on 2026-10-29 against a DataDome-protected website.
4
+
5
+ ## Root cause
6
+
7
+ The request reaches the website through the existing Gluetun VPN. DataDome then
8
+ chooses whether to show a device check, a CAPTCHA, or a hard block.
9
+ The headless browser received a hard-block document. That document has no
10
+ CAPTCHA widget. A headed browser on the same VPN exit received a working
11
+ manual slider. This is an external verification requirement.
12
+
13
+ WebSkrap also had three integration defects:
14
+
15
+ 1. Detection recognized `ct.captcha-delivery.com/c.js`, but missed the
16
+ `i.js` device-check script before its iframe appeared.
17
+ 2. It treated the device check as a manual challenge and did not wait for
18
+ its own script to reload the page.
19
+ 3. It read the parent document's iframe `src`. A cross-origin iframe can
20
+ navigate itself while that attribute stays unchanged. A hard block then
21
+ received the same hint as a solvable CAPTCHA.
22
+
23
+ The optional headless user-agent probe also started a separate browser
24
+ without the session's proxy or network flags.
25
+
26
+ ## Evidence
27
+
28
+ - Gluetun reported a healthy tunnel and a French exit. A browser request to
29
+ the IP check service returned the same exit as a request from the container.
30
+ - The proxy required authentication. Browser credentials stayed in memory.
31
+ - The container resolved the website and the CAPTCHA script, frame, and asset hosts.
32
+ Its BusyBox `nslookup` printed a parse error for one CNAME response. The
33
+ proxy still resolved and loaded that host successfully.
34
+ - The website returned HTTP 403 with `x-datadome: protected` and `x-dd-b: 3`.
35
+ - The device-check script and iframe returned HTTP 200. In the headed run,
36
+ CAPTCHA JavaScript, CSS, fonts, and logo also returned HTTP 200.
37
+ - Browser logs showed GPU and canvas warnings, but no failed CAPTCHA resource
38
+ request in the captured headed flow.
39
+ - The headless provider frame contained
40
+ `data-dd-response-page="hard-block"`. Its parent had no visible body text.
41
+ - The headed provider frame showed the manual verification slider.
42
+ - Cookies included `datadome`, `__cf_bm`, and `__cflb`. Cookie values and
43
+ challenge URLs are excluded from this report.
44
+
45
+ ## Changes
46
+
47
+ Detection now reads the active provider frame through the existing CDP frame
48
+ code. It uses provider origin and markup, not page titles or translated text.
49
+ Device checks use the existing bounded interstitial wait. Frame inspection
50
+ uses the remaining navigation budget. Manual challenges stay open in persistent
51
+ sessions. Hard blocks receive the block hint.
52
+
53
+ The user-agent probe now keeps the session's launch flags, including its
54
+ proxy and WebRTC policy. No CAPTCHA solver, token injection, provider script
55
+ patch, new dependency, or site-specific branch was added.
56
+
57
+ ## Manual completion
58
+
59
+ 1. Start a headed persistent browser on a display you can access.
60
+ 2. Configure the VPN proxy before navigation. Keep all page resources enabled.
61
+ 3. Complete the CAPTCHA yourself. Use its retry control if it has expired.
62
+ 4. Continue with the same browser, profile, and proxy. Do not copy challenge
63
+ tokens into another browser.
64
+
65
+ A temporary headed browser session and loopback VNC listener on port 15900 were used
66
+ to prepare manual completion. Both were closed when manual completion was
67
+ declined. Xvfb alone does not give remote access.
68
+
69
+ ## Verification limits
70
+
71
+ The final Linux ARM64 run passed 858 tests. It excluded 30 opt-in live tests
72
+ for unrelated public sites. Coverage was 88.85%, above the 85% project gate.
73
+ The focused suite passed 123 tests before the combined run.
74
+
75
+ Ruff lint and format checks, Pyright for Linux, Windows, and macOS, Bandit,
76
+ package builds, and Twine metadata checks passed. OSV found no vulnerabilities
77
+ in the 51 public packages from the lockfile. Pip-audit also found none.
78
+ Windows and macOS runtime tests require those operating systems and were not
79
+ run on this host.
80
+
81
+ Local regression fixtures cover device-check completion, a transition to a
82
+ manual challenge, a transition to a hard block, cross-origin frame navigation,
83
+ proxy authentication, image loading, and continuation with the same cookies.
84
+ The fixture's manual button is a local test control. It does not contact or
85
+ complete real CAPTCHA verification.
86
+
87
+ Live tests reached the rendered CAPTCHA. Navigation beyond verification
88
+ cannot be confirmed until a person completes it. The provider can still
89
+ refuse the session after manual verification. No forms were submitted.
90
+
91
+ ## VPN exit retest
92
+
93
+ Eight distinct French NordVPN exits were tested, including the initial
94
+ baseline. Each headed run returned HTTP 403 and a DataDome manual challenge.
95
+ Fresh browser profiles were used for the replacement exits. No CAPTCHA was
96
+ completed or bypassed. This sample did not identify a CAPTCHA-free exit.
97
+ It does not establish that IP reputation is the only input to the decision.
98
+
99
+ A control run placed the browser directly inside Gluetun's network namespace.
100
+ It used the same VPN exit without the HTTP proxy and still received a challenge.
101
+ The proxy is therefore not the sole cause.
102
+
103
+ Rotation also exposed an operational defect: Gluetun's NordVPN server catalog
104
+ was dated March 2024. Some listed endpoints timed out or rejected authentication.
105
+ The catalog was refreshed through Gluetun's existing updater. The original
106
+ catalog was backed up, and the VPN was restored to a healthy state.
107
+
108
+ The final verified exit was `187.13.198.190` in Marseille. It still required
109
+ verification. The refreshed catalog is in the container's writable layer;
110
+ container recreation can replace it because there is no catalog volume mount.
111
+
112
+ ## CDP identity investigation
113
+
114
+ An ordinary headed Chromium process was launched on the same VPN exit with
115
+ no debugging port, no debugging pipe, and no WebSkrap page setup. Its native
116
+ network log recorded an HTTP 403, a device check, and then a CAPTCHA.
117
+ This establishes that CDP is not required to trigger the current challenge.
118
+ It does not reveal DataDome's private scoring rule.
119
+
120
+ A separate public fingerprint probe reported `isBot: false` for default
121
+ headed WebSkrap. It reported no CDP, webdriver, worker-value, or client-hint
122
+ inconsistency. The headed Mesa/locale configuration also passed that probe.
123
+ Both configurations still received a CAPTCHA. The headless run was
124
+ flagged for its bot user agent and weak signals, with no CDP leak reported.
125
+ These are observations from one public probe, not universal detection claims.
126
+
127
+ Local page-owned probes compared request headers and JavaScript values in
128
+ documents, cross-origin iframes, dedicated workers, and shared workers.
129
+ Two full-profile setup defects were reproduced and fixed:
130
+
131
+ 1. A Windows or macOS user agent changed client hints but left the document's
132
+ `navigator.platform` at its native Linux value. The CDP override now sets
133
+ an unambiguous legacy platform as well. Unknown CPU values are not guessed.
134
+ 2. A desktop viewport could be wider than its real browser window. The new
135
+ regression reproduced `innerWidth=1365` with `outerWidth=780`. Setup now
136
+ resizes the real window before applying device metrics. A missing command
137
+ on an older browser keeps the previous behavior; other errors propagate.
138
+
139
+ The changes use Chromium's own
140
+ [user-agent override](https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setUserAgentOverride)
141
+ and [content-size command](https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setContentsSize).
142
+ No JavaScript getter replacements were added.
143
+
144
+ The probe also confirmed existing limits. UA masking removes high-entropy
145
+ client hints. Cross-process frames keep their real screen and pixel ratio.
146
+ Workers keep their native legacy platform, and shared workers keep their
147
+ native user agent. Full cross-platform emulation therefore cannot promise a
148
+ coherent identity across all contexts. Native mode leaves those values intact.
149
+ Neither the fixes nor changing the GPU and locale removed the website's verification
150
+ requirement in the measured runs.
151
+
152
+ The final follow-up suite ran inside Gluetun's network namespace. It passed
153
+ 872 tests with 88.89% coverage and excluded 30 opt-in live tests. The focused
154
+ suite passed 125 tests. Lint, format, Linux/Windows/macOS type checks, Bandit,
155
+ OSV, pip-audit, package builds, and Twine checks passed. Windows and macOS
156
+ runtime checks were not available on this Linux host.
157
+
158
+ ## Protected form retest
159
+
160
+ A later retest compared ordinary pages with the website's quote form path.
161
+ Each run used a fresh browser profile and a VPN exit that the run had not
162
+ used before.
163
+
164
+ The browser passed DataDome on ordinary pages. On some exits the first page
165
+ returned HTTP 403 with a device check (`x-dd-b: 3`). The device check then
166
+ reloaded into the requested page with HTTP 200. On other exits the first page
167
+ returned HTTP 200 at once. DataDome's page tag ran on those pages.
168
+
169
+ The quote form path returned HTTP 403 with a CAPTCHA on every exit. This
170
+ occurred with a direct load and with a click-through from the landing page.
171
+ These changes did not remove the CAPTCHA:
172
+
173
+ 1. Google Chrome stable for Linux ARM64 instead of Chrome for Testing. This
174
+ adds the `Google Chrome` brand.
175
+ 2. A locale and a timezone that match the exit country.
176
+ 3. Mesa WebGL.
177
+ 4. `x86` client hints instead of the host's `arm` value.
178
+ 5. A 25-second pause with mouse movement and wheel scrolling on each page.
179
+ 6. A four-page session with about 75 seconds of activity before the form.
180
+ 7. Exits in eleven countries on eight hosting ASNs.
181
+
182
+ A desktop-like host environment did not change the result. It added Noto
183
+ and Ubuntu fonts and PulseAudio audio devices. On a fresh exit, a direct load
184
+ of the form received a device check (`x-dd-b: 3`). The check reloaded into a
185
+ CAPTCHA. The same check reloaded into HTTP 200 on ordinary pages.
186
+
187
+ SwiftShader WebGL failed the device check and received a hard block.
188
+ The VPN provider's dedicated-IP servers refused this setup's credentials,
189
+ so a dedicated exit was not tested.
190
+
191
+ A Windows user agent made the result worse: the first page returned a hard
192
+ block. Repeated fresh profiles on one exit also made the result worse. A later
193
+ visit received the CAPTCHA, or a hard block, without a device check.
194
+
195
+ The VPN provider's catalog contains only hosting ASNs. The measured limit is
196
+ therefore the exit IP class for this form path, not a browser defect that
197
+ WebSkrap can fix. A residential or mobile IP was not tested.
198
+
199
+ The retest found two WebSkrap defects:
200
+
201
+ 1. Cookie decline did not search Commanders Act TrustCommander notices.
202
+ 2. Full profiles put q-values in `navigator.languages` and doubled them in
203
+ `Accept-Language`.
204
+
205
+ A later local fingerprint check found two more:
206
+
207
+ 1. With a full profile, `navigator.serviceWorker.register()` never
208
+ finished. The page and the browser both held the worker paused.
209
+ 2. Mouse presses reported `PointerEvent.pressure` 0. A real mouse reports
210
+ 0.5 while a button is down.
211
+
212
+ All four are fixed and have regression tests. No CAPTCHA was solved.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webskrap"
7
- version = "3.2.1"
7
+ version = "3.2.2"
8
8
  description = "Async Python scraping on Chromium over its own CDP client, with coherent browser profiles and session controls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -38,6 +38,10 @@ from webskrap.human import MAX_WHEEL_BATCHES, VIEW_MARGIN, bezier_path, click_po
38
38
  #: then ``e`` and Chromium's backend node id.
39
39
  REF_PATTERN = re.compile(r"^(?:f(\d+))?e(\d+)$")
40
40
  _POLL_S = 0.1
41
+ #: ``force`` for a held mouse button. CDP's default of 0 reaches pages as
42
+ #: ``PointerEvent.pressure`` 0, but a real mouse with a button down reports
43
+ #: 0.5, as the Pointer Events spec asks of devices without pressure sensing.
44
+ MOUSE_PRESSED_FORCE = 0.5
41
45
 
42
46
  # Builds the snapshot text with "[ref=#N]" placeholders and returns it with
43
47
  # the listed elements, so the caller can swap each placeholder for the
@@ -917,7 +921,10 @@ async def _human_pointer(element: _Element, *, click_count: int = 0) -> None:
917
921
  msg = f"another element covers the click point of: {element.description}"
918
922
  raise WebSkrapError(msg, code=ErrorCode.USAGE)
919
923
  press = {"x": x, "y": y, "button": "left", "clickCount": count}
920
- await page.send("Input.dispatchMouseEvent", {"type": "mousePressed", "buttons": 1, **press})
924
+ await page.send(
925
+ "Input.dispatchMouseEvent",
926
+ {"type": "mousePressed", "buttons": 1, "force": MOUSE_PRESSED_FORCE, **press},
927
+ )
921
928
  await asyncio.sleep(uniform(60, 140) / 1000)
922
929
  await page.send("Input.dispatchMouseEvent", {"type": "mouseReleased", **press})
923
930
 
@@ -1091,7 +1098,8 @@ async def instant_click(page: CDPPage, target: str, *, click_count: int = 1) ->
1091
1098
  for count in range(1, click_count + 1):
1092
1099
  press = {"x": x, "y": y, "button": "left", "clickCount": count}
1093
1100
  await element.input_page.send(
1094
- "Input.dispatchMouseEvent", {"type": "mousePressed", "buttons": 1, **press}
1101
+ "Input.dispatchMouseEvent",
1102
+ {"type": "mousePressed", "buttons": 1, "force": MOUSE_PRESSED_FORCE, **press},
1095
1103
  )
1096
1104
  await element.input_page.send(
1097
1105
  "Input.dispatchMouseEvent", {"type": "mouseReleased", **press}
@@ -407,7 +407,9 @@ async def goto(
407
407
  finally:
408
408
  documents.stop()
409
409
  status = challenges.response_status(response)
410
- found = await challenges.detect(page, challenges.response_headers(response), status)
410
+ found = await challenges.detect(
411
+ page, challenges.response_headers(response), status, deadline=deadline
412
+ )
411
413
  result: dict[str, Any] = {"status": status}
412
414
  if decline_cookies:
413
415
  result["cookie_notice_declined"] = (
@@ -750,6 +752,8 @@ _BUTTON_BITS = {"left": 1, "right": 2, "middle": 4}
750
752
  async def _mouse(
751
753
  page: CDPPage, kind: str, x: float, y: float, *, button: str = "none", **extra: Any
752
754
  ) -> None:
755
+ if extra.get("buttons"):
756
+ extra.setdefault("force", cdp_dom.MOUSE_PRESSED_FORCE)
753
757
  await page.send(
754
758
  "Input.dispatchMouseEvent",
755
759
  {"type": kind, "x": x, "y": y, "button": button, **extra},
@@ -18,7 +18,8 @@ Chromium refuses the device-metrics override below top-level pages, so an
18
18
  iframe still reports the real screen size and pixel ratio. A shared worker
19
19
  keeps the browser's own ``navigator.userAgent``; it is read before the
20
20
  override arrives. ``mask_headless_user_agent`` sets the user agent for the
21
- whole browser.
21
+ whole browser. Workers also keep their native ``navigator.platform``.
22
+ Desktop full profiles resize the browser window to fit the requested viewport.
22
23
 
23
24
  Proxy credentials are answered only to challenges whose source is the proxy.
24
25
  A website asking for HTTP authentication gets the browser's default answer,
@@ -37,7 +38,7 @@ from typing import Any
37
38
  from urllib.parse import urlsplit
38
39
 
39
40
  from webskrap import cdp_dom
40
- from webskrap.cdp import CDPBrowser, CDPConnection, CDPPage
41
+ from webskrap.cdp import CDPBrowser, CDPConnection, CDPError, CDPPage
41
42
  from webskrap.errors import ErrorCode, WebSkrapError
42
43
  from webskrap.models import BrowserProfile, ResourcePolicy, SessionConfig, user_agent_metadata
43
44
 
@@ -60,7 +61,8 @@ _NOT_ON_WORKERS = _TOP_LEVEL_ONLY | {
60
61
  "Emulation.setEmulatedMedia",
61
62
  "Emulation.setLocaleOverride",
62
63
  }
63
- _WORKER_TYPES = frozenset({"worker", "shared_worker", "service_worker"})
64
+ _BROWSER_WORKER_TYPES = frozenset({"shared_worker", "service_worker"})
65
+ _WORKER_TYPES = _BROWSER_WORKER_TYPES | {"worker"}
64
66
 
65
67
  # A blank document served for an origin while its localStorage is restored.
66
68
  _BLANK_PAGE = base64.b64encode(b"<!doctype html><title></title>").decode()
@@ -70,6 +72,8 @@ async def prepare_page(page: CDPPage, config: SessionConfig, profile: BrowserPro
70
72
  """Apply ``config`` and ``profile`` to a fresh page before it navigates."""
71
73
  await page.enable_events()
72
74
  page.default_timeout_s = config.default_timeout_ms / 1000
75
+ if config.profile_mode == "full" and not profile.is_mobile:
76
+ await _fit_window(page, profile)
73
77
  commands = await _emulation(page.connection, config, profile)
74
78
  for method, params in commands:
75
79
  await page.send(method, params)
@@ -81,6 +85,24 @@ async def prepare_page(page: CDPPage, config: SessionConfig, profile: BrowserPro
81
85
  )
82
86
 
83
87
 
88
+ async def _fit_window(page: CDPPage, profile: BrowserProfile) -> None:
89
+ """Fit a desktop viewport inside a real browser window before emulation."""
90
+ window = await page.connection.send("Browser.getWindowForTarget", {"targetId": page.target_id})
91
+ try:
92
+ await page.connection.send(
93
+ "Browser.setContentsSize",
94
+ {
95
+ "windowId": window["windowId"],
96
+ "width": profile.viewport.width,
97
+ "height": profile.viewport.height,
98
+ },
99
+ )
100
+ except CDPError as exc:
101
+ # Older Chromium versions lack this experimental browser command.
102
+ if not str(exc).startswith("CDP error -32601:"):
103
+ raise
104
+
105
+
84
106
  #: The brand each channel's browser reports next to ``Chromium``.
85
107
  _CHANNEL_BRANDS: dict[str | None, str] = {"chrome": "Google Chrome", "msedge": "Microsoft Edge"}
86
108
 
@@ -110,15 +132,15 @@ async def prepare_browser(
110
132
  ) -> None:
111
133
  """Give shared and service workers the session's identity and routing.
112
134
 
113
- They are browser targets, so a page's auto-attach does not report them.
114
- Each one waits for the debugger until it is set up.
135
+ They are browser targets: only this browser-level auto-attach sets them
136
+ up. Each one waits for the debugger until it is set up.
115
137
  """
116
138
  connection = browser.connection
117
139
  commands = await _emulation(connection, config, profile)
118
140
  if not commands and not _routes(config):
119
141
  return
120
142
  timeout_s = config.default_timeout_ms / 1000
121
- handler = _on_attached(connection, timeout_s, config, commands)
143
+ handler = _on_attached(connection, timeout_s, config, commands, browser_level=True)
122
144
  connection.on("Target.attachedToTarget", handler, session_id=None)
123
145
  await connection.send(
124
146
  "Target.setAutoAttach",
@@ -136,18 +158,28 @@ def _on_attached(
136
158
  timeout_s: float,
137
159
  config: SessionConfig,
138
160
  commands: list[tuple[str, dict[str, Any]]],
161
+ *,
162
+ browser_level: bool = False,
139
163
  ) -> Callable[[dict[str, Any]], None]:
140
164
  """Return a ``Target.attachedToTarget`` handler that sets up and resumes targets.
141
165
 
142
166
  ``commands`` are the page's emulation commands, from :func:`_emulation`.
167
+ A page's auto-attach also reports its service workers, which the
168
+ browser-level handler holds paused too. Held by two sessions, a worker
169
+ answers no setup command until both resume it, so two setups wait for
170
+ each other forever: page-level handlers only resume these workers.
143
171
  """
144
172
  tasks: set[asyncio.Task[None]] = set()
145
173
 
146
174
  def attached(params: dict[str, Any]) -> None:
147
175
  info = params.get("targetInfo", {})
176
+ kind = info.get("type")
148
177
  child = CDPPage(connection, params["sessionId"], info.get("targetId", ""))
149
178
  child.default_timeout_s = timeout_s
150
- task = asyncio.create_task(_prepare_target(child, info.get("type"), config, commands))
179
+ if kind in _BROWSER_WORKER_TYPES and not browser_level:
180
+ task = asyncio.create_task(_resume(child))
181
+ else:
182
+ task = asyncio.create_task(_prepare_target(child, kind, config, commands))
151
183
  tasks.add(task)
152
184
  task.add_done_callback(tasks.discard)
153
185
 
@@ -173,7 +205,7 @@ async def _prepare_target(
173
205
  await _route(child, config)
174
206
  handler = _on_attached(child.connection, child.default_timeout_s, config, commands)
175
207
  await cdp_dom.start_auto_attach(child, handler)
176
- elif kind in ("shared_worker", "service_worker"):
208
+ elif kind in _BROWSER_WORKER_TYPES:
177
209
  await _route(child, config)
178
210
  with suppress(WebSkrapError):
179
211
  skipped = _NOT_ON_WORKERS if kind in _WORKER_TYPES else _TOP_LEVEL_ONLY
@@ -181,8 +213,12 @@ async def _prepare_target(
181
213
  if method not in skipped:
182
214
  await child.send(method, params)
183
215
  finally:
184
- with suppress(WebSkrapError):
185
- await child.send("Runtime.runIfWaitingForDebugger")
216
+ await _resume(child)
217
+
218
+
219
+ async def _resume(child: CDPPage) -> None:
220
+ with suppress(WebSkrapError):
221
+ await child.send("Runtime.runIfWaitingForDebugger")
186
222
 
187
223
 
188
224
  def _document_switches(config: SessionConfig) -> bool:
@@ -28,6 +28,7 @@ from collections.abc import Mapping
28
28
  from contextlib import suppress
29
29
  from dataclasses import dataclass
30
30
  from typing import Any, Literal, Protocol
31
+ from urllib.parse import urlsplit
31
32
 
32
33
  from webskrap.cdp import CDPError, CDPPage, EventRecorder
33
34
  from webskrap.errors import ErrorCode, WebSkrapError
@@ -128,19 +129,32 @@ def blocked_hint(kind: BlockKind) -> str:
128
129
  # 1012 (access denied), 1015 (rate limited) and 1020 (firewall rule).
129
130
  _DETECT_SCRIPT = r"""({status, ray, cloudflare, akamai}) => {
130
131
  const found = (provider, kind, reloads = false) => ({provider, kind, reloads});
132
+ const here = new URL(location.href);
133
+ if (here.hostname === 'geo.captcha-delivery.com'
134
+ && /^\/(captcha|interstitial)\//.test(here.pathname)) {
135
+ if (here.searchParams.get('t') === 'b'
136
+ || document.querySelector('[data-dd-response-page="hard-block"]'))
137
+ return found('DataDome', 'block');
138
+ return found('DataDome', 'challenge', here.pathname.startsWith('/interstitial/'));
139
+ }
140
+ let datadome = null;
131
141
  for (const element of document.querySelectorAll('script[src], iframe[src]')) {
132
142
  let url;
133
143
  try { url = new URL(element.getAttribute('src'), document.baseURI); }
134
144
  catch { continue; }
135
145
  if (element.tagName === 'SCRIPT' && url.hostname === 'ct.captcha-delivery.com'
136
- && url.pathname === '/c.js') return found('DataDome', 'challenge');
146
+ && /^\/[ci]\.js$/.test(url.pathname))
147
+ datadome = found('DataDome', 'challenge', url.pathname === '/i.js');
137
148
  if (element.tagName === 'IFRAME' && url.hostname === 'geo.captcha-delivery.com'
138
- && /^\/(captcha|interstitial)\//.test(url.pathname)) return found('DataDome', 'challenge');
149
+ && /^\/(captcha|interstitial)\//.test(url.pathname))
150
+ return found('DataDome', url.searchParams.get('t') === 'b' ? 'block' : 'challenge',
151
+ url.pathname.startsWith('/interstitial/'));
139
152
  if (element.tagName === 'SCRIPT'
140
153
  && url.pathname.startsWith('/cdn-cgi/challenge-platform/')
141
154
  && /^Just a moment[.!\s]*$/i.test(document.title.trim()))
142
155
  return found('Cloudflare', 'challenge');
143
156
  }
157
+ if (datadome) return datadome;
144
158
  if (document.getElementById('sec-if-cpt-container')) return found('Akamai', 'challenge', true);
145
159
  const text = document.body ? document.body.textContent : '';
146
160
  if (cloudflare && (status === 403 || status === 429)) {
@@ -171,7 +185,11 @@ def _cloudflare_ray(headers: Mapping[str, str]) -> str | None:
171
185
 
172
186
 
173
187
  async def detect(
174
- page: ScriptRunner, headers: Mapping[str, str], status: int | None = None
188
+ page: ScriptRunner,
189
+ headers: Mapping[str, str],
190
+ status: int | None = None,
191
+ *,
192
+ deadline: float | None = None,
175
193
  ) -> Detection | None:
176
194
  """Return the challenge or block page ``page`` shows, or None.
177
195
 
@@ -199,11 +217,42 @@ async def detect(
199
217
  provider, kind = found.get("provider"), found.get("kind")
200
218
  if provider not in ("DataDome", "Cloudflare", "Akamai") or kind not in ("challenge", "block"):
201
219
  return None
202
- return Detection(provider, kind, reloads=bool(found.get("reloads")))
220
+ detection = Detection(provider, kind, reloads=bool(found.get("reloads")))
221
+ if provider == "DataDome" and isinstance(page, CDPPage):
222
+ budget = page.default_timeout_s
223
+ if deadline is not None:
224
+ budget = max(0.0, min(budget, deadline - time.monotonic()))
225
+ with suppress(TimeoutError):
226
+ return await asyncio.wait_for(_datadome_frame(page, detection), budget)
227
+ return detection
228
+
229
+
230
+ async def _datadome_frame(page: CDPPage, fallback: Detection) -> Detection:
231
+ """Read the active provider frame, whose URL can differ from its element's src."""
232
+ # Reuse the browser's frame handling, including out-of-process iframes.
233
+ from webskrap import cdp_dom
234
+
235
+ with suppress(WebSkrapError):
236
+ for frame in await cdp_dom.frames(page):
237
+ url = urlsplit(frame.url)
238
+ if url.hostname != "geo.captcha-delivery.com" or not url.path.startswith(
239
+ ("/captcha/", "/interstitial/")
240
+ ):
241
+ continue
242
+ found = await frame.page.evaluate(_DETECT_SCRIPT, {})
243
+ if isinstance(found, dict) and found.get("provider") == "DataDome":
244
+ kind = found.get("kind")
245
+ if kind in ("block", "challenge"):
246
+ return Detection("DataDome", kind, reloads=bool(found.get("reloads")))
247
+ return fallback
203
248
 
204
249
 
205
250
  async def raise_for_challenge(
206
- page: ScriptRunner, headers: Mapping[str, str], status: int | None = None
251
+ page: ScriptRunner,
252
+ headers: Mapping[str, str],
253
+ status: int | None = None,
254
+ *,
255
+ deadline: float | None = None,
207
256
  ) -> None:
208
257
  """Raise ``blocked`` for a recognized challenge or block page.
209
258
 
@@ -211,7 +260,7 @@ async def raise_for_challenge(
211
260
  The exception contains no page-controlled text, IPs, Ray IDs, reference
212
261
  IDs or session tokens; its hint depends on the kind.
213
262
  """
214
- found = await detect(page, headers, status)
263
+ found = await detect(page, headers, status, deadline=deadline)
215
264
  if found is not None:
216
265
  raise found.error()
217
266
 
@@ -262,9 +311,9 @@ async def settle(
262
311
  ) -> dict[str, Any]:
263
312
  """Let a self-clearing interstitial reload into the requested page.
264
313
 
265
- Akamai's sec-cpt page and AWS WAF's challenge action run their own script
266
- and reload the URL, as they do in any browser; nothing here interacts with
267
- them. Waits at most :data:`INTERSTITIAL_TIMEOUT_S` and never past
314
+ DataDome's device check, Akamai's sec-cpt page and AWS WAF's challenge run
315
+ their own script and reload the URL; nothing here interacts with them.
316
+ Waits at most :data:`INTERSTITIAL_TIMEOUT_S` and never past
268
317
  ``deadline``. ``events`` comes from :func:`record_documents`.
269
318
 
270
319
  Returns:
@@ -278,7 +327,9 @@ async def settle(
278
327
  current = document if document is not None else response
279
328
  while True:
280
329
  try:
281
- found = await detect(page, response_headers(current), response_status(current))
330
+ found = await detect(
331
+ page, response_headers(current), response_status(current), deadline=limit
332
+ )
282
333
  except CDPError:
283
334
  found = Detection("", "challenge", reloads=True) # the document is being replaced
284
335
  if found is None or not found.reloads or time.monotonic() >= limit: