diff --git a/README.md b/README.md index f3dfda1..24bc4a4 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,7 @@ MCP for xiaohongshu.com 1. 登录。第一步必须,小红书需要进行登录。 2. 发布图文。目前只支持发布图文,后续支持更多的发布功能。 3. 获取推荐列表。 - -Todos: - -- [ ] 搜索功能。 +4. 搜索内容。根据关键词搜索小红书内容。 ## 1. 使用教程 @@ -63,6 +60,12 @@ npx @modelcontextprotocol/inspector ![发布图文](./assets/inspect_mcp_publish.gif) +### 搜索内容 + +使用搜索功能,根据关键词搜索小红书内容: + +![搜索内容](./assets/search_result.png) + ## 2. MCP 客户端接入 本服务支持标准的 Model Context Protocol (MCP),可以接入各种支持 MCP 的 AI 客户端。 @@ -93,6 +96,7 @@ claude mcp add --transport http xiaohongshu-mcp http://localhost:18060/mcp - `check_login_status` - 检查登录状态 - `publish_content` - 发布图文内容 - `list_feeds` - 获取推荐列表 +- `search_feeds` - 搜索小红书内容(前提:用户已登录) ### 2.4. 使用示例 diff --git a/assets/search_result.png b/assets/search_result.png new file mode 100644 index 0000000..8f740aa Binary files /dev/null and b/assets/search_result.png differ diff --git a/handlers_api.go b/handlers_api.go index 5431c1f..f660d06 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -82,6 +82,27 @@ func (s *AppServer) listFeedsHandler(c *gin.Context) { respondSuccess(c, result, "获取Feeds列表成功") } +// searchFeedsHandler 搜索Feeds +func (s *AppServer) searchFeedsHandler(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + respondError(c, http.StatusBadRequest, "MISSING_KEYWORD", + "缺少关键词参数", "keyword parameter is required") + return + } + + // 搜索 Feeds + result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword) + if err != nil { + respondError(c, http.StatusInternalServerError, "SEARCH_FEEDS_FAILED", + "搜索Feeds失败", err.Error()) + return + } + + c.Set("account", "ai-report") + respondSuccess(c, result, "搜索Feeds成功") +} + // healthHandler 健康检查 func healthHandler(c *gin.Context) { respondSuccess(c, map[string]any{ diff --git a/handlers_mcp.go b/handlers_mcp.go index a9cd8f3..cab9589 100644 --- a/handlers_mcp.go +++ b/handlers_mcp.go @@ -116,6 +116,55 @@ func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult { } } +// handleSearchFeeds 处理搜索Feeds +func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]interface{}) *MCPToolResult { + logrus.Info("MCP: 搜索Feeds") + + // 解析参数 + keyword, ok := args["keyword"].(string) + if !ok || keyword == "" { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "搜索Feeds失败: 缺少关键词参数", + }}, + IsError: true, + } + } + + logrus.Infof("MCP: 搜索Feeds - 关键词: %s", keyword) + + result, err := s.xiaohongshuService.SearchFeeds(ctx, keyword) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "搜索Feeds失败: " + err.Error(), + }}, + IsError: true, + } + } + + // 格式化输出,转换为JSON字符串 + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: fmt.Sprintf("搜索Feeds成功,但序列化失败: %v", err), + }}, + IsError: true, + } + } + + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(jsonData), + }}, + } +} + // handleMCPRequest 处理 MCP 请求 func (s *AppServer) handleMCPRequest(w http.ResponseWriter, r *http.Request) { var req JSONRPCRequest @@ -134,6 +183,16 @@ func (s *AppServer) handleMCPRequest(w http.ResponseWriter, r *http.Request) { s.handleToolsList(w, req) case "tools/call": s.handleToolsCall(w, r, req) + case "notifications/initialized": + // 客户端通知已初始化完成,不需要响应 + logrus.Info("MCP: 客户端已初始化完成") + // 通知不需要响应 + return + case "notifications/cancelled": + // 客户端通知取消请求,记录日志即可 + logrus.Info("MCP: 收到取消请求通知") + // 通知不需要响应 + return default: logrus.Warnf("不支持的方法: %s", req.Method) s.sendJSONRPCError(w, req.ID, -32601, "Method not found", nil) @@ -199,6 +258,20 @@ func (s *AppServer) handleToolsList(w http.ResponseWriter, req JSONRPCRequest) { "properties": map[string]interface{}{}, }, }, + { + "name": "search_feeds", + "description": "搜索小红书内容(前提:用户已登录)", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "keyword": map[string]interface{}{ + "type": "string", + "description": "搜索关键词", + }, + }, + "required": []string{"keyword"}, + }, + }, } result := map[string]interface{}{ @@ -228,6 +301,8 @@ func (s *AppServer) handleToolsCall(w http.ResponseWriter, r *http.Request, req result = s.handlePublishContent(ctx, toolCall.Arguments) case "list_feeds": result = s.handleListFeeds(ctx) + case "search_feeds": + result = s.handleSearchFeeds(ctx, toolCall.Arguments) default: logrus.Warnf("不支持的工具: %s", toolCall.Name) s.sendJSONRPCError(w, req.ID, -32601, "Tool not found", nil) diff --git a/routes.go b/routes.go index 0401d0a..3031c3f 100644 --- a/routes.go +++ b/routes.go @@ -31,6 +31,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.GET("/login/status", appServer.checkLoginStatusHandler) api.POST("/publish", appServer.publishHandler) api.GET("/feeds/list", appServer.listFeedsHandler) + api.GET("/feeds/search", appServer.searchFeedsHandler) } return router diff --git a/service.go b/service.go index 6bc2ce1..064d757 100644 --- a/service.go +++ b/service.go @@ -145,3 +145,25 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, return response, nil } + +func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (*FeedsListResponse, error) { + b := browser.NewBrowser(configs.IsHeadless()) + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action := xiaohongshu.NewSearchAction(page) + + feeds, err := action.Search(ctx, keyword) + if err != nil { + return nil, err + } + + response := &FeedsListResponse{ + Feeds: feeds, + Count: len(feeds), + } + + return response, nil +} diff --git a/xiaohongshu/feeds.go b/xiaohongshu/feeds.go index d3bf0ab..1cd3ed6 100644 --- a/xiaohongshu/feeds.go +++ b/xiaohongshu/feeds.go @@ -13,8 +13,8 @@ type FeedsListAction struct { page *rod.Page } -// InitialState 定义页面初始状态结构 -type InitialState struct { +// FeedsResult 定义页面初始状态结构 +type FeedsResult struct { Feed FeedData `json:"feed"` } @@ -22,7 +22,7 @@ func NewFeedsListAction(page *rod.Page) *FeedsListAction { pp := page.Timeout(60 * time.Second) pp.MustNavigate("https://www.xiaohongshu.com") - pp.MustWaitLoad() + pp.MustWaitStable() pp.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) return &FeedsListAction{page: pp} @@ -45,7 +45,7 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) { } // 解析完整的 InitialState - var state InitialState + var state FeedsResult if err := json.Unmarshal([]byte(result), &state); err != nil { return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) } diff --git a/xiaohongshu/feeds_test.go b/xiaohongshu/feeds_test.go index 6ec0695..669ee4b 100644 --- a/xiaohongshu/feeds_test.go +++ b/xiaohongshu/feeds_test.go @@ -35,7 +35,6 @@ func TestGetFeedsList(t *testing.T) { require.NotEmpty(t, feed.ID, "Feed ID should not be empty") require.NotEmpty(t, feed.ModelType, "ModelType should not be empty") require.NotEmpty(t, feed.XsecToken, "XsecToken should not be empty") - require.NotEmpty(t, feed.TrackID, "TrackID should not be empty") require.NotEmpty(t, feed.NoteCard.Type, "NoteCard Type should not be empty") require.NotEmpty(t, feed.NoteCard.DisplayTitle, "DisplayTitle should not be empty") require.NotEmpty(t, feed.NoteCard.User.UserID, "User ID should not be empty") diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go new file mode 100644 index 0000000..436bcb8 --- /dev/null +++ b/xiaohongshu/search.go @@ -0,0 +1,65 @@ +package xiaohongshu + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/go-rod/rod" +) + +type SearchResult struct { + Search struct { + Feeds FeedsValue `json:"feeds"` + } `json:"search"` +} + +type SearchAction struct { + page *rod.Page +} + +func NewSearchAction(page *rod.Page) *SearchAction { + pp := page.Timeout(60 * time.Second) + + return &SearchAction{page: pp} +} + +func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, error) { + page := s.page.Context(ctx) + + searchURL := makeSearchURL(keyword) + page.MustNavigate(searchURL) + page.MustWaitStable() + + page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) + + // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 + result := page.MustEval(`() => { + if (window.__INITIAL_STATE__) { + return JSON.stringify(window.__INITIAL_STATE__); + } + return ""; + }`).String() + + if result == "" { + return nil, fmt.Errorf("__INITIAL_STATE__ not found") + } + + var searchResult SearchResult + if err := json.Unmarshal([]byte(result), &searchResult); err != nil { + return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + } + + return searchResult.Search.Feeds.Value, nil +} + +func makeSearchURL(keyword string) string { + + values := url.Values{} + values.Set("keyword", keyword) + values.Set("source", "web_explore_feed") + + return fmt.Sprintf("https://www.xiaohongshu.com/search_result?%s", values.Encode()) +} diff --git a/xiaohongshu/search_test.go b/xiaohongshu/search_test.go new file mode 100644 index 0000000..93e7164 --- /dev/null +++ b/xiaohongshu/search_test.go @@ -0,0 +1,34 @@ +package xiaohongshu + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xpzouying/xiaohongshu-mcp/browser" +) + +func TestSearch(t *testing.T) { + + t.Skip("SKIP: 测试发布") + + b := browser.NewBrowser(false) + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action := NewSearchAction(page) + + feeds, err := action.Search(context.Background(), "Kimi") + require.NoError(t, err) + require.NotEmpty(t, feeds, "feeds should not be empty") + + fmt.Printf("成功获取到 %d 个 Feed\n", len(feeds)) + + for _, feed := range feeds { + fmt.Printf("Feed ID: %s\n", feed.ID) + fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) + } +} diff --git a/xiaohongshu/types.go b/xiaohongshu/types.go index 9390cd5..01c30bb 100644 --- a/xiaohongshu/types.go +++ b/xiaohongshu/types.go @@ -19,15 +19,11 @@ type FeedsValue struct { // Feed 表示单个 Feed 项目 type Feed struct { - XsecToken string `json:"xsecToken"` - ID string `json:"id"` - ModelType string `json:"modelType"` - NoteCard NoteCard `json:"noteCard"` - TrackID string `json:"trackId"` - Ignore bool `json:"ignore"` - Index int `json:"index"` - Exposed bool `json:"exposed"` - SSRRendered bool `json:"ssrRendered"` + XsecToken string `json:"xsecToken"` + ID string `json:"id"` + ModelType string `json:"modelType"` + NoteCard NoteCard `json:"noteCard"` + Index int `json:"index"` } // NoteCard 表示笔记卡片信息 @@ -53,6 +49,12 @@ type User struct { type InteractInfo struct { Liked bool `json:"liked"` LikedCount string `json:"likedCount"` + + SharedCount string `json:"sharedCount"` + CommentCount string `json:"commentCount"` + + CollectedCount string `json:"collectedCount"` + Collected bool `json:"collected"` } // Cover 表示封面信息