diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 432f028..99d753b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(go mod:*)", "Bash(go build:*)", "Bash(go get:*)", - "Bash(go list:*)" + "Bash(go list:*)", + "Bash(go test:*)", + "Bash(rmdir:*)", + "Bash(rm:*)" ], "deny": [] } diff --git a/README.md b/README.md index c98a7fc..4730143 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,38 @@ MCP for xiaohongshu.com go run cmd/login/main.go ``` -### 发布 +### 启动 MCP 服务 启动 xiaohongshu-mcp 服务。 + +```bash +go run . -headless=false +``` + +## 验证 MCP + +```bash +npx @modelcontextprotocol/inspector +``` + +![运行 Inspector](./assets/mcp-inspector.png) + +运行后,打开红色标记的链接,配置 MCP inspector,输入 `http://localhost:18060/mcp` ,点击 `Connect` 按钮。 + +## 使用 MCP 发布 + +### 检查登录状态 + + + +### 发布图文 + +示例中是从 https://unsplash.com/ 中随机找了个图片做测试。 + + diff --git a/assets/check_login.mp4 b/assets/check_login.mp4 new file mode 100644 index 0000000..6234c82 Binary files /dev/null and b/assets/check_login.mp4 differ diff --git a/assets/inspect_mcp.png b/assets/inspect_mcp.png new file mode 100644 index 0000000..ccbbdbf Binary files /dev/null and b/assets/inspect_mcp.png differ diff --git a/assets/inspect_mcp_publish.mp4 b/assets/inspect_mcp_publish.mp4 new file mode 100644 index 0000000..a95a961 Binary files /dev/null and b/assets/inspect_mcp_publish.mp4 differ diff --git a/assets/run_inspect.png b/assets/run_inspect.png new file mode 100644 index 0000000..cd8b715 Binary files /dev/null and b/assets/run_inspect.png differ diff --git a/configs/image.go b/configs/image.go new file mode 100644 index 0000000..5d8d0d2 --- /dev/null +++ b/configs/image.go @@ -0,0 +1,14 @@ +package configs + +import ( + "os" + "path/filepath" +) + +const ( + ImagesDir = "xiaohongshu_images" +) + +func GetImagesPath() string { + return filepath.Join(os.TempDir(), ImagesDir) +} diff --git a/go.mod b/go.mod index 343e1a4..ba7f21b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.5 require ( github.com/gin-gonic/gin v1.10.1 github.com/go-rod/rod v0.116.2 + github.com/h2non/filetype v1.1.3 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 0dd6f4d..5266266 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -77,10 +79,6 @@ github.com/xpzouying/headless_browser v0.0.2 h1:sLc4gqUT/5IyTruYIOfCW4aZLinq38hI github.com/xpzouying/headless_browser v0.0.2/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= -github.com/ysmood/fetchup v0.4.0 h1:x8n2dskN+lFCALOHArJ5+3jlPN+z5TEpKwdq4jSCuNw= -github.com/ysmood/fetchup v0.4.0/go.mod h1:yCv8s8itjsCul1LGXJ1Q+8EQnZcVjfbZ4+l1zDm4StE= -github.com/ysmood/fetchup v0.5.2 h1:P9w3OIA7RSNEEFvEmOiTq09IOu42C96PMyZ1MWd8TAs= -github.com/ysmood/fetchup v0.5.2/go.mod h1:yCv8s8itjsCul1LGXJ1Q+8EQnZcVjfbZ4+l1zDm4StE= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= diff --git a/mcp_server.go b/mcp_server.go index af48b8f..c4648be 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -79,7 +79,7 @@ func handlePublishContent(ctx context.Context, args map[string]interface{}) *MCP // 解析参数 title, _ := args["title"].(string) content, _ := args["content"].(string) - imagePathsInterface, _ := args["image_paths"].([]interface{}) + imagePathsInterface, _ := args["images"].([]interface{}) var imagePaths []string for _, path := range imagePathsInterface { @@ -92,9 +92,9 @@ func handlePublishContent(ctx context.Context, args map[string]interface{}) *MCP // 构建发布请求 req := &PublishRequest{ - Title: title, - Content: content, - ImagePaths: imagePaths, + Title: title, + Content: content, + Images: imagePaths, } // 执行发布 @@ -183,14 +183,14 @@ func handleToolsList(w http.ResponseWriter, req JSONRPCRequest) { "type": "string", "description": "发布内容的正文", }, - "image_ids": map[string]interface{}{ + "images": map[string]interface{}{ "type": "array", "items": map[string]string{"type": "string"}, - "description": "图片ID列表(至少一个)", + "description": "图片路径或URL列表(支持本地文件路径和HTTP/HTTPS图片URL,至少一个)", "minItems": 1, }, }, - "required": []string{"title", "content", "image_ids"}, + "required": []string{"title", "content", "images"}, }, }, } diff --git a/pkg/downloader/images.go b/pkg/downloader/images.go new file mode 100644 index 0000000..110d4e5 --- /dev/null +++ b/pkg/downloader/images.go @@ -0,0 +1,148 @@ +package downloader + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/h2non/filetype" + "github.com/pkg/errors" +) + +// ImageDownloader 图片下载器 +type ImageDownloader struct { + savePath string + httpClient *http.Client +} + +// NewImageDownloader 创建图片下载器 +func NewImageDownloader(savePath string) *ImageDownloader { + // 确保保存目录存在 + if err := os.MkdirAll(savePath, 0755); err != nil { + panic(fmt.Sprintf("failed to create save path: %v", err)) + } + + return &ImageDownloader{ + savePath: savePath, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// DownloadImage 下载图片 +// 返回本地文件路径 +func (d *ImageDownloader) DownloadImage(imageURL string) (string, error) { + // 验证URL格式 + if !d.isValidImageURL(imageURL) { + return "", errors.New("invalid image URL format") + } + + // 下载图片数据 + resp, err := d.httpClient.Get(imageURL) + if err != nil { + return "", errors.Wrap(err, "failed to download image") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + // 读取图片数据 + imageData, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "failed to read image data") + } + + // 检测图片格式 + kind, err := filetype.Match(imageData) + if err != nil { + return "", errors.Wrap(err, "failed to detect file type") + } + + if !filetype.IsImage(imageData) { + return "", errors.New("downloaded file is not a valid image") + } + + // 生成唯一文件名 + fileName := d.generateFileName(imageURL, kind.Extension) + filePath := filepath.Join(d.savePath, fileName) + + // 如果文件已存在,直接返回路径 + if _, err := os.Stat(filePath); err == nil { + return filePath, nil + } + + // 保存到文件 + if err := os.WriteFile(filePath, imageData, 0644); err != nil { + return "", errors.Wrap(err, "failed to save image") + } + + return filePath, nil +} + +// DownloadImages 批量下载图片 +func (d *ImageDownloader) DownloadImages(imageURLs []string) ([]string, error) { + var localPaths []string + var errs []error + + for _, imageURL := range imageURLs { + localPath, err := d.DownloadImage(imageURL) + if err != nil { + errs = append(errs, fmt.Errorf("failed to download %s: %w", imageURL, err)) + continue + } + localPaths = append(localPaths, localPath) + } + + if len(errs) > 0 { + return localPaths, fmt.Errorf("download errors occurred: %v", errs) + } + + return localPaths, nil +} + +// isValidImageURL 检查是否为有效的图片URL +func (d *ImageDownloader) isValidImageURL(rawURL string) bool { + // 检查是否以http/https开头 + if !strings.HasPrefix(strings.ToLower(rawURL), "http://") && + !strings.HasPrefix(strings.ToLower(rawURL), "https://") { + return false + } + + // 检查URL格式 + parsedURL, err := url.Parse(rawURL) + if err != nil { + return false + } + + return parsedURL.Scheme != "" && parsedURL.Host != "" +} + +// generateFileName 生成唯一的文件名 +func (d *ImageDownloader) generateFileName(imageURL, extension string) string { + // 使用URL的SHA256哈希作为文件名,确保唯一性 + hash := sha256.Sum256([]byte(imageURL)) + hashStr := fmt.Sprintf("%x", hash) + + // 取前16位哈希值作为文件名 + shortHash := hashStr[:16] + + // 添加时间戳确保更好的唯一性 + timestamp := time.Now().Unix() + + return fmt.Sprintf("img_%s_%d.%s", shortHash, timestamp, extension) +} + +// IsImageURL 判断字符串是否为图片URL +func IsImageURL(path string) bool { + return strings.HasPrefix(strings.ToLower(path), "http://") || + strings.HasPrefix(strings.ToLower(path), "https://") +} diff --git a/pkg/downloader/images_test.go b/pkg/downloader/images_test.go new file mode 100644 index 0000000..f227e93 --- /dev/null +++ b/pkg/downloader/images_test.go @@ -0,0 +1,102 @@ +package downloader + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsImageURL(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"https://example.com/image.jpg", true}, + {"http://example.com/image.png", true}, + {"HTTPS://example.com/image.gif", true}, + {"/local/path/image.jpg", false}, + {"./relative/path/image.png", false}, + {"image.jpg", false}, + {"ftp://example.com/image.jpg", false}, + {"", false}, + } + + for _, test := range tests { + result := IsImageURL(test.input) + if result != test.expected { + t.Errorf("IsImageURL(%q) = %v, expected %v", test.input, result, test.expected) + } + } +} + +func TestNewImageDownloader(t *testing.T) { + tempDir := os.TempDir() + testPath := filepath.Join(tempDir, "test_downloader") + defer os.RemoveAll(testPath) + + downloader := NewImageDownloader(testPath) + + if downloader == nil { + t.Fatal("NewImageDownloader returned nil") + } + + if downloader.savePath != testPath { + t.Errorf("savePath = %q, expected %q", downloader.savePath, testPath) + } + + // 验证目录是否创建 + if _, err := os.Stat(testPath); os.IsNotExist(err) { + t.Errorf("save path directory was not created: %s", testPath) + } +} + +func TestImageDownloader_isValidImageURL(t *testing.T) { + downloader := NewImageDownloader(os.TempDir()) + + tests := []struct { + url string + expected bool + }{ + {"https://example.com/image.jpg", true}, + {"http://example.com/image.png", true}, + {"https://", false}, + {"http://", false}, + {"invalid-url", false}, + {"ftp://example.com/image.jpg", false}, + {"", false}, + } + + for _, test := range tests { + result := downloader.isValidImageURL(test.url) + if result != test.expected { + t.Errorf("isValidImageURL(%q) = %v, expected %v", test.url, result, test.expected) + } + } +} + +func TestImageDownloader_generateFileName(t *testing.T) { + downloader := NewImageDownloader(os.TempDir()) + + url := "https://example.com/image.jpg" + extension := "jpg" + + fileName1 := downloader.generateFileName(url, extension) + + // 文件名应该包含扩展名 + if filepath.Ext(fileName1) != "."+extension { + t.Errorf("fileName should end with .%s, got %s", extension, fileName1) + } + + // 文件名应该包含img_前缀 + if !strings.HasPrefix(filepath.Base(fileName1), "img_") { + t.Errorf("fileName should start with img_, got %s", fileName1) + } + + // 不同URL应该生成不同的文件名 + url2 := "https://example.com/different.jpg" + fileName2 := downloader.generateFileName(url2, extension) + if fileName1 == fileName2 { + t.Errorf("different URLs should generate different file names") + } +} diff --git a/pkg/downloader/processor.go b/pkg/downloader/processor.go new file mode 100644 index 0000000..e7a8603 --- /dev/null +++ b/pkg/downloader/processor.go @@ -0,0 +1,53 @@ +package downloader + +import ( + "fmt" + + "github.com/xpzouying/xiaohongshu-mcp/configs" +) + +// ImageProcessor 图片处理器 +type ImageProcessor struct { + downloader *ImageDownloader +} + +// NewImageProcessor 创建图片处理器 +func NewImageProcessor() *ImageProcessor { + return &ImageProcessor{ + downloader: NewImageDownloader(configs.GetImagesPath()), + } +} + +// ProcessImages 处理图片列表,返回本地文件路径 +// 支持两种输入格式: +// 1. URL格式 (http/https开头) - 自动下载到本地 +// 2. 本地文件路径 - 直接使用 +func (p *ImageProcessor) ProcessImages(images []string) ([]string, error) { + var localPaths []string + var urlsToDownload []string + + // 分离URL和本地路径 + for _, image := range images { + if IsImageURL(image) { + urlsToDownload = append(urlsToDownload, image) + } else { + // 本地路径直接添加 + localPaths = append(localPaths, image) + } + } + + // 批量下载URL图片 + if len(urlsToDownload) > 0 { + downloadedPaths, err := p.downloader.DownloadImages(urlsToDownload) + if err != nil { + return nil, fmt.Errorf("failed to download images: %w", err) + } + localPaths = append(localPaths, downloadedPaths...) + } + + if len(localPaths) == 0 { + return nil, fmt.Errorf("no valid images found") + } + + return localPaths, nil +} diff --git a/service.go b/service.go index 3a8e073..b2cb52a 100644 --- a/service.go +++ b/service.go @@ -2,10 +2,10 @@ package main import ( "context" - "errors" "github.com/xpzouying/xiaohongshu-mcp/browser" "github.com/xpzouying/xiaohongshu-mcp/configs" + "github.com/xpzouying/xiaohongshu-mcp/pkg/downloader" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" ) @@ -19,9 +19,9 @@ func NewXiaohongshuService() *XiaohongshuService { // PublishRequest 发布请求 type PublishRequest struct { - Title string `json:"title" binding:"required"` - Content string `json:"content" binding:"required"` - ImagePaths []string `json:"image_paths" binding:"required,min=1"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Images []string `json:"images" binding:"required,min=1"` } // LoginStatusResponse 登录状态响应 @@ -61,43 +61,51 @@ func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatus // PublishContent 发布内容 func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishRequest) (*PublishResponse, error) { - // 验证参数 - if req.Title == "" { - return nil, errors.New("标题不能为空") - } - if req.Content == "" { - return nil, errors.New("内容不能为空") - } - if len(req.ImagePaths) == 0 { - return nil, errors.New("至少需要一个图片ID") + // 处理图片:下载URL图片或使用本地路径 + imagePaths, err := s.processImages(req.Images) + if err != nil { + return nil, err } // 构建发布内容 content := xiaohongshu.PublishImageContent{ Title: req.Title, Content: req.Content, - ImagePaths: req.ImagePaths, - } - - // 使用全局单例浏览器创建新页面 - page := browser.NewPage() - defer page.Close() - action, err := xiaohongshu.NewPublishImageAction(page) - if err != nil { - return nil, err + ImagePaths: imagePaths, } // 执行发布 - if err := action.Publish(ctx, content); err != nil { + if err := s.publishContent(ctx, content); err != nil { return nil, err } response := &PublishResponse{ Title: req.Title, Content: req.Content, - Images: len(req.ImagePaths), + Images: len(imagePaths), Status: "发布完成", } return response, nil } + +// processImages 处理图片列表,支持URL下载和本地路径 +func (s *XiaohongshuService) processImages(images []string) ([]string, error) { + processor := downloader.NewImageProcessor() + return processor.ProcessImages(images) +} + +// publishContent 执行内容发布 +func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohongshu.PublishImageContent) error { + // 使用全局单例浏览器创建新页面 + page := browser.NewPage() + defer page.Close() + + action, err := xiaohongshu.NewPublishImageAction(page) + if err != nil { + return err + } + + // 执行发布 + return action.Publish(ctx, content) +}