Coverage for src/local_deep_research/web_search_engines/wayback_client.py: 98%

107 statements  

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

1"""URL-based Wayback availability lookup shared by archive callers.""" 

2 

3import posixpath 

4import re 

5from datetime import datetime 

6from urllib.parse import unquote, urlsplit 

7 

8from requests import Request 

9 

10from ..security.safe_requests import safe_get 

11from ..utilities.resource_utils import safe_close 

12from .rate_limiting import RateLimitError 

13 

14AVAILABLE_API = "https://archive.org/wayback/available" 

15 

16 

17def _archive_target_key(url: str): 

18 """Ignore scheme/www/default ports/fragments, retaining prepared path/query.""" 

19 prepared = Request("GET", validate_archive_url(url)).prepare().url or "" 

20 parsed = urlsplit(prepared) 

21 default_port = 443 if parsed.scheme == "https" else 80 

22 port = parsed.port if parsed.port != default_port else None 

23 return ( 

24 (parsed.hostname or "").removeprefix("www."), 

25 port, 

26 parsed.path or "/", 

27 parsed.query, 

28 ) 

29 

30 

31def validate_capture_fetch_url( 

32 url: str, timestamp: str, expected_url: str | None = None 

33) -> None: 

34 """Keep page retrieval and every redirect inside the selected capture.""" 

35 parsed = urlsplit(url) 

36 if ( 

37 parsed.scheme != "https" 

38 or parsed.hostname != "web.archive.org" 

39 or parsed.port not in {None, 443} 

40 or parsed.username is not None 

41 or parsed.password is not None 

42 or not re.match( 

43 rf"^/web/{re.escape(timestamp)}(?:id_|if_)?/https?://", parsed.path 

44 ) 

45 ): 

46 raise ValueError("Archive replay left the selected capture") 

47 if expected_url is not None: 47 ↛ exitline 47 didn't return from function 'validate_capture_fetch_url' because the condition on line 47 was always true

48 target = url.split(f"/web/{timestamp}", 1)[1].split("/", 1)[1] 

49 if _archive_target_key(target) != _archive_target_key(expected_url): 

50 raise ValueError("Archive replay changed the captured URL") 

51 

52 

53def capture_metadata_from_replay_url(url: str) -> dict | None: 

54 """Identify an exact supplied capture without an availability request. 

55 

56 A direct replay read needs the same boundary as a discovered capture, 

57 even before any citation exists. Unsupported capture forms fail before 

58 network access instead of becoming an ordinary live-page fetch. 

59 """ 

60 parsed = urlsplit(url) 

61 if parsed.scheme not in {"http", "https"} or not parsed.hostname: 

62 return None 

63 # Detection includes the spelling Requests will send. These aliases 

64 # must not fall through to an unrestricted live-page fetch, but only 

65 # the original, canonical capture spelling is accepted below. 

66 prepared = urlsplit(Request("GET", url).prepare().url or "") 

67 prepared_host = (prepared.hostname or "").rstrip(".") 

68 if prepared_host not in {"web.archive.org", "archive.org"}: 

69 return None 

70 

71 def has_replay_path(path): 

72 # Inspect the wrapper, not the embedded target's path. Decode one 

73 # layer for detection only; never fetch a normalized substitute. 

74 wrapper = unquote(path.split("://", 1)[0]).replace("\\", "/") 

75 return any( 

76 candidate.lstrip("/").partition("/")[0].lower() == "web" 

77 for candidate in (wrapper, posixpath.normpath(wrapper)) 

78 ) 

79 

80 if not any(has_replay_path(path) for path in (parsed.path, prepared.path)): 

81 return None 

82 if prepared_host != "web.archive.org": 

83 raise ValueError("Unsupported archive replay capture URL") 

84 replay = re.match(r"^/web/([0-9]{14})(?:id_|if_)?/https?://", parsed.path) 

85 if replay is None: 

86 raise ValueError("Unsupported archive replay capture URL") 

87 timestamp = replay.group(1) 

88 captured_date = datetime.strptime(timestamp, "%Y%m%d%H%M%S") 

89 target = url.split(f"/web/{timestamp}", 1)[1].split("/", 1)[1] 

90 validate_archive_url(target) 

91 validate_capture_fetch_url(url, timestamp, target) 

92 return { 

93 "source_type": "archive", 

94 "original_url": target, 

95 "requested_url": target, 

96 "timestamp": timestamp, 

97 "formatted_date": captured_date.strftime("%Y-%m-%d %H:%M:%S UTC"), 

98 } 

99 

100 

101def validate_archive_url(url: str) -> str: 

102 """Require one absolute HTTP(S) URL without embedded credentials.""" 

103 url = url.strip() 

104 if not url or any( 

105 character.isspace() or ord(character) < 32 for character in url 

106 ): 

107 raise ValueError( 

108 "Supply a single absolute HTTP(S) URL to fetch_archive" 

109 ) 

110 parsed = urlsplit(url) 

111 if ( 

112 parsed.scheme.lower() not in {"http", "https"} 

113 or not parsed.hostname 

114 or parsed.username is not None 

115 or parsed.password is not None 

116 ): 

117 raise ValueError("Supply an absolute HTTP(S) URL without credentials") 

118 # Accessing port validates malformed/non-numeric/out-of-range ports. 

119 parsed.port 

120 return url 

121 

122 

123def normalize_archive_date(value: str | None) -> str | None: 

124 """Convert a calendar date to the availability API's YYYYMMDD input.""" 

125 if value is None: 

126 return None 

127 if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): 

128 return datetime.strptime(value, "%Y-%m-%d").strftime("%Y%m%d") 

129 if re.fullmatch(r"\d{8}", value): 

130 return datetime.strptime(value, "%Y%m%d").strftime("%Y%m%d") 

131 raise ValueError("Archive date must be a valid YYYY-MM-DD or YYYYMMDD date") 

132 

133 

134def get_availability_response(url: str, timestamp=None, *, request_get=None): 

135 """Request the fixed archive API without following redirects.""" 

136 params = {"url": url} 

137 if timestamp is not None: 

138 params["timestamp"] = timestamp 

139 getter = request_get if request_get is not None else safe_get 

140 return getter( 

141 AVAILABLE_API, params=params, timeout=30, allow_redirects=False 

142 ) 

143 

144 

145def lookup_archive_snapshot(url: str, timestamp=None, *, request_get=None): 

146 """Return an accessible snapshot, preserving its actual capture time. 

147 

148 With no timestamp the API selects the most recent available capture; 

149 otherwise it selects a capture near the requested date. Never substitute 

150 the live page when the archive has no capture. 

151 """ 

152 url = validate_archive_url(url) 

153 response = get_availability_response( 

154 url, timestamp, request_get=request_get 

155 ) 

156 try: 

157 if response.status_code == 429: 

158 raise RateLimitError( 

159 "Wayback archive lookup rate limit exceeded", 

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

161 ) 

162 response.raise_for_status() 

163 if response.status_code != 200: 

164 raise ValueError( 

165 "Archive availability API returned an unexpected status" 

166 ) 

167 data = response.json() 

168 if not isinstance(data, dict) or not isinstance( 

169 data.get("archived_snapshots"), dict 

170 ): 

171 raise ValueError("Invalid archive availability response") 

172 closest = data["archived_snapshots"].get("closest") 

173 if closest is None: 

174 return None 

175 if not isinstance(closest, dict): 

176 raise ValueError("Invalid archive snapshot") 

177 if ( 

178 closest.get("available") is not True 

179 or str(closest.get("status")) != "200" 

180 ): 

181 return None 

182 captured = closest.get("timestamp") 

183 if not isinstance(captured, str) or not re.fullmatch( 

184 r"\d{14}", captured 

185 ): 

186 raise ValueError("Invalid archive capture timestamp") 

187 captured_date = datetime.strptime(captured, "%Y%m%d%H%M%S") 

188 replay = closest.get("url") 

189 if not isinstance(replay, str): 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 raise ValueError("Invalid archive replay URL") 

191 parsed = urlsplit(replay) 

192 prefix = f"/web/{captured}/" 

193 if ( 

194 parsed.scheme.lower() not in {"http", "https"} 

195 or parsed.hostname != "web.archive.org" 

196 or parsed.port not in {None, 443, 80} 

197 or parsed.username is not None 

198 or parsed.password is not None 

199 or not parsed.path.startswith(prefix) 

200 ): 

201 raise ValueError("Invalid archive replay URL") 

202 # Preserve the embedded target's query, punctuation and fragment. 

203 embedded = replay.split(prefix, 1)[1] 

204 validate_archive_url(embedded) 

205 if _archive_target_key(embedded) != _archive_target_key(url): 

206 raise ValueError("Archive capture does not match the requested URL") 

207 replay = "https://web.archive.org" + replay[replay.index(prefix) :] 

208 return { 

209 "original_url": embedded, 

210 "requested_url": url, 

211 "url": replay, 

212 "timestamp": captured, 

213 "formatted_date": captured_date.strftime("%Y-%m-%d %H:%M:%S UTC"), 

214 "available": True, 

215 "status": "200", 

216 } 

217 finally: 

218 safe_close(response, "Wayback availability response")