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.
- webskrap-3.2.2/.package-dist.dZyl3m/.gitignore +1 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/CHANGELOG.md +27 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/PKG-INFO +16 -3
- {webskrap-3.2.1 → webskrap-3.2.2}/README.md +15 -2
- webskrap-3.2.2/docs/diagnostics/datadome.md +212 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/pyproject.toml +1 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_dom.py +10 -2
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_session.py +5 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp_setup.py +46 -10
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/challenges.py +61 -10
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/client.py +8 -5
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/consent.py +2 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/guide.md +30 -5
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/models.py +22 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_vision.py +8 -2
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_browser.py +15 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_child_targets.py +126 -4
- webskrap-3.2.2/tests/test_cdp_setup.py +33 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_challenges.py +38 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_client_unit.py +19 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_consent.py +13 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_human.py +1 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_models.py +31 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/uv.lock +1 -1
- {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/cli.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/mcp.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.agents/skills/webskrap/references/python-api.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.claude/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.codex/skills/webskrap/SKILL.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.gitattributes +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/.gitignore +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/CONTRIBUTING.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/LICENSE +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/SECURITY.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/benchmarks.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/docs/design/cdp-driver.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/docs/migration-3.0.md +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/scripts/driver_parity.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/scripts/live_stealth_report.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/__init__.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/browser_cli.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/browser_session.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cdp.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cli.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/cli_output.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/diagnostics.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/display.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/errors.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/fetch_runtime.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/human.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/install.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/mcp_server.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/parsing.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/paths.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/profiles.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/proxy_auth.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/py.typed +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/search.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/text_search.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/src/webskrap/urls.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/conftest.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/accepted-202.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/api-403.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/article-access-denied.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/blocks/not-found-404.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing_challenge.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/bing_no_results.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg_challenge.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/fixtures/search/ddg_no_results.html +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/live_stealth_helpers.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_bot_detection.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_bot_detection_headless.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_cli.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_integration.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_browser_session_unit.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cdp_session.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_cli.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_consent_live.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_diagnostics.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_display.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_docs.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_errors.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_fetch_runtime.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_install.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_live_stealth_helpers.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_live_stealth_report.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_mcp.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_mcp_efficiency.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_parsing.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_paths.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_profiles.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_search.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_search_live.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_text_search.py +0 -0
- {webskrap-3.2.1 → webskrap-3.2.2}/tests/test_urls.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -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.
|
|
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.
|
|
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
|
|
86
|
-
|
|
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
|
|
52
|
-
|
|
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.
|
|
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(
|
|
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",
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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))
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
266
|
-
and reload the URL
|
|
267
|
-
|
|
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(
|
|
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:
|