Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7rEog2rS7uKNMb1CvpVjr
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Mechanical web-fetch helper for the /research skill.
|
|
|
|
Talks to the local SearXNG (search) and Firecrawl (scrape) services. Stdlib
|
|
only — no third-party runtime dependencies. The /research skill (Claude) does
|
|
all the thinking; this file only fetches and parses.
|
|
|
|
Endpoints resolve from env (WEBTOOLS_HOST / SEARXNG_PORT / FIRECRAWL_PORT) with
|
|
defaults for the `webtools` box. Any failure exits nonzero with a stderr message
|
|
so the calling skill can react.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
class WebToolsError(Exception):
|
|
"""Raised on any unrecoverable search/scrape failure."""
|
|
|
|
|
|
def endpoints() -> tuple[str, str]:
|
|
"""Return (searxng_base, firecrawl_base) from env, with webtools defaults."""
|
|
host = os.environ.get("WEBTOOLS_HOST", "10.10.20.37")
|
|
searx_port = os.environ.get("SEARXNG_PORT", "8080")
|
|
fire_port = os.environ.get("FIRECRAWL_PORT", "3002")
|
|
return (f"http://{host}:{searx_port}", f"http://{host}:{fire_port}")
|
|
|
|
|
|
def _request(url, *, data=None, headers=None, timeout=30, retries=2, backoff=1.5):
|
|
"""GET (data=None) or POST JSON; parse JSON; retry transient network errors.
|
|
|
|
Network failures (URLError/timeout) are retried with backoff. A non-JSON
|
|
response is NOT retried — it is almost always a deterministic
|
|
misconfiguration (e.g. SearXNG json format disabled), so we fail fast with
|
|
a clear message.
|
|
"""
|
|
headers = dict(headers or {})
|
|
body = None
|
|
if data is not None:
|
|
body = json.dumps(data).encode("utf-8")
|
|
headers.setdefault("Content-Type", "application/json")
|
|
|
|
last = None
|
|
for attempt in range(retries + 1):
|
|
try:
|
|
req = urllib.request.Request(
|
|
url, data=body, headers=headers,
|
|
method="POST" if body is not None else "GET",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read()
|
|
break
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
last = exc
|
|
if attempt < retries:
|
|
time.sleep(backoff ** attempt)
|
|
else:
|
|
raise WebToolsError(f"request failed after {retries + 1} attempt(s): {url}: {last}")
|
|
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except ValueError as exc:
|
|
raise WebToolsError(
|
|
f"non-JSON response from {url} (is SearXNG json format enabled?): {exc}"
|
|
)
|
|
|
|
|
|
def search(query, max_results=12, timeout=30):
|
|
"""Query SearXNG and return up to max_results normalized hits."""
|
|
searx_base, _ = endpoints()
|
|
qs = urllib.parse.urlencode({"q": query, "format": "json"})
|
|
payload = _request(f"{searx_base}/search?{qs}", timeout=timeout)
|
|
hits = []
|
|
for r in payload.get("results", []):
|
|
url = r.get("url")
|
|
if not url:
|
|
continue
|
|
hits.append({
|
|
"title": r.get("title", ""),
|
|
"url": url,
|
|
"snippet": r.get("content", ""),
|
|
"engine": r.get("engine", ""),
|
|
})
|
|
return hits[:max_results]
|
|
|
|
|
|
def scrape(url, timeout=60):
|
|
"""Scrape a URL to markdown via Firecrawl. Raises WebToolsError on failure."""
|
|
_, fire_base = endpoints()
|
|
payload = _request(
|
|
f"{fire_base}/v1/scrape",
|
|
data={"url": url, "formats": ["markdown"]},
|
|
timeout=timeout,
|
|
)
|
|
if not payload.get("success", True):
|
|
raise WebToolsError(f"firecrawl failed for {url}: {payload.get('error')}")
|
|
markdown = (payload.get("data") or {}).get("markdown", "")
|
|
if not markdown:
|
|
raise WebToolsError(f"firecrawl returned no markdown for {url}")
|
|
return markdown
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
prog="web.py",
|
|
description="Search (SearXNG) and scrape (Firecrawl) on the webtools box.",
|
|
)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
sp = sub.add_parser("search", help="search SearXNG")
|
|
sp.add_argument("query")
|
|
sp.add_argument("--max", type=int, default=12, help="max results (default 12)")
|
|
sp.add_argument("--json", action="store_true", help="emit JSON")
|
|
|
|
cp = sub.add_parser("scrape", help="scrape a URL to markdown")
|
|
cp.add_argument("url")
|
|
cp.add_argument("--out", help="write markdown to this path instead of stdout")
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
if args.cmd == "search":
|
|
hits = search(args.query, max_results=args.max)
|
|
if args.json:
|
|
print(json.dumps(hits, indent=2))
|
|
else:
|
|
for h in hits:
|
|
print(f"{h['title']}\n {h['url']}\n {h['snippet'][:160]}\n")
|
|
elif args.cmd == "scrape":
|
|
markdown = scrape(args.url)
|
|
if args.out:
|
|
Path(args.out).write_text(markdown, encoding="utf-8")
|
|
print(f"wrote {args.out}")
|
|
else:
|
|
print(markdown)
|
|
except WebToolsError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|