webget-cli 0.14.0__tar.gz → 0.15.0__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.
- {webget_cli-0.14.0/webget_cli.egg-info → webget_cli-0.15.0}/PKG-INFO +37 -2
- {webget_cli-0.14.0 → webget_cli-0.15.0}/README.md +36 -1
- {webget_cli-0.14.0 → webget_cli-0.15.0}/pyproject.toml +1 -1
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_http.py +6 -1
- webget_cli-0.15.0/tests/test_browser_discovery.py +270 -0
- webget_cli-0.15.0/tests/test_doctor.py +119 -0
- webget_cli-0.15.0/tests/test_truncate.py +88 -0
- webget_cli-0.15.0/webget/browser.py +276 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/cli.py +125 -1
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/firecrawl.py +3 -1
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/http.py +3 -2
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/ladder.py +77 -46
- webget_cli-0.15.0/webget/truncate.py +50 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0/webget_cli.egg-info}/PKG-INFO +37 -2
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/SOURCES.txt +5 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/LICENSE +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/setup.cfg +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_base64_strip.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cli_engine.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_cli_provenance.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_health.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_login_flow.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_engine.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_metadata.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_provenance.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_server.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_metadata.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_nonhtml.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_engine.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_failover.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_search_provenance.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/tests/test_webget.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/__init__.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/cache.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/discovery.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/health.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/profile.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/search.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget/ssrf.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_cli.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.15.0}/webget_mcp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -103,7 +103,7 @@ webget --help
|
|
|
103
103
|
|
|
104
104
|
### Browser runtime (optional)
|
|
105
105
|
|
|
106
|
-
Crawl4AI drives a Playwright
|
|
106
|
+
Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
|
|
107
107
|
installs the Python packages; the browser binary itself is downloaded
|
|
108
108
|
separately:
|
|
109
109
|
|
|
@@ -111,6 +111,41 @@ separately:
|
|
|
111
111
|
python -m playwright install chromium
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
**You may not need that download.** Before reaching for Playwright's bundled
|
|
115
|
+
Chromium, webget looks for a Chromium-family browser already installed on the
|
|
116
|
+
machine and uses it when it can actually be driven. Run `webget doctor` to see
|
|
117
|
+
what was found and what will be used:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
webget doctor
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Resolution order (first match wins):
|
|
124
|
+
|
|
125
|
+
| Step | Source | Notes |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| 1 | `WEBGET_BROWSER_CDP` | Attach to a running browser over CDP. The only route that works for browsers with no Playwright channel. |
|
|
128
|
+
| 2 | `WEBGET_BROWSER_CHANNEL` | A Playwright channel: `chrome`, `msedge`, or `chromium`. |
|
|
129
|
+
| 3 | `WEBGET_BROWSER_PATH` | An explicit binary. Accepted, but see the limitation below. |
|
|
130
|
+
| 4 | Auto-detected browser | Chrome, Edge, Chromium, Brave, Vivaldi or Opera found on the machine. |
|
|
131
|
+
| 5 | Playwright bundled Chromium | The fallback when nothing usable is found. |
|
|
132
|
+
|
|
133
|
+
Two limitations worth knowing, both reported honestly by `webget doctor`
|
|
134
|
+
instead of failing quietly:
|
|
135
|
+
|
|
136
|
+
- **Brave, Vivaldi and Opera cannot be launched by crawl4ai.** Playwright has no
|
|
137
|
+
channel for them, and crawl4ai 0.9.2 does not forward `executable_path` to
|
|
138
|
+
Playwright, so there is no path to the binary. They are detected and reported
|
|
139
|
+
as NOT USABLE. The workaround is CDP: start the browser with
|
|
140
|
+
`--remote-debugging-port=9222`, then set
|
|
141
|
+
`WEBGET_BROWSER_CDP=http://127.0.0.1:9222`.
|
|
142
|
+
- **CDP is opt-in on purpose.** Attaching to a browser you are already logged
|
|
143
|
+
into mixes your personal session cookies into crawl output, so webget never
|
|
144
|
+
auto-detects an open debugging port.
|
|
145
|
+
|
|
146
|
+
System Firefox is never selected: Playwright drives Firefox over WebDriver BiDi
|
|
147
|
+
with its own patched build.
|
|
148
|
+
|
|
114
149
|
Without the browser extra, `webget` still works for search and plain HTTP
|
|
115
150
|
fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
|
|
116
151
|
`login`) prints a clear warning telling you how to install it.
|
|
@@ -66,7 +66,7 @@ webget --help
|
|
|
66
66
|
|
|
67
67
|
### Browser runtime (optional)
|
|
68
68
|
|
|
69
|
-
Crawl4AI drives a Playwright
|
|
69
|
+
Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
|
|
70
70
|
installs the Python packages; the browser binary itself is downloaded
|
|
71
71
|
separately:
|
|
72
72
|
|
|
@@ -74,6 +74,41 @@ separately:
|
|
|
74
74
|
python -m playwright install chromium
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
**You may not need that download.** Before reaching for Playwright's bundled
|
|
78
|
+
Chromium, webget looks for a Chromium-family browser already installed on the
|
|
79
|
+
machine and uses it when it can actually be driven. Run `webget doctor` to see
|
|
80
|
+
what was found and what will be used:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
webget doctor
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Resolution order (first match wins):
|
|
87
|
+
|
|
88
|
+
| Step | Source | Notes |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| 1 | `WEBGET_BROWSER_CDP` | Attach to a running browser over CDP. The only route that works for browsers with no Playwright channel. |
|
|
91
|
+
| 2 | `WEBGET_BROWSER_CHANNEL` | A Playwright channel: `chrome`, `msedge`, or `chromium`. |
|
|
92
|
+
| 3 | `WEBGET_BROWSER_PATH` | An explicit binary. Accepted, but see the limitation below. |
|
|
93
|
+
| 4 | Auto-detected browser | Chrome, Edge, Chromium, Brave, Vivaldi or Opera found on the machine. |
|
|
94
|
+
| 5 | Playwright bundled Chromium | The fallback when nothing usable is found. |
|
|
95
|
+
|
|
96
|
+
Two limitations worth knowing, both reported honestly by `webget doctor`
|
|
97
|
+
instead of failing quietly:
|
|
98
|
+
|
|
99
|
+
- **Brave, Vivaldi and Opera cannot be launched by crawl4ai.** Playwright has no
|
|
100
|
+
channel for them, and crawl4ai 0.9.2 does not forward `executable_path` to
|
|
101
|
+
Playwright, so there is no path to the binary. They are detected and reported
|
|
102
|
+
as NOT USABLE. The workaround is CDP: start the browser with
|
|
103
|
+
`--remote-debugging-port=9222`, then set
|
|
104
|
+
`WEBGET_BROWSER_CDP=http://127.0.0.1:9222`.
|
|
105
|
+
- **CDP is opt-in on purpose.** Attaching to a browser you are already logged
|
|
106
|
+
into mixes your personal session cookies into crawl output, so webget never
|
|
107
|
+
auto-detects an open debugging port.
|
|
108
|
+
|
|
109
|
+
System Firefox is never selected: Playwright drives Firefox over WebDriver BiDi
|
|
110
|
+
with its own patched build.
|
|
111
|
+
|
|
77
112
|
Without the browser extra, `webget` still works for search and plain HTTP
|
|
78
113
|
fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
|
|
79
114
|
`login`) prints a clear warning telling you how to install it.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.15.0"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -166,9 +166,14 @@ class TestResponseBodies:
|
|
|
166
166
|
assert _one(res)["status"] == "success" # httpx auto-decompresses
|
|
167
167
|
|
|
168
168
|
def test_huge_response_is_bounded(self, fresh_cache):
|
|
169
|
+
from webget.truncate import ELLIPSIS_MARKER
|
|
170
|
+
|
|
169
171
|
server = fresh_cache
|
|
170
172
|
# 5MB body; scrape_many must truncate, not blow memory or hang.
|
|
171
173
|
res = asyncio.run(_fetch(server.url("/huge"), max_chars=1000))
|
|
172
174
|
out = _one(res)
|
|
173
175
|
assert out["status"] == "success"
|
|
174
|
-
|
|
176
|
+
# smart_truncate cuts at the limit and appends the ellipsis marker
|
|
177
|
+
# (this body has no clean boundary, so it is a hard cut at the limit).
|
|
178
|
+
assert len(out["markdown"]) <= 1000 + len(ELLIPSIS_MARKER)
|
|
179
|
+
assert out["markdown"].endswith(ELLIPSIS_MARKER)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Tests for browser discovery and the ladder's use of it.
|
|
2
|
+
|
|
3
|
+
No Chrome needed: everything here stubs the filesystem probe or checks the
|
|
4
|
+
precedence rules directly. The point is that the resolution ORDER and the
|
|
5
|
+
usability verdict are the product, not the individual detections.
|
|
6
|
+
|
|
7
|
+
The usability rules encode a hard constraint discovered by running the real
|
|
8
|
+
crawl4ai 0.9.2:
|
|
9
|
+
|
|
10
|
+
browser_manager._build_browser_args() returns
|
|
11
|
+
{"headless": ..., "args": ..., "channel": ...}
|
|
12
|
+
and then calls playwright.chromium.launch(**browser_args).
|
|
13
|
+
|
|
14
|
+
There is no executable_path in that dict, so a browser without a Playwright
|
|
15
|
+
channel (Brave, Vivaldi, Opera) cannot be launched at all, even though
|
|
16
|
+
Playwright itself supports executable_path. BrowserConfig(...) even raises
|
|
17
|
+
TypeError on executable_path. These tests pin that reality down so a future
|
|
18
|
+
crawl4ai upgrade that fixes it will fail loudly here instead of silently.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import unittest
|
|
24
|
+
from unittest import mock
|
|
25
|
+
|
|
26
|
+
from webget import browser as browser_mod
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScanTest(unittest.TestCase):
|
|
30
|
+
def test_brave_has_no_playwright_channel(self):
|
|
31
|
+
with mock.patch("shutil.which") as which, mock.patch.object(
|
|
32
|
+
browser_mod, "_macos_bundle", return_value=None
|
|
33
|
+
):
|
|
34
|
+
which.side_effect = lambda b: "/usr/bin/brave" if b == "brave-browser" else None
|
|
35
|
+
found = browser_mod.scan_installed_browsers()
|
|
36
|
+
self.assertEqual(found, [("brave", None, "/usr/bin/brave")])
|
|
37
|
+
|
|
38
|
+
def test_chrome_maps_to_channel(self):
|
|
39
|
+
with mock.patch("shutil.which") as which, mock.patch.object(
|
|
40
|
+
browser_mod, "_macos_bundle", return_value=None
|
|
41
|
+
):
|
|
42
|
+
which.side_effect = lambda b: "/usr/bin/google-chrome" if b == "google-chrome" else None
|
|
43
|
+
found = browser_mod.scan_installed_browsers()
|
|
44
|
+
self.assertEqual(found, [("chrome", "chrome", "/usr/bin/google-chrome")])
|
|
45
|
+
|
|
46
|
+
def test_chrome_preferred_over_chromium(self):
|
|
47
|
+
with mock.patch("shutil.which") as which, mock.patch.object(
|
|
48
|
+
browser_mod, "_macos_bundle", return_value=None
|
|
49
|
+
):
|
|
50
|
+
which.side_effect = lambda b: {
|
|
51
|
+
"google-chrome": "/usr/bin/google-chrome",
|
|
52
|
+
"chromium": "/usr/bin/chromium",
|
|
53
|
+
}.get(b)
|
|
54
|
+
found = browser_mod.scan_installed_browsers()
|
|
55
|
+
self.assertEqual([f[0] for f in found], ["chrome", "chromium"])
|
|
56
|
+
|
|
57
|
+
def test_firefox_is_never_selected(self):
|
|
58
|
+
"""System Firefox cannot be driven by Playwright; it must not appear."""
|
|
59
|
+
with mock.patch("shutil.which") as which, mock.patch.object(
|
|
60
|
+
browser_mod, "_macos_bundle", return_value=None
|
|
61
|
+
):
|
|
62
|
+
which.side_effect = lambda b: "/usr/bin/firefox" if b == "firefox" else None
|
|
63
|
+
self.assertEqual(browser_mod.scan_installed_browsers(), [])
|
|
64
|
+
|
|
65
|
+
def test_nothing_found_is_empty(self):
|
|
66
|
+
with mock.patch("shutil.which", return_value=None), mock.patch.object(
|
|
67
|
+
browser_mod, "_macos_bundle", return_value=None
|
|
68
|
+
):
|
|
69
|
+
self.assertEqual(browser_mod.scan_installed_browsers(), [])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UsabilityTest(unittest.TestCase):
|
|
73
|
+
"""Detected != usable. These pin the crawl4ai constraint."""
|
|
74
|
+
|
|
75
|
+
def test_brave_detected_but_reported_unusable(self):
|
|
76
|
+
with mock.patch.object(
|
|
77
|
+
browser_mod, "scan_installed_browsers",
|
|
78
|
+
return_value=[("brave", None, "/usr/bin/brave")],
|
|
79
|
+
):
|
|
80
|
+
choice = browser_mod.resolve_browser()
|
|
81
|
+
self.assertFalse(choice.usable)
|
|
82
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {})
|
|
83
|
+
self.assertIn("brave", choice.describe().lower())
|
|
84
|
+
self.assertIn("NOT usable", choice.describe())
|
|
85
|
+
|
|
86
|
+
def test_unusable_caveat_names_the_way_out(self):
|
|
87
|
+
with mock.patch.object(
|
|
88
|
+
browser_mod, "scan_installed_browsers",
|
|
89
|
+
return_value=[("brave", None, "/usr/bin/brave")],
|
|
90
|
+
):
|
|
91
|
+
choice = browser_mod.resolve_browser()
|
|
92
|
+
self.assertIn("WEBGET_BROWSER_CDP", choice.caveat)
|
|
93
|
+
self.assertIn("remote-debugging-port", choice.caveat)
|
|
94
|
+
|
|
95
|
+
def test_falls_back_to_bundled_when_nothing_found(self):
|
|
96
|
+
with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
|
|
97
|
+
choice = browser_mod.resolve_browser()
|
|
98
|
+
self.assertEqual(choice.source, "bundled")
|
|
99
|
+
self.assertTrue(choice.usable)
|
|
100
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {})
|
|
101
|
+
self.assertEqual(choice.describe(), "playwright bundled chromium")
|
|
102
|
+
|
|
103
|
+
def test_usable_browser_wins_over_unusable_one(self):
|
|
104
|
+
"""If both Brave and Chrome exist, pick Chrome, do not stop at Brave."""
|
|
105
|
+
with mock.patch.object(
|
|
106
|
+
browser_mod, "scan_installed_browsers",
|
|
107
|
+
return_value=[("brave", None, "/usr/bin/brave"), ("chrome", "chrome", "/usr/bin/google-chrome")],
|
|
108
|
+
):
|
|
109
|
+
choice = browser_mod.resolve_browser()
|
|
110
|
+
self.assertTrue(choice.usable)
|
|
111
|
+
self.assertEqual(choice.channel, "chrome")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConfigKwargsTest(unittest.TestCase):
|
|
115
|
+
"""crawl4ai forwards chrome_channel and cdp_url, nothing else."""
|
|
116
|
+
|
|
117
|
+
def test_channel_emits_chrome_channel(self):
|
|
118
|
+
choice = browser_mod.BrowserChoice(name="chrome", channel="chrome")
|
|
119
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {"chrome_channel": "chrome"})
|
|
120
|
+
|
|
121
|
+
def test_default_chromium_emits_nothing(self):
|
|
122
|
+
"""crawl4ai skips the default value because it breaks Windows."""
|
|
123
|
+
choice = browser_mod.BrowserChoice(name="chromium", channel="chromium")
|
|
124
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {})
|
|
125
|
+
|
|
126
|
+
def test_path_never_emits_executable_path(self):
|
|
127
|
+
"""BrowserConfig raises TypeError on executable_path in 0.9.2."""
|
|
128
|
+
choice = browser_mod.BrowserChoice(name="brave", path="/usr/bin/brave")
|
|
129
|
+
self.assertNotIn("executable_path", choice.to_browser_config_kwargs())
|
|
130
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {})
|
|
131
|
+
|
|
132
|
+
def test_cdp_emits_cdp_url(self):
|
|
133
|
+
choice = browser_mod.BrowserChoice(name="cdp", cdp_url="http://127.0.0.1:9222")
|
|
134
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {"cdp_url": "http://127.0.0.1:9222"})
|
|
135
|
+
|
|
136
|
+
def test_cdp_wins_over_channel(self):
|
|
137
|
+
choice = browser_mod.BrowserChoice(name="cdp", channel="chrome", cdp_url="http://x:1")
|
|
138
|
+
self.assertEqual(choice.to_browser_config_kwargs(), {"cdp_url": "http://x:1"})
|
|
139
|
+
|
|
140
|
+
def test_empty_when_bundled(self):
|
|
141
|
+
self.assertEqual(browser_mod.BrowserChoice(name="chromium").to_browser_config_kwargs(), {})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResolvePrecedenceTest(unittest.TestCase):
|
|
145
|
+
def setUp(self):
|
|
146
|
+
self._saved = dict(os.environ)
|
|
147
|
+
for key in ("WEBGET_BROWSER_PATH", "WEBGET_BROWSER_CHANNEL", "WEBGET_BROWSER_CDP"):
|
|
148
|
+
os.environ.pop(key, None)
|
|
149
|
+
|
|
150
|
+
def tearDown(self):
|
|
151
|
+
os.environ.clear()
|
|
152
|
+
os.environ.update(self._saved)
|
|
153
|
+
|
|
154
|
+
def test_cdp_wins_over_everything(self):
|
|
155
|
+
os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
|
|
156
|
+
os.environ["WEBGET_BROWSER_CHANNEL"] = "chrome"
|
|
157
|
+
choice = browser_mod.resolve_browser()
|
|
158
|
+
self.assertEqual(choice.cdp_url, "http://127.0.0.1:9222")
|
|
159
|
+
self.assertEqual(choice.source, "env:WEBGET_BROWSER_CDP")
|
|
160
|
+
|
|
161
|
+
def test_env_channel_wins_over_detection(self):
|
|
162
|
+
os.environ["WEBGET_BROWSER_CHANNEL"] = "msedge"
|
|
163
|
+
with mock.patch.object(
|
|
164
|
+
browser_mod, "scan_installed_browsers",
|
|
165
|
+
return_value=[("brave", None, "/usr/bin/brave")],
|
|
166
|
+
):
|
|
167
|
+
choice = browser_mod.resolve_browser()
|
|
168
|
+
self.assertEqual(choice.channel, "msedge")
|
|
169
|
+
self.assertEqual(choice.source, "env:WEBGET_BROWSER_CHANNEL")
|
|
170
|
+
|
|
171
|
+
def test_unsupported_env_channel_is_reported_not_ignored(self):
|
|
172
|
+
"""Asking for a channel Playwright does not have must not be silent."""
|
|
173
|
+
os.environ["WEBGET_BROWSER_CHANNEL"] = "brave"
|
|
174
|
+
choice = browser_mod.resolve_browser()
|
|
175
|
+
self.assertFalse(choice.usable)
|
|
176
|
+
self.assertIn("brave", choice.caveat.lower())
|
|
177
|
+
self.assertIn("WEBGET_BROWSER_CDP", choice.caveat)
|
|
178
|
+
|
|
179
|
+
def test_missing_env_path_raises_instead_of_falling_through(self):
|
|
180
|
+
"""A typo must not silently become 'use the bundled browser'."""
|
|
181
|
+
os.environ["WEBGET_BROWSER_PATH"] = "/nope/does-not-exist"
|
|
182
|
+
with mock.patch("os.path.exists", return_value=False), self.assertRaises(
|
|
183
|
+
ValueError
|
|
184
|
+
) as ctx:
|
|
185
|
+
browser_mod.resolve_browser()
|
|
186
|
+
self.assertIn("/nope/does-not-exist", str(ctx.exception))
|
|
187
|
+
|
|
188
|
+
def test_env_path_is_honest_about_being_unsupported(self):
|
|
189
|
+
"""We accept the var but must say it cannot be honoured by crawl4ai."""
|
|
190
|
+
os.environ["WEBGET_BROWSER_PATH"] = "/opt/my/brave"
|
|
191
|
+
with mock.patch("os.path.exists", return_value=True):
|
|
192
|
+
choice = browser_mod.resolve_browser()
|
|
193
|
+
self.assertFalse(choice.usable)
|
|
194
|
+
self.assertIn("executable_path", choice.caveat)
|
|
195
|
+
|
|
196
|
+
def test_detected_channel_browser_beats_bundled(self):
|
|
197
|
+
with mock.patch.object(
|
|
198
|
+
browser_mod, "scan_installed_browsers",
|
|
199
|
+
return_value=[("chrome", "chrome", "/usr/bin/google-chrome")],
|
|
200
|
+
):
|
|
201
|
+
choice = browser_mod.resolve_browser()
|
|
202
|
+
self.assertTrue(choice.is_external)
|
|
203
|
+
self.assertTrue(choice.usable)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CdpTest(unittest.TestCase):
|
|
207
|
+
def test_cdp_is_opt_in_only(self):
|
|
208
|
+
"""Attaching to a logged-in browser leaks the user's session; never auto."""
|
|
209
|
+
saved = os.environ.pop("WEBGET_BROWSER_CDP", None)
|
|
210
|
+
try:
|
|
211
|
+
self.assertIsNone(browser_mod.cdp_endpoint())
|
|
212
|
+
os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
|
|
213
|
+
self.assertEqual(browser_mod.cdp_endpoint(), "http://127.0.0.1:9222")
|
|
214
|
+
finally:
|
|
215
|
+
os.environ.pop("WEBGET_BROWSER_CDP", None)
|
|
216
|
+
if saved is not None:
|
|
217
|
+
os.environ["WEBGET_BROWSER_CDP"] = saved
|
|
218
|
+
|
|
219
|
+
def test_resolve_does_not_auto_detect_cdp(self):
|
|
220
|
+
saved = os.environ.pop("WEBGET_BROWSER_CDP", None)
|
|
221
|
+
try:
|
|
222
|
+
with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
|
|
223
|
+
choice = browser_mod.resolve_browser()
|
|
224
|
+
self.assertEqual(choice.source, "bundled")
|
|
225
|
+
self.assertIsNone(choice.cdp_url)
|
|
226
|
+
finally:
|
|
227
|
+
if saved is not None:
|
|
228
|
+
os.environ["WEBGET_BROWSER_CDP"] = saved
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class LadderWiringTest(unittest.TestCase):
|
|
232
|
+
"""The ladder must pass the resolved kwargs into BrowserConfig."""
|
|
233
|
+
|
|
234
|
+
def test_ladder_passes_resolved_kwargs(self):
|
|
235
|
+
import inspect
|
|
236
|
+
|
|
237
|
+
from webget import ladder
|
|
238
|
+
|
|
239
|
+
src = inspect.getsource(ladder)
|
|
240
|
+
self.assertIn("_browser_module.resolve_browser()", src)
|
|
241
|
+
self.assertIn("**bc_kwargs", src)
|
|
242
|
+
|
|
243
|
+
def test_ladder_does_not_pass_executable_path(self):
|
|
244
|
+
"""BrowserConfig rejects it, so it must never reach the constructor."""
|
|
245
|
+
import inspect
|
|
246
|
+
|
|
247
|
+
from webget import ladder
|
|
248
|
+
|
|
249
|
+
src = inspect.getsource(ladder)
|
|
250
|
+
self.assertNotIn("executable_path=", src)
|
|
251
|
+
|
|
252
|
+
def test_ladder_reports_a_bad_env_path_as_a_reason(self):
|
|
253
|
+
import inspect
|
|
254
|
+
|
|
255
|
+
from webget import ladder
|
|
256
|
+
|
|
257
|
+
src = inspect.getsource(ladder)
|
|
258
|
+
self.assertIn("except ValueError as exc:", src)
|
|
259
|
+
|
|
260
|
+
def test_ladder_warns_when_browser_is_unusable(self):
|
|
261
|
+
import inspect
|
|
262
|
+
|
|
263
|
+
from webget import ladder
|
|
264
|
+
|
|
265
|
+
src = inspect.getsource(ladder)
|
|
266
|
+
self.assertIn("choice.usable", src)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
unittest.main()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tests for `webget doctor`.
|
|
2
|
+
|
|
3
|
+
The command exists so a user can see WHY a crawl used (or ignored) a browser.
|
|
4
|
+
These tests pin the shape of the report and, more importantly, that it never
|
|
5
|
+
claims something is usable when the launcher cannot drive it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import unittest
|
|
12
|
+
from contextlib import redirect_stdout
|
|
13
|
+
from io import StringIO
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
from webget import browser as browser_mod
|
|
17
|
+
from webget import cli
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_doctor(json_out=False):
|
|
21
|
+
buf = StringIO()
|
|
22
|
+
with redirect_stdout(buf):
|
|
23
|
+
cli.cmd_doctor(json_out=json_out)
|
|
24
|
+
return buf.getvalue()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DoctorOutputTest(unittest.TestCase):
|
|
28
|
+
def setUp(self):
|
|
29
|
+
self._saved = dict(os.environ)
|
|
30
|
+
for key in ("WEBGET_BROWSER_CDP", "WEBGET_BROWSER_CHANNEL", "WEBGET_BROWSER_PATH"):
|
|
31
|
+
os.environ.pop(key, None)
|
|
32
|
+
|
|
33
|
+
def tearDown(self):
|
|
34
|
+
os.environ.clear()
|
|
35
|
+
os.environ.update(self._saved)
|
|
36
|
+
|
|
37
|
+
def test_text_output_names_the_selected_browser(self):
|
|
38
|
+
with mock.patch.object(
|
|
39
|
+
browser_mod, "scan_installed_browsers",
|
|
40
|
+
return_value=[("chrome", "chrome", "/usr/bin/google-chrome")],
|
|
41
|
+
):
|
|
42
|
+
out = _run_doctor()
|
|
43
|
+
self.assertIn("webget doctor", out)
|
|
44
|
+
self.assertIn("chrome", out)
|
|
45
|
+
self.assertIn("usable", out)
|
|
46
|
+
|
|
47
|
+
def test_unusable_browser_is_flagged_and_explained(self):
|
|
48
|
+
with mock.patch.object(
|
|
49
|
+
browser_mod, "scan_installed_browsers",
|
|
50
|
+
return_value=[("brave", None, "/usr/bin/brave")],
|
|
51
|
+
):
|
|
52
|
+
out = _run_doctor()
|
|
53
|
+
self.assertIn("NOT USABLE", out)
|
|
54
|
+
self.assertIn("WEBGET_BROWSER_CDP", out)
|
|
55
|
+
self.assertIn("no playwright channel", out)
|
|
56
|
+
|
|
57
|
+
def test_reports_bundled_fallback_when_nothing_found(self):
|
|
58
|
+
with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
|
|
59
|
+
out = _run_doctor()
|
|
60
|
+
self.assertIn("playwright bundled chromium", out)
|
|
61
|
+
self.assertIn("detected on this machine: none", out)
|
|
62
|
+
|
|
63
|
+
def test_json_output_is_valid_and_complete(self):
|
|
64
|
+
with mock.patch.object(browser_mod, "scan_installed_browsers", return_value=[]):
|
|
65
|
+
out = _run_doctor(json_out=True)
|
|
66
|
+
report = json.loads(out)
|
|
67
|
+
for key in ("browser", "crawl4ai", "playwright_cache", "env"):
|
|
68
|
+
self.assertIn(key, report)
|
|
69
|
+
self.assertIn("selected", report["browser"])
|
|
70
|
+
self.assertIn("usable", report["browser"])
|
|
71
|
+
|
|
72
|
+
def test_env_overrides_are_shown(self):
|
|
73
|
+
os.environ["WEBGET_BROWSER_CDP"] = "http://127.0.0.1:9222"
|
|
74
|
+
out = _run_doctor()
|
|
75
|
+
self.assertIn("WEBGET_BROWSER_CDP=http://127.0.0.1:9222", out)
|
|
76
|
+
|
|
77
|
+
def test_bad_env_path_reports_error_without_crashing(self):
|
|
78
|
+
"""A bad WEBGET_BROWSER_PATH must surface as an error line.
|
|
79
|
+
|
|
80
|
+
Note: do NOT mock os.path.exists globally here. crawl4ai's import chain
|
|
81
|
+
calls dotenv.find_dotenv(), which uses os.path.exists internally and
|
|
82
|
+
asserts on the result; a blanket mock breaks the import and the failure
|
|
83
|
+
looks like a doctor bug rather than a test artifact.
|
|
84
|
+
"""
|
|
85
|
+
os.environ["WEBGET_BROWSER_PATH"] = "/nope/missing"
|
|
86
|
+
out = _run_doctor()
|
|
87
|
+
self.assertIn("ERROR", out)
|
|
88
|
+
self.assertIn("/nope/missing", out)
|
|
89
|
+
|
|
90
|
+
def test_doctor_does_not_require_crawl4ai(self):
|
|
91
|
+
"""A missing optional dep must be reported, not raised."""
|
|
92
|
+
real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
|
|
93
|
+
|
|
94
|
+
def fake_import(name, *args, **kwargs):
|
|
95
|
+
if name == "crawl4ai":
|
|
96
|
+
raise ImportError("not installed")
|
|
97
|
+
return real_import(name, *args, **kwargs)
|
|
98
|
+
|
|
99
|
+
with mock.patch("builtins.__import__", side_effect=fake_import), mock.patch.object(
|
|
100
|
+
browser_mod, "scan_installed_browsers", return_value=[]
|
|
101
|
+
):
|
|
102
|
+
out = _run_doctor()
|
|
103
|
+
self.assertIn("NOT installed", out)
|
|
104
|
+
self.assertIn("pip install webget-cli[browser]", out)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DoctorWiringTest(unittest.TestCase):
|
|
108
|
+
def test_doctor_is_dispatched_from_main(self):
|
|
109
|
+
import inspect
|
|
110
|
+
|
|
111
|
+
src = inspect.getsource(cli.main)
|
|
112
|
+
self.assertIn('cmd == "doctor"', src)
|
|
113
|
+
|
|
114
|
+
def test_doctor_appears_in_usage(self):
|
|
115
|
+
self.assertIn("webget doctor", cli.__doc__ or "")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
unittest.main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import webget_cli as webget
|
|
4
|
+
from webget.truncate import smart_truncate
|
|
5
|
+
|
|
6
|
+
MARKER = "\n\n[...truncated]"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_short_text_passthrough():
|
|
10
|
+
assert smart_truncate("Short text.", 100) == "Short text."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_exact_length_passthrough():
|
|
14
|
+
assert smart_truncate("abcde", 5) == "abcde"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_paragraph_boundary_preferred():
|
|
18
|
+
text = "para one here\n\npara two here"
|
|
19
|
+
assert smart_truncate(text, 20) == "para one here" + MARKER
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_last_paragraph_boundary_wins():
|
|
23
|
+
text = "alpha\n\nbeta\n\ngamma"
|
|
24
|
+
assert smart_truncate(text, 15) == "alpha\n\nbeta" + MARKER
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_paragraph_boundary_beats_sentence():
|
|
28
|
+
# The paragraph break sits further along than the last sentence end in
|
|
29
|
+
# the first paragraph; the paragraph boundary must win.
|
|
30
|
+
text = "Alpha. Beta.\n\nGamma delta epsilon."
|
|
31
|
+
assert smart_truncate(text, 20) == "Alpha. Beta." + MARKER
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sentence_fallback_when_no_paragraph():
|
|
35
|
+
text = "First sentence. Second sentence. Third sentence."
|
|
36
|
+
assert smart_truncate(text, 25) == "First sentence." + MARKER
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_sentence_fallback_exclamation_and_question():
|
|
40
|
+
text = "Wow! Great. Hmm? No way."
|
|
41
|
+
assert smart_truncate(text, 12) == "Wow! Great." + MARKER
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_sentence_fallback_newline_terminated():
|
|
45
|
+
assert smart_truncate("Ends here.\nAnd more text", 12) == "Ends here." + MARKER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_word_fallback():
|
|
49
|
+
text = "one two three four five"
|
|
50
|
+
assert smart_truncate(text, 9) == "one two" + MARKER
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_hard_cut():
|
|
54
|
+
assert smart_truncate("abcdefghijkl", 5) == "abcde" + MARKER
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_marker_appended_exactly_once():
|
|
58
|
+
text = "alpha\n\nbeta\n\ngamma\n\ndelta"
|
|
59
|
+
result = smart_truncate(text, 20)
|
|
60
|
+
assert result.count(MARKER) == 1
|
|
61
|
+
assert result.endswith(MARKER)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_ladder_path_marks_truncated_content(fresh_cache):
|
|
65
|
+
"""Regression: the ladder's HTTP fast path must run scraped content
|
|
66
|
+
through smart_truncate, not a hard slice.
|
|
67
|
+
|
|
68
|
+
Exercises the real ladder path at unit level: scrape_many() ->
|
|
69
|
+
_resolve_fetch_http() -> fetch_http() (webget/http.py). The fetcher now
|
|
70
|
+
caps markdown with smart_truncate instead of markdown[:max_chars], so a
|
|
71
|
+
page that overflows the limit must come back already marked, and that
|
|
72
|
+
mark must survive to the final result (the CLI no longer re-slices it off).
|
|
73
|
+
|
|
74
|
+
max_chars is kept comfortably above the 100-char "thin content" floor so
|
|
75
|
+
the ladder reports success and returns the (truncated, marked) markdown.
|
|
76
|
+
"""
|
|
77
|
+
server = fresh_cache
|
|
78
|
+
# /normal serves LONG_BODY (~1.4KB of repeating sentences) > max_chars.
|
|
79
|
+
url = server.url("/normal")
|
|
80
|
+
max_chars = 300
|
|
81
|
+
res = asyncio.run(
|
|
82
|
+
webget.scrape_many([url], max_chars=max_chars, strategy="http", no_cache=True)
|
|
83
|
+
)
|
|
84
|
+
out = res[url]
|
|
85
|
+
assert out["status"] == "success", out.get("error")
|
|
86
|
+
md = out["markdown"]
|
|
87
|
+
assert md.endswith(MARKER), "ladder output must carry the truncation marker"
|
|
88
|
+
assert md.count(MARKER) == 1
|