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.
- {webget_cli-0.11.0/webget_cli.egg-info → webget_cli-0.12.1}/PKG-INFO +17 -1
- {webget_cli-0.11.0 → webget_cli-0.12.1}/README.md +15 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/pyproject.toml +2 -1
- webget_cli-0.12.1/tests/test_base64_strip.py +55 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_leak_review.py +1 -1
- webget_cli-0.12.1/tests/test_mcp_metadata.py +35 -0
- webget_cli-0.12.1/tests/test_metadata.py +103 -0
- webget_cli-0.12.1/tests/test_nonhtml.py +110 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/__init__.py +10 -1
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/cli.py +1 -0
- webget_cli-0.12.1/webget/http.py +368 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/ladder.py +15 -1
- {webget_cli-0.11.0 → webget_cli-0.12.1/webget_cli.egg-info}/PKG-INFO +17 -1
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/SOURCES.txt +4 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/requires.txt +1 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_mcp.py +1 -0
- webget_cli-0.11.0/webget/http.py +0 -174
- {webget_cli-0.11.0 → webget_cli-0.12.1}/LICENSE +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/setup.cfg +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_auth_review.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_cache_review.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_discovery_map.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_ladder_retry.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_login_flow.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_map.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_server.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_security_review.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_size_review.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_ssrf_dual_dns.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/tests/test_webget.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/cache.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/discovery.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/firecrawl.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/profile.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/search.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget/ssrf.py +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.11.0 → webget_cli-0.12.1}/webget_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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),
|
webget_cli-0.11.0/webget/http.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|