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": ""}