"""URL reading tool (SPEC-011, FDB-018). The model calls `fetch_url`; this module fetches safely and returns readable text plus prominent image URLs. Web pages are hostile input: every fetch is SSRF-guarded (no private/loopback/link-local targets, http/https only, redirects re-validated) and every byte of text is sanitized before it can reach the prompt. """ import ipaddress import logging import re import socket from html.parser import HTMLParser from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urljoin, urlparse import aiohttp from .ai_responder import sanitize_external_text from .httpread import read_capped DEFAULT_MAX_BYTES = 2 * 1024 * 1024 DEFAULT_MAX_CHARS = 8000 # URL-08: budget goes to content now, not chrome DEFAULT_MAX_IMAGES = 2 FETCH_TIMEOUT_S = 15 MAX_REDIRECTS = 5 FETCH_URL_TOOL = { "name": "fetch_url", "description": "Fetch a public web page and return its readable text plus prominent image links. " "Use when the user shares a URL and asks about it, or to get details behind a news link.", "parameters": { "type": "object", "properties": {"url": {"type": "string", "description": "The http/https URL to read."}}, "required": ["url"], }, } _META_REFRESH_URL = re.compile(r"url\s*=\s*['\"]?([^'\";\s]+)", re.I) _SKIP_TAGS = ("script", "style", "noscript", "svg", "nav", "header", "footer", "aside", "form", "select", "button") _BLOCK_TAGS = ("p", "li", "div", "section", "article", "td", "ul", "ol", "table", "h1", "h2", "h3", "h4", "h5", "h6") _LINK_DENSITY_MAX = 0.6 # boilerplate: block mostly link text ... (URL-08) _LINK_BLOCK_MAX_CHARS = 200 # ... AND short (menus, related lists); long linky paragraphs survive class _Extractor(HTMLParser): def __init__(self) -> None: super().__init__() self._skip = 0 self._links = 0 self._buf: List[str] = [] self._buf_link_chars = 0 self.blocks: List[Tuple[str, int]] = [] # (text, chars inside ) self.images: List[str] = [] self.og_image: Optional[str] = None self.refresh_url: Optional[str] = None def _flush(self) -> None: text = " ".join(self._buf).strip() if text: self.blocks.append((text, self._buf_link_chars)) self._buf, self._buf_link_chars = [], 0 def handle_starttag(self, tag: str, attrs) -> None: if tag in _SKIP_TAGS: self._skip += 1 if tag == "a": self._links += 1 if tag in _BLOCK_TAGS: self._flush() attr = dict(attrs) src = attr.get("src") if tag == "img" and src: self.images.append(src) if tag == "meta" and attr.get("property") == "og:image" and attr.get("content"): self.og_image = attr["content"] # meta-refresh redirect (link shorteners, getnews stubs) — URL-04 content = attr.get("content") if tag == "meta" and (attr.get("http-equiv") or "").lower() == "refresh" and content: match = _META_REFRESH_URL.search(content) if match and self.refresh_url is None: self.refresh_url = match.group(1) def handle_endtag(self, tag: str) -> None: if tag in _SKIP_TAGS and self._skip > 0: self._skip -= 1 if tag == "a" and self._links > 0: self._links -= 1 if tag in _BLOCK_TAGS: self._flush() def handle_data(self, data: str) -> None: if self._skip == 0 and data.strip(): self._buf.append(data.strip()) if self._links > 0: self._buf_link_chars += len(data.strip()) def content_parts(self) -> List[str]: """Blocks minus boilerplate: short blocks dominated by link text are chrome (URL-08).""" self._flush() out = [] for text, link_chars in self.blocks: if link_chars / max(1, len(text)) > _LINK_DENSITY_MAX and len(text) < _LINK_BLOCK_MAX_CHARS: continue out.append(text) return out def _ip_is_public(ip_str: str) -> bool: try: ip = ipaddress.ip_address(ip_str) except ValueError: return False return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified) def guard_url(url: str) -> Optional[str]: """Return None if safe to fetch, else a human-readable refusal reason (URL-02/03).""" parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return f"refused scheme {parsed.scheme!r} (only http/https)" host = parsed.hostname if not host: return "refused: no host" try: literal = ipaddress.ip_address(host) return None if _ip_is_public(str(literal)) else f"refused non-public address {host}" except ValueError: pass try: infos = socket.getaddrinfo(host, None) except socket.gaierror: return f"refused: cannot resolve {host}" for info in infos: if not _ip_is_public(str(info[4][0])): return f"refused: {host} resolves to non-public address" return None class URLReader: def __init__(self, config_getter: Callable[[], Dict[str, Any]], image_cache) -> None: self._config = config_getter self.image_cache = image_cache def enabled(self) -> bool: return bool(self._config().get("enable-url-reading", False)) async def _get(self, session, url: str, max_bytes: int) -> Tuple[str, bytes, str]: """Manual redirect handling so every hop is re-guarded (URL-04).""" current = url for _ in range(MAX_REDIRECTS): reason = guard_url(current) if reason: raise ValueError(reason) async with session.get(current, allow_redirects=False) as response: if response.status in (301, 302, 303, 307, 308) and response.headers.get("Location"): current = urljoin(current, response.headers["Location"]) continue response.raise_for_status() content_type = str(response.headers.get("Content-Type", "")).split(";")[0].strip().lower() return str(response.url), await read_capped(response, max_bytes), content_type raise ValueError("too many redirects") async def fetch(self, url: str, channel: str, user: str) -> Dict[str, Any]: config = self._config() max_bytes = int(config.get("url-max-bytes", DEFAULT_MAX_BYTES)) timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT_S) try: async with aiohttp.ClientSession(timeout=timeout, headers={"User-Agent": "FjerkroaBot/1.0"}) as session: final_url, body, content_type = await self._get(session, url, max_bytes) # follow a meta-refresh redirect (link shorteners / getnews stubs), re-guarded — URL-04 for _ in range(2): if content_type.startswith("image/"): break extractor = self._extract(body.decode("utf-8", "ignore")) if not extractor.refresh_url: break target = urljoin(final_url, extractor.refresh_url) if guard_url(target) is not None or target == final_url: break logging.info(f"url reader: following meta-refresh -> {target}") final_url, body, content_type = await self._get(session, target, max_bytes) except Exception as err: return {"error": str(err)} # a URL that IS an image: cache it and hand it over as sight (URL-09) if content_type.startswith("image/"): vision = [] if self.image_cache is not None and len(body) < max_bytes: # >= cap means possibly truncated sha = self.image_cache.ingest_bytes(body, channel, user, None) data_url = self._cached_data_url(sha, channel) if sha else None vision = [data_url] if data_url else [] return {"url": final_url, "text": "(image)", "images_cached": len(vision), "vision": vision} html = body.decode("utf-8", "ignore") clean = sanitize_external_text(self._to_text(html), int(config.get("url-max-chars", DEFAULT_MAX_CHARS))) vision = await self._ingest_images(html, final_url, channel, user) return {"url": final_url, "text": clean, "images_cached": len(vision), "vision": vision} def _extract(self, html: str) -> "_Extractor": extractor = _Extractor() try: extractor.feed(html) except Exception as err: logging.debug(f"html parse failed: {err!r}") return extractor def _to_text(self, html: str) -> str: return re.sub(r"\s+\n", "\n", " ".join(self._extract(html).content_parts())) async def _ingest_images(self, html: str, base_url: str, channel: str, user: str) -> List[str]: """Cache page images and return their data URLs for vision input (URL-09).""" if self.image_cache is None: return [] extractor = self._extract(html) candidates = ([extractor.og_image] if extractor.og_image else []) + extractor.images limit = int(self._config().get("url-max-images", DEFAULT_MAX_IMAGES)) data_urls: List[str] = [] for src in candidates: if len(data_urls) >= limit: break absolute = urljoin(base_url, src) if guard_url(absolute) is not None: continue sha = await self.image_cache.ingest_url(absolute, channel, user, None) data_url = self._cached_data_url(sha, channel) if sha else None if data_url: data_urls.append(data_url) return data_urls def _cached_data_url(self, sha: str, channel: str) -> Optional[str]: recent = self.image_cache.recent(channel, 8) ext = next((row["ext"] for row in recent if row["sha256"] == sha), None) return self.image_cache.data_url(sha, ext) if ext else None