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 +3 -0
- snapshot/__main__.py +4 -0
- snapshot/cli.py +325 -0
- snapshot/crawl_policy.py +156 -0
- snapshot/downloader.py +483 -0
- snapshot/manifest.py +50 -0
- snapshot/restore.py +134 -0
- snapshot/utils.py +153 -0
- web_snapshot_cli-0.1.0.dist-info/METADATA +204 -0
- web_snapshot_cli-0.1.0.dist-info/RECORD +13 -0
- web_snapshot_cli-0.1.0.dist-info/WHEEL +4 -0
- web_snapshot_cli-0.1.0.dist-info/entry_points.txt +2 -0
- web_snapshot_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
snapshot/downloader.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from markdownify import markdownify
|
|
12
|
+
|
|
13
|
+
from snapshot.crawl_policy import (
|
|
14
|
+
RobotsChecker,
|
|
15
|
+
discover_sitemap_urls,
|
|
16
|
+
url_matches_filters,
|
|
17
|
+
)
|
|
18
|
+
from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
|
|
19
|
+
from snapshot.utils import (
|
|
20
|
+
SRCSET_ATTRS,
|
|
21
|
+
URL_ATTRS,
|
|
22
|
+
normalize_url,
|
|
23
|
+
page_local_path,
|
|
24
|
+
parse_srcset,
|
|
25
|
+
relative_href,
|
|
26
|
+
rewrite_css_urls,
|
|
27
|
+
same_origin,
|
|
28
|
+
url_to_local_path,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SnapshotOptions:
|
|
34
|
+
crawl: bool = False
|
|
35
|
+
max_pages: int = 50
|
|
36
|
+
max_depth: int = 3
|
|
37
|
+
lang: str = "html"
|
|
38
|
+
download_assets: bool = True
|
|
39
|
+
timeout: float = 15.0
|
|
40
|
+
concurrency: int = 16
|
|
41
|
+
same_origin_only: bool = True
|
|
42
|
+
user_agent: str = "web-snapshot-cli/0.1 (+https://github.com/codingsushi79/Snapshot)"
|
|
43
|
+
cookies: list[str] = field(default_factory=list)
|
|
44
|
+
headers: list[str] = field(default_factory=list)
|
|
45
|
+
include_patterns: list[str] = field(default_factory=list)
|
|
46
|
+
exclude_patterns: list[str] = field(default_factory=list)
|
|
47
|
+
respect_robots: bool = True
|
|
48
|
+
crawl_delay: float = 0.0
|
|
49
|
+
use_sitemap: bool = False
|
|
50
|
+
resume: bool = False
|
|
51
|
+
verbose: bool = False
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SnapshotResult:
|
|
57
|
+
pages_saved: int = 0
|
|
58
|
+
assets_saved: int = 0
|
|
59
|
+
pages_skipped: int = 0
|
|
60
|
+
assets_skipped: int = 0
|
|
61
|
+
errors: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SnapshotEngine:
|
|
65
|
+
def __init__(self, root_url: str, output_dir: Path, options: SnapshotOptions):
|
|
66
|
+
self.root_url = normalize_url(root_url) or root_url
|
|
67
|
+
self.output_dir = output_dir.resolve()
|
|
68
|
+
self.options = options
|
|
69
|
+
self._seen_pages: set[str] = set()
|
|
70
|
+
self._queued_pages: set[str] = set()
|
|
71
|
+
self._seen_assets: set[str] = set()
|
|
72
|
+
self._url_to_path: dict[str, Path] = {}
|
|
73
|
+
self._errors: list[str] = []
|
|
74
|
+
self._sem: asyncio.Semaphore | None = None
|
|
75
|
+
self._robots = RobotsChecker(options.user_agent, options.respect_robots)
|
|
76
|
+
self._pages_skipped = 0
|
|
77
|
+
self._assets_skipped = 0
|
|
78
|
+
self._log_fn: Callable[[str], None] | None = None
|
|
79
|
+
|
|
80
|
+
async def run(
|
|
81
|
+
self,
|
|
82
|
+
progress: Callable[[str], None] | None = None,
|
|
83
|
+
log: Callable[[str], None] | None = None,
|
|
84
|
+
) -> SnapshotResult:
|
|
85
|
+
self._log_fn = log
|
|
86
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
self._sem = asyncio.Semaphore(self.options.concurrency)
|
|
88
|
+
self._load_resume_state()
|
|
89
|
+
|
|
90
|
+
client_headers = self._build_headers()
|
|
91
|
+
client_cookies = self._build_cookies()
|
|
92
|
+
|
|
93
|
+
limits = httpx.Limits(max_connections=self.options.concurrency * 2)
|
|
94
|
+
async with httpx.AsyncClient(
|
|
95
|
+
follow_redirects=True,
|
|
96
|
+
timeout=self.options.timeout,
|
|
97
|
+
headers=client_headers,
|
|
98
|
+
cookies=client_cookies,
|
|
99
|
+
limits=limits,
|
|
100
|
+
) as client:
|
|
101
|
+
self._client = client
|
|
102
|
+
if self.options.crawl:
|
|
103
|
+
await self._crawl(progress)
|
|
104
|
+
else:
|
|
105
|
+
await self._snapshot_page(self.root_url, depth=0, progress=progress)
|
|
106
|
+
|
|
107
|
+
if not self.options.dry_run:
|
|
108
|
+
manifest = SnapshotManifest(
|
|
109
|
+
root_url=self.root_url,
|
|
110
|
+
output_dir=str(self.output_dir),
|
|
111
|
+
pages=sorted(self._seen_pages),
|
|
112
|
+
assets=sorted(self._seen_assets),
|
|
113
|
+
options=self._manifest_options(),
|
|
114
|
+
)
|
|
115
|
+
manifest.save(self.output_dir)
|
|
116
|
+
|
|
117
|
+
return SnapshotResult(
|
|
118
|
+
pages_saved=len(self._seen_pages),
|
|
119
|
+
assets_saved=len(self._seen_assets),
|
|
120
|
+
pages_skipped=self._pages_skipped,
|
|
121
|
+
assets_skipped=self._assets_skipped,
|
|
122
|
+
errors=self._errors,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _manifest_options(self) -> dict:
|
|
126
|
+
return {
|
|
127
|
+
"crawl": self.options.crawl,
|
|
128
|
+
"max_pages": self.options.max_pages,
|
|
129
|
+
"max_depth": self.options.max_depth,
|
|
130
|
+
"lang": self.options.lang,
|
|
131
|
+
"download_assets": self.options.download_assets,
|
|
132
|
+
"same_origin_only": self.options.same_origin_only,
|
|
133
|
+
"respect_robots": self.options.respect_robots,
|
|
134
|
+
"crawl_delay": self.options.crawl_delay,
|
|
135
|
+
"use_sitemap": self.options.use_sitemap,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def _build_headers(self) -> dict[str, str]:
|
|
139
|
+
headers = {"User-Agent": self.options.user_agent}
|
|
140
|
+
for item in self.options.headers:
|
|
141
|
+
if ":" not in item:
|
|
142
|
+
continue
|
|
143
|
+
name, value = item.split(":", 1)
|
|
144
|
+
headers[name.strip()] = value.strip()
|
|
145
|
+
return headers
|
|
146
|
+
|
|
147
|
+
def _build_cookies(self) -> dict[str, str]:
|
|
148
|
+
cookies: dict[str, str] = {}
|
|
149
|
+
for item in self.options.cookies:
|
|
150
|
+
if "=" not in item:
|
|
151
|
+
continue
|
|
152
|
+
name, value = item.split("=", 1)
|
|
153
|
+
cookies[name.strip()] = value.strip()
|
|
154
|
+
return cookies
|
|
155
|
+
|
|
156
|
+
def _load_resume_state(self) -> None:
|
|
157
|
+
if not self.options.resume:
|
|
158
|
+
return
|
|
159
|
+
manifest_path = self.output_dir / MANIFEST_NAME
|
|
160
|
+
if not manifest_path.exists():
|
|
161
|
+
self._log("resume: no existing manifest, starting fresh")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
manifest = SnapshotManifest.load(self.output_dir)
|
|
165
|
+
page_ext = ".md" if self.options.lang == "md" else ".html"
|
|
166
|
+
for page_url in manifest.pages:
|
|
167
|
+
local_path = page_local_path(page_url, self.output_dir, lang=self.options.lang)
|
|
168
|
+
self._seen_pages.add(page_url)
|
|
169
|
+
self._url_to_path[page_url] = local_path
|
|
170
|
+
for asset_url in manifest.assets:
|
|
171
|
+
local_path = url_to_local_path(asset_url, self.output_dir, page_ext=page_ext)
|
|
172
|
+
self._seen_assets.add(asset_url)
|
|
173
|
+
self._url_to_path[asset_url] = local_path
|
|
174
|
+
self._log(
|
|
175
|
+
f"resume: loaded {len(manifest.pages)} pages, "
|
|
176
|
+
f"{len(manifest.assets)} assets from manifest"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _log(self, message: str) -> None:
|
|
180
|
+
if self._log_fn:
|
|
181
|
+
self._log_fn(message)
|
|
182
|
+
|
|
183
|
+
def _url_allowed(self, url: str) -> bool:
|
|
184
|
+
if self.options.same_origin_only and not same_origin(self.root_url, url):
|
|
185
|
+
return False
|
|
186
|
+
return url_matches_filters(
|
|
187
|
+
url, self.options.include_patterns, self.options.exclude_patterns
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def _crawl(self, progress: Callable[[str], None] | None) -> None:
|
|
191
|
+
queue: asyncio.Queue[tuple[str, int]] = asyncio.Queue()
|
|
192
|
+
seed_urls = [self.root_url]
|
|
193
|
+
|
|
194
|
+
if self.options.use_sitemap:
|
|
195
|
+
if progress:
|
|
196
|
+
progress("fetching sitemap…")
|
|
197
|
+
sitemap_urls = await discover_sitemap_urls(self._client, self.root_url, self._robots)
|
|
198
|
+
self._log(f"sitemap: discovered {len(sitemap_urls)} URLs")
|
|
199
|
+
seed_urls.extend(sitemap_urls)
|
|
200
|
+
|
|
201
|
+
for url in seed_urls:
|
|
202
|
+
normalized = normalize_url(url)
|
|
203
|
+
if not normalized or not self._url_allowed(normalized):
|
|
204
|
+
continue
|
|
205
|
+
if normalized in self._queued_pages:
|
|
206
|
+
continue
|
|
207
|
+
self._queued_pages.add(normalized)
|
|
208
|
+
await queue.put((normalized, 0))
|
|
209
|
+
|
|
210
|
+
workers = [
|
|
211
|
+
asyncio.create_task(self._crawl_worker(queue, progress))
|
|
212
|
+
for _ in range(min(self.options.concurrency, 8))
|
|
213
|
+
]
|
|
214
|
+
await queue.join()
|
|
215
|
+
for worker in workers:
|
|
216
|
+
worker.cancel()
|
|
217
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
218
|
+
|
|
219
|
+
async def _crawl_worker(
|
|
220
|
+
self,
|
|
221
|
+
queue: asyncio.Queue[tuple[str, int]],
|
|
222
|
+
progress: Callable[[str], None] | None,
|
|
223
|
+
) -> None:
|
|
224
|
+
while True:
|
|
225
|
+
url, depth = await queue.get()
|
|
226
|
+
try:
|
|
227
|
+
if len(self._seen_pages) >= self.options.max_pages:
|
|
228
|
+
continue
|
|
229
|
+
if depth > self.options.max_depth:
|
|
230
|
+
continue
|
|
231
|
+
if not self._url_allowed(url):
|
|
232
|
+
self._log(f"skip (filter): {url}")
|
|
233
|
+
continue
|
|
234
|
+
if not await self._robots.can_fetch(self._client, url):
|
|
235
|
+
self._log(f"skip (robots.txt): {url}")
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if url in self._seen_pages and self._page_exists(url):
|
|
239
|
+
self._pages_skipped += 1
|
|
240
|
+
self._log(f"skip (resume): {url}")
|
|
241
|
+
links = self._links_from_saved_page(url)
|
|
242
|
+
else:
|
|
243
|
+
links = await self._snapshot_page(url, depth=depth, progress=progress)
|
|
244
|
+
|
|
245
|
+
if depth < self.options.max_depth and links:
|
|
246
|
+
for link in links:
|
|
247
|
+
if len(self._seen_pages) >= self.options.max_pages:
|
|
248
|
+
break
|
|
249
|
+
if link in self._queued_pages:
|
|
250
|
+
continue
|
|
251
|
+
if not self._url_allowed(link):
|
|
252
|
+
continue
|
|
253
|
+
self._queued_pages.add(link)
|
|
254
|
+
await queue.put((link, depth + 1))
|
|
255
|
+
finally:
|
|
256
|
+
queue.task_done()
|
|
257
|
+
|
|
258
|
+
def _page_exists(self, url: str) -> bool:
|
|
259
|
+
local_path = self._url_to_path.get(url)
|
|
260
|
+
if local_path is None:
|
|
261
|
+
local_path = page_local_path(url, self.output_dir, lang=self.options.lang)
|
|
262
|
+
return local_path.exists()
|
|
263
|
+
|
|
264
|
+
def _links_from_saved_page(self, url: str) -> list[str]:
|
|
265
|
+
local_path = self._url_to_path.get(url) or page_local_path(
|
|
266
|
+
url, self.output_dir, lang=self.options.lang
|
|
267
|
+
)
|
|
268
|
+
if not local_path.exists():
|
|
269
|
+
return []
|
|
270
|
+
try:
|
|
271
|
+
html = local_path.read_text(encoding="utf-8")
|
|
272
|
+
except OSError:
|
|
273
|
+
return []
|
|
274
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
275
|
+
return self._collect_page_links(soup, url)
|
|
276
|
+
|
|
277
|
+
async def _snapshot_page(
|
|
278
|
+
self,
|
|
279
|
+
url: str,
|
|
280
|
+
depth: int,
|
|
281
|
+
progress: Callable[[str], None] | None,
|
|
282
|
+
) -> list[str]:
|
|
283
|
+
if url in self._seen_pages and self._page_exists(url):
|
|
284
|
+
self._pages_skipped += 1
|
|
285
|
+
return self._links_from_saved_page(url)
|
|
286
|
+
|
|
287
|
+
if not self._url_allowed(url):
|
|
288
|
+
return []
|
|
289
|
+
if not await self._robots.can_fetch(self._client, url):
|
|
290
|
+
self._log(f"skip (robots.txt): {url}")
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
if progress:
|
|
294
|
+
progress(f"page {url}")
|
|
295
|
+
self._log(f"fetch: {url}")
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
response = await self._fetch(url)
|
|
299
|
+
except Exception as exc: # noqa: BLE001
|
|
300
|
+
self._errors.append(f"{url}: {exc}")
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
self._seen_pages.add(url)
|
|
304
|
+
|
|
305
|
+
content_type = response.headers.get("content-type", "")
|
|
306
|
+
local_path = page_local_path(url, self.output_dir, lang=self.options.lang)
|
|
307
|
+
self._url_to_path[url] = local_path
|
|
308
|
+
|
|
309
|
+
if "text/html" not in content_type and not url.endswith((".html", ".htm")):
|
|
310
|
+
if not self.options.dry_run:
|
|
311
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
local_path.write_bytes(response.content)
|
|
313
|
+
else:
|
|
314
|
+
self._log(f"dry-run: would save {url} → {local_path}")
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
318
|
+
discovered = self._collect_page_links(soup, url)
|
|
319
|
+
|
|
320
|
+
asset_urls = self._collect_asset_urls(soup, url)
|
|
321
|
+
if self.options.download_assets:
|
|
322
|
+
await asyncio.gather(*(self._download_asset(u) for u in asset_urls))
|
|
323
|
+
|
|
324
|
+
if not self.options.dry_run:
|
|
325
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
self._rewrite_html(soup, url, local_path)
|
|
327
|
+
body = self._render_page(soup)
|
|
328
|
+
local_path.write_text(body, encoding="utf-8")
|
|
329
|
+
else:
|
|
330
|
+
self._log(f"dry-run: would save page {url} → {local_path}")
|
|
331
|
+
|
|
332
|
+
return discovered
|
|
333
|
+
|
|
334
|
+
def _collect_asset_urls(self, soup: BeautifulSoup, page_url: str) -> set[str]:
|
|
335
|
+
urls: set[str] = set()
|
|
336
|
+
for tag in soup.find_all(True):
|
|
337
|
+
for attr in URL_ATTRS:
|
|
338
|
+
value = tag.get(attr)
|
|
339
|
+
if value:
|
|
340
|
+
absolute = normalize_url(value, page_url)
|
|
341
|
+
if absolute and not self._is_page_url(absolute):
|
|
342
|
+
urls.add(absolute)
|
|
343
|
+
for attr in SRCSET_ATTRS:
|
|
344
|
+
value = tag.get(attr)
|
|
345
|
+
if value:
|
|
346
|
+
for piece in parse_srcset(value):
|
|
347
|
+
absolute = normalize_url(piece, page_url)
|
|
348
|
+
if absolute and not self._is_page_url(absolute):
|
|
349
|
+
urls.add(absolute)
|
|
350
|
+
for style in soup.find_all("style"):
|
|
351
|
+
if style.string:
|
|
352
|
+
for match in re.findall(r"url\(([^)]+)\)", style.string):
|
|
353
|
+
absolute = normalize_url(match.strip("'\""), page_url)
|
|
354
|
+
if absolute and not self._is_page_url(absolute):
|
|
355
|
+
urls.add(absolute)
|
|
356
|
+
return urls
|
|
357
|
+
|
|
358
|
+
def _collect_page_links(self, soup: BeautifulSoup, page_url: str) -> list[str]:
|
|
359
|
+
links: list[str] = []
|
|
360
|
+
for tag in soup.find_all("a", href=True):
|
|
361
|
+
absolute = normalize_url(tag["href"], page_url)
|
|
362
|
+
if absolute and self._is_page_url(absolute):
|
|
363
|
+
links.append(absolute)
|
|
364
|
+
return links
|
|
365
|
+
|
|
366
|
+
def _is_page_url(self, url: str) -> bool:
|
|
367
|
+
from snapshot.utils import _looks_like_page
|
|
368
|
+
|
|
369
|
+
return _looks_like_page(url)
|
|
370
|
+
|
|
371
|
+
async def _download_asset(self, url: str) -> None:
|
|
372
|
+
if url in self._seen_assets:
|
|
373
|
+
return
|
|
374
|
+
self._seen_assets.add(url)
|
|
375
|
+
local_path = url_to_local_path(url, self.output_dir)
|
|
376
|
+
self._url_to_path[url] = local_path
|
|
377
|
+
if local_path.exists():
|
|
378
|
+
self._assets_skipped += 1
|
|
379
|
+
self._log(f"skip (exists): {url}")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
self._log(f"asset: {url}")
|
|
383
|
+
try:
|
|
384
|
+
response = await self._fetch(url)
|
|
385
|
+
if self.options.dry_run:
|
|
386
|
+
self._log(f"dry-run: would save asset {url} → {local_path}")
|
|
387
|
+
return
|
|
388
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
if url.endswith(".css") or "text/css" in response.headers.get("content-type", ""):
|
|
390
|
+
css = response.text
|
|
391
|
+
css = rewrite_css_urls(
|
|
392
|
+
css,
|
|
393
|
+
url,
|
|
394
|
+
lambda asset_url: self._css_mapper(asset_url, local_path),
|
|
395
|
+
)
|
|
396
|
+
local_path.write_text(css, encoding="utf-8")
|
|
397
|
+
nested = self._extract_css_urls(css, url)
|
|
398
|
+
await asyncio.gather(*(self._download_asset(u) for u in nested))
|
|
399
|
+
else:
|
|
400
|
+
local_path.write_bytes(response.content)
|
|
401
|
+
except Exception as exc: # noqa: BLE001
|
|
402
|
+
self._errors.append(f"{url}: {exc}")
|
|
403
|
+
|
|
404
|
+
def _css_mapper(self, asset_url: str, css_path: Path) -> str | None:
|
|
405
|
+
target = self._url_to_path.get(asset_url)
|
|
406
|
+
if target is None:
|
|
407
|
+
target = url_to_local_path(asset_url, self.output_dir)
|
|
408
|
+
self._url_to_path[asset_url] = target
|
|
409
|
+
return relative_href(css_path, target)
|
|
410
|
+
|
|
411
|
+
def _extract_css_urls(self, css: str, base_url: str) -> set[str]:
|
|
412
|
+
urls: set[str] = set()
|
|
413
|
+
for match in re.findall(r"url\(([^)]+)\)", css):
|
|
414
|
+
absolute = normalize_url(match.strip("'\""), base_url)
|
|
415
|
+
if absolute:
|
|
416
|
+
urls.add(absolute)
|
|
417
|
+
return urls
|
|
418
|
+
|
|
419
|
+
def _rewrite_html(self, soup: BeautifulSoup, page_url: str, page_path: Path) -> None:
|
|
420
|
+
for tag in soup.find_all(True):
|
|
421
|
+
for attr in URL_ATTRS:
|
|
422
|
+
value = tag.get(attr)
|
|
423
|
+
if not value:
|
|
424
|
+
continue
|
|
425
|
+
absolute = normalize_url(value, page_url)
|
|
426
|
+
if not absolute:
|
|
427
|
+
continue
|
|
428
|
+
if absolute not in self._url_to_path and not same_origin(self.root_url, absolute):
|
|
429
|
+
continue
|
|
430
|
+
target = self._url_to_path.get(absolute)
|
|
431
|
+
if target is None:
|
|
432
|
+
target = (
|
|
433
|
+
page_local_path(absolute, self.output_dir, lang=self.options.lang)
|
|
434
|
+
if self._is_page_url(absolute)
|
|
435
|
+
else url_to_local_path(absolute, self.output_dir)
|
|
436
|
+
)
|
|
437
|
+
tag[attr] = relative_href(page_path, target)
|
|
438
|
+
for attr in SRCSET_ATTRS:
|
|
439
|
+
value = tag.get(attr)
|
|
440
|
+
if not value:
|
|
441
|
+
continue
|
|
442
|
+
parts = []
|
|
443
|
+
for piece in value.split(","):
|
|
444
|
+
chunk = piece.strip().split()
|
|
445
|
+
if not chunk:
|
|
446
|
+
continue
|
|
447
|
+
absolute = normalize_url(chunk[0], page_url)
|
|
448
|
+
if absolute and absolute in self._url_to_path:
|
|
449
|
+
local = relative_href(page_path, self._url_to_path[absolute])
|
|
450
|
+
rest = " ".join(chunk[1:])
|
|
451
|
+
parts.append(f"{local} {rest}".strip())
|
|
452
|
+
else:
|
|
453
|
+
parts.append(piece.strip())
|
|
454
|
+
if parts:
|
|
455
|
+
tag[attr] = ", ".join(parts)
|
|
456
|
+
|
|
457
|
+
base_tag = soup.find("base")
|
|
458
|
+
if base_tag:
|
|
459
|
+
base_tag.decompose()
|
|
460
|
+
|
|
461
|
+
def _render_page(self, soup: BeautifulSoup) -> str:
|
|
462
|
+
if self.options.lang == "md":
|
|
463
|
+
return self._html_to_markdown(soup)
|
|
464
|
+
return str(soup)
|
|
465
|
+
|
|
466
|
+
def _html_to_markdown(self, soup: BeautifulSoup) -> str:
|
|
467
|
+
title = soup.title.get_text(strip=True) if soup.title else "Untitled"
|
|
468
|
+
body = soup.body or soup
|
|
469
|
+
markdown = markdownify(
|
|
470
|
+
str(body),
|
|
471
|
+
heading_style="ATX",
|
|
472
|
+
bullets="-",
|
|
473
|
+
strip=["script", "style", "noscript"],
|
|
474
|
+
).strip()
|
|
475
|
+
return f"# {title}\n\n{markdown}\n"
|
|
476
|
+
|
|
477
|
+
async def _fetch(self, url: str) -> httpx.Response:
|
|
478
|
+
async with self._sem:
|
|
479
|
+
response = await self._client.get(url)
|
|
480
|
+
response.raise_for_status()
|
|
481
|
+
if self.options.crawl_delay > 0:
|
|
482
|
+
await asyncio.sleep(self.options.crawl_delay)
|
|
483
|
+
return response
|
snapshot/manifest.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
MANIFEST_NAME = ".snapshot.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SnapshotManifest:
|
|
14
|
+
version: str = "1"
|
|
15
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
16
|
+
root_url: str = ""
|
|
17
|
+
output_dir: str = ""
|
|
18
|
+
pages: list[str] = field(default_factory=list)
|
|
19
|
+
assets: list[str] = field(default_factory=list)
|
|
20
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def save(self, output_dir: Path) -> Path:
|
|
23
|
+
path = output_dir / MANIFEST_NAME
|
|
24
|
+
path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def load(cls, snapshot_dir: Path) -> SnapshotManifest:
|
|
29
|
+
path = snapshot_dir / MANIFEST_NAME
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
f"No snapshot manifest found at {path}. "
|
|
33
|
+
"Make sure this directory was created by snapshot."
|
|
34
|
+
)
|
|
35
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
+
return cls(**data)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def find_root(cls, snapshot_dir: Path) -> Path:
|
|
40
|
+
"""Return the directory that contains the manifest (may be nested)."""
|
|
41
|
+
candidate = snapshot_dir.resolve()
|
|
42
|
+
if (candidate / MANIFEST_NAME).exists():
|
|
43
|
+
return candidate
|
|
44
|
+
for child in candidate.iterdir():
|
|
45
|
+
if child.is_dir() and (child / MANIFEST_NAME).exists():
|
|
46
|
+
return child
|
|
47
|
+
raise FileNotFoundError(
|
|
48
|
+
f"No {MANIFEST_NAME} found in {snapshot_dir}. "
|
|
49
|
+
"Pass the folder that contains your snapshot files."
|
|
50
|
+
)
|
snapshot/restore.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import http.server
|
|
5
|
+
import mimetypes
|
|
6
|
+
import socketserver
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from snapshot.manifest import SnapshotManifest
|
|
12
|
+
from snapshot.utils import page_local_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def serve_snapshot(
|
|
16
|
+
snapshot_dir: Path, host: str = "127.0.0.1", port: int = 8080, open_browser: bool = True
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Serve a snapshot directory over HTTP."""
|
|
19
|
+
root = SnapshotManifest.find_root(snapshot_dir)
|
|
20
|
+
manifest = SnapshotManifest.load(root)
|
|
21
|
+
content_root = _content_root(root, manifest)
|
|
22
|
+
|
|
23
|
+
handler = functools.partial(
|
|
24
|
+
_SnapshotHandler,
|
|
25
|
+
directory=str(content_root),
|
|
26
|
+
manifest=manifest,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with _FindServer(host, port, handler) as httpd:
|
|
30
|
+
actual_port = httpd.server_address[1]
|
|
31
|
+
url = f"http://{host}:{actual_port}/"
|
|
32
|
+
print(f"snapshot restore: serving {content_root}")
|
|
33
|
+
print(f" root URL : {manifest.root_url}")
|
|
34
|
+
print(f" local URL: {url}")
|
|
35
|
+
print(" Press Ctrl+C to stop.")
|
|
36
|
+
if open_browser:
|
|
37
|
+
webbrowser.open(url)
|
|
38
|
+
try:
|
|
39
|
+
httpd.serve_forever()
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
print("\nStopped.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _content_root(root: Path, manifest: SnapshotManifest) -> Path:
|
|
45
|
+
"""Pick the host directory inside the snapshot output."""
|
|
46
|
+
parsed = urlparse(manifest.root_url)
|
|
47
|
+
host_name = parsed.netloc.replace(":", "_")
|
|
48
|
+
for candidate in root.iterdir():
|
|
49
|
+
if candidate.is_dir() and host_name in candidate.name:
|
|
50
|
+
return candidate
|
|
51
|
+
children = [p for p in root.iterdir() if p.is_dir() and p.name != "__pycache__"]
|
|
52
|
+
if len(children) == 1:
|
|
53
|
+
return children[0]
|
|
54
|
+
return root
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _SnapshotHandler(http.server.SimpleHTTPRequestHandler):
|
|
58
|
+
manifest: SnapshotManifest
|
|
59
|
+
|
|
60
|
+
def __init__(self, *args, directory: str, manifest: SnapshotManifest, **kwargs):
|
|
61
|
+
self.manifest = manifest
|
|
62
|
+
super().__init__(*args, directory=directory, **kwargs)
|
|
63
|
+
|
|
64
|
+
def do_GET(self) -> None: # noqa: N802
|
|
65
|
+
path = self.path.split("?", 1)[0]
|
|
66
|
+
resolved = self._resolve_path(path)
|
|
67
|
+
if resolved is not None:
|
|
68
|
+
self.path = "/" + resolved
|
|
69
|
+
return super().do_GET()
|
|
70
|
+
|
|
71
|
+
def _resolve_path(self, path: str) -> str | None:
|
|
72
|
+
root = Path(self.directory)
|
|
73
|
+
lang = self.manifest.options.get("lang", "html")
|
|
74
|
+
ext = ".md" if lang == "md" else ".html"
|
|
75
|
+
|
|
76
|
+
if path in {"/", ""}:
|
|
77
|
+
local = page_local_path(self.manifest.root_url, root, lang=lang)
|
|
78
|
+
if local.exists():
|
|
79
|
+
return local.relative_to(root).as_posix()
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
clean = path.strip("/")
|
|
83
|
+
if not clean:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
direct = root / clean
|
|
87
|
+
if direct.is_file():
|
|
88
|
+
return clean
|
|
89
|
+
|
|
90
|
+
index = direct / f"index{ext}"
|
|
91
|
+
if index.is_file():
|
|
92
|
+
return index.relative_to(root).as_posix()
|
|
93
|
+
|
|
94
|
+
with_ext = root / f"{clean}{ext}"
|
|
95
|
+
if with_ext.is_file():
|
|
96
|
+
return with_ext.relative_to(root).as_posix()
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def log_message(self, format: str, *args) -> None: # noqa: A003
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _FindServer:
|
|
105
|
+
def __init__(self, host: str, port: int, handler):
|
|
106
|
+
self.host = host
|
|
107
|
+
self.port = port
|
|
108
|
+
self.handler = handler
|
|
109
|
+
self.httpd = None
|
|
110
|
+
|
|
111
|
+
def __enter__(self):
|
|
112
|
+
for attempt in range(20):
|
|
113
|
+
try:
|
|
114
|
+
self.httpd = socketserver.TCPServer(
|
|
115
|
+
(self.host, self.port + attempt),
|
|
116
|
+
self.handler,
|
|
117
|
+
)
|
|
118
|
+
self.httpd.allow_reuse_address = True
|
|
119
|
+
return self.httpd
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
raise OSError(f"Could not bind to {self.host}:{self.port}+")
|
|
123
|
+
|
|
124
|
+
def __exit__(self, exc_type, exc, tb):
|
|
125
|
+
if self.httpd:
|
|
126
|
+
self.httpd.server_close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Ensure common web types are registered.
|
|
130
|
+
mimetypes.add_type("application/javascript", ".js")
|
|
131
|
+
mimetypes.add_type("application/javascript", ".mjs")
|
|
132
|
+
mimetypes.add_type("text/css", ".css")
|
|
133
|
+
mimetypes.add_type("image/webp", ".webp")
|
|
134
|
+
mimetypes.add_type("font/woff2", ".woff2")
|