webget-cli 0.11.0__tar.gz → 0.12.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 (50) hide show
  1. {webget_cli-0.11.0/webget_cli.egg-info → webget_cli-0.12.0}/PKG-INFO +17 -1
  2. {webget_cli-0.11.0 → webget_cli-0.12.0}/README.md +15 -0
  3. {webget_cli-0.11.0 → webget_cli-0.12.0}/pyproject.toml +2 -1
  4. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_mcp_leak_review.py +1 -1
  5. webget_cli-0.12.0/tests/test_metadata.py +129 -0
  6. webget_cli-0.12.0/tests/test_nonhtml.py +110 -0
  7. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/__init__.py +10 -1
  8. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/cli.py +1 -0
  9. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/http.py +179 -10
  10. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/ladder.py +15 -1
  11. {webget_cli-0.11.0 → webget_cli-0.12.0/webget_cli.egg-info}/PKG-INFO +17 -1
  12. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.egg-info/SOURCES.txt +2 -0
  13. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.egg-info/requires.txt +1 -0
  14. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_mcp.py +1 -0
  15. {webget_cli-0.11.0 → webget_cli-0.12.0}/LICENSE +0 -0
  16. {webget_cli-0.11.0 → webget_cli-0.12.0}/setup.cfg +0 -0
  17. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_auth.py +0 -0
  18. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_cache.py +0 -0
  19. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_concurrency.py +0 -0
  20. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_http.py +0 -0
  21. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_mcp.py +0 -0
  22. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_adversarial_ssrf.py +0 -0
  23. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_auth_review.py +0 -0
  24. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_browser_ssrf.py +0 -0
  25. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_cache_review.py +0 -0
  26. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_concurrency_review.py +0 -0
  27. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_discovery_map.py +0 -0
  28. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_extraction_markdown.py +0 -0
  29. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_firecrawl_policy.py +0 -0
  30. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_integration_ladder.py +0 -0
  31. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_ladder_retry.py +0 -0
  32. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_login_flow.py +0 -0
  33. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_mcp_map.py +0 -0
  34. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_mcp_profile.py +0 -0
  35. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_mcp_server.py +0 -0
  36. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_mcp_smoke.py +0 -0
  37. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_security_review.py +0 -0
  38. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_size_review.py +0 -0
  39. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_ssrf_dual_dns.py +0 -0
  40. {webget_cli-0.11.0 → webget_cli-0.12.0}/tests/test_webget.py +0 -0
  41. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/cache.py +0 -0
  42. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/discovery.py +0 -0
  43. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/firecrawl.py +0 -0
  44. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/profile.py +0 -0
  45. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/search.py +0 -0
  46. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget/ssrf.py +0 -0
  47. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  48. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.egg-info/entry_points.txt +0 -0
  49. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.egg-info/top_level.txt +0 -0
  50. {webget_cli-0.11.0 → webget_cli-0.12.0}/webget_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: Apache-2.0
@@ -23,6 +23,7 @@ Requires-Dist: ddgs
23
23
  Requires-Dist: httpx
24
24
  Requires-Dist: trafilatura
25
25
  Requires-Dist: markdownify
26
+ Requires-Dist: pypdf
26
27
  Provides-Extra: browser
27
28
  Requires-Dist: crawl4ai>=0.9; extra == "browser"
28
29
  Provides-Extra: mcp
@@ -188,13 +189,28 @@ path can reuse them. Secrets are never printed.
188
189
  "authenticated": true,
189
190
  "state": "success"
190
191
  },
192
+ "metadata": {
193
+ "author": "Jane Doe",
194
+ "published_at": "2026-09-01",
195
+ "site_name": "Campus Portal",
196
+ "language": "id"
197
+ },
191
198
  "error": null
192
199
  }
193
200
  }
194
201
  ```
195
202
 
203
+ `metadata` (author, published date, site name, language) comes from
204
+ trafilatura extraction on the HTTP path; values are `null` when unknown
205
+ or when the winning strategy was not HTTP.
206
+
196
207
  Status values: `success | login_required | challenge | blocked | error`.
197
208
 
209
+ The HTTP path also handles non-HTML: JSON becomes a pretty code block,
210
+ CSV becomes a GFM table, RSS/Atom feeds become a link list, PDFs are
211
+ extracted per page (via `pypdf`), plain text passes through. Valid
212
+ non-HTML payloads count as success even when short.
213
+
198
214
  ## Status detection rules
199
215
 
200
216
  | Signal | State |
@@ -154,13 +154,28 @@ path can reuse them. Secrets are never printed.
154
154
  "authenticated": true,
155
155
  "state": "success"
156
156
  },
157
+ "metadata": {
158
+ "author": "Jane Doe",
159
+ "published_at": "2026-09-01",
160
+ "site_name": "Campus Portal",
161
+ "language": "id"
162
+ },
157
163
  "error": null
158
164
  }
159
165
  }
160
166
  ```
161
167
 
168
+ `metadata` (author, published date, site name, language) comes from
169
+ trafilatura extraction on the HTTP path; values are `null` when unknown
170
+ or when the winning strategy was not HTTP.
171
+
162
172
  Status values: `success | login_required | challenge | blocked | error`.
163
173
 
174
+ The HTTP path also handles non-HTML: JSON becomes a pretty code block,
175
+ CSV becomes a GFM table, RSS/Atom feeds become a link list, PDFs are
176
+ extracted per page (via `pypdf`), plain text passes through. Valid
177
+ non-HTML payloads count as success even when short.
178
+
164
179
  ## Status detection rules
165
180
 
166
181
  | Signal | State |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webget-cli"
7
- version = "0.11.0"
7
+ version = "0.12.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"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "httpx",
27
27
  "trafilatura",
28
28
  "markdownify",
29
+ "pypdf",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -24,7 +24,7 @@ LEAK_MARKERS = [
24
24
  "/home/",
25
25
  "Traceback",
26
26
  'File "',
27
- "line ",
27
+ '", line ', # traceback frames only (plain "line " false-positives on real content, e.g. "online ", "deadline ")
28
28
  ]
29
29
 
30
30
  # The literal variable NAME may legitimately appear in error messages
@@ -0,0 +1,129 @@
1
+ """Ide #1: metadata from trafilatura exposed in fetch results."""
2
+
3
+ import webget_cli as webget
4
+
5
+
6
+ def _rich_html():
7
+ return (
8
+ "<html><head><title>T</title>"
9
+ '<meta name="author" content="Jane Doe">'
10
+ '<meta property="article:published_time" content="2026-09-01T10:00:00Z">'
11
+ "</head><body><article><h1>T</h1><p>"
12
+ + "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do. " * 10
13
+ + "</p></article></body></html>"
14
+ )
15
+
16
+
17
+ class TestExtractWithMetadata:
18
+ def test_returns_text_and_metadata(self):
19
+ text, meta = webget._extract_with_metadata(_rich_html())
20
+ assert len(text) > 100
21
+ assert meta["author"] == "Jane Doe"
22
+ assert "2026-09-01" in (meta.get("published_at") or "")
23
+
24
+ def test_fallback_returns_empty_metadata(self, monkeypatch):
25
+ import trafilatura
26
+
27
+ monkeypatch.setattr(trafilatura, "extract", lambda *a, **k: None)
28
+ body = "<p>" + "Fallback body content here yes indeed. " * 5 + "</p>"
29
+ text, meta = webget._extract_with_metadata("<h1>H</h1>" + body)
30
+ assert "# H" in text
31
+ assert meta == {"author": None, "published_at": None, "site_name": None, "language": None}
32
+
33
+ def test_extract_markdown_still_returns_str(self, monkeypatch):
34
+ # Regression guard: old contract returns plain str, never tuple.
35
+ import trafilatura
36
+
37
+ monkeypatch.setattr(trafilatura, "extract", lambda *a, **k: None)
38
+ body = "<p>" + "Fallback body content here yes indeed. " * 5 + "</p>"
39
+ out = webget._extract_markdown("<h1>H</h1>" + body)
40
+ assert isinstance(out, str)
41
+ assert "# H" in out
42
+
43
+
44
+ class TestFetchHttpMetadata:
45
+ def test_fetch_http_result_carries_metadata(self, fresh_cache):
46
+ import asyncio
47
+
48
+ res = asyncio.run(webget.fetch_http(fresh_cache.url("/long"), 6000, timeout=10))
49
+ assert set(res["metadata"]) == {"author", "published_at", "site_name", "language"}
50
+
51
+ def test_scrape_many_success_carries_metadata(self, fresh_cache):
52
+ import asyncio
53
+
54
+ out = asyncio.run(webget.scrape_many([fresh_cache.url("/long")], strategy="http"))
55
+ url = fresh_cache.url("/long")
56
+ assert out[url]["status"] == "success"
57
+ assert set(out[url]["metadata"]) == {
58
+ "author",
59
+ "published_at",
60
+ "site_name",
61
+ "language",
62
+ }
63
+
64
+ def test_cached_hit_carries_metadata(self, fresh_cache):
65
+ import asyncio
66
+
67
+ url = fresh_cache.url("/long")
68
+ asyncio.run(webget.scrape_many([url], strategy="http"))
69
+ out = asyncio.run(webget.scrape_many([url], strategy="http"))
70
+ assert out[url]["cached"] is True
71
+ assert set(out[url]["metadata"]) == {
72
+ "author",
73
+ "published_at",
74
+ "site_name",
75
+ "language",
76
+ }
77
+
78
+
79
+ class TestOutputExposure:
80
+ def test_mcp_fetch_passthrough_has_metadata(self, fresh_cache):
81
+ import asyncio
82
+
83
+ import webget_mcp
84
+
85
+ res = asyncio.run(
86
+ webget_mcp.fetch(fresh_cache.url("/long"), strategy="http", no_cache=True)
87
+ )
88
+ assert set(res["metadata"]) == {"author", "published_at", "site_name", "language"}
89
+
90
+ def test_mcp_search_fetch_rebuild_has_metadata(self, fresh_cache, monkeypatch):
91
+ import asyncio
92
+
93
+ import webget_mcp
94
+
95
+ monkeypatch.setattr(
96
+ webget_mcp.wg,
97
+ "search",
98
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
99
+ )
100
+ out = asyncio.run(webget_mcp.search_fetch("q", n=1, no_cache=True))
101
+ assert set(out[0]["metadata"]) == {
102
+ "author",
103
+ "published_at",
104
+ "site_name",
105
+ "language",
106
+ }
107
+
108
+ def test_cli_su_json_rebuild_has_metadata(self, fresh_cache, monkeypatch, capsys):
109
+ import webget_cli as wgcli
110
+ from webget import cli as cli_mod
111
+
112
+ monkeypatch.setattr(
113
+ cli_mod,
114
+ "search",
115
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
116
+ )
117
+ monkeypatch.setattr("sys.argv", ["webget", "su", "q", "1", "--json", "--no-cache"])
118
+ wgcli.main()
119
+ import json
120
+
121
+ printed = capsys.readouterr().out
122
+ data = json.loads(printed)
123
+ url = fresh_cache.url("/long")
124
+ assert set(data[url]["metadata"]) == {
125
+ "author",
126
+ "published_at",
127
+ "site_name",
128
+ "language",
129
+ }
@@ -0,0 +1,110 @@
1
+ """Ide #2: non-HTML content routing (JSON/text/CSV/feed/PDF to markdown)."""
2
+
3
+ import asyncio
4
+
5
+ import webget_cli as webget
6
+
7
+
8
+ class TestJsonRouting:
9
+ def test_fetch_json_returns_pretty_markdown(self, fresh_cache):
10
+ url = fresh_cache.url("/json")
11
+ res = asyncio.run(webget.fetch_http(url, 6000, timeout=10))
12
+ assert "hello" in res["markdown"]
13
+ assert "world" in res["markdown"]
14
+ assert res["metadata"]["site_name"] == "127.0.0.1"
15
+
16
+ def test_scrape_many_json_is_success(self, fresh_cache):
17
+ url = fresh_cache.url("/json")
18
+ out = asyncio.run(webget.scrape_many([url], strategy="http", no_cache=True))
19
+ assert out[url]["status"] == "success"
20
+ assert out[url]["method"] == "http"
21
+ assert "hello" in out[url]["markdown"]
22
+
23
+
24
+ class TestCsvRouting:
25
+ def test_csv_becomes_gfm_table(self):
26
+ body = b"name,age\nbudi,25\nsiti,30\n"
27
+ _title, md, meta = webget._convert_non_html("text/csv", body, "https://example.com/d.csv")
28
+ assert "| name | age |" in md
29
+ assert "| budi | 25 |" in md
30
+ assert meta["site_name"] == "example.com"
31
+
32
+ def test_csv_escapes_pipes(self):
33
+ body = b"a,b\nx|y,z\n"
34
+ _, md, _ = webget._convert_non_html("text/csv", body, "https://example.com/d.csv")
35
+ assert "x\\|y" in md
36
+
37
+
38
+ class TestFeedRouting:
39
+ RSS = (
40
+ b'<?xml version="1.0"?><rss version="2.0"><channel><title>Blog</title>'
41
+ b"<item><title>Post A</title><link>https://ex.com/a</link>"
42
+ b"<description>First post here</description></item>"
43
+ b"<item><title>Post B</title><link>https://ex.com/b</link></item>"
44
+ b"</channel></rss>"
45
+ )
46
+ ATOM = (
47
+ b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">'
48
+ b"<title>Blog</title>"
49
+ b'<entry><title>Entry One</title><link href="https://ex.com/1"/>'
50
+ b"<summary>Summary one</summary></entry>"
51
+ b"</feed>"
52
+ )
53
+
54
+ def test_rss_becomes_link_list(self):
55
+ _title, md, _meta = webget._convert_non_html(
56
+ "application/rss+xml", self.RSS, "https://ex.com/feed"
57
+ )
58
+ assert "[Post A](https://ex.com/a)" in md
59
+ assert "[Post B](https://ex.com/b)" in md
60
+ assert "First post here" in md
61
+
62
+ def test_atom_becomes_link_list(self):
63
+ _, md, _ = webget._convert_non_html(
64
+ "application/atom+xml", self.ATOM, "https://ex.com/feed"
65
+ )
66
+ assert "[Entry One](https://ex.com/1)" in md
67
+
68
+ def test_xml_without_items_falls_back_to_text(self):
69
+ _, md, _ = webget._convert_non_html(
70
+ "application/xml", b"<note><to>u</to></note>", "https://ex.com/n.xml"
71
+ )
72
+ assert "to" in md
73
+
74
+
75
+ class TestPdfRouting:
76
+ def test_pdf_routing_uses_pypdf(self, monkeypatch):
77
+ import sys
78
+ import types
79
+
80
+ fake_page = types.SimpleNamespace(extract_text=lambda: "Hello PDF page one")
81
+ fake_reader = lambda *a, **k: types.SimpleNamespace(pages=[fake_page])
82
+ fake_mod = types.SimpleNamespace(PdfReader=fake_reader)
83
+ monkeypatch.setitem(sys.modules, "pypdf", fake_mod)
84
+ _title, md, meta = webget._convert_non_html(
85
+ "application/pdf", b"%PDF-fake", "https://ex.com/d.pdf"
86
+ )
87
+ assert "Hello PDF page one" in md
88
+ assert meta["site_name"] == "ex.com"
89
+
90
+ def test_pdf_missing_dep_errors_clearly(self, monkeypatch):
91
+ import sys
92
+
93
+ monkeypatch.setitem(sys.modules, "pypdf", None)
94
+ import pytest
95
+
96
+ with pytest.raises(RuntimeError, match="pypdf"):
97
+ webget._convert_non_html("application/pdf", b"%PDF-fake", "https://ex.com/d.pdf")
98
+
99
+ def test_pdf_empty_text_errors(self, monkeypatch):
100
+ import sys
101
+ import types
102
+
103
+ fake_page = types.SimpleNamespace(extract_text=lambda: " ")
104
+ fake_reader = lambda *a, **k: types.SimpleNamespace(pages=[fake_page])
105
+ fake_mod = types.SimpleNamespace(PdfReader=fake_reader)
106
+ monkeypatch.setitem(sys.modules, "pypdf", fake_mod)
107
+ import pytest
108
+
109
+ with pytest.raises(RuntimeError, match="no extractable text"):
110
+ webget._convert_non_html("application/pdf", b"%PDF-fake", "https://ex.com/d.pdf")
@@ -32,7 +32,14 @@ from .cache import (
32
32
  from .cli import main, parse_opts
33
33
  from .discovery import discover_urls
34
34
  from .firecrawl import fetch_firecrawl, firecrawl_key
35
- from .http import MAX_RESPONSE_BYTES, ResponseTooLarge, _extract_markdown, fetch_http
35
+ from .http import (
36
+ MAX_RESPONSE_BYTES,
37
+ ResponseTooLarge,
38
+ _convert_non_html,
39
+ _extract_markdown,
40
+ _extract_with_metadata,
41
+ fetch_http,
42
+ )
36
43
  from .ladder import (
37
44
  _DEFAULT_CONCURRENCY,
38
45
  _STRATEGY_MEMORY_TTL,
@@ -97,11 +104,13 @@ __all__ = [
97
104
  "_auth_message",
98
105
  "_auth_state",
99
106
  "_cache_path",
107
+ "_convert_non_html",
100
108
  "_cookie_belongs_to",
101
109
  "_crawl4ai_once",
102
110
  "_domain_match",
103
111
  "_effective_cookies",
104
112
  "_extract_markdown",
113
+ "_extract_with_metadata",
105
114
  "_fmt_age",
106
115
  "_guard_browser_routes",
107
116
  "_hostname_private",
@@ -377,6 +377,7 @@ def main():
377
377
  "snippet": r.get("snippet", ""),
378
378
  "scrape_title": got.get("title", ""),
379
379
  "markdown": got.get("markdown", ""),
380
+ "metadata": got.get("metadata"),
380
381
  "status": got.get("status", ""),
381
382
  "method": got.get("method", ""),
382
383
  "cached": got.get("cached", False),
@@ -33,14 +33,40 @@ class ResponseTooLarge(Exception):
33
33
  """
34
34
 
35
35
 
36
- def _extract_markdown(html):
37
- """Try trafilatura (clean article text) then markdownify (full markdown)."""
36
+ _EMPTY_META = {"author": None, "published_at": None, "site_name": None, "language": None}
37
+
38
+
39
+ def _extract_with_metadata(html):
40
+ """Extract (text, metadata) from HTML.
41
+
42
+ Tries trafilatura JSON output (clean article text + author/date/site/
43
+ language metadata) first, then markdownify as a text-only fallback
44
+ (empty metadata). Returns a (str, dict) tuple; metadata keys are
45
+ always author/published_at/site_name/language, values None when
46
+ unknown.
47
+ """
38
48
  try:
49
+ import json
50
+
39
51
  import trafilatura
40
52
 
41
- text = trafilatura.extract(html, include_comments=False, include_tables=True)
42
- if text and len(text.strip()) > 100:
43
- return text.strip()
53
+ raw = trafilatura.extract(
54
+ html,
55
+ output_format="json",
56
+ with_metadata=True,
57
+ include_comments=False,
58
+ include_tables=True,
59
+ )
60
+ if raw:
61
+ doc = json.loads(raw)
62
+ text = (doc.get("text") or "").strip()
63
+ if text and len(text) > 100:
64
+ return text, {
65
+ "author": doc.get("author"),
66
+ "published_at": doc.get("date"),
67
+ "site_name": doc.get("sitename"),
68
+ "language": doc.get("language") or doc.get("lang"),
69
+ }
44
70
  except Exception: # noqa: BLE001, S110 - extraction libs vary; fall through
45
71
  pass
46
72
  try:
@@ -60,9 +86,138 @@ def _extract_markdown(html):
60
86
  # output style (verified differential 2026-08-08) so the fallback
61
87
  # stays close to 0.7.2 (semantic parity).
62
88
  converted = md(html, bullets="*", heading_style="ATX").strip()
63
- return converted if len(converted) > 50 else ""
89
+ if len(converted) > 50:
90
+ return converted, dict(_EMPTY_META)
91
+ return "", dict(_EMPTY_META)
64
92
  except Exception: # noqa: BLE001 - best-effort extraction, empty is fine
93
+ return "", dict(_EMPTY_META)
94
+
95
+
96
+ def _extract_markdown(html):
97
+ """Try trafilatura (clean article text) then markdownify (full markdown)."""
98
+ text, _ = _extract_with_metadata(html)
99
+ return text
100
+
101
+
102
+ def _convert_non_html(ctype, body, url):
103
+ """Convert a non-HTML response body to (title, markdown, metadata).
104
+
105
+ Routes by content-type: JSON -> pretty code block, text/* -> plain
106
+ text, CSV -> GFM table, RSS/Atom/XML feeds -> link list, PDF ->
107
+ per-page text via pypdf. Raises RuntimeError for unknown types.
108
+ """
109
+ from urllib.parse import urlparse
110
+
111
+ host = urlparse(url).hostname or ""
112
+ meta = {"author": None, "published_at": None, "site_name": host or None, "language": None}
113
+ low = (ctype or "").lower()
114
+ if "json" in low:
115
+ try:
116
+ import json as _json
117
+
118
+ pretty = _json.dumps(
119
+ _json.loads(body.decode("utf-8", errors="replace")),
120
+ indent=2,
121
+ ensure_ascii=False,
122
+ )
123
+ except Exception: # noqa: BLE001 - malformed JSON falls back to raw text
124
+ pretty = body.decode("utf-8", errors="replace")
125
+ return url, f"```json\n{pretty}\n```", meta
126
+ if low.startswith("text/"):
127
+ if "csv" in low:
128
+ return url, _csv_to_gfm(body), meta
129
+ return url, body.decode("utf-8", errors="replace").strip(), meta
130
+ if "xml" in low or "rss" in low or "atom" in low or "feed" in low:
131
+ return url, _feed_to_links(body), meta
132
+ if "pdf" in low:
133
+ return url, _pdf_to_text(body), meta
134
+ raise RuntimeError(f"not HTML ({ctype or 'unknown'})")
135
+
136
+
137
+ def _pdf_to_text(body):
138
+ """Extract per-page text from PDF bytes via pypdf."""
139
+ try:
140
+ import io as _io
141
+
142
+ from pypdf import PdfReader
143
+ except ImportError:
144
+ raise RuntimeError("PDF content requires 'pypdf' (pip install webget-cli)") from None
145
+ reader = PdfReader(_io.BytesIO(body))
146
+ parts = []
147
+ for i, page in enumerate(reader.pages):
148
+ try:
149
+ t = page.extract_text() or ""
150
+ except Exception: # noqa: BLE001 - one bad page must not kill the doc
151
+ t = ""
152
+ if t.strip():
153
+ parts.append(f"## Page {i + 1}\n\n{t.strip()}")
154
+ text = "\n\n".join(parts)
155
+ if not text.strip():
156
+ raise RuntimeError("PDF has no extractable text")
157
+ return text
158
+
159
+
160
+ def _feed_to_links(body):
161
+ """Convert RSS/Atom XML bytes to a markdown link list."""
162
+ import xml.etree.ElementTree as _ET
163
+
164
+ try:
165
+ root = _ET.fromstring(body)
166
+ except Exception: # noqa: BLE001 - malformed XML falls back to raw text
167
+ return body.decode("utf-8", errors="replace").strip()
168
+ lines = []
169
+ for item in root.iter("item"): # RSS
170
+ title = (item.findtext("title") or "").strip()
171
+ link = (item.findtext("link") or "").strip()
172
+ desc = (item.findtext("description") or "").strip()
173
+ if title and link:
174
+ lines.append(f"- [{title}]({link})")
175
+ elif title:
176
+ lines.append(f"- {title}")
177
+ if desc:
178
+ lines.append(f" > {desc[:200]}")
179
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
180
+ for entry in root.findall(".//atom:entry", ns): # Atom
181
+ t = entry.find("atom:title", ns)
182
+ title = (t.text or "").strip() if t is not None else ""
183
+ link = ""
184
+ for l in entry.findall("atom:link", ns):
185
+ href = (l.get("href") or "").strip()
186
+ if href and (l.get("rel", "alternate") == "alternate" or not l.get("rel")):
187
+ link = href
188
+ break
189
+ s = entry.find("atom:summary", ns)
190
+ desc = (s.text or "").strip() if s is not None else ""
191
+ if title and link:
192
+ lines.append(f"- [{title}]({link})")
193
+ elif title:
194
+ lines.append(f"- {title}")
195
+ if desc:
196
+ lines.append(f" > {desc[:200]}")
197
+ if lines:
198
+ return "\n".join(lines)
199
+ return body.decode("utf-8", errors="replace").strip()
200
+
201
+
202
+ def _csv_to_gfm(body):
203
+ """Convert CSV bytes to a GitHub-flavored markdown table."""
204
+ import csv as _csv
205
+ import io as _io
206
+
207
+ try:
208
+ rows = list(_csv.reader(_io.StringIO(body.decode("utf-8", errors="replace"))))
209
+ except Exception: # noqa: BLE001 - malformed CSV falls back to raw text
210
+ return body.decode("utf-8", errors="replace").strip()
211
+ rows = [r for r in rows if r]
212
+ if not rows:
65
213
  return ""
214
+ esc = lambda c: (c or "").replace("|", "\\|")
215
+ header = "| " + " | ".join(esc(c) for c in rows[0]) + " |"
216
+ sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
217
+ lines = [header, sep]
218
+ for r in rows[1:]:
219
+ lines.append("| " + " | ".join(esc(c) for c in r) + " |")
220
+ return "\n".join(lines)
66
221
 
67
222
 
68
223
  async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
@@ -143,8 +298,6 @@ async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
143
298
  current = str(httpx.URL(current).join(loc))
144
299
  continue
145
300
  ctype = r.headers.get("content-type", "")
146
- if "html" not in ctype and "text" not in ctype:
147
- raise RuntimeError(f"not HTML ({ctype or 'unknown'})")
148
301
  # Read with a hard cap while streaming, so a giant/binary
149
302
  # body cannot exhaust memory. httpx's timeout bounds a
150
303
  # single socket operation only, so a server that slow-drips
@@ -160,15 +313,31 @@ async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
160
313
  if total > MAX_RESPONSE_BYTES:
161
314
  raise ResponseTooLarge(f"response too large (> {MAX_RESPONSE_BYTES} bytes)")
162
315
  chunks.append(chunk)
163
- html = b"".join(chunks).decode("utf-8", errors="replace")
316
+ raw_body = b"".join(chunks)
317
+ low_ctype = ctype.lower()
318
+ if "html" in low_ctype or (
319
+ "text" in low_ctype and "csv" not in low_ctype and "xml" not in low_ctype
320
+ ):
321
+ html = raw_body.decode("utf-8", errors="replace")
322
+ else:
323
+ title, md, meta = _convert_non_html(ctype, raw_body, current)
324
+ return {
325
+ "title": title,
326
+ "markdown": md[:max_chars],
327
+ "metadata": meta,
328
+ "non_html": True,
329
+ "status_code": r.status_code,
330
+ "html": "",
331
+ }
164
332
  title = ""
165
333
  m = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
166
334
  if m:
167
335
  title = re.sub(r"\s+", " ", m.group(1)).strip()
168
- md = await asyncio.to_thread(_extract_markdown, html)
336
+ md, meta = await asyncio.to_thread(_extract_with_metadata, html)
169
337
  return {
170
338
  "title": title,
171
339
  "markdown": md[:max_chars],
340
+ "metadata": meta,
172
341
  "status_code": r.status_code,
173
342
  "html": html[:8000],
174
343
  }
@@ -144,10 +144,16 @@ def _reorder_steps_by_domain(steps, url):
144
144
  return [preferred] + [s for s in steps if s != preferred]
145
145
 
146
146
 
147
+ def _empty_meta():
148
+ """Fresh empty metadata dict (never shared mutable module state)."""
149
+ return {"author": None, "published_at": None, "site_name": None, "language": None}
150
+
151
+
147
152
  def _normalize_hit(hit):
148
153
  return {
149
154
  "title": hit.get("title", ""),
150
155
  "markdown": hit.get("markdown", ""),
156
+ "metadata": hit.get("metadata") or _empty_meta(),
151
157
  "status": "success",
152
158
  "method": "cache",
153
159
  "cached": True,
@@ -370,6 +376,7 @@ async def scrape_many(
370
376
  results[u] = {
371
377
  "title": "",
372
378
  "markdown": "",
379
+ "metadata": _empty_meta(),
373
380
  "status": "error",
374
381
  "method": "",
375
382
  "cached": False,
@@ -406,7 +413,11 @@ async def scrape_many(
406
413
  reasons[url].append(("error", method, detail))
407
414
  return None
408
415
  state, authenticated = _auth_state(res, profile)
409
- if state == "success" and len((res.get("markdown") or "").strip()) >= 100:
416
+ # Thin-check guards HTML extraction (an empty/login page misread as
417
+ # content). Non-HTML conversions (JSON/CSV/feed/PDF) are already
418
+ # validated by their parser, so a short-but-valid payload counts.
419
+ thin_ok = len((res.get("markdown") or "").strip()) >= 100 or res.get("non_html")
420
+ if state == "success" and thin_ok:
410
421
  auth = {"profile": profile, "authenticated": authenticated, "state": state}
411
422
  # Record which strategy won for this domain so future 'auto'
412
423
  # batches can try it first (per-domain strategy memory).
@@ -416,6 +427,7 @@ async def scrape_many(
416
427
  out = {
417
428
  "title": res.get("title", ""),
418
429
  "markdown": res.get("markdown", ""),
430
+ "metadata": res.get("metadata") or _empty_meta(),
419
431
  "status": "success",
420
432
  "method": method,
421
433
  "cached": False,
@@ -460,6 +472,7 @@ async def scrape_many(
460
472
  return url, {
461
473
  "title": "",
462
474
  "markdown": "",
475
+ "metadata": _empty_meta(),
463
476
  "status": "error",
464
477
  "method": "http",
465
478
  "cached": False,
@@ -602,6 +615,7 @@ async def scrape_many(
602
615
  results[url] = {
603
616
  "title": "",
604
617
  "markdown": "",
618
+ "metadata": _empty_meta(),
605
619
  "status": state,
606
620
  "method": method,
607
621
  "cached": False,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: Apache-2.0
@@ -23,6 +23,7 @@ Requires-Dist: ddgs
23
23
  Requires-Dist: httpx
24
24
  Requires-Dist: trafilatura
25
25
  Requires-Dist: markdownify
26
+ Requires-Dist: pypdf
26
27
  Provides-Extra: browser
27
28
  Requires-Dist: crawl4ai>=0.9; extra == "browser"
28
29
  Provides-Extra: mcp
@@ -188,13 +189,28 @@ path can reuse them. Secrets are never printed.
188
189
  "authenticated": true,
189
190
  "state": "success"
190
191
  },
192
+ "metadata": {
193
+ "author": "Jane Doe",
194
+ "published_at": "2026-09-01",
195
+ "site_name": "Campus Portal",
196
+ "language": "id"
197
+ },
191
198
  "error": null
192
199
  }
193
200
  }
194
201
  ```
195
202
 
203
+ `metadata` (author, published date, site name, language) comes from
204
+ trafilatura extraction on the HTTP path; values are `null` when unknown
205
+ or when the winning strategy was not HTTP.
206
+
196
207
  Status values: `success | login_required | challenge | blocked | error`.
197
208
 
209
+ The HTTP path also handles non-HTML: JSON becomes a pretty code block,
210
+ CSV becomes a GFM table, RSS/Atom feeds become a link list, PDFs are
211
+ extracted per page (via `pypdf`), plain text passes through. Valid
212
+ non-HTML payloads count as success even when short.
213
+
198
214
  ## Status detection rules
199
215
 
200
216
  | Signal | State |
@@ -24,6 +24,8 @@ tests/test_mcp_map.py
24
24
  tests/test_mcp_profile.py
25
25
  tests/test_mcp_server.py
26
26
  tests/test_mcp_smoke.py
27
+ tests/test_metadata.py
28
+ tests/test_nonhtml.py
27
29
  tests/test_security_review.py
28
30
  tests/test_size_review.py
29
31
  tests/test_ssrf_dual_dns.py
@@ -2,6 +2,7 @@ ddgs
2
2
  httpx
3
3
  trafilatura
4
4
  markdownify
5
+ pypdf
5
6
 
6
7
  [browser]
7
8
  crawl4ai>=0.9
@@ -230,6 +230,7 @@ async def search_fetch(
230
230
  "snippet": r.get("snippet", ""),
231
231
  "scrape_title": got.get("title", ""),
232
232
  "markdown": got.get("markdown", ""),
233
+ "metadata": got.get("metadata"),
233
234
  "status": got.get("status", ""),
234
235
  "method": got.get("method", ""),
235
236
  "cached": got.get("cached", False),
File without changes
File without changes
File without changes
File without changes
File without changes