From 94ed5d447792b1ebfd502a60dbc059b064748e70 Mon Sep 17 00:00:00 2001 From: hrz <394943230@qq.com> Date: Thu, 23 Oct 2025 23:52:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=20filterOptions=20=E8=AE=A9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=9B=B4=E5=8F=8B=E5=A5=BD=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: huruize <8985917+huruize007@user.noreply.gitee.com> --- handlers_api.go | 4 +- mcp_handlers.go | 43 +++++----- mcp_server.go | 12 ++- types.go | 4 +- xiaohongshu/search.go | 171 +++++++++++++++++++++---------------- xiaohongshu/search_test.go | 92 ++++++++++---------- 6 files changed, 172 insertions(+), 154 deletions(-) diff --git a/handlers_api.go b/handlers_api.go index 79a2a97..0dffaca 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -120,7 +120,7 @@ func (s *AppServer) listFeedsHandler(c *gin.Context) { // searchFeedsHandler 搜索Feeds func (s *AppServer) searchFeedsHandler(c *gin.Context) { var keyword string - var filters []xiaohongshu.FilterOption + var filters xiaohongshu.FilterOption switch c.Request.Method { case http.MethodPost: @@ -144,7 +144,7 @@ func (s *AppServer) searchFeedsHandler(c *gin.Context) { } // 搜索 Feeds - result, err := s.xiaohongshuService.SearchFeeds(c.Request.Context(), keyword, filters...) + 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 6125644..8e2c032 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -237,23 +237,18 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args SearchFeedsArgs) } } - 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) + logrus.Infof("MCP: 搜索Feeds - 关键词: %s", args.Keyword) + + // 将 MCP 的 FilterOption 转换为 xiaohongshu.FilterOption + filter := xiaohongshu.FilterOption{ + SortBy: args.Filters.SortBy, + NoteType: args.Filters.NoteType, + PublishTime: args.Filters.PublishTime, + SearchScope: args.Filters.SearchScope, + Location: args.Filters.Location, } - result, err := s.xiaohongshuService.SearchFeeds(ctx, args.Keyword, filters...) + + result, err := s.xiaohongshuService.SearchFeeds(ctx, args.Keyword, filter) if err != nil { return &MCPToolResult{ Content: []MCPContent{{ @@ -415,16 +410,16 @@ func (s *AppServer) handleLikeFeed(ctx context.Context, args map[string]interfac return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少xsec_token参数"}}, IsError: true} } unlike, _ := args["unlike"].(bool) - + var res *ActionResult var err error - + if unlike { res, err = s.xiaohongshuService.UnlikeFeed(ctx, feedID, xsecToken) } else { res, err = s.xiaohongshuService.LikeFeed(ctx, feedID, xsecToken) } - + if err != nil { action := "点赞" if unlike { @@ -432,7 +427,7 @@ func (s *AppServer) handleLikeFeed(ctx context.Context, args map[string]interfac } return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: action + "失败: " + err.Error()}}, IsError: true} } - + action := "点赞" if unlike { action = "取消点赞" @@ -451,16 +446,16 @@ func (s *AppServer) handleFavoriteFeed(ctx context.Context, args map[string]inte return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少xsec_token参数"}}, IsError: true} } unfavorite, _ := args["unfavorite"].(bool) - + var res *ActionResult var err error - + if unfavorite { res, err = s.xiaohongshuService.UnfavoriteFeed(ctx, feedID, xsecToken) } else { res, err = s.xiaohongshuService.FavoriteFeed(ctx, feedID, xsecToken) } - + if err != nil { action := "收藏" if unfavorite { @@ -468,7 +463,7 @@ func (s *AppServer) handleFavoriteFeed(ctx context.Context, args map[string]inte } return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: action + "失败: " + err.Error()}}, IsError: true} } - + action := "收藏" if unfavorite { action = "取消收藏" diff --git a/mcp_server.go b/mcp_server.go index 7bc4898..9237059 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -30,13 +30,17 @@ type PublishVideoArgs struct { // SearchFeedsArgs 搜索内容的参数 type SearchFeedsArgs struct { - Keyword string `json:"keyword" jsonschema:"搜索关键词"` - Filters []FilterOption `json:"filters,omitempty" jsonschema:"筛选选项列表"` + Keyword string `json:"keyword" jsonschema:"搜索关键词"` + Filters FilterOption `json:"filters,omitempty" jsonschema:"筛选选项"` } +// FilterOption 筛选选项结构体 type FilterOption struct { - FiltersIndex string `json:"filters_index" jsonschema:"筛选索引 排序依据 笔记类型, 发布时间, 搜索范围, 位置距离"` // - TagsIndex string `json:"tags_index" jsonschema:"筛选值 排序依据(综合、最新、最多点赞、最多评论、最多收藏)笔记类型(不限、视频、图文)发布时间(不限、一天内、一周内、半年内)搜索范围(不限、已看过、未看过、已关注)位置距离(不限、同城、附近)"` + SortBy string `json:"sort_by,omitempty" jsonschema:"排序依据: 综合|最新|最多点赞|最多评论|最多收藏,默认为'综合'"` + NoteType string `json:"note_type,omitempty" jsonschema:"笔记类型: 不限|视频|图文,默认为'不限'"` + PublishTime string `json:"publish_time,omitempty" jsonschema:"发布时间: 不限|一天内|一周内|半年内,默认为'不限'"` + SearchScope string `json:"search_scope,omitempty" jsonschema:"搜索范围: 不限|已看过|未看过|已关注,默认为'不限'"` + Location string `json:"location,omitempty" jsonschema:"位置距离: 不限|同城|附近,默认为'不限'"` } // FeedDetailArgs 获取Feed详情的参数 diff --git a/types.go b/types.go index 16763cf..96d9790 100644 --- a/types.go +++ b/types.go @@ -41,8 +41,8 @@ type FeedDetailRequest struct { } type SearchFeedsRequest struct { - Keyword string `json:"keyword" binding:"required"` - Filters []xiaohongshu.FilterOption `json:"filters" binding:"required"` + Keyword string `json:"keyword" binding:"required"` + Filters xiaohongshu.FilterOption `json:"filters,omitempty"` } // FeedDetailResponse Feed详情响应 diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go index 17fa856..8fc8762 100644 --- a/xiaohongshu/search.go +++ b/xiaohongshu/search.go @@ -17,14 +17,24 @@ type SearchResult struct { } `json:"search"` } +// FilterOption 筛选选项结构体 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:"标签文本描述"` + SortBy string `json:"sort_by,omitempty" jsonschema:"排序依据: 综合|最新|最多点赞|最多评论|最多收藏,默认为'综合'"` + NoteType string `json:"note_type,omitempty" jsonschema:"笔记类型: 不限|视频|图文,默认为'不限'"` + PublishTime string `json:"publish_time,omitempty" jsonschema:"发布时间: 不限|一天内|一周内|半年内,默认为'不限'"` + SearchScope string `json:"search_scope,omitempty" jsonschema:"搜索范围: 不限|已看过|未看过|已关注,默认为'不限'"` + Location string `json:"location,omitempty" jsonschema:"位置距离: 不限|同城|附近,默认为'不限'"` } -// 预定义的筛选选项映射表 -var FilterOptionsMap = map[int][]FilterOption{ +// internalFilterOption 内部使用的筛选选项(基于索引) +type internalFilterOption struct { + FiltersIndex int // 筛选组索引 + TagsIndex int // 标签索引 + Text string // 标签文本描述 +} + +// 预定义的筛选选项映射表(内部使用) +var filterOptionsMap = map[int][]internalFilterOption{ 1: { // 排序依据 {FiltersIndex: 1, TagsIndex: 1, Text: "综合"}, {FiltersIndex: 1, TagsIndex: 2, Text: "最新"}, @@ -56,24 +66,83 @@ var FilterOptionsMap = map[int][]FilterOption{ }, } -// 定义筛选组索引到中文描述的映射 -var filterGroupMap = map[int]string{ - 1: "排序依据", - 2: "笔记类型", - 3: "发布时间", - 4: "搜索范围", - 5: "位置距离", +// convertToInternalFilters 将 FilterOption 转换为内部的 internalFilterOption 列表 +func convertToInternalFilters(filter FilterOption) ([]internalFilterOption, error) { + var internalFilters []internalFilterOption + + // 处理排序依据 + if filter.SortBy != "" { + internal, err := findInternalOption(1, filter.SortBy) + if err != nil { + return nil, fmt.Errorf("排序依据错误: %w", err) + } + internalFilters = append(internalFilters, internal) + } + + // 处理笔记类型 + if filter.NoteType != "" { + internal, err := findInternalOption(2, filter.NoteType) + if err != nil { + return nil, fmt.Errorf("笔记类型错误: %w", err) + } + internalFilters = append(internalFilters, internal) + } + + // 处理发布时间 + if filter.PublishTime != "" { + internal, err := findInternalOption(3, filter.PublishTime) + if err != nil { + return nil, fmt.Errorf("发布时间错误: %w", err) + } + internalFilters = append(internalFilters, internal) + } + + // 处理搜索范围 + if filter.SearchScope != "" { + internal, err := findInternalOption(4, filter.SearchScope) + if err != nil { + return nil, fmt.Errorf("搜索范围错误: %w", err) + } + internalFilters = append(internalFilters, internal) + } + + // 处理位置距离 + if filter.Location != "" { + internal, err := findInternalOption(5, filter.Location) + if err != nil { + return nil, fmt.Errorf("位置距离错误: %w", err) + } + internalFilters = append(internalFilters, internal) + } + + return internalFilters, nil } -// validateFilterOption 验证筛选选项是否在有效范围内 -func validateFilterOption(filter FilterOption) error { +// findInternalOption 根据筛选组索引和文本查找内部筛选选项 +func findInternalOption(filtersIndex int, text string) (internalFilterOption, error) { + options, exists := filterOptionsMap[filtersIndex] + if !exists { + return internalFilterOption{}, fmt.Errorf("筛选组 %d 不存在", filtersIndex) + } + + for _, option := range options { + if option.Text == text { + return option, nil + } + } + + return internalFilterOption{}, fmt.Errorf("在筛选组 %d 中未找到文本 '%s'", filtersIndex, text) +} + +// validateInternalFilterOption 验证内部筛选选项是否在有效范围内 +func validateInternalFilterOption(filter internalFilterOption) error { // 检查筛选组索引是否有效 if filter.FiltersIndex < 1 || filter.FiltersIndex > 5 { return fmt.Errorf("无效的筛选组索引 %d,有效范围为 1-5", filter.FiltersIndex) } // 检查标签索引是否在对应筛选组的有效范围内 - options, exists := FilterOptionsMap[filter.FiltersIndex] + options, exists := filterOptionsMap[filter.FiltersIndex] if !exists { return fmt.Errorf("筛选组 %d 不存在", filter.FiltersIndex) } @@ -86,62 +155,6 @@ func validateFilterOption(filter FilterOption) error { 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 } @@ -163,9 +176,19 @@ func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...Fi // 如果有筛选条件,则应用筛选 if len(filters) > 0 { - // 验证所有筛选选项 + // 将所有 FilterOption 转换为内部筛选选项 + var allInternalFilters []internalFilterOption for _, filter := range filters { - if err := validateFilterOption(filter); err != nil { + internalFilters, err := convertToInternalFilters(filter) + if err != nil { + return nil, fmt.Errorf("筛选选项转换失败: %w", err) + } + allInternalFilters = append(allInternalFilters, internalFilters...) + } + + // 验证所有内部筛选选项 + for _, filter := range allInternalFilters { + if err := validateInternalFilterOption(filter); err != nil { return nil, fmt.Errorf("筛选选项验证失败: %w", err) } } @@ -178,7 +201,7 @@ func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...Fi page.MustWait(`() => document.querySelector('div.filter-panel') !== null`) // 应用所有筛选条件 - for _, filter := range filters { + for _, filter := range allInternalFilters { selector := fmt.Sprintf(`div.filter-panel div.filters:nth-child(%d) div.tags:nth-child(%d)`, filter.FiltersIndex, filter.TagsIndex) option := page.MustElement(selector) diff --git a/xiaohongshu/search_test.go b/xiaohongshu/search_test.go index 6885064..5049498 100644 --- a/xiaohongshu/search_test.go +++ b/xiaohongshu/search_test.go @@ -17,7 +17,9 @@ func TestSearch(t *testing.T) { defer b.Close() page := b.NewPage() - defer page.Close() + defer func() { + _ = page.Close() + }() action := NewSearchAction(page) @@ -35,75 +37,69 @@ func TestSearch(t *testing.T) { func TestSearchWithFilters(t *testing.T) { - t.Skip("SKIP: 测试筛选功能") + //t.Skip("SKIP: 测试筛选功能") b := browser.NewBrowser(false) defer b.Close() page := b.NewPage() - defer page.Close() + defer func() { + _ = page.Close() + }() action := NewSearchAction(page) - // 方式1:直接使用索引 - filters1 := []FilterOption{ - {FiltersIndex: 2, TagsIndex: 3, Text: "图文"}, // 笔记类型 -> 图文 - {FiltersIndex: 3, TagsIndex: 2, Text: "一天内"}, // 发布时间 -> 一天内 + // 使用新的 FilterOption 结构 + filter := FilterOption{ + NoteType: "图文", + PublishTime: "一天内", } - feeds1, err := action.Search(context.Background(), "dn432", filters1...) + feeds, err := action.Search(context.Background(), "dn432", filter) require.NoError(t, err) - require.NotEmpty(t, feeds1, "feeds should not be empty") + require.NotEmpty(t, feeds, "feeds should not be empty") - fmt.Printf("方式1 - 成功获取到 %d 个筛选后的 Feed\n", len(feeds1)) + fmt.Printf("成功获取到 %d 个筛选后的 Feed\n", len(feeds)) - // 方式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 { + for _, feed := range feeds { 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) + // 测试有效的筛选选项转换 + validFilter := FilterOption{ + NoteType: "图文", + PublishTime: "一天内", + } + internalFilters, err := convertToInternalFilters(validFilter) require.NoError(t, err) + require.Len(t, internalFilters, 2) - // 测试无效的筛选组索引 - invalidFilterGroup := FilterOption{FiltersIndex: 6, TagsIndex: 1, Text: "无效"} - err = validateFilterOption(invalidFilterGroup) - require.Error(t, err) - require.Contains(t, err.Error(), "无效的筛选组索引") + // 验证转换后的内部筛选选项 + for _, filter := range internalFilters { + err := validateInternalFilterOption(filter) + require.NoError(t, err) + } - // 测试无效的标签索引 - 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("不存在的类型") + // 测试无效的筛选值 + invalidFilter := FilterOption{ + NoteType: "不存在的类型", + } + _, err = convertToInternalFilters(invalidFilter) require.Error(t, err) require.Contains(t, err.Error(), "未找到文本") + + // 测试所有有效的筛选选项 + allFilters := FilterOption{ + SortBy: "最新", + NoteType: "视频", + PublishTime: "一周内", + SearchScope: "已关注", + Location: "同城", + } + internalFilters, err = convertToInternalFilters(allFilters) + require.NoError(t, err) + require.Len(t, internalFilters, 5) }