webget-cli 0.10.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.10.0/webget_cli.egg-info → webget_cli-0.12.0}/PKG-INFO +17 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/README.md +15 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/pyproject.toml +2 -1
- webget_cli-0.12.0/tests/test_discovery_map.py +29 -0
- webget_cli-0.12.0/tests/test_ladder_retry.py +41 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_login_flow.py +17 -6
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_leak_review.py +1 -1
- webget_cli-0.12.0/tests/test_mcp_map.py +18 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_server.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.12.0/tests/test_ssrf_dual_dns.py +34 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_webget.py +25 -10
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/__init__.py +16 -2
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/cache.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/cli.py +39 -8
- webget_cli-0.12.0/webget/discovery.py +94 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/firecrawl.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/http.py +182 -14
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/ladder.py +54 -3
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/profile.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/search.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/ssrf.py +53 -5
- {webget_cli-0.10.0 → webget_cli-0.12.0/webget_cli.egg-info}/PKG-INFO +17 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/SOURCES.txt +7 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/requires.txt +1 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.py +11 -1
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_mcp.py +23 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/LICENSE +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/setup.cfg +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/top_level.txt +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]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from webget.discovery import _extract_sitemap_urls, discover_urls
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_extract_sitemap_urls_standard_xml():
|
|
7
|
+
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
8
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
9
|
+
<url>
|
|
10
|
+
<loc>https://example.com/page1</loc>
|
|
11
|
+
</url>
|
|
12
|
+
<url>
|
|
13
|
+
<loc>https://example.com/page2</loc>
|
|
14
|
+
</url>
|
|
15
|
+
</urlset>
|
|
16
|
+
"""
|
|
17
|
+
urls = _extract_sitemap_urls(xml)
|
|
18
|
+
assert urls == ["https://example.com/page1", "https://example.com/page2"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_extract_sitemap_urls_malformed_regex_fallback():
|
|
22
|
+
xml = """<urlset><url><loc>https://example.com/broken1</loc></unclosed>"""
|
|
23
|
+
urls = _extract_sitemap_urls(xml)
|
|
24
|
+
assert "https://example.com/broken1" in urls
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_discover_urls_private_target_blocked():
|
|
28
|
+
urls = asyncio.run(discover_urls("http://127.0.0.1/sitemap.xml", allow_private=False))
|
|
29
|
+
assert urls == []
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from webget.ladder import scrape_many
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_scrape_many_retry_transient_timeout(fresh_cache, server):
|
|
8
|
+
calls = []
|
|
9
|
+
|
|
10
|
+
async def mock_fetch(url, *args, **kwargs):
|
|
11
|
+
calls.append(url)
|
|
12
|
+
if len(calls) == 1:
|
|
13
|
+
raise TimeoutError("timeout")
|
|
14
|
+
return {
|
|
15
|
+
"title": "Success After Retry",
|
|
16
|
+
"markdown": "Valid content length " * 10,
|
|
17
|
+
"status": "success",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async def run_without_retry():
|
|
21
|
+
return await scrape_many(
|
|
22
|
+
[server.url("/test")], strategy="http", per_url_timeout=1, retry_transient=False
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def run_with_retry():
|
|
26
|
+
return await scrape_many(
|
|
27
|
+
[server.url("/test")], strategy="http", per_url_timeout=1, retry_transient=True
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
with patch("webget.ladder._resolve_fetch_http", return_value=mock_fetch):
|
|
31
|
+
# Without retry flag -> error on first timeout
|
|
32
|
+
res = asyncio.run(run_without_retry())
|
|
33
|
+
target = server.url("/test")
|
|
34
|
+
assert res[target]["status"] == "error"
|
|
35
|
+
assert res[target]["attempts"] == 1
|
|
36
|
+
|
|
37
|
+
# With retry_transient=True -> retries and succeeds on 2nd attempt
|
|
38
|
+
calls.clear()
|
|
39
|
+
res = asyncio.run(run_with_retry())
|
|
40
|
+
assert res[target]["status"] == "success"
|
|
41
|
+
assert res[target]["attempts"] == 2
|
|
@@ -102,7 +102,9 @@ def _spawn_mcp(profile_root):
|
|
|
102
102
|
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
103
103
|
"WEBGET_ALLOW_PRIVATE": "1",
|
|
104
104
|
}
|
|
105
|
-
return StdioServerParameters(
|
|
105
|
+
return StdioServerParameters(
|
|
106
|
+
command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env
|
|
107
|
+
)
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
@@ -117,14 +119,18 @@ def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
|
117
119
|
gated_url = server.url("/cookie-gated")
|
|
118
120
|
|
|
119
121
|
async def run():
|
|
120
|
-
async with
|
|
122
|
+
async with (
|
|
123
|
+
stdio_client(_spawn_mcp(root)) as (read, write),
|
|
124
|
+
ClientSession(read, write) as session,
|
|
125
|
+
):
|
|
121
126
|
await session.initialize()
|
|
122
127
|
res = await session.call_tool(
|
|
123
128
|
"login",
|
|
124
129
|
{"url": login_url, "profile": "mcplogin", "headless": True, "wait_seconds": 30},
|
|
125
130
|
)
|
|
126
131
|
authed = await session.call_tool(
|
|
127
|
-
"fetch",
|
|
132
|
+
"fetch",
|
|
133
|
+
{"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"},
|
|
128
134
|
)
|
|
129
135
|
anon = await session.call_tool(
|
|
130
136
|
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True}
|
|
@@ -155,16 +161,21 @@ def test_mcp_login_rejects_bad_url_and_profile(server, tmp_path):
|
|
|
155
161
|
root = _profile_root(tmp_path)
|
|
156
162
|
|
|
157
163
|
async def run():
|
|
158
|
-
async with
|
|
164
|
+
async with (
|
|
165
|
+
stdio_client(_spawn_mcp(root)) as (read, write),
|
|
166
|
+
ClientSession(read, write) as session,
|
|
167
|
+
):
|
|
159
168
|
await session.initialize()
|
|
160
169
|
bad_url = await session.call_tool(
|
|
161
170
|
"login", {"url": "not a url", "profile": "x", "headless": True, "wait_seconds": 5}
|
|
162
171
|
)
|
|
163
172
|
bad_name = await session.call_tool(
|
|
164
|
-
"login",
|
|
173
|
+
"login",
|
|
174
|
+
{"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5},
|
|
165
175
|
)
|
|
166
176
|
bad_secs = await session.call_tool(
|
|
167
|
-
"login",
|
|
177
|
+
"login",
|
|
178
|
+
{"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1},
|
|
168
179
|
)
|
|
169
180
|
return bad_url, bad_name, bad_secs
|
|
170
181
|
|
|
@@ -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,18 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
from webget_mcp import map as mcp_map
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_mcp_map_clamps_limit():
|
|
8
|
+
res = asyncio.run(mcp_map("https://example.com", limit=-5))
|
|
9
|
+
assert len(res) == 1
|
|
10
|
+
assert "error: limit must be between" in res[0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_mcp_map_calls_discover_urls():
|
|
14
|
+
with patch("webget_cli.discover_urls", new_callable=AsyncMock) as mock_disc:
|
|
15
|
+
mock_disc.return_value = ["https://example.com/p1", "https://example.com/p2"]
|
|
16
|
+
res = asyncio.run(mcp_map("https://example.com", limit=50))
|
|
17
|
+
assert res == ["https://example.com/p1", "https://example.com/p2"]
|
|
18
|
+
mock_disc.assert_awaited_once_with("https://example.com", limit=50, timeout=15)
|
|
@@ -31,7 +31,7 @@ def test_tools_listed():
|
|
|
31
31
|
|
|
32
32
|
# order is not a contract; membership is
|
|
33
33
|
assert sorted(_run(run())) == sorted(
|
|
34
|
-
["search", "fetch", "search_fetch", "list_profiles", "login"]
|
|
34
|
+
["search", "fetch", "search_fetch", "list_profiles", "login", "map"]
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
@@ -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")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from webget.ssrf import _hostname_private, _resolve_hostname_ips
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_resolve_hostname_ips_primary_success():
|
|
8
|
+
ips = _resolve_hostname_ips("localhost")
|
|
9
|
+
assert any(ip.startswith("127.") or ip == "::1" for ip in ips)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_resolve_hostname_ips_fallback_on_primary_failure():
|
|
13
|
+
orig_getaddrinfo = socket.getaddrinfo
|
|
14
|
+
|
|
15
|
+
def mock_getaddrinfo(host, port, *args, **kwargs):
|
|
16
|
+
if host == "flaky.example":
|
|
17
|
+
raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
|
|
18
|
+
return orig_getaddrinfo(host, port, *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
# Secondary resolver gives fallback IP
|
|
21
|
+
with (
|
|
22
|
+
patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
|
|
23
|
+
patch("webget.ssrf._doh_resolve", return_value=["93.184.216.34"]),
|
|
24
|
+
):
|
|
25
|
+
ips = _resolve_hostname_ips("flaky.example")
|
|
26
|
+
assert "93.184.216.34" in ips
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_hostname_private_uses_fallback_and_detects_private():
|
|
30
|
+
with (
|
|
31
|
+
patch("socket.getaddrinfo", side_effect=socket.gaierror(socket.EAI_NONAME, "Fail")),
|
|
32
|
+
patch("webget.ssrf._doh_resolve", return_value=["192.168.1.1"]),
|
|
33
|
+
):
|
|
34
|
+
assert _hostname_private("router.local") is True
|
|
@@ -22,12 +22,21 @@ def auth_state(md="", html="", status=None, profile=None):
|
|
|
22
22
|
|
|
23
23
|
class TestParseOpts:
|
|
24
24
|
def test_positional(self):
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
(
|
|
26
|
+
remaining,
|
|
27
|
+
*_,
|
|
28
|
+
limit,
|
|
29
|
+
strategy,
|
|
30
|
+
profile,
|
|
31
|
+
no_cache,
|
|
32
|
+
headless,
|
|
33
|
+
concurrency,
|
|
34
|
+
retry_transient,
|
|
35
|
+
) = opts("u", "https://x.com")
|
|
28
36
|
assert remaining == ["u", "https://x.com"]
|
|
29
37
|
assert limit is None and strategy == "auto" and profile is None
|
|
30
38
|
assert no_cache is False and headless is False and concurrency is None
|
|
39
|
+
assert retry_transient is False
|
|
31
40
|
|
|
32
41
|
def test_cookies_short_and_long(self, tmp_path):
|
|
33
42
|
ck = tmp_path / "ck.txt"
|
|
@@ -45,27 +54,31 @@ class TestParseOpts:
|
|
|
45
54
|
assert mc1 == 500 and mc2 == 500
|
|
46
55
|
|
|
47
56
|
def test_limit(self):
|
|
48
|
-
*_, limit, _, _, _, _, _ = opts("s", "q", "--limit", "7")
|
|
57
|
+
*_, limit, _, _, _, _, _, _ = opts("s", "q", "--limit", "7")
|
|
49
58
|
assert limit == 7
|
|
50
59
|
|
|
51
60
|
def test_profile_and_no_cache(self):
|
|
52
|
-
*_, profile, no_cache, _, _ = opts("u", "https://x.com", "--profile", "campus")
|
|
61
|
+
*_, profile, no_cache, _, _, _ = opts("u", "https://x.com", "--profile", "campus")
|
|
53
62
|
assert profile == "campus" and no_cache is False
|
|
54
|
-
*_, profile, no_cache, _, _ = opts("u", "https://x.com", "--no-cache")
|
|
63
|
+
*_, profile, no_cache, _, _, _ = opts("u", "https://x.com", "--no-cache")
|
|
55
64
|
assert profile is None and no_cache is True
|
|
56
65
|
|
|
57
66
|
def test_strategy(self):
|
|
58
|
-
*_, strategy, _, _, _, _ = opts("u", "https://x.com", "--strategy", "crawl4ai")
|
|
67
|
+
*_, strategy, _, _, _, _, _ = opts("u", "https://x.com", "--strategy", "crawl4ai")
|
|
59
68
|
assert strategy == "crawl4ai"
|
|
60
69
|
|
|
61
70
|
def test_concurrency(self):
|
|
62
|
-
*_, concurrency = opts("u", "https://x.com", "--concurrency", "5")
|
|
71
|
+
*_, concurrency, _ = opts("u", "https://x.com", "--concurrency", "5")
|
|
63
72
|
assert concurrency == 5
|
|
64
73
|
|
|
65
74
|
def test_headless(self):
|
|
66
|
-
*_, headless, _ = opts("login", "https://x.com", "--headless")
|
|
75
|
+
*_, headless, _, _ = opts("login", "https://x.com", "--headless")
|
|
67
76
|
assert headless is True
|
|
68
77
|
|
|
78
|
+
def test_retry_flag(self):
|
|
79
|
+
*_, retry_transient = opts("u", "https://x.com", "--retry")
|
|
80
|
+
assert retry_transient is True
|
|
81
|
+
|
|
69
82
|
def test_unknown_flag_passthrough(self):
|
|
70
83
|
remaining, *_ = opts("u", "https://x.com", "--weird")
|
|
71
84
|
assert "--weird" in remaining
|
|
@@ -533,7 +546,9 @@ class TestStrategyMemory:
|
|
|
533
546
|
webget._learn_strategy("old.com", "crawl4ai")
|
|
534
547
|
# Age the entry beyond the TTL by shifting the clock forward.
|
|
535
548
|
real_time = webget.time.time
|
|
536
|
-
monkeypatch.setattr(
|
|
549
|
+
monkeypatch.setattr(
|
|
550
|
+
webget.time, "time", lambda: real_time() + webget._STRATEGY_MEMORY_TTL + 1
|
|
551
|
+
)
|
|
537
552
|
assert webget._load_strategy_memory() == {}
|
|
538
553
|
|
|
539
554
|
def test_fresh_entry_survives(self, isolated_env):
|