Spaces:
Running
Running
Download scripts/webdata_routes.py from eltociear/skill-audit: direct link, hf CLI and curl.
- Browser
- Download file 34.7 kB
-
https://huggingface.co/spaces/eltociear/skill-audit/resolve/main/scripts/webdata_routes.py
- Command line
-
hf download hf://spaces/eltociear/skill-audit/scripts/webdata_routes.py
-
curl -L -o webdata_routes.py https://huggingface.co/spaces/eltociear/skill-audit/resolve/main/scripts/webdata_routes.py
34.7 kB
| #!/usr/bin/env python3 | |
| """Shared web-data routes, mountable on any of our x402 FastAPI Spaces. | |
| Why this exists: 30 days of agentic.market data (53,127 endpoints, 336,081 paid | |
| calls) puts search + web-data at 59% of all x402 traffic at ~45 calls per endpoint, | |
| while security/audit is the worst-served category on the network β 4,614 endpoints | |
| splitting 8,800 calls, ~1.9 each. Our own on-chain history agrees: 331 settle events, | |
| every one of them at $0.005 or $0.01, none at the premium security prices. | |
| The catch is distribution: reaching the Coinbase CDP facilitator, which is what populates | |
| the 24,827-entry CDP Bazaar and, downstream, agentic.market and 402index. So the web-data | |
| routes live here, in one module, and get mounted on whichever Space can reach buyers. | |
| (Corrected 2026-07-29: this used to say skill-audit settles through Dexter and is | |
| invisible. It does not β `/` reports the CDP facilitator on BOTH Spaces. Re-verify from | |
| the live `payment.facilitator` field before repeating any claim about which rail a Space | |
| is on; the consequence of the stale version was nearly moving a Space OFF CDP.) | |
| Mount with: | |
| from webdata_routes import build_router, route_specs | |
| app.include_router(build_router(), prefix="/web") | |
| routes.update(route_specs(prefix="/web", price_cheap="$0.005", ...)) | |
| Every outbound fetch goes through _safe_get/_fetch_text, which refuse loopback, | |
| private, link-local and reserved targets (including cloud metadata at 169.254.169.254) | |
| and re-validate on every redirect hop. | |
| """ | |
| from datetime import datetime | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel | |
| from typing import Optional | |
| router = APIRouter() | |
| class ReadRequest(BaseModel): | |
| url: str | |
| include_links: Optional[bool] = True | |
| max_size: Optional[int] = 2_000_000 | |
| class SearchRequest(BaseModel): | |
| query: str | |
| max_results: Optional[int] = 10 | |
| class CrawlRequest(BaseModel): | |
| url: str | |
| max_pages: Optional[int] = 5 | |
| include_links: Optional[bool] = True | |
| class SitemapRequest(BaseModel): | |
| url: str | |
| max_urls: Optional[int] = 500 | |
| class RssRequest(BaseModel): | |
| url: str | |
| max_items: Optional[int] = 50 | |
| def _host_is_blocked(host: str) -> bool: | |
| """True if `host` (name or literal) resolves to any non-public IP.""" | |
| import ipaddress, socket | |
| host = host.strip("[]") # strip IPv6 literal brackets | |
| try: # already a literal? | |
| ip = ipaddress.ip_address(host) | |
| return not ip.is_global | |
| except ValueError: | |
| pass | |
| try: | |
| infos = socket.getaddrinfo(host, None) | |
| except Exception: | |
| return True # unresolvable β refuse | |
| for info in infos: | |
| ip_str = info[4][0].split("%")[0] # drop scope id | |
| try: | |
| if not ipaddress.ip_address(ip_str).is_global: | |
| return True | |
| except ValueError: | |
| return True | |
| return False | |
| def _validate_public_url(url: str): | |
| """Raise HTTPException(400) unless url is http(s) with a public host.""" | |
| from urllib.parse import urlparse | |
| p = urlparse(url) | |
| if p.scheme not in ("http", "https") or not p.hostname: | |
| raise HTTPException(400, "valid http/https URL required") | |
| if _host_is_blocked(p.hostname): | |
| raise HTTPException(400, "URL host is not a public address (private/loopback/link-local blocked)") | |
| async def _safe_get(client, url: str, *, ua: str, max_redirects: int = 5): | |
| """GET with manual, SSRF-validated redirect following (each hop re-checked).""" | |
| _validate_public_url(url) | |
| current = url | |
| for _ in range(max_redirects + 1): | |
| resp = await client.get(current, headers={"User-Agent": ua}) | |
| if resp.is_redirect and resp.headers.get("location"): | |
| from urllib.parse import urljoin | |
| current = urljoin(current, resp.headers["location"]) | |
| _validate_public_url(current) # block redirect-to-internal | |
| continue | |
| return resp | |
| raise HTTPException(400, "too many redirects") | |
| async def _fetch_text(url: str, max_size: int, timeout: float = 20.0, ua: str = "Mozilla/5.0 (compatible; clean-read/1.0)"): | |
| """Fetch a URL and return its decoded body, truncated to max_size (SSRF-guarded).""" | |
| import httpx | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client: | |
| resp = await _safe_get(client, url, ua=ua) | |
| resp.raise_for_status() | |
| html = resp.text | |
| return (html[:max_size] if len(html) > max_size else html), str(resp.url) | |
| def _to_markdown(html: str, include_links: bool): | |
| """trafilatura extraction shared by /read and /read/batch. Returns (markdown, title).""" | |
| import trafilatura | |
| markdown = trafilatura.extract( | |
| html, output_format="markdown", | |
| include_links=include_links, include_tables=True, favor_recall=True, | |
| ) | |
| title = None | |
| try: | |
| meta = trafilatura.extract_metadata(html) | |
| if meta: | |
| title = meta.title | |
| except Exception: | |
| pass | |
| return markdown, title | |
| async def read_url(req: ReadRequest): | |
| url = req.url | |
| if not url or not url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| import httpx | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=20.0) as client: | |
| resp = await _safe_get(client, url, ua="Mozilla/5.0 (compatible; clean-read/1.0)") | |
| resp.raise_for_status() | |
| except HTTPException: | |
| raise | |
| except httpx.HTTPStatusError as e: | |
| raise HTTPException(502, f"upstream returned {e.response.status_code}") | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}: {e}") | |
| html = resp.text | |
| if len(html) > req.max_size: | |
| html = html[: req.max_size] | |
| try: | |
| import trafilatura | |
| except ImportError: | |
| raise HTTPException(503, "extraction engine unavailable") | |
| markdown = trafilatura.extract( | |
| html, output_format="markdown", | |
| include_links=bool(req.include_links), include_tables=True, favor_recall=True, | |
| ) | |
| if not markdown: | |
| raise HTTPException(422, "could not extract main content from this page") | |
| title = None | |
| try: | |
| meta = trafilatura.extract_metadata(html) | |
| if meta: | |
| title = meta.title | |
| except Exception: | |
| pass | |
| return { | |
| "url": str(resp.url), | |
| "title": title, | |
| "markdown": markdown, | |
| "word_count": len(markdown.split()), | |
| "fetched_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| _SEARCH_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") | |
| def _unwrap_ddg(href: str) -> str: | |
| """DDG html wraps results as //duckduckgo.com/l/?uddg=<urlencoded target>.""" | |
| from urllib.parse import urlparse, parse_qs, unquote | |
| if "uddg=" in href: | |
| q = parse_qs(urlparse(href if href.startswith("http") else "https:" + href).query) | |
| if q.get("uddg"): | |
| return unquote(q["uddg"][0]) | |
| return href | |
| def _parse_ddg(html: str, limit: int): | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out = [] | |
| # Anchor-first: DDG nests several `result*`-classed divs per hit, so selecting | |
| # containers double-counts. One anchor == one hit; snippet lives in its ancestor. | |
| seen = set() | |
| for a in doc.xpath("//a[contains(@class,'result__a')]"): | |
| url = _unwrap_ddg(a.get("href") or "") | |
| if not url.startswith("http") or url in seen: | |
| continue | |
| if "duckduckgo.com/y.js" in url or "ad_provider" in url: # sponsored | |
| continue | |
| seen.add(url) | |
| snip = a.xpath("ancestor::div[contains(@class,'result')][1]" | |
| "//*[contains(@class,'result__snippet')]") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a.text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(snip[0].text_content().split()) if snip else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def _parse_ddg_lite(html: str, limit: int): | |
| """lite.duckduckgo.com/lite β table layout: a.result-link + td.result-snippet.""" | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out, seen = [], set() | |
| for a in doc.xpath("//a[contains(@class,'result-link')]"): | |
| url = _unwrap_ddg(a.get("href") or "") | |
| if not url.startswith("http") or url in seen: | |
| continue | |
| seen.add(url) | |
| snip = a.xpath("ancestor::tr[1]/following-sibling::tr[1]//td[contains(@class,'result-snippet')]") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a.text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(snip[0].text_content().split()) if snip else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def _parse_wiby(body: str, limit: int): | |
| """wiby.me/json β a real JSON API: [{URL, Title, Snippet}]. Small index, but it is | |
| structured and answers reliably from a datacenter IP.""" | |
| import json as _json | |
| try: | |
| data = _json.loads(body) | |
| except Exception: | |
| return [] | |
| out = [] | |
| for row in data if isinstance(data, list) else []: | |
| url = row.get("URL") or "" | |
| if not url.startswith("http"): | |
| url = "https://" + url.lstrip("/") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join((row.get("Title") or "").split()), | |
| "url": url, | |
| "snippet": " ".join((row.get("Snippet") or "").split()) or None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def _generic_parser(*self_hosts): | |
| """Heuristic result extractor for a server-rendered engine whose markup we cannot | |
| inspect (these hosts answer differently from a datacenter IP than from a desktop, so | |
| there is no way to develop a precise selector against them offline). | |
| Takes every outbound link with link text, drops the engine's own chrome and the usual | |
| social/footer destinations, and dedups by URL. `/web/selftest` reports how many results | |
| each engine yields, so this stays verifiable in production without paying. | |
| """ | |
| JUNK = ("twitter.com", "x.com", "facebook.com", "github.com/marginalia", | |
| "wikipedia.org/wiki/Main_Page", "creativecommons.org", "mastodon", | |
| "patreon.com", "ko-fi.com", "reddit.com/r/", "discord.gg", "matrix.to") | |
| def parse(html: str, limit: int): | |
| from lxml import html as lh | |
| from urllib.parse import urlparse | |
| doc = lh.fromstring(html) | |
| out, seen = [], set() | |
| for a in doc.xpath("//a[@href]"): | |
| url = (a.get("href") or "").strip() | |
| if not url.startswith("http"): | |
| continue | |
| host = (urlparse(url).hostname or "").lower() | |
| if any(host == h or host.endswith("." + h) for h in self_hosts): | |
| continue | |
| if any(j in url for j in JUNK): | |
| continue | |
| title = " ".join(a.text_content().split()) | |
| if len(title) < 12 or url in seen: # nav/chrome links are short | |
| continue | |
| seen.add(url) | |
| # Snippet: the nearest following text block that is not another link. | |
| snippet = None | |
| for sib in a.xpath("following::p[1] | following::div[not(.//a)][1]"): | |
| t = " ".join(sib.text_content().split()) | |
| if len(t) > 40: | |
| snippet = t[:300] | |
| break | |
| out.append({"rank": len(out) + 1, "title": title[:200], "url": url, | |
| "snippet": snippet}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| return parse | |
| def _parse_mojeek(html: str, limit: int): | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out = [] | |
| for li in doc.xpath("//ul[contains(@class,'results-standard')]/li"): | |
| a = li.xpath(".//a[contains(@class,'title')] | .//h2/a") | |
| if not a: | |
| continue | |
| url = a[0].get("href") or "" | |
| if not url.startswith("http"): | |
| continue | |
| p = li.xpath(".//p[@class='s']") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a[0].text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(p[0].text_content().split()) if p else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| # Backend order is measured, not assumed. From this Space's datacenter IP, | |
| # html.duckduckgo.com is INTERMITTENT (two probes connect-timed-out at 15s, a third | |
| # returned 30KB), mojeek/ecosia/searx-instances/yep/qwant-api all 403 or 429, and | |
| # lite.qwant.com serves a JS shell with zero anchors. Marginalia and wiby answer | |
| # consistently, so they are the fallbacks that actually catch a DDG outage. | |
| SEARCH_ENGINES = [ | |
| ("duckduckgo", "https://html.duckduckgo.com/html/?q={q}", _parse_ddg), | |
| ("duckduckgo-lite", "https://lite.duckduckgo.com/lite/?q={q}", _parse_ddg_lite), | |
| ("marginalia", "https://old-search.marginalia.nu/search?query={q}", | |
| _generic_parser("marginalia.nu", "old-search.marginalia.nu", "search.marginalia.nu")), | |
| ("mojeek", "https://www.mojeek.com/search?q={q}", _parse_mojeek), | |
| ("wiby", "https://wiby.me/json/?q={q}", _parse_wiby), | |
| ] | |
| async def _run_search(query: str, limit: int): | |
| """Try each backend in order; return (name, results, errors).""" | |
| from urllib.parse import quote_plus | |
| errors = {} | |
| for name, tmpl, parse in SEARCH_ENGINES: | |
| try: | |
| body, _ = await _fetch_text(tmpl.format(q=quote_plus(query)), 3_000_000, | |
| timeout=20.0, ua=_SEARCH_UA) | |
| results = parse(body, limit) | |
| if results: | |
| return name, results, errors | |
| errors[name] = "no results parsed" | |
| except Exception as e: | |
| errors[name] = f"{type(e).__name__}: {e}" | |
| return None, [], errors | |
| async def web_search(req: SearchRequest): | |
| query = (req.query or "").strip() | |
| if not query: | |
| raise HTTPException(400, "query is required") | |
| limit = max(1, min(int(req.max_results or 10), 25)) | |
| name, results, errors = await _run_search(query, limit) | |
| if not results: | |
| # 502 so the payment middleware never settles a call that returned nothing. | |
| raise HTTPException(502, f"all search backends failed or returned nothing: {errors}") | |
| return {"query": query, "engine": name, "count": len(results), "results": results, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| # GET/POST/HEAD: this is a free diagnostic and the crawler fleet POSTs it. Registered | |
| # GET-only it answered 405, which tells a prober the path is not there at all. Its | |
| # full path is also in mcp_http.FREE_PATHS so tokenguard's blanket POST sweep cannot | |
| # turn a diagnostic into a paid route. | |
| async def search_selftest(): | |
| """FREE. Per-backend reachability and parser yield for a fixed query. | |
| Paid routes cannot be exercised without paying, so without this there is no way to | |
| tell a backend that is unreachable from one whose parser silently returns nothing β | |
| and these engines answer differently from a datacenter IP than from a desktop, so it | |
| cannot be checked offline either. Returns counts and timings only, never a result, | |
| so it gives away nothing that /search is paid for. | |
| """ | |
| import time | |
| from urllib.parse import quote_plus | |
| out = {} | |
| for name, tmpl, parse in SEARCH_ENGINES: | |
| t0 = time.monotonic() | |
| try: | |
| body, _ = await _fetch_text(tmpl.format(q=quote_plus("open source license")), | |
| 3_000_000, timeout=20.0, ua=_SEARCH_UA) | |
| n = len(parse(body, 10)) | |
| out[name] = {"ok": n > 0, "bytes": len(body), "parsed": n, | |
| "ms": int((time.monotonic() - t0) * 1000)} | |
| except Exception as e: | |
| out[name] = {"ok": False, "error": f"{type(e).__name__}", | |
| "ms": int((time.monotonic() - t0) * 1000)} | |
| return {"backends": out, "usable": sum(1 for v in out.values() if v.get("ok")), | |
| "checked_at": datetime.utcnow().isoformat() + "Z"} | |
| async def crawl_site(req: CrawlRequest): | |
| start = (req.url or "").strip() | |
| if not start.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| max_pages = max(1, min(int(req.max_pages or 5), 10)) | |
| from urllib.parse import urljoin, urldefrag, urlparse | |
| from lxml import html as lh | |
| origin_host = urlparse(start).hostname or "" | |
| seen = {urldefrag(start)[0]} | |
| queue = [(urldefrag(start)[0], 0)] | |
| pages, failures = [], [] | |
| while queue and len(pages) < max_pages: | |
| current, depth = queue.pop(0) | |
| try: | |
| raw, final = await _fetch_text(current, 2_000_000) | |
| except Exception as e: | |
| failures.append({"url": current, "error": f"{type(e).__name__}: {e}"}) | |
| continue | |
| markdown, title = _to_markdown(raw, bool(req.include_links)) | |
| if markdown: | |
| pages.append({"url": final, "title": title, "markdown": markdown, | |
| "word_count": len(markdown.split()), "depth": depth}) | |
| else: | |
| failures.append({"url": final, "error": "could not extract main content"}) | |
| if len(pages) + len(queue) >= max_pages: | |
| continue | |
| try: # enqueue same-domain children | |
| doc = lh.fromstring(raw) | |
| for a in doc.xpath("//a[@href]"): | |
| link = urldefrag(urljoin(final, a.get("href")))[0] | |
| if not link.startswith(("http://", "https://")) or link in seen: | |
| continue | |
| if (urlparse(link).hostname or "") != origin_host: | |
| continue | |
| seen.add(link) | |
| queue.append((link, depth + 1)) | |
| if len(seen) > max_pages * 20: | |
| break | |
| except Exception: | |
| pass | |
| if not pages: | |
| raise HTTPException(502, f"no page could be crawled from {start}") | |
| return {"start_url": start, "pages_fetched": len(pages), "ok": len(pages), | |
| "failed": failures[:10], "pages": pages, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| # GET/POST/HEAD: this is a free diagnostic and the crawler fleet POSTs it. Registered | |
| # GET-only it answered 405, which tells a prober the path is not there at all. Its | |
| # full path is also in mcp_http.FREE_PATHS so tokenguard's blanket POST sweep cannot | |
| # turn a diagnostic into a paid route. | |
| async def sitemap_selftest(): | |
| """FREE diagnostic: can this host actually read sitemaps from the public internet? | |
| /sitemap is behind the paywall, so we cannot exercise it ourselves without paying β | |
| which is exactly how a regression stayed invisible: Cloudflare-fronted origins were | |
| answering our datacenter IP with a refusal, and the route reported that as "this site | |
| has no sitemap". Local testing cannot reproduce it because the block is IP-based. | |
| Deliberately parameterless: it probes a fixed list of well-known public sites and | |
| returns counts and status only, never URLs. That keeps it useless as an open fetch | |
| proxy while still revealing datacenter-IP reachability. GET, so the POST-only paywall | |
| sweep at the bottom of main.py leaves it free. | |
| """ | |
| import asyncio | |
| probes = { | |
| "no-cdn": "https://www.djangoproject.com", # known-good control | |
| "cloudflare": "https://www.cloudflare.com", # the origin class that regressed | |
| "no-sitemap": "https://example.com", # must stay a truthful 404 | |
| } | |
| async def one(site): | |
| # Hard per-probe ceiling: site_map's own timeouts (15s robots + 20s per sitemap, | |
| # times an index expansion) can add up past the hosting proxy's limit, and a | |
| # diagnostic that times out is worse than none. Run all three concurrently. | |
| try: | |
| # Measured 1.7-6s per probe; 25s is headroom for a slow origin, not a budget. | |
| r = await asyncio.wait_for(site_map(SitemapRequest(url=site, max_urls=5)), 25) | |
| return {"ok": True, "count": r["count"], "sitemaps": len(r["sitemaps"])} | |
| except asyncio.TimeoutError: | |
| return {"ok": False, "error": "probe exceeded 25s"} | |
| except HTTPException as e: | |
| detail = e.detail if isinstance(e.detail, dict) else {"error": str(e.detail)[:200]} | |
| return {"ok": False, "status": e.status_code, | |
| "blocked": detail.get("blocked"), "error": detail.get("error")} | |
| except Exception as e: # pragma: no cover | |
| return {"ok": False, "error": f"{type(e).__name__}: {str(e)[:120]}"} | |
| results = await asyncio.gather(*(one(s) for s in probes.values())) | |
| out = dict(zip(probes.keys(), results)) | |
| healthy = out.get("no-cdn", {}).get("ok") and out.get("cloudflare", {}).get("ok") | |
| return {"healthy": bool(healthy), "probes": out, | |
| "checked_at": datetime.utcnow().isoformat() + "Z"} | |
| async def site_map(req: SitemapRequest): | |
| raw_url = (req.url or "").strip() | |
| if not raw_url: | |
| raise HTTPException(400, "url is required") | |
| if not raw_url.startswith(("http://", "https://")): | |
| raw_url = "https://" + raw_url | |
| max_urls = max(1, min(int(req.max_urls or 500), 2000)) | |
| from urllib.parse import urlparse | |
| from lxml import etree | |
| p = urlparse(raw_url) | |
| site = f"{p.scheme}://{p.netloc}" | |
| # Cloudflare-fronted origins (python.org, cloudflare.com) 403 the default | |
| # "compatible; clean-read/1.0" UA from a datacenter IP, which used to surface as a | |
| # misleading "no sitemap found" 404 β the buyer paid and was told the site has no | |
| # sitemap when really we were blocked. Ask as a browser; sitemaps are public. | |
| SITEMAP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") | |
| # 1. robots.txt Sitemap: lines, then the conventional locations. | |
| candidates = [] | |
| blocked: list = [] | |
| async def _get(url: str, cap: int, timeout: float): | |
| """Fetch, recording upstream refusals so they can be reported rather than | |
| silently collapsing into 'no sitemap'.""" | |
| try: | |
| return await _fetch_text(url, cap, timeout=timeout, ua=SITEMAP_UA) | |
| except Exception as ex: | |
| status = getattr(getattr(ex, "response", None), "status_code", None) | |
| if status in (401, 403, 405, 406, 429) or status is None: | |
| blocked.append({"url": url, "status": status, | |
| "error": type(ex).__name__}) | |
| raise | |
| try: | |
| robots, _ = await _get(site + "/robots.txt", 500_000, 15.0) | |
| for line in robots.splitlines(): | |
| if line.lower().startswith("sitemap:"): | |
| loc = line.split(":", 1)[1].strip() | |
| if loc.startswith("http") and loc not in candidates: | |
| candidates.append(loc) | |
| except Exception: | |
| pass | |
| for guess in (site + "/sitemap.xml", site + "/sitemap_index.xml"): | |
| if guess not in candidates: | |
| candidates.append(guess) | |
| urls, used, index_expansions = [], [], 0 | |
| async def load(loc: str, depth: int = 0): | |
| nonlocal index_expansions | |
| if len(urls) >= max_urls or depth > 2: | |
| return | |
| try: | |
| body, final = await _get(loc, 5_000_000, 20.0) | |
| except Exception: | |
| return | |
| try: | |
| root = etree.fromstring(body.encode("utf-8", "replace"), | |
| etree.XMLParser(recover=True, resolve_entities=False)) | |
| except Exception: | |
| return | |
| if root is None: | |
| return | |
| used.append(final) | |
| tag = etree.QName(root).localname if root.tag is not etree.Comment else "" | |
| # Match on local names instead of a hardcoded namespace URI. Plenty of real | |
| # sitemaps declare no namespace, or the http vs https form of the schema URL; | |
| # a fixed `{sitemaps.org/schemas/sitemap/0.9}` prefix silently yields 0 URLs on | |
| # those, i.e. "this site has no sitemap" for a site that plainly does. Same | |
| # failure mode already burned us in the OFAC XML parser. | |
| def _find(el, name): | |
| return [e for e in el.iter() if isinstance(e.tag, str) | |
| and etree.QName(e).localname == name] | |
| def _child_text(el, name): | |
| for e in el: | |
| if isinstance(e.tag, str) and etree.QName(e).localname == name and e.text: | |
| return e.text.strip() | |
| return None | |
| if tag == "sitemapindex": | |
| children = [t for t in (_child_text(e, "loc") for e in _find(root, "sitemap")) if t] | |
| for child in children[:20]: | |
| index_expansions += 1 | |
| await load(child, depth + 1) | |
| return | |
| for u in _find(root, "url"): | |
| loc_text = _child_text(u, "loc") | |
| if loc_text: | |
| urls.append({"loc": loc_text, "lastmod": _child_text(u, "lastmod")}) | |
| if len(urls) >= max_urls: | |
| return | |
| for c in candidates: | |
| await load(c) | |
| if len(urls) >= max_urls: | |
| break | |
| if not urls: | |
| # Tell the buyer which of the two it is. 404 means "this site has no sitemap"; | |
| # 502 means "the origin refused us" β a paid call must not report the second | |
| # as the first. | |
| if blocked: | |
| raise HTTPException(502, { | |
| "error": f"origin refused our requests for {site}; no sitemap could be read", | |
| "blocked": blocked[:6], | |
| "hint": "the site is likely behind a bot filter that rejects datacenter IPs", | |
| }) | |
| raise HTTPException(404, f"no sitemap URLs found for {site} (tried {candidates})") | |
| return {"site": site, "sitemaps": used, "sitemap_index_expansions": index_expansions, | |
| "count": len(urls), "urls": urls, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| async def parse_feed(req: RssRequest): | |
| raw_url = (req.url or "").strip() | |
| if not raw_url: | |
| raise HTTPException(400, "url is required") | |
| if not raw_url.startswith(("http://", "https://")): | |
| raw_url = "https://" + raw_url | |
| max_items = max(1, min(int(req.max_items or 50), 200)) | |
| from lxml import etree, html as lh | |
| from urllib.parse import urljoin | |
| body, final = await _fetch_text(raw_url, 5_000_000, timeout=20.0) | |
| def as_xml(text: str): | |
| try: | |
| root = etree.fromstring(text.encode("utf-8", "replace"), | |
| etree.XMLParser(recover=True, resolve_entities=False)) | |
| except Exception: | |
| return None | |
| if root is None: | |
| return None | |
| name = etree.QName(root).localname.lower() if isinstance(root.tag, str) else "" | |
| return root if name in ("rss", "feed", "rdf") else None | |
| root = as_xml(body) | |
| if root is None: # looked like a site page β auto-discover its feed link | |
| try: | |
| doc = lh.fromstring(body) | |
| links = doc.xpath("//link[@type='application/rss+xml' or @type='application/atom+xml']") | |
| hrefs = [urljoin(final, l.get("href")) for l in links if l.get("href")] | |
| except Exception: | |
| hrefs = [] | |
| for h in hrefs[:3]: | |
| try: | |
| body2, final2 = await _fetch_text(h, 5_000_000, timeout=20.0) | |
| except Exception: | |
| continue | |
| root = as_xml(body2) | |
| if root is not None: | |
| final = final2 | |
| break | |
| if root is None: | |
| raise HTTPException(422, "not an RSS/Atom feed and no feed link could be discovered") | |
| ATOM = "http://www.w3.org/2005/Atom" | |
| def txt(el): | |
| return " ".join(el.text_content().split()) if hasattr(el, "text_content") else ( | |
| " ".join(el.text.split()) if el is not None and el.text else None) | |
| kind = etree.QName(root).localname.lower() | |
| items, feed_title = [], None | |
| if kind == "feed": # Atom | |
| t = root.find(f"{{{ATOM}}}title") | |
| feed_title = txt(t) | |
| for e in root.findall(f"{{{ATOM}}}entry")[:max_items]: | |
| link_el = e.find(f"{{{ATOM}}}link") | |
| summary = e.find(f"{{{ATOM}}}summary") | |
| if summary is None: | |
| summary = e.find(f"{{{ATOM}}}content") | |
| items.append({ | |
| "title": txt(e.find(f"{{{ATOM}}}title")), | |
| "link": link_el.get("href") if link_el is not None else None, | |
| "published": txt(e.find(f"{{{ATOM}}}updated")) or txt(e.find(f"{{{ATOM}}}published")), | |
| "summary": (txt(summary) or "")[:2000] or None, | |
| }) | |
| fmt = "atom" | |
| else: # RSS 2.0 / RDF β namespace-agnostic local-name search | |
| feed_title = txt(root.find(".//channel/title")) or txt(root.find(".//title")) | |
| entries = root.findall(".//item") or root.findall(".//{*}item") | |
| for e in entries[:max_items]: | |
| def sub(name): | |
| el = e.find(name) | |
| return el if el is not None else e.find("{*}" + name) | |
| items.append({ | |
| "title": txt(sub("title")), | |
| "link": txt(sub("link")) or (sub("guid") is not None and txt(sub("guid"))) or None, | |
| "published": txt(sub("pubDate")) or txt(sub("date")), | |
| "summary": (txt(sub("description")) or "")[:2000] or None, | |
| }) | |
| fmt = "rss" | |
| if not items: | |
| raise HTTPException(422, "feed parsed but contained no items") | |
| return {"feed_url": final, "title": feed_title, "format": fmt, | |
| "count": len(items), "items": items, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # x402 route specs | |
| # | |
| # Returned as plain data, not RouteConfig objects: each Space imports the x402 SDK | |
| # inside its own try/except and builds the config there, so a version skew in this | |
| # module can never abort a Space's paywall init (that except path silently serves | |
| # every paid route for free). | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def route_specs(prefix: str = "", cheap: str = "$0.005", mid: str = "$0.01", bulk: str = "$0.02"): | |
| """(path, price, description, input_example, input_schema, output_example) per route.""" | |
| p = prefix.rstrip("/") | |
| return [ | |
| (p + "/search", mid, | |
| "What does the open web say about this query? Ranked title/url/snippet results for agents, " | |
| "served through an automatic multi-engine failover chain so a single call still answers when " | |
| "any one backend is blocked, rate-limited or timing out from a datacenter IP", | |
| {"query": "x402 protocol spec"}, | |
| {"properties": {"query": {"type": "string", "description": "Search query"}, | |
| "max_results": {"type": "integer", "description": "1-25 results (default 10)"}}, | |
| "required": ["query"]}, | |
| {"query": "x402 protocol spec", "engine": "duckduckgo", "count": 10, | |
| "results": [{"rank": 1, "title": "x402 spec", "url": "https://example.com/spec", | |
| "snippet": "The x402 protocol definesβ¦"}]}), | |
| (p + "/read", cheap, | |
| "Fetch a URL and return its main content as clean Markdown (boilerplate stripped)", | |
| {"url": "https://example.com/article"}, | |
| {"properties": {"url": {"type": "string", "format": "uri", "description": "Page to fetch and clean"}, | |
| "include_links": {"type": "boolean", "description": "Keep hyperlinks (default true)"}}, | |
| "required": ["url"]}, | |
| {"url": "https://example.com/article", "title": "Article title", | |
| "markdown": "# Article title\n\nMain contentβ¦", "word_count": 1234}), | |
| (p + "/crawl", bulk, | |
| "Crawl a site from a start URL (same-domain, breadth-first) and return each page as clean Markdown", | |
| {"url": "https://example.com/docs", "max_pages": 5}, | |
| {"properties": {"url": {"type": "string", "format": "uri", "description": "Start URL"}, | |
| "max_pages": {"type": "integer", "description": "1-10 pages (default 5)"}, | |
| "include_links": {"type": "boolean", "description": "Keep hyperlinks (default true)"}}, | |
| "required": ["url"]}, | |
| {"start_url": "https://example.com/docs", "pages_fetched": 5, "ok": 5, | |
| "pages": [{"url": "https://example.com/docs", "title": "Docs", "markdown": "# Docsβ¦", | |
| "word_count": 800, "depth": 0}]}), | |
| (p + "/sitemap", cheap, | |
| "Enumerate a site's URLs from robots.txt + sitemap.xml (sitemap-index aware) β map a domain before crawling it", | |
| {"url": "https://example.com"}, | |
| {"properties": {"url": {"type": "string", "description": "Site URL or bare domain"}, | |
| "max_urls": {"type": "integer", "description": "Cap on returned URLs (default 500, max 2000)"}}, | |
| "required": ["url"]}, | |
| {"site": "https://example.com", "sitemaps": ["https://example.com/sitemap.xml"], | |
| "count": 120, "urls": [{"loc": "https://example.com/a", "lastmod": "2026-07-01"}]}), | |
| (p + "/rss", cheap, | |
| "Parse an RSS/Atom feed into structured items (title, link, published, summary) β or auto-discover the feed from a site URL", | |
| {"url": "https://example.com/feed.xml"}, | |
| {"properties": {"url": {"type": "string", "description": "Feed URL, or a site URL to auto-discover its feed"}, | |
| "max_items": {"type": "integer", "description": "Cap on items (default 50)"}}, | |
| "required": ["url"]}, | |
| {"feed_url": "https://example.com/feed.xml", "title": "Example Blog", "format": "rss", | |
| "count": 2, "items": [{"title": "Post", "link": "https://example.com/post", | |
| "published": "2026-07-20T10:00:00Z", "summary": "β¦"}]}), | |
| ] | |