Coverage for src/lilbee/crawler/save.py: 100%

270 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Page addresses and saved names, and the per-page save to disk under the crawl lock.""" 

2 

3from __future__ import annotations 

4 

5import hashlib 

6import ipaddress 

7import logging 

8import os 

9import re 

10import string 

11import tempfile 

12import unicodedata 

13from collections.abc import Callable 

14from dataclasses import dataclass, field 

15from datetime import UTC, datetime 

16from pathlib import Path, PurePosixPath 

17from urllib.parse import unquote 

18 

19from lilbee.core.config import cfg 

20from lilbee.core.security import validate_path_within 

21from lilbee.core.web_names import INDEX_STEM, PAGE_HASH_MARK, PAGE_SUFFIX, WEB_DIR 

22from lilbee.crawler.models import CrawlResult 

23from lilbee.crawler.url_filter import parsed_address, parsed_url 

24from lilbee.data.store import CrawlPage, CrawlPages, Store 

25 

26log = logging.getLogger(__name__) 

27 

28_NOT_SAVED = "Page %s is not saved: %s holds a file or folder of its own; move or delete it" 

29 

30# The characters RFC 3986 section 2.3 calls unreserved: an escape of one names the character. 

31_UNRESERVED = frozenset(string.ascii_letters + string.digits + "-._~") 

32_PERCENT_ESCAPE = re.compile(r"%([0-9A-Fa-f]{2})") 

33_MALFORMED_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") 

34_HEX = 16 

35_QUERY_MARK = "?" 

36_QUERY_SEPARATOR = "&" 

37_QUERY_VALUE_MARK = "=" 

38# Hex digits of the sha256 of a page's canonical address in its file name: 64 bits. 

39_HASH_HEX_DIGITS = 16 

40_MAX_SLUG_BYTES = 80 

41_MAX_PATH_BYTES = 200 

42# The bytes a readable name may take: the path less a full host folder, "/", "~", hash and suffix. 

43_MAX_NAME_BYTES = ( 

44 _MAX_PATH_BYTES 

45 - _MAX_SLUG_BYTES 

46 - len("/") 

47 - len(PAGE_HASH_MARK) 

48 - _HASH_HEX_DIGITS 

49 - len(PAGE_SUFFIX) 

50) 

51# A slug keeps letters, digits and these; every other run of characters becomes one fill. 

52_WORD_BREAKS = frozenset("-_.") 

53_SLUG_KEPT = _WORD_BREAKS | {"@"} 

54_SLUG_FILL = "-" 

55_SLUG_FILL_RUN = re.compile(f"{_SLUG_FILL}{{2,}}") 

56_SLUG_ENDS = ".- " 

57_EMPTY_SLUG = "_" 

58_DEVICE_MARK = "_" 

59_EXTENSION_MARK = "." 

60_PORT_MARK = ":" 

61# An IPv6 host is written in brackets, its groups apart by colons. 

62_IPV6_OPEN = "[" 

63_IPV6_CLOSE = "]" 

64_IPV6_GROUP_MARK = ":" 

65_UNKNOWN_HOST = "unknown" 

66# Names Windows gives to devices, with or without an extension, in any letter case. 

67_DEVICE_NAMES = frozenset( 

68 {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} 

69 | {f"{port}{n}" for port in ("COM", "LPT") for n in [*string.digits, "¹", "²", "³"]} 

70) 

71 

72 

73def _normalized_escape(escape: re.Match[str]) -> str: 

74 """The unreserved character *escape* names, or *escape* with upper-case hex digits.""" 

75 char = chr(int(escape.group(1), _HEX)) 

76 return char if char in _UNRESERVED else escape.group().upper() 

77 

78 

79def _parameter_name(parameter: str) -> str: 

80 """The name of one query parameter: the text before its first ``=``.""" 

81 return parameter.partition(_QUERY_VALUE_MARK)[0] 

82 

83 

84def _sorted_query(address: str) -> str: 

85 """*address* with its query parameters sorted by name, stable, each written as it was.""" 

86 before, mark, query = address.partition(_QUERY_MARK) 

87 if not mark: 

88 return address 

89 parameters = sorted(query.split(_QUERY_SEPARATOR), key=_parameter_name) 

90 return f"{before}{_QUERY_MARK}{_QUERY_SEPARATOR.join(parameters)}" 

91 

92 

93def canonical_address(url: str) -> str: 

94 """The key crawlberg 1.12.3's ``normalize_url`` gives the page at *url*, or *url* unparsed. 

95 

96 An address with a malformed escape keeps all its escapes as written: decoding the digits 

97 after a bad ``%`` could make a valid escape the next time the key is made. 

98 """ 

99 address = parsed_address(url) 

100 if address is None: 

101 return url 

102 if _MALFORMED_ESCAPE.search(address): 

103 return _sorted_query(address) 

104 return _sorted_query(_PERCENT_ESCAPE.sub(_normalized_escape, address)) 

105 

106 

107def _cut(text: str, limit: int) -> str: 

108 """*text* cut to at most *limit* UTF-8 bytes, on a character boundary.""" 

109 return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore") 

110 

111 

112def _filled(part: str) -> str: 

113 """One escaped part of an address, decoded, NFC, with every other run of characters as ``-``.""" 

114 text = unicodedata.normalize("NFC", unquote(part, errors="replace")) 

115 kept = "".join(c if c.isalnum() or c in _SLUG_KEPT else _SLUG_FILL for c in text) 

116 return _SLUG_FILL_RUN.sub(_SLUG_FILL, kept) 

117 

118 

119def _legal(text: str) -> str: 

120 """*text* with legal ends, never empty, and a mark before a Windows device name.""" 

121 slug = text.strip(_SLUG_ENDS) or _EMPTY_SLUG 

122 if slug.split(_EXTENSION_MARK)[0].upper() in _DEVICE_NAMES: 

123 return _DEVICE_MARK + slug 

124 return slug 

125 

126 

127def _slug(part: str) -> str: 

128 """A legal, readable file name for one escaped part of an address; lossy by design.""" 

129 return _legal(_cut(_filled(part), _MAX_SLUG_BYTES)) 

130 

131 

132def _without_suffix(text: str) -> str: 

133 """*text* without its last ``.`` and the text after it, when text stands on both sides.""" 

134 stem, mark, suffix = text.rpartition(_EXTENSION_MARK) 

135 return stem if stem and mark and suffix else text 

136 

137 

138def _cut_at_word(text: str, limit: int) -> str: 

139 """*text* cut to at most *limit* UTF-8 bytes, at the last separator when a word would break.""" 

140 head = _cut(text, limit) 

141 if len(head) == len(text) or text[len(head)] in _WORD_BREAKS: 

142 return head 

143 end = max(head.rfind(c) for c in _WORD_BREAKS) 

144 return head[:end] if end > 0 else head 

145 

146 

147def _page_slug(segment: str) -> str: 

148 """The readable name of the page whose last path segment is *segment*, without its suffix.""" 

149 marked = _legal(_without_suffix(_filled(segment))) 

150 return _legal(_cut_at_word(marked, _MAX_NAME_BYTES)) 

151 

152 

153def _folder_slug(part: str) -> str: 

154 """The slug of a folder; one ending in the page suffix ends in ``-md``, never a file's name.""" 

155 slug = _slug(part) 

156 if not slug.lower().endswith(PAGE_SUFFIX): 

157 return slug 

158 mark = len(slug) - len(PAGE_SUFFIX) 

159 return f"{slug[:mark]}{_SLUG_FILL}{slug[mark + 1 :]}" 

160 

161 

162def _readable_host(hostname: str) -> str: 

163 """*hostname* in Unicode, or an IPv6 address as its eight groups joined by ``-``.""" 

164 import ada_url 

165 

166 if not hostname.startswith(_IPV6_OPEN): 

167 return ada_url.idna_to_unicode(hostname) 

168 address = ipaddress.IPv6Address(hostname.removeprefix(_IPV6_OPEN).removesuffix(_IPV6_CLOSE)) 

169 groups = address.exploded.split(_IPV6_GROUP_MARK) 

170 return _SLUG_FILL.join(f"{int(group, _HEX):x}" for group in groups) 

171 

172 

173def _address_parts(address: str) -> tuple[str, list[str]]: 

174 """The host, readable and with any port, and the path segments of *address*.""" 

175 parsed = parsed_url(address) 

176 if parsed is None: 

177 return _UNKNOWN_HOST, [""] 

178 host = _readable_host(parsed.hostname) 

179 if parsed.port: 

180 host = f"{host}{_PORT_MARK}{parsed.port}" 

181 return host, parsed.pathname.removeprefix("/").split("/") 

182 

183 

184def page_name(address: str) -> str: 

185 """The path under ``_web`` of canonical *address*: readable folders and name, ``~``, hash. 

186 

187 The name comes from the last non-empty segment of the path, less its suffix. 

188 """ 

189 host, segments = _address_parts(address) 

190 named = [segment for segment in segments if segment] 

191 *folders, last = named or [""] 

192 digest = hashlib.sha256(address.encode("utf-8")).hexdigest()[:_HASH_HEX_DIGITS] 

193 readable = _page_slug(last) if last else INDEX_STEM 

194 file_name = f"{readable}{PAGE_HASH_MARK}{digest}{PAGE_SUFFIX}" 

195 head = [_slug(host), *map(_folder_slug, folders)] 

196 while len("/".join([*head, file_name]).encode("utf-8")) > _MAX_PATH_BYTES: 

197 head.pop() 

198 return "/".join([*head, file_name]) 

199 

200 

201def url_to_filename(url: str) -> str: 

202 """The path under ``_web`` that the page at *url* is saved under.""" 

203 return page_name(canonical_address(url)) 

204 

205 

206def web_dir() -> Path: 

207 """The folder under the documents directory that holds every crawled page.""" 

208 return cfg.documents_dir / WEB_DIR 

209 

210 

211def _replace_atomically(path: Path, text: str) -> None: 

212 """Write *text* to a temporary file beside *path*, sync it, then put it in place of *path*.""" 

213 tmp_name: str | None = None 

214 try: 

215 with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".tmp", delete=False) as tmp: 

216 tmp_name = tmp.name 

217 tmp.write(text.encode("utf-8")) 

218 tmp.flush() 

219 os.fsync(tmp.fileno()) 

220 Path(tmp_name).replace(path) 

221 except BaseException: 

222 if tmp_name is not None: 

223 Path(tmp_name).unlink(missing_ok=True) 

224 raise 

225 

226 

227def content_hash(text: str) -> str: 

228 """SHA-256 hex digest of text content.""" 

229 return hashlib.sha256(text.encode()).hexdigest() 

230 

231 

232# Reference-style nested-bracket links, e.g. Wikipedia footnote markers like 

233# ``[[1]](https://en.wikipedia.org/wiki/Foo#cite_note-1)``. The inner brackets 

234# make this a normal Markdown link with the text ``[1]``, but readers that 

235# treat ``[[...]]`` as a wikilink (Obsidian) mis-parse it as a broken wikilink 

236# followed by the literal URL. 

237_REFERENCE_LINK_RE = re.compile(r"\[\[([^\]]*)\]\]\(([^)]*)\)") 

238# The same link with its inner brackets escaped: ``[\[1\]](url)``. 

239_ESCAPED_REFERENCE_LINK = r"[\\[\1\\]](\2)" 

240 

241 

242def normalize_crawled_markdown(markdown: str) -> str: 

243 """Give the page LF line ends and escape the inner brackets of ``[[N]](url)`` links. 

244 

245 LF line ends keep the saved bytes equal to the text a universal-newline read 

246 gives back. ``[[N]](url)`` becomes ``[\\[N\\]](url)``: no ``[[`` is left for a 

247 wikilink reader, and the link text still reads ``[N]``, so "word[1]" does not 

248 index as "word1". Ordinary single-bracket links stay. 

249 """ 

250 lf = markdown.replace("\r\n", "\n").replace("\r", "\n") 

251 return _REFERENCE_LINK_RE.sub(_ESCAPED_REFERENCE_LINK, lf) 

252 

253 

254@dataclass 

255class CrawlRecord: 

256 """The crawl page table one crawl saves into, and the files it warned about.""" 

257 

258 pages: CrawlPages 

259 # Files standing where a folder of a page belongs, each warned about once per crawl. 

260 blocked: set[Path] = field(default_factory=set) 

261 

262 

263def load_crawl_record(store: Store) -> CrawlRecord: 

264 """The crawl page table of *store*, each earlier copy keyed by its canonical address.""" 

265 pages = store.crawl_pages 

266 with pages.held(): 

267 pages.rekey(canonical_address, _holds_text) 

268 return CrawlRecord(pages) 

269 

270 

271def finish_crawl(record: CrawlRecord) -> None: 

272 """Fold the commits the crawl's saves made into one version of the table.""" 

273 with record.pages.held(): 

274 record.pages.compact() 

275 

276 

277def _same_file(one: str, other: str) -> bool: 

278 """Whether two names under ``_web`` are one file as the disk resolves them.""" 

279 try: 

280 return os.path.samefile(web_dir() / one, web_dir() / other) 

281 except OSError: 

282 return False 

283 

284 

285def _holds_text(copy: CrawlPage) -> bool: 

286 """Whether the file of the earlier *copy* is under ``_web`` and holds the copy's text.""" 

287 path = web_dir() / copy.file 

288 try: 

289 validate_path_within(path, web_dir().resolve()) 

290 except ValueError: 

291 return False 

292 if not path.is_file(): 

293 return False 

294 return content_hash(path.read_text(encoding="utf-8", errors="replace")) == copy.content_hash 

295 

296 

297def _needed_by_twin(copy: CrawlPage, pages: CrawlPages) -> bool: 

298 """Whether another page, not yet saved at its own name, has the file of *copy* as its copy.""" 

299 return any( 

300 twin.address != copy.address 

301 and pages.current(twin.address) is None 

302 and _same_file(twin.file, copy.file) 

303 for twin in pages.copies_of(copy.content_hash) 

304 ) 

305 

306 

307def _remove_empty_folders(folder: Path) -> None: 

308 """Remove *folder* and each parent that is left empty, up to ``_web``.""" 

309 root = web_dir() 

310 while folder != root and folder.is_relative_to(root) and not any(folder.iterdir()): 

311 folder.rmdir() 

312 folder = folder.parent 

313 

314 

315def _disk_name(name: str) -> str: 

316 """*name* as the disk spells it, which a case-folding disk may spell differently.""" 

317 folder, parts = web_dir(), [] 

318 for part in PurePosixPath(name).parts: 

319 target = folder / part 

320 found = (e.name for e in os.scandir(folder) if os.path.samefile(e.path, target)) 

321 parts.append(next(found, part)) 

322 folder = folder / parts[-1] 

323 return "/".join(parts) 

324 

325 

326def _retire_earlier( 

327 address: str, pages: CrawlPages, remove_sources: Callable[[list[str]], object] 

328) -> None: 

329 """Delete each earlier copy of the page at *address* that holds its text and no other's. 

330 

331 The caller holds the crawl lock. The file goes before its index source, so a sync 

332 flush that runs between the two finds no file and adds no source. The row of a copy 

333 goes with its file, or at once when its file is gone, changed, or another page's own. 

334 """ 

335 for copy in pages.earlier(address): 

336 if pages.owners(copy.file) or not _holds_text(copy): 

337 pages.forget(copy) 

338 continue 

339 if _needed_by_twin(copy, pages): 

340 continue 

341 source = PurePosixPath(WEB_DIR, _disk_name(copy.file)).as_posix() 

342 (web_dir() / copy.file).unlink() 

343 remove_sources([source]) 

344 _remove_empty_folders((web_dir() / copy.file).parent) 

345 pages.forget(copy) 

346 

347 

348def _blocking_file(path: Path) -> Path | None: 

349 """The file that stands where a folder of *path* under ``_web`` belongs, or None.""" 

350 root = web_dir() 

351 return next( 

352 ( 

353 folder 

354 for folder in path.parents 

355 if folder.is_relative_to(root) and os.path.lexists(folder) and not folder.is_dir() 

356 ), 

357 None, 

358 ) 

359 

360 

361def _folders_are_free(path: Path, record: CrawlRecord) -> bool: 

362 """Whether the folders of *path* can be made; a file in their way is warned about once.""" 

363 blocker = _blocking_file(path) 

364 if blocker is None: 

365 return True 

366 if blocker not in record.blocked: 

367 record.blocked.add(blocker) 

368 log.warning("Pages under %s are not saved: it is a file; move or delete it", blocker) 

369 return False 

370 

371 

372def _page_path(result: CrawlResult, address: str, record: CrawlRecord) -> Path | None: 

373 """Where the page at *address* is saved, or None when its name is not free for it.""" 

374 root = web_dir() 

375 path = root / page_name(address) 

376 try: 

377 validate_path_within(path, root.resolve()) 

378 except ValueError: 

379 log.warning("Path traversal blocked: %s -> %s", result.url, path) 

380 return None 

381 if not _folders_are_free(path, record): 

382 return None 

383 if os.path.lexists(path) and (path.is_symlink() or not path.is_file()): 

384 log.warning(_NOT_SAVED, result.url, path) 

385 return None 

386 return path 

387 

388 

389def _held_text(path: Path) -> str | None: 

390 """The hash of the text the file at *path* holds, or None when there is no file. 

391 

392 The file is read with universal newlines: an older lilbee wrote pages in text 

393 mode, so on Windows a page it saved holds CRLF line ends its record never hashed. 

394 """ 

395 if not path.is_file(): 

396 return None 

397 return content_hash(path.read_text(encoding="utf-8", errors="replace")) 

398 

399 

400def _may_write(prev: CrawlPage | None, held: str | None, new_hash: str) -> bool: 

401 """Whether the file at the page's name holds nothing, the new text, or a text it claimed.""" 

402 owned = {new_hash} if prev is None else {new_hash, prev.content_hash, prev.replaces} 

403 return held is None or held in owned 

404 

405 

406def _unchanged(prev: CrawlPage | None, held: str | None, new_hash: str, title: str) -> bool: 

407 """Whether the row and the file already hold this text, and the row this title.""" 

408 if prev is None or held != new_hash or prev.content_hash != new_hash: 

409 return False 

410 return prev.title == title 

411 

412 

413def _kept_title(result: CrawlResult, prev: CrawlPage | None) -> str: 

414 """The title the crawl reports, else the one the row holds; no title never clears one.""" 

415 return result.title or (prev.title if prev is not None else "") 

416 

417 

418def _write( 

419 result: CrawlResult, 

420 address: str, 

421 path: Path, 

422 markdown: str, 

423 held: str | None, 

424 prev: CrawlPage | None, 

425 pages: CrawlPages, 

426) -> Path | None: 

427 """Claim *markdown* for the page, then write it at *path*; None when the file holds it. 

428 

429 The claim commits before the file is written, so a file exists only after its claim. 

430 """ 

431 new_hash = content_hash(markdown) 

432 title = _kept_title(result, prev) 

433 if _unchanged(prev, held, new_hash, title): 

434 log.info("Content unchanged, skipping save: %s", result.url) 

435 return None 

436 now = datetime.now(UTC).isoformat() 

437 replaces = "" if held is None else held 

438 pages.claim(CrawlPage(address, page_name(address), new_hash, now, replaces, title)) 

439 if held == new_hash: 

440 log.info("Recorded the page %s at %s, which already holds its text", result.url, path) 

441 return None 

442 path.parent.mkdir(parents=True, exist_ok=True) 

443 _replace_atomically(path, markdown) 

444 return path 

445 

446 

447def save_page( 

448 result: CrawlResult, record: CrawlRecord, remove_sources: Callable[[list[str]], object] 

449) -> Path | None: 

450 """Write one crawled page at its name; the path, or None when nothing was written. 

451 

452 The check, the claim, the file write and the retire of earlier copies run under one 

453 hold of the crawl lock, so the last claim of a page always describes its file. The 

454 save waits for the lock without a deadline: every holder's work is bounded, and a 

455 page skipped for a lock timeout would be a page lost. 

456 """ 

457 if result.failure_reason() is not None: 

458 return None 

459 address = canonical_address(result.url) 

460 with record.pages.held(timeout=None): 

461 path = _page_path(result, address, record) 

462 if path is None: 

463 return None 

464 markdown = normalize_crawled_markdown(result.markdown) 

465 held = _held_text(path) 

466 prev = record.pages.current(address) 

467 if not _may_write(prev, held, content_hash(markdown)): 

468 log.warning(_NOT_SAVED, result.url, path) 

469 return None 

470 written = _write(result, address, path, markdown, held, prev, record.pages) 

471 _retire_earlier(address, record.pages, remove_sources) 

472 record.pages.compact_when_due() 

473 return written