This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/tools/urlfetch.go
2026-03-08 16:54:17 +00:00

309 lines
7.8 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"unicode/utf8"
"golang.org/x/net/html"
)
func init() {
Register(&URLFetchTool{})
}
// ═══════════════════════════════════════════
// url_fetch
// ═══════════════════════════════════════════
type URLFetchTool struct{ BaseTool }
func (t *URLFetchTool) Definition() ToolDef {
return ToolDef{
Name: "url_fetch",
DisplayName: "Fetch URL",
Description: "Fetch and extract readable text content from a web page URL. Use this to read articles, documentation, or any web page the user shares or that appeared in search results. Returns the page title and extracted text.",
Category: "search",
Parameters: JSONSchema(map[string]interface{}{
"url": Prop("string", "Full URL to fetch (must start with http:// or https://)"),
"max_length": Prop("integer", "Maximum content length in characters (default 8000, max 16000)"),
}, []string{"url"}),
}
}
func (t *URLFetchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
URL string `json:"url"`
MaxLength int `json:"max_length"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.URL == "" {
return "", fmt.Errorf("url is required")
}
if !strings.HasPrefix(args.URL, "http://") && !strings.HasPrefix(args.URL, "https://") {
return "", fmt.Errorf("url must start with http:// or https://")
}
if args.MaxLength <= 0 {
args.MaxLength = 8000
}
if args.MaxLength > 16000 {
args.MaxLength = 16000
}
// SSRF protection: resolve hostname and block private IPs
if err := checkSSRF(args.URL); err != nil {
return "", err
}
// Fetch with timeout
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (URL Fetch Tool)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "text/plain") &&
!strings.Contains(ct, "application/xhtml") && !strings.Contains(ct, "application/json") {
return "", fmt.Errorf("unsupported content type: %s (expected HTML or text)", ct)
}
// Read body with limit (1MB raw)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
// Plain text or JSON → return as-is (truncated)
if strings.Contains(ct, "text/plain") || strings.Contains(ct, "application/json") {
content := string(body)
content = truncateUTF8(content, args.MaxLength)
result := map[string]interface{}{
"url": args.URL,
"content_type": ct,
"content": content,
"truncated": len(body) > args.MaxLength,
}
b, _ := json.Marshal(result)
return string(b), nil
}
// HTML → extract readable text
title, text := extractReadableHTML(string(body))
text = truncateUTF8(text, args.MaxLength)
result := map[string]interface{}{
"url": args.URL,
"title": title,
"content": text,
"truncated": utf8.RuneCountInString(string(body)) > args.MaxLength,
}
b, _ := json.Marshal(result)
return string(b), nil
}
// ── SSRF Protection ─────────────────────────
func checkSSRF(rawURL string) error {
// Parse hostname
host := rawURL
if idx := strings.Index(rawURL, "://"); idx >= 0 {
host = rawURL[idx+3:]
}
if idx := strings.IndexAny(host, ":/"); idx >= 0 {
host = host[:idx]
}
// Resolve DNS
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("cannot resolve host: %s", host)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("blocked: %s resolves to private IP %s", host, ip.String())
}
}
return nil
}
func isPrivateIP(ip net.IP) bool {
privateRanges := []struct {
network *net.IPNet
}{
{mustParseCIDR("10.0.0.0/8")},
{mustParseCIDR("172.16.0.0/12")},
{mustParseCIDR("192.168.0.0/16")},
{mustParseCIDR("127.0.0.0/8")},
{mustParseCIDR("169.254.0.0/16")},
{mustParseCIDR("::1/128")},
{mustParseCIDR("fc00::/7")},
{mustParseCIDR("fe80::/10")},
}
for _, r := range privateRanges {
if r.network.Contains(ip) {
return true
}
}
return false
}
func mustParseCIDR(s string) *net.IPNet {
_, net, err := net.ParseCIDR(s)
if err != nil {
panic("invalid CIDR: " + s)
}
return net
}
// ── HTML → Text Extraction ──────────────────
// extractReadableHTML extracts the page title and readable text from HTML.
// Strips scripts, styles, nav, header, footer elements.
func extractReadableHTML(htmlContent string) (title, text string) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return "", htmlContent
}
// Extract <title>
title = findTitle(doc)
// Extract text from body, skipping non-content elements
var sb strings.Builder
extractText(doc, &sb, false)
text = cleanText(sb.String())
return title, text
}
func findTitle(n *html.Node) string {
if n.Type == html.ElementNode && n.Data == "title" {
return strings.TrimSpace(textContentNode(n))
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if t := findTitle(c); t != "" {
return t
}
}
return ""
}
// skipTags are elements whose content is not readable text.
var skipTags = map[string]bool{
"script": true, "style": true, "noscript": true,
"nav": true, "header": true, "footer": true,
"aside": true, "svg": true, "form": true,
"iframe": true, "button": true, "select": true,
}
func extractText(n *html.Node, sb *strings.Builder, inBody bool) {
if n.Type == html.ElementNode {
if skipTags[n.Data] {
return
}
if n.Data == "body" {
inBody = true
}
}
if inBody && n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" {
sb.WriteString(text)
sb.WriteString(" ")
}
}
// Block elements get a newline
if n.Type == html.ElementNode {
switch n.Data {
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6",
"li", "tr", "blockquote", "pre", "article", "section":
sb.WriteString("\n")
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
extractText(c, sb, inBody)
}
}
func textContentNode(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var sb strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
sb.WriteString(textContentNode(c))
}
return sb.String()
}
// cleanText collapses multiple spaces/newlines into single separators.
func cleanText(s string) string {
var sb strings.Builder
prevNewline := false
prevSpace := false
for _, r := range s {
if r == '\n' {
if !prevNewline {
sb.WriteRune('\n')
}
prevNewline = true
prevSpace = false
} else if r == ' ' || r == '\t' {
if !prevSpace && !prevNewline {
sb.WriteRune(' ')
}
prevSpace = true
} else {
sb.WriteRune(r)
prevNewline = false
prevSpace = false
}
}
return strings.TrimSpace(sb.String())
}
func truncateUTF8(s string, maxChars int) string {
runes := []rune(s)
if len(runes) <= maxChars {
return s
}
return string(runes[:maxChars]) + "\n[content truncated]"
}