"""
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"
]*>([^<]+)", html, re.I)
d = re.search(
r']+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']+href=["\'\']([^\"\'\'#][^\"\'\']*)["\'\'][^>]*>([^<]{3,80})',
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": ""}