import io import ipaddress from urllib.parse import urljoin, urlparse import httpx import trafilatura from pypdf import PdfReader _FETCH_RECOVERY_HINT = ( "Try: (1) web_search for third-party citations or mirrors of this content; " "(2) fetch the archived version at https://web.archive.org/web/; " "(3) use the snippet from your prior web_search result instead of fetching the page." ) _MAX_REDIRECTS = 5 def _safe_get(url: str, **kwargs) -> httpx.Response: """GET with bounded manual redirect following; SSRF-rechecks every hop. The initial `_is_safe_http_url` check only validates the first URL; a server can still 302 to a private/metadata address. This helper follows redirects one hop at a time, re-running the static SSRF check before each request, and raises `httpx.HTTPError` if any hop resolves to an unsafe URL or the redirect budget is exceeded. """ kwargs.pop("follow_redirects", None) current = url for _ in range(_MAX_REDIRECTS + 1): ok, reason = _is_safe_http_url(current) if not ok: raise httpx.HTTPError(f"redirect to unsafe URL blocked ({reason})") resp = httpx.get(current, follow_redirects=False, **kwargs) if not resp.is_redirect: return resp location = resp.headers.get("Location") if not location: return resp current = urljoin(current, location) raise httpx.HTTPError(f"too many redirects (> {_MAX_REDIRECTS})") def _is_safe_http_url(url: str) -> tuple[bool, str]: """SSRF guard: allow only http/https to public hosts. Returns (ok, reason). Blocks file://, ftp://, javascript:, etc; and blocks loopback, RFC1918 private ranges, link-local (incl. cloud metadata 169.254.169.254), and IPv6 equivalents. Hostnames without a literal IP are allowed — DNS rebinding is a residual risk mitigated at fetch time by `httpx` following redirects; a second check runs after the request lands, but for now a hostname passes the static check. """ if not url or not isinstance(url, str): return False, "empty or non-string url" try: parsed = urlparse(url) except Exception as exc: return False, f"unparseable url: {exc}" if parsed.scheme not in {"http", "https"}: return False, f"blocked scheme {parsed.scheme!r}; only http/https allowed" host = parsed.hostname if not host: return False, "missing host" host_lower = host.lower() if host_lower in {"localhost", "ip6-localhost", "ip6-loopback"}: return False, "loopback hostname blocked" # If host is a literal IP (v4 or v6), check the range. try: ip = ipaddress.ip_address(host) except ValueError: ip = None if ip is not None: if ip.is_loopback: return False, "loopback address blocked" if ip.is_link_local: return False, "link-local address blocked (incl. cloud metadata)" if ip.is_private: return False, "private address blocked" if ip.is_reserved or ip.is_multicast or ip.is_unspecified: return False, "reserved/multicast/unspecified address blocked" return True, "" def fetch_url(url: str, max_chars: int = 8000, timeout: float = 60.0) -> str: ok, reason = _is_safe_http_url(url) if not ok: return f"WEB_FETCH_ERROR: unsafe URL blocked — {reason}" # 1. Check for PDF - Local extraction is always more reliable for PDFs if url.lower().endswith(".pdf"): try: resp = _safe_get(url, timeout=timeout) resp.raise_for_status() reader = PdfReader(io.BytesIO(resp.content)) text = "\n".join((page.extract_text() or "") for page in reader.pages) return text[:max_chars] if len(text) > max_chars else text except Exception as e: return f"WEB_FETCH_ERROR: PDF fetch failed for {url}: {e}. " + _FETCH_RECOVERY_HINT # 2. Try Jina Reader (r.jina.ai) — It's specifically built to bypass anti-bot and return clean markdown jina_url = f"https://r.jina.ai/{url}" headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "X-Return-Format": "markdown" } try: # Use a slightly shorter timeout for Jina to allow for fallback jina_timeout = min(timeout, 40.0) resp = _safe_get(jina_url, timeout=jina_timeout, headers=headers) if resp.status_code == 200 and len(resp.text) > 50: text = resp.text return text[:max_chars] if len(text) > max_chars else text except Exception: # Silently fail Jina attempt and proceed to fallback pass # 3. Fallback: Normal httpx + trafilatura try: resp = _safe_get( url, timeout=timeout, headers={ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }, ) resp.raise_for_status() # Final PDF check in case headers reveal it later content_type = resp.headers.get("Content-Type", "").lower() if "application/pdf" in content_type: reader = PdfReader(io.BytesIO(resp.content)) text = "\n".join((page.extract_text() or "") for page in reader.pages) else: extracted = trafilatura.extract(resp.text, include_comments=False, include_tables=True) text = extracted or resp.text # 4. CAPTCHA / Anti-Bot Detection captcha_markers = [ "verification required", "captcha", "anti-robot", "check connecting", "bot detection", "robot check", "friendly captcha" ] text_lower = text.lower() if any(marker in text_lower for marker in captcha_markers): return ( "WEB_FETCH_ERROR: CAPTCHA/Bot detection encountered. This URL is currently inaccessible via automated tools. " "DO NOT retry this exact URL. " + _FETCH_RECOVERY_HINT ) if len(text) > max_chars: text = text[:max_chars] + "\n...[truncated]" return text except Exception as e: return f"WEB_FETCH_ERROR: all fetch attempts failed for {url}: {e}. " + _FETCH_RECOVERY_HINT