diff --git a/Makefile b/Makefile
index 0427f4a..58408fb 100644
--- a/Makefile
+++ b/Makefile
@@ -31,7 +31,7 @@ SEARCH_Q ?= computer
ingest-grok ingest-grok-media \
ingest-self ingest-git ingest-hg \
verify search stats test docs chain-check chain-check-shards \
- falsify burn clean clean-db clean-data help
+ falsify burn bootstrap-crawler test-crawler clean clean-db clean-data help
all: bootstrap fetch-cur ingest-cur verify stats ## bootstrap → fetch cur → ingest cur → verify → stats
@@ -309,8 +309,17 @@ search: bootstrap ## keyword search; override SEARCH_Q (or pass Q=...)
stats: bootstrap ## counts: documents, chunks, edges, audit chain
$(ABORIST) --db $(DB) stats
-test: bootstrap ## run pytest suite
- $(VENV)/bin/pytest -q
+test: bootstrap ## run pytest suite (excludes opt-in crawler tests)
+ $(VENV)/bin/pytest -q --ignore=tests/crawler
+
+# Crawler tests are off-by-default — they hit the network in many cases
+# and require the heavy [crawler] extras (aiohttp, bs4, lxml, etc.).
+# Bootstrap installs the extras into the existing venv idempotently.
+bootstrap-crawler: bootstrap ## install [crawler] extras into the venv
+ $(PIP) install -e '.[crawler]'
+
+test-crawler: bootstrap-crawler ## run only the lifted crawler tests
+ $(VENV)/bin/pytest -q tests/crawler
DOT_SRCS := $(wildcard docs/diagrams/*.dot)
DOT_PNGS := $(DOT_SRCS:.dot=.png)
diff --git a/aborist/sources/crawler/__init__.py b/aborist/sources/crawler/__init__.py
new file mode 100644
index 0000000..8928083
--- /dev/null
+++ b/aborist/sources/crawler/__init__.py
@@ -0,0 +1,88 @@
+"""Crawler — ethical async web fetching with robots.txt compliance.
+
+Verbatim lift from ``~/git/agents.ai.unturf.com/core/`` (rev as of 2026-04-28):
+
+ 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 adaptations during the lift:
+
+1. Chat-bot fetch triggers (``has_fresh_fetch_trigger`` /
+ ``has_web_fetch_trigger`` from ``core.keywords``) were dropped. Aborist
+ has no chat surface — fetch intent is detected at the application
+ layer, not from message content.
+
+2. ``web_cache_manager.py`` (SQLAlchemy-backed page cache) was NOT
+ lifted. Aborist has its own content-addressed store; cache through
+ that path instead of carrying SQLAlchemy as a dep.
+
+**Off by default.** Heavy dependencies (aiohttp, beautifulsoup4, lxml,
+html5lib, html2text, miniuri, feedparser, Pillow, cairosvg, pypdf) ship
+as the ``[crawler]`` extras. Importing this module without the extras
+raises ``ImportError`` with the install hint. The default test suite
+(``make test``) does not exercise the crawler; ``make test-crawler``
+runs the lifted tests with the extras installed.
+
+Public surface — same as the agents repo:
+
+ from aborist.sources.crawler import AsyncWebFetcher, CrawlMode
+ from aborist.sources.crawler import (
+ URIContentType,
+ extract_all_uris_from_content,
+ extract_url_from_content,
+ build_sources_footer,
+ fetch_and_cache,
+ )
+
+Adopters should pass ``user_agent`` reflecting their deployment and
+honor ``robots.txt`` — that's what the underlying parser already does
+when given a URL.
+"""
+
+from aborist.sources.crawler.async_web_fetcher import (
+ AsyncWebFetcher,
+ CrawlMode,
+ FeedItem,
+ MediaItem,
+ MediaMetadata,
+ discover_feeds,
+ extract_media_from_html,
+ fetch_robots_txt,
+ parse_atom,
+ parse_feed,
+ parse_rss,
+ parse_sitemap,
+)
+from aborist.sources.crawler.web_fetch import (
+ URIContentType,
+ build_sources_footer,
+ detect_uri_content_type,
+ extract_all_uris_from_content,
+ extract_domain,
+ extract_url_from_content,
+ fetch_and_cache,
+ format_progress_message,
+)
+
+__all__ = [
+ "AsyncWebFetcher",
+ "CrawlMode",
+ "FeedItem",
+ "MediaItem",
+ "MediaMetadata",
+ "URIContentType",
+ "build_sources_footer",
+ "detect_uri_content_type",
+ "discover_feeds",
+ "extract_all_uris_from_content",
+ "extract_domain",
+ "extract_media_from_html",
+ "extract_url_from_content",
+ "fetch_and_cache",
+ "fetch_robots_txt",
+ "format_progress_message",
+ "parse_atom",
+ "parse_feed",
+ "parse_rss",
+ "parse_sitemap",
+]
diff --git a/aborist/sources/crawler/async_web_fetcher.py b/aborist/sources/crawler/async_web_fetcher.py
new file mode 100644
index 0000000..fb6b30b
--- /dev/null
+++ b/aborist/sources/crawler/async_web_fetcher.py
@@ -0,0 +1,2755 @@
+#!/usr/bin/env python3
+"""
+Async Web Fetcher for Discord Bot
+Ethical web scraping with robots.txt compliance, async/await compatible
+
+Supports multiple crawl modes:
+- text: Extract text content (default, current behavior)
+- images: Collect images only
+- videos: Collect videos only
+- media: Collect all media (images + videos + audio)
+- all: Uber crawl - text + all media, full domain slurp
+"""
+
+import os
+import io
+import hashlib
+import logging
+import asyncio
+import re
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from enum import Enum
+from typing import Optional, Dict, List, Tuple, Set, Any
+from urllib.parse import urljoin
+from urllib.robotparser import RobotFileParser
+import time
+
+# Heavy crawler dependencies are optional. Install via:
+# pip install 'aborist[crawler]'
+# Off by default per CLAUDE.md — the core aborist test suite does not
+# touch the network and must not import this module unless asked.
+try:
+ import aiohttp
+ from bs4 import BeautifulSoup
+ from miniuri import Uri
+except ImportError as e: # pragma: no cover
+ raise ImportError(
+ "crawler module requires extras: pip install 'aborist[crawler]'"
+ ) from e
+
+
+class CrawlMode(Enum):
+ """Crawl modes for different content types."""
+ TEXT = "text" # Extract text content (default)
+ IMAGES = "images" # Collect images only
+ VIDEOS = "videos" # Collect videos only
+ MEDIA = "media" # All media (images + videos + audio)
+ ALL = "all" # Uber crawl: text + all media
+
+
+# File extensions by media type
+IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.tif', '.avif'}
+VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v', '.ogv', '.flv', '.wmv'}
+AUDIO_EXTENSIONS = {'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac', '.wma'}
+
+# Code file extensions
+CODE_EXTENSIONS = {
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', # Python, JavaScript, TypeScript
+ '.rs', '.go', '.rb', '.php', '.pl', '.pm', # Rust, Go, Ruby, PHP, Perl
+ '.java', '.kt', '.kts', '.scala', '.groovy', # JVM languages
+ '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', # C/C++
+ '.cs', '.fs', '.fsx', # .NET
+ '.swift', '.m', '.mm', # Apple
+ '.lua', '.r', '.R', '.jl', # Lua, R, Julia
+ '.sh', '.bash', '.zsh', '.fish', '.ps1', # Shell
+ '.sql', '.graphql', '.gql', # Query languages
+ '.yaml', '.yml', '.toml', '.json', '.xml', # Config
+ '.md', '.rst', '.txt', # Docs
+ '.zig', '.nim', '.d', '.v', # Modern systems langs
+ '.ex', '.exs', '.erl', '.hrl', # Erlang/Elixir
+ '.clj', '.cljs', '.cljc', '.edn', # Clojure
+ '.hs', '.lhs', # Haskell
+ '.ml', '.mli', '.re', '.rei', # OCaml/ReasonML
+ '.lisp', '.cl', '.el', '.scm', '.rkt', # Lisps
+ '.f90', '.f95', '.f03', '.for', # Fortran
+ '.asm', '.s', # Assembly
+ '.cob', '.cbl', # COBOL
+ '.pro', # Prolog
+ '.tcl', # Tcl
+ '.dart', # Dart
+ '.raku', '.p6', # Raku
+ '.cr', # Crystal
+ '.vue', '.svelte', # Frontend frameworks
+ '.tf', '.hcl', # Terraform
+ '.dockerfile', '.makefile', # Build files
+}
+
+# Font file extensions
+FONT_EXTENSIONS = {'.woff', '.woff2', '.ttf', '.otf', '.eot', '.sfnt'}
+
+# Style file extensions
+STYLE_EXTENSIONS = {'.css', '.scss', '.sass', '.less', '.styl'}
+
+# MIME types by media type
+IMAGE_MIME_PREFIXES = ('image/',)
+VIDEO_MIME_PREFIXES = ('video/',)
+AUDIO_MIME_PREFIXES = ('audio/',)
+CODE_MIME_TYPES = {
+ 'text/x-python', 'application/x-python', 'text/x-python-script',
+ 'text/javascript', 'application/javascript', 'application/x-javascript',
+ 'text/typescript', 'application/typescript',
+ 'text/x-rust', 'text/x-go', 'text/x-ruby', 'application/x-ruby',
+ 'text/x-java-source', 'text/x-kotlin', 'text/x-scala',
+ 'text/x-c', 'text/x-c++', 'text/x-csrc', 'text/x-c++src',
+ 'text/x-csharp', 'text/x-fsharp',
+ 'text/x-swift', 'text/x-objective-c',
+ 'text/x-lua', 'text/x-r', 'text/x-julia',
+ 'text/x-shellscript', 'application/x-sh', 'text/x-bash',
+ 'application/sql', 'application/graphql',
+ 'application/json', 'application/xml', 'text/xml',
+ 'text/yaml', 'application/x-yaml', 'text/x-yaml',
+ 'text/markdown', 'text/x-markdown',
+ 'text/plain', # Often used for code
+}
+FONT_MIME_TYPES = {
+ 'font/woff', 'font/woff2', 'font/ttf', 'font/otf', 'font/sfnt',
+ 'application/font-woff', 'application/font-woff2',
+ 'application/x-font-ttf', 'application/x-font-otf',
+ 'application/vnd.ms-fontobject',
+}
+STYLE_MIME_TYPES = {'text/css', 'text/x-scss', 'text/x-sass', 'text/x-less'}
+
+
+@dataclass
+class MediaItem:
+ """Represents a discovered media item."""
+ url: str
+ source_page: str
+ media_type: str # 'image', 'video', 'audio'
+ mime_type: Optional[str] = None
+ md5_hash: Optional[str] = None
+ file_size: Optional[int] = None
+ alt_text: Optional[str] = None
+ title: Optional[str] = None
+ width: Optional[int] = None
+ height: Optional[int] = None
+ discovered_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
+
+
+class MediaMetadata:
+ """
+ Accumulates metadata from all sources without clobbering.
+
+ "Never clobber, always append" - collects ALL metadata from:
+ - img.alt, img.title
+ - a.title, a.text (link text)
+ - figcaption
+ - nearby headings
+ - page title/h1
+
+ Produces a combined searchable_text for full-text search.
+ """
+
+ def __init__(self):
+ self.titles: List[str] = []
+ self.alt_texts: List[str] = []
+ self.descriptions: List[str] = []
+ self.captions: List[str] = []
+ self.headings: List[str] = []
+ self.link_texts: List[str] = []
+ self.link_titles: List[str] = []
+
+ def _add_unique(self, lst: List[str], value: str, max_len: int = 500) -> None:
+ """Add value to list if non-empty and not duplicate."""
+ if value and value.strip():
+ clean = value.strip()[:max_len]
+ if clean not in lst:
+ lst.append(clean)
+
+ def add_img_alt(self, alt: str) -> None:
+ """Add img alt attribute."""
+ self._add_unique(self.alt_texts, alt)
+
+ def add_img_title(self, title: str) -> None:
+ """Add img title attribute."""
+ self._add_unique(self.titles, title)
+
+ def add_link_title(self, title: str) -> None:
+ """Add attribute."""
+ self._add_unique(self.link_titles, title)
+
+ def add_link_text(self, text: str) -> None:
+ """Add inner text."""
+ self._add_unique(self.link_texts, text)
+
+ def add_figcaption(self, caption: str) -> None:
+ """Add figcaption text."""
+ self._add_unique(self.captions, caption)
+
+ def add_heading(self, heading: str) -> None:
+ """Add nearby heading (h1-h6)."""
+ self._add_unique(self.headings, heading)
+
+ def add_description(self, desc: str) -> None:
+ """Add description (og:description, meta description, etc.)."""
+ self._add_unique(self.descriptions, desc)
+
+ def add_page_title(self, title: str) -> None:
+ """Add page title."""
+ self._add_unique(self.titles, title)
+
+ def get_best_title(self) -> Optional[str]:
+ """Get best title for display (first non-empty)."""
+ for lst in [self.titles, self.alt_texts, self.link_titles,
+ self.captions, self.link_texts, self.headings]:
+ if lst:
+ return lst[0]
+ return None
+
+ def get_best_alt(self) -> Optional[str]:
+ """Get best alt text for accessibility."""
+ if self.alt_texts:
+ return self.alt_texts[0]
+ return self.get_best_title()
+
+ def to_searchable_text(self) -> str:
+ """
+ Combine ALL collected metadata into searchable text.
+
+ This enables finding images by ANY associated text:
+ - "find images of cats" matches img alt="cute cat"
+ - "find images from blog post about python" matches page content
+ """
+ all_parts = []
+ # Dedupe while preserving order
+ seen = set()
+ for lst in [self.titles, self.alt_texts, self.descriptions,
+ self.captions, self.headings, self.link_texts, self.link_titles]:
+ for item in lst:
+ if item and item not in seen:
+ all_parts.append(item)
+ seen.add(item)
+ return ' | '.join(all_parts)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Export all collected metadata as dict."""
+ return {
+ 'titles': self.titles,
+ 'alt_texts': self.alt_texts,
+ 'descriptions': self.descriptions,
+ 'captions': self.captions,
+ 'headings': self.headings,
+ 'link_texts': self.link_texts,
+ 'link_titles': self.link_titles,
+ 'searchable_text': self.to_searchable_text(),
+ 'best_title': self.get_best_title(),
+ 'best_alt': self.get_best_alt(),
+ }
+
+
+# PDF text extraction
+try:
+ from pypdf import PdfReader
+ PDF_SUPPORT = True
+except ImportError:
+ PDF_SUPPORT = False
+
+logger = logging.getLogger(__name__)
+
+# Default crawl delay
+DEFAULT_CRAWL_DELAY = 2.0
+
+# Global last error storage (for error reporting without log parsing)
+# Format: {'type': 'robots_txt'|'http_error'|'timeout'|'dns'|'ssl'|'unknown', 'details': str, 'url': str}
+LAST_FETCH_ERROR = None
+
+
+def get_last_fetch_error() -> Optional[Dict]:
+ """Get the last fetch error, if any."""
+ return LAST_FETCH_ERROR
+
+
+def strip_uri_fragment(uri_str: str) -> str:
+ """
+ Strip the fragment (#anchor) from a URI using miniuri.
+
+ Args:
+ uri_str: URI that may contain a fragment
+
+ Returns:
+ URI without fragment
+ """
+ if not uri_str:
+ return uri_str
+
+ uri = Uri(uri_str)
+ # Reconstruct URI without fragment
+ result = f"{uri.scheme}://{uri.authority}{uri.path or ''}"
+ if uri.query:
+ result += f"?{uri.query}"
+ return result
+
+
+def normalize_link(link) -> Tuple[str, str]:
+ """
+ Extract URI and anchor text from a link, stripping fragments.
+
+ Args:
+ link: Either a dict with 'url' and optional 'anchor_text', or a string URI
+
+ Returns:
+ Tuple of (normalized_uri, anchor_text)
+ """
+ if isinstance(link, dict):
+ uri = strip_uri_fragment(link.get('url', ''))
+ anchor_text = link.get('anchor_text', '')
+ else:
+ uri = strip_uri_fragment(link)
+ anchor_text = ''
+ return uri, anchor_text
+
+
+# Alias for backwards compatibility
+strip_url_fragment = strip_uri_fragment
+
+
+def extract_text_from_pdf(pdf_bytes: bytes) -> Optional[str]:
+ """
+ Extract text from PDF binary data.
+
+ Args:
+ pdf_bytes: Raw PDF file bytes
+
+ Returns:
+ Extracted text or None if extraction fails
+ """
+ if not PDF_SUPPORT:
+ logger.warning("PDF support not available (pypdf not installed)")
+ return None
+
+ try:
+ reader = PdfReader(io.BytesIO(pdf_bytes))
+ text_parts = []
+ for page in reader.pages:
+ page_text = page.extract_text()
+ if page_text:
+ text_parts.append(page_text)
+ if text_parts:
+ return "\n\n".join(text_parts)
+ return None
+ except Exception as e:
+ logger.error(f"Failed to extract text from PDF: {e}")
+ return None
+
+
+def clear_last_fetch_error():
+ """Clear the last fetch error."""
+ global LAST_FETCH_ERROR
+ LAST_FETCH_ERROR = None
+
+
+async def fetch_robots_txt(url: str, user_agent: str = "uncloseai.com/1.42") -> Optional[str]:
+ """
+ Fetch raw robots.txt content for analysis and error reporting.
+
+ Args:
+ url: Any URL on the domain (robots.txt will be fetched from root)
+ user_agent: User agent string
+
+ Returns:
+ Raw robots.txt content or None if not available
+ """
+ parsed = Uri(url)
+ robots_url = f"{parsed.scheme}://{parsed.hostname}/robots.txt"
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ robots_url,
+ headers={"User-Agent": user_agent},
+ timeout=aiohttp.ClientTimeout(total=5)
+ ) as response:
+ if response.status == 200:
+ return await response.text()
+ except Exception as e:
+ logger.warning(f"Could not fetch robots.txt for error analysis: {e}")
+
+ return None
+
+
+def analyze_robots_txt_blocking(robots_txt: str, blocked_url: str, user_agent: str = "uncloseai.com/1.42") -> str:
+ """
+ Analyze robots.txt to explain why a URL is blocked.
+
+ Args:
+ robots_txt: Raw robots.txt content
+ blocked_url: The URL that was blocked
+ user_agent: User agent to check against
+
+ Returns:
+ Human-readable explanation of the blocking rule
+ """
+ parsed = Uri(blocked_url)
+ path = parsed.path or "/"
+
+ # Parse robots.txt manually for detailed analysis
+ lines = robots_txt.strip().split('\n')
+ current_agent = None
+ blocking_rule = None
+ all_rules = []
+
+ for line in lines:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ if ':' in line:
+ key, value = line.split(':', 1)
+ key = key.strip().lower()
+ value = value.strip()
+
+ if key == 'user-agent':
+ current_agent = value
+ elif key == 'disallow' and current_agent:
+ # Check if this rule applies to us
+ agent_matches = (
+ current_agent == '*' or
+ user_agent.lower().startswith(current_agent.lower()) or
+ current_agent.lower() in user_agent.lower()
+ )
+ if agent_matches:
+ all_rules.append(f"Disallow: {value}")
+ # Check if this rule blocks our path
+ if value and path.startswith(value):
+ blocking_rule = f"Disallow: {value}"
+
+ if blocking_rule:
+ result = f"**Blocked by rule:** `{blocking_rule}`\n"
+ result += f"**Path requested:** `{path}`\n"
+ result += f"**User-Agent:** `{user_agent}`\n\n"
+
+ # Show relevant rules
+ if all_rules:
+ result += "**Applicable rules:**\n"
+ for rule in all_rules[:10]: # Limit to first 10
+ result += f" • `{rule}`\n"
+
+ return result
+ elif all_rules:
+ return f"Blocked by robots.txt (exact rule unclear). Rules found:\n" + "\n".join(f" • `{r}`" for r in all_rules[:10])
+ else:
+ return "Blocked by robots.txt (no matching disallow rule found - may be a catch-all)"
+
+
+def get_media_type_from_extension(url: str) -> Optional[str]:
+ """
+ Determine media type from URL extension.
+
+ Returns:
+ 'image', 'video', 'audio', 'code', 'font', 'style', or None
+ """
+ parsed = Uri(url)
+ if not parsed.path:
+ return None
+ path = parsed.path.lower()
+
+ # Check for extension match
+ for ext in IMAGE_EXTENSIONS:
+ if path.endswith(ext):
+ return 'image'
+ for ext in VIDEO_EXTENSIONS:
+ if path.endswith(ext):
+ return 'video'
+ for ext in AUDIO_EXTENSIONS:
+ if path.endswith(ext):
+ return 'audio'
+ for ext in CODE_EXTENSIONS:
+ if path.endswith(ext):
+ return 'code'
+ for ext in FONT_EXTENSIONS:
+ if path.endswith(ext):
+ return 'font'
+ for ext in STYLE_EXTENSIONS:
+ if path.endswith(ext):
+ return 'style'
+ return None
+
+
+def get_media_type_from_mime(mime_type: str) -> Optional[str]:
+ """
+ Determine media type from MIME type.
+
+ Returns:
+ 'image', 'video', 'audio', 'code', 'font', 'style', or None
+ """
+ if not mime_type:
+ return None
+
+ mime_lower = mime_type.lower()
+ # Check prefixes first
+ if mime_lower.startswith(IMAGE_MIME_PREFIXES):
+ return 'image'
+ if mime_lower.startswith(VIDEO_MIME_PREFIXES):
+ return 'video'
+ if mime_lower.startswith(AUDIO_MIME_PREFIXES):
+ return 'audio'
+ # Check exact matches for code/font/style
+ if mime_lower in CODE_MIME_TYPES:
+ return 'code'
+ if mime_lower in FONT_MIME_TYPES:
+ return 'font'
+ if mime_lower in STYLE_MIME_TYPES:
+ return 'style'
+ return None
+
+
+def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMode.MEDIA) -> List[Dict[str, Any]]:
+ """
+ Extract media URLs from HTML content.
+
+ Extracts from:
+ -
and
+ -
+ -