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
+```
+
+
+
+运行后,打开红色标记的链接,配置 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)
+}