web-snapshot-cli 1.2.0__tar.gz → 1.3.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.
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/PKG-INFO +1 -1
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/index.html +1 -1
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/pyproject.toml +1 -1
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/__init__.py +1 -1
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/downloader.py +7 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlist.py +9 -14
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_crawler.py +27 -0
- web_snapshot_cli-1.3.0/tests/test_wordlist.py +92 -0
- web_snapshot_cli-1.2.0/tests/test_wordlist.py +0 -69
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/ci.yml +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/pages.yml +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.gitignore +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/LICENSE +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/README.md +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/CNAME +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/app.js +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/style.css +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/__main__.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/cli.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/crawl_policy.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/manifest.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/restore.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/utils.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/__init__.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/common.txt +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/large.txt +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_crawl_policy.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_features.py +0 -0
- {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-snapshot-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Take offline snapshots of any website and restore them locally
|
|
5
5
|
Project-URL: Homepage, https://github.com/codingsushi79/Snapshot
|
|
6
6
|
Project-URL: Repository, https://github.com/codingsushi79/Snapshot
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
<div class="field snapshot-only">
|
|
66
66
|
<label for="wordlists">Extra wordlists (one per line)</label>
|
|
67
67
|
<textarea id="wordlists" placeholder="common large /path/to/SecLists/Discovery/Web-Content/common.txt"></textarea>
|
|
68
|
-
<div class="hint">Builtin names: <code>common</code>, <code>large</code>.
|
|
68
|
+
<div class="hint">Builtin names: <code>common</code>, <code>large</code>. Probes follow redirects and skip any page whose text contains <code>404</code> or returns HTTP 404.</div>
|
|
69
69
|
</div>
|
|
70
70
|
<div class="field snapshot-only">
|
|
71
71
|
<label for="wordlistExt">Wordlist extensions</label>
|
|
@@ -364,6 +364,13 @@ class SnapshotEngine:
|
|
|
364
364
|
|
|
365
365
|
try:
|
|
366
366
|
response = await self._fetch(url)
|
|
367
|
+
except httpx.HTTPStatusError as exc:
|
|
368
|
+
if exc.response.status_code in {404, 410}:
|
|
369
|
+
self._pages_skipped += 1
|
|
370
|
+
self._log(f"skip (404): {url}")
|
|
371
|
+
return []
|
|
372
|
+
self._errors.append(f"{url}: {exc}")
|
|
373
|
+
return []
|
|
367
374
|
except Exception as exc: # noqa: BLE001
|
|
368
375
|
self._errors.append(f"{url}: {exc}")
|
|
369
376
|
return []
|
|
@@ -12,7 +12,7 @@ from snapshot.crawl_policy import RobotsChecker
|
|
|
12
12
|
from snapshot.utils import is_not_found_page
|
|
13
13
|
|
|
14
14
|
BUILTIN_WORDLISTS = ("common", "large")
|
|
15
|
-
|
|
15
|
+
FOUND_STATUS = {200, 201, 204, 401, 403}
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def resolve_wordlist_sources(
|
|
@@ -157,16 +157,11 @@ def normalize_probe_url(base: str, path: str) -> str | None:
|
|
|
157
157
|
|
|
158
158
|
|
|
159
159
|
async def _probe_url(client: httpx.AsyncClient, url: str) -> bool:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return False
|
|
169
|
-
if method == "HEAD":
|
|
170
|
-
continue
|
|
171
|
-
return not is_not_found_page(response)
|
|
172
|
-
return False
|
|
160
|
+
"""Probe a URL the same way page snapshots are fetched."""
|
|
161
|
+
try:
|
|
162
|
+
response = await client.get(url, follow_redirects=True)
|
|
163
|
+
except httpx.RequestError:
|
|
164
|
+
return False
|
|
165
|
+
if response.status_code not in FOUND_STATUS:
|
|
166
|
+
return False
|
|
167
|
+
return not is_not_found_page(response)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
3
3
|
|
|
4
|
+
import httpx
|
|
4
5
|
import pytest
|
|
5
6
|
from bs4 import BeautifulSoup
|
|
6
7
|
|
|
@@ -93,3 +94,29 @@ async def test_snapshot_skips_soft_404_page():
|
|
|
93
94
|
assert result.pages_saved == 0
|
|
94
95
|
assert result.pages_skipped == 1
|
|
95
96
|
assert "https://example.com/" not in engine._seen_pages
|
|
97
|
+
assert result.errors == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_snapshot_skips_http_404_without_error():
|
|
102
|
+
async def fake_fetch(url: str):
|
|
103
|
+
response = MagicMock()
|
|
104
|
+
response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
105
|
+
"not found",
|
|
106
|
+
request=MagicMock(),
|
|
107
|
+
response=MagicMock(status_code=404),
|
|
108
|
+
)
|
|
109
|
+
raise response.raise_for_status.side_effect
|
|
110
|
+
|
|
111
|
+
engine = SnapshotEngine(
|
|
112
|
+
"https://example.com/",
|
|
113
|
+
Path("/tmp/http-404-test"),
|
|
114
|
+
SnapshotOptions(crawl=False, download_assets=False),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with patch.object(engine, "_fetch", new=AsyncMock(side_effect=fake_fetch)):
|
|
118
|
+
result = await engine.run()
|
|
119
|
+
|
|
120
|
+
assert result.pages_saved == 0
|
|
121
|
+
assert result.pages_skipped == 1
|
|
122
|
+
assert result.errors == []
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from snapshot.wordlist import _probe_url, paths_for_word, resolve_wordlist_sources
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_paths_for_word_file_entry():
|
|
10
|
+
paths = paths_for_word("robots.txt", ())
|
|
11
|
+
assert paths == ["/robots.txt"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_paths_for_word_directory_entry():
|
|
15
|
+
paths = paths_for_word("admin", (".html",))
|
|
16
|
+
assert "/admin" in paths
|
|
17
|
+
assert "/admin/" in paths
|
|
18
|
+
assert "/admin.html" in paths
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_resolve_wordlist_sources_gobuster_defaults():
|
|
22
|
+
assert resolve_wordlist_sources(True, []) == ["common", "large"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resolve_wordlist_sources_custom():
|
|
26
|
+
assert resolve_wordlist_sources(False, ["common", "/tmp/list.txt"]) == [
|
|
27
|
+
"common",
|
|
28
|
+
"/tmp/list.txt",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_load_builtin_common():
|
|
33
|
+
from snapshot.wordlist import load_words
|
|
34
|
+
|
|
35
|
+
words = load_words(["common"])
|
|
36
|
+
assert "robots.txt" in words
|
|
37
|
+
assert "sitemap.xml" in words
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_probe_url_rejects_soft_404():
|
|
42
|
+
client = MagicMock(spec=httpx.AsyncClient)
|
|
43
|
+
response = MagicMock()
|
|
44
|
+
response.status_code = 200
|
|
45
|
+
response.headers = {"content-type": "text/html"}
|
|
46
|
+
response.url = "https://example.com/missing"
|
|
47
|
+
response.text = (
|
|
48
|
+
"<html><head><title>404 Not Found</title></head><body><h1>404</h1></body></html>"
|
|
49
|
+
)
|
|
50
|
+
client.get = AsyncMock(return_value=response)
|
|
51
|
+
|
|
52
|
+
assert await _probe_url(client, "https://example.com/missing") is False
|
|
53
|
+
client.get.assert_awaited_once_with("https://example.com/missing", follow_redirects=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_probe_url_accepts_valid_page():
|
|
58
|
+
client = MagicMock(spec=httpx.AsyncClient)
|
|
59
|
+
response = MagicMock()
|
|
60
|
+
response.status_code = 200
|
|
61
|
+
response.headers = {"content-type": "text/html"}
|
|
62
|
+
response.url = "https://example.com/about"
|
|
63
|
+
response.text = "<html><head><title>About</title></head><body><h1>About</h1></body></html>"
|
|
64
|
+
client.get = AsyncMock(return_value=response)
|
|
65
|
+
|
|
66
|
+
assert await _probe_url(client, "https://example.com/about") is True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_probe_url_rejects_hard_404_after_redirect():
|
|
71
|
+
client = MagicMock(spec=httpx.AsyncClient)
|
|
72
|
+
response = MagicMock()
|
|
73
|
+
response.status_code = 404
|
|
74
|
+
response.headers = {"content-type": "text/html"}
|
|
75
|
+
response.url = "https://example.com/missing"
|
|
76
|
+
response.text = "<html><body>missing</body></html>"
|
|
77
|
+
client.get = AsyncMock(return_value=response)
|
|
78
|
+
|
|
79
|
+
assert await _probe_url(client, "https://example.com/missing/") is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_probe_url_does_not_count_redirect_without_final_page():
|
|
84
|
+
client = MagicMock(spec=httpx.AsyncClient)
|
|
85
|
+
response = MagicMock()
|
|
86
|
+
response.status_code = 308
|
|
87
|
+
response.headers = {"content-type": "text/html"}
|
|
88
|
+
response.url = "https://example.com/old/"
|
|
89
|
+
response.text = ""
|
|
90
|
+
client.get = AsyncMock(return_value=response)
|
|
91
|
+
|
|
92
|
+
assert await _probe_url(client, "https://example.com/old/") is False
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
2
|
-
|
|
3
|
-
import httpx
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from snapshot.wordlist import _probe_url, paths_for_word, resolve_wordlist_sources
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_paths_for_word_file_entry():
|
|
10
|
-
paths = paths_for_word("robots.txt", ())
|
|
11
|
-
assert paths == ["/robots.txt"]
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_paths_for_word_directory_entry():
|
|
15
|
-
paths = paths_for_word("admin", (".html",))
|
|
16
|
-
assert "/admin" in paths
|
|
17
|
-
assert "/admin/" in paths
|
|
18
|
-
assert "/admin.html" in paths
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_resolve_wordlist_sources_gobuster_defaults():
|
|
22
|
-
assert resolve_wordlist_sources(True, []) == ["common", "large"]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_resolve_wordlist_sources_custom():
|
|
26
|
-
assert resolve_wordlist_sources(False, ["common", "/tmp/list.txt"]) == [
|
|
27
|
-
"common",
|
|
28
|
-
"/tmp/list.txt",
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_load_builtin_common():
|
|
33
|
-
from snapshot.wordlist import load_words
|
|
34
|
-
|
|
35
|
-
words = load_words(["common"])
|
|
36
|
-
assert "robots.txt" in words
|
|
37
|
-
assert "sitemap.xml" in words
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@pytest.mark.asyncio
|
|
41
|
-
async def test_probe_url_rejects_soft_404():
|
|
42
|
-
client = MagicMock(spec=httpx.AsyncClient)
|
|
43
|
-
head_response = MagicMock()
|
|
44
|
-
head_response.status_code = 200
|
|
45
|
-
get_response = MagicMock()
|
|
46
|
-
get_response.status_code = 200
|
|
47
|
-
get_response.headers = {"content-type": "text/html"}
|
|
48
|
-
get_response.url = "https://example.com/missing"
|
|
49
|
-
get_response.text = (
|
|
50
|
-
"<html><head><title>404 Not Found</title></head><body><h1>404</h1></body></html>"
|
|
51
|
-
)
|
|
52
|
-
client.request = AsyncMock(side_effect=[head_response, get_response])
|
|
53
|
-
|
|
54
|
-
assert await _probe_url(client, "https://example.com/missing") is False
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.mark.asyncio
|
|
58
|
-
async def test_probe_url_accepts_valid_page():
|
|
59
|
-
client = MagicMock(spec=httpx.AsyncClient)
|
|
60
|
-
head_response = MagicMock()
|
|
61
|
-
head_response.status_code = 200
|
|
62
|
-
get_response = MagicMock()
|
|
63
|
-
get_response.status_code = 200
|
|
64
|
-
get_response.headers = {"content-type": "text/html"}
|
|
65
|
-
get_response.url = "https://example.com/about"
|
|
66
|
-
get_response.text = "<html><head><title>About</title></head><body><h1>About</h1></body></html>"
|
|
67
|
-
client.request = AsyncMock(side_effect=[head_response, get_response])
|
|
68
|
-
|
|
69
|
-
assert await _probe_url(client, "https://example.com/about") is True
|
|
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
|