"""Fetch transport (spec §13–14, §109–118): httpx direct mode with conditional requests, per-domain rate limiting and concurrency caps, robots.txt awareness, bounded redirects (each hop SSRF-checked), size limits, failure classification and an *optional* headless-browser mode that is only used when a sensor's connector asks for it. Crawler inputs are untrusted: every destination and every redirect hop is validated before a socket is opened. """ from __future__ import annotations import asyncio import hashlib import ipaddress import logging import re import socket import time from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Self from urllib.parse import urljoin, urlparse from urllib.robotparser import RobotFileParser import httpx from companyatlas.config import settings from companyatlas.taxonomy import FailureClass from companyatlas.urls import registrable_domain log = logging.getLogger(__name__) TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504} BLOCK_STATUS = {401, 403, 451, 999} REDIRECT_STATUS = {301, 302, 303, 307, 308} # ---------------------------------------------------------------------------------------------- SSRF guard (spec §115–116) _BLOCKED_SUFFIXES = (".local", ".internal", ".localhost", ".localdomain", ".lan", ".home", ".corp", ".intranet", ".maclustr.io", ".ts.net") _BLOCKED_HOSTS = {"localhost", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"} _BLOCKED_NETWORKS = [ipaddress.ip_network(n) for n in ( "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "240.0.0.0/4", "::/128", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", "64:ff9b::/96", )] class BlockedDestination(ValueError): """The URL points at a private, local or otherwise non-public destination.""" def _ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: ip = ip.ipv4_mapped if isinstance(ip, ipaddress.IPv6Address) and ip in ipaddress.ip_network("64:ff9b::/96"): ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) # NAT64: judge the embedded IPv4 if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_reserved or ip.is_multicast: return True return any(ip in net for net in _BLOCKED_NETWORKS) def _parse_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: try: return ipaddress.ip_address(host.strip("[]").split("%")[0]) except ValueError: return None def _resolve(host: str, port: int) -> list[str]: try: infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) except socket.gaierror: return [] return sorted({info[4][0] for info in infos}) def validate_destination(url: str, *, resolved_ips: list[str] | None = None) -> None: p = urlparse(url.strip()) if p.scheme.lower() not in ("http", "https"): raise BlockedDestination(f"scheme {p.scheme!r}") host = (p.hostname or "").strip().lower().rstrip(".") if not host: raise BlockedDestination("no host") if p.username or p.password: raise BlockedDestination("credentials in URL") literal = _parse_ip(host) if literal is None and (host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES) or "." not in host): raise BlockedDestination(f"local host {host!r}") if literal is not None: if _ip_blocked(literal): raise BlockedDestination(f"non-public address {host}") return ips = resolved_ips if resolved_ips is not None else _resolve(host, p.port or (443 if p.scheme.lower() == "https" else 80)) for raw in ips: ip = _parse_ip(raw) if ip is not None and _ip_blocked(ip): raise BlockedDestination(f"{host} resolves to non-public address {raw}") async def validate_destination_async(url: str) -> None: p = urlparse(url.strip()) host = (p.hostname or "").strip().lower() validate_destination(url, resolved_ips=[]) if host and _parse_ip(host) is None: ips = await asyncio.to_thread(_resolve, host, p.port or (443 if p.scheme.lower() == "https" else 80)) validate_destination(url, resolved_ips=ips) # ---------------------------------------------------------------------------------------------- text decoding / sanity _META_CHARSET_RE = re.compile(rb"""]+charset\s*=\s*["']?\s*([a-zA-Z0-9_.:-]+)""", re.IGNORECASE) _XML_ENC_RE = re.compile(rb"""^\s*<\?xml[^>]*encoding\s*=\s*["']([a-zA-Z0-9_.:-]+)["']""", re.IGNORECASE) _CONTROL_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") def text_quality(text: str) -> float: """Share of the text that is NOT replacement characters / C0 control bytes (1.0 = clean). Empty text is 1.0.""" if not text: return 1.0 bad = text.count("\ufffd") + len(_CONTROL_RE.findall(text)) return max(0.0, 1.0 - bad / len(text)) def declared_encodings(content: bytes, content_type: str) -> list[str]: out: list[str] = [] if content.startswith(b"\xef\xbb\xbf"): out.append("utf-8-sig") elif content.startswith((b"\xff\xfe", b"\xfe\xff")): out.append("utf-16") ct = (content_type or "").lower() if "charset=" in ct: out.append(ct.split("charset=", 1)[1].split(";")[0].strip().strip('"')) head = content[:4096] m = _XML_ENC_RE.search(head) or _META_CHARSET_RE.search(head) if m: out.append(m.group(1).decode("ascii", "ignore")) seen: set[str] = set() result = [] for e in out + ["utf-8", "cp1252"]: e = e.lower().replace("_", "-") if e in ("iso-8859-1", "latin-1", "latin1", "ascii", "us-ascii"): e = "cp1252" # superset; what browsers actually do if e not in seen: seen.add(e) result.append(e) return result def decode_text(content: bytes, content_type: str = "") -> str: best: tuple[float, str] | None = None for enc in declared_encodings(content, content_type): try: candidate = content.decode(enc, errors="replace") except LookupError: continue q = text_quality(candidate) if q >= 0.99: return candidate if best is None or q > best[0]: best = (q, candidate) return best[1] if best else content.decode("utf-8", errors="replace") _TEXTUAL_TYPES = ("text/", "html", "xml", "json", "javascript", "rss", "atom", "csv") def looks_binary(content: bytes, content_type: str) -> bool: """A body announced as text/HTML/XML/JSON whose bytes are not text (undecoded compression, wrong Content-Type).""" ct = (content_type or "").lower() if not any(t in ct for t in _TEXTUAL_TYPES) and ct: return False # PDFs, images… are judged by their connectors, not here sample = content[:8192] if not sample: return False if sample[:2] == b"\x1f\x8b" or sample[:4] == b"\x28\xb5\x2f\xfd": # gzip / zstd magic left undecoded return True text_bytes = sum(1 for b in sample if 32 <= b < 127 or b in (9, 10, 13) or b >= 128) high = sum(1 for b in sample if b >= 128) controls = len(sample) - text_bytes if controls / len(sample) > 0.02: return True if high / len(sample) > 0.30: # legitimate UTF-8 (CJK) has structure; verify it decodes cleanly return text_quality(sample.decode("utf-8", errors="replace")) < 0.90 return False # ---------------------------------------------------------------------------------------------- results / errors class FetchError(Exception): def __init__(self, message: str, *, status: int | None = None, url: str = "", failure: FailureClass = FailureClass.UNKNOWN): super().__init__(message) self.status = status self.url = url self.failure = failure class BlockedError(FetchError): """Access denied by the origin or by robots — never bypassed.""" class NotModified(Exception): """HTTP 304 — content unchanged since our stored validators.""" def __init__(self, duration_ms: int = 0): super().__init__("not modified") self.duration_ms = duration_ms @dataclass class FetchResult: url: str final_url: str status: int headers: dict[str, str] content: bytes content_type: str fetched_at: datetime duration_ms: int transport: str = "http" redirects: int = 0 sha256: str = field(init=False) def __post_init__(self) -> None: self.sha256 = hashlib.sha256(self.content).hexdigest() @property def etag(self) -> str | None: return self.headers.get("etag") @property def last_modified(self) -> str | None: return self.headers.get("last-modified") @property def text(self) -> str: """Decoded body: BOM → HTTP charset → in-document declaration (, XML prolog) → UTF-8; if the chosen codec leaves more than 1 % replacement characters, the alternatives are tried and the cleanest decode wins (never garbage in, never silently).""" return decode_text(self.content, self.content_type) @property def is_html(self) -> bool: head = self.content[:512].lstrip().lower() return "html" in self.content_type or head.startswith((b" bool: return "json" in self.content_type or self.content[:1] in (b"{", b"[") @property def is_xml(self) -> bool: return "xml" in self.content_type or self.content[:5] == b" FailureClass: if isinstance(exc, FetchError): return exc.failure if isinstance(exc, httpx.ConnectTimeout | httpx.ReadTimeout | httpx.WriteTimeout | httpx.PoolTimeout | asyncio.TimeoutError | TimeoutError): return FailureClass.TIMEOUT if isinstance(exc, httpx.RemoteProtocolError | httpx.ProtocolError) or exc.__class__.__name__ in ("ProtocolError", "StreamReset", "ConnectionTerminated"): return FailureClass.TIMEOUT # transient transport-level protocol errors (h2 connection reuse, resets): retry policy, not a mystery if isinstance(exc, httpx.ConnectError): msg = str(exc).lower() if "nodename" in msg or "name or service" in msg or "getaddrinfo" in msg or "temporary failure in name" in msg or "no address" in msg: return FailureClass.DNS return FailureClass.TIMEOUT return FailureClass.UNKNOWN def _status_failure(status: int) -> FailureClass: if status == 429: return FailureClass.RATE_LIMIT if status in (404, 410): return FailureClass.PAGE_REMOVED if status in BLOCK_STATUS: return FailureClass.BOT_CHALLENGE if status in (403, 999) else FailureClass.HTTP_4XX if 400 <= status < 500: return FailureClass.HTTP_4XX return FailureClass.HTTP_5XX # ---------------------------------------------------------------------------------------------- politeness primitives class DomainGovernor: """Per-domain minimum spacing + concurrency cap (in-process). Cluster-wide fairness comes from Postgres domain budgets.""" def __init__(self) -> None: self._next: dict[str, float] = {} self._sem: dict[str, asyncio.Semaphore] = {} self._delay: dict[str, float] = {} self._lock = asyncio.Lock() def set_crawl_delay(self, domain: str, seconds: float) -> None: self._delay[domain] = min(120.0, max(0.0, seconds)) async def acquire(self, domain: str, per_min: int) -> asyncio.Semaphore: sem = self._sem.get(domain) if sem is None: sem = self._sem[domain] = asyncio.Semaphore(settings.domain_max_concurrency) await sem.acquire() async with self._lock: spacing = max(60.0 / max(1, per_min), self._delay.get(domain, 0.0)) now = time.monotonic() ready = self._next.get(domain, 0.0) wait = max(0.0, ready - now) self._next[domain] = max(now, ready) + spacing if wait > 0: await asyncio.sleep(wait) return sem class RobotsCache: def __init__(self) -> None: self._cache: dict[str, tuple[float, RobotFileParser | None, float | None]] = {} async def policy(self, client: httpx.AsyncClient, url: str) -> tuple[bool, float | None]: """(allowed, crawl_delay_seconds).""" if not settings.respect_robots: return True, None p = urlparse(url) key = f"{p.scheme}://{p.netloc}" cached = self._cache.get(key) if cached is None or cached[0] < time.monotonic(): rp: RobotFileParser | None = RobotFileParser() delay: float | None = None try: r = await client.get(f"{key}/robots.txt", timeout=15, follow_redirects=False) if r.status_code == 200 and len(r.content) < 512 * 1024 and b" Self: await self.open() return self async def __aexit__(self, *exc: object) -> None: await self.close() async def open(self) -> None: if self._client is None: self._client = httpx.AsyncClient(headers=self.headers, timeout=httpx.Timeout(self.timeout_s, connect=15), follow_redirects=False, http2=self._http2, limits=httpx.Limits(max_connections=self._max_connections, max_keepalive_connections=self._max_connections // 2)) async def close(self) -> None: if self._client is not None: await self._client.aclose() self._client = None @property def client(self) -> httpx.AsyncClient: if self._client is None: raise RuntimeError("Fetcher not opened — use `async with Fetcher() as f` or `await f.open()`") return self._client async def get(self, url: str, *, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1, retries: int | None = None, accept: str | None = None, rate_per_min: int | None = None, respect_robots: bool = True, max_bytes: int | None = None) -> FetchResult: return await self.request("GET", url, etag=etag, last_modified=last_modified, min_bytes=min_bytes, retries=retries, accept=accept, rate_per_min=rate_per_min, respect_robots=respect_robots, max_bytes=max_bytes) async def post_json(self, url: str, payload: object, *, accept: str | None = "application/json", **kw: object) -> FetchResult: """POST a JSON body to a *public* endpoint that a public page itself calls (spec §13 mode C, e.g. Workday job search).""" return await self.request("POST", url, json=payload, accept=accept, **kw) # type: ignore[arg-type] async def request(self, method: str, url: str, *, json: object | None = None, headers: dict[str, str] | None = None, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1, retries: int | None = None, accept: str | None = None, rate_per_min: int | None = None, respect_robots: bool = True, max_bytes: int | None = None) -> FetchResult: """Same guarantees as `get()` (SSRF guard on every hop, robots, per-domain governor, size caps, failure classes) for any method. Non-GET requests are never retried on transport errors beyond the configured retries and never follow redirects across registrable domains with the body.""" client = self.client method = method.upper() retries = settings.fetch_retries if retries is None else retries try: await validate_destination_async(url) except BlockedDestination as exc: raise FetchError(f"blocked destination: {exc}", url=url, failure=FailureClass.BLOCKED_DESTINATION) from exc domain = registrable_domain(url) if respect_robots: allowed, delay = await robots.policy(client, url) if delay: governor.set_crawl_delay(domain, delay) if not allowed: raise BlockedError(f"robots.txt disallows {url}", url=url, failure=FailureClass.ROBOTS) req_headers: dict[str, str] = dict(headers or {}) if etag: req_headers["If-None-Match"] = etag if last_modified: req_headers["If-Modified-Since"] = last_modified if accept: req_headers["Accept"] = accept headers = req_headers current = url hops = 0 attempt = 0 while True: sem = await governor.acquire(registrable_domain(current), rate_per_min or settings.default_rate_per_min) t0 = time.perf_counter() try: async with client.stream(method, current, headers=headers, json=json) as r: if r.status_code == 304: raise NotModified(int((time.perf_counter() - t0) * 1000)) if r.status_code in REDIRECT_STATUS: location = r.headers.get("location") if not location: raise FetchError(f"http {r.status_code} without Location", status=r.status_code, url=url, failure=FailureClass.REDIRECT) hops += 1 if hops > settings.max_redirects: raise FetchError(f"too many redirects (> {settings.max_redirects})", status=r.status_code, url=url, failure=FailureClass.REDIRECT) nxt = urljoin(current, location) try: await validate_destination_async(nxt) except BlockedDestination as exc: raise FetchError(f"blocked redirect {current} → {nxt}: {exc}", status=r.status_code, url=url, failure=FailureClass.BLOCKED_DESTINATION) from exc if registrable_domain(nxt) != registrable_domain(current): headers.pop("If-None-Match", None) headers.pop("If-Modified-Since", None) if method != "GET": raise FetchError(f"{method} redirected off-domain {current} → {nxt}", status=r.status_code, url=url, failure=FailureClass.REDIRECT) if r.status_code in (301, 302, 303) and method != "GET": method, json = "GET", None # per RFC 9110 user agents switch to GET current = nxt continue if r.status_code in TRANSIENT_STATUS and attempt < retries: retry_after = r.headers.get("retry-after") delay = min(60.0, float(retry_after)) if retry_after and retry_after.isdigit() else 2.0 * (2 ** attempt) attempt += 1 await asyncio.sleep(delay) continue if r.status_code in BLOCK_STATUS: raise BlockedError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code)) if r.status_code >= 400: raise FetchError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code)) return await self._read(r, url=url, final_url=current, t0=t0, min_bytes=min_bytes, hops=hops, max_bytes=max_bytes) except (NotModified, FetchError): raise except (httpx.TimeoutException, httpx.TransportError) as exc: if attempt < retries: attempt += 1 await asyncio.sleep(1.5 * (2 ** attempt)) continue raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url, failure=classify_exception(exc)) from exc finally: sem.release() async def _read(self, r: httpx.Response, *, url: str, final_url: str, t0: float, min_bytes: int, hops: int, max_bytes: int | None) -> FetchResult: limit = max_bytes or settings.max_body_bytes declared = r.headers.get("content-length") if declared and declared.isdigit() and int(declared) > limit: raise FetchError(f"declared size {declared} exceeds {limit}", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE) chunks: list[bytes] = [] size = 0 async for chunk in r.aiter_bytes(): size += len(chunk) if size > limit: raise FetchError(f"body exceeds {limit} bytes", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE) chunks.append(chunk) content = b"".join(chunks) enc = (r.headers.get("content-encoding") or "").lower() if enc and enc not in ("identity",) and enc not in ("gzip", "deflate", "br", "zstd"): raise FetchError(f"unsupported content-encoding {enc!r}", status=r.status_code, url=url, failure=FailureClass.PARSING) if content and looks_binary(content, r.headers.get("content-type", "")): raise FetchError(f"body is not text (content-encoding={enc or 'none'}, type={r.headers.get('content-type', '?')[:40]})", status=r.status_code, url=url, failure=FailureClass.PARSING) if len(content) < min_bytes: raise FetchError(f"short body ({len(content)} bytes)", status=r.status_code, url=url, failure=FailureClass.PARSING) res = FetchResult(url=url, final_url=final_url, status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()}, content=content, content_type=r.headers.get("content-type", "").lower(), fetched_at=datetime.now(UTC), duration_ms=int((time.perf_counter() - t0) * 1000), transport="http", redirects=hops) if res.is_html and looks_like_challenge(content): raise BlockedError(f"anti-bot challenge page at {final_url}", status=r.status_code, url=url, failure=FailureClass.BOT_CHALLENGE) return res # ------------------------------------------------------------------------------------------ optional browser mode (spec §13 B) async def get_rendered(self, url: str, *, wait_ms: int = 1500) -> FetchResult: """Headless Chromium render — pooled by `settings.browser_concurrency`; only when a connector declares fetch_mode=browser.""" if not settings.browser_enabled: raise FetchError("browser mode disabled", url=url, failure=FailureClass.UNKNOWN) await validate_destination_async(url) async with _browser_slots(): from playwright.async_api import async_playwright # optional dependency t0 = time.perf_counter() async with async_playwright() as p: browser = await p.chromium.launch(headless=True) try: ctx = await browser.new_context(user_agent=settings.user_agent, java_script_enabled=True) await ctx.route("**/*", lambda route: route.abort() if route.request.resource_type in ("image", "media", "font") else route.continue_()) page = await ctx.new_page() resp = await page.goto(url, wait_until="domcontentloaded", timeout=int(self.timeout_s * 1000)) await page.wait_for_timeout(wait_ms) html = await page.content() status = resp.status if resp else 200 final = page.url finally: await browser.close() return FetchResult(url=url, final_url=final, status=status, headers={}, content=html.encode(), content_type="text/html; charset=utf-8", fetched_at=datetime.now(UTC), duration_ms=int((time.perf_counter() - t0) * 1000), transport="browser") _browser_sem: asyncio.Semaphore | None = None def _browser_slots() -> asyncio.Semaphore: global _browser_sem if _browser_sem is None: _browser_sem = asyncio.Semaphore(settings.browser_concurrency) return _browser_sem def looks_like_challenge(content: bytes) -> bool: if len(content) > 80_000: return False head = content[:20000].lower() markers = (b"just a moment", b"cf-chl-", b"challenge-platform", b"attention required", b"verify you are human", b"access denied", b"captcha", b"perimeterx", b"_pxappid", b"datadome", b"enable javascript and cookies to continue", b"request unsuccessful. incapsula", b"bot detection", b"are you a robot") return sum(m in head for m in markers) >= 2 def file_result(path: str, *, url: str, content_type: str = "text/html") -> FetchResult: """Wrap a fixture file as a FetchResult (tests, `catlas run-sensor --file`).""" with open(path, "rb") as fh: content = fh.read() return FetchResult(url=url, final_url=url, status=200, headers={}, content=content, content_type=content_type, fetched_at=datetime.now(UTC), duration_ms=0, transport="file") __all__ = [ "BlockedDestination", "BlockedError", "FetchError", "FetchResult", "Fetcher", "NotModified", "classify_exception", "decode_text", "file_result", "governor", "looks_binary", "looks_like_challenge", "robots", "text_quality", "validate_destination", "validate_destination_async", ]