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.
Files changed (86) hide show
  1. {webget_cli-0.14.0 → webget_cli-0.16.0}/PKG-INFO +84 -5
  2. {webget_cli-0.14.0 → webget_cli-0.16.0}/README.md +82 -2
  3. {webget_cli-0.14.0 → webget_cli-0.16.0}/pyproject.toml +2 -3
  4. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_http.py +11 -3
  5. webget_cli-0.16.0/tests/test_auth_state.py +187 -0
  6. webget_cli-0.16.0/tests/test_browser_discovery.py +270 -0
  7. webget_cli-0.16.0/tests/test_cache_cap.py +115 -0
  8. webget_cli-0.16.0/tests/test_cli_crawl.py +38 -0
  9. webget_cli-0.16.0/tests/test_cli_parse_opts.py +263 -0
  10. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_concurrency_review.py +12 -7
  11. webget_cli-0.16.0/tests/test_crawler.py +62 -0
  12. webget_cli-0.16.0/tests/test_discovery_sitemap_regex.py +133 -0
  13. webget_cli-0.16.0/tests/test_discovery_ssrf.py +145 -0
  14. webget_cli-0.16.0/tests/test_doctor.py +147 -0
  15. webget_cli-0.16.0/tests/test_extraction.py +33 -0
  16. webget_cli-0.16.0/tests/test_feed_markdown.py +108 -0
  17. webget_cli-0.16.0/tests/test_firecrawl_status.py +140 -0
  18. webget_cli-0.16.0/tests/test_frontier.py +48 -0
  19. webget_cli-0.16.0/tests/test_ladder_honesty.py +159 -0
  20. webget_cli-0.16.0/tests/test_mcp_crawl.py +17 -0
  21. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_engine.py +2 -2
  22. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_metadata.py +1 -1
  23. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_provenance.py +3 -3
  24. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_server.py +1 -1
  25. webget_cli-0.16.0/tests/test_profile_logout_storage.py +123 -0
  26. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_engine.py +29 -1
  27. webget_cli-0.16.0/tests/test_search_normalize.py +157 -0
  28. webget_cli-0.16.0/tests/test_searxng.py +81 -0
  29. webget_cli-0.16.0/tests/test_truncate.py +88 -0
  30. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/__init__.py +21 -1
  31. webget_cli-0.16.0/webget/browser.py +276 -0
  32. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/cache.py +8 -0
  33. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/cli.py +342 -33
  34. webget_cli-0.16.0/webget/crawler.py +179 -0
  35. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/discovery.py +47 -7
  36. webget_cli-0.16.0/webget/extraction.py +133 -0
  37. webget_cli-0.16.0/webget/firecrawl.py +121 -0
  38. webget_cli-0.16.0/webget/frontier.py +200 -0
  39. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/health.py +10 -1
  40. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/http.py +48 -8
  41. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/ladder.py +140 -56
  42. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/profile.py +99 -6
  43. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/search.py +107 -16
  44. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget/ssrf.py +23 -0
  45. webget_cli-0.16.0/webget/truncate.py +50 -0
  46. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/PKG-INFO +84 -5
  47. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/SOURCES.txt +24 -0
  48. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_mcp.py +63 -10
  49. webget_cli-0.14.0/webget/firecrawl.py +0 -61
  50. {webget_cli-0.14.0 → webget_cli-0.16.0}/LICENSE +0 -0
  51. {webget_cli-0.14.0 → webget_cli-0.16.0}/setup.cfg +0 -0
  52. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_auth.py +0 -0
  53. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_cache.py +0 -0
  54. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_concurrency.py +0 -0
  55. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_mcp.py +0 -0
  56. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_adversarial_ssrf.py +0 -0
  57. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_auth_review.py +0 -0
  58. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_base64_strip.py +0 -0
  59. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_browser_ssrf.py +0 -0
  60. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cache_review.py +0 -0
  61. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cli_engine.py +0 -0
  62. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_cli_provenance.py +0 -0
  63. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_discovery_map.py +0 -0
  64. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_extraction_markdown.py +0 -0
  65. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_firecrawl_policy.py +0 -0
  66. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_health.py +0 -0
  67. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_integration_ladder.py +0 -0
  68. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_ladder_retry.py +0 -0
  69. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_login_flow.py +0 -0
  70. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_leak_review.py +0 -0
  71. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_map.py +0 -0
  72. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_profile.py +0 -0
  73. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_mcp_smoke.py +0 -0
  74. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_metadata.py +0 -0
  75. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_nonhtml.py +0 -0
  76. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_failover.py +0 -0
  77. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_search_provenance.py +0 -0
  78. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_security_review.py +0 -0
  79. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_size_review.py +0 -0
  80. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_ssrf_dual_dns.py +0 -0
  81. {webget_cli-0.14.0 → webget_cli-0.16.0}/tests/test_webget.py +0 -0
  82. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  83. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/entry_points.txt +0 -0
  84. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/requires.txt +0 -0
  85. {webget_cli-0.14.0 → webget_cli-0.16.0}/webget_cli.egg-info/top_level.txt +0 -0
  86. {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.14.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 Chromium. `pip install "webget-cli[browser]"`
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 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.
@@ -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.14.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 = { 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",
@@ -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
@@ -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
- assert len(out["markdown"]) <= 1000
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