Spaces:
Sleeping
Sleeping
File size: 14,392 Bytes
7d3b88b | 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 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 | """Web reader tool β fetches and extracts clean content from web pages.
Uses a lightweight extraction pipeline:
1. HTTP fetch via httpx (fast, no browser needed)
2. Content extraction via readability-lxml (Mozilla's Readability algorithm)
3. Fallback to BeautifulSoup heuristic extraction
4. Text cleaning: dedup, noise removal, fact extraction
Returns concise bullet-point facts extracted from the page content.
"""
from __future__ import annotations
import logging
import re
from typing import Any
import httpx
from app.tools.base_tool import BaseTool
logger = logging.getLogger(__name__)
# ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_TIMEOUT = 10.0 # seconds
_MAX_CONTENT_BYTES = 500_000 # reject very large pages (500 KB)
_MAX_TEXT_CHARS = 15_000 # truncate extracted text before processing
_MAX_FACTS = 10 # max bullet-point facts to return
_MIN_PARAGRAPH_LENGTH = 40 # chars β skip short/noisy lines
_MIN_FACT_WORDS = 5 # words β minimum for a meaningful fact
_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
_BLOCKED_EXTENSIONS = frozenset({
".pdf", ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp",
".mp4", ".mp3", ".wav", ".zip", ".tar", ".gz", ".rar",
".exe", ".dmg", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
})
# ββ Tool class βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class WebReaderTool(BaseTool):
"""Fetch a web page and extract its main content as clean text."""
@property
def name(self) -> str:
return "web_reader"
@property
def description(self) -> str:
return (
"Fetch a web page URL and extract its main article content as clean text. "
"Use this AFTER web_search to read the full content of a promising result. "
"Only use for 1β2 URLs per query β do not over-fetch. "
"Input must be a valid HTTP/HTTPS URL."
)
@property
def input_schema(self) -> dict[str, Any]:
return {
"url": {
"type": "string",
"description": (
"The full URL of the web page to read "
"(must start with http:// or https://)"
),
}
}
def run(self, **kwargs: Any) -> str:
"""Fetch URL β extract content β return bullet-point facts."""
url = kwargs.get("url", "")
if not url:
return "Error: 'url' parameter is required."
# ββ Validate URL ββ
if not url.startswith(("http://", "https://")):
return f"Error: Invalid URL β must start with http:// or https://. Got: {url}"
# Block non-HTML resources
lower_url = url.lower().split("?")[0]
if any(lower_url.endswith(ext) for ext in _BLOCKED_EXTENSIONS):
return f"Error: URL points to a non-HTML resource: {url}"
logger.info("[WebReader] Fetching: %s", url)
# ββ Fetch ββ
try:
html = self._fetch(url)
except Exception as exc:
logger.error("[WebReader] Fetch failed for %s: %s", url, exc)
return f"Error: Failed to fetch page β {exc}"
if not html or len(html.strip()) < 100:
return "Error: Page returned empty or minimal content."
# ββ Extract main content ββ
text = self._extract_content(html, url)
if not text or len(text.strip()) < 50:
return f"Error: Could not extract meaningful content from {url}"
# ββ Clean and extract facts ββ
facts = self._extract_facts(text)
if not facts:
return f"Error: No meaningful facts could be extracted from {url}"
# ββ Format output ββ
header = f"Content extracted from: {url}\n"
body = "\n".join(f"- {fact}" for fact in facts)
result = header + body
logger.info(
"[WebReader] Extracted %d facts (%d chars) from %s",
len(facts), len(result), url,
)
return result
# ββ Fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@staticmethod
def _fetch(url: str) -> str:
"""Fetch page HTML via httpx with timeout and size limits."""
headers = {
"User-Agent": _USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,"
"application/xml;q=0.9,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9",
}
with httpx.Client(
timeout=_TIMEOUT,
follow_redirects=True,
max_redirects=5,
) as client:
response = client.get(url, headers=headers)
response.raise_for_status()
# Verify content-type is HTML
content_type = response.headers.get("content-type", "")
if (
"text/html" not in content_type
and "application/xhtml" not in content_type
):
raise ValueError(f"Non-HTML content type: {content_type}")
# Reject oversized pages
if len(response.content) > _MAX_CONTENT_BYTES:
raise ValueError(
f"Page too large: {len(response.content):,} bytes "
f"(limit {_MAX_CONTENT_BYTES:,})"
)
return response.text
# ββ Content extraction βββββββββββββββββββββββββββββββββββββββββββββ
@staticmethod
def _extract_content(html: str, url: str) -> str:
"""Extract main article text.
Strategy 1: readability-lxml (Mozilla Readability algorithm)
Strategy 2: BeautifulSoup heuristic extraction
"""
# Strategy 1: readability-lxml
text = WebReaderTool._try_readability(html, url)
if text and len(text.strip()) > 100:
logger.info("[WebReader] Extracted via readability (%d chars)", len(text))
return text[:_MAX_TEXT_CHARS]
# Strategy 2: BeautifulSoup heuristics
text = WebReaderTool._try_beautifulsoup(html)
if text and len(text.strip()) > 100:
logger.info("[WebReader] Extracted via BeautifulSoup (%d chars)", len(text))
return text[:_MAX_TEXT_CHARS]
logger.warning(
"[WebReader] Both extraction methods returned insufficient content"
)
return ""
@staticmethod
def _try_readability(html: str, url: str) -> str:
"""Extract using readability-lxml (Mozilla Readability algorithm)."""
try:
from readability import Document # type: ignore[import-untyped]
from bs4 import BeautifulSoup
doc = Document(html, url=url)
summary_html = doc.summary()
# Convert the summary HTML fragment to plain text
soup = BeautifulSoup(summary_html, "lxml")
return soup.get_text(separator="\n", strip=True)
except ImportError:
logger.debug("[WebReader] readability-lxml not installed β skipping")
return ""
except Exception as exc:
logger.warning("[WebReader] Readability extraction failed: %s", exc)
return ""
@staticmethod
def _try_beautifulsoup(html: str) -> str:
"""Extract using BeautifulSoup heuristics β find largest text block."""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
# Remove non-content elements
for tag_name in (
"script", "style", "nav", "header", "footer",
"aside", "iframe", "noscript", "form",
"button", "input", "select", "textarea",
):
for tag in soup.find_all(tag_name):
tag.decompose()
# Remove elements with common non-content classes / IDs
_noise_re = re.compile(
r"(nav|menu|sidebar|footer|header|banner|ad[sv]?|social|"
r"share|comment|cookie|popup|modal|newsletter|subscribe|"
r"related|widget|breadcrumb)",
re.IGNORECASE,
)
for tag in soup.find_all(True):
classes = " ".join(tag.get("class", []))
tag_id = tag.get("id", "")
if _noise_re.search(classes) or _noise_re.search(tag_id):
tag.decompose()
# Try common article containers first
article = (
soup.find("article")
or soup.find("main")
or soup.find(attrs={"role": "main"})
or soup.find(
"div",
class_=re.compile(
r"(article|content|post|entry|story)", re.I
),
)
)
if article:
text = article.get_text(separator="\n", strip=True)
if len(text) > 200:
return text
# Fallback: div/section with the most paragraph text
best_text = ""
for container in soup.find_all(["div", "section"]):
paragraphs = container.find_all("p")
if len(paragraphs) >= 3:
text = "\n".join(
p.get_text(strip=True) for p in paragraphs
)
if len(text) > len(best_text):
best_text = text
if best_text:
return best_text
# Last resort: all <p> elements
all_p = soup.find_all("p")
if all_p:
return "\n".join(
p.get_text(strip=True)
for p in all_p
if len(p.get_text(strip=True)) > 20
)
return soup.get_text(separator="\n", strip=True)
except ImportError:
logger.error("[WebReader] beautifulsoup4 not installed")
return ""
except Exception as exc:
logger.warning("[WebReader] BeautifulSoup extraction failed: %s", exc)
return ""
# ββ Fact extraction ββββββββββββββββββββββββββββββββββββββββββββββββ
@staticmethod
def _extract_facts(text: str) -> list[str]:
"""Clean text and extract concise bullet-point facts."""
lines = text.split("\n")
# Phase 1: Clean lines β dedup, remove noise
cleaned: list[str] = []
seen: set[str] = set()
for line in lines:
line = line.strip()
if not line:
continue
if len(line) < _MIN_PARAGRAPH_LENGTH:
continue
if len(line.split()) < _MIN_FACT_WORDS:
continue
# Skip duplicate / near-duplicate lines
normalised = re.sub(r"\s+", " ", line.lower())
if normalised in seen:
continue
seen.add(normalised)
# Skip obvious noise
if WebReaderTool._is_noise(line):
continue
cleaned.append(line)
# Phase 2: Score and select top facts
scored: list[tuple[int, str]] = []
for line in cleaned:
score = 0
# Bonus for numbers / dates
if re.search(r"\d", line):
score += 2
# Bonus for proper nouns
if re.search(r"[A-Z][a-z]{2,}", line):
score += 1
# Bonus for factual language
if re.search(
r"(?:according|reported|announced|published|"
r"launched|released|percent|%|\$)",
line,
re.I,
):
score += 2
# Bonus for longer substantive lines
if len(line) > 100:
score += 1
scored.append((score, line))
scored.sort(key=lambda x: x[0], reverse=True)
# Take top facts, truncate overly long ones
facts: list[str] = []
for _, line in scored[:_MAX_FACTS]:
if len(line) > 300:
# Truncate at the nearest sentence boundary
sentences = re.split(r"(?<=[.!?])\s+", line)
truncated = ""
for s in sentences:
if len(truncated) + len(s) > 280:
break
truncated += s + " "
line = truncated.strip()
if not line.endswith((".", "!", "?")):
line += "β¦"
facts.append(line)
return facts
# ββ Noise detection ββββββββββββββββββββββββββββββββββββββββββββββββ
@staticmethod
def _is_noise(line: str) -> bool:
"""Return True if the line is likely navigation/UI noise."""
lower = line.lower()
noise_starters = (
"cookie", "subscribe", "sign up", "log in", "sign in",
"follow us", "share this", "read more", "click here",
"advertisement", "sponsored", "all rights reserved",
"privacy policy", "terms of", "copyright",
"skip to", "toggle", "menu", "search for",
"accept all", "reject all", "manage preferences",
)
if any(lower.startswith(n) for n in noise_starters):
return True
# Too many special characters β likely UI elements
special_ratio = (
sum(1 for c in line if not c.isalnum() and c != " ")
/ max(len(line), 1)
)
if special_ratio > 0.3:
return True
return False
|