webget-cli 0.14.0__tar.gz → 0.16.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webget_cli-0.14.0 → webget_cli-0.16.0}/PKG-INFO +84 -5
- {webget_cli-0.14.0 → webget_cli-0.16.0}/README.md +82 -2
- {webget_cli-0.14.0 → webget_cli-0.16.0}/pyproject.toml +2 -3
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_http.py +11 -3
- webget_cli-0.16.0/tests/test_auth_state.py +187 -0
- webget_cli-0.16.0/tests/test_browser_discovery.py +270 -0
- webget_cli-0.16.0/tests/test_cache_cap.py +115 -0
- webget_cli-0.16.0/tests/test_cli_crawl.py +38 -0
- webget_cli-0.16.0/tests/test_cli_parse_opts.py +263 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_concurrency_review.py +12 -7
- webget_cli-0.16.0/tests/test_crawler.py +62 -0
- webget_cli-0.16.0/tests/test_discovery_sitemap_regex.py +133 -0
- webget_cli-0.16.0/tests/test_discovery_ssrf.py +145 -0
- webget_cli-0.16.0/tests/test_doctor.py +147 -0
- webget_cli-0.16.0/tests/test_extraction.py +33 -0
- webget_cli-0.16.0/tests/test_feed_markdown.py +108 -0
- webget_cli-0.16.0/tests/test_firecrawl_status.py +140 -0
- webget_cli-0.16.0/tests/test_frontier.py +48 -0
- webget_cli-0.16.0/tests/test_ladder_honesty.py +159 -0
- webget_cli-0.16.0/tests/test_mcp_crawl.py +17 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_engine.py +2 -2
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_metadata.py +1 -1
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_provenance.py +3 -3
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_server.py +1 -1
- webget_cli-0.16.0/tests/test_profile_logout_storage.py +123 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_engine.py +29 -1
- webget_cli-0.16.0/tests/test_search_normalize.py +157 -0
- webget_cli-0.16.0/tests/test_searxng.py +81 -0
- webget_cli-0.16.0/tests/test_truncate.py +88 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/__init__.py +21 -1
- webget_cli-0.16.0/webget/browser.py +276 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/cache.py +8 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/cli.py +342 -33
- webget_cli-0.16.0/webget/crawler.py +179 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/discovery.py +47 -7
- webget_cli-0.16.0/webget/extraction.py +133 -0
- webget_cli-0.16.0/webget/firecrawl.py +121 -0
- webget_cli-0.16.0/webget/frontier.py +200 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/health.py +10 -1
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/http.py +48 -8
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/ladder.py +140 -56
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/profile.py +99 -6
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/search.py +107 -16
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/ssrf.py +23 -0
- webget_cli-0.16.0/webget/truncate.py +50 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/PKG-INFO +84 -5
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/SOURCES.txt +24 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_mcp.py +63 -10
- webget_cli-0.14.0/webget/firecrawl.py +0 -61
- {webget_cli-0.14.0 → webget_cli-0.16.0}/LICENSE +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/setup.cfg +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_base64_strip.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cli_engine.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cli_provenance.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_health.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_login_flow.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_metadata.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_nonhtml.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_failover.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_provenance.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_webget.py +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.py +0 -0
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.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
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/DavidPandleton/webget
|
|
8
8
|
Project-URL: Repository, https://github.com/DavidPandleton/webget
|
|
9
9
|
Project-URL: Changelog, https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md
|
|
10
10
|
Keywords: scraping,crawling,cli,search,duckduckgo,http
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -103,7 +102,7 @@ webget --help
|
|
|
103
102
|
|
|
104
103
|
### Browser runtime (optional)
|
|
105
104
|
|
|
106
|
-
Crawl4AI drives a Playwright
|
|
105
|
+
Crawl4AI drives a Playwright browser. `pip install "webget-cli[browser]"`
|
|
107
106
|
installs the Python packages; the browser binary itself is downloaded
|
|
108
107
|
separately:
|
|
109
108
|
|
|
@@ -111,6 +110,41 @@ separately:
|
|
|
111
110
|
python -m playwright install chromium
|
|
112
111
|
```
|
|
113
112
|
|
|
113
|
+
**You may not need that download.** Before reaching for Playwright's bundled
|
|
114
|
+
Chromium, webget looks for a Chromium-family browser already installed on the
|
|
115
|
+
machine and uses it when it can actually be driven. Run `webget doctor` to see
|
|
116
|
+
what was found and what will be used:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
webget doctor
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Resolution order (first match wins):
|
|
123
|
+
|
|
124
|
+
| Step | Source | Notes |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| 1 | `WEBGET_BROWSER_CDP` | Attach to a running browser over CDP. The only route that works for browsers with no Playwright channel. |
|
|
127
|
+
| 2 | `WEBGET_BROWSER_CHANNEL` | A Playwright channel: `chrome`, `msedge`, or `chromium`. |
|
|
128
|
+
| 3 | `WEBGET_BROWSER_PATH` | An explicit binary. Accepted, but see the limitation below. |
|
|
129
|
+
| 4 | Auto-detected browser | Chrome, Edge, Chromium, Brave, Vivaldi or Opera found on the machine. |
|
|
130
|
+
| 5 | Playwright bundled Chromium | The fallback when nothing usable is found. |
|
|
131
|
+
|
|
132
|
+
Two limitations worth knowing, both reported honestly by `webget doctor`
|
|
133
|
+
instead of failing quietly:
|
|
134
|
+
|
|
135
|
+
- **Brave, Vivaldi and Opera cannot be launched by crawl4ai.** Playwright has no
|
|
136
|
+
channel for them, and crawl4ai 0.9.2 does not forward `executable_path` to
|
|
137
|
+
Playwright, so there is no path to the binary. They are detected and reported
|
|
138
|
+
as NOT USABLE. The workaround is CDP: start the browser with
|
|
139
|
+
`--remote-debugging-port=9222`, then set
|
|
140
|
+
`WEBGET_BROWSER_CDP=http://127.0.0.1:9222`.
|
|
141
|
+
- **CDP is opt-in on purpose.** Attaching to a browser you are already logged
|
|
142
|
+
into mixes your personal session cookies into crawl output, so webget never
|
|
143
|
+
auto-detects an open debugging port.
|
|
144
|
+
|
|
145
|
+
System Firefox is never selected: Playwright drives Firefox over WebDriver BiDi
|
|
146
|
+
with its own patched build.
|
|
147
|
+
|
|
114
148
|
Without the browser extra, `webget` still works for search and plain HTTP
|
|
115
149
|
fetches. A fetch that needs the browser (JS rendering, `--profile` sessions,
|
|
116
150
|
`login`) prints a clear warning telling you how to install it.
|
|
@@ -132,11 +166,27 @@ webget s "rust async runtime" --json # machine-readable, includes engine pr
|
|
|
132
166
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
133
167
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
134
168
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
169
|
+
webget crawl https://example.com crawl.db --limit 20 --json # resumable local crawl
|
|
135
170
|
webget fetch https://example.com --json # machine-readable result
|
|
136
171
|
```
|
|
137
172
|
|
|
138
173
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
139
174
|
|
|
175
|
+
### Resumable local crawl
|
|
176
|
+
|
|
177
|
+
The crawler is local and bounded: it uses SQLite for durable frontier state
|
|
178
|
+
and page results, stays on the seed hostname, and can resume after a process
|
|
179
|
+
restart. It does not require Docker, Redis, Postgres, or a browser.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
webget crawl https://example.com ./crawl.db --limit 20 --timeout 20 --json
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The same operation is available through Python as `webget.crawl_site(...)` and
|
|
186
|
+
through MCP as the `crawl` tool. Results can also be exported with the Python
|
|
187
|
+
API to JSONL or Markdown. Stale in-progress leases are recovered using a
|
|
188
|
+
bounded timeout.
|
|
189
|
+
|
|
140
190
|
## Search engines
|
|
141
191
|
|
|
142
192
|
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
@@ -161,6 +211,31 @@ warning: unknown search engine(s): bogus - using auto
|
|
|
161
211
|
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
162
212
|
typo never kills a search.
|
|
163
213
|
|
|
214
|
+
### Optional SearXNG provider
|
|
215
|
+
|
|
216
|
+
SearXNG is not bundled or required. Point the optional HTTP adapter at an
|
|
217
|
+
existing instance using an environment variable:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
export WEBGET_SEARXNG_URL=http://localhost:8080
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Then use the provider from Python:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from webget import SearxngSearchProvider
|
|
227
|
+
from webget.search import search_with_provenance
|
|
228
|
+
|
|
229
|
+
provider = SearxngSearchProvider()
|
|
230
|
+
results, provenance = search_with_provenance(
|
|
231
|
+
"query", n=5, engine="searxng", provider=provider
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The adapter calls `/search?format=json`, normalizes result fields, and keeps
|
|
236
|
+
the usual per-call provenance. Live availability depends on the configured
|
|
237
|
+
SearXNG instance; the core package does not start one.
|
|
238
|
+
|
|
164
239
|
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
165
240
|
results, webget tries the remaining engines until a time budget is spent
|
|
166
241
|
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
@@ -347,7 +422,11 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
347
422
|
### Breaking change in 0.13.0: search returns an object, not a list
|
|
348
423
|
|
|
349
424
|
`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
|
|
425
|
+
They now return an object, because a list has nowhere to carry provenance.
|
|
426
|
+
The MCP result-count argument is `limit` (default 5 for `search`, 3 for
|
|
427
|
+
`search_fetch`), matching the CLI vocabulary. The legacy MCP argument `n`
|
|
428
|
+
remains accepted as an explicit compatibility alias; when both are supplied,
|
|
429
|
+
`n` wins. The Python API continues to use `n` for backward compatibility.
|
|
351
430
|
|
|
352
431
|
```jsonc
|
|
353
432
|
// <= 0.12.1 -> a list
|
|
@@ -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.
|
|
@@ -95,11 +130,27 @@ webget s "rust async runtime" --json # machine-readable, includes engine pr
|
|
|
95
130
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
96
131
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
97
132
|
cat urls.txt | webget u - # batch scrape, one browser instance
|
|
133
|
+
webget crawl https://example.com crawl.db --limit 20 --json # resumable local crawl
|
|
98
134
|
webget fetch https://example.com --json # machine-readable result
|
|
99
135
|
```
|
|
100
136
|
|
|
101
137
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
102
138
|
|
|
139
|
+
### Resumable local crawl
|
|
140
|
+
|
|
141
|
+
The crawler is local and bounded: it uses SQLite for durable frontier state
|
|
142
|
+
and page results, stays on the seed hostname, and can resume after a process
|
|
143
|
+
restart. It does not require Docker, Redis, Postgres, or a browser.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
webget crawl https://example.com ./crawl.db --limit 20 --timeout 20 --json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The same operation is available through Python as `webget.crawl_site(...)` and
|
|
150
|
+
through MCP as the `crawl` tool. Results can also be exported with the Python
|
|
151
|
+
API to JSONL or Markdown. Stale in-progress leases are recovered using a
|
|
152
|
+
bounded timeout.
|
|
153
|
+
|
|
103
154
|
## Search engines
|
|
104
155
|
|
|
105
156
|
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
@@ -124,6 +175,31 @@ warning: unknown search engine(s): bogus - using auto
|
|
|
124
175
|
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
125
176
|
typo never kills a search.
|
|
126
177
|
|
|
178
|
+
### Optional SearXNG provider
|
|
179
|
+
|
|
180
|
+
SearXNG is not bundled or required. Point the optional HTTP adapter at an
|
|
181
|
+
existing instance using an environment variable:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
export WEBGET_SEARXNG_URL=http://localhost:8080
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Then use the provider from Python:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from webget import SearxngSearchProvider
|
|
191
|
+
from webget.search import search_with_provenance
|
|
192
|
+
|
|
193
|
+
provider = SearxngSearchProvider()
|
|
194
|
+
results, provenance = search_with_provenance(
|
|
195
|
+
"query", n=5, engine="searxng", provider=provider
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The adapter calls `/search?format=json`, normalizes result fields, and keeps
|
|
200
|
+
the usual per-call provenance. Live availability depends on the configured
|
|
201
|
+
SearXNG instance; the core package does not start one.
|
|
202
|
+
|
|
127
203
|
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
128
204
|
results, webget tries the remaining engines until a time budget is spent
|
|
129
205
|
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
@@ -310,7 +386,11 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
310
386
|
### Breaking change in 0.13.0: search returns an object, not a list
|
|
311
387
|
|
|
312
388
|
`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
|
|
389
|
+
They now return an object, because a list has nowhere to carry provenance.
|
|
390
|
+
The MCP result-count argument is `limit` (default 5 for `search`, 3 for
|
|
391
|
+
`search_fetch`), matching the CLI vocabulary. The legacy MCP argument `n`
|
|
392
|
+
remains accepted as an explicit compatibility alias; when both are supplied,
|
|
393
|
+
`n` wins. The Python API continues to use `n` for backward compatibility.
|
|
314
394
|
|
|
315
395
|
```jsonc
|
|
316
396
|
// <= 0.12.1 -> a list
|
|
@@ -4,17 +4,16 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.16.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"
|
|
11
|
-
license =
|
|
11
|
+
license = "Apache-2.0"
|
|
12
12
|
authors = [{ name = "David Tarigan", email = "tarigansdavid@gmail.com" }]
|
|
13
13
|
keywords = ["scraping", "crawling", "cli", "search", "duckduckgo", "http"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Environment :: Console",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
|
-
"License :: OSI Approved :: Apache Software License",
|
|
18
17
|
"Programming Language :: Python :: 3",
|
|
19
18
|
"Programming Language :: Python :: 3.11",
|
|
20
19
|
"Programming Language :: Python :: 3.12",
|
|
@@ -141,8 +141,11 @@ class TestResponseBodies:
|
|
|
141
141
|
def test_empty_response(self, fresh_cache):
|
|
142
142
|
server = fresh_cache
|
|
143
143
|
res = asyncio.run(_fetch(server.url("/empty")))
|
|
144
|
-
#
|
|
145
|
-
|
|
144
|
+
# Body kosong: permintaan berhasil (HTTP 200) tapi kontennya
|
|
145
|
+
# ditolak karena tipis. Status akhirnya kini "thin", bukan
|
|
146
|
+
# "error" generik, supaya pemanggil bisa membedakan "server tidak
|
|
147
|
+
# bisa dihubungi" dari "konten ditolak, ubah ambang atau strategi".
|
|
148
|
+
assert _one(res)["status"] in ("thin", "error", "blocked")
|
|
146
149
|
|
|
147
150
|
def test_malformed_html(self, fresh_cache):
|
|
148
151
|
server = fresh_cache
|
|
@@ -166,9 +169,14 @@ class TestResponseBodies:
|
|
|
166
169
|
assert _one(res)["status"] == "success" # httpx auto-decompresses
|
|
167
170
|
|
|
168
171
|
def test_huge_response_is_bounded(self, fresh_cache):
|
|
172
|
+
from webget.truncate import ELLIPSIS_MARKER
|
|
173
|
+
|
|
169
174
|
server = fresh_cache
|
|
170
175
|
# 5MB body; scrape_many must truncate, not blow memory or hang.
|
|
171
176
|
res = asyncio.run(_fetch(server.url("/huge"), max_chars=1000))
|
|
172
177
|
out = _one(res)
|
|
173
178
|
assert out["status"] == "success"
|
|
174
|
-
|
|
179
|
+
# smart_truncate cuts at the limit and appends the ellipsis marker
|
|
180
|
+
# (this body has no clean boundary, so it is a hard cut at the limit).
|
|
181
|
+
assert len(out["markdown"]) <= 1000 + len(ELLIPSIS_MARKER)
|
|
182
|
+
assert out["markdown"].endswith(ELLIPSIS_MARKER)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Tes profile._auth_state: deteksi login.
|
|
2
|
+
|
|
3
|
+
Dua hal diuji di sini:
|
|
4
|
+
|
|
5
|
+
1. PERBAIKAN yang gue commit: `login_words` memakai kata utuh, bukan
|
|
6
|
+
substring mentah. Sebelumnya "sign in" cocok dengan "design in", dan
|
|
7
|
+
setiap kata yang mengandung "login" dihitung. Sekarang regex kata utuh.
|
|
8
|
+
|
|
9
|
+
2. FALSE POSITIVE YANG SENGAJA DIBIARKAN. Halaman publik yang punya menu
|
|
10
|
+
"Login" di navigasi DAN input password milik form lain tetap dilaporkan
|
|
11
|
+
"login_required". Tes terakhir mengunci perilaku itu supaya tidak
|
|
12
|
+
mengejutkan, dan docstring-nya menjelaskan mengapa.
|
|
13
|
+
|
|
14
|
+
Gue sempat mencoba memperbaikinya dengan menuntut perintah login dan input
|
|
15
|
+
password berada di <form> yang sama. Itu SALAH: halaman login sungguhan
|
|
16
|
+
sering menaruh kata "Login" di <h1> di luar <form>, sehingga kontrol
|
|
17
|
+
(portal yang benar-benar butuh login) ikut lolos jadi "success".
|
|
18
|
+
|
|
19
|
+
Menandai halaman publik sebagai butuh login = pengguna mengejar sesi yang
|
|
20
|
+
tidak ada. Melewatkan halaman login sungguhan = pengguna TIDAK diberi tahu
|
|
21
|
+
sesinya mati, dan mengambil halaman login sebagai konten yang sah. Yang
|
|
22
|
+
kedua lebih berbahaya, jadi bias sengaja diarahkan ke false positive dan
|
|
23
|
+
bukan false negative.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from webget.profile import _auth_state
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _hasil(markdown, html, status=200):
|
|
34
|
+
return {"markdown": markdown, "html": html, "status_code": status}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ISI = "Konten yang cukup panjang untuk dianggap isi sungguhan. " * 10
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestPerbaikanKataUtuh:
|
|
41
|
+
def test_design_in_bukan_sign_in(self):
|
|
42
|
+
"""'design in' tidak boleh dihitung sebagai perintah login."""
|
|
43
|
+
state, _ = _auth_state(
|
|
44
|
+
_hasil(f"Kami design in-house. {ISI}", "<p>Tentang kami</p>"), None
|
|
45
|
+
)
|
|
46
|
+
assert state == "success"
|
|
47
|
+
|
|
48
|
+
def test_sign_in_utuh_tetap_terdeteksi(self):
|
|
49
|
+
"""Perbaikan tidak boleh menghilangkan deteksi yang sah.
|
|
50
|
+
|
|
51
|
+
Frasa "sign in to continue" termasuk penanda keharusan, jadi
|
|
52
|
+
terdeteksi lewat jalur login_phrases meskipun tanpa form password.
|
|
53
|
+
"""
|
|
54
|
+
state, _ = _auth_state(
|
|
55
|
+
_hasil(f"Please sign in to continue. {ISI}", "<p>a</p>"), None
|
|
56
|
+
)
|
|
57
|
+
assert state == "login_required"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestHalamanLoginTanpaForm:
|
|
61
|
+
"""Celah yang diperbaiki: SPA tidak mengirim <input type=password>.
|
|
62
|
+
|
|
63
|
+
Halaman login yang di-render JavaScript tidak memuat tag form di HTML
|
|
64
|
+
mentah, sehingga pemeriksaan `has_password_input` meleset dan halaman
|
|
65
|
+
itu lolos sebagai "success". Pengguna tidak diberi tahu sesinya mati,
|
|
66
|
+
dan halaman login diambil sebagai konten sah.
|
|
67
|
+
|
|
68
|
+
Penanda frasa keharusan menutup celah ini tanpa menandai halaman
|
|
69
|
+
publik yang sekadar menyebut kata "login".
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"teks",
|
|
74
|
+
[
|
|
75
|
+
"Please sign in to continue.",
|
|
76
|
+
"You must login first.",
|
|
77
|
+
"You must log in to view this page.",
|
|
78
|
+
"Login to continue.",
|
|
79
|
+
"Your session has timed out.",
|
|
80
|
+
"Session expired.",
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
def test_frasa_keharusan_terdeteksi(self, teks):
|
|
84
|
+
state, auth = _auth_state(_hasil(f"{teks} {ISI}", "<p>JS app</p>"), None)
|
|
85
|
+
assert state == "login_required", f"{teks!r} tidak terdeteksi"
|
|
86
|
+
assert auth is False
|
|
87
|
+
|
|
88
|
+
def test_artikel_tentang_login_tidak_terdeteksi(self):
|
|
89
|
+
"""Kata 'login' di artikel/navigasi bukan permintaan otentikasi."""
|
|
90
|
+
state, _ = _auth_state(
|
|
91
|
+
_hasil(f"Artikel ini membahas cara login ke sistem. {ISI}",
|
|
92
|
+
"<nav>Login</nav>"),
|
|
93
|
+
None,
|
|
94
|
+
)
|
|
95
|
+
assert state == "success"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestDeteksiLoginTetapKuat:
|
|
99
|
+
def test_form_login_sungguhan(self):
|
|
100
|
+
state, auth = _auth_state(
|
|
101
|
+
_hasil(
|
|
102
|
+
f"Silakan Login untuk melanjutkan. {ISI}",
|
|
103
|
+
'<form><input type="text" name="u">'
|
|
104
|
+
'<input type="password" name="p"></form>',
|
|
105
|
+
),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
assert state == "login_required"
|
|
109
|
+
assert auth is False
|
|
110
|
+
|
|
111
|
+
def test_login_di_luar_form_tetap_terdeteksi(self):
|
|
112
|
+
"""Kontrol penting: kata Login di <h1>, input password di <form>.
|
|
113
|
+
|
|
114
|
+
Inilah kasus yang patah saat gue menuntut keduanya satu <form>.
|
|
115
|
+
Portal kampus sering berbentuk begini, jadi harus tetap terdeteksi.
|
|
116
|
+
"""
|
|
117
|
+
state, _ = _auth_state(
|
|
118
|
+
_hasil(
|
|
119
|
+
f"<h1>Login</h1> {ISI}",
|
|
120
|
+
'<form><input type="password" name="p"></form>',
|
|
121
|
+
),
|
|
122
|
+
None,
|
|
123
|
+
)
|
|
124
|
+
assert state == "login_required"
|
|
125
|
+
|
|
126
|
+
def test_status_401(self):
|
|
127
|
+
state, auth = _auth_state(_hasil(ISI, "<p>a</p>", status=401), None)
|
|
128
|
+
assert state == "login_required"
|
|
129
|
+
assert auth is False
|
|
130
|
+
|
|
131
|
+
def test_label_kredensial_nim(self):
|
|
132
|
+
"""SION memakai label NIM/username tanpa type=password."""
|
|
133
|
+
state, _ = _auth_state(
|
|
134
|
+
_hasil(
|
|
135
|
+
f"Masukkan password dan NIM Anda. {ISI}",
|
|
136
|
+
"<form><input name='nim'></form>",
|
|
137
|
+
),
|
|
138
|
+
None,
|
|
139
|
+
)
|
|
140
|
+
assert state == "login_required"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestFalsePositiveYangDibiarkan:
|
|
144
|
+
def test_menu_login_plus_input_password_lain(self):
|
|
145
|
+
"""False positive yang sengaja dibiarkan, dikunci di sini.
|
|
146
|
+
|
|
147
|
+
Halaman publik dengan menu "Login" di navigasi DAN input password
|
|
148
|
+
milik form lain (demo/pencarian) dilaporkan butuh login. Ini tidak
|
|
149
|
+
ideal, tapi memperbaikinya tanpa kehilangan kasus
|
|
150
|
+
test_login_di_luar_form_tetap_terdeteksi belum bisa dilakukan
|
|
151
|
+
dengan sinyal murni teks. Lebih baik terkunci dan terlihat daripada
|
|
152
|
+
menjadi kejutan.
|
|
153
|
+
"""
|
|
154
|
+
state, _ = _auth_state(
|
|
155
|
+
_hasil(
|
|
156
|
+
f"Selamat datang di dokumentasi publik. {ISI}",
|
|
157
|
+
'<nav><a href="/login">Login</a></nav>'
|
|
158
|
+
'<form action="/cari"><input type="text" name="q">'
|
|
159
|
+
'<input type="password" name="demo"></form>',
|
|
160
|
+
),
|
|
161
|
+
None,
|
|
162
|
+
)
|
|
163
|
+
assert state == "login_required", (
|
|
164
|
+
"perilaku berubah; kalau false positive ini akhirnya diperbaiki "
|
|
165
|
+
"dengan cara yang aman, ubah tes ini dan hapus docstring-nya"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestHalamanPublikNormal:
|
|
170
|
+
def test_input_password_tanpa_kata_login(self):
|
|
171
|
+
state, _ = _auth_state(
|
|
172
|
+
_hasil(f"Alat ini butuh kata sandi untuk demo. {ISI}",
|
|
173
|
+
'<input type="password" name="demo">'),
|
|
174
|
+
None,
|
|
175
|
+
)
|
|
176
|
+
assert state == "success"
|
|
177
|
+
|
|
178
|
+
def test_halaman_bersih_dengan_profil(self):
|
|
179
|
+
"""Dengan profil, halaman sukses berarti authenticated=True."""
|
|
180
|
+
state, auth = _auth_state(_hasil(ISI, "<p>a</p>"), "kampus")
|
|
181
|
+
assert state == "success"
|
|
182
|
+
assert auth is True
|
|
183
|
+
|
|
184
|
+
def test_halaman_bersih_tanpa_profil(self):
|
|
185
|
+
state, auth = _auth_state(_hasil(ISI, "<p>a</p>"), None)
|
|
186
|
+
assert state == "success"
|
|
187
|
+
assert auth is None
|