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