Spaces:
Running
Running
File size: 12,295 Bytes
28a08e7 | 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 | """
web_fetch.py β Scarica + pulisce una pagina web. NON restituisce HTML grezzo.
W-NAV: pipeline aggiornata con 3 livelli:
1. httpx (statico) + trafilatura come primary parser
2. Se content < 300 chars dopo pulizia β Playwright fallback (SPA/React/Vue/Next.js)
3. Se Playwright non disponibile β ritorna il poco testo estratto con warning
Problematiche anticipate:
- SPA JS-heavy: httpx riceve HTML quasi vuoto (~50 chars). Soglia 300 chars
copre questi casi senza attivare Playwright su pagine statiche normali.
- Playwright in web_fetch.py Γ¨ indipendente da browser.py (zero import circolari).
Crea una sessione Playwright usa-e-getta, non persiste in _sessions.
- OOM guard: _fetch_with_playwright usa asyncio.Lock per serializzare i launch
e ha un timeout aggressivo (12s) per non occupare memoria a lungo.
- trafilatura fallisce silenziosamente su pagine login/404/SPA vuote β
_clean_html regex come secondo fallback.
- TIMEOUT_PLAYWRIGHT < GOTO_TIMEOUT di browser.py (20s vs 25s) β intenzionale:
web_fetch Γ¨ un tool "leggero", browser.py Γ¨ il tool "pesante" per sessioni
interattive.
"""
import asyncio
import re
import httpx
import ipaddress
import socket
from urllib.parse import urlparse
from typing import Optional
import logging
_logger = logging.getLogger("tools.web_fetch")
# SAS (P45-SAS): Semantic Anchor Scoring β lazy import, fail-safe fallback
_sas_available = False
try:
from tools import semantic_nav as _semantic_nav # noqa: PLC0415
_sas_available = True
except ImportError:
pass
USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
MAX_CONTENT = 6000 # W-NAV: alzato da 5000 β 6000 (allineato a MAX_TEXT browser.py)
_PLAYWRIGHT_THRESHOLD = 300 # chars: se content < soglia β prova Playwright
_TIMEOUT_PLAYWRIGHT = 20_000 # QF-3: 12β20s β SPA complesse hanno bisogno di piΓΉ tempo (LCP/hydration)
# Lock globale: un solo Playwright instance alla volta in web_fetch
# (browser.py ha i suoi; vogliamo max 3 Chromium totali: 2 browser.py + 1 web_fetch)
_playwright_lock = asyncio.Lock()
# βββ HTML cleaners ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _clean_html(html: str) -> str:
"""Regex-based cleaner β fallback quando trafilatura non Γ¨ disponibile."""
html = re.sub(
r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>",
" ", html, flags=re.S | re.I,
)
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _extract_meta(html: str) -> dict:
t = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
d = re.search(
r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']',
html, re.I,
)
return {
"title": t.group(1).strip() if t else "",
"description": d.group(1).strip() if d else "",
}
def _extract_relevant_links(html: str, base_url: str, max_links: int = 10) -> list[dict]:
"""
ARL: estrae link rilevanti dall'HTML grezzo per navigazione ricorsiva (deepResearchTool).
Filtra: social, CDN, tracking, ancore interne, mailto, javascript.
Ordina: stesso dominio in testa (piΓΉ probabile contenuto rilevante).
"""
from urllib.parse import urljoin, urlparse as _up
base_domain = _up(base_url).netloc
skip = re.compile(
r'(twitter\.com|x\.com|facebook\.com|instagram\.com|linkedin\.com|'
r'cdn\.|analytics|pixel|tracking|googletagmanager|doubleclick|'
r'javascript:|mailto:|tel:)',
re.I
)
links: list[dict] = []
for m in re.finditer(
r'<a[^>]+href=["\'\']([^\"\'\'#][^\"\'\']*)["\'\'][^>]*>([^<]{3,80})</a>',
html, re.I
):
raw_href, text = m.group(1).strip(), m.group(2).strip()
if not raw_href or skip.search(raw_href):
continue
url = urljoin(base_url, raw_href)
if not url.startswith(("http://", "https://")):
continue
entry = {"url": url, "text": text[:60]}
if _up(url).netloc == base_domain:
links.insert(0, entry) # stesso dominio in testa
else:
links.append(entry)
return links[:max_links]
def _parse_content(html: str, url: str = "", max_chars: int = MAX_CONTENT) -> str:
"""
Parser livello 1: prova trafilatura, fallback a _clean_html.
Separato da fetch_page per essere usabile anche da _fetch_with_playwright.
"""
try:
import trafilatura # type: ignore[import-untyped]
extracted = trafilatura.extract(
html,
url=url or None,
include_comments=False,
include_tables=True,
include_images=False,
deduplicate=True,
favor_recall=True,
)
if extracted and len(extracted.strip()) > 200:
if len(extracted) > max_chars:
extracted = extracted[:max_chars] + "\n[troncato]"
return extracted
except Exception as _exc:
_logger.debug("[web_fetch] silenced %s", type(_exc).__name__) # noqa: BLE001
# Fallback regex
text = _clean_html(html)
if len(text) > max_chars:
text = text[:max_chars] + "\n[...troncato...]"
return text
# βββ Playwright fallback (usa-e-getta, nessun import da browser.py) βββββββββββ
async def _fetch_with_playwright(url: str, max_chars: int = MAX_CONTENT) -> Optional[str]:
"""
Fetch Playwright usa-e-getta per SPA/React/Vue/Next.js.
W-NAV1: attivato da fetch_page() quando content < _PLAYWRIGHT_THRESHOLD.
Design:
- asyncio.Lock globale: serializza i launch (max 1 alla volta da web_fetch)
- Timeout aggressivo: _TIMEOUT_PLAYWRIGHT = 20s (usa-e-getta β sessione persistente)
- networkidle: aspetta 500ms senza richieste di rete (copre SPA con lazy loading)
fallback a domcontentloaded se timeout
- trafilatura: estrae il mainbody dall'HTML grezzo di Playwright
- Sempre close() nel finally: zero memory leak
- Restituisce None se playwright non installato o fallisce (caller gestisce)
"""
try:
import playwright # noqa: F401 β verifica disponibilitΓ prima del lock
except ImportError:
return None # playwright non installato β caller usa il poco testo statico
async with _playwright_lock:
try:
from playwright.async_api import async_playwright
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True,
args=[
"--no-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage", "--disable-gpu",
"--single-process", "--no-zygote",
"--disable-extensions", "--mute-audio",
"--disable-background-networking",
],
)
ctx = await browser.new_context(
viewport={"width": 1280, "height": 800},
user_agent=USER_AGENT,
)
page = await ctx.new_page()
try:
# networkidle per SPA β fallback su timeout (polling infinito)
try:
await page.goto(url, wait_until="networkidle", timeout=_TIMEOUT_PLAYWRIGHT)
except Exception:
try:
await page.wait_for_load_state("domcontentloaded", timeout=3000)
except Exception as _exc:
_logger.debug("[web_fetch] silenced %s", type(_exc).__name__) # noqa: BLE001
# Breve attesa per rendering JS post-load
await page.wait_for_timeout(500)
# Estrai HTML e parsa con trafilatura
html = await page.content()
text = _parse_content(html, url=url, max_chars=max_chars)
return text if text and len(text.strip()) > 100 else None
finally:
await ctx.close()
await browser.close()
except Exception:
return None
# βββ Endpoint principale ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def fetch_page(url: str, max_chars: int = MAX_CONTENT, navigation_intent_keywords: list[str] | None = None) -> dict:
"""
Fetch + estrazione testo con pipeline a 3 livelli:
L1: httpx statico + trafilatura (veloce, nessun overhead)
L2: Playwright usa-e-getta se content < 300 chars (SPA/React/Vue)
L3: warning nel result se entrambi falliscono (mai ritornare stringa vuota)
Invariante W-NAV1: mai ritornare content vuoto senza aver tentato il browser.
"""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return {"url": url, "error": "Schema non supportato", "content": ""}
# S780-SSRF: blocca IP privati/loopback prima della fetch per prevenire SSRF
_host = parsed.hostname or ""
try:
_ip = ipaddress.ip_address(socket.gethostbyname(_host))
if _ip.is_private or _ip.is_loopback or _ip.is_link_local or _ip.is_reserved:
return {"url": url, "error": "SSRF bloccato: IP privato/loopback non permesso", "content": ""}
except Exception:
pass # hostname non risolvibile o parsing fallito β httpx gestirΓ l'errore
async with httpx.AsyncClient(
timeout=15,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
) as c:
r = await c.get(url)
html = r.text
meta = _extract_meta(html)
# L1: trafilatura / regex parse
text = _parse_content(html, url=url, max_chars=max_chars)
used_playwright = False
# L2: Playwright fallback per SPA/JS-heavy (W-NAV1)
if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
pw_text = await _fetch_with_playwright(url, max_chars=max_chars)
if pw_text and len(pw_text.strip()) >= _PLAYWRIGHT_THRESHOLD:
text = pw_text
used_playwright = True
# L3: se ancora poco testo, aggiungi warning nel result
# (mai ritornare errore β il poco testo Γ¨ comunque utile)
warning = None
if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
warning = (
f"Contenuto scarso ({len(text.strip())} chars) β "
"pagina potrebbe richiedere autenticazione o JavaScript avanzato."
)
result: dict = {
"url": url,
"title": meta["title"],
"description": meta["description"],
"content": text,
"status": r.status_code,
"chars": len(text),
}
if used_playwright:
result["extractor"] = "playwright"
if warning:
result["warning"] = warning
# SAS: prioritizza link per intent semantico (P45-SAS)
if _sas_available:
try:
result["relevant_links"] = _semantic_nav.extract_and_score_links(
html_content=html,
base_url=url,
navigation_intent_keywords=navigation_intent_keywords,
)
except Exception:
result["relevant_links"] = _extract_relevant_links(html, url)
else:
result["relevant_links"] = _extract_relevant_links(html, url)
return result
except httpx.TimeoutException:
return {"url": url, "error": "Timeout", "content": ""}
except Exception as e:
return {"url": url, "error": str(e), "content": ""}
|