webget-cli 0.12.1__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.12.1 → webget_cli-0.15.0}/PKG-INFO +168 -4
- {webget_cli-0.12.1 → webget_cli-0.15.0}/README.md +164 -2
- {webget_cli-0.12.1 → webget_cli-0.15.0}/pyproject.toml +24 -3
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_http.py +6 -1
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_mcp.py +26 -9
- webget_cli-0.15.0/tests/test_browser_discovery.py +270 -0
- webget_cli-0.15.0/tests/test_cli_engine.py +62 -0
- webget_cli-0.15.0/tests/test_cli_provenance.py +98 -0
- webget_cli-0.15.0/tests/test_doctor.py +119 -0
- webget_cli-0.15.0/tests/test_health.py +125 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_login_flow.py +2 -1
- webget_cli-0.15.0/tests/test_mcp_engine.py +66 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_leak_review.py +11 -3
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_metadata.py +6 -3
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_profile.py +4 -3
- webget_cli-0.15.0/tests/test_mcp_provenance.py +90 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_server.py +10 -8
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_smoke.py +17 -4
- webget_cli-0.15.0/tests/test_search_engine.py +108 -0
- webget_cli-0.15.0/tests/test_search_failover.py +247 -0
- webget_cli-0.15.0/tests/test_search_provenance.py +159 -0
- webget_cli-0.15.0/tests/test_truncate.py +88 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_webget.py +9 -8
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/__init__.py +2 -1
- webget_cli-0.15.0/webget/browser.py +276 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/cli.py +203 -41
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/firecrawl.py +3 -1
- webget_cli-0.15.0/webget/health.py +198 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/http.py +3 -2
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/ladder.py +77 -46
- webget_cli-0.15.0/webget/search.py +352 -0
- webget_cli-0.15.0/webget/truncate.py +50 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/PKG-INFO +168 -4
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/SOURCES.txt +14 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/requires.txt +3 -1
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_mcp.py +56 -11
- webget_cli-0.12.1/webget/search.py +0 -38
- {webget_cli-0.12.1 → webget_cli-0.15.0}/LICENSE +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/setup.cfg +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_base64_strip.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_metadata.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_nonhtml.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/cache.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/discovery.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/profile.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.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
|
|
@@ -27,10 +27,12 @@ Requires-Dist: pypdf
|
|
|
27
27
|
Provides-Extra: browser
|
|
28
28
|
Requires-Dist: crawl4ai>=0.9; extra == "browser"
|
|
29
29
|
Provides-Extra: mcp
|
|
30
|
-
Requires-Dist: fastmcp>=
|
|
30
|
+
Requires-Dist: fastmcp>=4; extra == "mcp"
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
33
33
|
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-timeout>=2; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-socket>=0.7; extra == "dev"
|
|
34
36
|
Dynamic: license-file
|
|
35
37
|
|
|
36
38
|
<div align="center">
|
|
@@ -101,7 +103,7 @@ webget --help
|
|
|
101
103
|
|
|
102
104
|
### Browser runtime (optional)
|
|
103
105
|
|
|
104
|
-
Crawl4AI drives a Playwright
|
|
106
|
+
Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
|
|
105
107
|
installs the Python packages; the browser binary itself is downloaded
|
|
106
108
|
separately:
|
|
107
109
|
|
|
@@ -109,6 +111,41 @@ separately:
|
|
|
109
111
|
python -m playwright install chromium
|
|
110
112
|
```
|
|
111
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
|
+
|
|
112
149
|
Without the browser extra, `webget` still works for search and plain HTTP
|
|
113
150
|
fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
|
|
114
151
|
`login`) prints a clear warning telling you how to install it.
|
|
@@ -124,7 +161,9 @@ uv pip install -e ".[dev,browser]"
|
|
|
124
161
|
## Usage
|
|
125
162
|
|
|
126
163
|
```bash
|
|
127
|
-
webget s "rust async runtime" # search
|
|
164
|
+
webget s "rust async runtime" # search (ddgs metasearch, top 5)
|
|
165
|
+
webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
|
|
166
|
+
webget s "rust async runtime" --json # machine-readable, includes engine provenance
|
|
128
167
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
129
168
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
130
169
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
@@ -133,6 +172,86 @@ webget fetch https://example.com --json # machine-readable result
|
|
|
133
172
|
|
|
134
173
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
135
174
|
|
|
175
|
+
## Search engines
|
|
176
|
+
|
|
177
|
+
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
178
|
+
keyless engines. The default `auto` queries all of them; naming a subset is
|
|
179
|
+
faster and skips engines that are having a bad hour.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
webget s "query" --engine brave,duckduckgo
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Do not hardcode engine names.** The set ddgs offers changes between its
|
|
186
|
+
releases, and webget validates against the registry at runtime rather than
|
|
187
|
+
a built-in list. Pass a deliberately bogus name to see what your installed
|
|
188
|
+
version supports:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
$ webget s "query" --engine bogus
|
|
192
|
+
warning: unknown search engine(s): bogus - using auto
|
|
193
|
+
(known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
197
|
+
typo never kills a search.
|
|
198
|
+
|
|
199
|
+
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
200
|
+
results, webget tries the remaining engines until a time budget is spent
|
|
201
|
+
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
202
|
+
first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
|
|
203
|
+
either too many (3 x 20s timeout = a minute of dead air) or too few (a live
|
|
204
|
+
engine sitting fifth in line never got reached). At least two alternates are
|
|
205
|
+
always tried, however fast the budget expires. This is not paranoia: engine
|
|
206
|
+
reachability depends on where you are, not just whether a service is up. A
|
|
207
|
+
benchmark from one residential connection found only 2 of 9 engines
|
|
208
|
+
reachable, and `auto` survived purely because they did.
|
|
209
|
+
|
|
210
|
+
Every substitution is announced, never silent:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
$ webget s "rust programming language book" --engine google
|
|
214
|
+
warning: engine 'google' failed (No results found.); fell back to 'brave'
|
|
215
|
+
1. The Rust Programming Language - doc.rust-lang.org/book/
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Results carry provenance.** Because failover can answer from an engine you
|
|
219
|
+
did not ask for, `--json` reports which one actually did:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
$ webget s "linux kernel" --engine google --json
|
|
223
|
+
{
|
|
224
|
+
"results": [ ... ],
|
|
225
|
+
"engine": "grokipedia", # who actually answered
|
|
226
|
+
"requested_engine": "google",
|
|
227
|
+
"failed_over": true
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This matters most over MCP, where a stderr warning is invisible to the
|
|
232
|
+
agent: the payload is the only signal that the results came from elsewhere.
|
|
233
|
+
|
|
234
|
+
**Failover order is learned, not hardcoded.** webget keeps a small health
|
|
235
|
+
ledger per install (`~/.local/state/webget/engine_health.json`) recording
|
|
236
|
+
whether each engine answered and how fast. When failover kicks in, engines
|
|
237
|
+
are tried best-first by that record instead of in registry order. The ledger
|
|
238
|
+
is advisory - it reorders candidates, it never removes one - and it decays:
|
|
239
|
+
an engine marked dead last week is retried with no penalty this week, because
|
|
240
|
+
the blocking that made it dead is exactly what changes. There is no baked-in
|
|
241
|
+
ranking anywhere in the source; the same webget learns a different order on
|
|
242
|
+
your machine than on the author's, which is the point.
|
|
243
|
+
|
|
244
|
+
Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
|
|
245
|
+
|
|
246
|
+
> **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
|
|
247
|
+
> now return an object `{results, engine, requested_engine, failed_over}`
|
|
248
|
+
> instead of a bare list, so provenance has somewhere to live. Errors are
|
|
249
|
+
> `{error, results: []}`.
|
|
250
|
+
|
|
251
|
+
Provenance is per-call, not per-result: `ddgs` merges every engine's hits
|
|
252
|
+
into one list and discards which engine supplied each hit, so per-hit
|
|
253
|
+
attribution is not obtainable through its public API.
|
|
254
|
+
|
|
136
255
|
### Options
|
|
137
256
|
|
|
138
257
|
| Flag | Meaning |
|
|
@@ -260,6 +379,51 @@ Register as a local MCP server in opencode:
|
|
|
260
379
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
261
380
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
262
381
|
|
|
382
|
+
### Breaking change in 0.13.0: search returns an object, not a list
|
|
383
|
+
|
|
384
|
+
`search` and `search_fetch` used to return a bare JSON array of results.
|
|
385
|
+
They now return an object, because a list has nowhere to carry provenance:
|
|
386
|
+
|
|
387
|
+
```jsonc
|
|
388
|
+
// <= 0.12.1 -> a list
|
|
389
|
+
[ { "title": "...", "href": "...", "body": "..." }, ... ]
|
|
390
|
+
|
|
391
|
+
// >= 0.13.0 -> an object
|
|
392
|
+
{
|
|
393
|
+
"results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
|
|
394
|
+
"engine": "brave", // the engine that actually answered
|
|
395
|
+
"requested_engine": "google", // what was asked for
|
|
396
|
+
"failed_over": true // an alternative was attempted
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Error responses changed shape too:
|
|
401
|
+
|
|
402
|
+
```jsonc
|
|
403
|
+
// <= 0.12.1 -> a plain error string / empty list
|
|
404
|
+
// >= 0.13.0
|
|
405
|
+
{
|
|
406
|
+
"error": "SearchError: RequestError(...)",
|
|
407
|
+
"results": []
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**How to migrate.** If you read the tool result as an array, read
|
|
412
|
+
`.results` instead. In JavaScript that is `result.results` rather than
|
|
413
|
+
`result`; in Python `data["results"]` rather than `data`. If you only
|
|
414
|
+
iterate the hits, the change is mechanical.
|
|
415
|
+
|
|
416
|
+
**Why it had to break.** Failover means the engine that answers is not
|
|
417
|
+
always the engine that was asked for, and over MCP there is no stderr for a
|
|
418
|
+
warning to land on, so the payload is the only place that fact can live.
|
|
419
|
+
Keeping the list shape would have meant silently returning results from an
|
|
420
|
+
unexpected engine with no way for a client to notice. An additive field was
|
|
421
|
+
not possible: a JSON array cannot carry sibling keys.
|
|
422
|
+
|
|
423
|
+
`fetch` is unchanged and still returns its string payload. The Python API is
|
|
424
|
+
unchanged: `search()` still returns a list, and `search_with_provenance()`
|
|
425
|
+
is the new opt-in that returns `(results, provenance)`.
|
|
426
|
+
|
|
263
427
|
### Authenticated sessions (profiles)
|
|
264
428
|
|
|
265
429
|
MCP tools can use locally stored login sessions. Create one first with the
|
|
@@ -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.
|
|
@@ -89,7 +124,9 @@ uv pip install -e ".[dev,browser]"
|
|
|
89
124
|
## Usage
|
|
90
125
|
|
|
91
126
|
```bash
|
|
92
|
-
webget s "rust async runtime" # search
|
|
127
|
+
webget s "rust async runtime" # search (ddgs metasearch, top 5)
|
|
128
|
+
webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
|
|
129
|
+
webget s "rust async runtime" --json # machine-readable, includes engine provenance
|
|
93
130
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
94
131
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
95
132
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
@@ -98,6 +135,86 @@ webget fetch https://example.com --json # machine-readable result
|
|
|
98
135
|
|
|
99
136
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
100
137
|
|
|
138
|
+
## Search engines
|
|
139
|
+
|
|
140
|
+
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
141
|
+
keyless engines. The default `auto` queries all of them; naming a subset is
|
|
142
|
+
faster and skips engines that are having a bad hour.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
webget s "query" --engine brave,duckduckgo
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Do not hardcode engine names.** The set ddgs offers changes between its
|
|
149
|
+
releases, and webget validates against the registry at runtime rather than
|
|
150
|
+
a built-in list. Pass a deliberately bogus name to see what your installed
|
|
151
|
+
version supports:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
$ webget s "query" --engine bogus
|
|
155
|
+
warning: unknown search engine(s): bogus - using auto
|
|
156
|
+
(known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
160
|
+
typo never kills a search.
|
|
161
|
+
|
|
162
|
+
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
163
|
+
results, webget tries the remaining engines until a time budget is spent
|
|
164
|
+
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
165
|
+
first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
|
|
166
|
+
either too many (3 x 20s timeout = a minute of dead air) or too few (a live
|
|
167
|
+
engine sitting fifth in line never got reached). At least two alternates are
|
|
168
|
+
always tried, however fast the budget expires. This is not paranoia: engine
|
|
169
|
+
reachability depends on where you are, not just whether a service is up. A
|
|
170
|
+
benchmark from one residential connection found only 2 of 9 engines
|
|
171
|
+
reachable, and `auto` survived purely because they did.
|
|
172
|
+
|
|
173
|
+
Every substitution is announced, never silent:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
$ webget s "rust programming language book" --engine google
|
|
177
|
+
warning: engine 'google' failed (No results found.); fell back to 'brave'
|
|
178
|
+
1. The Rust Programming Language - doc.rust-lang.org/book/
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Results carry provenance.** Because failover can answer from an engine you
|
|
182
|
+
did not ask for, `--json` reports which one actually did:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
$ webget s "linux kernel" --engine google --json
|
|
186
|
+
{
|
|
187
|
+
"results": [ ... ],
|
|
188
|
+
"engine": "grokipedia", # who actually answered
|
|
189
|
+
"requested_engine": "google",
|
|
190
|
+
"failed_over": true
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This matters most over MCP, where a stderr warning is invisible to the
|
|
195
|
+
agent: the payload is the only signal that the results came from elsewhere.
|
|
196
|
+
|
|
197
|
+
**Failover order is learned, not hardcoded.** webget keeps a small health
|
|
198
|
+
ledger per install (`~/.local/state/webget/engine_health.json`) recording
|
|
199
|
+
whether each engine answered and how fast. When failover kicks in, engines
|
|
200
|
+
are tried best-first by that record instead of in registry order. The ledger
|
|
201
|
+
is advisory - it reorders candidates, it never removes one - and it decays:
|
|
202
|
+
an engine marked dead last week is retried with no penalty this week, because
|
|
203
|
+
the blocking that made it dead is exactly what changes. There is no baked-in
|
|
204
|
+
ranking anywhere in the source; the same webget learns a different order on
|
|
205
|
+
your machine than on the author's, which is the point.
|
|
206
|
+
|
|
207
|
+
Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
|
|
208
|
+
|
|
209
|
+
> **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
|
|
210
|
+
> now return an object `{results, engine, requested_engine, failed_over}`
|
|
211
|
+
> instead of a bare list, so provenance has somewhere to live. Errors are
|
|
212
|
+
> `{error, results: []}`.
|
|
213
|
+
|
|
214
|
+
Provenance is per-call, not per-result: `ddgs` merges every engine's hits
|
|
215
|
+
into one list and discards which engine supplied each hit, so per-hit
|
|
216
|
+
attribution is not obtainable through its public API.
|
|
217
|
+
|
|
101
218
|
### Options
|
|
102
219
|
|
|
103
220
|
| Flag | Meaning |
|
|
@@ -225,6 +342,51 @@ Register as a local MCP server in opencode:
|
|
|
225
342
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
226
343
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
227
344
|
|
|
345
|
+
### Breaking change in 0.13.0: search returns an object, not a list
|
|
346
|
+
|
|
347
|
+
`search` and `search_fetch` used to return a bare JSON array of results.
|
|
348
|
+
They now return an object, because a list has nowhere to carry provenance:
|
|
349
|
+
|
|
350
|
+
```jsonc
|
|
351
|
+
// <= 0.12.1 -> a list
|
|
352
|
+
[ { "title": "...", "href": "...", "body": "..." }, ... ]
|
|
353
|
+
|
|
354
|
+
// >= 0.13.0 -> an object
|
|
355
|
+
{
|
|
356
|
+
"results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
|
|
357
|
+
"engine": "brave", // the engine that actually answered
|
|
358
|
+
"requested_engine": "google", // what was asked for
|
|
359
|
+
"failed_over": true // an alternative was attempted
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Error responses changed shape too:
|
|
364
|
+
|
|
365
|
+
```jsonc
|
|
366
|
+
// <= 0.12.1 -> a plain error string / empty list
|
|
367
|
+
// >= 0.13.0
|
|
368
|
+
{
|
|
369
|
+
"error": "SearchError: RequestError(...)",
|
|
370
|
+
"results": []
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**How to migrate.** If you read the tool result as an array, read
|
|
375
|
+
`.results` instead. In JavaScript that is `result.results` rather than
|
|
376
|
+
`result`; in Python `data["results"]` rather than `data`. If you only
|
|
377
|
+
iterate the hits, the change is mechanical.
|
|
378
|
+
|
|
379
|
+
**Why it had to break.** Failover means the engine that answers is not
|
|
380
|
+
always the engine that was asked for, and over MCP there is no stderr for a
|
|
381
|
+
warning to land on, so the payload is the only place that fact can live.
|
|
382
|
+
Keeping the list shape would have meant silently returning results from an
|
|
383
|
+
unexpected engine with no way for a client to notice. An additive field was
|
|
384
|
+
not possible: a JSON array cannot carry sibling keys.
|
|
385
|
+
|
|
386
|
+
`fetch` is unchanged and still returns its string payload. The Python API is
|
|
387
|
+
unchanged: `search()` still returns a list, and `search_with_provenance()`
|
|
388
|
+
is the new opt-in that returns `(results, provenance)`.
|
|
389
|
+
|
|
228
390
|
### Authenticated sessions (profiles)
|
|
229
391
|
|
|
230
392
|
MCP tools can use locally stored login sessions. Create one first with the
|
|
@@ -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"
|
|
@@ -36,8 +36,14 @@ Changelog = "https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md"
|
|
|
36
36
|
|
|
37
37
|
[project.optional-dependencies]
|
|
38
38
|
browser = ["crawl4ai>=0.9"]
|
|
39
|
-
mcp
|
|
40
|
-
|
|
39
|
+
# fastmcp pulls in the `mcp` SDK. The MCP SDK renamed CallToolResult.isError to
|
|
40
|
+
# is_error in the 2.x line, and fastmcp 4.x ships that newer SDK. The code and
|
|
41
|
+
# tests here use is_error, so the floor is the version that has it.
|
|
42
|
+
# Without a floor, an install could resolve to a fastmcp whose result objects
|
|
43
|
+
# expose isError only, and the MCP surface would look broken for a reason that
|
|
44
|
+
# has nothing to do with webget.
|
|
45
|
+
mcp = ["fastmcp>=4"]
|
|
46
|
+
dev = ["pytest>=8", "ruff>=0.6", "pytest-timeout>=2", "pytest-socket>=0.7"]
|
|
41
47
|
|
|
42
48
|
[project.scripts]
|
|
43
49
|
webget = "webget.cli:main"
|
|
@@ -50,7 +56,22 @@ packages = ["webget"]
|
|
|
50
56
|
[tool.pytest.ini_options]
|
|
51
57
|
testpaths = ["tests"]
|
|
52
58
|
addopts = "-q"
|
|
59
|
+
markers = [
|
|
60
|
+
# Tests that reach the PUBLIC internet (not the local test server). They are
|
|
61
|
+
# excluded from offline jobs with -m "not live_network" and run separately,
|
|
62
|
+
# because engine reachability is a property of the runner's network: during
|
|
63
|
+
# 0.13.0, DuckDuckGo was unreachable from the author's ISP while Google,
|
|
64
|
+
# Brave and Yandex answered in under a second. A red test here does not mean
|
|
65
|
+
# the release is broken.
|
|
66
|
+
"live_network: test reaches the public internet; excluded from offline CI",
|
|
67
|
+
]
|
|
53
68
|
|
|
54
69
|
[tool.ruff]
|
|
55
70
|
line-length = 100
|
|
56
71
|
target-version = "py311"
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.per-file-ignores]
|
|
74
|
+
# Ops scripts drive subprocesses and inspect returncode manually; requiring
|
|
75
|
+
# check=True everywhere would force try/except noise where a plain if already
|
|
76
|
+
# handles the failure.
|
|
77
|
+
"scripts/*" = ["PLW1510"]
|
|
@@ -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)
|
|
@@ -10,6 +10,8 @@ import pytest
|
|
|
10
10
|
from mcp import ClientSession, StdioServerParameters
|
|
11
11
|
from mcp.client.stdio import stdio_client
|
|
12
12
|
|
|
13
|
+
from tests.conftest import tool_failed
|
|
14
|
+
|
|
13
15
|
ROOT = Path(__file__).resolve().parent.parent
|
|
14
16
|
MCP = ROOT / "webget_mcp.py"
|
|
15
17
|
|
|
@@ -58,7 +60,7 @@ class TestMalformedArguments:
|
|
|
58
60
|
return res
|
|
59
61
|
|
|
60
62
|
res = _run(run())
|
|
61
|
-
assert res
|
|
63
|
+
assert tool_failed(res)
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
class TestInvalidURLs:
|
|
@@ -72,7 +74,7 @@ class TestInvalidURLs:
|
|
|
72
74
|
return res
|
|
73
75
|
|
|
74
76
|
res = _run(run())
|
|
75
|
-
assert "error" in res.content[0].text or res
|
|
77
|
+
assert "error" in res.content[0].text or tool_failed(res)
|
|
76
78
|
|
|
77
79
|
|
|
78
80
|
class TestRepeatedCalls:
|
|
@@ -86,7 +88,7 @@ class TestRepeatedCalls:
|
|
|
86
88
|
"fetch",
|
|
87
89
|
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
88
90
|
)
|
|
89
|
-
if res
|
|
91
|
+
if tool_failed(res):
|
|
90
92
|
return "ERROR"
|
|
91
93
|
return "OK"
|
|
92
94
|
|
|
@@ -106,7 +108,7 @@ class TestRepeatedCalls:
|
|
|
106
108
|
for _ in range(5)
|
|
107
109
|
]
|
|
108
110
|
)
|
|
109
|
-
return [r
|
|
111
|
+
return [tool_failed(r) for r in results]
|
|
110
112
|
|
|
111
113
|
assert _run(run()) == [False] * 5
|
|
112
114
|
|
|
@@ -133,7 +135,7 @@ class TestInputCaps:
|
|
|
133
135
|
return res
|
|
134
136
|
|
|
135
137
|
res = _run(run())
|
|
136
|
-
assert "must be between" in res.content[0].text or res
|
|
138
|
+
assert "must be between" in res.content[0].text or tool_failed(res)
|
|
137
139
|
|
|
138
140
|
|
|
139
141
|
class TestToolFailureIsolation:
|
|
@@ -171,8 +173,18 @@ class TestSSRFViaMCP:
|
|
|
171
173
|
res = _run(run())
|
|
172
174
|
assert "private" in res.content[0].text.lower()
|
|
173
175
|
|
|
176
|
+
@pytest.mark.live_network
|
|
174
177
|
def test_search_output_shape(self):
|
|
175
|
-
"""search must return
|
|
178
|
+
"""search must return {results: [...], engine: ...} with provenance.
|
|
179
|
+
|
|
180
|
+
Marked live_network deliberately: this is the ONLY test in the suite
|
|
181
|
+
that talks to the public internet (verified by running the whole MCP
|
|
182
|
+
suite with sockets blocked except localhost - everything passes except
|
|
183
|
+
this one, which fails exactly because the network is unavailable). It
|
|
184
|
+
exercises the real engine path end-to-end through a real server
|
|
185
|
+
process, which no mock can replace, so it stays but is excluded from
|
|
186
|
+
the offline jobs.
|
|
187
|
+
"""
|
|
176
188
|
|
|
177
189
|
async def run():
|
|
178
190
|
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
@@ -183,6 +195,11 @@ class TestSSRFViaMCP:
|
|
|
183
195
|
|
|
184
196
|
res = _run(run())
|
|
185
197
|
payload = json.loads(res.content[0].text)
|
|
186
|
-
assert isinstance(payload,
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
assert isinstance(payload, dict)
|
|
199
|
+
# Provenance keys are part of the contract: `engine` is the one that
|
|
200
|
+
# actually answered, which can differ from `requested_engine`.
|
|
201
|
+
assert {"results", "engine", "requested_engine", "failed_over"} <= set(payload)
|
|
202
|
+
results = payload["results"]
|
|
203
|
+
assert isinstance(results, list)
|
|
204
|
+
if results:
|
|
205
|
+
assert {"title", "url", "snippet"} <= set(results[0])
|