File size: 9,848 Bytes
e0265b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | from __future__ import annotations
"""Small, keyless web-search adapter for conversational context.
Search results are deliberately treated as untrusted reference material. This
module never opens result pages, downloads files, or performs actions other
than requesting the public search-results page.
"""
from dataclasses import dataclass
from html import unescape
from html.parser import HTMLParser
import ipaddress
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
@dataclass(frozen=True, slots=True)
class SearchResult:
title: str
url: str
snippet: str = ""
class WebSearchError(RuntimeError):
pass
class WebReadError(RuntimeError):
pass
class WebSearchClient:
"""Fetch a short list of public DuckDuckGo HTML search results."""
SEARCH_URL = "https://www.bing.com/search?format=rss&q={query}"
def search(self, query: str, *, limit: int = 5, timeout: float = 8.0) -> list[SearchResult]:
query = " ".join(query.split())
if not query:
return []
url = self.SEARCH_URL.format(query=urllib.parse.quote_plus(query))
request = urllib.request.Request(
url,
headers={"User-Agent": "ADAM-WebSearch/1.0 (+local assistant)"},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
page = response.read().decode("utf-8", errors="replace")
except (OSError, urllib.error.URLError, TimeoutError) as exc:
raise WebSearchError(f"Web search is unavailable: {exc}") from exc
results: list[SearchResult] = self._parse_bing_rss(page, limit)
if results:
return results
return self._parse_duckduckgo_html(page, limit)
@staticmethod
def _parse_bing_rss(page: str, limit: int) -> list[SearchResult]:
try:
root = ET.fromstring(page)
except ET.ParseError:
return []
results: list[SearchResult] = []
for item in root.findall("./channel/item"):
title = " ".join((item.findtext("title") or "").split())
result_url = (item.findtext("link") or "").strip()
snippet = " ".join((item.findtext("description") or "").split())
if title and result_url.startswith(("https://", "http://")):
results.append(SearchResult(title, result_url, snippet))
if len(results) >= max(1, min(limit, 10)):
break
return results
def _parse_duckduckgo_html(self, page: str, limit: int) -> list[SearchResult]:
results: list[SearchResult] = []
pattern = re.compile(
r'<a[^>]+class="result__a"[^>]+href="(?P<url>[^"]+)"[^>]*>(?P<title>.*?)</a>'
r'.{0,2500}?(?:<a[^>]+class="result__snippet"[^>]*>|<div[^>]+class="result__snippet"[^>]*>)(?P<snippet>.*?)</(?:a|div)>',
re.IGNORECASE | re.DOTALL,
)
for match in pattern.finditer(page):
title = self._clean_html(match.group("title"))
result_url = unescape(match.group("url"))
parsed = urllib.parse.urlparse(result_url)
if parsed.path.startswith("/l/"):
target = urllib.parse.parse_qs(parsed.query).get("uddg", [""])[0]
result_url = urllib.parse.unquote(target)
if title and result_url.startswith(("https://", "http://")):
results.append(SearchResult(title, result_url, self._clean_html(match.group("snippet"))))
if len(results) >= max(1, min(limit, 10)):
break
return results
@staticmethod
def _clean_html(value: str) -> str:
return " ".join(unescape(re.sub(r"<[^>]+>", " ", value)).split())
@dataclass(frozen=True, slots=True)
class WebPage:
url: str
title: str
text: str
class _ReadableTextParser(HTMLParser):
SKIP_TAGS = {"script", "style", "noscript", "svg", "nav", "footer", "header"}
def __init__(self) -> None:
super().__init__()
self.title = ""
self._in_title = False
self._skip_depth = 0
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in self.SKIP_TAGS:
self._skip_depth += 1
if tag == "title":
self._in_title = True
def handle_endtag(self, tag: str) -> None:
if tag in self.SKIP_TAGS and self._skip_depth:
self._skip_depth -= 1
if tag == "title":
self._in_title = False
def handle_data(self, data: str) -> None:
if self._skip_depth:
return
if self._in_title:
self.title += data
self.parts.append(data)
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
return None
class WebPageReader:
"""Fetch small readable extracts from explicitly requested public pages."""
MAX_BYTES = 1_500_000
MAX_REDIRECTS = 3
def read(self, url: str, *, timeout: float = 10.0, max_characters: int = 6_000) -> WebPage:
current_url = url
opener = urllib.request.build_opener(_NoRedirect())
for _ in range(self.MAX_REDIRECTS + 1):
self._validate_public_url(current_url)
request = urllib.request.Request(
current_url,
headers={"User-Agent": "ADAM-WebReader/1.0 (+local assistant)"},
)
try:
response = opener.open(request, timeout=timeout)
except urllib.error.HTTPError as exc:
if exc.code in {301, 302, 303, 307, 308} and exc.headers.get("Location"):
current_url = urllib.parse.urljoin(current_url, exc.headers["Location"])
continue
raise WebReadError(f"Could not read page (HTTP {exc.code}).") from exc
except (OSError, urllib.error.URLError, TimeoutError) as exc:
raise WebReadError(f"Could not read page: {exc}") from exc
with response:
content_type = response.headers.get_content_type()
if content_type not in {"text/html", "text/plain"}:
raise WebReadError(f"This page is {content_type}, not readable web text.")
raw = response.read(self.MAX_BYTES + 1)
if len(raw) > self.MAX_BYTES:
raise WebReadError("This page is too large to read safely.")
body = raw.decode(response.headers.get_content_charset() or "utf-8", errors="replace")
if content_type == "text/plain":
text = " ".join(body.split())
return WebPage(current_url, current_url, text[:max_characters])
parser = _ReadableTextParser()
parser.feed(body)
text = " ".join(parser.parts).strip()
title = " ".join(parser.title.split()) or current_url
return WebPage(current_url, title, text[:max_characters])
raise WebReadError("Page redirected too many times.")
@staticmethod
def _validate_public_url(url: str) -> None:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise WebReadError("Only public http(s) links can be read.")
try:
addresses = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise WebReadError("Could not resolve that website.") from exc
for item in addresses:
address = ipaddress.ip_address(item[4][0])
if not address.is_global:
raise WebReadError("Private, local, and reserved network addresses are blocked.")
def should_search(request: str) -> bool:
"""Whether a chat request explicitly asks ADAM for fresh web information."""
return bool(re.search(
r"\b(?:search(?:\s+the)?\s+(?:web|internet|online)|web\s+search|"
r"look\s+up|google|browse\s+(?:the\s+)?(?:web|internet)|"
r"latest|current|today'?s|recent\s+news)\b",
request,
re.IGNORECASE,
))
def search_query_from_request(request: str) -> str:
"""Remove a conversational search command before sending the actual topic."""
query = re.sub(
r"^\s*(?:please\s+)?(?:search(?:\s+the)?\s+(?:web|internet|online)|"
r"web\s+search|look\s+up|google|browse\s+(?:the\s+)?(?:web|internet))"
r"\s+(?:for\s+)?",
"",
request,
flags=re.IGNORECASE,
)
return query.strip(" \t\r\n?!.") or request
def should_read_links(request: str) -> bool:
return bool(re.search(
r"\b(?:read|open|research|study|summari[sz]e|go\s+further|learn\s+more|"
r"look\s+into)\b.*\b(?:link|links|result|results|page|pages|web|website|site)\b"
r"|\b(?:read|open)\s+https?://",
request,
re.IGNORECASE,
))
def urls_in_request(request: str) -> list[str]:
return re.findall(r"https?://[^\s<>\]\[\)\}\"']+", request)
def format_search_context(results: list[SearchResult]) -> str:
if not results:
return "No usable web results were returned. Be transparent about that."
lines = []
for index, result in enumerate(results, 1):
snippet = f" — {result.snippet}" if result.snippet else ""
lines.append(f"[{index}] {result.title}{snippet}\nURL: {result.url}")
return "\n".join(lines)
def format_page_context(pages: list[WebPage]) -> str:
if not pages:
return "No linked pages could be read."
return "\n\n".join(
f"PAGE: {page.title}\nURL: {page.url}\nEXTRACT: {page.text}"
for page in pages
)
|