webget-cli 0.15.0__tar.gz → 0.16.1__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 (86) hide show
  1. {webget_cli-0.15.0 → webget_cli-0.16.1}/PKG-INFO +48 -4
  2. {webget_cli-0.15.0 → webget_cli-0.16.1}/README.md +46 -1
  3. {webget_cli-0.15.0 → webget_cli-0.16.1}/pyproject.toml +2 -3
  4. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_auth.py +20 -0
  5. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_http.py +5 -2
  6. webget_cli-0.16.1/tests/test_auth_state.py +187 -0
  7. webget_cli-0.16.1/tests/test_cache_cap.py +115 -0
  8. webget_cli-0.16.1/tests/test_cli_crawl.py +38 -0
  9. webget_cli-0.16.1/tests/test_cli_parse_opts.py +263 -0
  10. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_concurrency_review.py +12 -7
  11. webget_cli-0.16.1/tests/test_crawler.py +62 -0
  12. webget_cli-0.16.1/tests/test_discovery_sitemap_regex.py +133 -0
  13. webget_cli-0.16.1/tests/test_discovery_ssrf.py +145 -0
  14. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_doctor.py +31 -3
  15. webget_cli-0.16.1/tests/test_extraction.py +33 -0
  16. webget_cli-0.16.1/tests/test_feed_markdown.py +108 -0
  17. webget_cli-0.16.1/tests/test_firecrawl_status.py +140 -0
  18. webget_cli-0.16.1/tests/test_frontier.py +48 -0
  19. webget_cli-0.16.1/tests/test_ladder_honesty.py +159 -0
  20. webget_cli-0.16.1/tests/test_mcp_crawl.py +17 -0
  21. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_engine.py +2 -2
  22. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_metadata.py +1 -1
  23. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_provenance.py +3 -3
  24. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_server.py +1 -1
  25. webget_cli-0.16.1/tests/test_profile_logout_storage.py +123 -0
  26. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_search_engine.py +29 -1
  27. webget_cli-0.16.1/tests/test_search_normalize.py +157 -0
  28. webget_cli-0.16.1/tests/test_searxng.py +81 -0
  29. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_webget.py +23 -0
  30. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/__init__.py +21 -1
  31. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/cache.py +12 -1
  32. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/cli.py +217 -32
  33. webget_cli-0.16.1/webget/crawler.py +179 -0
  34. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/discovery.py +47 -7
  35. webget_cli-0.16.1/webget/extraction.py +133 -0
  36. webget_cli-0.16.1/webget/firecrawl.py +121 -0
  37. webget_cli-0.16.1/webget/frontier.py +200 -0
  38. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/health.py +10 -1
  39. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/http.py +47 -7
  40. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/ladder.py +63 -10
  41. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/profile.py +99 -6
  42. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/search.py +107 -16
  43. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/ssrf.py +23 -0
  44. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/PKG-INFO +48 -4
  45. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/SOURCES.txt +19 -0
  46. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_mcp.py +63 -10
  47. webget_cli-0.15.0/webget/firecrawl.py +0 -63
  48. {webget_cli-0.15.0 → webget_cli-0.16.1}/LICENSE +0 -0
  49. {webget_cli-0.15.0 → webget_cli-0.16.1}/setup.cfg +0 -0
  50. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_cache.py +0 -0
  51. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_concurrency.py +0 -0
  52. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_mcp.py +0 -0
  53. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_adversarial_ssrf.py +0 -0
  54. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_auth_review.py +0 -0
  55. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_base64_strip.py +0 -0
  56. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_browser_discovery.py +0 -0
  57. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_browser_ssrf.py +0 -0
  58. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_cache_review.py +0 -0
  59. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_cli_engine.py +0 -0
  60. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_cli_provenance.py +0 -0
  61. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_discovery_map.py +0 -0
  62. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_extraction_markdown.py +0 -0
  63. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_firecrawl_policy.py +0 -0
  64. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_health.py +0 -0
  65. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_integration_ladder.py +0 -0
  66. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_ladder_retry.py +0 -0
  67. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_login_flow.py +0 -0
  68. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_leak_review.py +0 -0
  69. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_map.py +0 -0
  70. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_profile.py +0 -0
  71. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_mcp_smoke.py +0 -0
  72. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_metadata.py +0 -0
  73. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_nonhtml.py +0 -0
  74. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_search_failover.py +0 -0
  75. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_search_provenance.py +0 -0
  76. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_security_review.py +0 -0
  77. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_size_review.py +0 -0
  78. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_ssrf_dual_dns.py +0 -0
  79. {webget_cli-0.15.0 → webget_cli-0.16.1}/tests/test_truncate.py +0 -0
  80. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/browser.py +0 -0
  81. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget/truncate.py +0 -0
  82. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/dependency_links.txt +0 -0
  83. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/entry_points.txt +0 -0
  84. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/requires.txt +0 -0
  85. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.egg-info/top_level.txt +0 -0
  86. {webget_cli-0.15.0 → webget_cli-0.16.1}/webget_cli.py +0 -0
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.15.0
3
+ Version: 0.16.1
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.15.0"
7
+ version = "0.16.1"
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 = { text = "Apache-2.0" }
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",
@@ -128,6 +128,26 @@ class TestSessionReuse:
128
128
  res = asyncio.run(_fetch(server.url("/cookie-gated"), cookies=ck))
129
129
  assert _one(res)["status"] == "success"
130
130
 
131
+ def test_epoch_zero_cookie_is_expired_not_session(self, fresh_cache, isolated_env):
132
+ """expires == 0 is an already-expired cookie (epoch 1970), not a session.
133
+
134
+ The old jar build used ``expires or -1``, which turned an explicit 0
135
+ into -1 and made the cookie look like a non-expiring session cookie, so
136
+ an expired cookie was still sent. Session cookies are represented by a
137
+ missing expires or -1 (see profile.py), never by 0.
138
+ """
139
+ server = fresh_cache
140
+ ck = [_cookie("session", server.host, expires=0)]
141
+ res = asyncio.run(_fetch(server.url("/cookie-gated"), cookies=ck))
142
+ assert _one(res)["status"] == "blocked"
143
+
144
+ def test_absent_expiry_is_a_session_cookie(self, fresh_cache, isolated_env):
145
+ """A cookie with no `expires` key is a session cookie and IS sent."""
146
+ server = fresh_cache
147
+ ck = [{"name": "session", "value": "v", "domain": server.host, "path": "/"}]
148
+ res = asyncio.run(_fetch(server.url("/cookie-gated"), cookies=ck))
149
+ assert _one(res)["status"] == "success"
150
+
131
151
 
132
152
  class TestProfileMeta:
133
153
  def test_corrupt_state_reports_corrupt(self, isolated_env):
@@ -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
- # empty body -> markdown empty -> "content too thin" -> error terminal
145
- assert _one(res)["status"] in ("error", "blocked")
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