feat: 添加搜索功能 (#16)

- 新增 SearchFeeds 服务方法,支持关键词搜索小红书内容
- 添加 search_feeds MCP 工具,提供搜索接口
- 新增 /api/v1/feeds/search API 端点
- 实现搜索页面的浏览器自动化操作
- 优化 MCP 协议支持,处理 notifications/initialized 和 notifications/cancelled 通知
- 更新文档,添加搜索功能说明和使用示例
- 重构类型定义,优化数据结构

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zy
2025-08-17 23:35:22 +08:00
committed by GitHub
parent a0a063d418
commit aa09687751
11 changed files with 241 additions and 18 deletions

View File

@@ -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. 使用示例

BIN
assets/search_result.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -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{

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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")

65
xiaohongshu/search.go Normal file
View File

@@ -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())
}

View File

@@ -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)
}
}

View File

@@ -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 表示封面信息