CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
web_fetch.py646 linesDownload Raw Back to tools
1"""Web scraping tool with academic URL routing."""2 3from __future__ import annotations4 5import asyncio6import logging7 8import httpx9 10from frontier_agent.core.tool import tool11from frontier_agent.infra.config import FrontierAgentConfig, get_config12from frontier_agent.infra.summary_llm import summarize as _summary_llm_summarize13from frontier_agent.infra.usage_meter import record_api_request14from plugins.tools._academic_fetch import (15    biorxiv_to_pdf,16    extract_pmcid,17    fetch_pmc_fulltext,18    fetch_unpaywall_oa_url,19    is_garbage_content,20    pubmed_to_pmc,21    resolve_doi,22    route_url,23)24from plugins.tools._bounded_fetch import (25    MAX_REDIRECT_HOPS,26    RedirectRefused,27    binary_content_type,28    blocked_download_url,29    next_hop,30    non_public_url_error,31    pin_to_address,32    read_bounded,33    strip_cross_origin_credentials,34    vet_public_url,35)36from plugins.tools._render_check import unrendered_kind37from plugins.tools._scrape_cache import (38    ScrapeUnavailable,39    format_skip_message,40    scrape_result_cache,41)42from plugins.tools._scrape_cache import cache as _scrape_cache43 44logger = logging.getLogger(__name__)45 46_MAX_RETRIES = 347_SHORT_CONTENT_THRESHOLD = 500       # re-try via Unpaywall when content is this short48_PAYWALL_SHORT_THRESHOLD = 500       # tighter check used for known paywall domains49 50# Below this many chars the raw page is returned verbatim instead of being51# routed through the summary LLM, even when ``info_to_extract`` is set. The52# summary LLM exists purely to keep 50KB+ pages from blowing the agent's53# context; a short page (a policy paragraph, a financial snippet, a small54# table) costs nothing to carry whole, and paraphrasing it through a55# temperature=1.0 extractor risks drift.56#57# Threshold picked from an offline A/B on real pages: long policy/legal58# text (13-24K chars) summarised faithfully, but a short, number-dense59# page fabricated a derived percentage and back-filled figures that were60# never on the page. Drift tracks number density, not length, so the line61# sits high enough to route short number-dense pages to raw (a few K62# tokens whole — no real context cost) while long-form still gets63# compressed. Tune upward if headroom allows.64_SUMMARY_MIN_CHARS = 12_00065 66 67@tool68async def web_fetch(69    url: str | list[str],70    info_to_extract: str | list[str] = "",71) -> str:72    """Scrape and extract content from one or more web pages.73 74    Automatic backend selection based on URL domain: PMC / PubMed / bioRxiv /75    medRxiv URLs go to the corresponding OA API; known paywall domains are76    routed via Unpaywall; everything else goes through Jina Reader. Retry,77    negative cache, and arXiv PDF→HTML redirect are applied automatically.78 79    A non-empty ``info_to_extract`` routes the raw content through a cheap80    summary LLM that extracts only the information requested. With it81    omitted the raw extracted text is returned (subject to overflow trim).82 83    Args:84        url: A URL string, or a list of URLs for parallel fetch.85        info_to_extract: Optional focus for the summary LLM. A single86            string applies to every URL; a list pairs with ``url`` 1:1.87 88    Returns:89        For a single URL, the extracted content directly. For a list, a90        numbered block per URL: ``[i] URL: …\\n    Info: …``.91    """92    urls, focuses = _normalise_inputs(url, info_to_extract)93    if not urls:94        return "Error: URL is required."95 96    if len(urls) == 1:97        return await _fetch_one(urls[0], focuses[0])98 99    results = await asyncio.gather(100        *(_fetch_one(u, f) for u, f in zip(urls, focuses, strict=False))101    )102    return "\n\n".join(103        f"[{i}] URL: {u}\n    Info: {r}"104        for i, (u, r) in enumerate(zip(urls, results, strict=False), 1)105    )106 107 108def _normalise_inputs(109    url: str | list[str], info_to_extract: str | list[str],110) -> tuple[list[str], list[str]]:111    """Coerce the LangChain payload into paired URL + focus lists."""112    from plugins.tools._coerce import coerce_json_list113    urls = coerce_json_list(url) if isinstance(url, str) else url114    if isinstance(urls, str):115        urls = [urls]116    elif not isinstance(urls, list):117        urls = []118    urls = [u.strip() for u in urls if isinstance(u, str) and u.strip()]119 120    focuses = (121        coerce_json_list(info_to_extract) if isinstance(info_to_extract, str)122        else info_to_extract123    )124    if isinstance(focuses, str):125        focuses = [focuses] * len(urls)126    elif isinstance(focuses, list):127        focuses = [str(f) for f in focuses]128        if len(focuses) < len(urls):129            focuses = focuses + [""] * (len(urls) - len(focuses))130    else:131        focuses = [""] * len(urls)132    return urls, focuses133 134 135async def _fetch_one(url: str, info_to_extract: str) -> str:136    """Run the full fetch pipeline for a single URL."""137    if not url or not url.strip():138        return "Error: URL is required."139 140    url = url.strip()141    if not url.startswith(("http://", "https://")):142        url = f"https://{url}"143 144    # Vet the target before ANYTHING leaves the process — including the scrape145    # provider's request, which would otherwise be handed an internal URL.146    non_public = await non_public_url_error(url)147    if non_public:148        return (149            f"[BLOCKED] {non_public}. Only public http(s) endpoints may be "150            "fetched. Use a public source."151        )152 153    # Operator hard-block (WEB_DOMAIN_BLACKLIST_EXTRA) — same list that154    # filters web_search results, enforced here so a direct URL from page155    # content cannot bypass it.156    from plugins.tools.web_search import is_domain_blocked157    if is_domain_blocked(url):158        return (159            f"[BLOCKED] The domain of {url} is on the operator blocklist "160            f"and must not be accessed. Use a different source."161        )162 163    blocked_ext = blocked_download_url(url)164    if blocked_ext:165        return (166            f"[BLOCKED] This URL is a dataset/archive download ({blocked_ext}), "167            "not a web page. Do not download data files — read the dataset's "168            "documentation/landing page instead, or use aggregate API queries."169        )170 171    # arXiv PDFs consistently fail extraction — redirect to HTML abstract page.172    if "arxiv.org/pdf/" in url:173        html_url = url.replace("/pdf/", "/abs/").split(".pdf")[0]174        logger.info("Redirecting arxiv PDF → HTML: %s", html_url)175        url = html_url176    elif "arxiv.org/pdf" in url:177        html_url = url.replace("/pdf", "/abs")178        logger.info("Redirecting arxiv PDF → HTML: %s", html_url)179        url = html_url180 181    # Negative-cache check: skip URLs that recently returned 403/422/429.182    cached = _scrape_cache.check(url)183    if cached is not None:184        msg = format_skip_message(url, cached)185        logger.info("web_fetch skipped (cached %d): %s", cached.status, url[:60])186        return msg187 188    config = get_config()189    route = route_url(url)190 191    # A fresh scrape is classified in the scrape function, the cache predicate,192    # and the caller-facing warning path. Keep the verdicts for this one fetch193    # so HTML parsing happens once per distinct response body.194    render_verdicts: list[tuple[str, str | None]] = []195 196    def _render_kind(content: str) -> str | None:197        for seen, verdict in render_verdicts:198            if content is seen or content == seen:199                return verdict200        verdict = unrendered_kind(content)201        render_verdicts.append((content, verdict))202        return verdict203 204    # Single-flight cross-run cache: sibling agents fetching the same URL share205    # one round-trip. Only validated, non-garbage content is cached; the empty206    # / garbage branches raise so the failure is neither stored nor shared.207    async def _scrape() -> str:208        content = await _fetch_via_route(url, route, config)209        # Post-fetch quality check: Jina can return a CAPTCHA/login page as a210        # 200, and some un-listed paywalls slip past the route table. Try one211        # Unpaywall-driven retry (cheap — fails fast when no DOI resolves).212        content = await _maybe_recover_via_unpaywall(url, route, content, config)213        if not content:214            raise ScrapeUnavailable("empty")215        # Final garbage check — Jina can return a CAPTCHA/login page as success.216        if is_garbage_content(content):217            raise ScrapeUnavailable("garbage")218        # A pre-hydration DOM survived even the browser-engine escalation219        # above. Only the high-confidence ``shell`` verdict fails here: a220        # merely short body ("empty") is legitimate content often enough that221        # erroring on it would lose real pages.222        if _render_kind(content) == "shell":223            raise ScrapeUnavailable("unrendered")224        return content225 226    try:227        content = await scrape_result_cache.get_or_scrape(228            url,229            _scrape,230            # A low-confidence short page is still useful enough to return,231            # but it may be a pre-hydration race. Never let it become the232            # process-wide answer for every later fetch of this URL.233            should_cache=lambda scraped: _render_kind(scraped) is None,234        )235    except ScrapeUnavailable as exc:236        if str(exc) == "unrendered":237            return (238                f"[NOT RENDERED] The page at {url} is a JavaScript app that "239                f"served no content even with browser rendering. Fetching it "240                f"again the same way will not help: look for the same material "241                f"at another source (the site's own API/JSON endpoint, a PDF, "242                f"or an archive copy), or search for the page title instead."243            )244        if str(exc) == "garbage":245            return (246                f"[ACCESS BLOCKED] The page at {url} is behind a paywall or "247                f"anti-bot protection. Please try searching for an open-access "248                f"version (arxiv.org, PMC, institutional repositories)."249            )250        return f"Could not extract content from {url}"251 252    _scrape_cache.record_success(url)253 254    # LLM extraction if requested — uses dedicated SUMMARY_LLM_* config and255    # gracefully returns truncated raw content when unconfigured. Skipped for256    # short pages (< _SUMMARY_MIN_CHARS): they carry whole at no context cost,257    # and returning them verbatim avoids paraphrase drift on the exact numbers258    # / scope qualifiers the agent asked for. The focus is still served — the259    # agent reads ``info_to_extract`` straight from the raw page.260    if (261        info_to_extract262        and info_to_extract.strip()263        and len(content) >= _SUMMARY_MIN_CHARS264    ):265        output = await _summary_llm_summarize(content, info_to_extract)266    else:267        from plugins.tools._overflow import maybe_overflow268        output = maybe_overflow("web_fetch", content)269 270    if _render_kind(content) == "empty":271        return (272            f"[POSSIBLY NOT RENDERED] The page at {url} remained suspiciously "273            "short after the browser-render retry. This result was deliberately "274            "not cached. Use it if it answers the question; otherwise switch to "275            "another source instead of repeatedly fetching this URL.\n\n"276            f"{output}"277        )278    return output279 280 281# ── Routing ───────────────────────────────────────────────────────────────282 283async def _fetch_via_route(url: str, route: str, config: FrontierAgentConfig) -> str:284    """Dispatch to the domain-specific backend; always falls back to Jina."""285    if route == "pmc":286        return await _fetch_pmc(url, config)287    if route == "pubmed":288        return await _fetch_pubmed(url, config)289    if route == "biorxiv":290        return await _fetch_biorxiv(url, config)291    if route == "paywall":292        return await _fetch_paywall(url, config)293 294    # Generic route — Jina first, then trafilatura fallback.295    content = ""296    if config.jina_api_key:297        content = await _jina_scrape(url, config.jina_api_key, config.jina_base_url)298        if content.startswith("[UPSTREAM_EXHAUSTED"):299            direct = await _direct_scrape(url)300            return direct or f"Error: {content}"301    if not content:302        content = await _direct_scrape(url)303    return content304 305 306async def _fetch_pmc(url: str, config: FrontierAgentConfig) -> str:307    pmcid = extract_pmcid(url)308    if pmcid:309        text = await fetch_pmc_fulltext(pmcid)310        if text:311            return text312    return await _jina_or_empty(url, config)313 314 315async def _fetch_pubmed(url: str, config: FrontierAgentConfig) -> str:316    pmcid = await pubmed_to_pmc(url)317    if pmcid:318        text = await fetch_pmc_fulltext(pmcid)319        if text:320            return text321    return await _jina_or_empty(url, config)322 323 324async def _fetch_biorxiv(url: str, config: FrontierAgentConfig) -> str:325    pdf_url = biorxiv_to_pdf(url)326    text = ""327    if pdf_url:328        logger.info("[bioRxiv] Auto PDF: %s", pdf_url)329        text = await _jina_or_empty(pdf_url, config)330    if not text or len(text) < _SHORT_CONTENT_THRESHOLD:331        fallback = await _jina_or_empty(url, config)332        if fallback and len(fallback) > len(text):333            text = fallback334    return text335 336 337async def _fetch_paywall(url: str, config: FrontierAgentConfig) -> str:338    doi = await resolve_doi(url)339    text = ""340    if doi:341        oa_url = await fetch_unpaywall_oa_url(doi)342        if oa_url:343            logger.info("[Paywall bypass] %s → OA PDF: %s", url[:60], oa_url)344            text = await _jina_or_empty(oa_url, config)345    if not text or len(text) < _PAYWALL_SHORT_THRESHOLD:346        fallback = await _jina_or_empty(url, config)347        if fallback and len(fallback) > len(text):348            text = fallback349    return text350 351 352async def _jina_or_empty(url: str, config: FrontierAgentConfig) -> str:353    """Run Jina with the existing retry/cache logic; empty string on failure."""354    if not config.jina_api_key:355        return ""356    try:357        return await _jina_scrape(url, config.jina_api_key, config.jina_base_url)358    except Exception as exc:359        logger.warning("Jina fetch failed for %s: %s", url[:60], exc)360        return ""361 362 363async def _maybe_recover_via_unpaywall(364    url: str, route: str, content: str, config: FrontierAgentConfig,365) -> str:366    """If the routed fetch returned garbage or was suspiciously short on a367    paywall domain, attempt one Unpaywall-driven retry.368 369    Skipped for ``pmc``/``pubmed`` — if the BioC API couldn't resolve the370    article there's no DOI detour worth trying that Jina wouldn't already hit.371    """372    if route in ("pmc", "pubmed"):373        return content374 375    garbage = is_garbage_content(content)376    short_on_paywall = (377        route == "paywall"378        and content379        and len(content) < _PAYWALL_SHORT_THRESHOLD380    )381    if not garbage and not short_on_paywall:382        return content383 384    reason = "garbage" if garbage else "short"385    logger.warning(386        "[Quality] %s content detected for %s — trying Unpaywall fallback",387        reason, url[:60],388    )389    doi = await resolve_doi(url)390    if not doi:391        return content392    oa_url = await fetch_unpaywall_oa_url(doi)393    if not oa_url:394        return content395 396    alt = await _jina_or_empty(oa_url, config)397    if alt and not is_garbage_content(alt) and len(alt) > len(content):398        logger.info("[Quality] Unpaywall fallback succeeded: %d chars", len(alt))399        return alt400    return content401 402 403def _extract_jina_body(raw: str) -> str:404    """Return the usable content from a Jina response body.405 406    Jina's POST endpoint returns JSON wrapped as ``{"code":200,"status":...,407    "data":{"title":...,"content":"markdown..."}}`` when ``Accept: application/json``408    is set. Some proxies may return raw markdown instead. Handle both shapes409    and also detect balance errors.410    """411    if not raw:412        return ""413    stripped = raw.lstrip()414    if not stripped.startswith("{"):415        return raw416    try:417        import json as _json418        data = _json.loads(raw)419    except (ValueError, TypeError):420        return raw421 422    if isinstance(data, dict):423        # Balance-error sentinel.424        if data.get("name") == "InsufficientBalanceError":425            logger.error("Jina API: Insufficient balance")426            return ""427        # Wrapped success payload.428        inner = data.get("data")429        if isinstance(inner, dict) and isinstance(inner.get("content"), str):430            return inner["content"]431    return raw432 433 434async def _jina_request(435    url: str,436    api_key: str,437    base_url: str,438    *,439    browser: bool,440) -> tuple[int, str]:441    """Single Jina call. Returns (status_code, body_text). Status -1 on transport error.442 443    ``browser=True`` switches the Jina extraction engine to ``browser`` (real444    page render), which recovers many origins that block the direct fetch445    engine with 403/422. Uses a longer ``X-Timeout`` so dynamic content has446    time to load.447    """448    headers = {449        "Authorization": f"Bearer {api_key}",450        "Content-Type": "application/json",451        "Accept": "application/json",452        "X-Timeout": "30" if browser else "20",453    }454    if browser:455        headers["X-Engine"] = "browser"456        # The escalation exists because the first attempt produced nothing457        # usable; Jina caches its own responses, so without this the retry can458        # be answered from that same result.459        headers["X-No-Cache"] = "true"460 461    try:462        async with httpx.AsyncClient(timeout=60) as client:463            async with client.stream(464                "POST",465                base_url,466                headers=headers,467                json={"url": url},468            ) as resp:469                # Bounded read — a Jina conversion of a huge document470                # must not buffer past the byte cap.471                body, _ = await read_bounded(resp)472            # One answered Jina request is billable; non-2xx473            # statuses still consumed a reader call upstream, so count474            # them as requests too and tag the error separately.475            record_api_request(476                "jina", errors=0 if resp.status_code < 400 else 1,477            )478            # Jina returns UTF-8 JSON but omits ``charset`` in Content-Type,479            # which makes httpx fall back to chardet sniffing — chardet480            # mis-classifies CJK-heavy bodies with mostly-ASCII headers as481            # Windows-1252, producing mojibake like ``千と千尋`` →482            # ``åã¨åå°``. Force UTF-8 to skip the sniff entirely.483            return resp.status_code, body.decode("utf-8", errors="replace")484    except httpx.TimeoutException:485        record_api_request("jina", requests=0, errors=1)486        return -1, "timeout"487    except Exception as e:488        logger.error("Jina transport error for %s: %s", url[:60], e)489        record_api_request("jina", requests=0, errors=1)490        return -1, str(e)491 492 493async def _jina_scrape(url: str, api_key: str, base_url: str) -> str:494    """Scrape via Jina Reader with escalation retry.495 496    Strategy:497    1. Default (fast) engine first.498    2. On 403/422, escalate once to ``X-Engine: browser`` — covers SPAs and499       origins that block the direct fetch engine. Only caches the URL as500       banned if the browser-engine attempt also fails.501    3. Also escalate on a 200 whose body came back un-rendered: an SPA that had502       not hydrated when the reader captured it answers 200 with a503       navigation-only DOM, so a status-only rule never noticed (see504       ``_render_check``). The retry additionally bypasses Jina's own response505       cache, which would otherwise hand back the same shell.506    4. Retry 429/5xx with exponential backoff (unchanged).507    """508    escalated = False509    # Only a 429 means Jina capped us for volume; exhausting the retries against510    # 5xx is an outage. The two must not reach the visitor as the same message.511    rate_limited = False512 513    for attempt in range(_MAX_RETRIES):514        status, body = await _jina_request(url, api_key, base_url, browser=escalated)515 516        if status == 200:517            content = _extract_jina_body(body)518            kind = unrendered_kind(content)519            if kind is not None and not escalated:520                logger.info(521                    "Jina returned an un-rendered page for %s (%s, %d chars) "522                    "— escalating to browser engine", url[:60], kind, len(content),523                )524                escalated = True525                continue526            return content527 528        if status == 403:529            if not escalated:530                logger.info("Jina 403 for %s — escalating to browser engine", url[:60])531                escalated = True532                continue533            _scrape_cache.record_failure(url, 403)534            logger.warning("Jina 403 for %s after browser retry — cached (1h)", url[:60])535            return ""536 537        if status == 422:538            if not escalated:539                logger.info("Jina 422 for %s — escalating to browser engine", url[:60])540                escalated = True541                continue542            _scrape_cache.record_failure(url, 422)543            logger.info("Jina 422 for %s after browser retry — recorded", url[:60])544            return ""545 546        if status == 429:547            rate_limited = True548            wait = 2 ** attempt549            logger.warning("Jina 429 rate limited for %s, retrying in %ds", url[:60], wait)550            await asyncio.sleep(wait)551            continue552 553        if status >= 500:554            wait = 2 ** attempt555            logger.warning("Jina %d server error for %s, retrying in %ds", status, url[:60], wait)556            await asyncio.sleep(wait)557            continue558 559        if status == -1:560            # Transport error / timeout — treat like a soft retry.561            logger.warning("Jina transport failure for %s: %s (attempt %d)", url[:60], body[:60], attempt + 1)562            if attempt < _MAX_RETRIES - 1:563                await asyncio.sleep(1)564                continue565            return ""566 567        # Any other status — log and abort without caching.568        logger.error("Jina HTTP %d for %s (body: %s)", status, url[:60], body[:200])569        if status == 402:570            return "[UPSTREAM_EXHAUSTED provider=jina status=402]"571        return ""572 573    # Retries exhausted on 429/5xx — record as rate-limited so we back off.574    _scrape_cache.record_failure(url, 429)575    logger.error("Jina scrape exhausted retries for %s — cached 429 (5min)", url[:60])576    return "[UPSTREAM_EXHAUSTED provider=jina status=429]" if rate_limited else ""577 578 579async def _direct_scrape(url: str) -> str:580    """Direct scrape with httpx + trafilatura extraction."""581    try:582        import trafilatura583    except ImportError:584        return ""585 586    try:587        headers = {588            # Sent to every site this scrapes. Identify your deployment589            # here if you want operators to be able to contact you — the590            # default names the project, not any account.591            "User-Agent": (592                "Mozilla/5.0 (compatible; FrontierAgent/1.0; "593                "+https://github.com/ApodexAI/FrontierAgent)"594            ),595        }596        # Redirects are walked by hand so every hop is vetted: following them597        # automatically would let a vetted public URL hand us a 302 to598        # localhost or a metadata endpoint (see ``next_hop``).599        async with httpx.AsyncClient(timeout=60, follow_redirects=False) as client:600            hop_url, hop_headers = url, headers601            for _ in range(MAX_REDIRECT_HOPS):602                refusal, addresses = await vet_public_url(hop_url)603                if refusal:604                    raise RedirectRefused(refusal)605                dial_url, dial_headers, extensions = pin_to_address(606                    hop_url, addresses, hop_headers,607                )608                async with client.stream(609                    "GET", dial_url, headers=dial_headers, extensions=extensions,610                ) as resp:611                    target = await next_hop(resp, hop_url)612                    if target is not None:613                        hop_headers = strip_cross_origin_credentials(614                            hop_headers, hop_url, target,615                        )616                        hop_url = target617                        continue618                    resp.raise_for_status()619                    # A data blob (dataset zip, media) has no page text for620                    # trafilatura; skip the download instead of buffering it.621                    if binary_content_type(resp.headers.get("content-type")):622                        logger.warning(623                            "Direct scrape skipped binary content for %s", url[:60],624                        )625                        return ""626                    body, _ = await read_bounded(resp)627                    break628            else:629                logger.warning("Direct scrape exceeded redirect limit for %s", url[:60])630                return ""631    except RedirectRefused as exc:632        logger.warning("Direct scrape refused a redirect for %s: %s", url[:60], exc)633        return ""634    except httpx.TimeoutException:635        logger.warning("Direct scrape timeout for %s", url[:60])636        return ""637    except Exception as e:638        logger.warning("Direct scrape failed for %s: %s", url[:60], e)639        return ""640 641    # Pass raw bytes so trafilatura reads the HTML ``<meta charset>``642    # itself instead of trusting httpx's chardet sniff — pages that mix643    # heavy ASCII boilerplate with a small CJK payload get mis-classified644    # as Windows-1252 by chardet (UTF-8 → mojibake).645    return trafilatura.extract(body) or ""646