web-snapshot-cli 1.1.0__tar.gz → 1.2.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.1.0 → web_snapshot_cli-1.2.0}/PKG-INFO +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/index.html +3 -3
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/pyproject.toml +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/__init__.py +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/downloader.py +10 -12
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/utils.py +75 -27
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_utils.py +43 -3
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/pages.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/publish.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.gitignore +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/LICENSE +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/README.md +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/CNAME +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/app.js +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/style.css +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/__main__.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/cli.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/crawl_policy.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/manifest.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/restore.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlist.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/__init__.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/common.txt +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/large.txt +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_crawl_policy.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_crawler.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_features.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_wordlist.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.2.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
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</label>
|
|
51
51
|
<label class="check-item">
|
|
52
52
|
<input type="checkbox" id="gobuster" />
|
|
53
|
-
<div>--gobuster<span>
|
|
53
|
+
<div>--gobuster<span>Wordlist discovery; skips any page mentioning 404</span></div>
|
|
54
54
|
</label>
|
|
55
55
|
<label class="check-item">
|
|
56
56
|
<input type="checkbox" id="sitemap" />
|
|
@@ -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>. --gobuster uses both by default.
|
|
68
|
+
<div class="hint">Builtin names: <code>common</code>, <code>large</code>. --gobuster uses both by default. Any page whose full text contains <code>404</code> is skipped, even on HTTP 200.</div>
|
|
69
69
|
</div>
|
|
70
70
|
<div class="field snapshot-only">
|
|
71
71
|
<label for="wordlistExt">Wordlist extensions</label>
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
</label>
|
|
144
144
|
<label class="check-item">
|
|
145
145
|
<input type="checkbox" id="noAssets" />
|
|
146
|
-
<div>--no-assets<span>Skip CSS/JS/images</span></div>
|
|
146
|
+
<div>--no-assets<span>Skip CSS/JS/images/animations</span></div>
|
|
147
147
|
</label>
|
|
148
148
|
<label class="check-item">
|
|
149
149
|
<input type="checkbox" id="verbose" />
|
|
@@ -20,6 +20,7 @@ from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
|
|
|
20
20
|
from snapshot.utils import (
|
|
21
21
|
SRCSET_ATTRS,
|
|
22
22
|
URL_ATTRS,
|
|
23
|
+
extract_css_urls,
|
|
23
24
|
is_not_found_page,
|
|
24
25
|
normalize_url,
|
|
25
26
|
page_local_path,
|
|
@@ -419,12 +420,15 @@ class SnapshotEngine:
|
|
|
419
420
|
absolute = normalize_url(piece, page_url)
|
|
420
421
|
if absolute and not self._is_page_url(absolute):
|
|
421
422
|
urls.add(absolute)
|
|
422
|
-
|
|
423
|
-
if style
|
|
424
|
-
for match in re.findall(r"url\(([^)]+)\)", style
|
|
423
|
+
style = tag.get("style")
|
|
424
|
+
if style:
|
|
425
|
+
for match in re.findall(r"url\(([^)]+)\)", style):
|
|
425
426
|
absolute = normalize_url(match.strip("'\""), page_url)
|
|
426
427
|
if absolute and not self._is_page_url(absolute):
|
|
427
428
|
urls.add(absolute)
|
|
429
|
+
for style in soup.find_all("style"):
|
|
430
|
+
if style.string:
|
|
431
|
+
urls.update(extract_css_urls(style.string, page_url))
|
|
428
432
|
return urls
|
|
429
433
|
|
|
430
434
|
def _collect_page_links(self, soup: BeautifulSoup, page_url: str) -> list[str]:
|
|
@@ -466,7 +470,7 @@ class SnapshotEngine:
|
|
|
466
470
|
lambda asset_url: self._css_mapper(asset_url, local_path),
|
|
467
471
|
)
|
|
468
472
|
local_path.write_text(css, encoding="utf-8")
|
|
469
|
-
nested =
|
|
473
|
+
nested = extract_css_urls(css, url)
|
|
470
474
|
await asyncio.gather(*(self._download_asset(u) for u in nested))
|
|
471
475
|
else:
|
|
472
476
|
local_path.write_bytes(response.content)
|
|
@@ -480,14 +484,6 @@ class SnapshotEngine:
|
|
|
480
484
|
self._url_to_path[asset_url] = target
|
|
481
485
|
return relative_href(css_path, target)
|
|
482
486
|
|
|
483
|
-
def _extract_css_urls(self, css: str, base_url: str) -> set[str]:
|
|
484
|
-
urls: set[str] = set()
|
|
485
|
-
for match in re.findall(r"url\(([^)]+)\)", css):
|
|
486
|
-
absolute = normalize_url(match.strip("'\""), base_url)
|
|
487
|
-
if absolute:
|
|
488
|
-
urls.add(absolute)
|
|
489
|
-
return urls
|
|
490
|
-
|
|
491
487
|
def _rewrite_html(self, soup: BeautifulSoup, page_url: str, page_path: Path) -> None:
|
|
492
488
|
for tag in soup.find_all(True):
|
|
493
489
|
for attr in URL_ATTRS:
|
|
@@ -533,6 +529,8 @@ class SnapshotEngine:
|
|
|
533
529
|
def _render_page(self, soup: BeautifulSoup) -> str:
|
|
534
530
|
if self.options.lang == "md":
|
|
535
531
|
return self._html_to_markdown(soup)
|
|
532
|
+
if not soup.find("html"):
|
|
533
|
+
return f"<!DOCTYPE html>\n{str(soup)}"
|
|
536
534
|
return str(soup)
|
|
537
535
|
|
|
538
536
|
def _html_to_markdown(self, soup: BeautifulSoup) -> str:
|
|
@@ -12,8 +12,28 @@ if TYPE_CHECKING:
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
14
|
# Attributes that may contain fetchable URLs.
|
|
15
|
-
URL_ATTRS = (
|
|
15
|
+
URL_ATTRS = (
|
|
16
|
+
"href",
|
|
17
|
+
"src",
|
|
18
|
+
"poster",
|
|
19
|
+
"data-src",
|
|
20
|
+
"data-background",
|
|
21
|
+
"data-lazy-src",
|
|
22
|
+
"data-original",
|
|
23
|
+
"data-lazy",
|
|
24
|
+
"data-bg",
|
|
25
|
+
"data-background-image",
|
|
26
|
+
"data-image",
|
|
27
|
+
"data-href",
|
|
28
|
+
"data-url",
|
|
29
|
+
"data-video",
|
|
30
|
+
"data-poster",
|
|
31
|
+
"data-anim-src",
|
|
32
|
+
"data-animation",
|
|
33
|
+
)
|
|
16
34
|
SRCSET_ATTRS = ("srcset", "data-srcset")
|
|
35
|
+
TEXT_ATTRS = ("aria-label", "alt", "title", "placeholder")
|
|
36
|
+
_SKIP_TAGS = frozenset({"script", "style", "noscript"})
|
|
17
37
|
|
|
18
38
|
# Tags treated as HTML pages when crawled.
|
|
19
39
|
PAGE_EXTENSIONS = {".html", ".htm", ".xhtml", ""}
|
|
@@ -43,14 +63,44 @@ ASSET_EXTENSIONS = {
|
|
|
43
63
|
|
|
44
64
|
SKIP_SCHEMES = {"mailto", "tel", "javascript", "data", "blob", "about", "file"}
|
|
45
65
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
66
|
+
_404_MARKER = re.compile(r"404", re.IGNORECASE)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_page_text(html: str) -> str:
|
|
70
|
+
"""Collect visible and semantic text from an HTML document."""
|
|
71
|
+
from bs4 import BeautifulSoup
|
|
72
|
+
|
|
73
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
74
|
+
parts: list[str] = []
|
|
75
|
+
|
|
76
|
+
if soup.title:
|
|
77
|
+
parts.append(soup.title.get_text(" ", strip=True))
|
|
78
|
+
|
|
79
|
+
for meta in soup.find_all("meta"):
|
|
80
|
+
content = meta.get("content")
|
|
81
|
+
if content:
|
|
82
|
+
parts.append(str(content))
|
|
83
|
+
|
|
84
|
+
for tag in soup.find_all(True):
|
|
85
|
+
for attr in TEXT_ATTRS:
|
|
86
|
+
value = tag.get(attr)
|
|
87
|
+
if value:
|
|
88
|
+
parts.append(str(value))
|
|
89
|
+
|
|
90
|
+
for skip_tag in list(soup.find_all(_SKIP_TAGS)):
|
|
91
|
+
skip_tag.decompose()
|
|
92
|
+
|
|
93
|
+
parts.append(soup.get_text(" ", strip=True))
|
|
94
|
+
return " ".join(part for part in parts if part)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def page_mentions_404(text: str) -> bool:
|
|
98
|
+
"""Return True when page text contains a 404 marker."""
|
|
99
|
+
return bool(_404_MARKER.search(text))
|
|
50
100
|
|
|
51
101
|
|
|
52
102
|
def is_not_found_page(response: httpx.Response) -> bool:
|
|
53
|
-
"""Detect hard and soft 404 pages (HTTP 404 or
|
|
103
|
+
"""Detect hard and soft 404 pages (HTTP 404 or any page text mentioning 404)."""
|
|
54
104
|
if response.status_code == 404:
|
|
55
105
|
return True
|
|
56
106
|
if response.status_code not in {200, 201, 204}:
|
|
@@ -62,32 +112,16 @@ def is_not_found_page(response: httpx.Response) -> bool:
|
|
|
62
112
|
return False
|
|
63
113
|
|
|
64
114
|
try:
|
|
65
|
-
|
|
115
|
+
html = response.text
|
|
66
116
|
except Exception: # noqa: BLE001
|
|
67
117
|
return False
|
|
68
118
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
soup = BeautifulSoup(text, "html.parser")
|
|
72
|
-
|
|
73
|
-
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
|
74
|
-
if title and _NOT_FOUND_TEXT.search(title):
|
|
119
|
+
if page_mentions_404(extract_page_text(html)):
|
|
75
120
|
return True
|
|
76
121
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
heading_text = heading.get_text(" ", strip=True)
|
|
81
|
-
if heading_text and _NOT_FOUND_TEXT.search(heading_text):
|
|
82
|
-
return True
|
|
83
|
-
|
|
84
|
-
body = soup.body
|
|
85
|
-
if body:
|
|
86
|
-
body_text = body.get_text(" ", strip=True)
|
|
87
|
-
if body_text and len(body_text) < 500 and _NOT_FOUND_TEXT.search(body_text):
|
|
88
|
-
return True
|
|
89
|
-
|
|
90
|
-
return False
|
|
122
|
+
stripped = re.sub(r"(?is)<script[^>]*>.*?</script>", " ", html)
|
|
123
|
+
stripped = re.sub(r"(?is)<style[^>]*>.*?</style>", " ", stripped)
|
|
124
|
+
return page_mentions_404(stripped)
|
|
91
125
|
|
|
92
126
|
|
|
93
127
|
def normalize_url(url: str, base: str | None = None) -> str | None:
|
|
@@ -187,6 +221,20 @@ def parse_srcset(value: str) -> list[str]:
|
|
|
187
221
|
return urls
|
|
188
222
|
|
|
189
223
|
|
|
224
|
+
def extract_css_urls(css: str, base_url: str) -> set[str]:
|
|
225
|
+
"""Extract linked asset URLs from CSS, including @import rules."""
|
|
226
|
+
urls: set[str] = set()
|
|
227
|
+
for match in re.findall(r"url\(([^)]+)\)", css):
|
|
228
|
+
absolute = normalize_url(match.strip("'\""), base_url)
|
|
229
|
+
if absolute:
|
|
230
|
+
urls.add(absolute)
|
|
231
|
+
for match in re.findall(r"@import\s+(?:url\()?['\"]?([^'\")\s;]+)", css):
|
|
232
|
+
absolute = normalize_url(match.strip("'\""), base_url)
|
|
233
|
+
if absolute:
|
|
234
|
+
urls.add(absolute)
|
|
235
|
+
return urls
|
|
236
|
+
|
|
237
|
+
|
|
190
238
|
def rewrite_css_urls(css: str, base_url: str, mapper) -> str:
|
|
191
239
|
"""Rewrite url(...) references in CSS using mapper(url) -> local path or None."""
|
|
192
240
|
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from unittest.mock import MagicMock
|
|
3
3
|
|
|
4
|
-
from snapshot.utils import
|
|
4
|
+
from snapshot.utils import (
|
|
5
|
+
extract_css_urls,
|
|
6
|
+
extract_page_text,
|
|
7
|
+
is_not_found_page,
|
|
8
|
+
normalize_url,
|
|
9
|
+
page_local_path,
|
|
10
|
+
page_mentions_404,
|
|
11
|
+
url_to_local_path,
|
|
12
|
+
)
|
|
5
13
|
|
|
6
14
|
|
|
7
15
|
def _html_response(html: str, status_code: int = 200) -> MagicMock:
|
|
@@ -23,8 +31,14 @@ def test_is_not_found_page_soft_404_title():
|
|
|
23
31
|
assert is_not_found_page(_html_response(html)) is True
|
|
24
32
|
|
|
25
33
|
|
|
26
|
-
def
|
|
27
|
-
|
|
34
|
+
def test_is_not_found_page_soft_404_anywhere_on_page():
|
|
35
|
+
filler = " ".join(["lorem ipsum"] * 200)
|
|
36
|
+
html = f"<html><body><main>{filler}</main><footer>Error 404</footer></body></html>"
|
|
37
|
+
assert is_not_found_page(_html_response(html)) is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_is_not_found_page_soft_404_in_meta():
|
|
41
|
+
html = '<html><head><meta name="description" content="404 page"></head><body>Hi</body></html>'
|
|
28
42
|
assert is_not_found_page(_html_response(html)) is True
|
|
29
43
|
|
|
30
44
|
|
|
@@ -33,6 +47,32 @@ def test_is_not_found_page_valid_page():
|
|
|
33
47
|
assert is_not_found_page(_html_response(html)) is False
|
|
34
48
|
|
|
35
49
|
|
|
50
|
+
def test_extract_page_text_includes_meta_and_attributes():
|
|
51
|
+
html = (
|
|
52
|
+
"<html><head><title>Home</title>"
|
|
53
|
+
'<meta name="description" content="Welcome home"></head>'
|
|
54
|
+
'<body><img alt="Hero banner" src="/hero.png"><p>Hello</p></body></html>'
|
|
55
|
+
)
|
|
56
|
+
text = extract_page_text(html)
|
|
57
|
+
assert "Home" in text
|
|
58
|
+
assert "Welcome home" in text
|
|
59
|
+
assert "Hero banner" in text
|
|
60
|
+
assert "Hello" in text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_page_mentions_404_case_insensitive():
|
|
64
|
+
assert page_mentions_404("error 404") is True
|
|
65
|
+
assert page_mentions_404("ERROR 404") is True
|
|
66
|
+
assert page_mentions_404("all good") is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_extract_css_urls_import_and_url():
|
|
70
|
+
css = '@import url("fonts/inter.css"); body { background: url("/bg.png"); }'
|
|
71
|
+
urls = extract_css_urls(css, "https://example.com/style.css")
|
|
72
|
+
assert "https://example.com/fonts/inter.css" in urls
|
|
73
|
+
assert "https://example.com/bg.png" in urls
|
|
74
|
+
|
|
75
|
+
|
|
36
76
|
def test_normalize_url_drops_fragment():
|
|
37
77
|
assert normalize_url("https://example.com/path#section") == "https://example.com/path"
|
|
38
78
|
|
|
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
|