perf: harden content extractor - async fetch, single parse, batch cap

- Pages are fetched with httpx.AsyncClient under real connect (3s) and
  read timeouts on the event loop; only the CPU-bound Trafilatura parse
  runs in the thread pool. trafilatura.fetch_url previously ran inside
  the worker thread with no caller-side timeout control, so an
  asyncio.wait_for timeout abandoned the thread while it kept
  downloading for up to ~30s.
- Trafilatura now runs ONCE per document via bare_extraction (text and
  metadata together). The old path parsed three times: extract() for
  text, extract(output_format='xml') whose result was discarded, and
  bare_extraction for metadata.
- extract_batch caps full-page extractions per call (default 8,
  configurable); overflow URLs return unsuccessful results so the web
  leg falls back to the search snippet instead of fanning out unbounded
  downloads per search.
- Responses over 5MB are truncated before parsing; thread-pool queue
  depth is logged for backpressure visibility.

Verified live against a real URL (fetch + single-parse extraction OK).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 14:29:19 +02:00
co-authored by Claude Fable 5
parent c17c623936
commit 69e6a01e65
3 changed files with 316 additions and 190 deletions
+186 -155
View File
@@ -5,6 +5,20 @@ 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
@@ -12,80 +26,131 @@ 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"
}
# Responses larger than this are truncated before parsing (Trafilatura on a
# multi-hundred-MB response would pin a worker thread and exhaust memory).
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
class ContentExtractor:
"""
Generic content extraction client using Trafilatura.
Provides async wrappers around Trafilatura's synchronous extraction,
with support for parallel batch processing and configurable timeouts.
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
max_workers: int = 10,
connect_timeout: float = 3.0,
max_urls_per_batch: Optional[int] = None
):
"""
Initialize ContentExtractor.
Args:
timeout: Per-URL timeout in seconds
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"max_length={max_length}, max_workers={max_workers}"
f"connect_timeout={connect_timeout}s, max_length={max_length}, "
f"max_workers={max_workers}, max_urls_per_batch={self.max_urls_per_batch}"
)
def _extract_sync(
self,
url: str,
include_metadata: bool = True,
max_length: Optional[int] = None
) -> ContentExtractionResult:
async def _fetch(self, url: str) -> Optional[str]:
"""
Synchronous extraction (runs in thread pool).
Args:
url: URL to extract content from
include_metadata: Whether to extract title, author, date
max_length: Override default max length
Fetch a URL asynchronously under real connect/read timeouts.
Returns:
ContentExtractionResult with extracted content or error
Response text (truncated to MAX_RESPONSE_BYTES) or None when the
response is empty / not OK.
Raises:
httpx.HTTPError subclasses on timeout/network errors.
"""
effective_max_length = max_length or self.max_length
response = await self._http.get(url)
if response.status_code != 200 or not response.content:
logger.debug(f"Fetch returned status {response.status_code} for {url}")
return None
if len(response.content) > MAX_RESPONSE_BYTES:
logger.warning(
f"Response for {url} exceeds {MAX_RESPONSE_BYTES} bytes; truncating"
)
return response.content[:MAX_RESPONSE_BYTES].decode(
response.encoding or "utf-8", errors="replace"
)
return response.text
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:
# Fetch the URL
downloaded = trafilatura.fetch_url(url)
if not downloaded:
return ContentExtractionResult(
url=url,
content="",
success=False,
error="Failed to fetch URL"
)
# Extract content
content = trafilatura.extract(
downloaded,
doc = trafilatura.bare_extraction(
html,
url=url or None,
include_comments=False,
include_tables=True,
output_format='txt'
with_metadata=include_metadata
)
content = (doc or {}).get("text") or ""
if not content:
return ContentExtractionResult(
@@ -96,44 +161,16 @@ class ContentExtractor:
)
# Truncate if needed
if len(content) > effective_max_length:
content = content[:effective_max_length] + "..."
# Extract metadata if requested
title = None
author = None
date = None
language = None
if include_metadata:
metadata = trafilatura.extract(
downloaded,
output_format='xml',
include_comments=False
)
# Parse metadata from XML if available
# trafilatura.extract with output_format='xml' returns XML with metadata
# For simplicity, we'll use bare_extraction which returns a dict
try:
meta_dict = trafilatura.bare_extraction(
downloaded,
include_comments=False
)
if meta_dict:
title = meta_dict.get('title')
author = meta_dict.get('author')
date = meta_dict.get('date')
language = meta_dict.get('language')
except Exception as e:
logger.debug(f"Metadata extraction failed for {url}: {e}")
if len(content) > max_length:
content = content[:max_length] + "..."
return ContentExtractionResult(
url=url,
title=title,
title=doc.get("title") if include_metadata else None,
content=content,
author=author,
date=date,
language=language,
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
)
@@ -156,6 +193,9 @@ class ContentExtractor:
"""
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
@@ -164,60 +204,84 @@ class ContentExtractor:
Returns:
ContentExtractionResult with extracted content or error
"""
loop = asyncio.get_event_loop()
try:
result = await asyncio.wait_for(
loop.run_in_executor(
self._executor,
self._extract_sync,
url,
include_metadata,
max_length
),
timeout=self.timeout
)
return result
except asyncio.TimeoutError:
logger.warning(f"Content extraction timed out for {url}")
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"Extraction timed out after {self.timeout}s"
error=f"Fetch timed out after {self.timeout}s"
)
except Exception as e:
logger.error(f"Unexpected error extracting {url}: {e}")
except httpx.HTTPError as e:
logger.warning(f"Fetch failed for {url}: {e}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=str(e)
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_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 urls
for url in fetch_urls
]
results = await asyncio.gather(*tasks)
return list(results)
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,
@@ -238,73 +302,40 @@ class ContentExtractor:
Returns:
ContentExtractionResult with extracted content or error
"""
effective_max_length = max_length or self.max_length
def _extract():
try:
content = trafilatura.extract(
html,
include_comments=False,
include_tables=True,
output_format='txt'
)
if not content:
return ContentExtractionResult(
url=url,
content="",
success=False,
error="No content extracted from HTML"
)
# Truncate if needed
if len(content) > effective_max_length:
content = content[:effective_max_length] + "..."
# Extract metadata
title = None
author = None
date = None
language = None
if include_metadata:
try:
meta_dict = trafilatura.bare_extraction(
html,
include_comments=False
)
if meta_dict:
title = meta_dict.get('title')
author = meta_dict.get('author')
date = meta_dict.get('date')
language = meta_dict.get('language')
except Exception as e:
logger.debug(f"Metadata extraction failed: {e}")
return ContentExtractionResult(
url=url,
title=title,
content=content,
author=author,
date=date,
language=language,
success=True,
error=None
)
except Exception as e:
logger.error(f"HTML content extraction failed: {e}")
return ContentExtractionResult(
url=url,
content="",
success=False,
error=str(e)
)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(self._executor, _extract)
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 thread pool executor."""
"""Shutdown the HTTP client and the thread pool executor."""
await self._http.aclose()
self._executor.shutdown(wait=False)
logger.info("ContentExtractor closed")