From 2284d8897c2ae9b31d6f53c37ed8967caf8720dd Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 21:11:18 +0800 Subject: [PATCH] feat: restore bili plugin, merge only web+webfetch into browser plugin --- README.md | 3 +- example/bili/go.mod | 7 ++ example/bili/plg.json | 11 ++ example/bili/plugin.go | 234 ++++++++++++++++++++++++++++++++++++++ example/bili/plugin.h | 101 ++++++++++++++++ example/browser/plg.json | 4 +- example/browser/plugin.go | 156 +------------------------ 7 files changed, 358 insertions(+), 158 deletions(-) create mode 100644 example/bili/go.mod create mode 100644 example/bili/plg.json create mode 100644 example/bili/plugin.go create mode 100644 example/bili/plugin.h diff --git a/README.md b/README.md index 3866082..debe9be 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ enabled := sdk.AutoRestart() | 插件 | 说明 | |------|------| | a2a | Agent-to-Agent 协议通信 | -| browser | 网络搜索、网页抓取、浏览器渲染、视频下载(合并自 web/webfetch/bili) | +| bili | Bilibili 视频下载 | +| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) | | editdoc | 文档编辑 | | files | 文件管理 | | memo | 备忘录/记忆 | diff --git a/example/bili/go.mod b/example/bili/go.mod new file mode 100644 index 0000000..1051dc9 --- /dev/null +++ b/example/bili/go.mod @@ -0,0 +1,7 @@ +module bili + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/bili/plg.json b/example/bili/plg.json new file mode 100644 index 0000000..a537a61 --- /dev/null +++ b/example/bili/plg.json @@ -0,0 +1,11 @@ +{ + "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" +} diff --git a/example/bili/plugin.go b/example/bili/plugin.go new file mode 100644 index 0000000..cc3a4d7 --- /dev/null +++ b/example/bili/plugin.go @@ -0,0 +1,234 @@ +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 +} diff --git a/example/bili/plugin.h b/example/bili/plugin.h new file mode 100644 index 0000000..618dde6 --- /dev/null +++ b/example/bili/plugin.h @@ -0,0 +1,101 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package bili */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#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 +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 +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +#include +typedef std::complex GoComplex64; +typedef std::complex 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 diff --git a/example/browser/plg.json b/example/browser/plg.json index c320b10..52c862f 100644 --- a/example/browser/plg.json +++ b/example/browser/plg.json @@ -3,9 +3,9 @@ "name_zh": "浏览器", "name_en": "browser", "version": "1.0.0", - "description": "网络资源搜索与获取:搜索引擎查询(browser_search)、网页抓取(browser_fetch,SSRF防护)、无头浏览器渲染(browser_render)、视频下载(browser_video,基于yt-dlp)", + "description": "网络资源搜索与获取:搜索引擎查询(browser_search)、网页抓取(browser_fetch,SSRF防护)、无头浏览器渲染(browser_render)", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["web", "search", "fetch", "video", "browser"], + "tags": ["web", "search", "fetch", "browser"], "targets": "linux/amd64" } diff --git a/example/browser/plugin.go b/example/browser/plugin.go index cf942c2..4bc1ac5 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -11,7 +11,6 @@ import ( "net/url" "os" "os/exec" - "path/filepath" "regexp" "strings" "sync" @@ -28,7 +27,6 @@ type Plugin struct { timeout int proxy string client *http.Client - outputDir string } func newHTTPClient(timeout int, proxyURL string) *http.Client { @@ -74,12 +72,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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 } @@ -88,8 +80,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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{ @@ -131,21 +121,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, 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) + log.Printf("[%s] started, timeout=%ds proxy=%q", p.name, p.timeout, p.proxy) return nil } @@ -466,136 +442,6 @@ sys.stdout.write(text) 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 }