webget-cli 0.12.1__tar.gz → 0.14.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.14.0}/PKG-INFO +132 -3
- {webget_cli-0.12.1 → webget_cli-0.14.0}/README.md +128 -1
- {webget_cli-0.12.1 → webget_cli-0.14.0}/pyproject.toml +24 -3
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_mcp.py +26 -9
- webget_cli-0.14.0/tests/test_cli_engine.py +62 -0
- webget_cli-0.14.0/tests/test_cli_provenance.py +98 -0
- webget_cli-0.14.0/tests/test_health.py +125 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_login_flow.py +2 -1
- webget_cli-0.14.0/tests/test_mcp_engine.py +66 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_leak_review.py +11 -3
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_metadata.py +6 -3
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_profile.py +4 -3
- webget_cli-0.14.0/tests/test_mcp_provenance.py +90 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_server.py +10 -8
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_smoke.py +17 -4
- webget_cli-0.14.0/tests/test_search_engine.py +108 -0
- webget_cli-0.14.0/tests/test_search_failover.py +247 -0
- webget_cli-0.14.0/tests/test_search_provenance.py +159 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_webget.py +9 -8
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/__init__.py +2 -1
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/cli.py +77 -39
- webget_cli-0.14.0/webget/health.py +198 -0
- webget_cli-0.14.0/webget/search.py +352 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/PKG-INFO +132 -3
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/SOURCES.txt +9 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/requires.txt +3 -1
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_mcp.py +56 -11
- webget_cli-0.12.1/webget/search.py +0 -38
- {webget_cli-0.12.1 → webget_cli-0.14.0}/LICENSE +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/setup.cfg +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_base64_strip.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_metadata.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_nonhtml.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/cache.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/discovery.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/firecrawl.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/http.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/ladder.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/profile.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/ssrf.py +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.12.1 → webget_cli-0.14.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.14.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">
|
|
@@ -124,7 +126,9 @@ uv pip install -e ".[dev,browser]"
|
|
|
124
126
|
## Usage
|
|
125
127
|
|
|
126
128
|
```bash
|
|
127
|
-
webget s "rust async runtime" # search
|
|
129
|
+
webget s "rust async runtime" # search (ddgs metasearch, top 5)
|
|
130
|
+
webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
|
|
131
|
+
webget s "rust async runtime" --json # machine-readable, includes engine provenance
|
|
128
132
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
129
133
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
130
134
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
@@ -133,6 +137,86 @@ webget fetch https://example.com --json # machine-readable result
|
|
|
133
137
|
|
|
134
138
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
135
139
|
|
|
140
|
+
## Search engines
|
|
141
|
+
|
|
142
|
+
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
143
|
+
keyless engines. The default `auto` queries all of them; naming a subset is
|
|
144
|
+
faster and skips engines that are having a bad hour.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
webget s "query" --engine brave,duckduckgo
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Do not hardcode engine names.** The set ddgs offers changes between its
|
|
151
|
+
releases, and webget validates against the registry at runtime rather than
|
|
152
|
+
a built-in list. Pass a deliberately bogus name to see what your installed
|
|
153
|
+
version supports:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
$ webget s "query" --engine bogus
|
|
157
|
+
warning: unknown search engine(s): bogus - using auto
|
|
158
|
+
(known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
162
|
+
typo never kills a search.
|
|
163
|
+
|
|
164
|
+
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
165
|
+
results, webget tries the remaining engines until a time budget is spent
|
|
166
|
+
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
167
|
+
first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
|
|
168
|
+
either too many (3 x 20s timeout = a minute of dead air) or too few (a live
|
|
169
|
+
engine sitting fifth in line never got reached). At least two alternates are
|
|
170
|
+
always tried, however fast the budget expires. This is not paranoia: engine
|
|
171
|
+
reachability depends on where you are, not just whether a service is up. A
|
|
172
|
+
benchmark from one residential connection found only 2 of 9 engines
|
|
173
|
+
reachable, and `auto` survived purely because they did.
|
|
174
|
+
|
|
175
|
+
Every substitution is announced, never silent:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
$ webget s "rust programming language book" --engine google
|
|
179
|
+
warning: engine 'google' failed (No results found.); fell back to 'brave'
|
|
180
|
+
1. The Rust Programming Language - doc.rust-lang.org/book/
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Results carry provenance.** Because failover can answer from an engine you
|
|
184
|
+
did not ask for, `--json` reports which one actually did:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
$ webget s "linux kernel" --engine google --json
|
|
188
|
+
{
|
|
189
|
+
"results": [ ... ],
|
|
190
|
+
"engine": "grokipedia", # who actually answered
|
|
191
|
+
"requested_engine": "google",
|
|
192
|
+
"failed_over": true
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
This matters most over MCP, where a stderr warning is invisible to the
|
|
197
|
+
agent: the payload is the only signal that the results came from elsewhere.
|
|
198
|
+
|
|
199
|
+
**Failover order is learned, not hardcoded.** webget keeps a small health
|
|
200
|
+
ledger per install (`~/.local/state/webget/engine_health.json`) recording
|
|
201
|
+
whether each engine answered and how fast. When failover kicks in, engines
|
|
202
|
+
are tried best-first by that record instead of in registry order. The ledger
|
|
203
|
+
is advisory - it reorders candidates, it never removes one - and it decays:
|
|
204
|
+
an engine marked dead last week is retried with no penalty this week, because
|
|
205
|
+
the blocking that made it dead is exactly what changes. There is no baked-in
|
|
206
|
+
ranking anywhere in the source; the same webget learns a different order on
|
|
207
|
+
your machine than on the author's, which is the point.
|
|
208
|
+
|
|
209
|
+
Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
|
|
210
|
+
|
|
211
|
+
> **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
|
|
212
|
+
> now return an object `{results, engine, requested_engine, failed_over}`
|
|
213
|
+
> instead of a bare list, so provenance has somewhere to live. Errors are
|
|
214
|
+
> `{error, results: []}`.
|
|
215
|
+
|
|
216
|
+
Provenance is per-call, not per-result: `ddgs` merges every engine's hits
|
|
217
|
+
into one list and discards which engine supplied each hit, so per-hit
|
|
218
|
+
attribution is not obtainable through its public API.
|
|
219
|
+
|
|
136
220
|
### Options
|
|
137
221
|
|
|
138
222
|
| Flag | Meaning |
|
|
@@ -260,6 +344,51 @@ Register as a local MCP server in opencode:
|
|
|
260
344
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
261
345
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
262
346
|
|
|
347
|
+
### Breaking change in 0.13.0: search returns an object, not a list
|
|
348
|
+
|
|
349
|
+
`search` and `search_fetch` used to return a bare JSON array of results.
|
|
350
|
+
They now return an object, because a list has nowhere to carry provenance:
|
|
351
|
+
|
|
352
|
+
```jsonc
|
|
353
|
+
// <= 0.12.1 -> a list
|
|
354
|
+
[ { "title": "...", "href": "...", "body": "..." }, ... ]
|
|
355
|
+
|
|
356
|
+
// >= 0.13.0 -> an object
|
|
357
|
+
{
|
|
358
|
+
"results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
|
|
359
|
+
"engine": "brave", // the engine that actually answered
|
|
360
|
+
"requested_engine": "google", // what was asked for
|
|
361
|
+
"failed_over": true // an alternative was attempted
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Error responses changed shape too:
|
|
366
|
+
|
|
367
|
+
```jsonc
|
|
368
|
+
// <= 0.12.1 -> a plain error string / empty list
|
|
369
|
+
// >= 0.13.0
|
|
370
|
+
{
|
|
371
|
+
"error": "SearchError: RequestError(...)",
|
|
372
|
+
"results": []
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**How to migrate.** If you read the tool result as an array, read
|
|
377
|
+
`.results` instead. In JavaScript that is `result.results` rather than
|
|
378
|
+
`result`; in Python `data["results"]` rather than `data`. If you only
|
|
379
|
+
iterate the hits, the change is mechanical.
|
|
380
|
+
|
|
381
|
+
**Why it had to break.** Failover means the engine that answers is not
|
|
382
|
+
always the engine that was asked for, and over MCP there is no stderr for a
|
|
383
|
+
warning to land on, so the payload is the only place that fact can live.
|
|
384
|
+
Keeping the list shape would have meant silently returning results from an
|
|
385
|
+
unexpected engine with no way for a client to notice. An additive field was
|
|
386
|
+
not possible: a JSON array cannot carry sibling keys.
|
|
387
|
+
|
|
388
|
+
`fetch` is unchanged and still returns its string payload. The Python API is
|
|
389
|
+
unchanged: `search()` still returns a list, and `search_with_provenance()`
|
|
390
|
+
is the new opt-in that returns `(results, provenance)`.
|
|
391
|
+
|
|
263
392
|
### Authenticated sessions (profiles)
|
|
264
393
|
|
|
265
394
|
MCP tools can use locally stored login sessions. Create one first with the
|
|
@@ -89,7 +89,9 @@ uv pip install -e ".[dev,browser]"
|
|
|
89
89
|
## Usage
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
webget s "rust async runtime" # search
|
|
92
|
+
webget s "rust async runtime" # search (ddgs metasearch, top 5)
|
|
93
|
+
webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
|
|
94
|
+
webget s "rust async runtime" --json # machine-readable, includes engine provenance
|
|
93
95
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
94
96
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
95
97
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
@@ -98,6 +100,86 @@ webget fetch https://example.com --json # machine-readable result
|
|
|
98
100
|
|
|
99
101
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
100
102
|
|
|
103
|
+
## Search engines
|
|
104
|
+
|
|
105
|
+
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
106
|
+
keyless engines. The default `auto` queries all of them; naming a subset is
|
|
107
|
+
faster and skips engines that are having a bad hour.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
webget s "query" --engine brave,duckduckgo
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Do not hardcode engine names.** The set ddgs offers changes between its
|
|
114
|
+
releases, and webget validates against the registry at runtime rather than
|
|
115
|
+
a built-in list. Pass a deliberately bogus name to see what your installed
|
|
116
|
+
version supports:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
$ webget s "query" --engine bogus
|
|
120
|
+
warning: unknown search engine(s): bogus - using auto
|
|
121
|
+
(known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
125
|
+
typo never kills a search.
|
|
126
|
+
|
|
127
|
+
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
128
|
+
results, webget tries the remaining engines until a time budget is spent
|
|
129
|
+
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
130
|
+
first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
|
|
131
|
+
either too many (3 x 20s timeout = a minute of dead air) or too few (a live
|
|
132
|
+
engine sitting fifth in line never got reached). At least two alternates are
|
|
133
|
+
always tried, however fast the budget expires. This is not paranoia: engine
|
|
134
|
+
reachability depends on where you are, not just whether a service is up. A
|
|
135
|
+
benchmark from one residential connection found only 2 of 9 engines
|
|
136
|
+
reachable, and `auto` survived purely because they did.
|
|
137
|
+
|
|
138
|
+
Every substitution is announced, never silent:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
$ webget s "rust programming language book" --engine google
|
|
142
|
+
warning: engine 'google' failed (No results found.); fell back to 'brave'
|
|
143
|
+
1. The Rust Programming Language - doc.rust-lang.org/book/
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Results carry provenance.** Because failover can answer from an engine you
|
|
147
|
+
did not ask for, `--json` reports which one actually did:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
$ webget s "linux kernel" --engine google --json
|
|
151
|
+
{
|
|
152
|
+
"results": [ ... ],
|
|
153
|
+
"engine": "grokipedia", # who actually answered
|
|
154
|
+
"requested_engine": "google",
|
|
155
|
+
"failed_over": true
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
This matters most over MCP, where a stderr warning is invisible to the
|
|
160
|
+
agent: the payload is the only signal that the results came from elsewhere.
|
|
161
|
+
|
|
162
|
+
**Failover order is learned, not hardcoded.** webget keeps a small health
|
|
163
|
+
ledger per install (`~/.local/state/webget/engine_health.json`) recording
|
|
164
|
+
whether each engine answered and how fast. When failover kicks in, engines
|
|
165
|
+
are tried best-first by that record instead of in registry order. The ledger
|
|
166
|
+
is advisory - it reorders candidates, it never removes one - and it decays:
|
|
167
|
+
an engine marked dead last week is retried with no penalty this week, because
|
|
168
|
+
the blocking that made it dead is exactly what changes. There is no baked-in
|
|
169
|
+
ranking anywhere in the source; the same webget learns a different order on
|
|
170
|
+
your machine than on the author's, which is the point.
|
|
171
|
+
|
|
172
|
+
Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
|
|
173
|
+
|
|
174
|
+
> **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
|
|
175
|
+
> now return an object `{results, engine, requested_engine, failed_over}`
|
|
176
|
+
> instead of a bare list, so provenance has somewhere to live. Errors are
|
|
177
|
+
> `{error, results: []}`.
|
|
178
|
+
|
|
179
|
+
Provenance is per-call, not per-result: `ddgs` merges every engine's hits
|
|
180
|
+
into one list and discards which engine supplied each hit, so per-hit
|
|
181
|
+
attribution is not obtainable through its public API.
|
|
182
|
+
|
|
101
183
|
### Options
|
|
102
184
|
|
|
103
185
|
| Flag | Meaning |
|
|
@@ -225,6 +307,51 @@ Register as a local MCP server in opencode:
|
|
|
225
307
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
226
308
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
227
309
|
|
|
310
|
+
### Breaking change in 0.13.0: search returns an object, not a list
|
|
311
|
+
|
|
312
|
+
`search` and `search_fetch` used to return a bare JSON array of results.
|
|
313
|
+
They now return an object, because a list has nowhere to carry provenance:
|
|
314
|
+
|
|
315
|
+
```jsonc
|
|
316
|
+
// <= 0.12.1 -> a list
|
|
317
|
+
[ { "title": "...", "href": "...", "body": "..." }, ... ]
|
|
318
|
+
|
|
319
|
+
// >= 0.13.0 -> an object
|
|
320
|
+
{
|
|
321
|
+
"results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
|
|
322
|
+
"engine": "brave", // the engine that actually answered
|
|
323
|
+
"requested_engine": "google", // what was asked for
|
|
324
|
+
"failed_over": true // an alternative was attempted
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Error responses changed shape too:
|
|
329
|
+
|
|
330
|
+
```jsonc
|
|
331
|
+
// <= 0.12.1 -> a plain error string / empty list
|
|
332
|
+
// >= 0.13.0
|
|
333
|
+
{
|
|
334
|
+
"error": "SearchError: RequestError(...)",
|
|
335
|
+
"results": []
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**How to migrate.** If you read the tool result as an array, read
|
|
340
|
+
`.results` instead. In JavaScript that is `result.results` rather than
|
|
341
|
+
`result`; in Python `data["results"]` rather than `data`. If you only
|
|
342
|
+
iterate the hits, the change is mechanical.
|
|
343
|
+
|
|
344
|
+
**Why it had to break.** Failover means the engine that answers is not
|
|
345
|
+
always the engine that was asked for, and over MCP there is no stderr for a
|
|
346
|
+
warning to land on, so the payload is the only place that fact can live.
|
|
347
|
+
Keeping the list shape would have meant silently returning results from an
|
|
348
|
+
unexpected engine with no way for a client to notice. An additive field was
|
|
349
|
+
not possible: a JSON array cannot carry sibling keys.
|
|
350
|
+
|
|
351
|
+
`fetch` is unchanged and still returns its string payload. The Python API is
|
|
352
|
+
unchanged: `search()` still returns a list, and `search_with_provenance()`
|
|
353
|
+
is the new opt-in that returns `(results, provenance)`.
|
|
354
|
+
|
|
228
355
|
### Authenticated sessions (profiles)
|
|
229
356
|
|
|
230
357
|
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.14.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"]
|
|
@@ -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])
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for the CLI surface of multi-engine search."""
|
|
2
|
+
|
|
3
|
+
from webget import cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _opts(*args):
|
|
7
|
+
return cli.parse_opts(list(args))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestParseOptsEngine:
|
|
11
|
+
def test_engine_flag_long(self):
|
|
12
|
+
r = _opts("s", "q", "--engine", "brave")
|
|
13
|
+
assert r[-1] == "brave"
|
|
14
|
+
|
|
15
|
+
def test_engine_flag_short(self):
|
|
16
|
+
r = _opts("s", "q", "-e", "brave,duckduckgo")
|
|
17
|
+
assert r[-1] == "brave,duckduckgo"
|
|
18
|
+
|
|
19
|
+
def test_engine_defaults_to_none(self):
|
|
20
|
+
r = _opts("s", "q")
|
|
21
|
+
assert r[-1] is None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestCmdSearchEngine:
|
|
25
|
+
def test_engine_reaches_search_call(self, monkeypatch, capsys):
|
|
26
|
+
captured = {}
|
|
27
|
+
|
|
28
|
+
def fake_prov(query, n=5, engine=None):
|
|
29
|
+
captured["query"] = query
|
|
30
|
+
captured["n"] = n
|
|
31
|
+
captured["engine"] = engine
|
|
32
|
+
return (
|
|
33
|
+
[{"title": "T", "url": "https://x.example", "snippet": "s"}],
|
|
34
|
+
{"requested": engine or "auto", "engine": engine or "auto", "failed_over": False},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# patch where it is USED (webget.cli namespace), not where defined.
|
|
38
|
+
# The CLI prefers search_with_provenance, so patching only `search`
|
|
39
|
+
# would leave the real network path live.
|
|
40
|
+
monkeypatch.setattr(cli, "search_with_provenance", fake_prov)
|
|
41
|
+
monkeypatch.setattr("sys.argv", ["webget", "s", "query", "--engine", "brave"])
|
|
42
|
+
try:
|
|
43
|
+
cli.main()
|
|
44
|
+
except SystemExit as e:
|
|
45
|
+
assert e.code in (0, None)
|
|
46
|
+
assert captured["engine"] == "brave"
|
|
47
|
+
assert captured["query"] == "query"
|
|
48
|
+
|
|
49
|
+
def test_invalid_engine_does_not_crash_cli(self, monkeypatch, capsys):
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
cli,
|
|
52
|
+
"search_with_provenance",
|
|
53
|
+
lambda query, n=5, engine=None: (
|
|
54
|
+
[{"title": "T", "url": "https://y.example", "snippet": "s"}],
|
|
55
|
+
{"requested": engine or "auto", "engine": engine or "auto", "failed_over": False},
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
monkeypatch.setattr("sys.argv", ["webget", "s", "q", "--engine", "totally-bogus"])
|
|
59
|
+
try:
|
|
60
|
+
cli.main()
|
|
61
|
+
except SystemExit as e:
|
|
62
|
+
assert e.code in (0, None)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for engine provenance surfacing through the CLI.
|
|
2
|
+
|
|
3
|
+
The point of provenance is that it must be REACHABLE. A stderr warning is
|
|
4
|
+
lost when output is piped, so `webget s q --json` has to carry it in the
|
|
5
|
+
payload. Note the bare `webget s q --json` path did not previously exist at
|
|
6
|
+
all (cmd == "s" had no json branch), so this adds it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from webget import cli
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(argv, monkeypatch, capsys, search_impl=None, prov_impl=None):
|
|
17
|
+
"""Run the CLI with search stubbed.
|
|
18
|
+
|
|
19
|
+
Both hooks are patched: the CLI prefers `search_with_provenance`, so
|
|
20
|
+
patching only `search` would leave the real network path live. Pass
|
|
21
|
+
`prov_impl` to control provenance explicitly.
|
|
22
|
+
"""
|
|
23
|
+
impl = search_impl or (lambda *a, **k: [])
|
|
24
|
+
|
|
25
|
+
if prov_impl is None:
|
|
26
|
+
|
|
27
|
+
def _default_prov(query, n=5, engine=None):
|
|
28
|
+
return impl(query, n, engine), {
|
|
29
|
+
"requested": engine or "auto",
|
|
30
|
+
"engine": engine or "auto",
|
|
31
|
+
"failed_over": False,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
prov_impl = _default_prov
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(cli, "search", lambda *a, **k: impl(*a, **k))
|
|
37
|
+
monkeypatch.setattr(cli, "search_with_provenance", prov_impl)
|
|
38
|
+
monkeypatch.setattr("sys.argv", ["webget", *argv])
|
|
39
|
+
try:
|
|
40
|
+
cli.main()
|
|
41
|
+
except SystemExit as e:
|
|
42
|
+
assert e.code in (0, None)
|
|
43
|
+
return capsys.readouterr()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestProvenanceIsWiredUp:
|
|
47
|
+
"""Guard against the fallback silently swallowing real provenance.
|
|
48
|
+
|
|
49
|
+
`_search_with_prov` falls back to a best-effort dict when
|
|
50
|
+
`search_with_provenance` is missing from the module namespace. That
|
|
51
|
+
fallback exists for older tests, but if the real import is ever
|
|
52
|
+
dropped the CLI would report `failed_over: False` forever while
|
|
53
|
+
looking perfectly green. This asserts the real function is reachable.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def test_cli_namespace_exposes_the_real_function(self):
|
|
57
|
+
assert hasattr(cli, "search_with_provenance")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestSearchJsonOutput:
|
|
61
|
+
def test_json_flag_emits_valid_json(self, monkeypatch, capsys):
|
|
62
|
+
out = _run(
|
|
63
|
+
["s", "q", "--json"],
|
|
64
|
+
monkeypatch,
|
|
65
|
+
capsys,
|
|
66
|
+
lambda *a, **k: [{"title": "T", "url": "https://x.example/", "snippet": "s"}],
|
|
67
|
+
)
|
|
68
|
+
payload = json.loads(out.out)
|
|
69
|
+
assert payload["results"][0]["url"] == "https://x.example/"
|
|
70
|
+
|
|
71
|
+
def test_json_includes_engine_provenance_when_available(self, monkeypatch, capsys):
|
|
72
|
+
def fake_prov(query, n=5, engine=None):
|
|
73
|
+
return (
|
|
74
|
+
[{"title": "T", "url": "https://x.example/", "snippet": "s"}],
|
|
75
|
+
{
|
|
76
|
+
"requested": "google",
|
|
77
|
+
"engine": "brave",
|
|
78
|
+
"failed_over": True,
|
|
79
|
+
"tried": ["google", "brave"],
|
|
80
|
+
"first_error": "No results found.",
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
out = _run(["s", "q", "--json"], monkeypatch, capsys, prov_impl=fake_prov)
|
|
85
|
+
payload = json.loads(out.out)
|
|
86
|
+
assert payload["engine"] == "brave"
|
|
87
|
+
assert payload["requested_engine"] == "google"
|
|
88
|
+
assert payload["failed_over"] is True
|
|
89
|
+
|
|
90
|
+
def test_plain_output_still_prints_results(self, monkeypatch, capsys):
|
|
91
|
+
out = _run(
|
|
92
|
+
["s", "q"],
|
|
93
|
+
monkeypatch,
|
|
94
|
+
capsys,
|
|
95
|
+
lambda *a, **k: [{"title": "T", "url": "https://x.example/", "snippet": "snip"}],
|
|
96
|
+
)
|
|
97
|
+
assert "https://x.example/" in out.out
|
|
98
|
+
assert "1." in out.out
|