Coverage for haystack/components/fetchers/link_content.py: 91%

202 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import asyncio 

6from collections import defaultdict 

7from collections.abc import Callable 

8from concurrent.futures import ThreadPoolExecutor 

9from dataclasses import replace 

10from fnmatch import fnmatch 

11from typing import Any, cast 

12 

13import httpx 

14from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt, wait_exponential 

15 

16from haystack import component, logging 

17from haystack.dataclasses import ByteStream 

18from haystack.lazy_imports import LazyImport 

19from haystack.version import __version__ 

20 

21# HTTP/2 support via lazy import 

22with LazyImport("Run 'pip install httpx[http2]' to use HTTP/2 support") as h2_import: 

23 pass # nothing to import as we simply set the http2 attribute, library handles the rest 

24 

25logger = logging.getLogger(__name__) 

26 

27DEFAULT_USER_AGENT = f"haystack/LinkContentFetcher/{__version__}" 

28 

29REQUEST_HEADERS = { 

30 "accept": "*/*", 

31 "User-Agent": DEFAULT_USER_AGENT, 

32 "Accept-Language": "en-US,en;q=0.9,it;q=0.8,es;q=0.7", 

33 "referer": "https://www.google.com/", 

34} 

35 

36 

37def _merge_headers(*args: dict[str, str]) -> dict[str, str]: 

38 """ 

39 Merge a list of dict using case-insensitively 

40 

41 :param args: a list of dict to merge 

42 :returns: The merged dict 

43 """ 

44 merged = {} 

45 keymap = {} 

46 

47 for d in args: 

48 for k, v in d.items(): 

49 kl = k.lower() 

50 keymap[kl] = k 

51 merged[kl] = v 

52 

53 return {keymap[kl]: v for kl, v in merged.items()} 

54 

55 

56def _text_content_handler(response: httpx.Response) -> ByteStream: 

57 """ 

58 Handles text content. 

59 

60 :param response: Response object from the request. 

61 :returns: The extracted text. 

62 """ 

63 return ByteStream.from_string(response.text) 

64 

65 

66def _binary_content_handler(response: httpx.Response) -> ByteStream: 

67 """ 

68 Handles binary content. 

69 

70 :param response: Response object from the request. 

71 :returns: The extracted binary file-like object. 

72 """ 

73 return ByteStream(data=response.content) 

74 

75 

76@component 

77class LinkContentFetcher: 

78 """ 

79 Fetches and extracts content from URLs. 

80 

81 It supports various content types, retries on failures, and automatic user-agent rotation for failed web 

82 requests. Use it as the data-fetching step in your pipelines. 

83 

84 You may need to convert LinkContentFetcher's output into a list of documents. Use HTMLToDocument 

85 converter to do this. 

86 

87 ### Usage example 

88 

89 ```python 

90 from haystack.components.fetchers.link_content import LinkContentFetcher 

91 

92 fetcher = LinkContentFetcher() 

93 streams = fetcher.run(urls=["https://www.google.com"])["streams"] 

94 

95 assert len(streams) == 1 

96 assert streams[0].meta == {'content_type': 'text/html', 'url': 'https://www.google.com'} 

97 assert streams[0].data 

98 ``` 

99 

100 For async usage: 

101 

102 ```python 

103 import asyncio 

104 from haystack.components.fetchers import LinkContentFetcher 

105 

106 async def fetch_async(): 

107 fetcher = LinkContentFetcher() 

108 result = await fetcher.run_async(urls=["https://www.google.com"]) 

109 return result["streams"] 

110 

111 streams = asyncio.run(fetch_async()) 

112 ``` 

113 """ 

114 

115 def __init__( 

116 self, 

117 raise_on_failure: bool = True, 

118 user_agents: list[str] | None = None, 

119 retry_attempts: int = 2, 

120 timeout: int = 3, 

121 http2: bool = False, 

122 client_kwargs: dict | None = None, 

123 request_headers: dict[str, str] | None = None, 

124 ) -> None: 

125 """ 

126 Initializes the component. 

127 

128 :param raise_on_failure: If `True`, raises an exception if it fails to fetch a single URL. 

129 For multiple URLs, it logs errors and returns the content it successfully fetched. 

130 :param user_agents: [User agents](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) 

131 for fetching content. If `None`, a default user agent is used. 

132 :param retry_attempts: The number of times to retry to fetch the URL's content. 

133 :param timeout: Timeout in seconds for the request. 

134 :param http2: Whether to enable HTTP/2 support for requests. Defaults to False. 

135 Requires the 'h2' package to be installed (via `pip install httpx[http2]`). 

136 :param client_kwargs: Additional keyword arguments to pass to the httpx client. 

137 If `None`, default values are used. 

138 :param request_headers: Additional headers to send with every request. These take precedence over the 

139 component's default headers but not over the rotating `User-Agent`. 

140 """ 

141 self.raise_on_failure = raise_on_failure 

142 self.user_agents = user_agents or [DEFAULT_USER_AGENT] 

143 self.retry_attempts = retry_attempts 

144 self.timeout = timeout 

145 self.http2 = http2 

146 self.client_kwargs = client_kwargs or {} 

147 self.request_headers = request_headers or {} 

148 

149 # Configure default client settings 

150 self.client_kwargs.setdefault("timeout", timeout) 

151 self.client_kwargs.setdefault("follow_redirects", True) 

152 

153 # httpx clients are built lazily in warm_up / warm_up_async (resource lifecycle) 

154 self._client: httpx.Client | None = None 

155 self._async_client: httpx.AsyncClient | None = None 

156 

157 # register default content handlers that extract data from the response 

158 self.handlers: dict[str, Callable[[httpx.Response], ByteStream]] = defaultdict(lambda: _text_content_handler) 

159 self.handlers["text/*"] = _text_content_handler 

160 self.handlers["text/html"] = _binary_content_handler 

161 self.handlers["application/json"] = _text_content_handler 

162 self.handlers["application/*"] = _binary_content_handler 

163 self.handlers["image/*"] = _binary_content_handler 

164 self.handlers["audio/*"] = _binary_content_handler 

165 self.handlers["video/*"] = _binary_content_handler 

166 

167 def _get_response(self, url: str) -> httpx.Response: 

168 """ 

169 Gets a response from a URL, rotating the user agent on every failed attempt. 

170 

171 The rotation cursor is local to this call: `run` fetches URLs concurrently, so a cursor kept on the 

172 component would be advanced and reset by whichever fetches happen to be in flight at the same time. 

173 

174 :param url: The URL to fetch. 

175 :returns: The httpx Response object. 

176 """ 

177 user_agent_idx = 0 

178 

179 def rotate_user_agent(retry_state: RetryCallState) -> None: # noqa: ARG001 

180 nonlocal user_agent_idx 

181 user_agent_idx = (user_agent_idx + 1) % len(self.user_agents) 

182 logger.debug("Switched user agent to {user_agent}", user_agent=self.user_agents[user_agent_idx]) 

183 

184 @retry( 

185 reraise=True, 

186 stop=stop_after_attempt(self.retry_attempts + 1), 

187 wait=wait_exponential(multiplier=1, min=2, max=10), 

188 retry=(retry_if_exception_type((httpx.HTTPStatusError, httpx.RequestError))), 

189 # This callback is invoked only after failed requests (exception raised) 

190 after=rotate_user_agent, 

191 ) 

192 def get_response(url: str) -> httpx.Response: 

193 assert self._client is not None # mypy: client is built by warm_up before run 

194 response = self._client.get(url, headers=self._get_headers(self.user_agents[user_agent_idx])) 

195 response.raise_for_status() 

196 return response 

197 

198 return get_response(url) 

199 

200 def _build_client_kwargs(self) -> dict[str, Any]: 

201 """ 

202 Build the keyword arguments used to construct the httpx clients. 

203 

204 Resolves optional HTTP/2 support, downgrading to HTTP/1.1 if the 'h2' package is not installed. 

205 """ 

206 client_kwargs = {**self.client_kwargs} 

207 

208 # Optional HTTP/2 support 

209 if self.http2: 

210 try: 

211 h2_import.check() 

212 client_kwargs["http2"] = True 

213 except ImportError: 

214 logger.warning( 

215 "HTTP/2 support requested but 'h2' package is not installed. " 

216 "Falling back to HTTP/1.1. Install with `pip install httpx[http2]` to enable HTTP/2 support." 

217 ) 

218 self.http2 = False # Update the setting to match actual capability 

219 

220 return client_kwargs 

221 

222 def warm_up(self) -> None: 

223 """ 

224 Initializes the synchronous httpx client. 

225 """ 

226 if self._client is None: 

227 self._client = httpx.Client(**self._build_client_kwargs()) 

228 

229 async def warm_up_async(self) -> None: # noqa: RUF029 

230 """ 

231 Initializes the asynchronous httpx client on the serving event loop. 

232 """ 

233 if self._async_client is None: 

234 self._async_client = httpx.AsyncClient(**self._build_client_kwargs()) 

235 

236 def close(self) -> None: 

237 """ 

238 Releases the synchronous httpx client. 

239 """ 

240 if self._client is not None: 

241 self._client.close() 

242 self._client = None 

243 

244 async def close_async(self) -> None: 

245 """ 

246 Releases the asynchronous httpx client. 

247 """ 

248 if self._async_client is not None: 

249 await self._async_client.aclose() 

250 self._async_client = None 

251 

252 def _get_headers(self, user_agent: str) -> dict[str, str]: 

253 """ 

254 Build headers with precedence 

255 

256 client defaults -> component defaults -> user-provided -> rotating UA 

257 

258 :param user_agent: The user agent for this attempt, taken from the caller's own rotation. 

259 """ 

260 base = dict(self._client.headers) if self._client is not None else {} 

261 return _merge_headers(base, REQUEST_HEADERS, self.request_headers, {"User-Agent": user_agent}) 

262 

263 @component.output_types(streams=list[ByteStream]) 

264 def run(self, urls: list[str]) -> dict[str, Any]: 

265 """ 

266 Fetches content from a list of URLs and returns a list of extracted content streams. 

267 

268 Each content stream is a `ByteStream` object containing the extracted content as binary data. 

269 Each ByteStream object in the returned list corresponds to the contents of a single URL. 

270 The content type of each stream is stored in the metadata of the ByteStream object under 

271 the key "content_type". The URL of the fetched content is stored under the key "url". 

272 

273 :param urls: A list of URLs to fetch content from. 

274 :returns: `ByteStream` objects representing the extracted content. 

275 

276 :raises Exception: If the provided list of URLs contains only a single URL, and `raise_on_failure` is set to 

277 `True`, an exception will be raised in case of an error during content retrieval. 

278 In all other scenarios, any retrieval errors are logged, and a list of successfully retrieved `ByteStream` 

279 objects is returned. 

280 """ 

281 self.warm_up() 

282 

283 streams: list[ByteStream] = [] 

284 if not urls: 

285 return {"streams": streams} 

286 

287 # don't use multithreading if there's only one URL 

288 if len(urls) == 1: 

289 stream_metadata, stream = self._fetch(urls[0]) 

290 stream.meta.update(stream_metadata) 

291 stream = replace(stream, mime_type=stream.meta.get("content_type", None)) 

292 streams.append(stream) 

293 else: 

294 with ThreadPoolExecutor() as executor: 

295 results = executor.map(self._fetch_with_exception_suppression, urls) 

296 

297 for stream_metadata, stream in results: # type: ignore 

298 if stream_metadata is not None and stream is not None: 

299 stream.meta.update(stream_metadata) 

300 stream = replace(stream, mime_type=stream.meta.get("content_type", None)) 

301 streams.append(stream) 

302 

303 return {"streams": streams} 

304 

305 @component.output_types(streams=list[ByteStream]) 

306 async def run_async(self, urls: list[str]) -> dict[str, Any]: 

307 """ 

308 Asynchronously fetches content from a list of URLs and returns a list of extracted content streams. 

309 

310 This is the asynchronous version of the `run` method with the same parameters and return values. 

311 

312 :param urls: A list of URLs to fetch content from. 

313 :returns: `ByteStream` objects representing the extracted content. 

314 """ 

315 await self.warm_up_async() 

316 

317 streams: list[ByteStream] = [] 

318 if not urls: 

319 return {"streams": streams} 

320 

321 assert self._async_client is not None # mypy: async_client is built by warm_up_async above 

322 # Create tasks for all URLs using _fetch_async directly 

323 tasks = [self._fetch_async(url, self._async_client) for url in urls] 

324 

325 # Only capture exceptions when we have multiple URLs or raise_on_failure=False 

326 # This ensures errors propagate appropriately for single URLs with raise_on_failure=True 

327 return_exceptions = not (len(urls) == 1 and self.raise_on_failure) 

328 results = await asyncio.gather(*tasks, return_exceptions=return_exceptions) 

329 

330 # Process results 

331 for i, result in enumerate(results): 

332 # Handle exception results (only happens when return_exceptions=True) 

333 if isinstance(result, Exception): 

334 logger.warning("Error fetching {url}: {error}", url=urls[i], error=str(result)) 

335 # Add an empty result for failed URLs when raise_on_failure=False 

336 if not self.raise_on_failure: 

337 streams.append(ByteStream(data=b"", meta={"content_type": "Unknown", "url": urls[i]})) 

338 continue 

339 

340 # Process successful results 

341 # At this point, result is not an exception, so we need to cast it to the correct type for mypy 

342 if not isinstance(result, Exception): # Runtime check 

343 # Use cast to tell mypy that result is the tuple type returned by _fetch_async 

344 result_tuple = cast(tuple[dict[str, str] | None, ByteStream | None], result) 

345 stream_metadata, stream = result_tuple 

346 if stream_metadata is not None and stream is not None: 

347 stream.meta.update(stream_metadata) 

348 stream = replace(stream, mime_type=stream.meta.get("content_type", None)) 

349 streams.append(stream) 

350 

351 return {"streams": streams} 

352 

353 def _fetch(self, url: str) -> tuple[dict[str, str], ByteStream]: 

354 """ 

355 Fetches content from a URL and returns it as a ByteStream. 

356 

357 :param url: The URL to fetch content from. 

358 :returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream. 

359 ByteStream metadata contains the URL and the content type of the fetched content. 

360 The content type is a string indicating the type of content fetched (for example, "text/html", 

361 "application/pdf"). The ByteStream object contains the fetched content as binary data. 

362 

363 :raises: If an error occurs during content retrieval and `raise_on_failure` is set to True, this method will 

364 raise an exception. Otherwise, all fetching errors are logged, and an empty ByteStream is returned. 

365 

366 """ 

367 content_type: str = "text/html" 

368 stream: ByteStream = ByteStream(data=b"") 

369 try: 

370 response = self._get_response(url) 

371 content_type = self._get_content_type(response) 

372 handler: Callable = self._resolve_handler(content_type) 

373 stream = handler(response) 

374 except Exception as e: 

375 if self.raise_on_failure: 

376 raise e 

377 # less verbose log as this is expected to happen often (requests failing, blocked, etc.) 

378 logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e)) 

379 

380 return {"content_type": content_type, "url": url}, stream 

381 

382 async def _fetch_async( 

383 self, url: str, client: httpx.AsyncClient 

384 ) -> tuple[dict[str, str] | None, ByteStream | None]: 

385 """ 

386 Asynchronously fetches content from a URL and returns it as a ByteStream. 

387 

388 :param url: The URL to fetch content from. 

389 :param client: The async httpx client to use for making requests. 

390 :returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream. 

391 """ 

392 content_type: str = "text/html" 

393 stream: ByteStream | None = None 

394 metadata: dict[str, str] | None = None 

395 

396 try: 

397 response = await self._get_response_async(url, client) 

398 content_type = self._get_content_type(response) 

399 handler: Callable = self._resolve_handler(content_type) 

400 stream = handler(response) 

401 metadata = {"content_type": content_type, "url": url} 

402 except Exception as e: 

403 if self.raise_on_failure: 

404 raise e 

405 logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e)) 

406 # Create an empty ByteStream for failed requests when raise_on_failure is False 

407 stream = ByteStream(data=b"") 

408 metadata = {"content_type": content_type, "url": url} 

409 

410 return metadata, stream 

411 

412 def _fetch_with_exception_suppression(self, url: str) -> tuple[dict[str, str] | None, ByteStream | None]: 

413 """ 

414 Fetches content from a URL and returns it as a ByteStream. 

415 

416 If `raise_on_failure` is set to True, this method will wrap the fetch() method and catch any exceptions. 

417 Otherwise, it will simply call the fetch() method. 

418 :param url: The URL to fetch content from. 

419 :returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream. 

420 

421 """ 

422 if self.raise_on_failure: 

423 try: 

424 return self._fetch(url) 

425 except Exception as e: 

426 logger.warning("Error fetching {url}: {error}", url=url, error=str(e)) 

427 return {"content_type": "Unknown", "url": url}, None 

428 else: 

429 return self._fetch(url) 

430 

431 async def _get_response_async(self, url: str, client: httpx.AsyncClient) -> httpx.Response: 

432 """ 

433 Asynchronously gets a response from a URL with retry logic. 

434 

435 :param url: The URL to fetch. 

436 :param client: The async httpx client to use for making requests. 

437 :returns: The httpx Response object. 

438 """ 

439 attempt = 0 

440 last_exception = None 

441 # Local to this call: `run_async` gathers URLs concurrently, so a cursor on the component 

442 # would be shared by every request in flight. 

443 user_agent_idx = 0 

444 

445 while attempt <= self.retry_attempts: 

446 try: 

447 response = await client.get(url, headers=self._get_headers(self.user_agents[user_agent_idx])) 

448 response.raise_for_status() 

449 return response 

450 except (httpx.HTTPStatusError, httpx.RequestError) as e: 

451 last_exception = e 

452 attempt += 1 

453 if attempt <= self.retry_attempts: 

454 # Switch user agent for next retry 

455 user_agent_idx = (user_agent_idx + 1) % len(self.user_agents) 

456 # Wait before retry using exponential backoff 

457 await asyncio.sleep(min(2 * 2 ** (attempt - 1), 10)) 

458 else: 

459 break 

460 

461 # If we've exhausted all retries, raise the last exception 

462 if last_exception: 

463 raise last_exception 

464 

465 # This should never happen, but just in case 

466 raise httpx.RequestError("Failed to get response after retries", request=None) 

467 

468 def _get_content_type(self, response: httpx.Response) -> str: 

469 """ 

470 Get the content type of the response. 

471 

472 :param response: The response object. 

473 :returns: The content type of the response. 

474 """ 

475 content_type = response.headers.get("Content-Type", "") 

476 return content_type.split(";")[0] 

477 

478 def _resolve_handler(self, content_type: str) -> Callable[[httpx.Response], ByteStream]: 

479 """ 

480 Resolves the handler for the given content type. 

481 

482 First, it tries to find a direct match for the content type in the handlers dictionary. 

483 If no direct match is found, it tries to find a pattern match using the fnmatch function. 

484 If no pattern match is found, it returns the default handler for text/plain. 

485 

486 :param content_type: The content type to resolve the handler for. 

487 :returns: The handler for the given content type, if found. Otherwise, the default handler for text/plain. 

488 """ 

489 # direct match 

490 if content_type in self.handlers: 

491 return self.handlers[content_type] 

492 

493 # pattern matches 

494 for pattern, handler in self.handlers.items(): 

495 if fnmatch(content_type, pattern): 

496 return handler 

497 

498 # default handler 

499 return self.handlers["text/plain"]