webget-cli 0.11.0__tar.gz → 0.12.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 (53) hide show
  1. {webget_cli-0.11.0/webget_cli.egg-info → webget_cli-0.12.1}/PKG-INFO +17 -1
  2. {webget_cli-0.11.0 → webget_cli-0.12.1}/README.md +15 -0
  3. {webget_cli-0.11.0 → webget_cli-0.12.1}/pyproject.toml +2 -1
  4. webget_cli-0.12.1/tests/test_base64_strip.py +55 -0
  5. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_leak_review.py +1 -1
  6. webget_cli-0.12.1/tests/test_mcp_metadata.py +35 -0
  7. webget_cli-0.12.1/tests/test_metadata.py +103 -0
  8. webget_cli-0.12.1/tests/test_nonhtml.py +110 -0
  9. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/__init__.py +10 -1
  10. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/cli.py +1 -0
  11. webget_cli-0.12.1/webget/http.py +368 -0
  12. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/ladder.py +15 -1
  13. {webget_cli-0.11.0 → webget_cli-0.12.1/webget_cli.egg-info}/PKG-INFO +17 -1
  14. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/SOURCES.txt +4 -0
  15. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/requires.txt +1 -0
  16. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_mcp.py +1 -0
  17. webget_cli-0.11.0/webget/http.py +0 -174
  18. {webget_cli-0.11.0 → webget_cli-0.12.1}/LICENSE +0 -0
  19. {webget_cli-0.11.0 → webget_cli-0.12.1}/setup.cfg +0 -0
  20. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_auth.py +0 -0
  21. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_cache.py +0 -0
  22. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_concurrency.py +0 -0
  23. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_http.py +0 -0
  24. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_mcp.py +0 -0
  25. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_ssrf.py +0 -0
  26. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_auth_review.py +0 -0
  27. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_browser_ssrf.py +0 -0
  28. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_cache_review.py +0 -0
  29. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_concurrency_review.py +0 -0
  30. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_discovery_map.py +0 -0
  31. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_extraction_markdown.py +0 -0
  32. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_firecrawl_policy.py +0 -0
  33. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_integration_ladder.py +0 -0
  34. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_ladder_retry.py +0 -0
  35. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_login_flow.py +0 -0
  36. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_map.py +0 -0
  37. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_profile.py +0 -0
  38. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_server.py +0 -0
  39. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_smoke.py +0 -0
  40. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_security_review.py +0 -0
  41. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_size_review.py +0 -0
  42. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_ssrf_dual_dns.py +0 -0
  43. {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_webget.py +0 -0
  44. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/cache.py +0 -0
  45. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/discovery.py +0 -0
  46. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/firecrawl.py +0 -0
  47. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/profile.py +0 -0
  48. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/search.py +0 -0
  49. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/ssrf.py +0 -0
  50. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/dependency_links.txt +0 -0
  51. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/entry_points.txt +0 -0
  52. {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/top_level.txt +0 -0
  53. {webget_cli-0.11.0 → webget_cli-0.12.1}/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.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
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.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"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "httpx",
27
27
  "trafilatura",
28
28
  "markdownify",
29
+ "pypdf",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -0,0 +1,55 @@
1
+ """Ide #3: oversized base64 image payloads are stripped from extracted markdown.
2
+
3
+ Bug scope (probed 2026-09-16, see .hermes/plans/2026-09-16_webget-ide03-base64-strip.md):
4
+ the markdownify fallback keeps inline `data:image/...;base64,...` URLs verbatim,
5
+ so a single hero image can carry hundreds of KB into the output. The trafilatura
6
+ path drops images entirely, so only the fallback needs the strip.
7
+ """
8
+
9
+ import webget_cli as webget
10
+
11
+ BIG_PAYLOAD = "iVBORw0KGgo" + "A" * 800 # > _BASE64_PAYLOAD_MIN
12
+ SMALL_PAYLOAD = "B" * 120 # <= threshold, must survive
13
+
14
+
15
+ def _page(payload):
16
+ return (
17
+ "<html><head><title>T</title></head><body><article><h1>T</h1>"
18
+ f'<p><img alt="hero shot" src="data:image/png;base64,{payload}"></p>'
19
+ "<p>" + ("Body text filler words here to satisfy the length floor. " * 6) + "</p>"
20
+ "</article></body></html>"
21
+ )
22
+
23
+
24
+ def _fallback_only(monkeypatch):
25
+ """Force the markdownify path (trafilatura returns None)."""
26
+ import trafilatura
27
+
28
+ monkeypatch.setattr(trafilatura, "extract", lambda *a, **k: None)
29
+
30
+
31
+ class TestBase64Strip:
32
+ def test_big_payload_stripped_alt_and_mime_survive(self, monkeypatch):
33
+ _fallback_only(monkeypatch)
34
+ text, _ = webget._extract_with_metadata(_page(BIG_PAYLOAD))
35
+ assert BIG_PAYLOAD not in text
36
+ assert "data:image/png;base64,stripped" in text
37
+ assert "hero shot" in text
38
+
39
+ def test_small_payload_preserved(self, monkeypatch):
40
+ _fallback_only(monkeypatch)
41
+ text, _ = webget._extract_with_metadata(_page(SMALL_PAYLOAD))
42
+ assert SMALL_PAYLOAD in text
43
+
44
+ def test_threshold_boundary(self, monkeypatch):
45
+ _fallback_only(monkeypatch)
46
+ at_limit = "C" * 200
47
+ just_over = "D" * 201
48
+ assert at_limit in webget._extract_with_metadata(_page(at_limit))[0]
49
+ assert just_over not in webget._extract_with_metadata(_page(just_over))[0]
50
+
51
+ def test_extract_markdown_contract_unchanged(self, monkeypatch):
52
+ _fallback_only(monkeypatch)
53
+ out = webget._extract_markdown(_page(BIG_PAYLOAD))
54
+ assert isinstance(out, str)
55
+ assert BIG_PAYLOAD not in out
@@ -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,35 @@
1
+ """Ide #1: metadata passthrough in the MCP tool layer.
2
+
3
+ Moved out of tests/test_metadata.py: these import webget_mcp, which needs
4
+ fastmcp/mcp, so they run in the mcp-test CI job (unit job has no fastmcp).
5
+ """
6
+
7
+
8
+ class TestMcpMetadataExposure:
9
+ def test_mcp_fetch_passthrough_has_metadata(self, fresh_cache):
10
+ import asyncio
11
+
12
+ import webget_mcp
13
+
14
+ res = asyncio.run(
15
+ webget_mcp.fetch(fresh_cache.url("/long"), strategy="http", no_cache=True)
16
+ )
17
+ assert set(res["metadata"]) == {"author", "published_at", "site_name", "language"}
18
+
19
+ def test_mcp_search_fetch_rebuild_has_metadata(self, fresh_cache, monkeypatch):
20
+ import asyncio
21
+
22
+ import webget_mcp
23
+
24
+ monkeypatch.setattr(
25
+ webget_mcp.wg,
26
+ "search",
27
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
28
+ )
29
+ out = asyncio.run(webget_mcp.search_fetch("q", n=1, no_cache=True))
30
+ assert set(out[0]["metadata"]) == {
31
+ "author",
32
+ "published_at",
33
+ "site_name",
34
+ "language",
35
+ }
@@ -0,0 +1,103 @@
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
+ # NOTE: MCP-layer metadata passthrough tests live in tests/test_mcp_metadata.py
80
+ # (they import webget_mcp, which needs fastmcp; that file runs in the mcp-test CI job).
81
+ class TestOutputExposure:
82
+ def test_cli_su_json_rebuild_has_metadata(self, fresh_cache, monkeypatch, capsys):
83
+ import webget_cli as wgcli
84
+ from webget import cli as cli_mod
85
+
86
+ monkeypatch.setattr(
87
+ cli_mod,
88
+ "search",
89
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
90
+ )
91
+ monkeypatch.setattr("sys.argv", ["webget", "su", "q", "1", "--json", "--no-cache"])
92
+ wgcli.main()
93
+ import json
94
+
95
+ printed = capsys.readouterr().out
96
+ data = json.loads(printed)
97
+ url = fresh_cache.url("/long")
98
+ assert set(data[url]["metadata"]) == {
99
+ "author",
100
+ "published_at",
101
+ "site_name",
102
+ "language",
103
+ }
@@ -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),
@@ -0,0 +1,368 @@
1
+ """HTTP fast-path fetch + markdown extraction for webget.
2
+
3
+ fetch_http() is the cheapest strategy: a plain HTTP GET, manual redirect
4
+ following so the SSRF guard runs on every hop, and a streaming body cap
5
+ so a giant/binary download cannot exhaust memory. Extraction tries
6
+ trafilatura first (clean article text), then markdownify as a fallback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import re
13
+ import time
14
+ import warnings
15
+ from urllib.parse import urlparse
16
+
17
+ import httpx
18
+
19
+ from .ssrf import SSRFError, _private_ip_for
20
+
21
+ # Max response body webget will read from the HTTP fast path (bytes).
22
+ # Guards against memory exhaustion from giant/binary downloads.
23
+ MAX_RESPONSE_BYTES = 25 * 1024 * 1024
24
+
25
+
26
+ class ResponseTooLarge(Exception):
27
+ """HTTP response exceeded MAX_RESPONSE_BYTES.
28
+
29
+ Raised by the streaming cap in fetch_http. This is a TERMINAL state
30
+ for the ladder: retrying a 30MB page in a browser (crawl4ai) would
31
+ just re-download the same giant body through Chromium, so escalating
32
+ is pure waste. scrape_many treats it as terminal, not a ladder step.
33
+ """
34
+
35
+
36
+ _EMPTY_META = {"author": None, "published_at": None, "site_name": None, "language": None}
37
+
38
+ # Inline data:image payloads longer than this (base64 chars) are replaced
39
+ # in extracted markdown; shorter ones are left alone since they cost little
40
+ # and may carry meaningful thumbnails.
41
+ _BASE64_PAYLOAD_MIN = 200
42
+ _DATA_URL_IMAGE_RE = re.compile(r"(data:image/[a-z0-9.+-]+;base64,)([A-Za-z0-9+/=]+)")
43
+
44
+
45
+ def _strip_base64_images(text):
46
+ """Replace oversized inline base64 image payloads with 'stripped'.
47
+
48
+ Pages routinely inline images as data URLs; a single hero image can
49
+ carry hundreds of KB of base64, which the markdownify fallback would
50
+ otherwise pass through verbatim as token-burning noise. Keeps the
51
+ mime prefix and the markdown around the URL (alt text lives outside
52
+ the URL), drops only the payload.
53
+ """
54
+
55
+ def _repl(m):
56
+ if len(m.group(2)) <= _BASE64_PAYLOAD_MIN:
57
+ return m.group(0)
58
+ return m.group(1) + "stripped"
59
+
60
+ return _DATA_URL_IMAGE_RE.sub(_repl, text)
61
+
62
+
63
+ def _extract_with_metadata(html):
64
+ """Extract (text, metadata) from HTML.
65
+
66
+ Tries trafilatura JSON output (clean article text + author/date/site/
67
+ language metadata) first, then markdownify as a text-only fallback
68
+ (empty metadata). Returns a (str, dict) tuple; metadata keys are
69
+ always author/published_at/site_name/language, values None when
70
+ unknown.
71
+ """
72
+ try:
73
+ import json
74
+
75
+ import trafilatura
76
+
77
+ raw = trafilatura.extract(
78
+ html,
79
+ output_format="json",
80
+ with_metadata=True,
81
+ include_comments=False,
82
+ include_tables=True,
83
+ )
84
+ if raw:
85
+ doc = json.loads(raw)
86
+ text = (doc.get("text") or "").strip()
87
+ if text and len(text) > 100:
88
+ return text, {
89
+ "author": doc.get("author"),
90
+ "published_at": doc.get("date"),
91
+ "site_name": doc.get("sitename"),
92
+ "language": doc.get("language") or doc.get("lang"),
93
+ }
94
+ except Exception: # noqa: BLE001, S110 - extraction libs vary; fall through
95
+ pass
96
+ try:
97
+ from markdownify import markdownify as md
98
+
99
+ # Feeds/sitemaps served as text/html make BeautifulSoup (via
100
+ # markdownify) warn per document; the HTML parser still produces
101
+ # usable markdown, so the warning is noise for a CLI.
102
+ with warnings.catch_warnings():
103
+ try:
104
+ from bs4 import XMLParsedAsHTMLWarning
105
+
106
+ warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
107
+ except ImportError:
108
+ pass
109
+ # bullets="*" and heading_style="ATX" match the previous html2text
110
+ # output style (verified differential 2026-08-08) so the fallback
111
+ # stays close to 0.7.2 (semantic parity).
112
+ converted = md(html, bullets="*", heading_style="ATX").strip()
113
+ converted = _strip_base64_images(converted)
114
+ if len(converted) > 50:
115
+ return converted, dict(_EMPTY_META)
116
+ return "", dict(_EMPTY_META)
117
+ except Exception: # noqa: BLE001 - best-effort extraction, empty is fine
118
+ return "", dict(_EMPTY_META)
119
+
120
+
121
+ def _extract_markdown(html):
122
+ """Try trafilatura (clean article text) then markdownify (full markdown)."""
123
+ text, _ = _extract_with_metadata(html)
124
+ return text
125
+
126
+
127
+ def _convert_non_html(ctype, body, url):
128
+ """Convert a non-HTML response body to (title, markdown, metadata).
129
+
130
+ Routes by content-type: JSON -> pretty code block, text/* -> plain
131
+ text, CSV -> GFM table, RSS/Atom/XML feeds -> link list, PDF ->
132
+ per-page text via pypdf. Raises RuntimeError for unknown types.
133
+ """
134
+ from urllib.parse import urlparse
135
+
136
+ host = urlparse(url).hostname or ""
137
+ meta = {"author": None, "published_at": None, "site_name": host or None, "language": None}
138
+ low = (ctype or "").lower()
139
+ if "json" in low:
140
+ try:
141
+ import json as _json
142
+
143
+ pretty = _json.dumps(
144
+ _json.loads(body.decode("utf-8", errors="replace")),
145
+ indent=2,
146
+ ensure_ascii=False,
147
+ )
148
+ except Exception: # noqa: BLE001 - malformed JSON falls back to raw text
149
+ pretty = body.decode("utf-8", errors="replace")
150
+ return url, f"```json\n{pretty}\n```", meta
151
+ if low.startswith("text/"):
152
+ if "csv" in low:
153
+ return url, _csv_to_gfm(body), meta
154
+ return url, body.decode("utf-8", errors="replace").strip(), meta
155
+ if "xml" in low or "rss" in low or "atom" in low or "feed" in low:
156
+ return url, _feed_to_links(body), meta
157
+ if "pdf" in low:
158
+ return url, _pdf_to_text(body), meta
159
+ raise RuntimeError(f"not HTML ({ctype or 'unknown'})")
160
+
161
+
162
+ def _pdf_to_text(body):
163
+ """Extract per-page text from PDF bytes via pypdf."""
164
+ try:
165
+ import io as _io
166
+
167
+ from pypdf import PdfReader
168
+ except ImportError:
169
+ raise RuntimeError("PDF content requires 'pypdf' (pip install webget-cli)") from None
170
+ reader = PdfReader(_io.BytesIO(body))
171
+ parts = []
172
+ for i, page in enumerate(reader.pages):
173
+ try:
174
+ t = page.extract_text() or ""
175
+ except Exception: # noqa: BLE001 - one bad page must not kill the doc
176
+ t = ""
177
+ if t.strip():
178
+ parts.append(f"## Page {i + 1}\n\n{t.strip()}")
179
+ text = "\n\n".join(parts)
180
+ if not text.strip():
181
+ raise RuntimeError("PDF has no extractable text")
182
+ return text
183
+
184
+
185
+ def _feed_to_links(body):
186
+ """Convert RSS/Atom XML bytes to a markdown link list."""
187
+ import xml.etree.ElementTree as _ET
188
+
189
+ try:
190
+ root = _ET.fromstring(body)
191
+ except Exception: # noqa: BLE001 - malformed XML falls back to raw text
192
+ return body.decode("utf-8", errors="replace").strip()
193
+ lines = []
194
+ for item in root.iter("item"): # RSS
195
+ title = (item.findtext("title") or "").strip()
196
+ link = (item.findtext("link") or "").strip()
197
+ desc = (item.findtext("description") or "").strip()
198
+ if title and link:
199
+ lines.append(f"- [{title}]({link})")
200
+ elif title:
201
+ lines.append(f"- {title}")
202
+ if desc:
203
+ lines.append(f" > {desc[:200]}")
204
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
205
+ for entry in root.findall(".//atom:entry", ns): # Atom
206
+ t = entry.find("atom:title", ns)
207
+ title = (t.text or "").strip() if t is not None else ""
208
+ link = ""
209
+ for l in entry.findall("atom:link", ns):
210
+ href = (l.get("href") or "").strip()
211
+ if href and (l.get("rel", "alternate") == "alternate" or not l.get("rel")):
212
+ link = href
213
+ break
214
+ s = entry.find("atom:summary", ns)
215
+ desc = (s.text or "").strip() if s is not None else ""
216
+ if title and link:
217
+ lines.append(f"- [{title}]({link})")
218
+ elif title:
219
+ lines.append(f"- {title}")
220
+ if desc:
221
+ lines.append(f" > {desc[:200]}")
222
+ if lines:
223
+ return "\n".join(lines)
224
+ return body.decode("utf-8", errors="replace").strip()
225
+
226
+
227
+ def _csv_to_gfm(body):
228
+ """Convert CSV bytes to a GitHub-flavored markdown table."""
229
+ import csv as _csv
230
+ import io as _io
231
+
232
+ try:
233
+ rows = list(_csv.reader(_io.StringIO(body.decode("utf-8", errors="replace"))))
234
+ except Exception: # noqa: BLE001 - malformed CSV falls back to raw text
235
+ return body.decode("utf-8", errors="replace").strip()
236
+ rows = [r for r in rows if r]
237
+ if not rows:
238
+ return ""
239
+ esc = lambda c: (c or "").replace("|", "\\|")
240
+ header = "| " + " | ".join(esc(c) for c in rows[0]) + " |"
241
+ sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
242
+ lines = [header, sep]
243
+ for r in rows[1:]:
244
+ lines.append("| " + " | ".join(esc(c) for c in r) + " |")
245
+ return "\n".join(lines)
246
+
247
+
248
+ async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
249
+ """Fast path: plain HTTP GET + local markdown extraction.
250
+
251
+ SSRF guard: the initial URL is checked; redirect hops are followed
252
+ MANUALLY (follow_redirects=False) so every hop is checked against the
253
+ private-address policy before being requested. Response body is read
254
+ with a hard cap (MAX_RESPONSE_BYTES).
255
+
256
+ The guard runs the (blocking) resolver in a worker thread bounded by
257
+ the request timeout: a sick DNS server must not stall the event loop
258
+ and stretch every httpx timer in a concurrent batch.
259
+ """
260
+ # One absolute wall-clock budget for the whole request: DNS guard,
261
+ # connect, redirects, and body streaming all draw from it.
262
+ deadline = time.monotonic() + timeout
263
+
264
+ async def ssrf_guard(target):
265
+ remaining = deadline - time.monotonic()
266
+ try:
267
+ # Resolve through the shim so test patches (e.g.
268
+ # `webget._private_ip_for = guarded`) reach this fast path's
269
+ # SSRF check; the package module's is the default.
270
+ try:
271
+ import webget_cli as _shim
272
+
273
+ _fn = getattr(_shim, "_private_ip_for", None)
274
+ except ImportError:
275
+ _fn = None
276
+ resolver = _fn if (_fn is not None and _fn is not _private_ip_for) else _private_ip_for
277
+ ip = await asyncio.wait_for(asyncio.to_thread(resolver, target), max(remaining, 0.1))
278
+ except TimeoutError:
279
+ raise TimeoutError(f"DNS resolution exceeded {timeout}s") from None
280
+ if ip is not None:
281
+ raise SSRFError(f"blocked by SSRF guard: {target} resolves to private address {ip}")
282
+
283
+ await ssrf_guard(url)
284
+
285
+ hdrs = {
286
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
287
+ "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
288
+ }
289
+ if headers:
290
+ hdrs.update(headers)
291
+ cj = {}
292
+ if cookies:
293
+ host = (urlparse(url).hostname or "").lower()
294
+ now = time.time()
295
+ if host:
296
+ for c in cookies:
297
+ d = (c.get("domain") or "").lstrip(".").lower()
298
+ if d and (host == d or host.endswith("." + d)):
299
+ exp = c.get("expires") or -1
300
+ # Skip expired cookies: session cookies (expires<0) and
301
+ # future-expiry cookies are sent; past-expiry are not.
302
+ if 0 <= exp < now:
303
+ continue
304
+ cj[c["name"]] = c["value"]
305
+
306
+ current = url
307
+ redirects = 0
308
+ async with httpx.AsyncClient(
309
+ follow_redirects=False, timeout=timeout, headers=hdrs, cookies=cj
310
+ ) as client:
311
+ while True:
312
+ await ssrf_guard(current)
313
+ # stream=True is REQUIRED: client.get() would buffer the whole
314
+ # body into memory before our cap could stop it.
315
+ async with client.stream("GET", current) as r:
316
+ if r.status_code in (301, 302, 303, 307, 308):
317
+ loc = r.headers.get("location")
318
+ if not loc:
319
+ break
320
+ redirects += 1
321
+ if redirects > 20:
322
+ raise RuntimeError("too many redirects")
323
+ current = str(httpx.URL(current).join(loc))
324
+ continue
325
+ ctype = r.headers.get("content-type", "")
326
+ # Read with a hard cap while streaming, so a giant/binary
327
+ # body cannot exhaust memory. httpx's timeout bounds a
328
+ # single socket operation only, so a server that slow-drips
329
+ # the body in small chunks over minutes can keep it alive
330
+ # far past the deadline; enforce an absolute wall-clock cap
331
+ # here so one slow URL cannot stall the whole batch.
332
+ chunks = []
333
+ total = 0
334
+ async for chunk in r.aiter_bytes():
335
+ if time.monotonic() > deadline:
336
+ raise TimeoutError(f"streaming body exceeded {timeout}s deadline")
337
+ total += len(chunk)
338
+ if total > MAX_RESPONSE_BYTES:
339
+ raise ResponseTooLarge(f"response too large (> {MAX_RESPONSE_BYTES} bytes)")
340
+ chunks.append(chunk)
341
+ raw_body = b"".join(chunks)
342
+ low_ctype = ctype.lower()
343
+ if "html" in low_ctype or (
344
+ "text" in low_ctype and "csv" not in low_ctype and "xml" not in low_ctype
345
+ ):
346
+ html = raw_body.decode("utf-8", errors="replace")
347
+ else:
348
+ title, md, meta = _convert_non_html(ctype, raw_body, current)
349
+ return {
350
+ "title": title,
351
+ "markdown": md[:max_chars],
352
+ "metadata": meta,
353
+ "non_html": True,
354
+ "status_code": r.status_code,
355
+ "html": "",
356
+ }
357
+ title = ""
358
+ m = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
359
+ if m:
360
+ title = re.sub(r"\s+", " ", m.group(1)).strip()
361
+ md, meta = await asyncio.to_thread(_extract_with_metadata, html)
362
+ return {
363
+ "title": title,
364
+ "markdown": md[:max_chars],
365
+ "metadata": meta,
366
+ "status_code": r.status_code,
367
+ "html": html[:8000],
368
+ }
@@ -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.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
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 |
@@ -10,6 +10,7 @@ tests/test_adversarial_http.py
10
10
  tests/test_adversarial_mcp.py
11
11
  tests/test_adversarial_ssrf.py
12
12
  tests/test_auth_review.py
13
+ tests/test_base64_strip.py
13
14
  tests/test_browser_ssrf.py
14
15
  tests/test_cache_review.py
15
16
  tests/test_concurrency_review.py
@@ -21,9 +22,12 @@ tests/test_ladder_retry.py
21
22
  tests/test_login_flow.py
22
23
  tests/test_mcp_leak_review.py
23
24
  tests/test_mcp_map.py
25
+ tests/test_mcp_metadata.py
24
26
  tests/test_mcp_profile.py
25
27
  tests/test_mcp_server.py
26
28
  tests/test_mcp_smoke.py
29
+ tests/test_metadata.py
30
+ tests/test_nonhtml.py
27
31
  tests/test_security_review.py
28
32
  tests/test_size_review.py
29
33
  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),
@@ -1,174 +0,0 @@
1
- """HTTP fast-path fetch + markdown extraction for webget.
2
-
3
- fetch_http() is the cheapest strategy: a plain HTTP GET, manual redirect
4
- following so the SSRF guard runs on every hop, and a streaming body cap
5
- so a giant/binary download cannot exhaust memory. Extraction tries
6
- trafilatura first (clean article text), then markdownify as a fallback.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import asyncio
12
- import re
13
- import time
14
- import warnings
15
- from urllib.parse import urlparse
16
-
17
- import httpx
18
-
19
- from .ssrf import SSRFError, _private_ip_for
20
-
21
- # Max response body webget will read from the HTTP fast path (bytes).
22
- # Guards against memory exhaustion from giant/binary downloads.
23
- MAX_RESPONSE_BYTES = 25 * 1024 * 1024
24
-
25
-
26
- class ResponseTooLarge(Exception):
27
- """HTTP response exceeded MAX_RESPONSE_BYTES.
28
-
29
- Raised by the streaming cap in fetch_http. This is a TERMINAL state
30
- for the ladder: retrying a 30MB page in a browser (crawl4ai) would
31
- just re-download the same giant body through Chromium, so escalating
32
- is pure waste. scrape_many treats it as terminal, not a ladder step.
33
- """
34
-
35
-
36
- def _extract_markdown(html):
37
- """Try trafilatura (clean article text) then markdownify (full markdown)."""
38
- try:
39
- import trafilatura
40
-
41
- text = trafilatura.extract(html, include_comments=False, include_tables=True)
42
- if text and len(text.strip()) > 100:
43
- return text.strip()
44
- except Exception: # noqa: BLE001, S110 - extraction libs vary; fall through
45
- pass
46
- try:
47
- from markdownify import markdownify as md
48
-
49
- # Feeds/sitemaps served as text/html make BeautifulSoup (via
50
- # markdownify) warn per document; the HTML parser still produces
51
- # usable markdown, so the warning is noise for a CLI.
52
- with warnings.catch_warnings():
53
- try:
54
- from bs4 import XMLParsedAsHTMLWarning
55
-
56
- warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
57
- except ImportError:
58
- pass
59
- # bullets="*" and heading_style="ATX" match the previous html2text
60
- # output style (verified differential 2026-08-08) so the fallback
61
- # stays close to 0.7.2 (semantic parity).
62
- converted = md(html, bullets="*", heading_style="ATX").strip()
63
- return converted if len(converted) > 50 else ""
64
- except Exception: # noqa: BLE001 - best-effort extraction, empty is fine
65
- return ""
66
-
67
-
68
- async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
69
- """Fast path: plain HTTP GET + local markdown extraction.
70
-
71
- SSRF guard: the initial URL is checked; redirect hops are followed
72
- MANUALLY (follow_redirects=False) so every hop is checked against the
73
- private-address policy before being requested. Response body is read
74
- with a hard cap (MAX_RESPONSE_BYTES).
75
-
76
- The guard runs the (blocking) resolver in a worker thread bounded by
77
- the request timeout: a sick DNS server must not stall the event loop
78
- and stretch every httpx timer in a concurrent batch.
79
- """
80
- # One absolute wall-clock budget for the whole request: DNS guard,
81
- # connect, redirects, and body streaming all draw from it.
82
- deadline = time.monotonic() + timeout
83
-
84
- async def ssrf_guard(target):
85
- remaining = deadline - time.monotonic()
86
- try:
87
- # Resolve through the shim so test patches (e.g.
88
- # `webget._private_ip_for = guarded`) reach this fast path's
89
- # SSRF check; the package module's is the default.
90
- try:
91
- import webget_cli as _shim
92
-
93
- _fn = getattr(_shim, "_private_ip_for", None)
94
- except ImportError:
95
- _fn = None
96
- resolver = _fn if (_fn is not None and _fn is not _private_ip_for) else _private_ip_for
97
- ip = await asyncio.wait_for(asyncio.to_thread(resolver, target), max(remaining, 0.1))
98
- except TimeoutError:
99
- raise TimeoutError(f"DNS resolution exceeded {timeout}s") from None
100
- if ip is not None:
101
- raise SSRFError(f"blocked by SSRF guard: {target} resolves to private address {ip}")
102
-
103
- await ssrf_guard(url)
104
-
105
- hdrs = {
106
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
107
- "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
108
- }
109
- if headers:
110
- hdrs.update(headers)
111
- cj = {}
112
- if cookies:
113
- host = (urlparse(url).hostname or "").lower()
114
- now = time.time()
115
- if host:
116
- for c in cookies:
117
- d = (c.get("domain") or "").lstrip(".").lower()
118
- if d and (host == d or host.endswith("." + d)):
119
- exp = c.get("expires") or -1
120
- # Skip expired cookies: session cookies (expires<0) and
121
- # future-expiry cookies are sent; past-expiry are not.
122
- if 0 <= exp < now:
123
- continue
124
- cj[c["name"]] = c["value"]
125
-
126
- current = url
127
- redirects = 0
128
- async with httpx.AsyncClient(
129
- follow_redirects=False, timeout=timeout, headers=hdrs, cookies=cj
130
- ) as client:
131
- while True:
132
- await ssrf_guard(current)
133
- # stream=True is REQUIRED: client.get() would buffer the whole
134
- # body into memory before our cap could stop it.
135
- async with client.stream("GET", current) as r:
136
- if r.status_code in (301, 302, 303, 307, 308):
137
- loc = r.headers.get("location")
138
- if not loc:
139
- break
140
- redirects += 1
141
- if redirects > 20:
142
- raise RuntimeError("too many redirects")
143
- current = str(httpx.URL(current).join(loc))
144
- continue
145
- 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
- # Read with a hard cap while streaming, so a giant/binary
149
- # body cannot exhaust memory. httpx's timeout bounds a
150
- # single socket operation only, so a server that slow-drips
151
- # the body in small chunks over minutes can keep it alive
152
- # far past the deadline; enforce an absolute wall-clock cap
153
- # here so one slow URL cannot stall the whole batch.
154
- chunks = []
155
- total = 0
156
- async for chunk in r.aiter_bytes():
157
- if time.monotonic() > deadline:
158
- raise TimeoutError(f"streaming body exceeded {timeout}s deadline")
159
- total += len(chunk)
160
- if total > MAX_RESPONSE_BYTES:
161
- raise ResponseTooLarge(f"response too large (> {MAX_RESPONSE_BYTES} bytes)")
162
- chunks.append(chunk)
163
- html = b"".join(chunks).decode("utf-8", errors="replace")
164
- title = ""
165
- m = re.search(r"<title[^>]*>(.*?)</title>", html, re.DOTALL | re.IGNORECASE)
166
- if m:
167
- title = re.sub(r"\s+", " ", m.group(1)).strip()
168
- md = await asyncio.to_thread(_extract_markdown, html)
169
- return {
170
- "title": title,
171
- "markdown": md[:max_chars],
172
- "status_code": r.status_code,
173
- "html": html[:8000],
174
- }
File without changes
File without changes
File without changes
File without changes
File without changes