arborist/aborist/sources/crawler/web_fetch.py
russell@unturf.com a5e709a470
crawler: verbatim lift from agents.ai.unturf.com (off by default)
Lifts the async web fetcher into aborist as an opt-in source. The
implementation comes directly from ~/git/agents.ai.unturf.com/core
(rev 2026-04-28); aborist's adaptations are minimal and documented in
aborist/sources/crawler/__init__.py:

  core/async_web_fetcher.py  ->  aborist/sources/crawler/async_web_fetcher.py
  core/web_fetch.py          ->  aborist/sources/crawler/web_fetch.py

Two source-side changes during the lift:

1. Heavy deps (aiohttp, bs4, miniuri) wrapped in try/except so a bare
   `import aborist.sources.crawler` raises ImportError with the install
   hint instead of leaking AttributeErrors deep in user code.

2. Chat-bot fetch triggers (`has_fresh_fetch_trigger`,
   `has_web_fetch_trigger` from agents.ai.unturf.com/core/keywords)
   replaced with NotImplementedError stubs. Aborist has no chat
   surface — fetch intent is detected at the application layer. The
   two test classes that exercised these triggers are
   `@pytest.mark.skip`'d with the same rationale.

Not lifted: web_cache_manager.py — it backs page caching with
SQLAlchemy. Aborist has its own content-addressed cache via
providence_cache; no need to carry SQLAlchemy as a dep just for
crawled-page memoization.

Off by default:

- `[crawler]` extras section in pyproject.toml carries the heavy
  deps. `[dev]` pulls them in so the crawler tests can run.
- `make test` ignores tests/crawler/ entirely.
- `make bootstrap-crawler` installs the extras into the venv.
- `make test-crawler` runs only the lifted tests after extras land.

Tests: 74 passed, 9 skipped (the chat-bot trigger tests deliberately
dropped). Default `make test` stays at 273 passed, 1 skipped.
2026-04-28 20:23:36 -04:00

520 lines
18 KiB
Python

"""
Web Fetch Module - Platform-agnostic web content fetching.
This module provides URL extraction, web content fetching, and source citation
building without platform-specific dependencies.
"""
import re
import logging
from enum import Enum
from typing import Optional, Dict, List, Callable, Awaitable
# Heavy crawler dependencies are optional. Install via:
# pip install 'aborist[crawler]'
try:
from miniuri import Uri
except ImportError as e: # pragma: no cover
raise ImportError(
"crawler module requires extras: pip install 'aborist[crawler]'"
) from e
# `core.keywords` lived in agents.ai.unturf.com and detected fetch-intent
# from chat-bot messages. Aborist has no chat surface, so the verbatim
# lift drops those triggers. Callers asking "is this content a fetch
# request?" should adapt at the application layer instead.
def has_fresh_fetch_trigger(content: str) -> bool: # pragma: no cover
raise NotImplementedError(
"chat-bot fetch triggers were dropped during the aborist lift; "
"callers should detect fetch intent at the application layer"
)
def has_web_fetch_trigger(content: str) -> bool: # pragma: no cover
raise NotImplementedError(
"chat-bot fetch triggers were dropped during the aborist lift; "
"callers should detect fetch intent at the application layer"
)
logger = logging.getLogger(__name__)
class URIContentType(Enum):
"""Content type categories for URIs."""
PAGE = "page" # HTML, text pages - fetch and extract
PDF = "pdf" # PDF documents - fetch and extract
IMAGE = "image" # Images (png, jpg, gif, webp) - use vision
SVG = "svg" # SVG - convert to PNG then vision
VIDEO = "video" # Video - skip or extract metadata
AUDIO = "audio" # Audio - skip or transcribe
UNKNOWN = "unknown" # Unknown - try fetch, fallback to vision
# Content-type mapping
CONTENT_TYPE_MAP = {
# Pages
'text/html': URIContentType.PAGE,
'application/xhtml+xml': URIContentType.PAGE,
'text/plain': URIContentType.PAGE,
'text/markdown': URIContentType.PAGE,
'application/json': URIContentType.PAGE,
'application/xml': URIContentType.PAGE,
'text/xml': URIContentType.PAGE,
# PDF
'application/pdf': URIContentType.PDF,
# Images
'image/png': URIContentType.IMAGE,
'image/jpeg': URIContentType.IMAGE,
'image/gif': URIContentType.IMAGE,
'image/webp': URIContentType.IMAGE,
'image/bmp': URIContentType.IMAGE,
'image/tiff': URIContentType.IMAGE,
# SVG (special handling)
'image/svg+xml': URIContentType.SVG,
# Video
'video/mp4': URIContentType.VIDEO,
'video/webm': URIContentType.VIDEO,
'video/ogg': URIContentType.VIDEO,
# Audio
'audio/mpeg': URIContentType.AUDIO,
'audio/ogg': URIContentType.AUDIO,
'audio/wav': URIContentType.AUDIO,
}
async def detect_uri_content_type(url: str, session=None) -> URIContentType:
"""
Detect content type of URI using HEAD request.
Args:
url: URI to check
session: Optional aiohttp session to reuse
Returns:
URIContentType enum value
"""
import aiohttp
try:
uri = Uri(url)
path = uri.path or ''
# Make HEAD request
close_session = False
if session is None:
session = aiohttp.ClientSession()
close_session = True
try:
async with session.head(
url,
allow_redirects=True,
timeout=aiohttp.ClientTimeout(total=10),
headers={'User-Agent': 'uncloseai.com/1.42 (ethical web crawler; +https://uncloseai.com)'}
) as response:
content_type = response.headers.get('Content-Type', '')
# Strip charset etc: "text/html; charset=utf-8" -> "text/html"
mime_type = content_type.split(';')[0].strip().lower()
logger.info(f"HEAD {url} -> {response.status}, Content-Type: {mime_type}")
if mime_type in CONTENT_TYPE_MAP:
return CONTENT_TYPE_MAP[mime_type]
# Fallback heuristics based on extension
if path.endswith('.pdf'):
return URIContentType.PDF
elif path.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
return URIContentType.IMAGE
elif path.endswith('.svg'):
return URIContentType.SVG
# Default to page for unknown
return URIContentType.PAGE
finally:
if close_session:
await session.close()
except Exception as e:
logger.warning(f"Failed to detect content type for {url}: {e}")
return URIContentType.UNKNOWN
def extract_all_uris_from_content(content: str) -> List[str]:
"""
Extract ALL URIs from message content using regex.
This function bypasses LLM tool calling to prevent URI hallucination.
It extracts all URIs with protocol first, then any domain-like patterns.
URIs inside code blocks are excluded.
Args:
content: Message content to extract URIs from
Returns:
List of extracted URI strings (may be empty)
Examples:
>>> extract_all_uris_from_content("Check https://a.com and https://b.com/file.css")
['https://a.com', 'https://b.com/file.css']
>>> extract_all_uris_from_content("```python\\nurl = 'https://ignore.me'\\n```\\nVisit https://real.com")
['https://real.com']
"""
# Strip Matrix mentions first (e.g., @user:matrix.org) to avoid matching them as URIs
content_cleaned = re.sub(r'@[\w.-]+:[\w.-]+', '', content)
# Remove code blocks (``` ... ```) to avoid extracting URIs from code
content_cleaned = re.sub(r'```[\s\S]*?```', '', content_cleaned)
# Also remove inline code (`...`)
content_cleaned = re.sub(r'`[^`]+`', '', content_cleaned)
uris = []
# Find all URIs with protocol
for match in re.finditer(r'https?://[^\s<>]+', content_cleaned):
uri = match.group(0)
if uri not in uris:
uris.append(uri)
logger.info(f"Extracted URI with protocol: {uri}")
# Find URIs without protocol (e.g., "example.com", "media.unturf.com/test")
# Exclude common file extensions that look like TLDs
file_extensions = {'html', 'htm', 'css', 'js', 'jsx', 'tsx', 'ts', 'json', 'xml', 'svg',
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'txt', 'md',
'py', 'java', 'cpp', 'hpp', 'go', 'rs', 'rb', 'php', 'sh', 'bash',
'yaml', 'yml', 'toml', 'ini', 'conf', 'log', 'csv', 'sql'}
for match in re.finditer(r'(?:www\.)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:/[^\s<>]*)?', content_cleaned):
matched = match.group(0)
# Check if this is followed by :number (error line reference like "file.html:98:35")
end_pos = match.end()
if end_pos < len(content_cleaned) and content_cleaned[end_pos] == ':':
# Check if followed by digits
remaining = content_cleaned[end_pos+1:]
if remaining and remaining[0].isdigit():
logger.debug(f"Skipping error reference pattern: {matched}")
continue
# Extract the TLD (last part after final dot, before any path)
domain_part = matched.split('/')[0] # Get just the domain
tld = domain_part.split('.')[-1].lower()
# Skip if it looks like a filename (TLD is a file extension)
if tld in file_extensions:
logger.debug(f"Skipping filename-like pattern: {matched}")
continue
uri = f"https://{matched}"
# Check it wasn't already captured with protocol
if uri not in uris and not any(uri in existing for existing in uris):
uris.append(uri)
logger.info(f"Extracted URI without protocol, added https://: {uri}")
if not uris:
logger.debug("No URIs found in content")
return uris
def extract_url_from_content(content: str) -> Optional[str]:
"""
Extract first URL from message content using regex.
This function bypasses LLM tool calling to prevent URL hallucination.
It first tries to match URLs with protocol (http:// or https://),
then falls back to matching domain-like patterns and prepending https://.
Args:
content: Message content to extract URL from
Returns:
Extracted URL string or None if no URL found
Examples:
>>> extract_url_from_content("Check out https://example.com")
'https://example.com'
>>> extract_url_from_content("Visit example.com")
'https://example.com'
>>> extract_url_from_content("What about media.unturf.com/test")
'https://media.unturf.com/test'
"""
uris = extract_all_uris_from_content(content)
return uris[0] if uris else None
def detect_fresh_fetch_intent(content: str) -> bool:
"""
Detect if user wants to bypass cache and fetch fresh content.
Uses centralized multilingual keywords (fresh, refresh, reload, update, etc.)
Args:
content: Message content to analyze
Returns:
True if fresh fetch is requested, False otherwise
"""
if has_fresh_fetch_trigger(content):
logger.info("Fresh fetch intent detected via centralized keywords")
return True
return False
def build_sources_footer(source_urls: List[Dict[str, str]], format: str = "markdown") -> str:
"""
Build sources footer from source URL metadata.
Args:
source_urls: List of dicts with 'url' and 'title' keys
format: Output format - "markdown" (Discord), "plain" (IRC/Matrix), or "html"
Returns:
Formatted sources section
Example:
>>> sources = [{'url': 'https://example.com', 'title': 'Example Page'}]
>>> build_sources_footer(sources)
'**Sources:**\\n- **Example Page**: <https://example.com>\\n'
>>> build_sources_footer(sources, format="plain")
'Sources: Example Page (https://example.com)'
"""
if not source_urls:
return ""
if format == "plain":
# Single line, no markdown, no emoji - for IRC and Matrix
parts = []
for source in source_urls:
title = source.get('title', 'Untitled')
url = source.get('url', '')
if url:
parts.append(f"{title} ({url})")
return "Sources: " + ", ".join(parts) if parts else ""
else:
# Markdown format - for Discord
footer = "**Sources:**\n"
for source in source_urls:
title = source.get('title', 'Untitled')
url = source.get('url', '')
if url:
# Use angle brackets to suppress Discord embeds
footer += f"- **{title}**: <{url}>\n"
return footer
async def fetch_and_cache(
url: str,
web_cache_manager,
keywords: Optional[List[str]] = None,
keyword_variations: Optional[List[str]] = None,
depth: int = 2,
fresh: bool = False,
progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
user_query: Optional[str] = None
) -> Optional[Dict]:
"""
Fetch and cache web content using the web cache manager.
This is a thin wrapper around web_cache_manager.get_or_fetch() that
provides a platform-agnostic interface.
Args:
url: URL to fetch
web_cache_manager: WebCacheManager instance
keywords: Primary keywords for relevance scoring
keyword_variations: Keyword variations/synonyms
depth: Crawl depth (0 = single page, 1+ = follow links)
fresh: If True, bypass cache and fetch fresh
progress_callback: Optional async callback for progress updates
user_query: Original user query for context
Returns:
Dict with keys: url, title, extracted_text, fetched_at, crawl_depth,
source_urls, crawl_tree, token_count, from_cache
Returns None if fetch failed
"""
try:
result = await web_cache_manager.get_or_fetch(
url=url,
depth=depth,
force_refresh=fresh,
query_keywords=keywords if keywords else None,
keyword_variations=keyword_variations if keyword_variations else None,
progress_callback=progress_callback,
user_query=user_query
)
if result:
logger.info(f"Web fetch successful: {url} ({len(result.get('source_urls', []))} pages)")
else:
logger.warning(f"Web fetch failed: {url}")
return result
except Exception as e:
logger.error(f"Error fetching {url}: {e}", exc_info=True)
return None
def is_web_fetch_request(content: str) -> bool:
"""
Detect if message content is a web fetch request.
Requires BOTH a URL AND a fetch keyword (fetch, scrape, crawl).
Just having a URL is not enough - user must explicitly request fetch.
Args:
content: Message content to analyze
Returns:
True if this appears to be a web fetch request
"""
url = extract_url_from_content(content)
if not url:
return False
return has_web_fetch_trigger(content)
def extract_domain(url: str) -> Optional[str]:
"""
Extract domain from URL using miniuri.
Args:
url: URL to extract domain from
Returns:
Domain string or None if extraction fails
Example:
>>> extract_domain("https://example.com/path")
'example.com'
"""
try:
uri = Uri(url)
return uri.authority or None
except Exception as e:
logger.warning(f"Failed to extract domain from {url}: {e}")
# Fallback to urlparse
from urllib.parse import urlparse
try:
return urlparse(url).netloc or None
except:
return None
def format_progress_message(
url: str,
task: str = "",
keywords: Optional[List[str]] = None,
variations: Optional[List[str]] = None,
stems: Optional[List[str]] = None,
format_instruction: str = "",
depth: int = 2,
cache_mode: str = "default",
status: str = "",
crawl_delay: Optional[float] = None
) -> str:
"""
Format a detailed progress message for web fetch operations.
Args:
url: URL being fetched
task: Extracted task description
keywords: Primary keywords
variations: Keyword variations
stems: Keyword stems
format_instruction: Format instructions
depth: Crawl depth
cache_mode: "default" or "fresh"
status: Current status message
crawl_delay: Optional crawl delay in seconds
Returns:
Formatted progress message
"""
task_display = task if task else 'none'
keywords_display = ', '.join(keywords) if keywords else 'none'
variations_display = ', '.join(variations) if variations else 'none'
stems_display = ', '.join(stems) if stems else 'none'
format_display = format_instruction if format_instruction else 'default'
msg = f"🔍 **Searching** <{url}>\n"
msg += f"**Task:** {task_display}\n"
msg += f"**Keywords:** {keywords_display}\n"
msg += f"**Variations:** {variations_display}\n"
msg += f"**Stems:** {stems_display}\n"
msg += f"**Format:** {format_display}\n"
msg += f"**Depth:** {depth}\n"
msg += f"**Mode:** {cache_mode}"
if crawl_delay and crawl_delay > 2.0:
msg += f"\n**Crawl delay:** {crawl_delay}s (from robots.txt)"
if status:
msg += f"\n\n{status}"
return msg
def format_answering_message(num_pages: int) -> str:
"""
Format a message for the answering phase after fetch completes.
Args:
num_pages: Number of pages fetched
Returns:
Formatted answering message
"""
return f"💭 **Answering your research...**\nAnalyzing {num_pages} page(s)"
def generate_keyword_stems(keywords: List[str], variations: List[str]) -> List[str]:
"""
Generate stems from keywords and variations using simple suffix stripping.
This is a copy of the function from bot.py to avoid circular imports.
Args:
keywords: Primary keywords
variations: Keyword variations
Returns:
List of unique stems (excluding duplicates from keywords/variations)
"""
def simple_stem(word: str) -> str:
"""Simple suffix-stripping stemmer for common English suffixes"""
word = word.lower()
# Common suffixes in order of longest first
suffixes = ['ings', 'ing', 'ed', 'es', 's', 'ly', 'er', 'est', 'tion', 'sion', 'ness', 'ment', 'able', 'ible']
for suffix in suffixes:
if word.endswith(suffix) and len(word) > len(suffix) + 2: # Keep at least 3 chars in stem
return word[:-len(suffix)]
return word
stems = []
# Lowercase all keywords and variations for comparison
keywords_lower = [kw.lower() for kw in keywords]
variations_lower = [v.lower() for v in variations]
all_words = keywords + variations
for word in all_words:
# Split phrases into individual words and stem each separately
individual_words = word.split()
for individual_word in individual_words:
stem = simple_stem(individual_word)
# Only add if:
# 1. Stem is different from original word (stemming actually changed it)
# 2. Stem is not already in keywords (case-insensitive)
# 3. Stem is not already in variations (case-insensitive)
if (stem != individual_word.lower() and
stem not in keywords_lower and
stem not in variations_lower):
stems.append(stem)
# Deduplicate stems
return list(dict.fromkeys(stems))