webskrap 3.2.0__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.0 → webskrap-3.2.2}/.gitignore +1 -0
  2. webskrap-3.2.2/.package-dist.dZyl3m/.gitignore +1 -0
  3. {webskrap-3.2.0 → webskrap-3.2.2}/CHANGELOG.md +35 -1
  4. {webskrap-3.2.0 → webskrap-3.2.2}/PKG-INFO +16 -3
  5. {webskrap-3.2.0 → webskrap-3.2.2}/README.md +15 -2
  6. webskrap-3.2.2/docs/diagnostics/datadome.md +212 -0
  7. {webskrap-3.2.0 → webskrap-3.2.2}/pyproject.toml +1 -1
  8. {webskrap-3.2.0 → webskrap-3.2.2}/scripts/driver_parity.py +7 -108
  9. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cdp_dom.py +10 -2
  10. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cdp_session.py +5 -1
  11. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cdp_setup.py +46 -10
  12. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/challenges.py +61 -10
  13. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/client.py +8 -5
  14. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/consent.py +2 -0
  15. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/guide.md +30 -5
  16. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/models.py +22 -1
  17. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_browser_vision.py +8 -2
  18. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_cdp_browser.py +15 -0
  19. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_cdp_child_targets.py +126 -4
  20. webskrap-3.2.2/tests/test_cdp_setup.py +33 -0
  21. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_challenges.py +38 -0
  22. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_client_unit.py +19 -1
  23. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_consent.py +13 -0
  24. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_human.py +1 -0
  25. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_models.py +31 -1
  26. {webskrap-3.2.0 → webskrap-3.2.2}/uv.lock +1 -1
  27. {webskrap-3.2.0 → webskrap-3.2.2}/.agents/skills/webskrap/SKILL.md +0 -0
  28. {webskrap-3.2.0 → webskrap-3.2.2}/.agents/skills/webskrap/references/cli.md +0 -0
  29. {webskrap-3.2.0 → webskrap-3.2.2}/.agents/skills/webskrap/references/mcp.md +0 -0
  30. {webskrap-3.2.0 → webskrap-3.2.2}/.agents/skills/webskrap/references/python-api.md +0 -0
  31. {webskrap-3.2.0 → webskrap-3.2.2}/.claude/skills/webskrap/SKILL.md +0 -0
  32. {webskrap-3.2.0 → webskrap-3.2.2}/.codex/skills/webskrap/SKILL.md +0 -0
  33. {webskrap-3.2.0 → webskrap-3.2.2}/.gitattributes +0 -0
  34. {webskrap-3.2.0 → webskrap-3.2.2}/CONTRIBUTING.md +0 -0
  35. {webskrap-3.2.0 → webskrap-3.2.2}/LICENSE +0 -0
  36. {webskrap-3.2.0 → webskrap-3.2.2}/SECURITY.md +0 -0
  37. {webskrap-3.2.0 → webskrap-3.2.2}/benchmarks.py +0 -0
  38. {webskrap-3.2.0 → webskrap-3.2.2}/docs/design/cdp-driver.md +0 -0
  39. {webskrap-3.2.0 → webskrap-3.2.2}/docs/migration-3.0.md +0 -0
  40. {webskrap-3.2.0 → webskrap-3.2.2}/scripts/live_stealth_report.py +0 -0
  41. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/__init__.py +0 -0
  42. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/browser_cli.py +0 -0
  43. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/browser_session.py +0 -0
  44. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cdp.py +0 -0
  45. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cli.py +0 -0
  46. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/cli_output.py +0 -0
  47. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/diagnostics.py +0 -0
  48. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/display.py +0 -0
  49. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/errors.py +0 -0
  50. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/fetch_runtime.py +0 -0
  51. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/human.py +0 -0
  52. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/install.py +0 -0
  53. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/mcp_server.py +0 -0
  54. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/parsing.py +0 -0
  55. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/paths.py +0 -0
  56. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/profiles.py +0 -0
  57. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/proxy_auth.py +0 -0
  58. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/py.typed +0 -0
  59. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/search.py +0 -0
  60. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/text_search.py +0 -0
  61. {webskrap-3.2.0 → webskrap-3.2.2}/src/webskrap/urls.py +0 -0
  62. {webskrap-3.2.0 → webskrap-3.2.2}/tests/conftest.py +0 -0
  63. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/accepted-202.html +0 -0
  64. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
  65. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
  66. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
  67. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/api-403.html +0 -0
  68. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/article-access-denied.html +0 -0
  69. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
  70. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
  71. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/blocks/not-found-404.html +0 -0
  72. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/bing.html +0 -0
  73. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/bing_challenge.html +0 -0
  74. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/bing_no_results.html +0 -0
  75. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/ddg.html +0 -0
  76. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/ddg_challenge.html +0 -0
  77. {webskrap-3.2.0 → webskrap-3.2.2}/tests/fixtures/search/ddg_no_results.html +0 -0
  78. {webskrap-3.2.0 → webskrap-3.2.2}/tests/live_stealth_helpers.py +0 -0
  79. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_bot_detection.py +0 -0
  80. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_bot_detection_headless.py +0 -0
  81. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_browser_cli.py +0 -0
  82. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_browser_integration.py +0 -0
  83. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_browser_session_unit.py +0 -0
  84. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_cdp.py +0 -0
  85. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_cdp_session.py +0 -0
  86. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_cli.py +0 -0
  87. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_consent_live.py +0 -0
  88. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_diagnostics.py +0 -0
  89. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_display.py +0 -0
  90. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_docs.py +0 -0
  91. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_errors.py +0 -0
  92. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_fetch_runtime.py +0 -0
  93. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_install.py +0 -0
  94. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_live_stealth_helpers.py +0 -0
  95. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_live_stealth_report.py +0 -0
  96. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_mcp.py +0 -0
  97. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_mcp_efficiency.py +0 -0
  98. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_parsing.py +0 -0
  99. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_paths.py +0 -0
  100. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_profiles.py +0 -0
  101. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_search.py +0 -0
  102. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_search_live.py +0 -0
  103. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_text_search.py +0 -0
  104. {webskrap-3.2.0 → webskrap-3.2.2}/tests/test_urls.py +0 -0
@@ -15,3 +15,4 @@ test.py
15
15
  AGENTS.md
16
16
  CLAUDE.md
17
17
  .cache
18
+ webskrap-output/
@@ -9,6 +9,38 @@ 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
+
37
+ ## [3.2.1] - 2026-10-26
38
+
39
+ ### Removed
40
+
41
+ - `scripts/driver_parity.py` no longer offers `--click`. The script only
42
+ measures whether a target challenges each driver.
43
+
12
44
  ## [3.2.0] - 2026-10-26
13
45
 
14
46
  ### Added
@@ -929,7 +961,9 @@ entries below.
929
961
 
930
962
  Earlier releases (0.1.0 - 0.4.9) are recorded in the git history and tags only.
931
963
 
932
- [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.0...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
966
+ [3.2.1]: https://github.com/kacigaya/webskrap/compare/v3.2.0...v3.2.1
933
967
  [3.2.0]: https://github.com/kacigaya/webskrap/compare/v3.1.0...v3.2.0
934
968
  [3.1.0]: https://github.com/kacigaya/webskrap/compare/v3.0.1...v3.1.0
935
969
  [3.0.1]: https://github.com/kacigaya/webskrap/compare/v3.0.0...v3.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webskrap
3
- Version: 3.2.0
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.0"
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"
@@ -31,7 +31,6 @@ from collections.abc import AsyncIterator, Awaitable, Callable
31
31
  from contextlib import AbstractAsyncContextManager, asynccontextmanager
32
32
  from dataclasses import dataclass
33
33
  from pathlib import Path
34
- from random import SystemRandom
35
34
  from typing import Any
36
35
  from uuid import uuid4
37
36
 
@@ -43,8 +42,6 @@ from webskrap.display import VirtualDisplay
43
42
  from webskrap.profiles import get_profile
44
43
 
45
44
  REPORT = Path(".webskrap/reports/driver-parity.json")
46
- # Cursor jitter only; system entropy keeps the scanner quiet, as in human.py.
47
- _rng = SystemRandom()
48
45
 
49
46
  # A page passes when no known challenge is on it and its title is not a
50
47
  # challenge or block title.
@@ -120,8 +117,6 @@ TARGETS: dict[str, tuple[str, str]] = {
120
117
  Evaluate = Callable[[str], Awaitable[Any]]
121
118
  #: Writes a PNG of the page's viewport to the given path.
122
119
  Screenshot = Callable[[Path], Awaitable[None]]
123
- #: Sends one mouse event: ("move" | "down" | "up", x, y) in viewport CSS pixels.
124
- Mouse = Callable[[str, float, float], Awaitable[None]]
125
120
 
126
121
 
127
122
  @dataclass
@@ -130,51 +125,11 @@ class Page:
130
125
 
131
126
  evaluate: Evaluate
132
127
  screenshot: Screenshot
133
- mouse: Mouse
134
128
  #: Returns DataDome's response page kind from its iframe, or None when the
135
129
  #: path cannot read that iframe.
136
130
  datadome_kind: Callable[[], Awaitable[str | None]] | None = None
137
131
 
138
132
 
139
- # Finds the Turnstile widget: the parent of its response input, or else the
140
- # first widget-high box. The host can span the content width while the 300 px
141
- # iframe sits at its left edge, so only the height is checked. Returns the
142
- # host's viewport rectangle.
143
- _TURNSTILE_BOX = """(() => {
144
- const fits = (r) => r.width >= 250 && r.height >= 55 && r.height <= 80;
145
- const input = document.querySelector('input[name="cf-turnstile-response"]');
146
- const candidates = [input && input.parentElement, ...document.querySelectorAll('div')];
147
- for (const element of candidates) {
148
- if (!element) continue;
149
- const r = element.getBoundingClientRect();
150
- if (fits(r)) return { x: r.left, y: r.top, w: r.width, h: r.height };
151
- }
152
- return null;
153
- })()"""
154
-
155
-
156
- async def human_click(mouse: Mouse, x: float, y: float) -> None:
157
- """Move along an eased, jittered curve to (x, y), then press and release.
158
-
159
- Both paths get the same cursor path through CDP input events, so the
160
- comparison measures the browser setup, not the cursor model.
161
- """
162
- start_x, start_y = x + _rng.uniform(250, 450), y + _rng.uniform(150, 300)
163
- control_x, control_y = (start_x + x) / 2 + _rng.uniform(-80, 80), start_y - 60
164
- steps = _rng.randint(22, 32)
165
- for step in range(1, steps + 1):
166
- t = step / steps
167
- t = t * t * (3 - 2 * t) # ease in and out
168
- px = (1 - t) ** 2 * start_x + 2 * (1 - t) * t * control_x + t * t * x
169
- py = (1 - t) ** 2 * start_y + 2 * (1 - t) * t * control_y + t * t * y
170
- await mouse("move", px + _rng.uniform(-0.8, 0.8), py + _rng.uniform(-0.8, 0.8))
171
- await asyncio.sleep(_rng.uniform(0.008, 0.022))
172
- await asyncio.sleep(_rng.uniform(0.12, 0.3))
173
- await mouse("down", x, y)
174
- await asyncio.sleep(_rng.uniform(0.06, 0.12))
175
- await mouse("up", x, y)
176
-
177
-
178
133
  def _config(mode: str) -> SessionConfig:
179
134
  return SessionConfig(
180
135
  channel="chromium",
@@ -189,22 +144,6 @@ def _page(page: CDPPage) -> Page:
189
144
  shot = await page.send("Page.captureScreenshot", {"format": "png"})
190
145
  path.write_bytes(base64.b64decode(shot["data"]))
191
146
 
192
- async def mouse(kind: str, x: float, y: float) -> None:
193
- # A held left button reports buttons=1 until it is released.
194
- event = {"move": "mouseMoved", "down": "mousePressed", "up": "mouseReleased"}[kind]
195
- pressed = kind == "down"
196
- await page.send(
197
- "Input.dispatchMouseEvent",
198
- {
199
- "type": event,
200
- "x": x,
201
- "y": y,
202
- "button": "none" if kind == "move" else "left",
203
- "buttons": 1 if pressed else 0,
204
- "clickCount": 0 if kind == "move" else 1,
205
- },
206
- )
207
-
208
147
  # Isolated worlds: the page cannot see the checks, and they need no globals.
209
148
  async def datadome_kind() -> str | None:
210
149
  for frame in await cdp_dom.frames(page):
@@ -212,7 +151,7 @@ def _page(page: CDPPage) -> Page:
212
151
  return await frame.page.evaluate(_DATADOME_KIND)
213
152
  return None
214
153
 
215
- return Page(page.evaluate, screenshot, mouse, datadome_kind)
154
+ return Page(page.evaluate, screenshot, datadome_kind)
216
155
 
217
156
 
218
157
  @asynccontextmanager
@@ -257,7 +196,7 @@ def _via_session() -> Callable[[str, str], AbstractAsyncContextManager[Page]]:
257
196
  """Run a target through a persistent session, as `webskrap browser` does.
258
197
 
259
198
  Every call attaches anew, like each CLI command or MCP tool call. Sessions
260
- run headless or headed; a virtual display and clicks are not offered.
199
+ run headless or headed; a virtual display is not offered.
261
200
  """
262
201
 
263
202
  @asynccontextmanager
@@ -281,10 +220,7 @@ def _via_session() -> Callable[[str, str], AbstractAsyncContextManager[Page]]:
281
220
  async def screenshot(path: Path) -> None:
282
221
  await act(lambda page: browser_session.screenshot(page, path, full_page=False))
283
222
 
284
- async def mouse(_kind: str, _x: float, _y: float) -> None:
285
- raise NotImplementedError("--click is not offered with --via session")
286
-
287
- yield Page(evaluate, screenshot, mouse)
223
+ yield Page(evaluate, screenshot)
288
224
  finally:
289
225
  browser_session.close_session(name, delete_data=True)
290
226
 
@@ -293,29 +229,6 @@ def _via_session() -> Callable[[str, str], AbstractAsyncContextManager[Page]]:
293
229
 
294
230
  SESSION_DRIVERS = {"session": _via_session()}
295
231
 
296
- # The widget box renders before the checkbox inside its iframe does.
297
- WIDGET_SETTLE_S = 3.0
298
-
299
-
300
- async def _click_turnstile(page: Page, shot: Path | None) -> bool:
301
- """Click the Turnstile checkbox once it shows; False while it is absent."""
302
- box = None
303
- with contextlib.suppress(Exception):
304
- box = await page.evaluate(_TURNSTILE_BOX)
305
- if not box:
306
- await asyncio.sleep(1)
307
- return False
308
- await asyncio.sleep(WIDGET_SETTLE_S)
309
- if shot is not None:
310
- with contextlib.suppress(Exception):
311
- shot.parent.mkdir(parents=True, exist_ok=True)
312
- await page.screenshot(shot.with_name(f"{shot.stem}-before-click.png"))
313
- # The checkbox sits near the widget's left edge, centered vertically.
314
- x = box["x"] + 22 + _rng.uniform(-3, 3)
315
- y = box["y"] + box["h"] / 2 + _rng.uniform(-3, 3)
316
- await human_click(page.mouse, x, y)
317
- return True
318
-
319
232
 
320
233
  async def run_once(
321
234
  driver: str,
@@ -323,7 +236,6 @@ async def run_once(
323
236
  target: str,
324
237
  wait_s: float,
325
238
  shot: Path | None = None,
326
- click: bool = False,
327
239
  via: str = "launch",
328
240
  ) -> dict[str, Any]:
329
241
  url, check = TARGETS[target]
@@ -332,7 +244,6 @@ async def run_once(
332
244
  # A pass after a challenge means the challenge cleared; a pass without one
333
245
  # means the target never challenged this run.
334
246
  challenged = False
335
- clicked = False
336
247
  try:
337
248
  drivers = SESSION_DRIVERS if via == "session" else DRIVERS
338
249
  async with drivers[driver](url, mode) as page:
@@ -348,9 +259,6 @@ async def run_once(
348
259
  challenged = challenged or state in ("challenge", "datadome", "captcha")
349
260
  if state == "passed":
350
261
  break
351
- if click and not clicked and state == "challenge":
352
- clicked = await _click_turnstile(page, shot)
353
- continue
354
262
  if state in _SETTLING and time.monotonic() - started >= SETTLE_S:
355
263
  break
356
264
  await asyncio.sleep(1)
@@ -368,7 +276,6 @@ async def run_once(
368
276
  "target": target,
369
277
  "seconds": round(time.monotonic() - started, 1),
370
278
  "challenged": challenged,
371
- "clicked": clicked,
372
279
  **verdict,
373
280
  }
374
281
 
@@ -390,11 +297,6 @@ async def main() -> None:
390
297
  type=Path,
391
298
  help="Write a PNG of each run's last state to this directory.",
392
299
  )
393
- parser.add_argument(
394
- "--click",
395
- action="store_true",
396
- help="Click a Cloudflare Turnstile checkbox when a challenge shows it.",
397
- )
398
300
  parser.add_argument(
399
301
  "--via",
400
302
  choices=("launch", "session"),
@@ -402,8 +304,8 @@ async def main() -> None:
402
304
  help="launch a browser per run, or go through a persistent session.",
403
305
  )
404
306
  args = parser.parse_args()
405
- if args.via == "session" and (args.click or "virtual-display" in (args.mode or [])):
406
- parser.error("--via session runs headless or headed, without --click")
307
+ if args.via == "session" and "virtual-display" in (args.mode or []):
308
+ parser.error("--via session runs headless or headed, not on a virtual display")
407
309
  modes = args.mode or (
408
310
  ["headless"] if args.via == "session" else ["headless", "virtual-display"]
409
311
  )
@@ -429,16 +331,13 @@ async def main() -> None:
429
331
  if args.screenshots
430
332
  else None
431
333
  )
432
- result = await run_once(
433
- driver, mode, target, args.wait, shot, click=args.click, via=args.via
434
- )
334
+ result = await run_once(driver, mode, target, args.wait, shot, via=args.via)
435
335
  results.append(result)
436
336
  cleared = result["challenged"] and result["state"] == "passed"
437
337
  print(
438
338
  f"{target:13} {mode:15} {driver:10} {result['state']:9} "
439
339
  f"{result['seconds']:5}s {result['detail']}"
440
- f"{' (after challenge)' if cleared else ''}"
441
- f"{' [clicked]' if result['clicked'] else ''}",
340
+ f"{' (after challenge)' if cleared else ''}",
442
341
  flush=True,
443
342
  )
444
343
 
@@ -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},