web-snapshot-cli 0.1.0__tar.gz → 1.0.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 (26) hide show
  1. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/.github/workflows/publish.yml +3 -2
  2. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/PKG-INFO +16 -38
  3. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/README.md +14 -36
  4. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/pyproject.toml +6 -2
  5. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/__init__.py +1 -1
  6. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/cli.py +47 -5
  7. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/downloader.py +69 -1
  8. web_snapshot_cli-1.0.0/src/snapshot/wordlist.py +171 -0
  9. web_snapshot_cli-1.0.0/src/snapshot/wordlists/__init__.py +0 -0
  10. web_snapshot_cli-1.0.0/src/snapshot/wordlists/common.txt +132 -0
  11. web_snapshot_cli-1.0.0/src/snapshot/wordlists/large.txt +18637 -0
  12. web_snapshot_cli-1.0.0/tests/test_wordlist.py +32 -0
  13. web_snapshot_cli-0.1.0/install.ps1 +0 -67
  14. web_snapshot_cli-0.1.0/install.sh +0 -109
  15. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/.github/workflows/ci.yml +0 -0
  16. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/.gitignore +0 -0
  17. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/LICENSE +0 -0
  18. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/__main__.py +0 -0
  19. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/crawl_policy.py +0 -0
  20. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/manifest.py +0 -0
  21. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/restore.py +0 -0
  22. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/utils.py +0 -0
  23. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_crawl_policy.py +0 -0
  24. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_crawler.py +0 -0
  25. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_features.py +0 -0
  26. {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_utils.py +0 -0
@@ -1,5 +1,4 @@
1
- # PyPI project: web-snapshot-cli
2
- # Trusted publisher: owner codingsushi79, repo Snapshot, workflow publish.yml, env pypi
1
+ # PyPI: web-snapshot-cli
3
2
  name: Publish to PyPI
4
3
 
5
4
  on:
@@ -62,3 +61,5 @@ jobs:
62
61
  path: dist/
63
62
 
64
63
  - uses: pypa/gh-action-pypi-publish@release/v1
64
+ with:
65
+ skip-existing: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-snapshot-cli
3
- Version: 0.1.0
3
+ Version: 1.0.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
@@ -8,7 +8,7 @@ Author: snapshot contributors
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
10
10
  Keywords: cli,crawler,offline,snapshot,web-archive
11
- Classifier: Development Status :: 4 - Beta
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
@@ -33,25 +33,7 @@ Take a fast offline copy of any public website, then serve it locally.
33
33
 
34
34
  ## Install
35
35
 
36
- **One-liner (macOS / Linux / WSL):**
37
-
38
- ```bash
39
- curl -fsSL https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.sh | bash
40
- ```
41
-
42
- **One-liner (Windows PowerShell):**
43
-
44
- ```powershell
45
- irm https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.ps1 | iex
46
- ```
47
-
48
- **Universal fallback** (requires Python 3.10+):
49
-
50
- ```bash
51
- python3 -m pip install --user git+https://github.com/codingsushi79/Snapshot.git
52
- ```
53
-
54
- **From PyPI** (once published):
36
+ Requires Python 3.10+.
55
37
 
56
38
  ```bash
57
39
  pip install web-snapshot-cli
@@ -63,23 +45,6 @@ Then run:
63
45
  snapshot https://example.com ./mirror
64
46
  ```
65
47
 
66
- <details>
67
- <summary>Other install methods</summary>
68
-
69
- ```bash
70
- # With pipx (recommended for CLI tools)
71
- pipx install web-snapshot-cli
72
- # or from git:
73
- pipx install git+https://github.com/codingsushi79/Snapshot.git
74
-
75
- # From a local clone
76
- git clone https://github.com/codingsushi79/Snapshot.git
77
- cd Snapshot
78
- pip install -e .
79
- ```
80
-
81
- </details>
82
-
83
48
  ## Usage
84
49
 
85
50
  ### Snapshot a single page
@@ -120,6 +85,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
120
85
  snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
121
86
  ```
122
87
 
88
+ ### Gobuster-style path discovery
89
+
90
+ Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
91
+
92
+ ```bash
93
+ snapshot --crawl --gobuster https://example.com ./mirror
94
+ snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
95
+ snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
96
+ ```
97
+
123
98
  ### Resume an interrupted snapshot
124
99
 
125
100
  ```bash
@@ -172,6 +147,9 @@ snapshot -restore ./mirror --port 3000 --no-open
172
147
  | `--robots` / `--no-robots` | Respect robots.txt (default: on) |
173
148
  | `--crawl-delay` | Seconds to wait after each request (default: 0) |
174
149
  | `--sitemap` | Seed crawl from sitemap.xml |
150
+ | `--gobuster`, `-g` | Discover paths with bundled wordlists |
151
+ | `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
152
+ | `--wordlist-ext` | Extensions to append per word (`html,php`) |
175
153
  | `--resume` | Skip pages/assets already saved |
176
154
  | `--verbose`, `-v` | Detailed log output |
177
155
  | `--dry-run` | Fetch without writing files |
@@ -4,25 +4,7 @@ Take a fast offline copy of any public website, then serve it locally.
4
4
 
5
5
  ## Install
6
6
 
7
- **One-liner (macOS / Linux / WSL):**
8
-
9
- ```bash
10
- curl -fsSL https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.sh | bash
11
- ```
12
-
13
- **One-liner (Windows PowerShell):**
14
-
15
- ```powershell
16
- irm https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.ps1 | iex
17
- ```
18
-
19
- **Universal fallback** (requires Python 3.10+):
20
-
21
- ```bash
22
- python3 -m pip install --user git+https://github.com/codingsushi79/Snapshot.git
23
- ```
24
-
25
- **From PyPI** (once published):
7
+ Requires Python 3.10+.
26
8
 
27
9
  ```bash
28
10
  pip install web-snapshot-cli
@@ -34,23 +16,6 @@ Then run:
34
16
  snapshot https://example.com ./mirror
35
17
  ```
36
18
 
37
- <details>
38
- <summary>Other install methods</summary>
39
-
40
- ```bash
41
- # With pipx (recommended for CLI tools)
42
- pipx install web-snapshot-cli
43
- # or from git:
44
- pipx install git+https://github.com/codingsushi79/Snapshot.git
45
-
46
- # From a local clone
47
- git clone https://github.com/codingsushi79/Snapshot.git
48
- cd Snapshot
49
- pip install -e .
50
- ```
51
-
52
- </details>
53
-
54
19
  ## Usage
55
20
 
56
21
  ### Snapshot a single page
@@ -91,6 +56,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
91
56
  snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
92
57
  ```
93
58
 
59
+ ### Gobuster-style path discovery
60
+
61
+ Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
62
+
63
+ ```bash
64
+ snapshot --crawl --gobuster https://example.com ./mirror
65
+ snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
66
+ snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
67
+ ```
68
+
94
69
  ### Resume an interrupted snapshot
95
70
 
96
71
  ```bash
@@ -143,6 +118,9 @@ snapshot -restore ./mirror --port 3000 --no-open
143
118
  | `--robots` / `--no-robots` | Respect robots.txt (default: on) |
144
119
  | `--crawl-delay` | Seconds to wait after each request (default: 0) |
145
120
  | `--sitemap` | Seed crawl from sitemap.xml |
121
+ | `--gobuster`, `-g` | Discover paths with bundled wordlists |
122
+ | `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
123
+ | `--wordlist-ext` | Extensions to append per word (`html,php`) |
146
124
  | `--resume` | Skip pages/assets already saved |
147
125
  | `--verbose`, `-v` | Detailed log output |
148
126
  | `--dry-run` | Fetch without writing files |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "web-snapshot-cli"
7
- version = "0.1.0"
7
+ version = "1.0.0"
8
8
  description = "Take offline snapshots of any website and restore them locally"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,7 +12,7 @@ requires-python = ">=3.10"
12
12
  authors = [{ name = "snapshot contributors" }]
13
13
  keywords = ["cli", "web-archive", "snapshot", "offline", "crawler"]
14
14
  classifiers = [
15
- "Development Status :: 4 - Beta",
15
+ "Development Status :: 5 - Production/Stable",
16
16
  "Environment :: Console",
17
17
  "Intended Audience :: Developers",
18
18
  "License :: OSI Approved :: MIT License",
@@ -41,6 +41,10 @@ Repository = "https://github.com/codingsushi79/Snapshot"
41
41
  [tool.hatch.build.targets.wheel]
42
42
  packages = ["src/snapshot"]
43
43
 
44
+ [tool.hatch.build.targets.wheel.force-include]
45
+ "src/snapshot/wordlists/common.txt" = "snapshot/wordlists/common.txt"
46
+ "src/snapshot/wordlists/large.txt" = "snapshot/wordlists/large.txt"
47
+
44
48
  [tool.pytest.ini_options]
45
49
  asyncio_mode = "auto"
46
50
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """snapshot — offline website snapshots."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "1.0.0"
@@ -68,13 +68,20 @@ def _build_options(
68
68
  respect_robots: bool,
69
69
  crawl_delay: float,
70
70
  use_sitemap: bool,
71
+ gobuster: bool,
72
+ wordlists: tuple[str, ...],
73
+ wordlist_ext: str,
71
74
  resume: bool,
72
75
  verbose: bool,
73
76
  dry_run: bool,
74
77
  ) -> SnapshotOptions:
75
78
  default_ua = SnapshotOptions().user_agent
79
+ use_gobuster = gobuster or bool(extras.get("gobuster")) or bool(
80
+ wordlists or extras.get("wordlist")
81
+ )
82
+ crawl_enabled = crawl or bool(extras.get("crawl")) or use_gobuster
76
83
  return SnapshotOptions(
77
- crawl=crawl or bool(extras.get("crawl")),
84
+ crawl=crawl_enabled,
78
85
  max_pages=int(extras.get("max_pages", max_pages)),
79
86
  max_depth=int(extras.get("depth", depth)),
80
87
  lang=str(extras.get("lang", lang)),
@@ -90,6 +97,9 @@ def _build_options(
90
97
  respect_robots=bool(extras.get("respect_robots", respect_robots)),
91
98
  crawl_delay=float(extras.get("crawl_delay", crawl_delay)),
92
99
  use_sitemap=use_sitemap or bool(extras.get("sitemap")),
100
+ gobuster=gobuster or bool(extras.get("gobuster")),
101
+ wordlists=list(wordlists) + _parse_list_value(extras.get("wordlist")),
102
+ wordlist_extensions=_parse_list_value(extras.get("wordlist_ext") or wordlist_ext),
93
103
  resume=resume or bool(extras.get("resume")),
94
104
  verbose=verbose or bool(extras.get("verbose")),
95
105
  dry_run=dry_run or bool(extras.get("dry_run")),
@@ -180,9 +190,27 @@ def _build_options(
180
190
  )
181
191
  @click.option(
182
192
  "--sitemap",
193
+ "use_sitemap",
183
194
  is_flag=True,
184
195
  help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
185
196
  )
197
+ @click.option(
198
+ "--gobuster",
199
+ "-g",
200
+ is_flag=True,
201
+ help="Brute-force discover paths using bundled wordlists (common + large).",
202
+ )
203
+ @click.option(
204
+ "--wordlist",
205
+ "-w",
206
+ multiple=True,
207
+ help="Wordlist file or builtin name: common, large (repeatable). Enables path scanning.",
208
+ )
209
+ @click.option(
210
+ "--wordlist-ext",
211
+ default="",
212
+ help="Comma-separated extensions to try with each word (e.g. html,php,asp).",
213
+ )
186
214
  @click.option(
187
215
  "--resume",
188
216
  is_flag=True,
@@ -198,7 +226,9 @@ def _build_options(
198
226
  @click.option("--host", default="127.0.0.1", show_default=True, help="Host for -restore.")
199
227
  @click.option("--no-open", is_flag=True, help="Do not open a browser when restoring.")
200
228
  @click.version_option(__version__, prog_name="snapshot")
229
+ @click.pass_context
201
230
  def main(
231
+ ctx: click.Context,
202
232
  url: str | None,
203
233
  output_dir: str | None,
204
234
  extra_args: tuple[str, ...],
@@ -219,6 +249,9 @@ def main(
219
249
  robots: bool,
220
250
  crawl_delay: float,
221
251
  use_sitemap: bool,
252
+ gobuster: bool,
253
+ wordlist: tuple[str, ...],
254
+ wordlist_ext: str,
222
255
  resume: bool,
223
256
  verbose: bool,
224
257
  dry_run: bool,
@@ -232,7 +265,8 @@ def main(
232
265
  Examples:
233
266
  snapshot https://example.com ./mirror
234
267
  snapshot --crawl https://docs.example.com ./docs --max-pages=200
235
- snapshot --crawl --sitemap --crawl-delay 1 https://example.com ./mirror
268
+ snapshot --crawl --gobuster --sitemap https://example.com ./mirror
269
+ snapshot --crawl -w common -w /path/to/SecLists.txt URL ./out
236
270
  snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
237
271
  snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
238
272
  snapshot --resume --crawl URL ./mirror
@@ -245,9 +279,9 @@ def main(
245
279
  return
246
280
 
247
281
  if not url or not output_dir:
248
- console.print("[red]Usage:[/red] snapshot [OPTIONS] URL OUTPUT_DIR [extra args]")
249
- console.print(" snapshot -restore DIR")
250
- raise SystemExit(1)
282
+ if not url:
283
+ ctx.fail("Missing argument 'URL'.")
284
+ ctx.fail("Missing argument 'OUTPUT_DIR'.")
251
285
 
252
286
  options = _build_options(
253
287
  extras,
@@ -267,6 +301,9 @@ def main(
267
301
  respect_robots=robots,
268
302
  crawl_delay=crawl_delay,
269
303
  use_sitemap=use_sitemap,
304
+ gobuster=gobuster,
305
+ wordlists=wordlist,
306
+ wordlist_ext=wordlist_ext,
270
307
  resume=resume,
271
308
  verbose=verbose,
272
309
  dry_run=dry_run,
@@ -280,6 +317,9 @@ def main(
280
317
  console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
281
318
  if options.use_sitemap:
282
319
  console.print(" sitemap: enabled")
320
+ if options.gobuster or options.wordlists:
321
+ sources = options.wordlists or ["common", "large"]
322
+ console.print(f" wordlist: {', '.join(sources)}")
283
323
  if options.resume:
284
324
  console.print(" resume: enabled")
285
325
  console.print(f" format: {options.lang}")
@@ -311,6 +351,8 @@ def main(
311
351
  console.print(
312
352
  f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
313
353
  )
354
+ if result.wordlist_hits:
355
+ console.print(f" wordlist: {result.wordlist_hits} paths discovered")
314
356
  if result.errors:
315
357
  console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
316
358
  for err in result.errors[:10]:
@@ -13,6 +13,7 @@ from markdownify import markdownify
13
13
  from snapshot.crawl_policy import (
14
14
  RobotsChecker,
15
15
  discover_sitemap_urls,
16
+ parse_sitemap_xml,
16
17
  url_matches_filters,
17
18
  )
18
19
  from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
@@ -27,6 +28,7 @@ from snapshot.utils import (
27
28
  same_origin,
28
29
  url_to_local_path,
29
30
  )
31
+ from snapshot.wordlist import load_words, resolve_wordlist_sources, scan_wordlists
30
32
 
31
33
 
32
34
  @dataclass
@@ -39,7 +41,7 @@ class SnapshotOptions:
39
41
  timeout: float = 15.0
40
42
  concurrency: int = 16
41
43
  same_origin_only: bool = True
42
- user_agent: str = "web-snapshot-cli/0.1 (+https://github.com/codingsushi79/Snapshot)"
44
+ user_agent: str = "web-snapshot-cli/1.0 (+https://github.com/codingsushi79/Snapshot)"
43
45
  cookies: list[str] = field(default_factory=list)
44
46
  headers: list[str] = field(default_factory=list)
45
47
  include_patterns: list[str] = field(default_factory=list)
@@ -47,6 +49,9 @@ class SnapshotOptions:
47
49
  respect_robots: bool = True
48
50
  crawl_delay: float = 0.0
49
51
  use_sitemap: bool = False
52
+ gobuster: bool = False
53
+ wordlists: list[str] = field(default_factory=list)
54
+ wordlist_extensions: list[str] = field(default_factory=list)
50
55
  resume: bool = False
51
56
  verbose: bool = False
52
57
  dry_run: bool = False
@@ -58,6 +63,7 @@ class SnapshotResult:
58
63
  assets_saved: int = 0
59
64
  pages_skipped: int = 0
60
65
  assets_skipped: int = 0
66
+ wordlist_hits: int = 0
61
67
  errors: list[str] = field(default_factory=list)
62
68
 
63
69
 
@@ -75,6 +81,7 @@ class SnapshotEngine:
75
81
  self._robots = RobotsChecker(options.user_agent, options.respect_robots)
76
82
  self._pages_skipped = 0
77
83
  self._assets_skipped = 0
84
+ self._wordlist_hits = 0
78
85
  self._log_fn: Callable[[str], None] | None = None
79
86
 
80
87
  async def run(
@@ -119,6 +126,7 @@ class SnapshotEngine:
119
126
  assets_saved=len(self._seen_assets),
120
127
  pages_skipped=self._pages_skipped,
121
128
  assets_skipped=self._assets_skipped,
129
+ wordlist_hits=self._wordlist_hits,
122
130
  errors=self._errors,
123
131
  )
124
132
 
@@ -133,6 +141,8 @@ class SnapshotEngine:
133
141
  "respect_robots": self.options.respect_robots,
134
142
  "crawl_delay": self.options.crawl_delay,
135
143
  "use_sitemap": self.options.use_sitemap,
144
+ "gobuster": self.options.gobuster,
145
+ "wordlists": self.options.wordlists,
136
146
  }
137
147
 
138
148
  def _build_headers(self) -> dict[str, str]:
@@ -198,6 +208,35 @@ class SnapshotEngine:
198
208
  self._log(f"sitemap: discovered {len(sitemap_urls)} URLs")
199
209
  seed_urls.extend(sitemap_urls)
200
210
 
211
+ wordlist_sources = resolve_wordlist_sources(
212
+ self.options.gobuster,
213
+ self.options.wordlists,
214
+ )
215
+ if wordlist_sources:
216
+ if progress:
217
+ progress("loading wordlists…")
218
+ words = load_words(wordlist_sources)
219
+ extensions = tuple(self.options.wordlist_extensions)
220
+ found = await scan_wordlists(
221
+ self._client,
222
+ self.root_url,
223
+ words,
224
+ extensions=extensions,
225
+ concurrency=self.options.concurrency,
226
+ crawl_delay=self.options.crawl_delay,
227
+ robots=self._robots,
228
+ url_allowed=self._url_allowed,
229
+ log=self._log,
230
+ progress=progress,
231
+ )
232
+ self._wordlist_hits = len(found)
233
+ extra = await self._expand_metadata_urls(found)
234
+ seed_urls.extend(found)
235
+ seed_urls.extend(extra)
236
+ self._log(
237
+ f"wordlist: {len(found)} paths, {len(extra)} extra URLs from robots/sitemaps"
238
+ )
239
+
201
240
  for url in seed_urls:
202
241
  normalized = normalize_url(url)
203
242
  if not normalized or not self._url_allowed(normalized):
@@ -216,6 +255,35 @@ class SnapshotEngine:
216
255
  worker.cancel()
217
256
  await asyncio.gather(*workers, return_exceptions=True)
218
257
 
258
+ async def _expand_metadata_urls(self, discovered: list[str]) -> list[str]:
259
+ """Parse robots.txt and sitemap XML found during wordlist scanning."""
260
+ from snapshot.crawl_policy import parse_robots_sitemaps
261
+
262
+ extra: list[str] = []
263
+ seen: set[str] = set(discovered)
264
+ for url in discovered:
265
+ lower = url.lower()
266
+ try:
267
+ if lower.endswith("/robots.txt") or lower.endswith("robots.txt"):
268
+ response = await self._client.get(url)
269
+ if response.status_code == 200:
270
+ for sitemap_url in parse_robots_sitemaps(response.text):
271
+ if sitemap_url not in seen:
272
+ seen.add(sitemap_url)
273
+ extra.append(sitemap_url)
274
+ if "sitemap" in lower and (
275
+ lower.endswith(".xml") or lower.endswith(".xml.gz") or lower.endswith(".txt")
276
+ ):
277
+ response = await self._client.get(url)
278
+ if response.status_code == 200:
279
+ for page_url in parse_sitemap_xml(response.text):
280
+ if page_url not in seen:
281
+ seen.add(page_url)
282
+ extra.append(page_url)
283
+ except Exception as exc: # noqa: BLE001
284
+ self._errors.append(f"{url}: {exc}")
285
+ return extra
286
+
219
287
  async def _crawl_worker(
220
288
  self,
221
289
  queue: asyncio.Queue[tuple[str, int]],
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from urllib.parse import urljoin, urlparse
8
+
9
+ import httpx
10
+
11
+ from snapshot.crawl_policy import RobotsChecker
12
+
13
+ BUILTIN_WORDLISTS = ("common", "large")
14
+ HIT_STATUS = {200, 201, 204, 301, 302, 307, 308, 401, 403}
15
+
16
+
17
+ def resolve_wordlist_sources(
18
+ gobuster: bool,
19
+ wordlist_args: list[str],
20
+ ) -> list[Path | str]:
21
+ """Return paths and/or builtin names to load."""
22
+ if wordlist_args:
23
+ return list(wordlist_args)
24
+ if gobuster:
25
+ return list(BUILTIN_WORDLISTS)
26
+ return []
27
+
28
+
29
+ def load_words(sources: list[Path | str]) -> list[str]:
30
+ words: list[str] = []
31
+ seen: set[str] = set()
32
+ for source in sources:
33
+ for word in _load_source(source):
34
+ if word not in seen:
35
+ seen.add(word)
36
+ words.append(word)
37
+ return words
38
+
39
+
40
+ def _load_source(source: Path | str) -> list[str]:
41
+ name = str(source).strip()
42
+ if name in BUILTIN_WORDLISTS:
43
+ return _load_builtin(name)
44
+ path = Path(name)
45
+ if not path.is_file():
46
+ msg = f"wordlist not found: {name} (use common, large, or a file path)"
47
+ raise FileNotFoundError(msg)
48
+ return _parse_wordlist_text(path.read_text(encoding="utf-8", errors="replace"))
49
+
50
+
51
+ def _load_builtin(name: str) -> list[str]:
52
+ text = resources.files("snapshot.wordlists").joinpath(f"{name}.txt").read_text(
53
+ encoding="utf-8"
54
+ )
55
+ return _parse_wordlist_text(text)
56
+
57
+
58
+ def _parse_wordlist_text(text: str) -> list[str]:
59
+ words: list[str] = []
60
+ for line in text.splitlines():
61
+ stripped = line.strip()
62
+ if not stripped or stripped.startswith("#"):
63
+ continue
64
+ words.append(stripped.lstrip("/"))
65
+ return words
66
+
67
+
68
+ def paths_for_word(word: str, extensions: tuple[str, ...]) -> list[str]:
69
+ """Build URL path candidates for a single wordlist entry."""
70
+ cleaned = word.strip().strip("/")
71
+ if not cleaned:
72
+ return []
73
+
74
+ basename = cleaned.split("/")[-1]
75
+ paths = [f"/{cleaned}"]
76
+
77
+ if "." not in basename:
78
+ paths.append(f"/{cleaned}/")
79
+ for ext in extensions:
80
+ suffix = ext if ext.startswith(".") else f".{ext}"
81
+ paths.append(f"/{cleaned}{suffix}")
82
+
83
+ return list(dict.fromkeys(paths))
84
+
85
+
86
+ async def scan_wordlists(
87
+ client: httpx.AsyncClient,
88
+ root_url: str,
89
+ words: list[str],
90
+ *,
91
+ extensions: tuple[str, ...],
92
+ concurrency: int,
93
+ crawl_delay: float,
94
+ robots: RobotsChecker,
95
+ url_allowed: Callable[[str], bool],
96
+ log: Callable[[str], None] | None = None,
97
+ progress: Callable[[str], None] | None = None,
98
+ ) -> list[str]:
99
+ """Probe wordlist paths and return URLs that appear to exist."""
100
+ parsed_root = urlparse(root_url)
101
+ base = f"{parsed_root.scheme}://{parsed_root.netloc}"
102
+
103
+ candidates: list[str] = []
104
+ seen_candidates: set[str] = set()
105
+ for word in words:
106
+ for path in paths_for_word(word, extensions):
107
+ absolute = normalize_probe_url(base, path)
108
+ if absolute is None or absolute in seen_candidates:
109
+ continue
110
+ if not url_allowed(absolute):
111
+ continue
112
+ seen_candidates.add(absolute)
113
+ candidates.append(absolute)
114
+
115
+ if not candidates:
116
+ return []
117
+
118
+ if progress:
119
+ progress(f"wordlist: probing {len(candidates)} paths…")
120
+ if log:
121
+ log(f"wordlist: probing {len(candidates)} paths from {len(words)} words")
122
+
123
+ sem = asyncio.Semaphore(max(1, concurrency))
124
+ found: list[str] = []
125
+ found_lock = asyncio.Lock()
126
+ probed = 0
127
+
128
+ async def probe(target: str) -> None:
129
+ nonlocal probed
130
+ if robots.enabled and not await robots.can_fetch(client, target):
131
+ return
132
+ async with sem:
133
+ hit = await _probe_url(client, target)
134
+ if crawl_delay > 0:
135
+ await asyncio.sleep(crawl_delay)
136
+ probed += 1
137
+ if probed % 250 == 0 and progress:
138
+ progress(f"wordlist: {probed}/{len(candidates)} probes, {len(found)} hits")
139
+ if hit:
140
+ async with found_lock:
141
+ found.append(target)
142
+ if log:
143
+ log(f"wordlist hit: {target}")
144
+
145
+ await asyncio.gather(*(probe(url) for url in candidates))
146
+
147
+ if log:
148
+ log(f"wordlist: {len(found)} paths found")
149
+ return sorted(found)
150
+
151
+
152
+ def normalize_probe_url(base: str, path: str) -> str | None:
153
+ joined = urljoin(f"{base}/", path.lstrip("/"))
154
+ parsed = urlparse(joined)
155
+ if not parsed.scheme or not parsed.netloc:
156
+ return None
157
+ return parsed._replace(fragment="").geturl()
158
+
159
+
160
+ async def _probe_url(client: httpx.AsyncClient, url: str) -> bool:
161
+ for method in ("HEAD", "GET"):
162
+ try:
163
+ response = await client.request(method, url, follow_redirects=False)
164
+ except httpx.RequestError:
165
+ return False
166
+ if response.status_code in HIT_STATUS:
167
+ return True
168
+ if response.status_code == 405 and method == "HEAD":
169
+ continue
170
+ return False
171
+ return False