webskrap 3.2.1__tar.gz → 3.2.3__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.3/.package-dist.zjESut/.gitignore +1 -0
  2. {webskrap-3.2.1 → webskrap-3.2.3}/CHANGELOG.md +47 -1
  3. {webskrap-3.2.1 → webskrap-3.2.3}/PKG-INFO +16 -3
  4. {webskrap-3.2.1 → webskrap-3.2.3}/README.md +15 -2
  5. {webskrap-3.2.1 → webskrap-3.2.3}/SECURITY.md +5 -0
  6. webskrap-3.2.3/docs/diagnostics/datadome.md +236 -0
  7. {webskrap-3.2.1 → webskrap-3.2.3}/pyproject.toml +1 -1
  8. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/browser_session.py +4 -0
  9. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cdp.py +4 -0
  10. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cdp_dom.py +10 -2
  11. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cdp_session.py +5 -1
  12. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cdp_setup.py +46 -10
  13. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/challenges.py +61 -10
  14. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/client.py +8 -5
  15. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/consent.py +2 -0
  16. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/guide.md +30 -5
  17. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/install.py +6 -1
  18. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/models.py +22 -1
  19. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/search.py +13 -3
  20. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/urls.py +4 -2
  21. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_browser_session_unit.py +15 -0
  22. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_browser_vision.py +8 -2
  23. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_cdp_browser.py +15 -0
  24. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_cdp_child_targets.py +126 -4
  25. webskrap-3.2.3/tests/test_cdp_setup.py +33 -0
  26. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_challenges.py +38 -0
  27. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_client_unit.py +19 -1
  28. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_consent.py +13 -0
  29. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_human.py +1 -0
  30. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_install.py +12 -0
  31. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_mcp.py +2 -0
  32. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_models.py +31 -1
  33. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_search.py +24 -0
  34. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_urls.py +18 -0
  35. {webskrap-3.2.1 → webskrap-3.2.3}/uv.lock +1 -1
  36. {webskrap-3.2.1 → webskrap-3.2.3}/.agents/skills/webskrap/SKILL.md +0 -0
  37. {webskrap-3.2.1 → webskrap-3.2.3}/.agents/skills/webskrap/references/cli.md +0 -0
  38. {webskrap-3.2.1 → webskrap-3.2.3}/.agents/skills/webskrap/references/mcp.md +0 -0
  39. {webskrap-3.2.1 → webskrap-3.2.3}/.agents/skills/webskrap/references/python-api.md +0 -0
  40. {webskrap-3.2.1 → webskrap-3.2.3}/.claude/skills/webskrap/SKILL.md +0 -0
  41. {webskrap-3.2.1 → webskrap-3.2.3}/.codex/skills/webskrap/SKILL.md +0 -0
  42. {webskrap-3.2.1 → webskrap-3.2.3}/.gitattributes +0 -0
  43. {webskrap-3.2.1 → webskrap-3.2.3}/.gitignore +0 -0
  44. {webskrap-3.2.1 → webskrap-3.2.3}/CONTRIBUTING.md +0 -0
  45. {webskrap-3.2.1 → webskrap-3.2.3}/LICENSE +0 -0
  46. {webskrap-3.2.1 → webskrap-3.2.3}/benchmarks.py +0 -0
  47. {webskrap-3.2.1 → webskrap-3.2.3}/docs/design/cdp-driver.md +0 -0
  48. {webskrap-3.2.1 → webskrap-3.2.3}/docs/migration-3.0.md +0 -0
  49. {webskrap-3.2.1 → webskrap-3.2.3}/scripts/driver_parity.py +0 -0
  50. {webskrap-3.2.1 → webskrap-3.2.3}/scripts/live_stealth_report.py +0 -0
  51. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/__init__.py +0 -0
  52. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/browser_cli.py +0 -0
  53. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cli.py +0 -0
  54. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/cli_output.py +0 -0
  55. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/diagnostics.py +0 -0
  56. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/display.py +0 -0
  57. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/errors.py +0 -0
  58. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/fetch_runtime.py +0 -0
  59. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/human.py +0 -0
  60. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/mcp_server.py +0 -0
  61. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/parsing.py +0 -0
  62. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/paths.py +0 -0
  63. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/profiles.py +0 -0
  64. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/proxy_auth.py +0 -0
  65. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/py.typed +0 -0
  66. {webskrap-3.2.1 → webskrap-3.2.3}/src/webskrap/text_search.py +0 -0
  67. {webskrap-3.2.1 → webskrap-3.2.3}/tests/conftest.py +0 -0
  68. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/accepted-202.html +0 -0
  69. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
  70. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
  71. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
  72. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/api-403.html +0 -0
  73. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/article-access-denied.html +0 -0
  74. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
  75. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
  76. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/blocks/not-found-404.html +0 -0
  77. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/bing.html +0 -0
  78. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/bing_challenge.html +0 -0
  79. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/bing_no_results.html +0 -0
  80. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/ddg.html +0 -0
  81. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/ddg_challenge.html +0 -0
  82. {webskrap-3.2.1 → webskrap-3.2.3}/tests/fixtures/search/ddg_no_results.html +0 -0
  83. {webskrap-3.2.1 → webskrap-3.2.3}/tests/live_stealth_helpers.py +0 -0
  84. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_bot_detection.py +0 -0
  85. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_bot_detection_headless.py +0 -0
  86. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_browser_cli.py +0 -0
  87. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_browser_integration.py +0 -0
  88. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_cdp.py +0 -0
  89. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_cdp_session.py +0 -0
  90. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_cli.py +0 -0
  91. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_consent_live.py +0 -0
  92. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_diagnostics.py +0 -0
  93. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_display.py +0 -0
  94. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_docs.py +0 -0
  95. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_errors.py +0 -0
  96. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_fetch_runtime.py +0 -0
  97. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_live_stealth_helpers.py +0 -0
  98. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_live_stealth_report.py +0 -0
  99. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_mcp_efficiency.py +0 -0
  100. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_parsing.py +0 -0
  101. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_paths.py +0 -0
  102. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_profiles.py +0 -0
  103. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_search_live.py +0 -0
  104. {webskrap-3.2.1 → webskrap-3.2.3}/tests/test_text_search.py +0 -0
@@ -9,6 +9,50 @@ history, so they summarize each release rather than list every change.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [3.2.3] - 2026-10-30
13
+
14
+ ### Security
15
+
16
+ - MCP URL checks now decode host escapes and treat backslashes as path
17
+ separators before checking for private addresses, as Chromium does.
18
+ - Browser installation rejects ZIP entries with Windows separators or drive
19
+ components that could write outside the install folder.
20
+
21
+ ### Fixed
22
+
23
+ - On macOS, Chrome for Testing no longer asks for the "Chromium Safe Storage"
24
+ keychain password at each launch. Launches now use `--use-mock-keychain`.
25
+ Upgrade note: on macOS, persistent sessions and `user_data_dir` profiles
26
+ made before this release used the real Keychain key. They lose their saved
27
+ cookies and logins once, and you must sign in again.
28
+ - Search skips malformed result URLs instead of discarding all results from
29
+ the page. This also applies to URLs decoded from search-engine redirects.
30
+
31
+ ## [3.2.2] - 2026-10-30
32
+
33
+ ### Fixed
34
+
35
+ - DataDome detection now follows the device check when it navigates its
36
+ frame. It reads the live challenge frame, and it reports a manual
37
+ challenge apart from a hard block. The user-agent probe now uses the
38
+ session proxy and network flags, so it cannot connect directly.
39
+ - Full desktop profiles now set `navigator.platform` to match the user
40
+ agent. They also fit the viewport inside the real browser window before
41
+ the metrics override.
42
+ - Cookie decline now finds the reject button of Commanders Act
43
+ TrustCommander notices. Their markup has no "cookie" or "consent" name, so
44
+ the text search did not look inside them.
45
+ - Full profiles no longer put q-values in `navigator.languages`, and no longer
46
+ send a doubled q-value in `Accept-Language` (`fr;q=0.9;q=0.9`). Chromium
47
+ weights the header itself, so the override now gets a plain list.
48
+ - With `profile_mode="full"` (or `"locale"` off Linux),
49
+ `navigator.serviceWorker.register()` no longer hangs. The page and the
50
+ browser both held the worker paused, and each waited for the other. Now
51
+ only the browser-level setup prepares service and shared workers; the page
52
+ only resumes them.
53
+ - Mouse presses now report `PointerEvent.pressure` 0.5 while the button is
54
+ down, as a real mouse does. CDP's default of 0 was visible to pages.
55
+
12
56
  ## [3.2.1] - 2026-10-26
13
57
 
14
58
  ### Removed
@@ -936,7 +980,9 @@ entries below.
936
980
 
937
981
  Earlier releases (0.1.0 - 0.4.9) are recorded in the git history and tags only.
938
982
 
939
- [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.1...HEAD
983
+ [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.3...HEAD
984
+ [3.2.3]: https://github.com/kacigaya/webskrap/compare/v3.2.2...v3.2.3
985
+ [3.2.2]: https://github.com/kacigaya/webskrap/compare/v3.2.1...v3.2.2
940
986
  [3.2.1]: https://github.com/kacigaya/webskrap/compare/v3.2.0...v3.2.1
941
987
  [3.2.0]: https://github.com/kacigaya/webskrap/compare/v3.1.0...v3.2.0
942
988
  [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.3
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
 
@@ -65,6 +65,11 @@ to permit them. The Python API and CLI take URLs from the operator and skip
65
65
  the DNS check; point them at internal hosts deliberately, not with
66
66
  page-derived input.
67
67
 
68
+ This check applies to the supplied URL. It does not filter redirects,
69
+ subresources, or requests from page scripts. DNS can also change after the
70
+ check. Use network-level restrictions when the browser must not reach private
71
+ services. The URL check alone does not provide full SSRF isolation.
72
+
68
73
  ### Chromium sandbox
69
74
 
70
75
  Persistent sessions keep Chromium's OS sandbox. That sandbox is what stops a
@@ -0,0 +1,236 @@
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
+ ### Reuse a solved session
70
+
71
+ A persistent session keeps its cookies in its profile on disk
72
+ (`~/.webskrap/browser/<session>/user-data`). The DataDome cookie from a
73
+ manual solve stays there. No extra flag or export step is necessary.
74
+
75
+ 1. Open the session by the same name:
76
+ `webskrap browser open --session <name> --headed <url>`.
77
+ 2. Keep the same exit IP. The cookie belongs to the session that passed
78
+ the check.
79
+ 3. Do not use the profile headless. A headless visit can receive a new
80
+ challenge or a hard block, and DataDome can then replace the cookie.
81
+
82
+ On 2026-10-30, a macOS run checked this on a DataDome website from a
83
+ residential IP. A person solved the CAPTCHA once in a headed session. A
84
+ reload then returned HTTP 200. The session was closed, and a new browser
85
+ process opened the same profile. A direct load of the protected page
86
+ returned HTTP 200 again, without a challenge. This run did not measure how
87
+ long the cookie stays valid. DataDome can challenge the session again at
88
+ any time.
89
+
90
+ WebSkrap does not export the cookie or copy it into other browsers. That is
91
+ token reuse, which is out of scope.
92
+
93
+ ## Verification limits
94
+
95
+ The final Linux ARM64 run passed 858 tests. It excluded 30 opt-in live tests
96
+ for unrelated public sites. Coverage was 88.85%, above the 85% project gate.
97
+ The focused suite passed 123 tests before the combined run.
98
+
99
+ Ruff lint and format checks, Pyright for Linux, Windows, and macOS, Bandit,
100
+ package builds, and Twine metadata checks passed. OSV found no vulnerabilities
101
+ in the 51 public packages from the lockfile. Pip-audit also found none.
102
+ Windows and macOS runtime tests require those operating systems and were not
103
+ run on this host.
104
+
105
+ Local regression fixtures cover device-check completion, a transition to a
106
+ manual challenge, a transition to a hard block, cross-origin frame navigation,
107
+ proxy authentication, image loading, and continuation with the same cookies.
108
+ The fixture's manual button is a local test control. It does not contact or
109
+ complete real CAPTCHA verification.
110
+
111
+ Live tests reached the rendered CAPTCHA. Navigation beyond verification
112
+ cannot be confirmed until a person completes it. The provider can still
113
+ refuse the session after manual verification. No forms were submitted.
114
+
115
+ ## VPN exit retest
116
+
117
+ Eight distinct French NordVPN exits were tested, including the initial
118
+ baseline. Each headed run returned HTTP 403 and a DataDome manual challenge.
119
+ Fresh browser profiles were used for the replacement exits. No CAPTCHA was
120
+ completed or bypassed. This sample did not identify a CAPTCHA-free exit.
121
+ It does not establish that IP reputation is the only input to the decision.
122
+
123
+ A control run placed the browser directly inside Gluetun's network namespace.
124
+ It used the same VPN exit without the HTTP proxy and still received a challenge.
125
+ The proxy is therefore not the sole cause.
126
+
127
+ Rotation also exposed an operational defect: Gluetun's NordVPN server catalog
128
+ was dated March 2024. Some listed endpoints timed out or rejected authentication.
129
+ The catalog was refreshed through Gluetun's existing updater. The original
130
+ catalog was backed up, and the VPN was restored to a healthy state.
131
+
132
+ The final verified exit was `187.13.198.190` in Marseille. It still required
133
+ verification. The refreshed catalog is in the container's writable layer;
134
+ container recreation can replace it because there is no catalog volume mount.
135
+
136
+ ## CDP identity investigation
137
+
138
+ An ordinary headed Chromium process was launched on the same VPN exit with
139
+ no debugging port, no debugging pipe, and no WebSkrap page setup. Its native
140
+ network log recorded an HTTP 403, a device check, and then a CAPTCHA.
141
+ This establishes that CDP is not required to trigger the current challenge.
142
+ It does not reveal DataDome's private scoring rule.
143
+
144
+ A separate public fingerprint probe reported `isBot: false` for default
145
+ headed WebSkrap. It reported no CDP, webdriver, worker-value, or client-hint
146
+ inconsistency. The headed Mesa/locale configuration also passed that probe.
147
+ Both configurations still received a CAPTCHA. The headless run was
148
+ flagged for its bot user agent and weak signals, with no CDP leak reported.
149
+ These are observations from one public probe, not universal detection claims.
150
+
151
+ Local page-owned probes compared request headers and JavaScript values in
152
+ documents, cross-origin iframes, dedicated workers, and shared workers.
153
+ Two full-profile setup defects were reproduced and fixed:
154
+
155
+ 1. A Windows or macOS user agent changed client hints but left the document's
156
+ `navigator.platform` at its native Linux value. The CDP override now sets
157
+ an unambiguous legacy platform as well. Unknown CPU values are not guessed.
158
+ 2. A desktop viewport could be wider than its real browser window. The new
159
+ regression reproduced `innerWidth=1365` with `outerWidth=780`. Setup now
160
+ resizes the real window before applying device metrics. A missing command
161
+ on an older browser keeps the previous behavior; other errors propagate.
162
+
163
+ The changes use Chromium's own
164
+ [user-agent override](https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setUserAgentOverride)
165
+ and [content-size command](https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setContentsSize).
166
+ No JavaScript getter replacements were added.
167
+
168
+ The probe also confirmed existing limits. UA masking removes high-entropy
169
+ client hints. Cross-process frames keep their real screen and pixel ratio.
170
+ Workers keep their native legacy platform, and shared workers keep their
171
+ native user agent. Full cross-platform emulation therefore cannot promise a
172
+ coherent identity across all contexts. Native mode leaves those values intact.
173
+ Neither the fixes nor changing the GPU and locale removed the website's verification
174
+ requirement in the measured runs.
175
+
176
+ The final follow-up suite ran inside Gluetun's network namespace. It passed
177
+ 872 tests with 88.89% coverage and excluded 30 opt-in live tests. The focused
178
+ suite passed 125 tests. Lint, format, Linux/Windows/macOS type checks, Bandit,
179
+ OSV, pip-audit, package builds, and Twine checks passed. Windows and macOS
180
+ runtime checks were not available on this Linux host.
181
+
182
+ ## Protected form retest
183
+
184
+ A later retest compared ordinary pages with the website's quote form path.
185
+ Each run used a fresh browser profile and a VPN exit that the run had not
186
+ used before.
187
+
188
+ The browser passed DataDome on ordinary pages. On some exits the first page
189
+ returned HTTP 403 with a device check (`x-dd-b: 3`). The device check then
190
+ reloaded into the requested page with HTTP 200. On other exits the first page
191
+ returned HTTP 200 at once. DataDome's page tag ran on those pages.
192
+
193
+ The quote form path returned HTTP 403 with a CAPTCHA on every exit. This
194
+ occurred with a direct load and with a click-through from the landing page.
195
+ These changes did not remove the CAPTCHA:
196
+
197
+ 1. Google Chrome stable for Linux ARM64 instead of Chrome for Testing. This
198
+ adds the `Google Chrome` brand.
199
+ 2. A locale and a timezone that match the exit country.
200
+ 3. Mesa WebGL.
201
+ 4. `x86` client hints instead of the host's `arm` value.
202
+ 5. A 25-second pause with mouse movement and wheel scrolling on each page.
203
+ 6. A four-page session with about 75 seconds of activity before the form.
204
+ 7. Exits in eleven countries on eight hosting ASNs.
205
+
206
+ A desktop-like host environment did not change the result. It added Noto
207
+ and Ubuntu fonts and PulseAudio audio devices. On a fresh exit, a direct load
208
+ of the form received a device check (`x-dd-b: 3`). The check reloaded into a
209
+ CAPTCHA. The same check reloaded into HTTP 200 on ordinary pages.
210
+
211
+ SwiftShader WebGL failed the device check and received a hard block.
212
+ The VPN provider's dedicated-IP servers refused this setup's credentials,
213
+ so a dedicated exit was not tested.
214
+
215
+ A Windows user agent made the result worse: the first page returned a hard
216
+ block. Repeated fresh profiles on one exit also made the result worse. A later
217
+ visit received the CAPTCHA, or a hard block, without a device check.
218
+
219
+ The VPN provider's catalog contains only hosting ASNs. The measured limit is
220
+ therefore the exit IP class for this form path, not a browser defect that
221
+ WebSkrap can fix. A residential or mobile IP was not tested.
222
+
223
+ The retest found two WebSkrap defects:
224
+
225
+ 1. Cookie decline did not search Commanders Act TrustCommander notices.
226
+ 2. Full profiles put q-values in `navigator.languages` and doubled them in
227
+ `Accept-Language`.
228
+
229
+ A later local fingerprint check found two more:
230
+
231
+ 1. With a full profile, `navigator.serviceWorker.register()` never
232
+ finished. The page and the browser both held the worker paused.
233
+ 2. Mouse presses reported `PointerEvent.pressure` 0. A real mouse reports
234
+ 0.5 while a button is down.
235
+
236
+ 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.3"
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"
@@ -499,6 +499,10 @@ def launch_browser(
499
499
  f"--user-data-dir={user_data_dir}",
500
500
  "--no-first-run",
501
501
  "--no-default-browser-check",
502
+ # Chrome for Testing is unsigned on macOS, so every launch asks the
503
+ # user for the "Chromium Safe Storage" keychain item. The mock keychain
504
+ # keeps cookie encryption profile-local; pages cannot see it.
505
+ *(["--use-mock-keychain"] if sys.platform == "darwin" else []),
502
506
  # A bfcache restore re-fires no load events, stranding the
503
507
  # go_back/go_forward load wait. Each command attaches anew and cannot
504
508
  # track restores across attachments, so trade bfcache for
@@ -861,6 +861,10 @@ async def launch(
861
861
  f"--user-data-dir={profile}",
862
862
  "--no-first-run",
863
863
  "--no-default-browser-check",
864
+ # Chrome for Testing is unsigned on macOS, so every launch asks the
865
+ # user for the "Chromium Safe Storage" keychain item. The mock keychain
866
+ # keeps cookie encryption profile-local; pages cannot see it.
867
+ *(["--use-mock-keychain"] if sys.platform == "darwin" else []),
864
868
  # Chromium builds (not branded Chrome) apply their testing field
865
869
  # trials by default. Stable Chrome never runs that config, and one of
866
870
  # its trials trims navigator.languages to a single entry.
@@ -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: