webget-cli 0.15.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.15.0 → webget_cli-0.16.0}/PKG-INFO +48 -4
- {webget_cli-0.15.0 → webget_cli-0.16.0}/README.md +46 -1
- {webget_cli-0.15.0 → webget_cli-0.16.0}/pyproject.toml +2 -3
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_http.py +5 -2
- webget_cli-0.16.0/tests/test_auth_state.py +187 -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.15.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.15.0 → webget_cli-0.16.0}/tests/test_doctor.py +31 -3
- 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.15.0 → webget_cli-0.16.0}/tests/test_mcp_engine.py +2 -2
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_metadata.py +1 -1
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_provenance.py +3 -3
- {webget_cli-0.15.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.15.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.15.0 → webget_cli-0.16.0}/webget/__init__.py +21 -1
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/cache.py +8 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/cli.py +217 -32
- webget_cli-0.16.0/webget/crawler.py +179 -0
- {webget_cli-0.15.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.15.0 → webget_cli-0.16.0}/webget/health.py +10 -1
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/http.py +45 -6
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/ladder.py +63 -10
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/profile.py +99 -6
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/search.py +107 -16
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/ssrf.py +23 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/PKG-INFO +48 -4
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/SOURCES.txt +19 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_mcp.py +63 -10
- webget_cli-0.15.0/webget/firecrawl.py +0 -63
- {webget_cli-0.15.0 → webget_cli-0.16.0}/LICENSE +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/setup.cfg +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_base64_strip.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_browser_discovery.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_cli_engine.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_cli_provenance.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_health.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_login_flow.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_metadata.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_nonhtml.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_search_failover.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_search_provenance.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_truncate.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/tests/test_webget.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/browser.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget/truncate.py +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.15.0 → webget_cli-0.16.0}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.15.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
|
|
@@ -167,11 +166,27 @@ webget s "rust async runtime" --json # machine-readable, includes engine pr
|
|
|
167
166
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
168
167
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
169
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
|
|
170
170
|
webget fetch https://example.com --json # machine-readable result
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
174
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
|
+
|
|
175
190
|
## Search engines
|
|
176
191
|
|
|
177
192
|
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
@@ -196,6 +211,31 @@ warning: unknown search engine(s): bogus - using auto
|
|
|
196
211
|
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
197
212
|
typo never kills a search.
|
|
198
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
|
+
|
|
199
239
|
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
200
240
|
results, webget tries the remaining engines until a time budget is spent
|
|
201
241
|
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
@@ -382,7 +422,11 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
382
422
|
### Breaking change in 0.13.0: search returns an object, not a list
|
|
383
423
|
|
|
384
424
|
`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
|
|
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.
|
|
386
430
|
|
|
387
431
|
```jsonc
|
|
388
432
|
// <= 0.12.1 -> a list
|
|
@@ -130,11 +130,27 @@ webget s "rust async runtime" --json # machine-readable, includes engine pr
|
|
|
130
130
|
webget u https://example.com # scrape (auto: http -> crawl4ai)
|
|
131
131
|
webget su "llm inference" 5 # search + scrape top 5, parallel
|
|
132
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
|
|
133
134
|
webget fetch https://example.com --json # machine-readable result
|
|
134
135
|
```
|
|
135
136
|
|
|
136
137
|
Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
|
|
137
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
|
+
|
|
138
154
|
## Search engines
|
|
139
155
|
|
|
140
156
|
webget searches through `ddgs`, a metasearch library that aggregates several
|
|
@@ -159,6 +175,31 @@ warning: unknown search engine(s): bogus - using auto
|
|
|
159
175
|
Unknown names degrade to `auto` with a warning instead of failing, so a
|
|
160
176
|
typo never kills a search.
|
|
161
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
|
+
|
|
162
203
|
**Failover is automatic.** If the engine you named fails, or returns zero
|
|
163
204
|
results, webget tries the remaining engines until a time budget is spent
|
|
164
205
|
(15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
|
|
@@ -345,7 +386,11 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
345
386
|
### Breaking change in 0.13.0: search returns an object, not a list
|
|
346
387
|
|
|
347
388
|
`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
|
|
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.
|
|
349
394
|
|
|
350
395
|
```jsonc
|
|
351
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
|
|
@@ -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
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tes cache: WEBGET_CACHE_MAX tidak valid tidak boleh menghapus segalanya.
|
|
2
|
+
|
|
3
|
+
Eviction di cache_put:
|
|
4
|
+
|
|
5
|
+
cap = int(os.environ.get("WEBGET_CACHE_MAX", "5000"))
|
|
6
|
+
...
|
|
7
|
+
if len(files) > cap:
|
|
8
|
+
... buang yang kedaluwarsa ...
|
|
9
|
+
if len(live) > cap:
|
|
10
|
+
live.sort(key=os.path.getmtime)
|
|
11
|
+
for f in live[: len(live) - int(cap * 0.8)]:
|
|
12
|
+
os.remove(f)
|
|
13
|
+
|
|
14
|
+
Dengan cap <= 0, irisan itu mencakup SELURUH daftar, sehingga satu
|
|
15
|
+
penulisan menghapus seluruh cache:
|
|
16
|
+
|
|
17
|
+
cap = 0 -> int(0*0.8) == 0, live[:len(live)] -> semua
|
|
18
|
+
cap = -5 -> int(-5*0.8) == -4, live[:len(live)+4] -> semua
|
|
19
|
+
|
|
20
|
+
Sebelumnya hanya ValueError yang ditangani, jadi "abc" aman tapi "0" dan
|
|
21
|
+
"-5" justru paling merusak. Sekarang cap <= 0 juga kembali ke bawaan.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from webget import cache
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def cache_dir(tmp_path, monkeypatch):
|
|
35
|
+
"""Arahkan cache ke tmp_path SEBELUM penulisan apa pun.
|
|
36
|
+
|
|
37
|
+
Hook override ada di SHIM `webget_cli.CACHE_DIR`, bukan
|
|
38
|
+
`webget.cache.CACHE_DIR`: _cache_dir() mengimpor webget_cli dan membaca
|
|
39
|
+
atributnya, sehingga menetapkan atribut di modul cache sendiri tidak
|
|
40
|
+
berpengaruh. Versi pertama tes ini menambal nama yang salah, portanto
|
|
41
|
+
nol file tertulis di tmp dan assertion-nya lolos/gagal karena salah
|
|
42
|
+
alasan.
|
|
43
|
+
"""
|
|
44
|
+
import webget_cli
|
|
45
|
+
|
|
46
|
+
d = tmp_path / "cache"
|
|
47
|
+
d.mkdir()
|
|
48
|
+
monkeypatch.setattr(webget_cli, "CACHE_DIR", str(d))
|
|
49
|
+
return d
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _isi(d, n):
|
|
53
|
+
for i in range(n):
|
|
54
|
+
cache.cache_put(
|
|
55
|
+
url=f"https://x.test/{i}",
|
|
56
|
+
cookies=None,
|
|
57
|
+
headers=None,
|
|
58
|
+
max_chars=1000,
|
|
59
|
+
data={"markdown": f"isi {i}", "status": "success"},
|
|
60
|
+
)
|
|
61
|
+
return len([f for f in os.listdir(d) if f.endswith(".json")])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestCapTidakValid:
|
|
65
|
+
@pytest.mark.parametrize("nilai", ["0", "-5", "-1"])
|
|
66
|
+
def test_cap_nol_atau_negatif_tidak_menghapus_semua(self, cache_dir, monkeypatch, nilai):
|
|
67
|
+
"""cap <= 0 harus jatuh ke bawaan, bukan menghapus seluruh cache."""
|
|
68
|
+
monkeypatch.setenv("WEBGET_CACHE_MAX", nilai)
|
|
69
|
+
sebelum = _isi(cache_dir, 10)
|
|
70
|
+
assert sebelum == 10, f"setup gagal: hanya {sebelum} entri tertulis"
|
|
71
|
+
|
|
72
|
+
# Satu penulisan tambahan memicu jalur eviction.
|
|
73
|
+
cache.cache_put(
|
|
74
|
+
url="https://x.test/baru",
|
|
75
|
+
cookies=None,
|
|
76
|
+
headers=None,
|
|
77
|
+
max_chars=1000,
|
|
78
|
+
data={"markdown": "baru", "status": "success"},
|
|
79
|
+
)
|
|
80
|
+
sesudah = len([f for f in os.listdir(cache_dir) if f.endswith(".json")])
|
|
81
|
+
assert sesudah == 11, (
|
|
82
|
+
f"WEBGET_CACHE_MAX={nilai} menghapus cache: {sebelum} -> {sesudah}. "
|
|
83
|
+
f"cap <= 0 membuat irisan eviction mencakup semua entri."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def test_cap_bukan_angka_jatuh_ke_bawaan(self, cache_dir, monkeypatch):
|
|
87
|
+
monkeypatch.setenv("WEBGET_CACHE_MAX", "abc")
|
|
88
|
+
sebelum = _isi(cache_dir, 5)
|
|
89
|
+
cache.cache_put(
|
|
90
|
+
url="https://x.test/baru", cookies=None, headers=None,
|
|
91
|
+
max_chars=1000, data={"markdown": "x", "status": "success"},
|
|
92
|
+
)
|
|
93
|
+
sesudah = len([f for f in os.listdir(cache_dir) if f.endswith(".json")])
|
|
94
|
+
assert sesudah >= sebelum, "nilai tidak valid tidak boleh menghapus apa pun"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestEvictionNormal:
|
|
98
|
+
def test_cap_kecil_membatasi_jumlah_entri(self, cache_dir, monkeypatch):
|
|
99
|
+
"""cap yang sah tetap mengecilkan cache, tapi tidak ke nol.
|
|
100
|
+
|
|
101
|
+
Ini bukan bug: cap=2 menyisakan int(2*0.8)==1 entri, jadi cache
|
|
102
|
+
memang agresif untuk cap sangat kecil. Yang diuji adalah bahwa
|
|
103
|
+
eviction benar-benar jalan dan menyisakan sesuatu, bukan menghapus
|
|
104
|
+
semuanya.
|
|
105
|
+
"""
|
|
106
|
+
monkeypatch.setenv("WEBGET_CACHE_MAX", "5")
|
|
107
|
+
_isi(cache_dir, 12)
|
|
108
|
+
cache.cache_put(
|
|
109
|
+
url="https://x.test/picu", cookies=None, headers=None,
|
|
110
|
+
max_chars=1000, data={"markdown": "x", "status": "success"},
|
|
111
|
+
)
|
|
112
|
+
sisa = len([f for f in os.listdir(cache_dir) if f.endswith(".json")])
|
|
113
|
+
assert 0 < sisa <= 12, f"eviction menyisakan {sisa} entri"
|
|
114
|
+
# int(5*0.8) == 4, jadi harusnya menyisakan sekitar 4.
|
|
115
|
+
assert sisa <= 6, f"cap=5 seharusnya memangkas ke sekitar 4, dapat {sisa}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from webget import cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_cli_crawl_dispatches_and_emits_json(monkeypatch, tmp_path):
|
|
12
|
+
seen = {}
|
|
13
|
+
|
|
14
|
+
async def fake_crawl(seed, frontier, **kwargs):
|
|
15
|
+
seen.update(seed=seed, frontier=frontier, kwargs=kwargs)
|
|
16
|
+
return {"stats": {"done": 1, "failed": 0}, "results": []}
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr(cli, "crawl_site", fake_crawl)
|
|
19
|
+
monkeypatch.setattr(
|
|
20
|
+
sys,
|
|
21
|
+
"argv",
|
|
22
|
+
["webget", "crawl", "https://example.com", str(tmp_path / "crawl.db"), "--json", "--limit", "1"],
|
|
23
|
+
)
|
|
24
|
+
output = io.StringIO()
|
|
25
|
+
with redirect_stdout(output):
|
|
26
|
+
cli.main()
|
|
27
|
+
assert json.loads(output.getvalue())["stats"]["done"] == 1
|
|
28
|
+
assert seen["seed"] == "https://example.com"
|
|
29
|
+
assert seen["kwargs"]["max_pages"] == 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cli_crawl_requires_frontier_path(monkeypatch, capsys):
|
|
33
|
+
monkeypatch.setattr(sys, "argv", ["webget", "crawl", "https://example.com"])
|
|
34
|
+
with pytest.raises(SystemExit) as exc:
|
|
35
|
+
cli.main()
|
|
36
|
+
captured = capsys.readouterr()
|
|
37
|
+
assert exc.value.code == 2
|
|
38
|
+
assert "crawl expects URL and frontier SQLite path" in captured.out
|