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.
Files changed (67) hide show
  1. {webget_cli-0.12.1 → webget_cli-0.15.0}/PKG-INFO +168 -4
  2. {webget_cli-0.12.1 → webget_cli-0.15.0}/README.md +164 -2
  3. {webget_cli-0.12.1 → webget_cli-0.15.0}/pyproject.toml +24 -3
  4. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_http.py +6 -1
  5. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_mcp.py +26 -9
  6. webget_cli-0.15.0/tests/test_browser_discovery.py +270 -0
  7. webget_cli-0.15.0/tests/test_cli_engine.py +62 -0
  8. webget_cli-0.15.0/tests/test_cli_provenance.py +98 -0
  9. webget_cli-0.15.0/tests/test_doctor.py +119 -0
  10. webget_cli-0.15.0/tests/test_health.py +125 -0
  11. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_login_flow.py +2 -1
  12. webget_cli-0.15.0/tests/test_mcp_engine.py +66 -0
  13. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_leak_review.py +11 -3
  14. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_metadata.py +6 -3
  15. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_profile.py +4 -3
  16. webget_cli-0.15.0/tests/test_mcp_provenance.py +90 -0
  17. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_server.py +10 -8
  18. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_smoke.py +17 -4
  19. webget_cli-0.15.0/tests/test_search_engine.py +108 -0
  20. webget_cli-0.15.0/tests/test_search_failover.py +247 -0
  21. webget_cli-0.15.0/tests/test_search_provenance.py +159 -0
  22. webget_cli-0.15.0/tests/test_truncate.py +88 -0
  23. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_webget.py +9 -8
  24. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/__init__.py +2 -1
  25. webget_cli-0.15.0/webget/browser.py +276 -0
  26. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/cli.py +203 -41
  27. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/firecrawl.py +3 -1
  28. webget_cli-0.15.0/webget/health.py +198 -0
  29. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/http.py +3 -2
  30. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/ladder.py +77 -46
  31. webget_cli-0.15.0/webget/search.py +352 -0
  32. webget_cli-0.15.0/webget/truncate.py +50 -0
  33. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/PKG-INFO +168 -4
  34. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/SOURCES.txt +14 -0
  35. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/requires.txt +3 -1
  36. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_mcp.py +56 -11
  37. webget_cli-0.12.1/webget/search.py +0 -38
  38. {webget_cli-0.12.1 → webget_cli-0.15.0}/LICENSE +0 -0
  39. {webget_cli-0.12.1 → webget_cli-0.15.0}/setup.cfg +0 -0
  40. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_auth.py +0 -0
  41. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_cache.py +0 -0
  42. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_concurrency.py +0 -0
  43. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_adversarial_ssrf.py +0 -0
  44. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_auth_review.py +0 -0
  45. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_base64_strip.py +0 -0
  46. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_browser_ssrf.py +0 -0
  47. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_cache_review.py +0 -0
  48. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_concurrency_review.py +0 -0
  49. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_discovery_map.py +0 -0
  50. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_extraction_markdown.py +0 -0
  51. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_firecrawl_policy.py +0 -0
  52. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_integration_ladder.py +0 -0
  53. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_ladder_retry.py +0 -0
  54. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_mcp_map.py +0 -0
  55. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_metadata.py +0 -0
  56. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_nonhtml.py +0 -0
  57. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_security_review.py +0 -0
  58. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_size_review.py +0 -0
  59. {webget_cli-0.12.1 → webget_cli-0.15.0}/tests/test_ssrf_dual_dns.py +0 -0
  60. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/cache.py +0 -0
  61. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/discovery.py +0 -0
  62. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/profile.py +0 -0
  63. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget/ssrf.py +0 -0
  64. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  65. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/entry_points.txt +0 -0
  66. {webget_cli-0.12.1 → webget_cli-0.15.0}/webget_cli.egg-info/top_level.txt +0 -0
  67. {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.12.1
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>=2; extra == "mcp"
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 Chromium. `pip install "webget-cli[browser]"`
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 DuckDuckGo (top 5)
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 Chromium. `pip install "webget-cli[browser]"`
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 DuckDuckGo (top 5)
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.12.1"
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 = ["fastmcp>=2"]
40
- dev = ["pytest>=8", "ruff>=0.6"]
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
- assert len(out["markdown"]) <= 1000
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.isError
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.isError
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.isError:
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.isError for r in results]
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.isError
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 list of dicts with title/url/snippet."""
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, list)
187
- if payload:
188
- assert {"title", "url", "snippet"} <= set(payload[0])
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])