mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-15 12:15:11 +00:00
feat: merge web/webfetch/bili into single browser plugin (search/fetch/render/video)
This commit is contained in:
@ -160,15 +160,13 @@ enabled := sdk.AutoRestart()
|
|||||||
| 插件 | 说明 |
|
| 插件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| a2a | Agent-to-Agent 协议通信 |
|
| a2a | Agent-to-Agent 协议通信 |
|
||||||
| bili | Bilibili 数据获取 |
|
| browser | 网络搜索、网页抓取、浏览器渲染、视频下载(合并自 web/webfetch/bili) |
|
||||||
| editdoc | 文档编辑 |
|
| editdoc | 文档编辑 |
|
||||||
| files | 文件管理 |
|
| files | 文件管理 |
|
||||||
| memo | 备忘录/记忆 |
|
| memo | 备忘录/记忆 |
|
||||||
| ocr | 光学字符识别 |
|
| ocr | 光学字符识别 |
|
||||||
| qq | QQ 消息集成 |
|
| qq | QQ 消息集成 |
|
||||||
| sanitizer | 内容清洗/安全过滤 |
|
| sanitizer | 内容清洗/安全过滤 |
|
||||||
| web | 网页浏览与交互 |
|
|
||||||
| webfetch | 网页内容抓取 |
|
|
||||||
|
|
||||||
## 构建与安装
|
## 构建与安装
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
module bili
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bili",
|
|
||||||
"name_zh": "B站视频下载",
|
|
||||||
"name_en": "Bilibili Video Downloader",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
|
||||||
"author": "HomeAgent",
|
|
||||||
"entry": "plugin.so",
|
|
||||||
"tags": ["bili", "video", "download"],
|
|
||||||
"targets": "linux/amd64"
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Plugin struct {
|
|
||||||
name string
|
|
||||||
sdk *sdk.PluginSDK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|
||||||
s.SetAutoRestart(true)
|
|
||||||
p.sdk = s
|
|
||||||
tp := p.name + "_"
|
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
|
||||||
Key: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos",
|
|
||||||
Type: "string", DisplayName: "下载目录",
|
|
||||||
Description: "B站视频下载后的保存目录",
|
|
||||||
Category: p.name,
|
|
||||||
})
|
|
||||||
|
|
||||||
s.RegisterTool(tp+"video", sdk.ToolDef{
|
|
||||||
Name: tp + "video",
|
|
||||||
Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"},
|
|
||||||
"info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"},
|
|
||||||
"format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"},
|
|
||||||
},
|
|
||||||
"required": []string{"url"},
|
|
||||||
},
|
|
||||||
}, p.handleBiliVideo)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Stop() error { return nil }
|
|
||||||
|
|
||||||
type ytdlpFormat struct {
|
|
||||||
FormatID string `json:"format_id"`
|
|
||||||
FormatNote string `json:"format_note"`
|
|
||||||
Ext string `json:"ext"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
TBR float64 `json:"tbr"`
|
|
||||||
Filesize int64 `json:"filesize"`
|
|
||||||
FilesizeApprox int64 `json:"filesize_approx"`
|
|
||||||
VCodec string `json:"vcodec"`
|
|
||||||
ACodec string `json:"acodec"`
|
|
||||||
FPS float64 `json:"fps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ytdlpInfo struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Duration float64 `json:"duration"`
|
|
||||||
WebpageURL string `json:"webpage_url"`
|
|
||||||
Filename string `json:"_filename"`
|
|
||||||
Formats []ytdlpFormat `json:"formats"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
|
|
||||||
url, _ := args["url"].(string)
|
|
||||||
if url == "" {
|
|
||||||
return nil, fmt.Errorf("url is required")
|
|
||||||
}
|
|
||||||
infoOnly, _ := args["info_only"].(bool)
|
|
||||||
format, _ := args["format"].(string)
|
|
||||||
|
|
||||||
outputDir := "/tmp/bili_videos"
|
|
||||||
if p.sdk != nil {
|
|
||||||
if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
outputDir = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
os.MkdirAll(outputDir, 0755)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
|
||||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &out
|
|
||||||
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
var info ytdlpInfo
|
|
||||||
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if infoOnly {
|
|
||||||
var filtered []ytdlpFormat
|
|
||||||
for _, f := range info.Formats {
|
|
||||||
if f.VCodec != "none" || f.ACodec != "none" {
|
|
||||||
filtered = append(filtered, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info.Formats = filtered
|
|
||||||
|
|
||||||
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
|
|
||||||
if info.Duration > 0 {
|
|
||||||
lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration))
|
|
||||||
}
|
|
||||||
|
|
||||||
type fmtLine struct {
|
|
||||||
ID string
|
|
||||||
Note string
|
|
||||||
Res string
|
|
||||||
Ext string
|
|
||||||
Size string
|
|
||||||
}
|
|
||||||
var seen []string
|
|
||||||
var display []fmtLine
|
|
||||||
for _, f := range info.Formats {
|
|
||||||
if f.FormatNote == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := f.FormatNote + f.Ext
|
|
||||||
if contains(seen, key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen = append(seen, key)
|
|
||||||
res := ""
|
|
||||||
if f.Width > 0 && f.Height > 0 {
|
|
||||||
res = fmt.Sprintf("%dx%d", f.Width, f.Height)
|
|
||||||
}
|
|
||||||
sz := ""
|
|
||||||
fs := f.Filesize
|
|
||||||
if fs == 0 {
|
|
||||||
fs = f.FilesizeApprox
|
|
||||||
}
|
|
||||||
if fs > 0 {
|
|
||||||
sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576)
|
|
||||||
}
|
|
||||||
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
|
|
||||||
}
|
|
||||||
if len(display) > 0 {
|
|
||||||
lines = append(lines, "清晰度列表:")
|
|
||||||
for _, d := range display {
|
|
||||||
r := d.Res
|
|
||||||
if r != "" {
|
|
||||||
r = " " + r
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
dlArgs := []string{
|
|
||||||
"--no-warnings",
|
|
||||||
"--socket-timeout", "30",
|
|
||||||
"--retries", "3",
|
|
||||||
"--fragment-retries", "3",
|
|
||||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
|
||||||
"--no-overwrites",
|
|
||||||
}
|
|
||||||
if format != "" {
|
|
||||||
dlArgs = append(dlArgs, "-f", format)
|
|
||||||
}
|
|
||||||
dlArgs = append(dlArgs, url)
|
|
||||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
|
||||||
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
|
||||||
var dlOut bytes.Buffer
|
|
||||||
cmd2.Stdout = &dlOut
|
|
||||||
cmd2.Stderr = &dlOut
|
|
||||||
if err := cmd2.Run(); err != nil {
|
|
||||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, _ := os.ReadDir(outputDir)
|
|
||||||
var newest string
|
|
||||||
var newestTime int64
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fi, _ := e.Info()
|
|
||||||
if fi == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t := fi.ModTime().Unix()
|
|
||||||
if t > newestTime {
|
|
||||||
newestTime = t
|
|
||||||
newest = e.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if newest == "" {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": "下载完成,但未找到视频文件",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
dlPath := filepath.Join(outputDir, newest)
|
|
||||||
fi, _ := os.Stat(dlPath)
|
|
||||||
var fileSize int64
|
|
||||||
if fi != nil {
|
|
||||||
fileSize = fi.Size()
|
|
||||||
}
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
|
||||||
"file": dlPath,
|
|
||||||
"filename": newest,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(slice []string, s string) bool {
|
|
||||||
for _, v := range slice {
|
|
||||||
if v == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
|
||||||
return &Plugin{name: name}, nil
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
|
||||||
|
|
||||||
/* package bili */
|
|
||||||
|
|
||||||
|
|
||||||
#line 1 "cgo-builtin-export-prolog"
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
|
||||||
#define GO_CGO_EXPORT_PROLOGUE_H
|
|
||||||
|
|
||||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
|
||||||
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
|
||||||
extern size_t _GoStringLen(_GoString_ s);
|
|
||||||
extern const char *_GoStringPtr(_GoString_ s);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Start of preamble from import "C" comments. */
|
|
||||||
|
|
||||||
|
|
||||||
#line 3 "z_bridge_gen.go"
|
|
||||||
|
|
||||||
#include <stdlib.h>
|
|
||||||
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
|
||||||
|
|
||||||
#line 1 "cgo-generated-wrapper"
|
|
||||||
|
|
||||||
|
|
||||||
/* End of preamble from import "C" comments. */
|
|
||||||
|
|
||||||
|
|
||||||
/* Start of boilerplate cgo prologue. */
|
|
||||||
#line 1 "cgo-gcc-export-header-prolog"
|
|
||||||
|
|
||||||
#ifndef GO_CGO_PROLOGUE_H
|
|
||||||
#define GO_CGO_PROLOGUE_H
|
|
||||||
|
|
||||||
typedef signed char GoInt8;
|
|
||||||
typedef unsigned char GoUint8;
|
|
||||||
typedef short GoInt16;
|
|
||||||
typedef unsigned short GoUint16;
|
|
||||||
typedef int GoInt32;
|
|
||||||
typedef unsigned int GoUint32;
|
|
||||||
typedef long long GoInt64;
|
|
||||||
typedef unsigned long long GoUint64;
|
|
||||||
typedef GoInt64 GoInt;
|
|
||||||
typedef GoUint64 GoUint;
|
|
||||||
typedef size_t GoUintptr;
|
|
||||||
typedef float GoFloat32;
|
|
||||||
typedef double GoFloat64;
|
|
||||||
#ifdef _MSC_VER
|
|
||||||
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
|
||||||
#include <complex.h>
|
|
||||||
typedef _Fcomplex GoComplex64;
|
|
||||||
typedef _Dcomplex GoComplex128;
|
|
||||||
#else
|
|
||||||
#include <complex>
|
|
||||||
typedef std::complex<float> GoComplex64;
|
|
||||||
typedef std::complex<double> GoComplex128;
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
typedef float _Complex GoComplex64;
|
|
||||||
typedef double _Complex GoComplex128;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/*
|
|
||||||
static assertion to make sure the file is being used on architecture
|
|
||||||
at least with matching size of GoInt.
|
|
||||||
*/
|
|
||||||
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
|
||||||
|
|
||||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
|
||||||
typedef _GoString_ GoString;
|
|
||||||
#endif
|
|
||||||
typedef void *GoMap;
|
|
||||||
typedef void *GoChan;
|
|
||||||
typedef struct { void *t; void *v; } GoInterface;
|
|
||||||
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* End of boilerplate cgo prologue. */
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
|
|
||||||
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
|
|
||||||
extern int go_stop_plugin(char** errorOut);
|
|
||||||
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
|
|
||||||
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
|
|
||||||
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
|
|
||||||
extern void go_free_string(char* ptr);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
module web
|
module browser
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
11
example/browser/plg.json
Normal file
11
example/browser/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "browser",
|
||||||
|
"name_zh": "浏览器",
|
||||||
|
"name_en": "browser",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "网络资源搜索与获取:搜索引擎查询(browser_search)、网页抓取(browser_fetch,SSRF防护)、无头浏览器渲染(browser_render)、视频下载(browser_video,基于yt-dlp)",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["web", "search", "fetch", "video", "browser"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
601
example/browser/plugin.go
Normal file
601
example/browser/plugin.go
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
timeout int
|
||||||
|
proxy string
|
||||||
|
client *http.Client
|
||||||
|
outputDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: time.Duration(timeout) * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Duration(timeout) * time.Second,
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Duration(timeout) * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 5 {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.browser.timeout", Default: "30", Type: "int",
|
||||||
|
DisplayName: "HTTP 超时(秒)", Description: "HTTP 请求超时时间",
|
||||||
|
Category: "browser",
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.browser.proxy", Default: "", Type: "string",
|
||||||
|
DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port。为空则不使用代理",
|
||||||
|
Category: "browser",
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.browser.output_dir", Default: "/tmp/browser_videos", Type: "string",
|
||||||
|
DisplayName: "视频下载目录", Description: "视频下载后的保存目录",
|
||||||
|
Category: "browser",
|
||||||
|
})
|
||||||
|
|
||||||
|
t := getSetting[float64](s.Settings(), "timeout", 30)
|
||||||
|
p.timeout = int(t)
|
||||||
|
if p.timeout < 5 { p.timeout = 5 }
|
||||||
|
if p.timeout > 120 { p.timeout = 120 }
|
||||||
|
|
||||||
|
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||||
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
|
p.outputDir = getSetting[string](s.Settings(), "output_dir", "/tmp/browser_videos")
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||||
|
Name: tp + "search",
|
||||||
|
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
||||||
|
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
},
|
||||||
|
}, p.handleSearch)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
||||||
|
Name: tp + "fetch",
|
||||||
|
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
||||||
|
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleFetch)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"render", sdk.ToolDef{
|
||||||
|
Name: tp + "render",
|
||||||
|
Description: "Render a web page using headless Chromium browser and extract the text content. Supports JavaScript-rendered pages. Returns title and first 5000 characters.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "URL to render"},
|
||||||
|
"wait": map[string]interface{}{"type": "integer", "description": "Seconds to wait for JS rendering (default 0)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleRender)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||||
|
Name: tp + "video",
|
||||||
|
Description: "Download a video from supported sites (Bilibili, YouTube, etc.) using yt-dlp. Supports viewing video info before downloading.",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "Video URL (Bilibili, YouTube, etc.)"},
|
||||||
|
"info_only": map[string]interface{}{"type": "boolean", "description": "Only fetch video info (title, available formats), do not download"},
|
||||||
|
"format": map[string]interface{}{"type": "string", "description": "Format ID (e.g. 30112=1080P), auto-selects best if not specified"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleVideo)
|
||||||
|
|
||||||
|
log.Printf("[%s] started, timeout=%ds proxy=%q output=%s", p.name, p.timeout, p.proxy, p.outputDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
if p.client != nil {
|
||||||
|
p.client.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||||
|
v, err := s.Get(key)
|
||||||
|
if err != nil || v == nil { return def }
|
||||||
|
val, ok := v.(T)
|
||||||
|
if !ok { return def }
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func convInt64(v interface{}) (int64, error) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64: return int64(x), nil
|
||||||
|
case int64: return x, nil
|
||||||
|
case json.Number: return x.Int64()
|
||||||
|
default: return 0, fmt.Errorf("cannot convert %T to int64", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSRF ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var privateCIDRs []*net.IPNet
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, c := range []string{
|
||||||
|
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12",
|
||||||
|
"192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16",
|
||||||
|
"::1/128", "fc00::/7", "fe80::/10",
|
||||||
|
} {
|
||||||
|
_, n, _ := net.ParseCIDR(c)
|
||||||
|
if n != nil { privateCIDRs = append(privateCIDRs, n) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
for _, n := range privateCIDRs {
|
||||||
|
if n.Contains(ip) { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ssrfCheck(rawURL string) error {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil { return fmt.Errorf("invalid URL: %w", err) }
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("only http/https URLs allowed, got: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
ips, err := net.LookupHost(u.Hostname())
|
||||||
|
if err != nil { return fmt.Errorf("DNS lookup failed: %w", err) }
|
||||||
|
for _, ip := range ips {
|
||||||
|
if parsed := net.ParseIP(ip); parsed != nil && isPrivateIP(parsed) {
|
||||||
|
return fmt.Errorf("blocked request to private IP: %s (%s)", u.Hostname(), ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DuckDuckGo Search ─────────────────────────────────────
|
||||||
|
|
||||||
|
type ddgResult struct {
|
||||||
|
Title, URL, Snippet string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
||||||
|
form := url.Values{"q": {query}}
|
||||||
|
req, _ := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil { return nil, fmt.Errorf("request failed: %w", err) }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return parseDDGResults(string(body), count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDDGResults(html string, count int) []ddgResult {
|
||||||
|
var results []ddgResult
|
||||||
|
marker := `result__body"`
|
||||||
|
for i := 0; i < len(html); i++ {
|
||||||
|
idx := strings.Index(html[i:], marker)
|
||||||
|
if idx < 0 { break }
|
||||||
|
i += idx
|
||||||
|
closeIdx := findClosingTag(html, i, "</div>")
|
||||||
|
if closeIdx < 0 { break }
|
||||||
|
if r := parseSingleDDGResult(html[i : closeIdx+6]); r.URL != "" {
|
||||||
|
results = append(results, r)
|
||||||
|
if len(results) >= count { break }
|
||||||
|
}
|
||||||
|
i = closeIdx + 6
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func findClosingTag(s string, start int, tag string) int {
|
||||||
|
depth := 1
|
||||||
|
for pos := start; pos < len(s); {
|
||||||
|
nextOpen := strings.Index(s[pos:], `<div`)
|
||||||
|
nextClose := strings.Index(s[pos:], tag)
|
||||||
|
if nextClose < 0 { return -1 }
|
||||||
|
if nextOpen >= 0 && nextOpen < nextClose {
|
||||||
|
depth++
|
||||||
|
pos += nextOpen + 4
|
||||||
|
} else {
|
||||||
|
depth--
|
||||||
|
if depth == 0 { return pos + nextClose }
|
||||||
|
pos += nextClose + len(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSingleDDGResult(block string) ddgResult {
|
||||||
|
var r ddgResult
|
||||||
|
urlMarker := `class="result__a" href="`
|
||||||
|
if uIdx := strings.Index(block, urlMarker); uIdx >= 0 {
|
||||||
|
start := uIdx + len(urlMarker)
|
||||||
|
if end := strings.Index(block[start:], `"`); end >= 0 {
|
||||||
|
r.URL = block[start : start+end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, marker := range []string{`<a class="result__snippet`, `<div class="result__snippet`} {
|
||||||
|
if sIdx := strings.Index(block, marker); sIdx >= 0 {
|
||||||
|
if aStart := strings.Index(block[sIdx:], `>`); aStart >= 0 {
|
||||||
|
snipStart := sIdx + aStart + 1
|
||||||
|
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
||||||
|
if snipEnd < 0 { snipEnd = strings.Index(block[snipStart:], `</div>`) }
|
||||||
|
if snipEnd >= 0 { r.Snippet = stripTags(block[snipStart : snipStart+snipEnd]) }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
if query == "" { return errorResult("query is required"), nil }
|
||||||
|
count := 5
|
||||||
|
if v, ok := args["count"].(float64); ok && v > 0 { count = int(v) }
|
||||||
|
if count < 1 { count = 1 }
|
||||||
|
if count > 20 { count = 20 }
|
||||||
|
|
||||||
|
results, err := p.ddgSearch(query, count)
|
||||||
|
if err != nil { return errorResult("search failed: " + err.Error()), nil }
|
||||||
|
if len(results) == 0 { return map[string]interface{}{"content": "No results found."}, nil }
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
||||||
|
for i, r := range results {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": strings.TrimSpace(sb.String())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web Fetch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func htmlToText(html string) string {
|
||||||
|
for _, tag := range []string{"<script", "<style"} {
|
||||||
|
closing := "</" + tag[1:] + ">"
|
||||||
|
for {
|
||||||
|
start := strings.Index(strings.ToLower(html), tag)
|
||||||
|
if start < 0 { break }
|
||||||
|
end := strings.Index(html[start:], closing)
|
||||||
|
if end < 0 { break }
|
||||||
|
html = html[:start] + html[start+end+len(closing):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
||||||
|
html = strings.ReplaceAll(html, tag, "\n")
|
||||||
|
}
|
||||||
|
html = stripTags(html)
|
||||||
|
for _, pair := range [][2]string{
|
||||||
|
{"&", "&"}, {"<", "<"}, {">", ">"},
|
||||||
|
{""", "\""}, {"'", "'"}, {" ", " "},
|
||||||
|
} {
|
||||||
|
html = strings.ReplaceAll(html, pair[0], pair[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(html, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" { continue }
|
||||||
|
in := []rune(line)
|
||||||
|
var out []rune
|
||||||
|
space := false
|
||||||
|
for _, r := range in {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
if !space { out = append(out, ' '); space = true }
|
||||||
|
} else { out = append(out, r); space = false }
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, string(out))
|
||||||
|
}
|
||||||
|
return strings.Join(cleaned, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '<' { inTag = true; continue }
|
||||||
|
if r == '>' { inTag = false; continue }
|
||||||
|
if !inTag { out.WriteRune(r) }
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
if rawURL == "" { return errorResult("url is required"), nil }
|
||||||
|
maxChars := 20000
|
||||||
|
if v, ok := args["max_chars"].(float64); ok && v > 0 { maxChars = int(v) }
|
||||||
|
if maxChars > 500000 { maxChars = 500000 }
|
||||||
|
if err := p.ssrfCheck(rawURL); err != nil { return errorResult(err.Error()), nil }
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", rawURL, nil)
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil { return errorResult("fetch failed: " + err.Error()), nil }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
||||||
|
rawText := string(body)
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
var extracted string
|
||||||
|
if strings.Contains(ct, "text/html") {
|
||||||
|
extracted = htmlToText(rawText)
|
||||||
|
} else if strings.Contains(ct, "application/json") {
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(body, &v) == nil {
|
||||||
|
if pretty, err := json.MarshalIndent(v, "", " "); err == nil { extracted = string(pretty) }
|
||||||
|
}
|
||||||
|
if extracted == "" { extracted = rawText }
|
||||||
|
} else { extracted = rawText }
|
||||||
|
|
||||||
|
extracted = strings.TrimSpace(extracted)
|
||||||
|
if len(extracted) > maxChars { extracted = extracted[:maxChars] + "\n\n[Content truncated]" }
|
||||||
|
if extracted == "" { extracted = "(empty content)" }
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": extracted,
|
||||||
|
"details": map[string]interface{}{"url": rawURL, "status": resp.StatusCode, "content_type": ct},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chromium Render ───────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
if rawURL == "" { return nil, fmt.Errorf("url is required") }
|
||||||
|
waitSec, _ := convInt64(args["wait"])
|
||||||
|
if waitSec > 0 { time.Sleep(time.Duration(waitSec) * time.Second) }
|
||||||
|
|
||||||
|
var html string
|
||||||
|
chromiumPath := "/usr/local/bin/chromium"
|
||||||
|
if _, err := os.Stat(chromiumPath); err == nil {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("chromium: %w", err)
|
||||||
|
}
|
||||||
|
html = out.String()
|
||||||
|
} else {
|
||||||
|
resp, err := http.Get(rawURL)
|
||||||
|
if err != nil { return nil, fmt.Errorf("http get: %w", err) }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil { return nil, fmt.Errorf("read body: %w", err) }
|
||||||
|
html = string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
||||||
|
title = m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var textOut bytes.Buffer
|
||||||
|
pyCmd := exec.Command("python3", "-c", `
|
||||||
|
import sys, re, html
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', raw)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
text = html.unescape(text)
|
||||||
|
sys.stdout.write(text)
|
||||||
|
`)
|
||||||
|
pyCmd.Stdin = strings.NewReader(html)
|
||||||
|
pyCmd.Stdout = &textOut
|
||||||
|
pyCmd.Run()
|
||||||
|
text := strings.TrimSpace(textOut.String())
|
||||||
|
|
||||||
|
origLen := len(text)
|
||||||
|
truncated := origLen > 5000
|
||||||
|
if truncated { text = text[:5000] }
|
||||||
|
|
||||||
|
result := ""
|
||||||
|
if title != "" { result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, rawURL) }
|
||||||
|
result += text
|
||||||
|
if truncated { result += fmt.Sprintf("\n\n...(内容过长,仅显示前 5000 字符,共 %d 字符)", origLen) }
|
||||||
|
|
||||||
|
return map[string]interface{}{"content": result, "title": title}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Video Download ────────────────────────────────────────
|
||||||
|
|
||||||
|
type ytdlpFormat struct {
|
||||||
|
FormatID string `json:"format_id"`
|
||||||
|
FormatNote string `json:"format_note"`
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
TBR float64 `json:"tbr"`
|
||||||
|
Filesize int64 `json:"filesize"`
|
||||||
|
FilesizeApprox int64 `json:"filesize_approx"`
|
||||||
|
VCodec string `json:"vcodec"`
|
||||||
|
ACodec string `json:"acodec"`
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ytdlpInfo struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
WebpageURL string `json:"webpage_url"`
|
||||||
|
Filename string `json:"_filename"`
|
||||||
|
Formats []ytdlpFormat `json:"formats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleVideo(args map[string]interface{}) (interface{}, error) {
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
if rawURL == "" { return nil, fmt.Errorf("url is required") }
|
||||||
|
infoOnly, _ := args["info_only"].(bool)
|
||||||
|
format, _ := args["format"].(string)
|
||||||
|
|
||||||
|
os.MkdirAll(p.outputDir, 0755)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
ytdlpArgs := []string{"--no-warnings", "--dump-json", rawURL}
|
||||||
|
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &out
|
||||||
|
proxyEnv := "http://127.0.0.1:7890"
|
||||||
|
if p.proxy != "" { proxyEnv = p.proxy }
|
||||||
|
cmd.Env = append(os.Environ(), "HTTP_PROXY="+proxyEnv, "HTTPS_PROXY="+proxyEnv)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ytdlpInfo
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if infoOnly {
|
||||||
|
var filtered []ytdlpFormat
|
||||||
|
for _, f := range info.Formats {
|
||||||
|
if f.VCodec != "none" || f.ACodec != "none" { filtered = append(filtered, f) }
|
||||||
|
}
|
||||||
|
info.Formats = filtered
|
||||||
|
|
||||||
|
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
|
||||||
|
if info.Duration > 0 { lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration)) }
|
||||||
|
|
||||||
|
type fmtLine struct{ ID, Note, Res, Ext, Size string }
|
||||||
|
var seen []string
|
||||||
|
var display []fmtLine
|
||||||
|
for _, f := range info.Formats {
|
||||||
|
if f.FormatNote == "" { continue }
|
||||||
|
key := f.FormatNote + f.Ext
|
||||||
|
if contains(seen, key) { continue }
|
||||||
|
seen = append(seen, key)
|
||||||
|
res := ""
|
||||||
|
if f.Width > 0 && f.Height > 0 { res = fmt.Sprintf("%dx%d", f.Width, f.Height) }
|
||||||
|
sz := ""
|
||||||
|
fs := f.Filesize
|
||||||
|
if fs == 0 { fs = f.FilesizeApprox }
|
||||||
|
if fs > 0 { sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576) }
|
||||||
|
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
|
||||||
|
}
|
||||||
|
if len(display) > 0 {
|
||||||
|
lines = append(lines, "清晰度列表:")
|
||||||
|
for _, d := range display {
|
||||||
|
r := d.Res
|
||||||
|
if r != "" { r = " " + r }
|
||||||
|
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dlArgs := []string{
|
||||||
|
"--no-warnings", "--socket-timeout", "30",
|
||||||
|
"--retries", "3", "--fragment-retries", "3",
|
||||||
|
"-o", filepath.Join(p.outputDir, "%(title)s.%(ext)s"),
|
||||||
|
"--no-overwrites",
|
||||||
|
}
|
||||||
|
if format != "" { dlArgs = append(dlArgs, "-f", format) }
|
||||||
|
dlArgs = append(dlArgs, rawURL)
|
||||||
|
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||||
|
cmd2.Env = append(os.Environ(), "HTTP_PROXY="+proxyEnv, "HTTPS_PROXY="+proxyEnv)
|
||||||
|
var dlOut bytes.Buffer
|
||||||
|
cmd2.Stdout = &dlOut
|
||||||
|
cmd2.Stderr = &dlOut
|
||||||
|
if err := cmd2.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var newest string
|
||||||
|
var newestTime int64
|
||||||
|
entries, _ := os.ReadDir(p.outputDir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() { continue }
|
||||||
|
if fi, _ := e.Info(); fi != nil {
|
||||||
|
if t := fi.ModTime().Unix(); t > newestTime { newestTime = t; newest = e.Name() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newest == "" {
|
||||||
|
return map[string]interface{}{"content": "下载完成,但未找到视频文件"}, nil
|
||||||
|
}
|
||||||
|
dlPath := filepath.Join(p.outputDir, newest)
|
||||||
|
fi, _ := os.Stat(dlPath)
|
||||||
|
var fileSize int64
|
||||||
|
if fi != nil { fileSize = fi.Size() }
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||||
|
"file": dlPath, "filename": newest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, s string) bool {
|
||||||
|
for _, v := range slice { if v == s { return true } }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
@ -1503,7 +1503,7 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
os.MkdirAll(p.filesDir, 0755)
|
os.MkdirAll(p.filesDir, 0755)
|
||||||
botIDStr := strconv.FormatInt(p.botID, 10)
|
botIDStr := strconv.FormatInt(p.botID, 10)
|
||||||
var parts []string
|
var parts []string
|
||||||
type dlItem struct{ fileID, name string }
|
type dlItem struct{ fileID, name, url string }
|
||||||
var dlQueue []dlItem
|
var dlQueue []dlItem
|
||||||
|
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
@ -1544,7 +1544,7 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
sizeDesc = fmt.Sprintf(" (%.1f MB)", float64(s)/1048576)
|
sizeDesc = fmt.Sprintf(" (%.1f MB)", float64(s)/1048576)
|
||||||
}
|
}
|
||||||
if fid != "" {
|
if fid != "" {
|
||||||
dlQueue = append(dlQueue, dlItem{fid, name})
|
dlQueue = append(dlQueue, dlItem{fileID: fid, name: name})
|
||||||
}
|
}
|
||||||
if name != "" {
|
if name != "" {
|
||||||
parts = append(parts, fmt.Sprintf("[文件:%s%s]", name, sizeDesc))
|
parts = append(parts, fmt.Sprintf("[文件:%s%s]", name, sizeDesc))
|
||||||
@ -1554,8 +1554,11 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
case "image":
|
case "image":
|
||||||
fid, _ := data["file"].(string)
|
fid, _ := data["file"].(string)
|
||||||
summary, _ := data["summary"].(string)
|
summary, _ := data["summary"].(string)
|
||||||
|
imgURL, _ := data["url"].(string)
|
||||||
if fid != "" {
|
if fid != "" {
|
||||||
dlQueue = append(dlQueue, dlItem{fid, "image_" + fid + ".jpg"})
|
dlQueue = append(dlQueue, dlItem{fileID: fid, name: "image_" + fid + ".jpg", url: imgURL})
|
||||||
|
} else if imgURL != "" {
|
||||||
|
dlQueue = append(dlQueue, dlItem{url: imgURL, name: "image_" + filepath.Base(imgURL)})
|
||||||
}
|
}
|
||||||
label := "图片"
|
label := "图片"
|
||||||
if summary != "" {
|
if summary != "" {
|
||||||
@ -1564,8 +1567,11 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
parts = append(parts, fmt.Sprintf("[%s]", label))
|
parts = append(parts, fmt.Sprintf("[%s]", label))
|
||||||
case "video":
|
case "video":
|
||||||
fid, _ := data["file"].(string)
|
fid, _ := data["file"].(string)
|
||||||
|
videoURL, _ := data["url"].(string)
|
||||||
if fid != "" {
|
if fid != "" {
|
||||||
dlQueue = append(dlQueue, dlItem{fid, "video_" + fid + ".mp4"})
|
dlQueue = append(dlQueue, dlItem{fileID: fid, name: "video_" + fid + ".mp4", url: videoURL})
|
||||||
|
} else if videoURL != "" {
|
||||||
|
dlQueue = append(dlQueue, dlItem{url: videoURL, name: "video_" + filepath.Base(videoURL)})
|
||||||
}
|
}
|
||||||
parts = append(parts, "[视频]")
|
parts = append(parts, "[视频]")
|
||||||
case "reply":
|
case "reply":
|
||||||
@ -1597,7 +1603,7 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
if len(dlQueue) > 0 {
|
if len(dlQueue) > 0 {
|
||||||
go func(items []dlItem) {
|
go func(items []dlItem) {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
p.downloadFile(item.fileID, item.name)
|
p.downloadFile(item.fileID, item.name, item.url)
|
||||||
}
|
}
|
||||||
}(dlQueue)
|
}(dlQueue)
|
||||||
}
|
}
|
||||||
@ -1605,12 +1611,34 @@ func (p *Plugin) processMessageSegments(segments []interface{}) string {
|
|||||||
return strings.TrimSpace(strings.Join(parts, " "))
|
return strings.TrimSpace(strings.Join(parts, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) downloadFile(fileID, filename string) string {
|
func (p *Plugin) downloadFile(fileID, filename, fileURL string) string {
|
||||||
if fileID == "" || p.filesDir == "" {
|
if p.filesDir == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
os.MkdirAll(p.filesDir, 0755)
|
os.MkdirAll(p.filesDir, 0755)
|
||||||
|
|
||||||
|
// 优先使用 URL 直下(NapCat 消息 data 中的 url 字段)
|
||||||
|
if fileURL != "" {
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file_" + filepath.Base(fileURL)
|
||||||
|
}
|
||||||
|
filename = sanitizeFilename(filename)
|
||||||
|
localPath := filepath.Join(p.filesDir, filename)
|
||||||
|
dlResp, err := p.httpClient.Get(fileURL)
|
||||||
|
if err == nil {
|
||||||
|
defer dlResp.Body.Close()
|
||||||
|
data, err := io.ReadAll(dlResp.Body)
|
||||||
|
if err == nil && len(data) > 0 {
|
||||||
|
os.WriteFile(localPath, data, 0644)
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 base64:// 前缀的内嵌文件
|
// 处理 base64:// 前缀的内嵌文件
|
||||||
if strings.HasPrefix(fileID, "base64://") {
|
if strings.HasPrefix(fileID, "base64://") {
|
||||||
data, err := base64.StdEncoding.DecodeString(fileID[9:])
|
data, err := base64.StdEncoding.DecodeString(fileID[9:])
|
||||||
@ -1651,7 +1679,7 @@ func (p *Plugin) downloadFile(fileID, filename string) string {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil {
|
if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil {
|
||||||
log.Printf("[qq] parse get_file %s: bad response", fileID)
|
log.Printf("[qq] get_file %s: bad response (NapCat returned no data, fileID=%q url=%q)", fileID, fileID, fileURL)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
info := resp.Data
|
info := resp.Data
|
||||||
@ -1676,7 +1704,7 @@ func (p *Plugin) downloadFile(fileID, filename string) string {
|
|||||||
|
|
||||||
// 其次 URL 下载
|
// 其次 URL 下载
|
||||||
if info.URL != "" {
|
if info.URL != "" {
|
||||||
dlResp, err := http.Get(info.URL)
|
dlResp, err := p.httpClient.Get(info.URL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer dlResp.Body.Close()
|
defer dlResp.Body.Close()
|
||||||
data, err := io.ReadAll(dlResp.Body)
|
data, err := io.ReadAll(dlResp.Body)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"name_zh": "网络搜索",
|
|
||||||
"name_en": "web",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "网络搜索与抓取工具(web_search/web_fetch)",
|
|
||||||
"author": "HomeAgent",
|
|
||||||
"entry": "plugin.so",
|
|
||||||
"tags": ["web", "search", "fetch"],
|
|
||||||
"targets": "linux/amd64"
|
|
||||||
}
|
|
||||||
@ -1,568 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Plugin struct {
|
|
||||||
name string
|
|
||||||
sdk *sdk.PluginSDK
|
|
||||||
mu sync.RWMutex
|
|
||||||
timeout int
|
|
||||||
proxy string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
|
||||||
transport := &http.Transport{
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: time.Duration(timeout) * time.Second,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
|
||||||
ResponseHeaderTimeout: time.Duration(timeout) * time.Second,
|
|
||||||
}
|
|
||||||
if proxyURL != "" {
|
|
||||||
u, err := url.Parse(proxyURL)
|
|
||||||
if err == nil {
|
|
||||||
transport.Proxy = http.ProxyURL(u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &http.Client{
|
|
||||||
Timeout: time.Duration(timeout) * time.Second,
|
|
||||||
Transport: transport,
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
if len(via) >= 5 {
|
|
||||||
return fmt.Errorf("too many redirects")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|
||||||
s.SetAutoRestart(true)
|
|
||||||
p.sdk = s
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
|
||||||
Key: "plugin.web.timeout",
|
|
||||||
Default: "30",
|
|
||||||
Type: "int",
|
|
||||||
DisplayName: "HTTP 超时(秒)",
|
|
||||||
Description: "Web fetch 和搜索的 HTTP 请求超时时间",
|
|
||||||
Category: "web",
|
|
||||||
})
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
|
||||||
Key: "plugin.web.proxy",
|
|
||||||
Default: "",
|
|
||||||
Type: "string",
|
|
||||||
DisplayName: "HTTP 代理",
|
|
||||||
Description: "HTTP 代理地址,如 http://<proxy-host>:<proxy-port>。为空则不使用代理",
|
|
||||||
Category: "web",
|
|
||||||
})
|
|
||||||
|
|
||||||
t := getSetting[float64](s.Settings(), "timeout", 30)
|
|
||||||
p.timeout = int(t)
|
|
||||||
if p.timeout < 5 {
|
|
||||||
p.timeout = 5
|
|
||||||
}
|
|
||||||
if p.timeout > 120 {
|
|
||||||
p.timeout = 120
|
|
||||||
}
|
|
||||||
|
|
||||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
|
||||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
|
||||||
|
|
||||||
tp := p.name + "_"
|
|
||||||
|
|
||||||
s.RegisterTool(tp+"search", sdk.ToolDef{
|
|
||||||
Name: tp + "search",
|
|
||||||
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
|
||||||
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
|
||||||
},
|
|
||||||
"required": []string{"query"},
|
|
||||||
},
|
|
||||||
}, p.handleSearch)
|
|
||||||
|
|
||||||
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
|
||||||
Name: tp + "fetch",
|
|
||||||
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
|
||||||
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
|
||||||
},
|
|
||||||
"required": []string{"url"},
|
|
||||||
},
|
|
||||||
}, p.handleFetch)
|
|
||||||
|
|
||||||
proxyMsg := ""
|
|
||||||
if p.proxy != "" {
|
|
||||||
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
|
|
||||||
}
|
|
||||||
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
|
||||||
p.client.CloseIdleConnections()
|
|
||||||
log.Printf("[%s] stopped", p.name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSRF 保护 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
var privateCIDRs []*net.IPNet
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cidrs := []string{
|
|
||||||
"127.0.0.0/8", // loopback
|
|
||||||
"10.0.0.0/8", // private
|
|
||||||
"172.16.0.0/12", // private
|
|
||||||
"192.168.0.0/16", // private
|
|
||||||
"100.64.0.0/10", // carrier-grade NAT
|
|
||||||
"169.254.0.0/16", // link-local
|
|
||||||
"::1/128", // IPv6 loopback
|
|
||||||
"fc00::/7", // IPv6 unique local
|
|
||||||
"fe80::/10", // IPv6 link-local
|
|
||||||
}
|
|
||||||
for _, c := range cidrs {
|
|
||||||
_, n, err := net.ParseCIDR(c)
|
|
||||||
if err == nil {
|
|
||||||
privateCIDRs = append(privateCIDRs, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPrivateIP(ip net.IP) bool {
|
|
||||||
for _, n := range privateCIDRs {
|
|
||||||
if n.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) ssrfCheck(rawURL string) error {
|
|
||||||
u, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
if u.Scheme != "http" && u.Scheme != "https" {
|
|
||||||
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
host := u.Hostname()
|
|
||||||
ips, err := net.LookupHost(host)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ip := range ips {
|
|
||||||
parsed := net.ParseIP(ip)
|
|
||||||
if parsed == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isPrivateIP(parsed) {
|
|
||||||
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DuckDuckGo 搜索 ────────────────────────────────────────
|
|
||||||
|
|
||||||
type ddgResult struct {
|
|
||||||
Title string
|
|
||||||
URL string
|
|
||||||
Snippet string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
|
||||||
form := url.Values{"q": {query}}
|
|
||||||
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseDDGResults(string(body), count), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDDGResults(html string, count int) []ddgResult {
|
|
||||||
var results []ddgResult
|
|
||||||
|
|
||||||
// Find all result blocks: <div class="result__body"> ... </div>
|
|
||||||
bodyMarker := `result__body"`
|
|
||||||
for i := 0; i < len(html); i++ {
|
|
||||||
idx := strings.Index(html[i:], bodyMarker)
|
|
||||||
if idx < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i += idx
|
|
||||||
|
|
||||||
// Find closing </div>
|
|
||||||
closeIdx := findClosingTag(html, i, "</div>")
|
|
||||||
if closeIdx < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
block := html[i : closeIdx+6]
|
|
||||||
|
|
||||||
r := parseSingleDDGResult(block)
|
|
||||||
if r.URL != "" {
|
|
||||||
results = append(results, r)
|
|
||||||
if len(results) >= count {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i = closeIdx + 6
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func findClosingTag(s string, start int, tag string) int {
|
|
||||||
depth := 1
|
|
||||||
pos := start
|
|
||||||
for pos < len(s) {
|
|
||||||
nextOpen := strings.Index(s[pos:], `<div`)
|
|
||||||
nextClose := strings.Index(s[pos:], tag)
|
|
||||||
if nextClose < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if nextOpen >= 0 && nextOpen < nextClose {
|
|
||||||
depth++
|
|
||||||
pos += nextOpen + 4
|
|
||||||
} else {
|
|
||||||
depth--
|
|
||||||
if depth == 0 {
|
|
||||||
return pos + nextClose
|
|
||||||
}
|
|
||||||
pos += nextClose + len(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSingleDDGResult(block string) ddgResult {
|
|
||||||
var r ddgResult
|
|
||||||
|
|
||||||
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
|
|
||||||
urlMarker := `class="result__a" href="`
|
|
||||||
uIdx := strings.Index(block, urlMarker)
|
|
||||||
if uIdx >= 0 {
|
|
||||||
start := uIdx + len(urlMarker)
|
|
||||||
end := strings.Index(block[start:], `"`)
|
|
||||||
if end >= 0 {
|
|
||||||
r.URL = block[start : start+end]
|
|
||||||
}
|
|
||||||
|
|
||||||
aStart := strings.Index(block[start+end:], `>`)
|
|
||||||
if aStart >= 0 {
|
|
||||||
titleStart := start + end + aStart + 1
|
|
||||||
aEnd := strings.Index(block[titleStart:], `</a>`)
|
|
||||||
if aEnd >= 0 {
|
|
||||||
r.Title = stripTags(block[titleStart : titleStart+aEnd])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract snippet: <a class="result__snippet" ...> ... </a>
|
|
||||||
snippetMarkers := []string{
|
|
||||||
`<a class="result__snippet`,
|
|
||||||
`<div class="result__snippet`,
|
|
||||||
}
|
|
||||||
for _, marker := range snippetMarkers {
|
|
||||||
sIdx := strings.Index(block, marker)
|
|
||||||
if sIdx >= 0 {
|
|
||||||
aStart := strings.Index(block[sIdx:], `>`)
|
|
||||||
if aStart >= 0 {
|
|
||||||
snipStart := sIdx + aStart + 1
|
|
||||||
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
|
||||||
if snipEnd < 0 {
|
|
||||||
snipEnd = strings.Index(block[snipStart:], `</div>`)
|
|
||||||
}
|
|
||||||
if snipEnd >= 0 {
|
|
||||||
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Web Fetch ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
|
||||||
rawURL, _ := args["url"].(string)
|
|
||||||
if rawURL == "" {
|
|
||||||
return errorResult("url is required"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
maxChars := 20000
|
|
||||||
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
|
||||||
maxChars = int(v)
|
|
||||||
}
|
|
||||||
if maxChars > 500000 {
|
|
||||||
maxChars = 500000
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.ssrfCheck(rawURL); err != nil {
|
|
||||||
return errorResult(err.Error()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", rawURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return errorResult("invalid URL: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return errorResult("fetch failed: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
|
||||||
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
|
||||||
if err != nil {
|
|
||||||
return errorResult("read error: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rawText := string(body)
|
|
||||||
|
|
||||||
// Extract readable content based on content type
|
|
||||||
ct := resp.Header.Get("Content-Type")
|
|
||||||
var extracted string
|
|
||||||
if strings.Contains(ct, "text/html") {
|
|
||||||
extracted = htmlToText(rawText)
|
|
||||||
} else if strings.Contains(ct, "application/json") {
|
|
||||||
// Pretty-print JSON
|
|
||||||
var v interface{}
|
|
||||||
if json.Unmarshal(body, &v) == nil {
|
|
||||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
|
||||||
extracted = string(pretty)
|
|
||||||
} else {
|
|
||||||
extracted = rawText
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extracted = rawText
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extracted = rawText
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up and truncate
|
|
||||||
extracted = strings.TrimSpace(extracted)
|
|
||||||
if len(extracted) > maxChars {
|
|
||||||
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
if extracted == "" {
|
|
||||||
extracted = "(empty content)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": extracted,
|
|
||||||
"details": map[string]interface{}{
|
|
||||||
"url": rawURL,
|
|
||||||
"status": resp.StatusCode,
|
|
||||||
"content_type": ct,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTML → 文本 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func htmlToText(html string) string {
|
|
||||||
// Remove scripts
|
|
||||||
for {
|
|
||||||
start := strings.Index(strings.ToLower(html), "<script")
|
|
||||||
if start < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end := strings.Index(html[start:], "</script>")
|
|
||||||
if end < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
html = html[:start] + html[start+end+9:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove styles
|
|
||||||
for {
|
|
||||||
start := strings.Index(strings.ToLower(html), "<style")
|
|
||||||
if start < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
end := strings.Index(html[start:], "</style>")
|
|
||||||
if end < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
html = html[:start] + html[start+end+8:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace block-level tags with newlines
|
|
||||||
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
|
||||||
html = strings.ReplaceAll(html, tag, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove remaining tags
|
|
||||||
html = stripTags(html)
|
|
||||||
|
|
||||||
// Decode common entities
|
|
||||||
html = strings.ReplaceAll(html, "&", "&")
|
|
||||||
html = strings.ReplaceAll(html, "<", "<")
|
|
||||||
html = strings.ReplaceAll(html, ">", ">")
|
|
||||||
html = strings.ReplaceAll(html, """, "\"")
|
|
||||||
html = strings.ReplaceAll(html, "'", "'")
|
|
||||||
html = strings.ReplaceAll(html, " ", " ")
|
|
||||||
|
|
||||||
// Collapse whitespace
|
|
||||||
lines := strings.Split(html, "\n")
|
|
||||||
var cleaned []string
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Collapse internal whitespace
|
|
||||||
in := []rune(line)
|
|
||||||
var out []rune
|
|
||||||
space := false
|
|
||||||
for _, r := range in {
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
if !space {
|
|
||||||
out = append(out, ' ')
|
|
||||||
space = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out = append(out, r)
|
|
||||||
space = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleaned = append(cleaned, string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(cleaned, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripTags(s string) string {
|
|
||||||
var out strings.Builder
|
|
||||||
inTag := false
|
|
||||||
for _, r := range s {
|
|
||||||
if r == '<' {
|
|
||||||
inTag = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == '>' {
|
|
||||||
inTag = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inTag {
|
|
||||||
out.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Search 处理 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
|
||||||
query, _ := args["query"].(string)
|
|
||||||
if query == "" {
|
|
||||||
return errorResult("query is required"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 5
|
|
||||||
if v, ok := args["count"].(float64); ok && v > 0 {
|
|
||||||
count = int(v)
|
|
||||||
}
|
|
||||||
if count < 1 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
if count > 20 {
|
|
||||||
count = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
results, err := p.ddgSearch(query, count)
|
|
||||||
if err != nil {
|
|
||||||
return errorResult("search failed: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": "No results found.",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
|
||||||
for i, r := range results {
|
|
||||||
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": strings.TrimSpace(sb.String()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 工具函数 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func errorResult(msg string) map[string]interface{} {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"isError": true,
|
|
||||||
"content": msg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
|
||||||
v, err := s.Get(key)
|
|
||||||
if err != nil || v == nil {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
val, ok := v.(T)
|
|
||||||
if !ok {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
|
||||||
return &Plugin{name: name}, nil
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
module webfetch
|
|
||||||
|
|
||||||
go 1.25.0
|
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "webfetch",
|
|
||||||
"name_zh": "网页抓取",
|
|
||||||
"name_en": "webfetch",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "网页内容抓取工具,使用无头 Chromium 浏览器获取网页文字内容",
|
|
||||||
"author": "HomeAgent",
|
|
||||||
"entry": "plugin.so",
|
|
||||||
"tags": ["web", "fetch"],
|
|
||||||
"targets": "linux/amd64"
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Plugin struct {
|
|
||||||
name string
|
|
||||||
sdk *sdk.PluginSDK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|
||||||
s.SetAutoRestart(true)
|
|
||||||
p.sdk = s
|
|
||||||
|
|
||||||
s.RegisterTool("web_fetch", sdk.ToolDef{
|
|
||||||
Name: "web_fetch",
|
|
||||||
Description: "获取网页文字内容。使用无头 Chromium 浏览器渲染页面后提取正文文字,返回标题和前 5000 字符。适用于需要查看网页内容的场景。",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"url": map[string]interface{}{"type": "string", "description": "要访问的网页 URL"},
|
|
||||||
"wait": map[string]interface{}{"type": "integer", "description": "等待秒数(用于 JS 渲染页面,默认 0)"},
|
|
||||||
},
|
|
||||||
"required": []string{"url"},
|
|
||||||
},
|
|
||||||
}, p.handleWebFetch)
|
|
||||||
|
|
||||||
log.Printf("[%s] plugin started", p.name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) Stop() error { return nil }
|
|
||||||
|
|
||||||
func convInt64(v interface{}) (int64, error) {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int64(x), nil
|
|
||||||
case int64:
|
|
||||||
return x, nil
|
|
||||||
case json.Number:
|
|
||||||
return x.Int64()
|
|
||||||
case string:
|
|
||||||
return 0, fmt.Errorf("cannot convert string to int64")
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("cannot convert %T to int64", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) handleWebFetch(args map[string]interface{}) (interface{}, error) {
|
|
||||||
url, _ := args["url"].(string)
|
|
||||||
if url == "" {
|
|
||||||
return nil, fmt.Errorf("url is required")
|
|
||||||
}
|
|
||||||
waitSec, _ := convInt64(args["wait"])
|
|
||||||
|
|
||||||
if waitSec > 0 {
|
|
||||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
var html string
|
|
||||||
chromiumPath := "/usr/local/bin/chromium"
|
|
||||||
if _, err := os.Stat(chromiumPath); err == nil {
|
|
||||||
var out bytes.Buffer
|
|
||||||
argsList := []string{"--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", url}
|
|
||||||
cmd := exec.Command(chromiumPath, argsList...)
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = nil
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return nil, fmt.Errorf("chromium: %w", err)
|
|
||||||
}
|
|
||||||
html = out.String()
|
|
||||||
} else {
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("http get: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
|
||||||
}
|
|
||||||
html = string(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
title := ""
|
|
||||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
|
||||||
title = m[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
var textOut bytes.Buffer
|
|
||||||
pyCmd := exec.Command("python3", "-c", `
|
|
||||||
import sys, re, html
|
|
||||||
raw = sys.stdin.read()
|
|
||||||
text = re.sub(r'<[^>]+>', ' ', raw)
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
text = html.unescape(text)
|
|
||||||
sys.stdout.write(text)
|
|
||||||
`)
|
|
||||||
pyCmd.Stdin = strings.NewReader(html)
|
|
||||||
pyCmd.Stdout = &textOut
|
|
||||||
pyCmd.Stderr = nil
|
|
||||||
pyCmd.Run()
|
|
||||||
text := strings.TrimSpace(textOut.String())
|
|
||||||
|
|
||||||
origLen := len(text)
|
|
||||||
truncated := origLen > 5000
|
|
||||||
if truncated {
|
|
||||||
text = text[:5000]
|
|
||||||
}
|
|
||||||
|
|
||||||
result := ""
|
|
||||||
if title != "" {
|
|
||||||
result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, url)
|
|
||||||
}
|
|
||||||
result += text
|
|
||||||
if truncated {
|
|
||||||
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 5000 字符,共 %d 字符)", origLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"content": result,
|
|
||||||
"title": title,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
|
||||||
return &Plugin{name: name}, nil
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user