SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
27.8 KB · 594 lines python
Raw Blame History
1"""Fetch transport (spec §13–14, §109–118): httpx direct mode with conditional requests, per-domain rate limiting and concurrency2caps, robots.txt awareness, bounded redirects (each hop SSRF-checked), size limits, failure classification and an *optional*3headless-browser mode that is only used when a sensor's connector asks for it.45Crawler inputs are untrusted: every destination and every redirect hop is validated before a socket is opened.6"""7from __future__ import annotations89import asyncio10import hashlib11import ipaddress12import logging13import re14import socket15import time16from dataclasses import dataclass, field17from datetime import UTC, datetime18from typing import Self19from urllib.parse import urljoin, urlparse20from urllib.robotparser import RobotFileParser2122import httpx2324from companyatlas.config import settings25from companyatlas.taxonomy import FailureClass26from companyatlas.urls import registrable_domain2728log = logging.getLogger(__name__)2930TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504}31BLOCK_STATUS = {401, 403, 451, 999}32REDIRECT_STATUS = {301, 302, 303, 307, 308}3334# ---------------------------------------------------------------------------------------------- SSRF guard (spec §115–116)35_BLOCKED_SUFFIXES = (".local", ".internal", ".localhost", ".localdomain", ".lan", ".home", ".corp", ".intranet", ".maclustr.io", ".ts.net")36_BLOCKED_HOSTS = {"localhost", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"}37_BLOCKED_NETWORKS = [ipaddress.ip_network(n) for n in (38    "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",39    "198.18.0.0/15", "240.0.0.0/4", "::/128", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", "64:ff9b::/96",40)]414243class BlockedDestination(ValueError):44    """The URL points at a private, local or otherwise non-public destination."""454647def _ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:48    if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:49        ip = ip.ipv4_mapped50    if isinstance(ip, ipaddress.IPv6Address) and ip in ipaddress.ip_network("64:ff9b::/96"):51        ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)          # NAT64: judge the embedded IPv452    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:53        return True54    return any(ip in net for net in _BLOCKED_NETWORKS)555657def _parse_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:58    try:59        return ipaddress.ip_address(host.strip("[]").split("%")[0])60    except ValueError:61        return None626364def _resolve(host: str, port: int) -> list[str]:65    try:66        infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)67    except socket.gaierror:68        return []69    return sorted({info[4][0] for info in infos})707172def validate_destination(url: str, *, resolved_ips: list[str] | None = None) -> None:73    p = urlparse(url.strip())74    if p.scheme.lower() not in ("http", "https"):75        raise BlockedDestination(f"scheme {p.scheme!r}")76    host = (p.hostname or "").strip().lower().rstrip(".")77    if not host:78        raise BlockedDestination("no host")79    if p.username or p.password:80        raise BlockedDestination("credentials in URL")81    literal = _parse_ip(host)82    if literal is None and (host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES) or "." not in host):83        raise BlockedDestination(f"local host {host!r}")84    if literal is not None:85        if _ip_blocked(literal):86            raise BlockedDestination(f"non-public address {host}")87        return88    ips = resolved_ips if resolved_ips is not None else _resolve(host, p.port or (443 if p.scheme.lower() == "https" else 80))89    for raw in ips:90        ip = _parse_ip(raw)91        if ip is not None and _ip_blocked(ip):92            raise BlockedDestination(f"{host} resolves to non-public address {raw}")939495async def validate_destination_async(url: str) -> None:96    p = urlparse(url.strip())97    host = (p.hostname or "").strip().lower()98    validate_destination(url, resolved_ips=[])99    if host and _parse_ip(host) is None:100        ips = await asyncio.to_thread(_resolve, host, p.port or (443 if p.scheme.lower() == "https" else 80))101        validate_destination(url, resolved_ips=ips)102103104# ---------------------------------------------------------------------------------------------- text decoding / sanity105_META_CHARSET_RE = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([a-zA-Z0-9_.:-]+)""", re.IGNORECASE)106_XML_ENC_RE = re.compile(rb"""^\s*<\?xml[^>]*encoding\s*=\s*["']([a-zA-Z0-9_.:-]+)["']""", re.IGNORECASE)107_CONTROL_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")108109110def text_quality(text: str) -> float:111    """Share of the text that is NOT replacement characters / C0 control bytes (1.0 = clean). Empty text is 1.0."""112    if not text:113        return 1.0114    bad = text.count("\ufffd") + len(_CONTROL_RE.findall(text))115    return max(0.0, 1.0 - bad / len(text))116117118def declared_encodings(content: bytes, content_type: str) -> list[str]:119    out: list[str] = []120    if content.startswith(b"\xef\xbb\xbf"):121        out.append("utf-8-sig")122    elif content.startswith((b"\xff\xfe", b"\xfe\xff")):123        out.append("utf-16")124    ct = (content_type or "").lower()125    if "charset=" in ct:126        out.append(ct.split("charset=", 1)[1].split(";")[0].strip().strip('"'))127    head = content[:4096]128    m = _XML_ENC_RE.search(head) or _META_CHARSET_RE.search(head)129    if m:130        out.append(m.group(1).decode("ascii", "ignore"))131    seen: set[str] = set()132    result = []133    for e in out + ["utf-8", "cp1252"]:134        e = e.lower().replace("_", "-")135        if e in ("iso-8859-1", "latin-1", "latin1", "ascii", "us-ascii"):136            e = "cp1252"          # superset; what browsers actually do137        if e not in seen:138            seen.add(e)139            result.append(e)140    return result141142143def decode_text(content: bytes, content_type: str = "") -> str:144    best: tuple[float, str] | None = None145    for enc in declared_encodings(content, content_type):146        try:147            candidate = content.decode(enc, errors="replace")148        except LookupError:149            continue150        q = text_quality(candidate)151        if q >= 0.99:152            return candidate153        if best is None or q > best[0]:154            best = (q, candidate)155    return best[1] if best else content.decode("utf-8", errors="replace")156157158_TEXTUAL_TYPES = ("text/", "html", "xml", "json", "javascript", "rss", "atom", "csv")159160161def looks_binary(content: bytes, content_type: str) -> bool:162    """A body announced as text/HTML/XML/JSON whose bytes are not text (undecoded compression, wrong Content-Type)."""163    ct = (content_type or "").lower()164    if not any(t in ct for t in _TEXTUAL_TYPES) and ct:165        return False                      # PDFs, images… are judged by their connectors, not here166    sample = content[:8192]167    if not sample:168        return False169    if sample[:2] == b"\x1f\x8b" or sample[:4] == b"\x28\xb5\x2f\xfd":       # gzip / zstd magic left undecoded170        return True171    text_bytes = sum(1 for b in sample if 32 <= b < 127 or b in (9, 10, 13) or b >= 128)172    high = sum(1 for b in sample if b >= 128)173    controls = len(sample) - text_bytes174    if controls / len(sample) > 0.02:175        return True176    if high / len(sample) > 0.30:        # legitimate UTF-8 (CJK) has structure; verify it decodes cleanly177        return text_quality(sample.decode("utf-8", errors="replace")) < 0.90178    return False179180181# ---------------------------------------------------------------------------------------------- results / errors182183184class FetchError(Exception):185    def __init__(self, message: str, *, status: int | None = None, url: str = "", failure: FailureClass = FailureClass.UNKNOWN):186        super().__init__(message)187        self.status = status188        self.url = url189        self.failure = failure190191192class BlockedError(FetchError):193    """Access denied by the origin or by robots — never bypassed."""194195196class NotModified(Exception):197    """HTTP 304 — content unchanged since our stored validators."""198199    def __init__(self, duration_ms: int = 0):200        super().__init__("not modified")201        self.duration_ms = duration_ms202203204@dataclass205class FetchResult:206    url: str207    final_url: str208    status: int209    headers: dict[str, str]210    content: bytes211    content_type: str212    fetched_at: datetime213    duration_ms: int214    transport: str = "http"215    redirects: int = 0216    sha256: str = field(init=False)217218    def __post_init__(self) -> None:219        self.sha256 = hashlib.sha256(self.content).hexdigest()220221    @property222    def etag(self) -> str | None:223        return self.headers.get("etag")224225    @property226    def last_modified(self) -> str | None:227        return self.headers.get("last-modified")228229    @property230    def text(self) -> str:231        """Decoded body: BOM → HTTP charset → in-document declaration (<meta charset>, XML prolog) → UTF-8; if the chosen codec leaves232        more than 1 % replacement characters, the alternatives are tried and the cleanest decode wins (never garbage in, never silently)."""233        return decode_text(self.content, self.content_type)234235    @property236    def is_html(self) -> bool:237        head = self.content[:512].lstrip().lower()238        return "html" in self.content_type or head.startswith((b"<!doctype html", b"<html")) or b"<html" in head239240    @property241    def is_json(self) -> bool:242        return "json" in self.content_type or self.content[:1] in (b"{", b"[")243244    @property245    def is_xml(self) -> bool:246        return "xml" in self.content_type or self.content[:5] == b"<?xml" or self.content[:200].lstrip().startswith((b"<rss", b"<feed", b"<urlset", b"<sitemapindex"))247248    def json(self):  # type: ignore[no-untyped-def]249        import orjson250251        return orjson.loads(self.content)252253254def classify_exception(exc: BaseException) -> FailureClass:255    if isinstance(exc, FetchError):256        return exc.failure257    if isinstance(exc, httpx.ConnectTimeout | httpx.ReadTimeout | httpx.WriteTimeout | httpx.PoolTimeout | asyncio.TimeoutError | TimeoutError):258        return FailureClass.TIMEOUT259    if isinstance(exc, httpx.RemoteProtocolError | httpx.ProtocolError) or exc.__class__.__name__ in ("ProtocolError", "StreamReset", "ConnectionTerminated"):260        return FailureClass.TIMEOUT            # transient transport-level protocol errors (h2 connection reuse, resets): retry policy, not a mystery261    if isinstance(exc, httpx.ConnectError):262        msg = str(exc).lower()263        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:264            return FailureClass.DNS265        return FailureClass.TIMEOUT266    return FailureClass.UNKNOWN267268269def _status_failure(status: int) -> FailureClass:270    if status == 429:271        return FailureClass.RATE_LIMIT272    if status in (404, 410):273        return FailureClass.PAGE_REMOVED274    if status in BLOCK_STATUS:275        return FailureClass.BOT_CHALLENGE if status in (403, 999) else FailureClass.HTTP_4XX276    if 400 <= status < 500:277        return FailureClass.HTTP_4XX278    return FailureClass.HTTP_5XX279280281# ---------------------------------------------------------------------------------------------- politeness primitives282283284class DomainGovernor:285    """Per-domain minimum spacing + concurrency cap (in-process). Cluster-wide fairness comes from Postgres domain budgets."""286287    def __init__(self) -> None:288        self._next: dict[str, float] = {}289        self._sem: dict[str, asyncio.Semaphore] = {}290        self._delay: dict[str, float] = {}291        self._lock = asyncio.Lock()292293    def set_crawl_delay(self, domain: str, seconds: float) -> None:294        self._delay[domain] = min(120.0, max(0.0, seconds))295296    async def acquire(self, domain: str, per_min: int) -> asyncio.Semaphore:297        sem = self._sem.get(domain)298        if sem is None:299            sem = self._sem[domain] = asyncio.Semaphore(settings.domain_max_concurrency)300        await sem.acquire()301        async with self._lock:302            spacing = max(60.0 / max(1, per_min), self._delay.get(domain, 0.0))303            now = time.monotonic()304            ready = self._next.get(domain, 0.0)305            wait = max(0.0, ready - now)306            self._next[domain] = max(now, ready) + spacing307        if wait > 0:308            await asyncio.sleep(wait)309        return sem310311312class RobotsCache:313    def __init__(self) -> None:314        self._cache: dict[str, tuple[float, RobotFileParser | None, float | None]] = {}315316    async def policy(self, client: httpx.AsyncClient, url: str) -> tuple[bool, float | None]:317        """(allowed, crawl_delay_seconds)."""318        if not settings.respect_robots:319            return True, None320        p = urlparse(url)321        key = f"{p.scheme}://{p.netloc}"322        cached = self._cache.get(key)323        if cached is None or cached[0] < time.monotonic():324            rp: RobotFileParser | None = RobotFileParser()325            delay: float | None = None326            try:327                r = await client.get(f"{key}/robots.txt", timeout=15, follow_redirects=False)328                if r.status_code == 200 and len(r.content) < 512 * 1024 and b"<html" not in r.content[:512].lower():329                    rp.parse(r.text.splitlines())  # type: ignore[union-attr]330                    agent = settings.user_agent.split("/")[0]331                    try:332                        d = rp.crawl_delay(agent) or rp.crawl_delay("*")  # type: ignore[union-attr]333                        delay = float(d) if d else None334                    except Exception:  # noqa: BLE001335                        delay = None336                else:337                    rp = None338            except Exception:  # noqa: BLE001339                rp = None340            self._cache[key] = (time.monotonic() + 12 * 3600, rp, delay)341            cached = self._cache[key]342        rp, delay = cached[1], cached[2]343        if rp is None:344            return True, None345        agent = settings.user_agent.split("/")[0]346        try:347            return (rp.can_fetch(agent, url) or rp.can_fetch("*", url)), delay348        except Exception:  # noqa: BLE001349            return True, delay350351352governor = DomainGovernor()353robots = RobotsCache()354355356# ---------------------------------------------------------------------------------------------- fetcher357358359class Fetcher:360    """Shared httpx client (one per worker process). `get()` raises NotModified / FetchError / BlockedError.361    HTTP/1.1 by default: h2 connection reuse against thousands of heterogeneous origins produced `ConnectionInputs.SEND_SETTINGS in state CLOSED`362    protocol errors (2026-09-13); HTTP/2 stays available per Fetcher (`http2=True`)."""363364    def __init__(self, *, timeout_s: float | None = None, headers: dict[str, str] | None = None, http2: bool = False,365                 max_connections: int | None = None):366        self.timeout_s = timeout_s or settings.http_timeout_s367        self.headers = {"User-Agent": settings.user_agent,368                        "Accept": "text/html,application/xhtml+xml,application/xml,application/json,application/rss+xml,text/*;q=0.9,*/*;q=0.7",369                        "Accept-Language": "en-US,en;q=0.9,fr;q=0.6,de;q=0.4,*;q=0.2",370                        **(headers or {})}371        self._client: httpx.AsyncClient | None = None372        self._http2 = http2373        self._max_connections = max_connections or max(16, settings.fetch_concurrency * 2)374375    async def __aenter__(self) -> Self:376        await self.open()377        return self378379    async def __aexit__(self, *exc: object) -> None:380        await self.close()381382    async def open(self) -> None:383        if self._client is None:384            self._client = httpx.AsyncClient(headers=self.headers, timeout=httpx.Timeout(self.timeout_s, connect=15), follow_redirects=False,385                                             http2=self._http2, limits=httpx.Limits(max_connections=self._max_connections,386                                                                                    max_keepalive_connections=self._max_connections // 2))387388    async def close(self) -> None:389        if self._client is not None:390            await self._client.aclose()391            self._client = None392393    @property394    def client(self) -> httpx.AsyncClient:395        if self._client is None:396            raise RuntimeError("Fetcher not opened — use `async with Fetcher() as f` or `await f.open()`")397        return self._client398399    async def get(self, url: str, *, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1,400                  retries: int | None = None, accept: str | None = None, rate_per_min: int | None = None, respect_robots: bool = True,401                  max_bytes: int | None = None) -> FetchResult:402        return await self.request("GET", url, etag=etag, last_modified=last_modified, min_bytes=min_bytes, retries=retries, accept=accept,403                                  rate_per_min=rate_per_min, respect_robots=respect_robots, max_bytes=max_bytes)404405    async def post_json(self, url: str, payload: object, *, accept: str | None = "application/json", **kw: object) -> FetchResult:406        """POST a JSON body to a *public* endpoint that a public page itself calls (spec §13 mode C, e.g. Workday job search)."""407        return await self.request("POST", url, json=payload, accept=accept, **kw)  # type: ignore[arg-type]408409    async def request(self, method: str, url: str, *, json: object | None = None, headers: dict[str, str] | None = None,410                      etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1, retries: int | None = None,411                      accept: str | None = None, rate_per_min: int | None = None, respect_robots: bool = True,412                      max_bytes: int | None = None) -> FetchResult:413        """Same guarantees as `get()` (SSRF guard on every hop, robots, per-domain governor, size caps, failure classes) for any414        method. Non-GET requests are never retried on transport errors beyond the configured retries and never follow redirects415        across registrable domains with the body."""416        client = self.client417        method = method.upper()418        retries = settings.fetch_retries if retries is None else retries419        try:420            await validate_destination_async(url)421        except BlockedDestination as exc:422            raise FetchError(f"blocked destination: {exc}", url=url, failure=FailureClass.BLOCKED_DESTINATION) from exc423        domain = registrable_domain(url)424        if respect_robots:425            allowed, delay = await robots.policy(client, url)426            if delay:427                governor.set_crawl_delay(domain, delay)428            if not allowed:429                raise BlockedError(f"robots.txt disallows {url}", url=url, failure=FailureClass.ROBOTS)430        req_headers: dict[str, str] = dict(headers or {})431        if etag:432            req_headers["If-None-Match"] = etag433        if last_modified:434            req_headers["If-Modified-Since"] = last_modified435        if accept:436            req_headers["Accept"] = accept437        headers = req_headers438        current = url439        hops = 0440        attempt = 0441        while True:442            sem = await governor.acquire(registrable_domain(current), rate_per_min or settings.default_rate_per_min)443            t0 = time.perf_counter()444            try:445                async with client.stream(method, current, headers=headers, json=json) as r:446                    if r.status_code == 304:447                        raise NotModified(int((time.perf_counter() - t0) * 1000))448                    if r.status_code in REDIRECT_STATUS:449                        location = r.headers.get("location")450                        if not location:451                            raise FetchError(f"http {r.status_code} without Location", status=r.status_code, url=url, failure=FailureClass.REDIRECT)452                        hops += 1453                        if hops > settings.max_redirects:454                            raise FetchError(f"too many redirects (> {settings.max_redirects})", status=r.status_code, url=url, failure=FailureClass.REDIRECT)455                        nxt = urljoin(current, location)456                        try:457                            await validate_destination_async(nxt)458                        except BlockedDestination as exc:459                            raise FetchError(f"blocked redirect {current} → {nxt}: {exc}", status=r.status_code, url=url,460                                             failure=FailureClass.BLOCKED_DESTINATION) from exc461                        if registrable_domain(nxt) != registrable_domain(current):462                            headers.pop("If-None-Match", None)463                            headers.pop("If-Modified-Since", None)464                            if method != "GET":465                                raise FetchError(f"{method} redirected off-domain {current} → {nxt}", status=r.status_code, url=url,466                                                 failure=FailureClass.REDIRECT)467                        if r.status_code in (301, 302, 303) and method != "GET":468                            method, json = "GET", None      # per RFC 9110 user agents switch to GET469                        current = nxt470                        continue471                    if r.status_code in TRANSIENT_STATUS and attempt < retries:472                        retry_after = r.headers.get("retry-after")473                        delay = min(60.0, float(retry_after)) if retry_after and retry_after.isdigit() else 2.0 * (2 ** attempt)474                        attempt += 1475                        await asyncio.sleep(delay)476                        continue477                    if r.status_code in BLOCK_STATUS:478                        raise BlockedError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code))479                    if r.status_code >= 400:480                        raise FetchError(f"http {r.status_code} for {current}", status=r.status_code, url=url, failure=_status_failure(r.status_code))481                    return await self._read(r, url=url, final_url=current, t0=t0, min_bytes=min_bytes, hops=hops, max_bytes=max_bytes)482            except (NotModified, FetchError):483                raise484            except (httpx.TimeoutException, httpx.TransportError) as exc:485                if attempt < retries:486                    attempt += 1487                    await asyncio.sleep(1.5 * (2 ** attempt))488                    continue489                raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url, failure=classify_exception(exc)) from exc490            finally:491                sem.release()492493    async def _read(self, r: httpx.Response, *, url: str, final_url: str, t0: float, min_bytes: int, hops: int, max_bytes: int | None) -> FetchResult:494        limit = max_bytes or settings.max_body_bytes495        declared = r.headers.get("content-length")496        if declared and declared.isdigit() and int(declared) > limit:497            raise FetchError(f"declared size {declared} exceeds {limit}", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE)498        chunks: list[bytes] = []499        size = 0500        async for chunk in r.aiter_bytes():501            size += len(chunk)502            if size > limit:503                raise FetchError(f"body exceeds {limit} bytes", status=r.status_code, url=url, failure=FailureClass.TOO_LARGE)504            chunks.append(chunk)505        content = b"".join(chunks)506        enc = (r.headers.get("content-encoding") or "").lower()507        if enc and enc not in ("identity",) and enc not in ("gzip", "deflate", "br", "zstd"):508            raise FetchError(f"unsupported content-encoding {enc!r}", status=r.status_code, url=url, failure=FailureClass.PARSING)509        if content and looks_binary(content, r.headers.get("content-type", "")):510            raise FetchError(f"body is not text (content-encoding={enc or 'none'}, type={r.headers.get('content-type', '?')[:40]})",511                             status=r.status_code, url=url, failure=FailureClass.PARSING)512        if len(content) < min_bytes:513            raise FetchError(f"short body ({len(content)} bytes)", status=r.status_code, url=url, failure=FailureClass.PARSING)514        res = FetchResult(url=url, final_url=final_url, status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()},515                          content=content, content_type=r.headers.get("content-type", "").lower(), fetched_at=datetime.now(UTC),516                          duration_ms=int((time.perf_counter() - t0) * 1000), transport="http", redirects=hops)517        if res.is_html and looks_like_challenge(content):518            raise BlockedError(f"anti-bot challenge page at {final_url}", status=r.status_code, url=url, failure=FailureClass.BOT_CHALLENGE)519        return res520521    # ------------------------------------------------------------------------------------------ optional browser mode (spec §13 B)522    async def get_rendered(self, url: str, *, wait_ms: int = 1500) -> FetchResult:523        """Headless Chromium render — pooled by `settings.browser_concurrency`; only when a connector declares fetch_mode=browser."""524        if not settings.browser_enabled:525            raise FetchError("browser mode disabled", url=url, failure=FailureClass.UNKNOWN)526        await validate_destination_async(url)527        async with _browser_slots():528            from playwright.async_api import async_playwright  # optional dependency529530            t0 = time.perf_counter()531            async with async_playwright() as p:532                browser = await p.chromium.launch(headless=True)533                try:534                    ctx = await browser.new_context(user_agent=settings.user_agent, java_script_enabled=True)535                    await ctx.route("**/*", lambda route: route.abort() if route.request.resource_type in ("image", "media", "font") else route.continue_())536                    page = await ctx.new_page()537                    resp = await page.goto(url, wait_until="domcontentloaded", timeout=int(self.timeout_s * 1000))538                    await page.wait_for_timeout(wait_ms)539                    html = await page.content()540                    status = resp.status if resp else 200541                    final = page.url542                finally:543                    await browser.close()544            return FetchResult(url=url, final_url=final, status=status, headers={}, content=html.encode(), content_type="text/html; charset=utf-8",545                               fetched_at=datetime.now(UTC), duration_ms=int((time.perf_counter() - t0) * 1000), transport="browser")546547548_browser_sem: asyncio.Semaphore | None = None549550551def _browser_slots() -> asyncio.Semaphore:552    global _browser_sem553    if _browser_sem is None:554        _browser_sem = asyncio.Semaphore(settings.browser_concurrency)555    return _browser_sem556557558def looks_like_challenge(content: bytes) -> bool:559    if len(content) > 80_000:560        return False561    head = content[:20000].lower()562    markers = (b"just a moment", b"cf-chl-", b"challenge-platform", b"attention required", b"verify you are human", b"access denied",563               b"captcha", b"perimeterx", b"_pxappid", b"datadome", b"enable javascript and cookies to continue", b"request unsuccessful. incapsula",564               b"bot detection", b"are you a robot")565    return sum(m in head for m in markers) >= 2566567568def file_result(path: str, *, url: str, content_type: str = "text/html") -> FetchResult:569    """Wrap a fixture file as a FetchResult (tests, `catlas run-sensor --file`)."""570    with open(path, "rb") as fh:571        content = fh.read()572    return FetchResult(url=url, final_url=url, status=200, headers={}, content=content, content_type=content_type,573                       fetched_at=datetime.now(UTC), duration_ms=0, transport="file")574575576__all__ = [577    "BlockedDestination",578    "BlockedError",579    "FetchError",580    "FetchResult",581    "Fetcher",582    "NotModified",583    "classify_exception",584    "decode_text",585    "file_result",586    "governor",587    "looks_binary",588    "looks_like_challenge",589    "robots",590    "text_quality",591    "validate_destination",592    "validate_destination_async",593]594