""" domain_mapper.py Comprehensive domain URL discovery for Crawl4AI Discovers all URLs under a domain using 8 sources without deep crawling: sitemap — per-host sitemap.xml / sitemap_index.xml / robots.txt Sitemap: directives cc — Common Crawl CDX API wayback — Wayback Machine CDX API crt — Certificate Transparency (crt.sh) subdomain discovery probe — common-path probing with soft-404 detection robots — robots.txt Disallow:/Allow: path mining feed — RSS/Atom feed parsing homepage — homepage link extraction via quick_extract_links() """ from __future__ import annotations import asyncio import hashlib import json import os import pathlib import re import time import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple, Union from urllib.parse import urljoin, urlparse, quote import httpx try: from lxml import etree LXML = True except ImportError: LXML = False try: import rank_bm25 HAS_BM25 = True except ImportError: HAS_BM25 = False from .async_logger import AsyncLoggerBase, AsyncLogger from .async_url_seeder import AsyncUrlSeeder, _parse_head from .utils import ( normalize_url, get_base_domain, is_external_url, quick_extract_links, ) from typing import TYPE_CHECKING if TYPE_CHECKING: from .async_configs import DomainMapperConfig # ──────────────────────────────────────────────────────────────── constants WAYBACK_CDX_URL = "https://web.archive.org/cdx/search/cdx" CRT_SH_URL = "https://crt.sh/" DEFAULT_PROBE_PATHS = [ "/", "/docs", "/api", "/login", "/dashboard", "/blog", "/features", "/pricing", "/about", "/contact", "/auth/login", "/openapi.json", "/swagger.json", "/api-docs", "/graphql", "/status", "/health", "/changelog", "/terms", "/privacy", "/faq", "/help", "/support", "/products", "/services", ] DEFAULT_COMMON_SUBDOMAINS = [ "www", "app", "api", "docs", "blog", "admin", "staging", "dev", "cloud", "mail", "cdn", "static", "portal", "dashboard", "help", "support", "shop", "store", ] FEED_PATHS = [ "/feed", "/rss", "/atom.xml", "/feed.xml", "/rss.xml", "/index.xml", "/feed/rss", "/blog/feed", "/feed/atom", ] VALID_SOURCES = {"sitemap", "cc", "wayback", "crt", "probe", "robots", "feed", "homepage"} # Minimal nonsense filter — DomainMapper WANTS /login, /admin etc. _NONSENSE_SUFFIXES = ( "/robots.txt", "/sitemap.xml", "/sitemap_index.xml", "/favicon.ico", "/apple-touch-icon.png", "/browserconfig.xml", "/manifest.json", "/ads.txt", "/humans.txt", "/crossdomain.xml", ) _NONSENSE_CONTAINS = ( "/.well-known/", "/.git/", "/.svn/", "/.hg/", ) # Static asset extensions to filter (JS, CSS, fonts, images, media) _ASSET_EXTENSIONS = ( ".js", ".css", ".scss", ".sass", ".less", ".map", ".woff", ".woff2", ".ttf", ".eot", ".otf", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".ico", ".avif", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".zip", ".tar", ".gz", ".rar", ".7z", ".bz2", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ) # ──────────────────────────────────────────────────────────────── dataclass @dataclass class Soft404Fingerprint: """Fingerprint of a known-bad URL response for soft-404 detection.""" status_code: int title: Optional[str] content_length: int body_hash: str # ──────────────────────────────────────────────────────────────── class class DomainMapper: """ Comprehensive domain URL discovery without deep crawling. Discovers all URLs under a domain using 8 sources: sitemap, cc, wayback, crt, probe, robots, feed, homepage. Usage:: async with DomainMapper() as mapper: results = await mapper.scan("example.com") # Or via AsyncWebCrawler: async with AsyncWebCrawler() as crawler: results = await crawler.amap_domain("example.com") """ def __init__( self, client: Optional[httpx.AsyncClient] = None, logger: Optional[AsyncLoggerBase] = None, base_directory: Optional[Union[str, Path]] = None, ): self._owns_client = client is None self.client = client or httpx.AsyncClient( http2=True, timeout=15, headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/123.0.0.0 Safari/537.36" ), }, ) self.logger = logger or AsyncLogger(verbose=False) self.base_directory = Path( base_directory or os.getenv("CRAWL4_AI_BASE_DIRECTORY", Path.home()) ) self.cache_dir = self.base_directory / ".crawl4ai" / "domain_mapper_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) self._seeder: Optional[AsyncUrlSeeder] = None self._rate_sem: Optional[asyncio.Semaphore] = None # ──────────────────────── lifecycle async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() return False async def close(self): if self._seeder: await self._seeder.close() self._seeder = None if self._owns_client and self.client: await self.client.aclose() # ──────────────────────── logging def _log(self, level: str, message: str, tag: str = "MAPPER", **kwargs): if self.logger: fn = getattr(self.logger, level, None) if fn: fn(message=message, tag=tag, params=kwargs.get("params", {})) # ──────────────────────── seeder composition async def _get_seeder(self) -> AsyncUrlSeeder: if self._seeder is None: self._seeder = AsyncUrlSeeder( client=self.client, logger=self.logger, base_directory=self.base_directory, ) return self._seeder # ════════════════════════════════════════════════════════════════════════ # MAIN ENTRY POINT # ════════════════════════════════════════════════════════════════════════ async def scan( self, domain: str, config: Optional["DomainMapperConfig"] = None, **kwargs, ) -> List[Dict[str, Any]]: """ Discover all URLs under a domain. Args: domain: Domain to scan (e.g., "superdesign.dev") config: DomainMapperConfig. kwargs override config fields. Returns: List of dicts with url, host, source, status, head_data, relevance_score. """ from .async_configs import DomainMapperConfig as _Cfg if config: config = config.clone(**kwargs) if kwargs else config else: config = _Cfg(**kwargs) if kwargs else _Cfg() if config.verbose is not None and self.logger: self.logger.verbose = config.verbose # Parse + validate sources sources = {s.strip().lower() for s in config.source.split("+") if s.strip()} invalid = sources - VALID_SOURCES if invalid: raise ValueError(f"Invalid source(s): {invalid}. Valid: {VALID_SOURCES}") # Rate limiter if config.hits_per_sec and config.hits_per_sec > 0: self._rate_sem = asyncio.Semaphore(config.hits_per_sec) else: self._rate_sem = None # Normalize domain base_domain = re.sub(r"^https?://", "", domain).strip("/").lower() self._log("info", "Scanning domain: {domain} with sources: {sources}", params={"domain": base_domain, "sources": config.source}) # ── Phase 1: Host Discovery ────────────────────────────────────── hosts = await self._discover_hosts(base_domain, sources, config) self._log("info", "Discovered {count} live hosts", params={"count": len(hosts)}) if not hosts: self._log("warning", "No live hosts found for {domain}", params={"domain": base_domain}) return [] # ── Phase 2: Per-Host Scanning ─────────────────────────────────── all_results: List[Dict[str, Any]] = [] scan_tasks = [] for host in hosts: scan_tasks.append(self._scan_host(host, base_domain, sources, config)) host_results = await asyncio.gather(*scan_tasks, return_exceptions=True) for i, result in enumerate(host_results): if isinstance(result, Exception): self._log("error", "Error scanning host {host}: {err}", params={"host": list(hosts)[i], "err": str(result)}) else: all_results.extend(result) self._log("info", "Collected {count} raw URLs from all hosts", params={"count": len(all_results)}) # ── Phase 3: Post-Processing ───────────────────────────────────── results = self._normalize_and_dedup(all_results, base_domain) self._log("info", "{count} URLs after normalization/dedup", params={"count": len(results)}) if config.filter_nonsense_urls: results = [r for r in results if not self._is_nonsense(r["url"])] # Head extraction if config.extract_head: results = await self._extract_heads(results, config) # BM25 scoring if config.query and config.extract_head: results = await self._apply_scoring(results, config) # Limit if config.max_urls > 0: results = results[:config.max_urls] self._log("info", "Scan complete: {count} URLs found across {hosts} hosts", params={"count": len(results), "hosts": len({r["host"] for r in results})}) return results # ════════════════════════════════════════════════════════════════════════ # PHASE 1: HOST DISCOVERY # ════════════════════════════════════════════════════════════════════════ async def _discover_hosts( self, base_domain: str, sources: Set[str], config: "DomainMapperConfig" ) -> Set[str]: """Discover all subdomains/hosts under base_domain.""" hosts: Set[str] = {base_domain} # When include_subdomains is False, skip all subdomain discovery # and only scan the exact domain provided if not getattr(config, "include_subdomains", True): self._log("info", "Subdomain discovery disabled, scanning only {domain}", params={"domain": base_domain}) validated = await self._validate_hosts(hosts, config) return validated discovery_tasks = [] if "crt" in sources: discovery_tasks.append(("crt", self._discover_via_crt(base_domain, config))) if "wayback" in sources: discovery_tasks.append(("wayback", self._discover_via_wayback(base_domain, config))) if "cc" in sources: discovery_tasks.append(("cc", self._discover_via_cc(base_domain, config))) # Always guess common subdomains when crt is enabled if "crt" in sources or "probe" in sources: prefixes = list(DEFAULT_COMMON_SUBDOMAINS) if config.common_subdomains: prefixes.extend(config.common_subdomains) discovery_tasks.append(("dns", self._guess_subdomains(base_domain, prefixes, config))) # Run all discovery in parallel (per-source timeout) if discovery_tasks: source_timeout = getattr(config, "source_timeout", 30.0) coros = [ asyncio.wait_for(t[1], timeout=source_timeout) for t in discovery_tasks ] names = [t[0] for t in discovery_tasks] results = await asyncio.gather(*coros, return_exceptions=True) for name, result in zip(names, results): if isinstance(result, asyncio.TimeoutError): self._log("warning", "{source} discovery timed out after {timeout}s, skipping", params={"source": name, "timeout": source_timeout}) elif isinstance(result, Exception): self._log("warning", "{source} discovery failed: {err}", params={"source": name, "err": str(result)}) else: self._log("info", "{source} discovered {count} hosts: {hosts}", params={"source": name, "count": len(result), "hosts": ", ".join(sorted(result)[:10])}) hosts.update(result) # Validate all hosts are actually alive validated = await self._validate_hosts(hosts, config) return validated async def _discover_via_crt(self, base_domain: str, config: "DomainMapperConfig") -> Set[str]: """Discover subdomains via Certificate Transparency logs (crt.sh).""" hosts: Set[str] = set() url = f"{CRT_SH_URL}?q=%.{base_domain}&output=json" try: resp = await self.client.get(url, timeout=15, follow_redirects=True) if resp.status_code != 200: self._log("warning", "crt.sh returned HTTP {code}", params={"code": resp.status_code}) return hosts entries = resp.json() for entry in entries: for field_name in ("common_name", "name_value"): val = entry.get(field_name, "") # name_value can have newline-separated SANs for name in val.split("\n"): name = name.strip().lower() # Skip wildcards if name.startswith("*."): name = name[2:] if not name: continue # Must be a subdomain of base_domain if name == base_domain or name.endswith(f".{base_domain}"): hosts.add(name) except Exception as e: self._log("warning", "crt.sh query failed: {err}", params={"err": str(e)}) return hosts async def _discover_via_wayback(self, base_domain: str, config: "DomainMapperConfig") -> Set[str]: """Discover hosts from Wayback Machine CDX API.""" hosts: Set[str] = set() # Also store URLs for later use in Phase 2 self._wayback_urls: List[str] = [] params = { "url": f"*.{base_domain}/*", "output": "text", "fl": "original", "collapse": "urlkey", "limit": "10000", } try: resp = await self.client.get( WAYBACK_CDX_URL, params=params, timeout=30, follow_redirects=True ) if resp.status_code != 200: return hosts for line in resp.text.strip().splitlines(): url = line.strip() if not url: continue self._wayback_urls.append(url) parsed = urlparse(url) host = parsed.netloc.lower().split(":")[0] if host and (host == base_domain or host.endswith(f".{base_domain}")): hosts.add(host) except Exception as e: self._log("warning", "Wayback CDX query failed: {err}", params={"err": str(e)}) return hosts async def _discover_via_cc(self, base_domain: str, config: "DomainMapperConfig") -> Set[str]: """Extract unique hostnames from Common Crawl results.""" hosts: Set[str] = set() try: seeder = await self._get_seeder() # We need the CC index if seeder.index_id is None: seeder.index_id = await seeder._latest_index() from .async_configs import SeedingConfig cc_cfg = SeedingConfig( source="cc", max_urls=-1, filter_nonsense_urls=False, extract_head=False, live_check=False, force=config.force, ) results = await seeder.urls(base_domain, cc_cfg) for r in results: url = r["url"] if isinstance(r, dict) else r parsed = urlparse(url) host = parsed.netloc.lower().split(":")[0] if host and (host == base_domain or host.endswith(f".{base_domain}")): hosts.add(host) except Exception as e: self._log("warning", "CC host discovery failed: {err}", params={"err": str(e)}) return hosts async def _guess_subdomains( self, base_domain: str, prefixes: List[str], config: "DomainMapperConfig" ) -> Set[str]: """Guess common subdomains via DNS resolution.""" hosts: Set[str] = set() async def check(prefix: str): fqdn = f"{prefix}.{base_domain}" try: loop = asyncio.get_event_loop() await asyncio.wait_for( loop.getaddrinfo(fqdn, None), timeout=config.dns_timeout, ) return fqdn except Exception: return None results = await asyncio.gather(*[check(p) for p in prefixes]) for r in results: if r: hosts.add(r) return hosts async def _validate_hosts(self, hosts: Set[str], config: "DomainMapperConfig") -> Set[str]: """Validate hosts are reachable via HTTP.""" validated: Set[str] = set() # Track redirects so we can map canonical hosts self._host_redirects: Dict[str, str] = {} async def check(host: str): for scheme in ("https", "http"): url = f"{scheme}://{host}/" try: resp = await self.client.head( url, timeout=config.http_timeout, follow_redirects=False ) # Record redirect target for dedup if resp.status_code in (301, 302, 303, 307, 308): loc = resp.headers.get("location", "") if loc: target_host = urlparse(urljoin(url, loc)).netloc.lower().split(":")[0] if target_host != host: self._host_redirects[host] = target_host return host except Exception: continue return None results = await asyncio.gather(*[check(h) for h in hosts]) for r in results: if r: validated.add(r) return validated # ════════════════════════════════════════════════════════════════════════ # PHASE 2: PER-HOST SCANNING # ════════════════════════════════════════════════════════════════════════ async def _scan_host( self, host: str, base_domain: str, sources: Set[str], config: "DomainMapperConfig" ) -> List[Dict[str, Any]]: """Run all enabled sources against a single host.""" results: List[Dict[str, Any]] = [] # Soft-404 fingerprint (must run first) soft_404_fp = None if config.soft_404_detection: soft_404_fp = await self._fingerprint_soft_404(host, config) if soft_404_fp and soft_404_fp.status_code == 200: self._log("info", "Soft-404 fingerprint for {host}: title={title}", params={"host": host, "title": soft_404_fp.title or "(none)"}) else: soft_404_fp = None # Host returns proper 404s, no soft-404 issue # robots.txt (feeds into sitemap + probe) sitemap_urls: List[str] = [] disallow_paths: List[str] = [] if "robots" in sources or "sitemap" in sources: sitemap_urls, disallow_paths = await self._scan_robots_txt(host, config) # Gather per-host sources in parallel tasks: Dict[str, Any] = {} if "sitemap" in sources: tasks["sitemap"] = self._scan_sitemaps(host, sitemap_urls, config) if "probe" in sources or "robots" in sources: # If host has soft-404 fingerprint, add robots paths to probe (which does soft-404 checking) # rather than treating them as known-good probe_paths = list(DEFAULT_PROBE_PATHS) if config.probe_paths: probe_paths.extend(config.probe_paths) if "robots" in sources and disallow_paths: probe_paths.extend(disallow_paths) # Deduplicate probe_paths = list(dict.fromkeys(probe_paths)) tasks["probe"] = self._probe_paths(host, probe_paths, soft_404_fp, config) if "feed" in sources: tasks["feed"] = self._discover_feeds(host, config) if "homepage" in sources: tasks["homepage"] = self._scan_homepage(host, base_domain, config) # Run in parallel (per-source timeout) if tasks: source_timeout = getattr(config, "source_timeout", 30.0) source_names = list(tasks.keys()) coros = [ asyncio.wait_for(c, timeout=source_timeout) for c in tasks.values() ] task_results = await asyncio.gather(*coros, return_exceptions=True) for name, result in zip(source_names, task_results): if isinstance(result, asyncio.TimeoutError): self._log("warning", "{source} scan timed out on {host} after {timeout}s, skipping", params={"source": name, "host": host, "timeout": source_timeout}) continue if isinstance(result, Exception): self._log("warning", "{source} scan failed on {host}: {err}", params={"source": name, "host": host, "err": str(result)}) continue # For sitemap URLs on hosts with soft-404: sample-check a few URLs # If all samples are soft-404, skip the entire batch if name == "sitemap" and soft_404_fp and len(result) > 5: import random sample = random.sample(result, min(5, len(result))) soft_404_count = 0 for sample_url in sample: try: resp = await self.client.get( sample_url, timeout=config.http_timeout, follow_redirects=True, headers={"Accept-Encoding": "identity"}, ) if self._is_soft_404(resp.status_code, resp.content, soft_404_fp): soft_404_count += 1 except Exception: pass if soft_404_count == len(sample): self._log("info", "Skipping {count} sitemap URLs from {host} (all samples are soft-404)", params={"count": len(result), "host": host}) continue for url in result: results.append({ "url": url, "host": host, "source": name, "status": "valid", "head_data": {}, }) # Add Wayback URLs that belong to this host if "wayback" in sources and hasattr(self, "_wayback_urls"): for url in self._wayback_urls: parsed = urlparse(url) url_host = parsed.netloc.lower().split(":")[0] if url_host == host: results.append({ "url": url, "host": host, "source": "wayback", "status": "valid", "head_data": {}, }) return results # ──────────────────────── Soft-404 Detection async def _fingerprint_soft_404( self, host: str, config: "DomainMapperConfig" ) -> Optional[Soft404Fingerprint]: """Fetch a known-bad URL to fingerprint soft-404 responses.""" random_path = f"/c4ai-probe-{uuid.uuid4().hex[:12]}" url = f"https://{host}{random_path}" try: resp = await self.client.get( url, timeout=config.http_timeout, follow_redirects=True ) body = resp.content[:2048] title = None m = re.search(rb"