web-snapshot-cli 0.1.0__py3-none-any.whl

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.
snapshot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """snapshot — offline website snapshots."""
2
+
3
+ __version__ = "0.1.0"
snapshot/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from snapshot.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
snapshot/cli.py ADDED
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from snapshot import __version__
11
+ from snapshot.downloader import SnapshotEngine, SnapshotOptions
12
+ from snapshot.restore import serve_snapshot
13
+
14
+ console = Console()
15
+
16
+
17
+ def _parse_extra_args(extra: tuple[str, ...]) -> dict:
18
+ """Parse key=value extra args like crawl=true max-pages=100."""
19
+ parsed: dict = {}
20
+ for item in extra:
21
+ if "=" not in item:
22
+ parsed[item.replace("-", "_")] = True
23
+ continue
24
+ key, value = item.split("=", 1)
25
+ key = key.lstrip("-").replace("-", "_")
26
+ lowered = value.lower()
27
+ if lowered in {"true", "yes", "1"}:
28
+ parsed[key] = True
29
+ elif lowered in {"false", "no", "0"}:
30
+ parsed[key] = False
31
+ else:
32
+ try:
33
+ parsed[key] = int(value)
34
+ except ValueError:
35
+ try:
36
+ parsed[key] = float(value)
37
+ except ValueError:
38
+ parsed[key] = value
39
+ return parsed
40
+
41
+
42
+ def _parse_list_value(value: object) -> list[str]:
43
+ if value is None:
44
+ return []
45
+ if isinstance(value, list):
46
+ return value
47
+ if isinstance(value, str):
48
+ return [part.strip() for part in value.split(",") if part.strip()]
49
+ return []
50
+
51
+
52
+ def _build_options(
53
+ extras: dict,
54
+ *,
55
+ crawl: bool,
56
+ lang: str,
57
+ max_pages: int,
58
+ depth: int,
59
+ no_assets: bool,
60
+ timeout: float,
61
+ concurrency: int,
62
+ same_origin: bool,
63
+ user_agent: str | None,
64
+ cookies: tuple[str, ...],
65
+ headers: tuple[str, ...],
66
+ include: tuple[str, ...],
67
+ exclude: tuple[str, ...],
68
+ respect_robots: bool,
69
+ crawl_delay: float,
70
+ use_sitemap: bool,
71
+ resume: bool,
72
+ verbose: bool,
73
+ dry_run: bool,
74
+ ) -> SnapshotOptions:
75
+ default_ua = SnapshotOptions().user_agent
76
+ return SnapshotOptions(
77
+ crawl=crawl or bool(extras.get("crawl")),
78
+ max_pages=int(extras.get("max_pages", max_pages)),
79
+ max_depth=int(extras.get("depth", depth)),
80
+ lang=str(extras.get("lang", lang)),
81
+ download_assets=not no_assets and not extras.get("no_assets"),
82
+ timeout=float(extras.get("timeout", timeout)),
83
+ concurrency=int(extras.get("concurrency", concurrency)),
84
+ same_origin_only=bool(extras.get("same_origin", same_origin)),
85
+ user_agent=str(extras.get("user_agent", user_agent or default_ua)),
86
+ cookies=list(cookies) + _parse_list_value(extras.get("cookie")),
87
+ headers=list(headers) + _parse_list_value(extras.get("header")),
88
+ include_patterns=list(include) + _parse_list_value(extras.get("include")),
89
+ exclude_patterns=list(exclude) + _parse_list_value(extras.get("exclude")),
90
+ respect_robots=bool(extras.get("respect_robots", respect_robots)),
91
+ crawl_delay=float(extras.get("crawl_delay", crawl_delay)),
92
+ use_sitemap=use_sitemap or bool(extras.get("sitemap")),
93
+ resume=resume or bool(extras.get("resume")),
94
+ verbose=verbose or bool(extras.get("verbose")),
95
+ dry_run=dry_run or bool(extras.get("dry_run")),
96
+ )
97
+
98
+
99
+ @click.command(
100
+ context_settings={"help_option_names": ["-h", "--help"]},
101
+ add_help_option=True,
102
+ )
103
+ @click.argument("url", required=False)
104
+ @click.argument("output_dir", required=False, type=click.Path())
105
+ @click.argument("extra_args", nargs=-1)
106
+ @click.option(
107
+ "-restore",
108
+ "restore_dir",
109
+ type=click.Path(exists=True, file_okay=False),
110
+ help="Serve a saved snapshot from DIR.",
111
+ )
112
+ @click.option(
113
+ "--crawl",
114
+ "-c",
115
+ is_flag=True,
116
+ help="Crawl same-origin pages and download them all.",
117
+ )
118
+ @click.option(
119
+ "--lang",
120
+ "-l",
121
+ type=click.Choice(["html", "md"]),
122
+ default="html",
123
+ show_default=True,
124
+ help="Output format for saved pages.",
125
+ )
126
+ @click.option(
127
+ "--max-pages",
128
+ type=int,
129
+ default=50,
130
+ show_default=True,
131
+ help="Maximum pages when crawling.",
132
+ )
133
+ @click.option("--depth", type=int, default=3, show_default=True, help="Maximum crawl depth.")
134
+ @click.option("--no-assets", is_flag=True, help="Skip downloading CSS, JS, images, and fonts.")
135
+ @click.option(
136
+ "--timeout", type=float, default=15.0, show_default=True, help="HTTP timeout in seconds."
137
+ )
138
+ @click.option(
139
+ "--concurrency", type=int, default=16, show_default=True, help="Parallel download workers."
140
+ )
141
+ @click.option(
142
+ "--same-origin/--no-same-origin",
143
+ default=True,
144
+ show_default=True,
145
+ help="Only follow links on the same origin when crawling.",
146
+ )
147
+ @click.option("--user-agent", default=None, help="Custom User-Agent header.")
148
+ @click.option(
149
+ "--cookie",
150
+ multiple=True,
151
+ help="Cookie as name=value (repeatable). Example: --cookie session=abc123",
152
+ )
153
+ @click.option(
154
+ "--header",
155
+ multiple=True,
156
+ help='Extra HTTP header (repeatable). Example: --header "Authorization: Bearer token"',
157
+ )
158
+ @click.option(
159
+ "--include",
160
+ multiple=True,
161
+ help="Only crawl/fetch URLs matching this glob (repeatable). Example: --include '/docs/*'",
162
+ )
163
+ @click.option(
164
+ "--exclude",
165
+ multiple=True,
166
+ help="Skip URLs matching this glob (repeatable). Example: --exclude '/admin/*'",
167
+ )
168
+ @click.option(
169
+ "--robots/--no-robots",
170
+ default=True,
171
+ show_default=True,
172
+ help="Respect robots.txt when crawling.",
173
+ )
174
+ @click.option(
175
+ "--crawl-delay",
176
+ type=float,
177
+ default=0.0,
178
+ show_default=True,
179
+ help="Seconds to wait after each HTTP request.",
180
+ )
181
+ @click.option(
182
+ "--sitemap",
183
+ is_flag=True,
184
+ help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
185
+ )
186
+ @click.option(
187
+ "--resume",
188
+ is_flag=True,
189
+ help="Resume a previous snapshot; skip pages and assets already on disk.",
190
+ )
191
+ @click.option("--verbose", "-v", is_flag=True, help="Print detailed progress messages.")
192
+ @click.option(
193
+ "--dry-run",
194
+ is_flag=True,
195
+ help="Discover and fetch without writing files or updating the manifest.",
196
+ )
197
+ @click.option("--port", type=int, default=8080, show_default=True, help="Port for -restore.")
198
+ @click.option("--host", default="127.0.0.1", show_default=True, help="Host for -restore.")
199
+ @click.option("--no-open", is_flag=True, help="Do not open a browser when restoring.")
200
+ @click.version_option(__version__, prog_name="snapshot")
201
+ def main(
202
+ url: str | None,
203
+ output_dir: str | None,
204
+ extra_args: tuple[str, ...],
205
+ restore_dir: str | None,
206
+ crawl: bool,
207
+ lang: str,
208
+ max_pages: int,
209
+ depth: int,
210
+ no_assets: bool,
211
+ timeout: float,
212
+ concurrency: int,
213
+ same_origin: bool,
214
+ user_agent: str | None,
215
+ cookie: tuple[str, ...],
216
+ header: tuple[str, ...],
217
+ include: tuple[str, ...],
218
+ exclude: tuple[str, ...],
219
+ robots: bool,
220
+ crawl_delay: float,
221
+ use_sitemap: bool,
222
+ resume: bool,
223
+ verbose: bool,
224
+ dry_run: bool,
225
+ port: int,
226
+ host: str,
227
+ no_open: bool,
228
+ ) -> None:
229
+ """Take offline snapshots of websites and restore them locally.
230
+
231
+ \b
232
+ Examples:
233
+ snapshot https://example.com ./mirror
234
+ snapshot --crawl https://docs.example.com ./docs --max-pages=200
235
+ snapshot --crawl --sitemap --crawl-delay 1 https://example.com ./mirror
236
+ snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
237
+ snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
238
+ snapshot --resume --crawl URL ./mirror
239
+ snapshot --dry-run --verbose --crawl URL ./mirror
240
+ snapshot -restore ./mirror
241
+ """
242
+ extras = _parse_extra_args(extra_args)
243
+ if restore_dir:
244
+ serve_snapshot(Path(restore_dir), host=host, port=port, open_browser=not no_open)
245
+ return
246
+
247
+ 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)
251
+
252
+ options = _build_options(
253
+ extras,
254
+ crawl=crawl,
255
+ lang=lang,
256
+ max_pages=max_pages,
257
+ depth=depth,
258
+ no_assets=no_assets,
259
+ timeout=timeout,
260
+ concurrency=concurrency,
261
+ same_origin=same_origin,
262
+ user_agent=user_agent,
263
+ cookies=cookie,
264
+ headers=header,
265
+ include=include,
266
+ exclude=exclude,
267
+ respect_robots=robots,
268
+ crawl_delay=crawl_delay,
269
+ use_sitemap=use_sitemap,
270
+ resume=resume,
271
+ verbose=verbose,
272
+ dry_run=dry_run,
273
+ )
274
+
275
+ out = Path(output_dir)
276
+ console.print(f"[bold]snapshot[/bold] {url} → {out.resolve()}")
277
+ if options.dry_run:
278
+ console.print(" [yellow]dry-run[/yellow]: no files will be written")
279
+ if options.crawl:
280
+ console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
281
+ if options.use_sitemap:
282
+ console.print(" sitemap: enabled")
283
+ if options.resume:
284
+ console.print(" resume: enabled")
285
+ console.print(f" format: {options.lang}")
286
+
287
+ def on_log(message: str) -> None:
288
+ if options.verbose:
289
+ console.print(f"[dim]{message}[/dim]")
290
+
291
+ with Progress(
292
+ SpinnerColumn(),
293
+ TextColumn("[progress.description]{task.description}"),
294
+ console=console,
295
+ transient=not options.verbose,
296
+ ) as progress:
297
+ task = progress.add_task("starting…", total=None)
298
+
299
+ def on_progress(message: str) -> None:
300
+ progress.update(task, description=message)
301
+
302
+ engine = SnapshotEngine(url, out, options)
303
+ result = asyncio.run(engine.run(progress=on_progress, log=on_log))
304
+
305
+ label = "Would save" if options.dry_run else "Done"
306
+ console.print(
307
+ f"[green]{label}.[/green] {result.pages_saved} pages, "
308
+ f"{result.assets_saved} assets → {out.resolve()}"
309
+ )
310
+ if result.pages_skipped or result.assets_skipped:
311
+ console.print(
312
+ f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
313
+ )
314
+ if result.errors:
315
+ console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
316
+ for err in result.errors[:10]:
317
+ console.print(f" • {err}")
318
+ if len(result.errors) > 10:
319
+ console.print(f" … and {len(result.errors) - 10} more")
320
+ if not options.dry_run:
321
+ console.print(f"Restore with: [cyan]snapshot -restore {out}[/cyan]")
322
+
323
+
324
+ if __name__ == "__main__":
325
+ main()
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import xml.etree.ElementTree as ET
5
+ from urllib.parse import urlparse
6
+ from urllib.robotparser import RobotFileParser
7
+
8
+ import httpx
9
+
10
+ SITEMAP_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
11
+
12
+
13
+ def url_matches_filters(url: str, includes: list[str], excludes: list[str]) -> bool:
14
+ """Return True when a URL passes include/exclude glob patterns."""
15
+ if excludes and any(_match_pattern(url, pattern) for pattern in excludes):
16
+ return False
17
+ if includes:
18
+ return any(_match_pattern(url, pattern) for pattern in includes)
19
+ return True
20
+
21
+
22
+ def _match_pattern(url: str, pattern: str) -> bool:
23
+ parsed = urlparse(url)
24
+ candidates = [
25
+ url,
26
+ parsed.path,
27
+ f"{parsed.netloc}{parsed.path}",
28
+ f"{parsed.scheme}://{parsed.netloc}{parsed.path}",
29
+ ]
30
+ return any(fnmatch.fnmatchcase(candidate, pattern) for candidate in candidates)
31
+
32
+
33
+ def parse_robots_sitemaps(text: str) -> list[str]:
34
+ sitemaps: list[str] = []
35
+ for line in text.splitlines():
36
+ stripped = line.strip()
37
+ if stripped.lower().startswith("sitemap:"):
38
+ sitemaps.append(stripped.split(":", 1)[1].strip())
39
+ return sitemaps
40
+
41
+
42
+ def parse_sitemap_xml(xml_text: str) -> list[str]:
43
+ """Extract <loc> URLs from a sitemap or sitemap index document."""
44
+ try:
45
+ root = ET.fromstring(xml_text)
46
+ except ET.ParseError:
47
+ return []
48
+
49
+ tag = root.tag.rsplit("}", 1)[-1].lower()
50
+ if tag == "sitemapindex":
51
+ return _collect_locs(root)
52
+
53
+ if tag == "urlset":
54
+ return _collect_locs(root)
55
+
56
+ return _collect_locs(root)
57
+
58
+
59
+ def _collect_locs(root: ET.Element) -> list[str]:
60
+ urls: list[str] = []
61
+ for loc in root.findall(".//sm:loc", SITEMAP_NS):
62
+ if loc.text:
63
+ urls.append(loc.text.strip())
64
+ if not urls:
65
+ for loc in root.findall(".//loc"):
66
+ if loc.text:
67
+ urls.append(loc.text.strip())
68
+ return urls
69
+
70
+
71
+ def origin_url(url: str) -> str:
72
+ parsed = urlparse(url)
73
+ return f"{parsed.scheme}://{parsed.netloc}"
74
+
75
+
76
+ class RobotsChecker:
77
+ def __init__(self, user_agent: str, enabled: bool = True):
78
+ self.user_agent = user_agent
79
+ self.enabled = enabled
80
+ self._parsers: dict[str, RobotFileParser | None] = {}
81
+ self.sitemap_urls: list[str] = []
82
+
83
+ async def can_fetch(self, client: httpx.AsyncClient, url: str) -> bool:
84
+ if not self.enabled:
85
+ return True
86
+ parser = await self._get_parser(client, origin_url(url))
87
+ if parser is None:
88
+ return True
89
+ return parser.can_fetch(self.user_agent, url)
90
+
91
+ async def _get_parser(self, client: httpx.AsyncClient, origin: str) -> RobotFileParser | None:
92
+ if origin in self._parsers:
93
+ return self._parsers[origin]
94
+
95
+ robots_url = f"{origin}/robots.txt"
96
+ try:
97
+ response = await client.get(robots_url)
98
+ if response.status_code != 200:
99
+ self._parsers[origin] = None
100
+ return None
101
+ parser = RobotFileParser()
102
+ parser.parse(response.text.splitlines())
103
+ self.sitemap_urls.extend(parse_robots_sitemaps(response.text))
104
+ self._parsers[origin] = parser
105
+ return parser
106
+ except Exception: # noqa: BLE001
107
+ self._parsers[origin] = None
108
+ return None
109
+
110
+
111
+ async def discover_sitemap_urls(
112
+ client: httpx.AsyncClient,
113
+ root_url: str,
114
+ robots_checker: RobotsChecker,
115
+ ) -> list[str]:
116
+ """Discover page URLs from sitemap.xml and robots.txt Sitemap directives."""
117
+ origin = origin_url(root_url)
118
+ await robots_checker._get_parser(client, origin)
119
+
120
+ candidates = [f"{origin}/sitemap.xml", *robots_checker.sitemap_urls]
121
+ seen_sitemaps: set[str] = set()
122
+ page_urls: list[str] = []
123
+
124
+ for sitemap_url in candidates:
125
+ await _collect_sitemap_urls(client, sitemap_url, seen_sitemaps, page_urls)
126
+
127
+ return list(dict.fromkeys(page_urls))
128
+
129
+
130
+ async def _collect_sitemap_urls(
131
+ client: httpx.AsyncClient,
132
+ sitemap_url: str,
133
+ seen_sitemaps: set[str],
134
+ page_urls: list[str],
135
+ ) -> None:
136
+ if sitemap_url in seen_sitemaps:
137
+ return
138
+ seen_sitemaps.add(sitemap_url)
139
+
140
+ try:
141
+ response = await client.get(sitemap_url)
142
+ if response.status_code != 200:
143
+ return
144
+ locs = parse_sitemap_xml(response.text)
145
+ except Exception: # noqa: BLE001
146
+ return
147
+
148
+ if not locs:
149
+ return
150
+
151
+ if any(loc.endswith(".xml") for loc in locs[:3]):
152
+ for loc in locs:
153
+ await _collect_sitemap_urls(client, loc, seen_sitemaps, page_urls)
154
+ return
155
+
156
+ page_urls.extend(locs)