yc1838
lint
abd5331
Raw
History Blame Contribute Delete
6.52 kB
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/<url>; "
"(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