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.
Files changed (29) hide show
  1. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/PKG-INFO +1 -1
  2. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/index.html +3 -3
  3. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/pyproject.toml +1 -1
  4. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/__init__.py +1 -1
  5. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/downloader.py +10 -12
  6. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/utils.py +75 -27
  7. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_utils.py +43 -3
  8. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  9. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/pages.yml +0 -0
  10. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.github/workflows/publish.yml +0 -0
  11. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/.gitignore +0 -0
  12. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/LICENSE +0 -0
  13. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/README.md +0 -0
  14. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/CNAME +0 -0
  15. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/app.js +0 -0
  16. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/docs/style.css +0 -0
  17. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/__main__.py +0 -0
  18. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/cli.py +0 -0
  19. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/crawl_policy.py +0 -0
  20. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/manifest.py +0 -0
  21. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/restore.py +0 -0
  22. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlist.py +0 -0
  23. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/__init__.py +0 -0
  24. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/common.txt +0 -0
  25. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/src/snapshot/wordlists/large.txt +0 -0
  26. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_crawl_policy.py +0 -0
  27. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_crawler.py +0 -0
  28. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.2.0}/tests/test_features.py +0 -0
  29. {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.1.0
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>Bundled wordlists; soft 404 pages are filtered out</span></div>
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&#10;large&#10;/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. Pages that return 200 but show 404/not-found content are skipped.</div>
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" />
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "web-snapshot-cli"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "Take offline snapshots of any website and restore them locally"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """snapshot — offline website snapshots."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.0"
@@ -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
- for style in soup.find_all("style"):
423
- if style.string:
424
- for match in re.findall(r"url\(([^)]+)\)", style.string):
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 = self._extract_css_urls(css, url)
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 = ("href", "src", "poster", "data-src", "data-background")
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
- _NOT_FOUND_TEXT = re.compile(
47
- r"\b404\b|not\s+found|page\s+not\s+found|page\s+cannot\s+be\s+found",
48
- re.IGNORECASE,
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 HTML error content with 200 status)."""
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
- text = response.text
115
+ html = response.text
66
116
  except Exception: # noqa: BLE001
67
117
  return False
68
118
 
69
- from bs4 import BeautifulSoup
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
- for tag_name in ("h1", "h2"):
78
- heading = soup.find(tag_name)
79
- if heading:
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 is_not_found_page, normalize_url, page_local_path, url_to_local_path
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 test_is_not_found_page_soft_404_heading():
27
- html = "<html><body><h1>Page Not Found</h1><p>Sorry</p></body></html>"
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