Coverage for src/local_deep_research/web_search_engines/engines/search_engine_wayback.py: 98%

306 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import json 

2import re 

3from typing import Any, Dict, List, Optional, Tuple 

4 

5import requests 

6from langchain_core.language_models import BaseLLM 

7 

8from ...research_library.downloaders.extraction import extract_content 

9from ...security import redact_url_for_log 

10from ...security.safe_requests import safe_get 

11from ...security.secure_logging import logger 

12from ..rate_limiting import RateLimitError 

13from ..wayback_client import get_availability_response, validate_archive_url 

14from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

15 

16# Characters that separate list entries: ASCII and fullwidth commas and 

17# semicolons, and the ideographic comma. None is valid in a host name. 

18_LIST_SEPARATORS = ",;\uff0c\uff1b\u3001" 

19 

20# Characters that end a URL in running text rather than belong to it: 

21# sentence stops (ASCII, CJK, fullwidth, ellipsis), Markdown emphasis, 

22# straight and curly closing quotes, and closing guillemets. 

23_PROSE_URL_ENDINGS = frozenset( 

24 ".,;:!?*'\u2019\u201d\u00bb\u203a" 

25 "\u3001\u3002\uff0c\uff1b\uff1a\uff01\uff1f\uff0e\u2026" 

26) 

27 

28# Opening and closing bracket pairs, ASCII, fullwidth and CJK. In running 

29# text a closing bracket ends a URL only when the URL holds more of it than 

30# of its opener, so a balanced pair in a path stays, as in a Wikipedia title 

31# that ends in a bracket. 

32_URL_BRACKET_PAIRS: Tuple[Tuple[str, str], ...] = ( 

33 ("(", ")"), 

34 ("[", "]"), 

35 ("{", "}"), 

36 ("\uff08", "\uff09"), 

37 ("\uff3b", "\uff3d"), 

38 ("\uff5b", "\uff5d"), 

39 ("\u300c", "\u300d"), 

40 ("\u300e", "\u300f"), 

41 ("\u3010", "\u3011"), 

42 ("\u3008", "\u3009"), 

43 ("\u300a", "\u300b"), 

44 ("\u3014", "\u3015"), 

45) 

46 

47 

48class WaybackSearchEngine(BaseSearchEngine): 

49 """ 

50 Look up historical versions of supplied URLs or domains in the 

51 Internet Archive Wayback Machine. This engine does not discover URLs 

52 through another search provider. 

53 """ 

54 

55 # Mark as public search engine 

56 is_public = True 

57 egress_sensitivity = Sensitivity.NON_SENSITIVE 

58 egress_exposure = Exposure.EXPOSING 

59 

60 def __init__( 

61 self, 

62 max_results: int = 10, 

63 max_snapshots_per_url: int = 3, 

64 llm: Optional[BaseLLM] = None, 

65 language: str = "English", 

66 max_filtered_results: Optional[int] = None, 

67 closest_only: bool = False, 

68 settings_snapshot: Optional[Dict[str, Any]] = None, 

69 ): 

70 """ 

71 Initialize the Wayback Machine search engine. 

72 

73 Args: 

74 max_results: Maximum number of search results 

75 max_snapshots_per_url: Maximum snapshots to retrieve per URL 

76 llm: Language model for relevance filtering 

77 language: Language for content processing 

78 max_filtered_results: Maximum number of results to keep after filtering 

79 closest_only: If True, only retrieves the closest snapshot for each URL 

80 """ 

81 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results 

82 super().__init__( 

83 llm=llm, 

84 max_filtered_results=max_filtered_results, 

85 max_results=max_results, 

86 settings_snapshot=settings_snapshot, 

87 ) 

88 self.max_snapshots_per_url = max_snapshots_per_url 

89 self.language = language 

90 self.closest_only = closest_only 

91 

92 # API endpoints 

93 self.available_api = "https://archive.org/wayback/available" 

94 self.cdx_api = "https://web.archive.org/cdx/search/cdx" 

95 

96 def _extract_urls_from_query(self, query: str) -> List[str]: 

97 """ 

98 Extract explicit URLs or interpret a bare domain/path as a URL. 

99 Topic queries need URL discovery through a configured search 

100 provider before they can be looked up in the Wayback Machine. 

101 

102 Args: 

103 query: The search query or URL 

104 

105 Returns: 

106 List of URLs to search in the Wayback Machine 

107 """ 

108 # Accept a complete bare domain, optionally with a port, path, 

109 # query or fragment, before looking for embedded URLs: query 

110 # parameters can themselves contain another URL. 

111 target = query.strip() 

112 domain_pattern = re.compile( 

113 r"(?:[-\w.]|%[\da-fA-F]{2})+\.[\w-]+" 

114 r"(?::\d+)?(?:[/?#][^\s<>\"`]*)?" 

115 ) 

116 if domain_pattern.fullmatch(target): 

117 logger.info("Query appears to be a domain or partial URL") 

118 return [f"http://{target}"] 

119 

120 # Inline Markdown link labels are display text, not archive 

121 # destinations. Track balanced brackets, including IPv6 labels; 

122 # escaped brackets do not open or close a label. 

123 label_starts = [] 

124 label_spans = [] 

125 for bracket in re.finditer(r"\\.|[\[\]]", query): 

126 if bracket.group() == "[": 

127 label_starts.append(bracket.start()) 

128 elif bracket.group() == "]" and label_starts: 

129 start = label_starts.pop() 

130 if query[bracket.end() : bracket.end() + 1] == "(": 

131 label_spans.append((start, bracket.end() + 1)) 

132 

133 # Labels nest like the brackets that delimit them, and URL starts 

134 # only move forward, so a stack of the labels open at each URL 

135 # finds its innermost label without rescanning every label. 

136 label_spans.sort() 

137 open_labels: List[Tuple[int, int]] = [] 

138 next_label = 0 

139 

140 # A URL's greedy run ends at whitespace, a double quote, a 

141 # backtick or an angle bracket, and every URL of a compact list 

142 # shares one run. No URL slices or rescans the rest of its run: 

143 # searches that look past it are cached for the run's later URLs, 

144 # so a long pasted list stays linear. 

145 url_start = re.compile(r"https?://(?=[^\s<>\"`])", re.IGNORECASE) 

146 run_boundary = re.compile(r"[\s<>\"`]") 

147 quote_separator = re.compile( 

148 rf"'[{_LIST_SEPARATORS}]*'(?=https?://)", re.IGNORECASE 

149 ) 

150 path_separator = re.compile( 

151 rf"[{_LIST_SEPARATORS}](?=https?://)", re.IGNORECASE 

152 ) 

153 path_mark = re.compile(r"[?#]") 

154 authority_mark = re.compile(r"[/?#]") 

155 host_separator = re.compile(rf"[{_LIST_SEPARATORS}]") 

156 bracket_pairs = {"(": re.compile(r"[()]"), "[": re.compile(r"[\[\]]")} 

157 run_end = 0 

158 closing_quote = None 

159 first_hits: Dict[re.Pattern, Optional[int]] = {} 

160 

161 def first_in_run(pattern: re.Pattern, position: int) -> Optional[int]: 

162 # A run's URLs are visited in order. An earlier search that 

163 # found nothing, or found a match at or after this position, 

164 # already answers the question. 

165 hit = first_hits.get(pattern, -1) 

166 if hit is not None and hit < position: 

167 found = pattern.search(query, position, run_end) 

168 hit = found.start() if found else None 

169 first_hits[pattern] = hit 

170 return hit 

171 

172 def host_separator_in( 

173 scheme_end: int, authority_end: int 

174 ) -> Optional[int]: 

175 # The host follows the authority's last "@". 

176 host_start = query.rfind("@", scheme_end, authority_end) + 1 

177 cut = host_separator.search( 

178 query, max(host_start, scheme_end), authority_end 

179 ) 

180 return cut.start() if cut else None 

181 

182 urls = [] 

183 cursor = 0 

184 while match := url_start.search(query, cursor): 

185 start = match.start() 

186 while ( 

187 next_label < len(label_spans) 

188 and label_spans[next_label][0] < start 

189 ): 

190 label = label_spans[next_label] 

191 next_label += 1 

192 while open_labels and open_labels[-1][1] <= label[0]: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 open_labels.pop() 

194 open_labels.append(label) 

195 while open_labels and open_labels[-1][1] <= start: 

196 open_labels.pop() 

197 if open_labels: 

198 cursor = open_labels[-1][1] 

199 continue 

200 

201 if start >= run_end: 

202 boundary = run_boundary.search(query, start) 

203 run_end = boundary.start() if boundary else len(query) 

204 first_hits.clear() 

205 # An unmatched opening prose quote must not turn an 

206 # internal apostrophe into a closing delimiter: the run's 

207 # last apostrophe closes a quote only when nothing but 

208 # closing punctuation follows it. The backward scan stops 

209 # at this URL's scheme at the latest. 

210 tail = run_end 

211 while query[tail - 1] in ".,;:!?)]}": 

212 tail -= 1 

213 closing_quote = tail - 1 if query[tail - 1] == "'" else None 

214 

215 # List separators after the host are prose/list separators, 

216 # including a compact list with no whitespace. Keep these 

217 # characters in userinfo, paths and query values. 

218 # The authority read stops at the next "/", "?" or "#", and a 

219 # following URL's scheme supplies one, so it never spans the 

220 # rest of a compact list. 

221 scheme_end = match.end() 

222 authority_match = authority_mark.search(query, scheme_end, run_end) 

223 authority_end = ( 

224 authority_match.start() if authority_match else run_end 

225 ) 

226 host_separator_at = host_separator_in(scheme_end, authority_end) 

227 

228 url_end = run_end 

229 preceding = query[start - 1] if start else "" 

230 quoted = False 

231 if preceding == "'": 

232 # Strip the enclosing quote, retaining apostrophes in 

233 # the target. A following quoted URL can start a list. 

234 separator = first_in_run(quote_separator, start) 

235 quote_end = closing_quote if separator is None else separator 

236 if quote_end is not None: 

237 url_end = quote_end 

238 quoted = True 

239 

240 # Markdown links, parentheses and square brackets add a 

241 # closing delimiter. Preserve balanced pairs inside the URL, 

242 # including IPv6 hosts, and literal closers in raw URLs. A 

243 # closer after a separator in the host cannot change the 

244 # target, so the scan stops there. 

245 wrapped = preceding in bracket_pairs 

246 if wrapped: 

247 depth = 0 

248 for bracket in bracket_pairs[preceding].finditer( 

249 query, 

250 start, 

251 run_end if host_separator_at is None else host_separator_at, 

252 ): 

253 if bracket.group() == preceding: 

254 depth += 1 

255 elif depth: 

256 depth -= 1 

257 else: 

258 url_end = bracket.start() 

259 break 

260 

261 delimited = quoted or (bool(preceding) and preceding in '"<`') 

262 if not wrapped and not delimited: 

263 # A compact list may separate complete path URLs too. 

264 # Query/fragment values can contain literal nested URLs; 

265 # explicitly delimited targets also retain literal paths. 

266 path_end = first_in_run(path_mark, start) 

267 separator = first_in_run(path_separator, start) 

268 if separator is not None and ( 

269 path_end is None or separator < path_end 

270 ): 

271 url_end = separator 

272 

273 if host_separator_at is not None and url_end >= host_separator_at: 

274 url_end = host_separator_at 

275 elif url_end < authority_end: 

276 # A cut inside the authority shortens it: an "@" after 

277 # the cut no longer marks userinfo, so re-read the host. 

278 separator = host_separator_in(scheme_end, url_end) 

279 if separator is not None: 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 url_end = separator 

281 url = query[start:url_end] 

282 

283 # A complete raw URL or an explicitly delimited destination 

284 # keeps literal punctuation. Inside a parenthesis or square 

285 # bracket, a list puts its separator before the closer or the 

286 # next space. In prose, peel sentence endings, quotes, emphasis, 

287 # CJK punctuation and a closer (ASCII, fullwidth or CJK) without 

288 # its opener, without damaging balanced pairs in URL paths. 

289 if wrapped: 

290 url = url.rstrip(_LIST_SEPARATORS) 

291 elif not delimited and url != target: 

292 unmatched = { 

293 closer: max(0, url.count(closer) - url.count(opener)) 

294 for opener, closer in _URL_BRACKET_PAIRS 

295 } 

296 end = len(url) 

297 while end: 297 ↛ 306line 297 didn't jump to line 306 because the condition on line 297 was always true

298 character = url[end - 1] 

299 if character in _PROSE_URL_ENDINGS: 

300 end -= 1 

301 elif unmatched.get(character): 

302 end -= 1 

303 unmatched[character] -= 1 

304 else: 

305 break 

306 url = url[:end] 

307 

308 urls.append(url) 

309 # Resume after the actual target, not the rest of its run: 

310 # the run may also hold the next URL or Markdown link. 

311 cursor = start + len(url) 

312 if urls: 

313 logger.info(f"Found {len(urls)} URLs in query") 

314 return urls 

315 

316 # Return empty list if nothing worked 

317 logger.warning( 

318 "Wayback needs a URL or domain; discover URLs with a configured " 

319 "search provider first" 

320 ) 

321 return [] 

322 

323 def _format_timestamp(self, timestamp: str) -> str: 

324 """Format Wayback Machine timestamp into readable date""" 

325 if len(timestamp) < 14: 

326 return timestamp 

327 

328 try: 

329 year = timestamp[0:4] 

330 month = timestamp[4:6] 

331 day = timestamp[6:8] 

332 hour = timestamp[8:10] 

333 minute = timestamp[10:12] 

334 second = timestamp[12:14] 

335 return f"{year}-{month}-{day} {hour}:{minute}:{second}" 

336 except Exception: 

337 logger.debug("Timestamp formatting failed, returning original") 

338 return timestamp 

339 

340 def _get_wayback_snapshots(self, url: str) -> List[Dict[str, Any]]: 

341 """ 

342 Get snapshots from the Wayback Machine for a specific URL. 

343 

344 Args: 

345 url: URL to get snapshots for 

346 

347 Returns: 

348 List of snapshot dictionaries 

349 """ 

350 snapshots = [] 

351 

352 try: 

353 if self.closest_only: 

354 # Get only the closest snapshot 

355 response = get_availability_response(url, request_get=safe_get) 

356 

357 # Check for rate limit 

358 if response.status_code == 429: 

359 raise RateLimitError( # noqa: TRY301 — re-raised for base retry handling 

360 "Wayback Machine rate limit exceeded", 

361 retry_after=response.headers.get("Retry-After"), 

362 ) 

363 

364 data = response.json() 

365 

366 if ( 

367 "archived_snapshots" in data 

368 and "closest" in data["archived_snapshots"] 

369 ): 

370 snapshot = data["archived_snapshots"]["closest"] 

371 snapshot_url = snapshot["url"] 

372 timestamp = snapshot["timestamp"] 

373 

374 snapshots.append( 

375 { 

376 "timestamp": timestamp, 

377 "formatted_date": self._format_timestamp(timestamp), 

378 "url": snapshot_url, 

379 "original_url": url, 

380 "available": snapshot.get("available", True), 

381 "status": snapshot.get("status", "200"), 

382 } 

383 ) 

384 else: 

385 # Get multiple snapshots using CDX API 

386 response = safe_get( 

387 self.cdx_api, 

388 params={ 

389 "url": url, 

390 "output": "json", 

391 "fl": "timestamp,original,statuscode,mimetype", 

392 "collapse": "timestamp:4", # Group by year 

393 "limit": self.max_snapshots_per_url, 

394 }, 

395 ) 

396 

397 # Check for rate limit 

398 if response.status_code == 429: 

399 raise RateLimitError( # noqa: TRY301 — re-raised by except RateLimitError for base class retry 

400 "Wayback Machine CDX API rate limit exceeded", 

401 retry_after=response.headers.get("Retry-After"), 

402 ) 

403 

404 # Check if response is valid JSON 

405 data = response.json() 

406 

407 # First item is the header 

408 if len(data) > 1: 

409 headers = data[0] 

410 for item in data[1:]: 

411 snapshot = dict(zip(headers, item, strict=False)) 

412 timestamp = snapshot.get("timestamp", "") 

413 

414 wayback_url = ( 

415 f"https://web.archive.org/web/{timestamp}/{url}" 

416 ) 

417 

418 snapshots.append( 

419 { 

420 "timestamp": timestamp, 

421 "formatted_date": self._format_timestamp( 

422 timestamp 

423 ), 

424 "url": wayback_url, 

425 "original_url": url, 

426 "available": True, 

427 "status": snapshot.get("statuscode", "200"), 

428 } 

429 ) 

430 

431 # Limit to max snapshots per URL 

432 snapshots = snapshots[: self.max_snapshots_per_url] 

433 

434 except RateLimitError: 

435 # Re-raise rate limit errors for base class retry handling 

436 raise 

437 except Exception as e: 

438 if not isinstance( 

439 e, (json.JSONDecodeError, requests.exceptions.JSONDecodeError) 

440 ): 

441 # A non-JSON reply is per URL (e.g. the archive's 

442 # "blocked site" page), not a Wayback outage. 

443 self._record_search_failure(e) 

444 safe_msg = self._scrub_error(e) 

445 logger.exception( 

446 f"Error getting Wayback snapshots for {redact_url_for_log(url)} ({type(e).__name__}): {safe_msg}" 

447 ) 

448 

449 return snapshots 

450 

451 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

452 """ 

453 Get preview information for Wayback Machine snapshots. 

454 

455 Args: 

456 query: The search query 

457 

458 Returns: 

459 List of preview dictionaries 

460 """ 

461 logger.info("Getting Wayback Machine previews") 

462 

463 urls = [] 

464 for url in self._extract_urls_from_query(query): 

465 # fetch_archive's URL check: a user name or password before the 

466 # host never reaches the archive or the results. A credential in 

467 # a query value is sent as written, as with fetch_archive, and 

468 # unlike fetch_archive no per-target egress check runs here. 

469 try: 

470 urls.append(validate_archive_url(url)) 

471 except ValueError: 

472 logger.warning( 

473 f"Skipping Wayback target {redact_url_for_log(url)}: not " 

474 "one absolute HTTP(S) URL without credentials" 

475 ) 

476 # Each target costs an archive request, so look up no more targets 

477 # than this search returns results. 

478 if len(urls) > self.max_results: 

479 logger.info( 

480 f"Looking up the first {self.max_results} of {len(urls)} URLs" 

481 ) 

482 urls = urls[: self.max_results] 

483 

484 if not urls: 

485 logger.warning("No URLs found in the query") 

486 return [] 

487 

488 # Get snapshots for each URL 

489 all_snapshots = [] 

490 for url in urls: 

491 snapshots = self._get_wayback_snapshots(url) 

492 all_snapshots.extend(snapshots) 

493 

494 # Apply rate limiting between requests 

495 if len(urls) > 1: 

496 self.rate_tracker.apply_rate_limit(self.engine_type) 

497 

498 # Format as previews 

499 previews = [] 

500 for snapshot in all_snapshots: 

501 preview = { 

502 "id": f"{snapshot['timestamp']}_{snapshot['original_url']}", 

503 "title": f"Archive of {snapshot['original_url']} ({snapshot['formatted_date']})", 

504 "link": snapshot["url"], 

505 "snippet": f"Archived version from {snapshot['formatted_date']}", 

506 "original_url": snapshot["original_url"], 

507 "timestamp": snapshot["timestamp"], 

508 "formatted_date": snapshot["formatted_date"], 

509 } 

510 previews.append(preview) 

511 

512 logger.info(f"Found {len(previews)} Wayback Machine snapshots") 

513 return previews 

514 

515 def _remove_boilerplate(self, html: str) -> str: 

516 """Remove boilerplate using the shared extraction pipeline.""" 

517 if not html or not html.strip(): 

518 return "" 

519 try: 

520 return extract_content(html, language=self.language) or "" 

521 except Exception as e: 

522 safe_msg = self._scrub_error(e) 

523 logger.exception( 

524 f"Error removing boilerplate ({type(e).__name__}): {safe_msg}" 

525 ) 

526 return html 

527 

528 def _get_wayback_content(self, url: str) -> Tuple[str, str]: 

529 """ 

530 Retrieve content from a Wayback Machine URL. 

531 

532 Args: 

533 url: Wayback Machine URL 

534 

535 Returns: 

536 Tuple of (raw_html, cleaned_text) 

537 """ 

538 try: 

539 headers = { 

540 "User-Agent": "Mozilla/5.0 (Local Deep Research Bot; research project)" 

541 } 

542 response = safe_get(url, headers=headers, timeout=10) 

543 raw_html = response.text 

544 

545 # Clean the HTML 

546 cleaned_text = self._remove_boilerplate(raw_html) 

547 

548 return raw_html, cleaned_text 

549 except Exception as e: 

550 safe_msg = self._scrub_error(e) 

551 logger.exception( 

552 f"Error retrieving content from {redact_url_for_log(url)} ({type(e).__name__}): {safe_msg}" 

553 ) 

554 return "", f"Error retrieving content: {safe_msg}" 

555 

556 def _get_full_content( 

557 self, relevant_items: List[Dict[str, Any]] 

558 ) -> List[Dict[str, Any]]: 

559 """ 

560 Get full content for the relevant Wayback Machine snapshots. 

561 

562 Args: 

563 relevant_items: List of relevant preview dictionaries 

564 

565 Returns: 

566 List of result dictionaries with full content 

567 """ 

568 logger.info( 

569 f"Getting full content for {len(relevant_items)} Wayback Machine snapshots" 

570 ) 

571 

572 results = [] 

573 for item in relevant_items: 

574 wayback_url = item.get("link") 

575 if not wayback_url: 

576 results.append(item) 

577 continue 

578 

579 logger.info( 

580 f"Retrieving content from {redact_url_for_log(wayback_url)}" 

581 ) 

582 

583 try: 

584 # Retrieve content 

585 raw_html, full_content = self._get_wayback_content(wayback_url) 

586 

587 # Add full content to the result 

588 result = item.copy() 

589 result["raw_html"] = raw_html 

590 result["full_content"] = full_content 

591 result["full_content_is_complete"] = bool( 

592 raw_html and full_content 

593 ) 

594 

595 results.append(result) 

596 

597 # Apply rate limiting 

598 self.rate_tracker.apply_rate_limit(self.engine_type) 

599 except Exception as e: 

600 safe_msg = self._scrub_error(e) 

601 logger.exception( 

602 f"Error processing {redact_url_for_log(wayback_url)} ({type(e).__name__}): {safe_msg}" 

603 ) 

604 results.append(item) 

605 

606 return results 

607 

608 def search_by_url( 

609 self, url: str, max_snapshots: int | None = None 

610 ) -> List[Dict[str, Any]]: 

611 """ 

612 Search for archived versions of a specific URL. 

613 

614 Args: 

615 url: The URL to search for archives 

616 max_snapshots: Maximum number of snapshots to return 

617 

618 Returns: 

619 List of snapshot dictionaries 

620 """ 

621 max_snapshots = max_snapshots or self.max_snapshots_per_url 

622 

623 snapshots = self._get_wayback_snapshots(url) 

624 previews = [] 

625 

626 for snapshot in snapshots[:max_snapshots]: 

627 preview = { 

628 "id": f"{snapshot['timestamp']}_{snapshot['original_url']}", 

629 "title": f"Archive of {snapshot['original_url']} ({snapshot['formatted_date']})", 

630 "link": snapshot["url"], 

631 "snippet": f"Archived version from {snapshot['formatted_date']}", 

632 "original_url": snapshot["original_url"], 

633 "timestamp": snapshot["timestamp"], 

634 "formatted_date": snapshot["formatted_date"], 

635 } 

636 previews.append(preview) 

637 

638 return self._get_full_content_cached(previews) 

639 

640 def search_by_date_range( 

641 self, url: str, start_date: str, end_date: str 

642 ) -> List[Dict[str, Any]]: 

643 """ 

644 Search for archived versions of a URL within a date range. 

645 

646 Args: 

647 url: The URL to search for archives 

648 start_date: Start date in format YYYYMMDD 

649 end_date: End date in format YYYYMMDD 

650 

651 Returns: 

652 List of snapshot dictionaries 

653 """ 

654 try: 

655 # Use CDX API with date range 

656 response = safe_get( 

657 self.cdx_api, 

658 params={ 

659 "url": url, 

660 "output": "json", 

661 "fl": "timestamp,original,statuscode,mimetype", 

662 "from": start_date, 

663 "to": end_date, 

664 "limit": self.max_snapshots_per_url, 

665 }, 

666 ) 

667 

668 # Process response 

669 data = response.json() 

670 

671 # First item is the header 

672 if len(data) <= 1: 

673 return [] 

674 

675 headers = data[0] 

676 snapshots = [] 

677 

678 for item in data[1:]: 

679 snapshot = dict(zip(headers, item, strict=False)) 

680 timestamp = snapshot.get("timestamp", "") 

681 

682 wayback_url = f"https://web.archive.org/web/{timestamp}/{url}" 

683 

684 snapshots.append( 

685 { 

686 "id": f"{timestamp}_{url}", 

687 "title": f"Archive of {url} ({self._format_timestamp(timestamp)})", 

688 "link": wayback_url, 

689 "snippet": f"Archived version from {self._format_timestamp(timestamp)}", 

690 "original_url": url, 

691 "timestamp": timestamp, 

692 "formatted_date": self._format_timestamp(timestamp), 

693 } 

694 ) 

695 

696 return self._get_full_content_cached(snapshots) 

697 

698 except Exception as e: 

699 safe_msg = self._scrub_error(e) 

700 logger.exception( 

701 f"Error searching date range for {redact_url_for_log(url)} ({type(e).__name__}): {safe_msg}" 

702 ) 

703 return [] 

704 

705 def get_latest_snapshot(self, url: str) -> Optional[Dict[str, Any]]: 

706 """ 

707 Get the most recent snapshot of a URL. 

708 

709 Args: 

710 url: The URL to get the latest snapshot for 

711 

712 Returns: 

713 Dictionary with snapshot information or None if not found 

714 """ 

715 try: 

716 response = safe_get(self.available_api, params={"url": url}) 

717 data = response.json() 

718 

719 if ( 

720 "archived_snapshots" in data 

721 and "closest" in data["archived_snapshots"] 

722 ): 

723 snapshot = data["archived_snapshots"]["closest"] 

724 timestamp = snapshot["timestamp"] 

725 wayback_url = snapshot["url"] 

726 

727 result = { 

728 "id": f"{timestamp}_{url}", 

729 "title": f"Latest archive of {url} ({self._format_timestamp(timestamp)})", 

730 "link": wayback_url, 

731 "snippet": f"Archived version from {self._format_timestamp(timestamp)}", 

732 "original_url": url, 

733 "timestamp": timestamp, 

734 "formatted_date": self._format_timestamp(timestamp), 

735 } 

736 

737 raw_html, full_content = self._get_wayback_content(wayback_url) 

738 result["raw_html"] = raw_html 

739 result["full_content"] = full_content 

740 result["full_content_is_complete"] = bool( 

741 raw_html and full_content 

742 ) 

743 

744 return result 

745 

746 return None 

747 

748 except Exception as e: 

749 safe_msg = self._scrub_error(e) 

750 logger.exception( 

751 f"Error getting latest snapshot for {redact_url_for_log(url)} ({type(e).__name__}): {safe_msg}" 

752 ) 

753 return None