From d5138d32bcbcbcc81632de77067e063ef4fb688a Mon Sep 17 00:00:00 2001 From: Carlo Date: Thu, 16 Oct 2025 21:15:13 +0800 Subject: [PATCH] new_search (#238) Co-authored-by: Buf Generate --- handlers_api.go | 23 +++++- mcp_handlers.go | 27 +++++-- mcp_server.go | 14 ++-- routes.go | 1 + service.go | 4 +- types.go | 7 ++ xiaohongshu/search.go | 159 ++++++++++++++++++++++++++++++++++++- xiaohongshu/search_test.go | 75 +++++++++++++++++ 8 files changed, 292 insertions(+), 18 deletions(-) diff --git a/handlers_api.go b/handlers_api.go index a7dcd04..41deec6 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -3,6 +3,8 @@ package main import ( "net/http" + "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) @@ -117,7 +119,24 @@ func (s *AppServer) listFeedsHandler(c *gin.Context) { // searchFeedsHandler 搜索Feeds func (s *AppServer) searchFeedsHandler(c *gin.Context) { - keyword := c.Query("keyword") + var keyword string + var filters []xiaohongshu.FilterOption + + switch c.Request.Method { + case http.MethodPost: + // 对于POST请求,从JSON中获取keyword + var searchReq SearchFeedsRequest + if err := c.ShouldBindJSON(&searchReq); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_REQUEST", + "请求参数错误", err.Error()) + return + } + keyword = searchReq.Keyword + filters = searchReq.Filters + default: + keyword = c.Query("keyword") + } + if keyword == "" { respondError(c, http.StatusBadRequest, "MISSING_KEYWORD", "缺少关键词参数", "keyword parameter is required") @@ -125,7 +144,7 @@ func (s *AppServer) searchFeedsHandler(c *gin.Context) { } // 搜索 Feeds - result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword) + result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword, filters...) if err != nil { respondError(c, http.StatusInternalServerError, "SEARCH_FEEDS_FAILED", "搜索Feeds失败", err.Error()) diff --git a/mcp_handlers.go b/mcp_handlers.go index b2577d7..6125644 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" "strings" "time" ) @@ -223,12 +224,10 @@ func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult { } // handleSearchFeeds 处理搜索Feeds -func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]interface{}) *MCPToolResult { +func (s *AppServer) handleSearchFeeds(ctx context.Context, args SearchFeedsArgs) *MCPToolResult { logrus.Info("MCP: 搜索Feeds") - // 解析参数 - keyword, ok := args["keyword"].(string) - if !ok || keyword == "" { + if args.Keyword == "" { return &MCPToolResult{ Content: []MCPContent{{ Type: "text", @@ -238,9 +237,23 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]inter } } - logrus.Infof("MCP: 搜索Feeds - 关键词: %s", keyword) - - result, err := s.xiaohongshuService.SearchFeeds(ctx, keyword) + logrus.Infof("MCP: 搜索Feeds - 关键词: %s, 筛选条件数量: %d", args.Keyword, len(args.Filters)) + var filters []xiaohongshu.FilterOption + for _, filter := range args.Filters { + filterOption, err := xiaohongshu.NewFilterOption(xiaohongshu.GetFilterGroupIndex(filter.FiltersIndex), filter.TagsIndex) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: fmt.Sprintf("搜索Feeds失败: 筛选组 %v 的标签索引 %v 错误: %v", + filter.FiltersIndex, filter.TagsIndex, err), + }}, + IsError: true, + } + } + filters = append(filters, filterOption) + } + result, err := s.xiaohongshuService.SearchFeeds(ctx, args.Keyword, filters...) if err != nil { return &MCPToolResult{ Content: []MCPContent{{ diff --git a/mcp_server.go b/mcp_server.go index 3e1ce9e..8b2df73 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/base64" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sirupsen/logrus" ) @@ -28,7 +27,13 @@ type PublishVideoArgs struct { // SearchFeedsArgs 搜索内容的参数 type SearchFeedsArgs struct { - Keyword string `json:"keyword" jsonschema:"搜索关键词"` + Keyword string `json:"keyword" jsonschema:"搜索关键词"` + Filters []FilterOption `json:"filters,omitempty" jsonschema:"筛选选项列表"` +} + +type FilterOption struct { + FiltersIndex string `json:"filters_index" jsonschema:"筛选索引 排序依据 笔记类型, 发布时间, 搜索范围, 位置距离"` // + TagsIndex string `json:"tags_index" jsonschema:"筛选值 排序依据(综合、最新、最多点赞、最多评论、最多收藏)笔记类型(不限、视频、图文)发布时间(不限、一天内、一周内、半年内)搜索范围(不限、已看过、未看过、已关注)位置距离(不限、同城、附近)"` } // FeedDetailArgs 获取Feed详情的参数 @@ -147,10 +152,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Description: "搜索小红书内容(需要已登录)", }, func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) { - argsMap := map[string]interface{}{ - "keyword": args.Keyword, - } - result := appServer.handleSearchFeeds(ctx, argsMap) + result := appServer.handleSearchFeeds(ctx, args) return convertToMCPResult(result), nil, nil }, ) diff --git a/routes.go b/routes.go index c709943..4d87dd1 100644 --- a/routes.go +++ b/routes.go @@ -44,6 +44,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.POST("/publish_video", appServer.publishVideoHandler) api.GET("/feeds/list", appServer.listFeedsHandler) api.GET("/feeds/search", appServer.searchFeedsHandler) + api.POST("/feeds/search", appServer.searchFeedsHandler) api.POST("/feeds/detail", appServer.getFeedDetailHandler) api.POST("/user/profile", appServer.userProfileHandler) api.POST("/feeds/comment", appServer.postCommentHandler) diff --git a/service.go b/service.go index 9cee00b..f2a37b4 100644 --- a/service.go +++ b/service.go @@ -295,7 +295,7 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, return response, nil } -func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (*FeedsListResponse, error) { +func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, filters ...xiaohongshu.FilterOption) (*FeedsListResponse, error) { b := newBrowser() defer b.Close() @@ -304,7 +304,7 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (* action := xiaohongshu.NewSearchAction(page) - feeds, err := action.Search(ctx, keyword) + feeds, err := action.Search(ctx, keyword, filters...) if err != nil { return nil, err } diff --git a/types.go b/types.go index aee7fdc..16763cf 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,7 @@ package main +import "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" + // HTTP API 响应类型 // ErrorResponse 错误响应 @@ -38,6 +40,11 @@ type FeedDetailRequest struct { XsecToken string `json:"xsec_token" binding:"required"` } +type SearchFeedsRequest struct { + Keyword string `json:"keyword" binding:"required"` + Filters []xiaohongshu.FilterOption `json:"filters" binding:"required"` +} + // FeedDetailResponse Feed详情响应 type FeedDetailResponse struct { FeedID string `json:"feed_id"` diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go index 436bcb8..0337379 100644 --- a/xiaohongshu/search.go +++ b/xiaohongshu/search.go @@ -16,6 +16,131 @@ type SearchResult struct { } `json:"search"` } +type FilterOption struct { + FiltersIndex int `json:"filters_index" jsonschema:"筛选组索引 1=排序依据, 2=笔记类型, 3=发布时间, 4=搜索范围, 5=位置距离"` + TagsIndex int `json:"tags_index" jsonschema:"标签索引,根据不同的筛选组索引对应不同的选项: 1=排序依据(1-5), 2=笔记类型(1-3), 3=发布时间(1-4), 4=搜索范围(1-4), 5=位置距离(1-3)"` + Text string `json:"text" jsonschema:"标签文本描述"` +} + +// 预定义的筛选选项映射表 +var FilterOptionsMap = map[int][]FilterOption{ + 1: { // 排序依据 + {FiltersIndex: 1, TagsIndex: 1, Text: "综合"}, + {FiltersIndex: 1, TagsIndex: 2, Text: "最新"}, + {FiltersIndex: 1, TagsIndex: 3, Text: "最多点赞"}, + {FiltersIndex: 1, TagsIndex: 4, Text: "最多评论"}, + {FiltersIndex: 1, TagsIndex: 5, Text: "最多收藏"}, + }, + 2: { // 笔记类型 + {FiltersIndex: 2, TagsIndex: 1, Text: "不限"}, + {FiltersIndex: 2, TagsIndex: 2, Text: "视频"}, + {FiltersIndex: 2, TagsIndex: 3, Text: "图文"}, + }, + 3: { // 发布时间 + {FiltersIndex: 3, TagsIndex: 1, Text: "不限"}, + {FiltersIndex: 3, TagsIndex: 2, Text: "一天内"}, + {FiltersIndex: 3, TagsIndex: 3, Text: "一周内"}, + {FiltersIndex: 3, TagsIndex: 4, Text: "半年内"}, + }, + 4: { // 搜索范围 + {FiltersIndex: 4, TagsIndex: 1, Text: "不限"}, + {FiltersIndex: 4, TagsIndex: 2, Text: "已看过"}, + {FiltersIndex: 4, TagsIndex: 3, Text: "未看过"}, + {FiltersIndex: 4, TagsIndex: 4, Text: "已关注"}, + }, + 5: { // 位置距离 + {FiltersIndex: 5, TagsIndex: 1, Text: "不限"}, + {FiltersIndex: 5, TagsIndex: 2, Text: "同城"}, + {FiltersIndex: 5, TagsIndex: 3, Text: "附近"}, + }, +} + +// 定义筛选组索引到中文描述的映射 +var filterGroupMap = map[int]string{ + 1: "排序依据", + 2: "笔记类型", + 3: "发布时间", + 4: "搜索范围", + 5: "位置距离", +} + +// validateFilterOption 验证筛选选项是否在有效范围内 +func validateFilterOption(filter FilterOption) error { + // 检查筛选组索引是否有效 + if filter.FiltersIndex < 1 || filter.FiltersIndex > 5 { + return fmt.Errorf("无效的筛选组索引 %d,有效范围为 1-5", filter.FiltersIndex) + } + + // 检查标签索引是否在对应筛选组的有效范围内 + options, exists := FilterOptionsMap[filter.FiltersIndex] + if !exists { + return fmt.Errorf("筛选组 %d 不存在", filter.FiltersIndex) + } + + if filter.TagsIndex < 1 || filter.TagsIndex > len(options) { + return fmt.Errorf("筛选组 %d 的标签索引 %d 超出范围,有效范围为 1-%d", + filter.FiltersIndex, filter.TagsIndex, len(options)) + } + + return nil +} + +// 便利函数:根据文本创建筛选选项 +func NewFilterOption(filtersIndex int, text string) (FilterOption, error) { + options, exists := FilterOptionsMap[filtersIndex] + if !exists { + return FilterOption{}, fmt.Errorf("筛选组 %d 不存在", filtersIndex) + } + + for _, option := range options { + if option.Text == text { + return option, nil + } + } + + return FilterOption{}, fmt.Errorf("在筛选组 %d 中未找到文本 '%s'", filtersIndex, text) +} + +// 便利函数:创建常用的筛选选项 +func SortBy(text string) (FilterOption, error) { + return NewFilterOption(1, text) // 排序依据 +} + +func NoteType(text string) (FilterOption, error) { + return NewFilterOption(2, text) // 笔记类型 +} + +func TimeRange(text string) (FilterOption, error) { + return NewFilterOption(3, text) // 发布时间 +} + +func SearchScope(text string) (FilterOption, error) { + return NewFilterOption(4, text) // 搜索范围 +} + +func LocationDistance(text string) (FilterOption, error) { + return NewFilterOption(5, text) // 位置距离 +} + +// GetFilterGroupDescription 根据筛选组索引获取中文描述 +func GetFilterGroupDescription(index int) string { + if desc, exists := filterGroupMap[index]; exists { + return desc + } + return "未知筛选组" +} + +// GetFilterGroupIndex 根据中文描述获取筛选组索引 +func GetFilterGroupIndex(text string) int { + // 通过遍历filterGroupMap获取对应的索引 + for index, description := range filterGroupMap { + if description == text { + return index + } + } + return -1 // 未找到匹配项时返回-1 +} + type SearchAction struct { page *rod.Page } @@ -26,7 +151,7 @@ func NewSearchAction(page *rod.Page) *SearchAction { return &SearchAction{page: pp} } -func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, error) { +func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...FilterOption) ([]Feed, error) { page := s.page.Context(ctx) searchURL := makeSearchURL(keyword) @@ -35,6 +160,36 @@ func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, erro page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) + // 如果有筛选条件,则应用筛选 + if len(filters) > 0 { + // 验证所有筛选选项 + for _, filter := range filters { + if err := validateFilterOption(filter); err != nil { + return nil, fmt.Errorf("筛选选项验证失败: %w", err) + } + } + + // 悬停在筛选按钮上 + filterButton := page.MustElement(`div.filter`) + filterButton.MustHover() + + // 等待筛选面板出现 + page.MustWait(`() => document.querySelector('div.filter-panel') !== null`) + + // 应用所有筛选条件 + for _, filter := range filters { + selector := fmt.Sprintf(`div.filter-panel div.filters:nth-child(%d) div.tags:nth-child(%d)`, + filter.FiltersIndex, filter.TagsIndex) + option := page.MustElement(selector) + option.MustClick() + } + + // 等待页面更新 + page.MustWaitStable() + // 重新等待 __INITIAL_STATE__ 更新 + page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) + } + // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 result := page.MustEval(`() => { if (window.__INITIAL_STATE__) { @@ -61,5 +216,7 @@ func makeSearchURL(keyword string) string { values.Set("keyword", keyword) values.Set("source", "web_explore_feed") + //https://www.xiaohongshu.com/search_result?keyword=%25E7%258E%258B%25E5%25AD%2590&source=web_search_result_notes + //https://www.xiaohongshu.com/search_result?keyword=%25E7%258E%258B%25E5%25AD%2590&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 index 93e7164..6885064 100644 --- a/xiaohongshu/search_test.go +++ b/xiaohongshu/search_test.go @@ -32,3 +32,78 @@ func TestSearch(t *testing.T) { fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) } } + +func TestSearchWithFilters(t *testing.T) { + + t.Skip("SKIP: 测试筛选功能") + + b := browser.NewBrowser(false) + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action := NewSearchAction(page) + + // 方式1:直接使用索引 + filters1 := []FilterOption{ + {FiltersIndex: 2, TagsIndex: 3, Text: "图文"}, // 笔记类型 -> 图文 + {FiltersIndex: 3, TagsIndex: 2, Text: "一天内"}, // 发布时间 -> 一天内 + } + + feeds1, err := action.Search(context.Background(), "dn432", filters1...) + require.NoError(t, err) + require.NotEmpty(t, feeds1, "feeds should not be empty") + + fmt.Printf("方式1 - 成功获取到 %d 个筛选后的 Feed\n", len(feeds1)) + + // 方式2:使用便利函数 + filter2, err := NoteType("图文") + require.NoError(t, err) + + filter3, err := TimeRange("一天内") + require.NoError(t, err) + + filters2 := []FilterOption{filter2, filter3} + feeds2, err := action.Search(context.Background(), "dn432", filters2...) + require.NoError(t, err) + require.NotEmpty(t, feeds2, "feeds should not be empty") + + fmt.Printf("方式2 - 成功获取到 %d 个筛选后的 Feed\n", len(feeds2)) + + for _, feed := range feeds2 { + fmt.Printf("Feed ID: %s\n", feed.ID) + fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) + } +} + +func TestFilterValidation(t *testing.T) { + // 测试有效的筛选选项 + validFilter := FilterOption{FiltersIndex: 2, TagsIndex: 3, Text: "图文"} + err := validateFilterOption(validFilter) + require.NoError(t, err) + + // 测试无效的筛选组索引 + invalidFilterGroup := FilterOption{FiltersIndex: 6, TagsIndex: 1, Text: "无效"} + err = validateFilterOption(invalidFilterGroup) + require.Error(t, err) + require.Contains(t, err.Error(), "无效的筛选组索引") + + // 测试无效的标签索引 + invalidTagIndex := FilterOption{FiltersIndex: 2, TagsIndex: 5, Text: "无效"} + err = validateFilterOption(invalidTagIndex) + require.Error(t, err) + require.Contains(t, err.Error(), "标签索引 5 超出范围") + + // 测试便利函数 + filter, err := NoteType("图文") + require.NoError(t, err) + require.Equal(t, 2, filter.FiltersIndex) + require.Equal(t, 3, filter.TagsIndex) + require.Equal(t, "图文", filter.Text) + + // 测试不存在的文本 + _, err = NoteType("不存在的类型") + require.Error(t, err) + require.Contains(t, err.Error(), "未找到文本") +}