c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
494 lines
20 KiB
Python
494 lines
20 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import replace
|
|
from fnmatch import fnmatch
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
|
|
from haystack import component, logging
|
|
from haystack.dataclasses import ByteStream
|
|
from haystack.lazy_imports import LazyImport
|
|
from haystack.version import __version__
|
|
|
|
# HTTP/2 support via lazy import
|
|
with LazyImport("Run 'pip install httpx[http2]' to use HTTP/2 support") as h2_import:
|
|
pass # nothing to import as we simply set the http2 attribute, library handles the rest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_USER_AGENT = f"haystack/LinkContentFetcher/{__version__}"
|
|
|
|
REQUEST_HEADERS = {
|
|
"accept": "*/*",
|
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
"Accept-Language": "en-US,en;q=0.9,it;q=0.8,es;q=0.7",
|
|
"referer": "https://www.google.com/",
|
|
}
|
|
|
|
|
|
def _merge_headers(*args: dict[str, str]) -> dict[str, str]:
|
|
"""
|
|
Merge a list of dict using case-insensitively
|
|
|
|
:param args: a list of dict to merge
|
|
:returns: The merged dict
|
|
"""
|
|
merged = {}
|
|
keymap = {}
|
|
|
|
for d in args:
|
|
for k, v in d.items():
|
|
kl = k.lower()
|
|
keymap[kl] = k
|
|
merged[kl] = v
|
|
|
|
return {keymap[kl]: v for kl, v in merged.items()}
|
|
|
|
|
|
def _text_content_handler(response: httpx.Response) -> ByteStream:
|
|
"""
|
|
Handles text content.
|
|
|
|
:param response: Response object from the request.
|
|
:returns: The extracted text.
|
|
"""
|
|
return ByteStream.from_string(response.text)
|
|
|
|
|
|
def _binary_content_handler(response: httpx.Response) -> ByteStream:
|
|
"""
|
|
Handles binary content.
|
|
|
|
:param response: Response object from the request.
|
|
:returns: The extracted binary file-like object.
|
|
"""
|
|
return ByteStream(data=response.content)
|
|
|
|
|
|
@component
|
|
class LinkContentFetcher:
|
|
"""
|
|
Fetches and extracts content from URLs.
|
|
|
|
It supports various content types, retries on failures, and automatic user-agent rotation for failed web
|
|
requests. Use it as the data-fetching step in your pipelines.
|
|
|
|
You may need to convert LinkContentFetcher's output into a list of documents. Use HTMLToDocument
|
|
converter to do this.
|
|
|
|
### Usage example
|
|
|
|
```python
|
|
from haystack.components.fetchers.link_content import LinkContentFetcher
|
|
|
|
fetcher = LinkContentFetcher()
|
|
streams = fetcher.run(urls=["https://www.google.com"])["streams"]
|
|
|
|
assert len(streams) == 1
|
|
assert streams[0].meta == {'content_type': 'text/html', 'url': 'https://www.google.com'}
|
|
assert streams[0].data
|
|
```
|
|
|
|
For async usage:
|
|
|
|
```python
|
|
import asyncio
|
|
from haystack.components.fetchers import LinkContentFetcher
|
|
|
|
async def fetch_async():
|
|
fetcher = LinkContentFetcher()
|
|
result = await fetcher.run_async(urls=["https://www.google.com"])
|
|
return result["streams"]
|
|
|
|
streams = asyncio.run(fetch_async())
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
raise_on_failure: bool = True,
|
|
user_agents: list[str] | None = None,
|
|
retry_attempts: int = 2,
|
|
timeout: int = 3,
|
|
http2: bool = False,
|
|
client_kwargs: dict | None = None,
|
|
request_headers: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""
|
|
Initializes the component.
|
|
|
|
:param raise_on_failure: If `True`, raises an exception if it fails to fetch a single URL.
|
|
For multiple URLs, it logs errors and returns the content it successfully fetched.
|
|
:param user_agents: [User agents](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent)
|
|
for fetching content. If `None`, a default user agent is used.
|
|
:param retry_attempts: The number of times to retry to fetch the URL's content.
|
|
:param timeout: Timeout in seconds for the request.
|
|
:param http2: Whether to enable HTTP/2 support for requests. Defaults to False.
|
|
Requires the 'h2' package to be installed (via `pip install httpx[http2]`).
|
|
:param client_kwargs: Additional keyword arguments to pass to the httpx client.
|
|
If `None`, default values are used.
|
|
"""
|
|
self.raise_on_failure = raise_on_failure
|
|
self.user_agents = user_agents or [DEFAULT_USER_AGENT]
|
|
self.current_user_agent_idx: int = 0
|
|
self.retry_attempts = retry_attempts
|
|
self.timeout = timeout
|
|
self.http2 = http2
|
|
self.client_kwargs = client_kwargs or {}
|
|
self.request_headers = request_headers or {}
|
|
|
|
# Configure default client settings
|
|
self.client_kwargs.setdefault("timeout", timeout)
|
|
self.client_kwargs.setdefault("follow_redirects", True)
|
|
|
|
# httpx clients are built lazily in warm_up / warm_up_async (resource lifecycle)
|
|
self._client: httpx.Client | None = None
|
|
self._async_client: httpx.AsyncClient | None = None
|
|
|
|
# register default content handlers that extract data from the response
|
|
self.handlers: dict[str, Callable[[httpx.Response], ByteStream]] = defaultdict(lambda: _text_content_handler)
|
|
self.handlers["text/*"] = _text_content_handler
|
|
self.handlers["text/html"] = _binary_content_handler
|
|
self.handlers["application/json"] = _text_content_handler
|
|
self.handlers["application/*"] = _binary_content_handler
|
|
self.handlers["image/*"] = _binary_content_handler
|
|
self.handlers["audio/*"] = _binary_content_handler
|
|
self.handlers["video/*"] = _binary_content_handler
|
|
|
|
@retry(
|
|
reraise=True,
|
|
stop=stop_after_attempt(self.retry_attempts),
|
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
retry=(retry_if_exception_type((httpx.HTTPStatusError, httpx.RequestError))),
|
|
# This method is invoked only after failed requests (exception raised)
|
|
after=self._switch_user_agent,
|
|
)
|
|
def get_response(url: str) -> httpx.Response:
|
|
assert self._client is not None # mypy: client is built by warm_up before run
|
|
response = self._client.get(url, headers=self._get_headers())
|
|
response.raise_for_status()
|
|
return response
|
|
|
|
self._get_response: Callable = get_response
|
|
|
|
def _build_client_kwargs(self) -> dict[str, Any]:
|
|
"""
|
|
Build the keyword arguments used to construct the httpx clients.
|
|
|
|
Resolves optional HTTP/2 support, downgrading to HTTP/1.1 if the 'h2' package is not installed.
|
|
"""
|
|
client_kwargs = {**self.client_kwargs}
|
|
|
|
# Optional HTTP/2 support
|
|
if self.http2:
|
|
try:
|
|
h2_import.check()
|
|
client_kwargs["http2"] = True
|
|
except ImportError:
|
|
logger.warning(
|
|
"HTTP/2 support requested but 'h2' package is not installed. "
|
|
"Falling back to HTTP/1.1. Install with `pip install httpx[http2]` to enable HTTP/2 support."
|
|
)
|
|
self.http2 = False # Update the setting to match actual capability
|
|
|
|
return client_kwargs
|
|
|
|
def warm_up(self) -> None:
|
|
"""
|
|
Initializes the synchronous httpx client.
|
|
"""
|
|
if self._client is None:
|
|
self._client = httpx.Client(**self._build_client_kwargs())
|
|
|
|
async def warm_up_async(self) -> None: # noqa: RUF029
|
|
"""
|
|
Initializes the asynchronous httpx client on the serving event loop.
|
|
"""
|
|
if self._async_client is None:
|
|
self._async_client = httpx.AsyncClient(**self._build_client_kwargs())
|
|
|
|
def close(self) -> None:
|
|
"""
|
|
Releases the synchronous httpx client.
|
|
"""
|
|
if self._client is not None:
|
|
self._client.close()
|
|
self._client = None
|
|
|
|
async def close_async(self) -> None:
|
|
"""
|
|
Releases the asynchronous httpx client.
|
|
"""
|
|
if self._async_client is not None:
|
|
await self._async_client.aclose()
|
|
self._async_client = None
|
|
|
|
def _get_headers(self) -> dict[str, str]:
|
|
"""
|
|
Build headers with precedence
|
|
|
|
client defaults -> component defaults -> user-provided -> rotating UA
|
|
"""
|
|
base = dict(self._client.headers) if self._client is not None else {}
|
|
return _merge_headers(
|
|
base, REQUEST_HEADERS, self.request_headers, {"User-Agent": self.user_agents[self.current_user_agent_idx]}
|
|
)
|
|
|
|
@component.output_types(streams=list[ByteStream])
|
|
def run(self, urls: list[str]) -> dict[str, Any]:
|
|
"""
|
|
Fetches content from a list of URLs and returns a list of extracted content streams.
|
|
|
|
Each content stream is a `ByteStream` object containing the extracted content as binary data.
|
|
Each ByteStream object in the returned list corresponds to the contents of a single URL.
|
|
The content type of each stream is stored in the metadata of the ByteStream object under
|
|
the key "content_type". The URL of the fetched content is stored under the key "url".
|
|
|
|
:param urls: A list of URLs to fetch content from.
|
|
:returns: `ByteStream` objects representing the extracted content.
|
|
|
|
:raises Exception: If the provided list of URLs contains only a single URL, and `raise_on_failure` is set to
|
|
`True`, an exception will be raised in case of an error during content retrieval.
|
|
In all other scenarios, any retrieval errors are logged, and a list of successfully retrieved `ByteStream`
|
|
objects is returned.
|
|
"""
|
|
self.warm_up()
|
|
|
|
streams: list[ByteStream] = []
|
|
if not urls:
|
|
return {"streams": streams}
|
|
|
|
# don't use multithreading if there's only one URL
|
|
if len(urls) == 1:
|
|
stream_metadata, stream = self._fetch(urls[0])
|
|
stream.meta.update(stream_metadata)
|
|
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
|
streams.append(stream)
|
|
else:
|
|
with ThreadPoolExecutor() as executor:
|
|
results = executor.map(self._fetch_with_exception_suppression, urls)
|
|
|
|
for stream_metadata, stream in results: # type: ignore
|
|
if stream_metadata is not None and stream is not None:
|
|
stream.meta.update(stream_metadata)
|
|
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
|
streams.append(stream)
|
|
|
|
return {"streams": streams}
|
|
|
|
@component.output_types(streams=list[ByteStream])
|
|
async def run_async(self, urls: list[str]) -> dict[str, Any]:
|
|
"""
|
|
Asynchronously fetches content from a list of URLs and returns a list of extracted content streams.
|
|
|
|
This is the asynchronous version of the `run` method with the same parameters and return values.
|
|
|
|
:param urls: A list of URLs to fetch content from.
|
|
:returns: `ByteStream` objects representing the extracted content.
|
|
"""
|
|
await self.warm_up_async()
|
|
|
|
streams: list[ByteStream] = []
|
|
if not urls:
|
|
return {"streams": streams}
|
|
|
|
assert self._async_client is not None # mypy: async_client is built by warm_up_async above
|
|
# Create tasks for all URLs using _fetch_async directly
|
|
tasks = [self._fetch_async(url, self._async_client) for url in urls]
|
|
|
|
# Only capture exceptions when we have multiple URLs or raise_on_failure=False
|
|
# This ensures errors propagate appropriately for single URLs with raise_on_failure=True
|
|
return_exceptions = not (len(urls) == 1 and self.raise_on_failure)
|
|
results = await asyncio.gather(*tasks, return_exceptions=return_exceptions)
|
|
|
|
# Process results
|
|
for i, result in enumerate(results):
|
|
# Handle exception results (only happens when return_exceptions=True)
|
|
if isinstance(result, Exception):
|
|
logger.warning("Error fetching {url}: {error}", url=urls[i], error=str(result))
|
|
# Add an empty result for failed URLs when raise_on_failure=False
|
|
if not self.raise_on_failure:
|
|
streams.append(ByteStream(data=b"", meta={"content_type": "Unknown", "url": urls[i]}))
|
|
continue
|
|
|
|
# Process successful results
|
|
# At this point, result is not an exception, so we need to cast it to the correct type for mypy
|
|
if not isinstance(result, Exception): # Runtime check
|
|
# Use cast to tell mypy that result is the tuple type returned by _fetch_async
|
|
result_tuple = cast(tuple[dict[str, str] | None, ByteStream | None], result)
|
|
stream_metadata, stream = result_tuple
|
|
if stream_metadata is not None and stream is not None:
|
|
stream.meta.update(stream_metadata)
|
|
stream = replace(stream, mime_type=stream.meta.get("content_type", None))
|
|
streams.append(stream)
|
|
|
|
return {"streams": streams}
|
|
|
|
def _fetch(self, url: str) -> tuple[dict[str, str], ByteStream]:
|
|
"""
|
|
Fetches content from a URL and returns it as a ByteStream.
|
|
|
|
:param url: The URL to fetch content from.
|
|
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
|
ByteStream metadata contains the URL and the content type of the fetched content.
|
|
The content type is a string indicating the type of content fetched (for example, "text/html",
|
|
"application/pdf"). The ByteStream object contains the fetched content as binary data.
|
|
|
|
:raises: If an error occurs during content retrieval and `raise_on_failure` is set to True, this method will
|
|
raise an exception. Otherwise, all fetching errors are logged, and an empty ByteStream is returned.
|
|
|
|
"""
|
|
content_type: str = "text/html"
|
|
stream: ByteStream = ByteStream(data=b"")
|
|
try:
|
|
response = self._get_response(url)
|
|
content_type = self._get_content_type(response)
|
|
handler: Callable = self._resolve_handler(content_type)
|
|
stream = handler(response)
|
|
except Exception as e:
|
|
if self.raise_on_failure:
|
|
raise e
|
|
# less verbose log as this is expected to happen often (requests failing, blocked, etc.)
|
|
logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e))
|
|
|
|
finally:
|
|
self.current_user_agent_idx = 0
|
|
|
|
return {"content_type": content_type, "url": url}, stream
|
|
|
|
async def _fetch_async(
|
|
self, url: str, client: httpx.AsyncClient
|
|
) -> tuple[dict[str, str] | None, ByteStream | None]:
|
|
"""
|
|
Asynchronously fetches content from a URL and returns it as a ByteStream.
|
|
|
|
:param url: The URL to fetch content from.
|
|
:param client: The async httpx client to use for making requests.
|
|
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
|
"""
|
|
content_type: str = "text/html"
|
|
stream: ByteStream | None = None
|
|
metadata: dict[str, str] | None = None
|
|
|
|
try:
|
|
response = await self._get_response_async(url, client)
|
|
content_type = self._get_content_type(response)
|
|
handler: Callable = self._resolve_handler(content_type)
|
|
stream = handler(response)
|
|
metadata = {"content_type": content_type, "url": url}
|
|
except Exception as e:
|
|
if self.raise_on_failure:
|
|
raise e
|
|
logger.debug("Couldn't retrieve content from {url} because {error}", url=url, error=str(e))
|
|
# Create an empty ByteStream for failed requests when raise_on_failure is False
|
|
stream = ByteStream(data=b"")
|
|
metadata = {"content_type": content_type, "url": url}
|
|
finally:
|
|
self.current_user_agent_idx = 0
|
|
|
|
return metadata, stream
|
|
|
|
def _fetch_with_exception_suppression(self, url: str) -> tuple[dict[str, str] | None, ByteStream | None]:
|
|
"""
|
|
Fetches content from a URL and returns it as a ByteStream.
|
|
|
|
If `raise_on_failure` is set to True, this method will wrap the fetch() method and catch any exceptions.
|
|
Otherwise, it will simply call the fetch() method.
|
|
:param url: The URL to fetch content from.
|
|
:returns: A tuple containing the ByteStream metadata dict and the corresponding ByteStream.
|
|
|
|
"""
|
|
if self.raise_on_failure:
|
|
try:
|
|
return self._fetch(url)
|
|
except Exception as e:
|
|
logger.warning("Error fetching {url}: {error}", url=url, error=str(e))
|
|
return {"content_type": "Unknown", "url": url}, None
|
|
else:
|
|
return self._fetch(url)
|
|
|
|
async def _get_response_async(self, url: str, client: httpx.AsyncClient) -> httpx.Response:
|
|
"""
|
|
Asynchronously gets a response from a URL with retry logic.
|
|
|
|
:param url: The URL to fetch.
|
|
:param client: The async httpx client to use for making requests.
|
|
:returns: The httpx Response object.
|
|
"""
|
|
attempt = 0
|
|
last_exception = None
|
|
|
|
while attempt <= self.retry_attempts:
|
|
try:
|
|
response = await client.get(url, headers=self._get_headers())
|
|
response.raise_for_status()
|
|
return response
|
|
except (httpx.HTTPStatusError, httpx.RequestError) as e:
|
|
last_exception = e
|
|
attempt += 1
|
|
if attempt <= self.retry_attempts:
|
|
self._switch_user_agent(None) # Switch user agent for next retry
|
|
# Wait before retry using exponential backoff
|
|
await asyncio.sleep(min(2 * 2 ** (attempt - 1), 10))
|
|
else:
|
|
break
|
|
|
|
# If we've exhausted all retries, raise the last exception
|
|
if last_exception:
|
|
raise last_exception
|
|
|
|
# This should never happen, but just in case
|
|
raise httpx.RequestError("Failed to get response after retries", request=None)
|
|
|
|
def _get_content_type(self, response: httpx.Response) -> str:
|
|
"""
|
|
Get the content type of the response.
|
|
|
|
:param response: The response object.
|
|
:returns: The content type of the response.
|
|
"""
|
|
content_type = response.headers.get("Content-Type", "")
|
|
return content_type.split(";")[0]
|
|
|
|
def _resolve_handler(self, content_type: str) -> Callable[[httpx.Response], ByteStream]:
|
|
"""
|
|
Resolves the handler for the given content type.
|
|
|
|
First, it tries to find a direct match for the content type in the handlers dictionary.
|
|
If no direct match is found, it tries to find a pattern match using the fnmatch function.
|
|
If no pattern match is found, it returns the default handler for text/plain.
|
|
|
|
:param content_type: The content type to resolve the handler for.
|
|
:returns: The handler for the given content type, if found. Otherwise, the default handler for text/plain.
|
|
"""
|
|
# direct match
|
|
if content_type in self.handlers:
|
|
return self.handlers[content_type]
|
|
|
|
# pattern matches
|
|
for pattern, handler in self.handlers.items():
|
|
if fnmatch(content_type, pattern):
|
|
return handler
|
|
|
|
# default handler
|
|
return self.handlers["text/plain"]
|
|
|
|
def _switch_user_agent(self, retry_state: RetryCallState | None = None) -> None: # noqa: ARG002
|
|
"""
|
|
Switches the User-Agent for this LinkContentRetriever to the next one in the list of user agents.
|
|
|
|
Used by tenacity to retry the requests with a different user agent.
|
|
|
|
:param retry_state: The retry state (unused, required by tenacity).
|
|
"""
|
|
self.current_user_agent_idx = (self.current_user_agent_idx + 1) % len(self.user_agents)
|
|
logger.debug("Switched user agent to {user_agent}", user_agent=self.user_agents[self.current_user_agent_idx])
|