webskrap 3.2.2__tar.gz → 3.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {webskrap-3.2.2 → webskrap-3.2.4}/CHANGELOG.md +43 -1
  2. {webskrap-3.2.2 → webskrap-3.2.4}/CONTRIBUTING.md +1 -1
  3. {webskrap-3.2.2 → webskrap-3.2.4}/PKG-INFO +2 -1
  4. {webskrap-3.2.2 → webskrap-3.2.4}/SECURITY.md +9 -4
  5. {webskrap-3.2.2 → webskrap-3.2.4}/docs/diagnostics/datadome.md +27 -3
  6. {webskrap-3.2.2 → webskrap-3.2.4}/pyproject.toml +16 -3
  7. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/browser_session.py +4 -0
  8. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cdp.py +4 -0
  9. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/install.py +6 -1
  10. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/search.py +13 -3
  11. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/urls.py +4 -2
  12. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_browser_session_unit.py +15 -0
  13. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cli.py +2 -0
  14. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_install.py +12 -0
  15. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_mcp.py +2 -0
  16. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_search.py +24 -0
  17. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_urls.py +18 -0
  18. {webskrap-3.2.2 → webskrap-3.2.4}/uv.lock +1 -1
  19. webskrap-3.2.2/.agents/skills/webskrap/SKILL.md +0 -151
  20. webskrap-3.2.2/.agents/skills/webskrap/references/cli.md +0 -154
  21. webskrap-3.2.2/.agents/skills/webskrap/references/mcp.md +0 -130
  22. webskrap-3.2.2/.agents/skills/webskrap/references/python-api.md +0 -147
  23. webskrap-3.2.2/.claude/skills/webskrap/SKILL.md +0 -1
  24. webskrap-3.2.2/.codex/skills/webskrap/SKILL.md +0 -1
  25. webskrap-3.2.2/tests/test_docs.py +0 -44
  26. {webskrap-3.2.2 → webskrap-3.2.4}/.gitattributes +0 -0
  27. {webskrap-3.2.2 → webskrap-3.2.4}/.gitignore +0 -0
  28. {webskrap-3.2.2/.package-dist.dZyl3m → webskrap-3.2.4/.package-dist.sya6Sl}/.gitignore +0 -0
  29. {webskrap-3.2.2 → webskrap-3.2.4}/LICENSE +0 -0
  30. {webskrap-3.2.2 → webskrap-3.2.4}/README.md +0 -0
  31. {webskrap-3.2.2 → webskrap-3.2.4}/benchmarks.py +0 -0
  32. {webskrap-3.2.2 → webskrap-3.2.4}/docs/design/cdp-driver.md +0 -0
  33. {webskrap-3.2.2 → webskrap-3.2.4}/docs/migration-3.0.md +0 -0
  34. {webskrap-3.2.2 → webskrap-3.2.4}/scripts/driver_parity.py +0 -0
  35. {webskrap-3.2.2 → webskrap-3.2.4}/scripts/live_stealth_report.py +0 -0
  36. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/__init__.py +0 -0
  37. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/browser_cli.py +0 -0
  38. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cdp_dom.py +0 -0
  39. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cdp_session.py +0 -0
  40. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cdp_setup.py +0 -0
  41. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/challenges.py +0 -0
  42. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cli.py +0 -0
  43. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/cli_output.py +0 -0
  44. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/client.py +0 -0
  45. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/consent.py +0 -0
  46. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/diagnostics.py +0 -0
  47. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/display.py +0 -0
  48. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/errors.py +0 -0
  49. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/fetch_runtime.py +0 -0
  50. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/guide.md +0 -0
  51. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/human.py +0 -0
  52. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/mcp_server.py +0 -0
  53. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/models.py +0 -0
  54. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/parsing.py +0 -0
  55. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/paths.py +0 -0
  56. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/profiles.py +0 -0
  57. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/proxy_auth.py +0 -0
  58. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/py.typed +0 -0
  59. {webskrap-3.2.2 → webskrap-3.2.4}/src/webskrap/text_search.py +0 -0
  60. {webskrap-3.2.2 → webskrap-3.2.4}/tests/conftest.py +0 -0
  61. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/accepted-202.html +0 -0
  62. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/akamai-invalid-url-400.html +0 -0
  63. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/akamai-reference-403.html +0 -0
  64. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/akamai-sec-cpt.html +0 -0
  65. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/api-403.html +0 -0
  66. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/article-access-denied.html +0 -0
  67. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/cloudflare-1020.html +0 -0
  68. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/cloudflare-custom-block.html +0 -0
  69. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/blocks/not-found-404.html +0 -0
  70. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/bing.html +0 -0
  71. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/bing_challenge.html +0 -0
  72. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/bing_no_results.html +0 -0
  73. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/ddg.html +0 -0
  74. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/ddg_challenge.html +0 -0
  75. {webskrap-3.2.2 → webskrap-3.2.4}/tests/fixtures/search/ddg_no_results.html +0 -0
  76. {webskrap-3.2.2 → webskrap-3.2.4}/tests/live_stealth_helpers.py +0 -0
  77. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_bot_detection.py +0 -0
  78. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_bot_detection_headless.py +0 -0
  79. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_browser_cli.py +0 -0
  80. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_browser_integration.py +0 -0
  81. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_browser_vision.py +0 -0
  82. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cdp.py +0 -0
  83. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cdp_browser.py +0 -0
  84. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cdp_child_targets.py +0 -0
  85. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cdp_session.py +0 -0
  86. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_cdp_setup.py +0 -0
  87. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_challenges.py +0 -0
  88. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_client_unit.py +0 -0
  89. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_consent.py +0 -0
  90. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_consent_live.py +0 -0
  91. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_diagnostics.py +0 -0
  92. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_display.py +0 -0
  93. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_errors.py +0 -0
  94. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_fetch_runtime.py +0 -0
  95. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_human.py +0 -0
  96. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_live_stealth_helpers.py +0 -0
  97. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_live_stealth_report.py +0 -0
  98. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_mcp_efficiency.py +0 -0
  99. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_models.py +0 -0
  100. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_parsing.py +0 -0
  101. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_paths.py +0 -0
  102. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_profiles.py +0 -0
  103. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_search_live.py +0 -0
  104. {webskrap-3.2.2 → webskrap-3.2.4}/tests/test_text_search.py +0 -0
@@ -9,6 +9,46 @@ history, so they summarize each release rather than list every change.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [3.2.4] - 2026-11-01
13
+
14
+ ### Added
15
+
16
+ - Python 3.15 is a supported version. CI runs the test suite on it, and the
17
+ package metadata lists it.
18
+
19
+ ### Changed
20
+
21
+ - The source distribution no longer contains the agent skill folders and the
22
+ test that checks them. They stay in the Git repository. Before, the sdist
23
+ held only one of the linked skill reference paths, and its test failed.
24
+
25
+ ### Fixed
26
+
27
+ - The CLI guide lists the exit status for each error code. It said before that
28
+ every browser command failure exits with 1.
29
+ - The MCP guide and the agent reference now describe `browser_wait_for`.
30
+ - `SECURITY.md` names the supported release line without an old version
31
+ number.
32
+
33
+ ## [3.2.3] - 2026-10-30
34
+
35
+ ### Security
36
+
37
+ - MCP URL checks now decode host escapes and treat backslashes as path
38
+ separators before checking for private addresses, as Chromium does.
39
+ - Browser installation rejects ZIP entries with Windows separators or drive
40
+ components that could write outside the install folder.
41
+
42
+ ### Fixed
43
+
44
+ - On macOS, Chrome for Testing no longer asks for the "Chromium Safe Storage"
45
+ keychain password at each launch. Launches now use `--use-mock-keychain`.
46
+ Upgrade note: on macOS, persistent sessions and `user_data_dir` profiles
47
+ made before this release used the real Keychain key. They lose their saved
48
+ cookies and logins once, and you must sign in again.
49
+ - Search skips malformed result URLs instead of discarding all results from
50
+ the page. This also applies to URLs decoded from search-engine redirects.
51
+
12
52
  ## [3.2.2] - 2026-10-30
13
53
 
14
54
  ### Fixed
@@ -961,7 +1001,9 @@ entries below.
961
1001
 
962
1002
  Earlier releases (0.1.0 - 0.4.9) are recorded in the git history and tags only.
963
1003
 
964
- [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.2...HEAD
1004
+ [Unreleased]: https://github.com/kacigaya/webskrap/compare/v3.2.4...HEAD
1005
+ [3.2.4]: https://github.com/kacigaya/webskrap/compare/v3.2.3...v3.2.4
1006
+ [3.2.3]: https://github.com/kacigaya/webskrap/compare/v3.2.2...v3.2.3
965
1007
  [3.2.2]: https://github.com/kacigaya/webskrap/compare/v3.2.1...v3.2.2
966
1008
  [3.2.1]: https://github.com/kacigaya/webskrap/compare/v3.2.0...v3.2.1
967
1009
  [3.2.0]: https://github.com/kacigaya/webskrap/compare/v3.1.0...v3.2.0
@@ -5,7 +5,7 @@ what a reviewable pull request looks like.
5
5
 
6
6
  ## Requirements
7
7
 
8
- - Python 3.11, 3.12, 3.13, or 3.14 (CI runs all four).
8
+ - Python 3.11, 3.12, 3.13, 3.14, or 3.15 (CI runs all five).
9
9
  - [uv](https://docs.astral.sh/uv/) for environments and dependencies.
10
10
  - A Chromium build for browser tests, installed via `webskrap install`.
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webskrap
3
- Version: 3.2.2
3
+ Version: 3.2.4
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
20
21
  Classifier: Typing :: Typed
21
22
  Requires-Python: >=3.11
22
23
  Requires-Dist: mcp<3,>=2
@@ -5,10 +5,10 @@
5
5
  WebSkrap is developed on `main` and released from tags. Fixes land in a new
6
6
  patch release on top of the latest minor; older minors are not backported.
7
7
 
8
- | Version | Supported |
9
- | ----------------------------------------- | --------- |
10
- | Latest minor (2.13.x as of 2026-10-01) | Yes |
11
- | Older minors | No |
8
+ | Version | Supported |
9
+ | ------------ | --------- |
10
+ | Latest minor | Yes |
11
+ | Older minors | No |
12
12
 
13
13
  ## Reporting a vulnerability
14
14
 
@@ -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
@@ -66,6 +66,30 @@ A temporary headed browser session and loopback VNC listener on port 15900 were
66
66
  to prepare manual completion. Both were closed when manual completion was
67
67
  declined. Xvfb alone does not give remote access.
68
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
+
69
93
  ## Verification limits
70
94
 
71
95
  The final Linux ARM64 run passed 858 tests. It excluded 30 opt-in live tests
@@ -105,9 +129,9 @@ was dated March 2024. Some listed endpoints timed out or rejected authentication
105
129
  The catalog was refreshed through Gluetun's existing updater. The original
106
130
  catalog was backed up, and the VPN was restored to a healthy state.
107
131
 
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.
132
+ The final verified exit still required verification. The refreshed catalog is
133
+ in the container's writable layer; container recreation can replace it because
134
+ there is no catalog volume mount.
111
135
 
112
136
  ## CDP identity investigation
113
137
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webskrap"
7
- version = "3.2.2"
7
+ version = "3.2.4"
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"
@@ -22,6 +22,7 @@ classifiers = [
22
22
  "Programming Language :: Python :: 3.12",
23
23
  "Programming Language :: Python :: 3.13",
24
24
  "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: Python :: 3.15",
25
26
  "Typing :: Typed",
26
27
  ]
27
28
  dependencies = [
@@ -56,8 +57,20 @@ packages = ["src/webskrap"]
56
57
 
57
58
  [tool.hatch.build.targets.sdist]
58
59
  # The web/ frontend ships only to GitHub Pages, never to PyPI. assets/ holds
59
- # the README logo, which no install step reads.
60
- exclude = ["web", ".github", "assets"]
60
+ # the README logo, which no install step reads. The agent skill folders link
61
+ # to one shared references/ copy, and Hatch keeps only one path to a linked
62
+ # directory, so they stay in the Git checkout together with test_docs.py,
63
+ # which checks them.
64
+ exclude = [
65
+ "web",
66
+ ".github",
67
+ "assets",
68
+ ".agents",
69
+ ".claude",
70
+ ".codex",
71
+ "references",
72
+ "tests/test_docs.py",
73
+ ]
61
74
 
62
75
  [tool.pytest.ini_options]
63
76
  asyncio_mode = "auto"
@@ -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.
@@ -164,7 +164,12 @@ def _download(url: str, target: IO[bytes]) -> int:
164
164
  def _safe_member(name: str) -> PurePosixPath:
165
165
  """Return ``name`` as a relative path, refusing absolute or ``..`` paths."""
166
166
  path = PurePosixPath(name)
167
- if path.is_absolute() or ".." in path.parts or (path.parts and ":" in path.parts[0]):
167
+ if (
168
+ path.is_absolute()
169
+ or ".." in path.parts
170
+ or "\\" in name
171
+ or any(":" in part for part in path.parts)
172
+ ):
168
173
  msg = f"refusing archive entry outside the install folder: {name}"
169
174
  raise WebSkrapError(msg, code=ErrorCode.BROWSER_LAUNCH)
170
175
  return path
@@ -165,7 +165,10 @@ def _ddg_unwrap(href: str) -> str:
165
165
  """
166
166
  if href.startswith("//"):
167
167
  href = "https:" + href
168
- parts = urlsplit(href)
168
+ try:
169
+ parts = urlsplit(href)
170
+ except ValueError:
171
+ return href
169
172
  if parts.netloc.endswith("duckduckgo.com") and parts.path.startswith("/l/"):
170
173
  return parse_qs(parts.query).get("uddg", [""])[0]
171
174
  return href
@@ -200,7 +203,10 @@ def _bing_unwrap(href: str) -> str:
200
203
  URL-safe base64 of the destination, without padding. Anything that does
201
204
  not decode comes back as given rather than failing the whole page.
202
205
  """
203
- parts = urlsplit(href)
206
+ try:
207
+ parts = urlsplit(href)
208
+ except ValueError:
209
+ return href
204
210
  if not (parts.netloc.endswith("bing.com") and parts.path == "/ck/a"):
205
211
  return href
206
212
  encoded = parse_qs(parts.query).get("u", [""])[0]
@@ -292,7 +298,11 @@ def parse_results(engine: SearchEngine, html: str) -> list[SearchHit]:
292
298
  seen: set[str] = set()
293
299
  hits: list[SearchHit] = []
294
300
  for hit in spec.hits(root):
295
- if urlsplit(hit.url).scheme not in ("http", "https") or hit.url in seen:
301
+ try:
302
+ scheme = urlsplit(hit.url).scheme
303
+ except ValueError:
304
+ continue
305
+ if scheme not in ("http", "https") or hit.url in seen:
296
306
  continue
297
307
  seen.add(hit.url)
298
308
  hits.append(hit)
@@ -19,7 +19,7 @@ import asyncio
19
19
  import ipaddress
20
20
  import os
21
21
  import socket
22
- from urllib.parse import urlsplit
22
+ from urllib.parse import unquote, urlsplit
23
23
 
24
24
  from webskrap.errors import ErrorCode, WebSkrapError
25
25
 
@@ -122,7 +122,9 @@ async def reject_private_network(url: str) -> None:
122
122
  if urlsplit(url).scheme.lower() not in ("http", "https"):
123
123
  # data: and about:blank perform no network request; nothing to guard.
124
124
  return
125
- hostname = urlsplit(url).hostname or ""
125
+ # Chromium treats backslashes as path separators and decodes host escapes.
126
+ # Check the host it will contact, not urllib's literal authority.
127
+ hostname = unquote(urlsplit(url.replace("\\", "/")).hostname or "")
126
128
  if _host_is_blocked_literal(hostname):
127
129
  blocked = True
128
130
  else:
@@ -5,6 +5,7 @@ import os
5
5
  import signal
6
6
  import stat
7
7
  import subprocess
8
+ import sys
8
9
  import threading
9
10
  from datetime import datetime, timedelta
10
11
  from pathlib import Path
@@ -62,6 +63,20 @@ def _launch(tmp_path: Path, **kwargs: Any) -> None:
62
63
  browser_session.launch_browser(tmp_path, executable="/bin/chromium", **kwargs)
63
64
 
64
65
 
66
+ def test_launch_uses_mock_keychain_on_macos_only(
67
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
68
+ ) -> None:
69
+ commands = _capture_launch(monkeypatch)
70
+
71
+ monkeypatch.setattr(sys, "platform", "darwin")
72
+ _launch(tmp_path, headless=True)
73
+ monkeypatch.setattr(sys, "platform", "linux")
74
+ _launch(tmp_path, headless=True)
75
+
76
+ assert "--use-mock-keychain" in commands[0]
77
+ assert "--use-mock-keychain" not in commands[1]
78
+
79
+
65
80
  def test_launch_sandboxes_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
66
81
  commands = _capture_launch(monkeypatch)
67
82
 
@@ -786,6 +786,8 @@ def test_schema_human_format_lists_commands() -> None:
786
786
 
787
787
 
788
788
  def test_fetch_stays_sandboxed_by_default(monkeypatch: Any) -> None:
789
+ # The host can opt out globally; this test checks the built-in default.
790
+ monkeypatch.delenv("WEBSKRAP_CHROMIUM_SANDBOX", raising=False)
789
791
  _fake_client(monkeypatch)
790
792
 
791
793
  result = runner.invoke(cli.app, ["fetch", "https://example.test", "--format", "json"])
@@ -183,3 +183,15 @@ def test_install_refuses_an_archive_without_the_browser(
183
183
  def test_browsers_dir_honors_the_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
184
184
  monkeypatch.setenv("WEBSKRAP_BROWSERS_PATH", str(tmp_path))
185
185
  assert install.browsers_dir() == tmp_path
186
+
187
+
188
+ @pytest.mark.parametrize(
189
+ "name",
190
+ [r"..\escape", r"\escape", r"\\server\share\escape", "folder/C:/escape", "folder/C:escape"],
191
+ )
192
+ def test_extract_refuses_windows_path_escapes(tmp_path: Path, name: str) -> None:
193
+ archive = _archive(tmp_path, [(name, b"x", stat.S_IFREG | 0o644)])
194
+ with pytest.raises(WebSkrapError) as error:
195
+ install.extract(archive, tmp_path / "out")
196
+ assert error.value.code is ErrorCode.BROWSER_LAUNCH
197
+ assert not (tmp_path / "out").exists()
@@ -734,6 +734,8 @@ def test_guide_documents_every_error_code() -> None:
734
734
  pytest.param("ftp://example.test/x", id="ftp"),
735
735
  pytest.param("https://user:pass@example.test/", id="userinfo"),
736
736
  pytest.param("http://169.254.169.254/", id="link-local"),
737
+ pytest.param("http://%31%32%37.0.0.1/", id="encoded-loopback"),
738
+ pytest.param(r"http://127.0.0.1\example.com/", id="backslash-host"),
737
739
  ],
738
740
  )
739
741
  def test_fetch_rejects_untrusted_targets_before_launch(monkeypatch: Any, url: str) -> None:
@@ -7,7 +7,9 @@ engine changes its markup, refresh the fixture and fix the one extractor.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import base64
10
11
  from pathlib import Path
12
+ from urllib.parse import quote
11
13
 
12
14
  import pytest
13
15
 
@@ -241,3 +243,25 @@ def test_unclosed_and_void_tags_do_not_swallow_later_results() -> None:
241
243
  assert [hit.url for hit in hits] == ["https://a.example/", "https://b.example/"]
242
244
  assert hits[0].title == "A one"
243
245
  assert hits[0].snippet == "first"
246
+
247
+
248
+ @pytest.mark.parametrize("engine", list(SearchEngine))
249
+ @pytest.mark.parametrize("wrapped", [False, True])
250
+ def test_malformed_result_url_keeps_valid_neighbors(engine: SearchEngine, wrapped: bool) -> None:
251
+ href = "http://[broken"
252
+ if wrapped:
253
+ if engine is SearchEngine.DDG:
254
+ href = "https://duckduckgo.com/l/?uddg=" + quote(href, safe="")
255
+ else:
256
+ payload = base64.urlsafe_b64encode(href.encode()).decode().rstrip("=")
257
+ href = "https://www.bing.com/ck/a?u=a1" + payload
258
+ if engine is SearchEngine.DDG:
259
+ template = '<div class="result"><a class="result__a" href="{}">hit</a></div>'
260
+ else:
261
+ template = '<li class="b_algo"><h2><a href="{}">hit</a></h2></li>'
262
+ html = '<ol id="b_results">' + template.format(href) + template.format("https://example.com/")
263
+ html += "</ol>"
264
+
265
+ assert parse_results(engine, html) == [
266
+ SearchHit(title="hit", url="https://example.com/", snippet="")
267
+ ]
@@ -75,6 +75,10 @@ def test_private_net_opt_in_values(monkeypatch: pytest.MonkeyPatch, value: str)
75
75
  "url",
76
76
  [
77
77
  pytest.param("http://127.0.0.1:8000/", id="loopback-v4"),
78
+ pytest.param("http://%31%32%37.0.0.1/", id="encoded-loopback"),
79
+ pytest.param("http://%6cocalhost/", id="encoded-localhost"),
80
+ pytest.param(r"http://127.0.0.1\example.com/", id="backslash-host"),
81
+ pytest.param(r"http://%31%32%37.0.0.1\example.com/", id="encoded-backslash-host"),
78
82
  pytest.param("http://[::1]/", id="loopback-v6"),
79
83
  pytest.param("http://10.0.0.1/", id="rfc1918-10"),
80
84
  pytest.param("http://192.168.1.1/", id="rfc1918-192"),
@@ -145,3 +149,17 @@ def test_validate_mcp_url_combines_both_checks(monkeypatch: pytest.MonkeyPatch)
145
149
  asyncio.run(validate_mcp_url("http://169.254.169.254/"))
146
150
 
147
151
  assert asyncio.run(validate_mcp_url("https://example.com/")) == "https://example.com/"
152
+
153
+
154
+ def test_private_network_resolves_decoded_public_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
155
+ monkeypatch.delenv(PRIVATE_NET_ENV, raising=False)
156
+ hosts: list[str] = []
157
+
158
+ def resolve(hostname: str) -> bool:
159
+ hosts.append(hostname)
160
+ return False
161
+
162
+ monkeypatch.setattr("webskrap.urls._resolve_blocked", resolve)
163
+ url = "https://%65xample.com/path%20name"
164
+ assert asyncio.run(validate_mcp_url(url)) == url
165
+ assert hosts == ["example.com"]
@@ -1163,7 +1163,7 @@ wheels = [
1163
1163
 
1164
1164
  [[package]]
1165
1165
  name = "webskrap"
1166
- version = "3.2.2"
1166
+ version = "3.2.4"
1167
1167
  source = { editable = "." }
1168
1168
  dependencies = [
1169
1169
  { name = "mcp" },
@@ -1,151 +0,0 @@
1
- ---
2
- name: webskrap
3
- description: Use when writing, debugging, documenting, or reviewing Python scraping, web search, and visual browser automation with WebSkrap. Not for ordinary HTTP clients or search APIs.
4
- ---
5
-
6
- # WebSkrap
7
-
8
- WebSkrap is an async Python scraping package that drives Chromium through its
9
- own Chrome DevTools Protocol (CDP) client, with no Playwright. The Python API,
10
- `webskrap` CLI, and `webskrap-mcp` server share core behavior.
11
-
12
- WebSkrap loads known URLs. To find URLs it loads DuckDuckGo or Bing's
13
- results page in the same browser and parses the hits. It calls no search API
14
- and it does not offer Google.
15
-
16
- ## Workflow
17
-
18
- 1. Identify the affected public surface: Python, CLI, MCP, or more than one.
19
- 2. Read its implementation and nearest tests. Trace calls into shared code
20
- before editing.
21
- 3. Change the shared implementation when behavior belongs to more than one
22
- surface. Do not patch each wrapper independently.
23
- 4. Update affected public contracts: models, serialization, errors, command
24
- help, MCP schemas, guides, and tests.
25
- 5. Run focused tests during development, then the full validation gate before
26
- handoff when practical.
27
-
28
- ## Route by task
29
-
30
- | Task | Implementation | Tests and contracts |
31
- | --- | --- | --- |
32
- | Fetching, sessions, links, or screenshots | `src/webskrap/client.py`, `src/webskrap/models.py` | `tests/test_client_unit.py`, `tests/test_models.py` |
33
- | Search engines, redirect unwrapping, or results parsing | `src/webskrap/search.py`, `src/webskrap/models.py` | `tests/test_search.py` and its fixtures under `tests/fixtures/search/` |
34
- | Parsing | `src/webskrap/parsing.py` | `tests/test_parsing.py` |
35
- | Errors, hints, or exit statuses | `src/webskrap/errors.py`, `src/webskrap/cli_output.py` | `tests/test_errors.py`, CLI and MCP callers |
36
- | Main CLI commands | `src/webskrap/cli.py` | `tests/test_cli.py`, `tests/test_diagnostics.py` |
37
- | Persistent browser state | `src/webskrap/browser_session.py`, `src/webskrap/paths.py` | `tests/test_browser_session_unit.py`, `tests/test_paths.py` |
38
- | Interactive browser commands | `src/webskrap/browser_cli.py`, `src/webskrap/mcp_server.py` | `tests/test_browser_cli.py`, `tests/test_browser_integration.py`, `tests/test_mcp.py` |
39
- | MCP tools, resources, or annotations | `src/webskrap/mcp_server.py` | `tests/test_mcp.py`, `src/webskrap/guide.md` |
40
- | Profiles or stealth defaults | `src/webskrap/profiles.py`, `src/webskrap/models.py`, `src/webskrap/client.py` | `tests/test_profiles.py`, opt-in bot-detection tests when needed |
41
- | Cookie rejection | `src/webskrap/consent.py` | `tests/test_consent.py`; `tests/test_consent_live.py` only for selector rot |
42
- | Documentation and examples | `README.md`, `src/webskrap/guide.md` | `tests/test_docs.py`, current CLI help, public Python exports |
43
-
44
- Search for every constructor, serializer, command, and schema that uses a
45
- changed model or field. Fetch and search behavior cross Python, CLI, and MCP.
46
- Persistent browser behavior crosses the browser CLI and MCP. Engine markup
47
- lives only in `src/webskrap/search.py`. When an engine changes its page,
48
- refresh that engine's fixture and fix that one extractor.
49
-
50
- ## Choose the public surface
51
-
52
- | Need | Python | CLI | MCP |
53
- | --- | --- | --- | --- |
54
- | Fetch one known URL | `client.fetch()` | `webskrap fetch` | `stealth_fetch` |
55
- | Find URLs for a query | `client.search()` | `webskrap search` | `search` |
56
- | Preserve cookies or storage | `client.session()` | `webskrap browser open` | `browser_open` |
57
- | See a rendered page or interact with canvas | `browser_session.view()` on a session page | `webskrap browser view` and `mouse` | `browser_view` and `browser_mouse` |
58
- | Click, fill, wait, or run a flow | `session.new_page()` and `human_click()` | `webskrap browser` | `browser_interact` and related tools |
59
- | Diagnose installation | Inspect raised error | `webskrap doctor` | `doctor` |
60
-
61
- Use a one-shot fetch for one page. Use a persistent browser only when state or
62
- interaction must survive between operations. Do not repeatedly fetch a page to
63
- drive a multi-step flow.
64
-
65
- Read only the reference needed for the task:
66
-
67
- - [Python API](references/python-api.md): sessions, pages, profile modes, consent, links,
68
- and fingerprint controls.
69
- - [CLI](references/cli.md): one-shot fetches, persistent browser commands, and
70
- Linux ARM64 behavior.
71
- - [MCP](references/mcp.md): tool selection, confined paths, annotations, and
72
- keeping tool results small.
73
-
74
- ## Visual browser interaction
75
-
76
- Use `browser_view` (MCP) or `webskrap browser view --format json` (CLI) when
77
- layout, images, canvas, or controls missing from the accessibility tree matter.
78
- MCP returns an image block directly. CLI saves a PNG; open its returned `path`
79
- with the agent's image-viewing tool before choosing coordinates. A path or
80
- base64 text alone does not give a model vision. The client and model must
81
- support image input.
82
-
83
- The view covers the viewport at one PNG pixel per CSS pixel. Coordinates start
84
- at its top-left; use the original `width` and `height` if the image viewer
85
- resizes it. Do not add `scroll_x` or `scroll_y` to mouse coordinates. Full-page
86
- screenshots are for inspection or export and do not map directly to the mouse.
87
-
88
- Prefer refs/selectors when they identify a control. Use `browser_mouse` / CLI
89
- `mouse` for visual targets, then `browser_insert_text` / CLI `insert-text` for
90
- literal text in the focused control and `browser_press` / CLI `press` for keys.
91
- Wait for the expected change and take a fresh view after scrolling, navigation,
92
- or layout changes. Coordinate clicks hit whatever occupies that point.
93
- Run dependent actions sequentially within a session.
94
-
95
- This is the browser's rendered page, available headless without Xvfb. It does
96
- not show browser chrome, OS dialogs, or a desktop stream. One-shot fetch
97
- sessions are separate; open a persistent browser to drive a visual flow.
98
- Read the [CLI](references/cli.md) or [MCP](references/mcp.md) reference for examples.
99
-
100
- ## Guardrails
101
-
102
- - Use public pages, local test servers, or targets the user is authorized to
103
- access.
104
- - Do not add CAPTCHA solving, login-wall bypass, credential bypass, or
105
- access-control circumvention.
106
- - Do not commit cookies, storage state, proxy credentials, or persistent browser
107
- data such as `.webskrap/`.
108
- - Preserve Chromium's sandbox by default. Any opt-out must remain explicit and
109
- outside page-controlled input.
110
- - Keep MCP file paths confined to their configured roots. Reject absolute paths,
111
- traversal, and symlink escapes.
112
- - Prefer public `webskrap` exports. Use private helpers only in tests.
113
- - Keep changes typed. Add focused tests for parsing, state transitions, output
114
- contracts, and tool-safety behavior.
115
-
116
- ## Public contract checklist
117
-
118
- When public behavior changes, check all applicable items:
119
-
120
- - Python models and exports
121
- - CLI arguments, help, JSON output, and exit status
122
- - MCP input schema, result shape, annotations, and server instructions
123
- - `README.md` and `src/webskrap/guide.md`
124
- - unit tests for each affected surface
125
-
126
- Use the implementation as the source of truth. In particular, read
127
- `src/webskrap/models.py` for result fields, `src/webskrap/errors.py` for error
128
- codes and exits, and `src/webskrap/diagnostics.py` for environment reporting.
129
- Do not copy these catalogs into the skill.
130
-
131
- ## Validation
132
-
133
- Run the nearest tests while editing. Before handoff, run the repository gate
134
- when practical:
135
-
136
- ```bash
137
- uv run --extra dev pytest -q -m "not browser and not live"
138
- uv run --extra dev ruff check .
139
- uv run --extra dev ruff format --check .
140
- uvx --from pyright==1.1.411 pyright --pythonpath .venv/bin/python src/webskrap
141
- uv run --extra dev bandit -c pyproject.toml -q -r src/
142
- uv run --extra dev --with pip-audit==2.9.0 pip-audit
143
- uv build
144
- ```
145
-
146
- Run browser tests when browser behavior changes and the required browsers are
147
- installed.
148
-
149
- Use `WEBSKRAP_LIVE=1 pytest -q -m live` only when explicitly checking public
150
- third-party behavior. Live tests are non-deterministic and do not replace the
151
- default suite.