Files
library-desk/src/clients/content_extractor.py
T
jpmschweitzerandClaude Fable 5 bd1699115a fix: stream content fetches so the 5MB cap aborts the download
ContentExtractor._fetch used client.get(), buffering the whole body in
memory before the MAX_RESPONSE_BYTES check truncated it - the cap
protected Trafilatura but not memory/bandwidth (a multi-hundred-MB URL
was still fully downloaded, on up to max_urls_per_batch concurrent
fetches, bounded only by the read timeout).

Fetches now stream via client.stream + aiter_bytes and close the
connection as soon as the cap is reached; charset still comes from the
Content-Type header, available before the body is read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 15:21:05 +02:00

367 lines
13 KiB
Python

"""
Content extraction client for Library Desk.
A reusable Trafilatura wrapper that can be used throughout library-desk:
- RAG search service (extract content from search results)
- Ingestion service (extract content from URLs)
- Standalone endpoint (ad-hoc content extraction)
Hardening notes:
- Pages are fetched with httpx.AsyncClient under real connect/read timeouts
on the event loop. Only the CPU-bound Trafilatura parse runs in the thread
pool, so a slow server can no longer pin a worker thread for the duration
of a blind blocking download (trafilatura.fetch_url had no caller-side
timeout control and kept downloading after asyncio.wait_for gave up).
- Trafilatura runs ONCE per document (bare_extraction returns text and
metadata together); the old code ran extract() twice (the XML pass was
computed and thrown away) plus bare_extraction — three full parses.
- extract_batch caps the number of full-page extractions per call; overflow
URLs are returned as unsuccessful results so callers fall back to the
search-engine snippet.
- Thread-pool queue depth is logged so extraction backpressure is visible.
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional
import httpx
import trafilatura
from src.models.content import ContentExtractionResult
logger = logging.getLogger(__name__)
# Modest but honest identification; some sites reject empty user agents.
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) LibraryDesk-ContentExtractor"
}
# Downloads are streamed and ABORTED past this many bytes (protects memory
# and bandwidth during the fetch, and keeps Trafilatura from pinning a
# worker thread on a multi-hundred-MB response).
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
class ContentExtractor:
"""
Generic content extraction client using Trafilatura.
Fetches pages asynchronously via httpx and runs Trafilatura's
synchronous extraction in a thread pool, with support for parallel
batch processing and configurable timeouts.
"""
# Hard cap on full-page extractions per extract_batch call (e.g. one
# web-search leg). Overflow URLs get an unsuccessful result and callers
# fall back to the search snippet.
DEFAULT_MAX_URLS_PER_BATCH = 8
def __init__(
self,
timeout: int = 5,
max_length: int = 2000,
max_workers: int = 10,
connect_timeout: float = 3.0,
max_urls_per_batch: Optional[int] = None
):
"""
Initialize ContentExtractor.
Args:
timeout: Per-URL read/parse timeout in seconds
max_length: Maximum content length to return (truncated if longer)
max_workers: Max concurrent extractions for batch operations
connect_timeout: TCP/TLS connect timeout in seconds
max_urls_per_batch: Cap on full-page extractions per
extract_batch call (None = DEFAULT_MAX_URLS_PER_BATCH)
"""
self.timeout = timeout
self.max_length = max_length
self.max_urls_per_batch = (
max_urls_per_batch
if max_urls_per_batch is not None
else self.DEFAULT_MAX_URLS_PER_BATCH
)
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self._http = httpx.AsyncClient(
timeout=httpx.Timeout(timeout, connect=connect_timeout),
follow_redirects=True,
headers=DEFAULT_HEADERS,
limits=httpx.Limits(
max_connections=max_workers,
max_keepalive_connections=max_workers
),
)
logger.info(
f"Initialized ContentExtractor: timeout={timeout}s, "
f"connect_timeout={connect_timeout}s, max_length={max_length}, "
f"max_workers={max_workers}, max_urls_per_batch={self.max_urls_per_batch}"
)
async def _fetch(self, url: str) -> Optional[str]:
"""
Fetch a URL asynchronously under real connect/read timeouts.
The body is STREAMED and the download is aborted as soon as
MAX_RESPONSE_BYTES have been received, so the cap bounds memory and
bandwidth during the download itself (the old implementation
buffered the entire response before truncating, so a
multi-hundred-MB URL was still fully downloaded).
Returns:
Response text (capped at MAX_RESPONSE_BYTES) or None when the
response is empty / not OK.
Raises:
httpx.HTTPError subclasses on timeout/network errors.
"""
async with self._http.stream("GET", url) as response:
if response.status_code != 200:
logger.debug(
f"Fetch returned status {response.status_code} for {url}"
)
return None
chunks: List[bytes] = []
received = 0
truncated = False
async for chunk in response.aiter_bytes():
if received + len(chunk) >= MAX_RESPONSE_BYTES:
chunks.append(chunk[: MAX_RESPONSE_BYTES - received])
truncated = True
break
chunks.append(chunk)
received += len(chunk)
body = b"".join(chunks)
if not body:
return None
if truncated:
logger.warning(
f"Response for {url} exceeds {MAX_RESPONSE_BYTES} bytes; "
"download aborted and body truncated"
)
# charset comes from the Content-Type header, available before
# the body is read.
encoding = response.charset_encoding or "utf-8"
return body.decode(encoding, errors="replace")
def _log_queue_depth(self, context: str) -> None:
"""Log thread-pool queue depth so extraction backpressure is visible."""
depth = self._executor._work_queue.qsize()
if depth > 0:
logger.info(f"ContentExtractor thread-pool queue depth ({context}): {depth}")
@staticmethod
def _extract_html_sync(
html: str,
url: str,
include_metadata: bool,
max_length: int
) -> ContentExtractionResult:
"""
Synchronous Trafilatura pass (runs in the thread pool).
Runs bare_extraction ONCE — it returns text and metadata together
(the old implementation parsed the document three times).
"""
try:
doc = trafilatura.bare_extraction(
html,
url=url or None,
include_comments=False,
include_tables=True,
with_metadata=include_metadata
)
content = (doc or {}).get("text") or ""
if not content:
return ContentExtractionResult(
url=url,
content="",
success=False,
error="No content extracted"
)
# Truncate if needed
if len(content) > max_length:
content = content[:max_length] + "..."
return ContentExtractionResult(
url=url,
title=doc.get("title") if include_metadata else None,
content=content,
author=doc.get("author") if include_metadata else None,
date=doc.get("date") if include_metadata else None,
language=doc.get("language") if include_metadata else None,
success=True,
error=None
)
except Exception as e:
logger.error(f"Content extraction failed for {url}: {e}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=str(e)
)
async def extract(
self,
url: str,
include_metadata: bool = True,
max_length: Optional[int] = None
) -> ContentExtractionResult:
"""
Extract content from a single URL asynchronously.
The download happens on the event loop under httpx connect/read
timeouts; only the parse occupies a worker thread.
Args:
url: URL to extract content from
include_metadata: Whether to extract title, author, date
max_length: Override default max length
Returns:
ContentExtractionResult with extracted content or error
"""
try:
html = await self._fetch(url)
except httpx.TimeoutException:
logger.warning(f"Fetch timed out for {url}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=f"Fetch timed out after {self.timeout}s"
)
except httpx.HTTPError as e:
logger.warning(f"Fetch failed for {url}: {e}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=f"Failed to fetch URL: {e}"
)
if not html:
return ContentExtractionResult(
url=url,
content="",
success=False,
error="Failed to fetch URL"
)
return await self.extract_from_html(html, url, include_metadata, max_length)
async def extract_batch(
self,
urls: List[str],
include_metadata: bool = True,
max_length: Optional[int] = None,
max_urls: Optional[int] = None
) -> List[ContentExtractionResult]:
"""
Extract content from multiple URLs in parallel.
At most max_urls (default: max_urls_per_batch) URLs get a full-page
extraction; the rest are returned unsuccessful so callers fall back
to their existing snippet.
Args:
urls: List of URLs to extract content from
include_metadata: Whether to extract title, author, date
max_length: Override default max length
max_urls: Override the per-call full-page extraction cap
Returns:
List of ContentExtractionResult in same order as input URLs
"""
cap = max_urls if max_urls is not None else self.max_urls_per_batch
fetch_urls = urls[:cap]
skipped_urls = urls[cap:]
if skipped_urls:
logger.info(
f"extract_batch capped at {cap} full-page extractions; "
f"skipping {len(skipped_urls)} of {len(urls)} URLs"
)
self._log_queue_depth("extract_batch")
tasks = [
self.extract(url, include_metadata, max_length)
for url in fetch_urls
]
results = list(await asyncio.gather(*tasks))
results.extend(
ContentExtractionResult(
url=url,
content="",
success=False,
error=f"Skipped: per-call extraction cap ({cap}) reached"
)
for url in skipped_urls
)
return results
async def extract_from_html(
self,
html: str,
url: str = "",
include_metadata: bool = True,
max_length: Optional[int] = None
) -> ContentExtractionResult:
"""
Extract content from raw HTML string.
Args:
html: Raw HTML content
url: Optional URL for reference (not fetched)
include_metadata: Whether to extract title, author, date
max_length: Override default max length
Returns:
ContentExtractionResult with extracted content or error
"""
loop = asyncio.get_event_loop()
self._log_queue_depth("extract_from_html")
try:
return await asyncio.wait_for(
loop.run_in_executor(
self._executor,
self._extract_html_sync,
html,
url,
include_metadata,
max_length or self.max_length
),
timeout=self.timeout
)
except asyncio.TimeoutError:
logger.warning(f"Content extraction timed out for {url or '<raw html>'}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=f"Extraction timed out after {self.timeout}s"
)
except Exception as e:
logger.error(f"Unexpected error extracting {url or '<raw html>'}: {e}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=str(e)
)
async def close(self):
"""Shutdown the HTTP client and the thread pool executor."""
await self._http.aclose()
self._executor.shutdown(wait=False)
logger.info("ContentExtractor closed")