From 70ab07fe94895dd9aac12f1a443cd62501af7f0d Mon Sep 17 00:00:00 2001 From: zy Date: Sat, 11 Oct 2025 00:13:27 +0800 Subject: [PATCH 01/13] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f7b1738..3a80de8 100644 --- a/README.md +++ b/README.md @@ -736,9 +736,11 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 -| 【飞书二群】:扫码进入 | 【微信群 8 群】:扫码进入 | +| 【飞书3群】:扫码进入 | 【微信群 8 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| qrcode_2qun | WechatIMG119 | + + ## 🙏 致谢贡献者 ✨ From c485f1cd49d54403988359f455bd4942d95bc467 Mon Sep 17 00:00:00 2001 From: haikow Date: Sun, 12 Oct 2025 13:01:43 +0800 Subject: [PATCH 02/13] docs: add Windows 11 setup guide for xiaohongshu-mcp (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Windows 11 setup guide for xiaohongshu-mcp - Introduced a comprehensive guide for quickly setting up xiaohongshu-mcp on Windows 11. - Included steps for downloading the latest build, resolving virus warnings, and starting the application. - Added detailed instructions for managing Windows Security exclusions and launching the MCP service. - Provided installation steps for the AI client (iflow cli) and usage examples. * 更新 windows_guide.md --------- Co-authored-by: juesheng.Lin --- docs/windows_guide.md | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/docs/windows_guide.md b/docs/windows_guide.md index 18ae364..3cf8908 100644 --- a/docs/windows_guide.md +++ b/docs/windows_guide.md @@ -30,3 +30,107 @@ ``` 祝大家使用 xiaohongshu-mcp 服务愉快哦~ + +# xiaohongshu-mcp Windows11快速搭建 + +## 1.  下载最新构建版本 + +[github.com](https://github.com/xpzouying/xiaohongshu-mcp/releases) + +如果当前系统为Windows 则选择 xiaohongshu-mcp-windows-amd64.zip 下载 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_597379_Dw_WBLdYI-KsFlXm_1760067122?w=1137&h=633&type=image/png) + +下载完解压文件 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_806026_wozodlNLyXADgJzQ_1760067150?w=1097&h=437&type=image/png) + +在当前文件夹中右键打开终端 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_24479_igFOK7Lf332tlvkM_1760067218?w=1090&h=622&type=image/png) + +先运行登录命令程序 + +``` +./xiaohongshu-login-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_557435_MEWWz-JeHubKmkhc_1760067518?w=1709&h=810&type=image/png) + +等待下载完 + +## 2.  解决Windows 11 报病毒问题 + +在运行之前的程序后会报病毒,如下图 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_79147_lDOh7CnkzJEWiROM_1760067634?w=1761&h=518&type=image/png) + +这时候我们需要打开Windows 安全中心(Windows 11 版本演示) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_436678__HrwxQPD57zZvW5h_1760067781?w=1424&h=932&type=image/png) + +点击进入管理设置后,查看最下方的排除项 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_936924_6OPZpwjyICV7NlGc_1760067974?w=1166&h=916&type=image/png) + +把之前的错误程序的路径添加进去,如下图 + +要改成你当前报错的实际路径 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_871687_NBwGzTWJ1RHTQgBQ_1760068159?w=1901&h=439&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_710523_eExonqwWf2gSc5RD_1760068191?w=1838&h=658&type=image/png) + +总结解决路径办法 + +解决步骤: + +1. 打开 Windows 安全中心(Windows Security)。 + +2. 点击 病毒和威胁防护(Virus & threat protection)。 + +3. 在“病毒和威胁防护设置”下,点击 管理设置(Manage settings)。 + +4. 向下滚动,找到并点击 添加或删除排除项(Add or remove exclusions)。 + +5. 点击 添加排除项(Add an exclusion)。 + +6. 选择 文件夹(Folder)。 + +7. 导航到以下路径并选择该文件夹: + +``` +C:\Users\你的用户(当前电脑)\AppData\Local\Temp\leakless-amd64-adb80298fa6a3af7ced8b1c9b5f18007 +``` + +8.  . 确认添加排除项。 + +## 3.  启动程序 + +``` +./xiaohongshu-login-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_986235_Vn-u3F7LZXOsYE6c_1760078263?w=1118&h=346&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_215347_jIpS7bT7J6nQPIDs_1760078324?w=901&h=830&type=image/png) + +登录小红书 + +启动MCP服务 + +``` +./xiaohongshu-mcp-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_66988_0r6LHv0FuL9Aidlv_1760094345?w=970&h=291&type=image/png) + +## 4.  MCP 验证 + +``` +npx @modelcontextprotocol/inspector +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_861647_Lo0xw1oXyLKD5A2Y_1760165693?w=1074&h=452&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_260079_5FFeEfMTVXaLGXoz_1760165797?w=1905&h=937&type=image/png) \ No newline at end of file From d5138d32bcbcbcc81632de77067e063ef4fb688a Mon Sep 17 00:00:00 2001 From: Carlo Date: Thu, 16 Oct 2025 21:15:13 +0800 Subject: [PATCH 03/13] 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(), "未找到文本") +} From 23f85616b47f556c04aae961bca317771fa30995 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:16:58 +0800 Subject: [PATCH 04/13] docs: add a67793581 as a contributor for code (#241) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index a875179..2383412 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -138,6 +138,15 @@ "contributions": [ "code" ] + }, + { + "login": "a67793581", + "name": "Carlo", + "avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4", + "profile": "https://carlo-blog.aiju.fun/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 3a80de8..16bc542 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # xiaohongshu-mcp -[![All Contributors](https://img.shields.io/badge/all_contributors-14-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat-square)](#contributors-) MCP for 小红书/xiaohongshu.com。 @@ -770,6 +770,9 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 lmxdawn
lmxdawn

💻 haikow
haikow

💻 + + Carlo
Carlo

💻 + From 844ff8c1021fa2ee0bc4f4a48c0ad10d5fdf681f Mon Sep 17 00:00:00 2001 From: Carlo Date: Thu, 16 Oct 2025 21:17:28 +0800 Subject: [PATCH 05/13] myProfileHandler (#239) Co-authored-by: Buf Generate --- handlers_api.go | 14 ++++++++++++++ routes.go | 1 + service.go | 35 +++++++++++++++++++++++++++++++++++ xiaohongshu/navigate.go | 20 ++++++++++++++++++++ xiaohongshu/user_profile.go | 22 ++++++++++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/handlers_api.go b/handlers_api.go index 41deec6..79a2a97 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -227,3 +227,17 @@ func healthHandler(c *gin.Context) { "timestamp": "now", }, "服务正常") } + +// myProfileHandler 我的信息 +func (s *AppServer) myProfileHandler(c *gin.Context) { + // 获取当前登录用户信息 + result, err := s.xiaohongshuService.GetMyProfile(c.Request.Context()) + if err != nil { + respondError(c, http.StatusInternalServerError, "GET_MY_PROFILE_FAILED", + "获取我的主页失败", err.Error()) + return + } + + c.Set("account", "ai-report") + respondSuccess(c, map[string]any{"data": result}, "获取我的主页成功") +} diff --git a/routes.go b/routes.go index 4d87dd1..58ff566 100644 --- a/routes.go +++ b/routes.go @@ -48,6 +48,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.POST("/feeds/detail", appServer.getFeedDetailHandler) api.POST("/user/profile", appServer.userProfileHandler) api.POST("/feeds/comment", appServer.postCommentHandler) + api.GET("/user/me", appServer.myProfileHandler) } return router diff --git a/service.go b/service.go index f2a37b4..ce9c368 100644 --- a/service.go +++ b/service.go @@ -461,3 +461,38 @@ func saveCookies(page *rod.Page) error { cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath()) return cookieLoader.SaveCookies(data) } + +// withBrowserPage 执行需要浏览器页面的操作的通用函数 +func withBrowserPage(fn func(*rod.Page) error) error { + b := newBrowser() + defer b.Close() + + page := b.NewPage() + defer page.Close() + + return fn(page) +} + +// GetMyProfile 获取当前登录用户的个人信息 +func (s *XiaohongshuService) GetMyProfile(ctx context.Context) (*UserProfileResponse, error) { + var result *xiaohongshu.UserProfileResponse + var err error + + err = withBrowserPage(func(page *rod.Page) error { + action := xiaohongshu.NewUserProfileAction(page) + result, err = action.GetMyProfileViaSidebar(ctx) + return err + }) + + if err != nil { + return nil, err + } + + response := &UserProfileResponse{ + UserBasicInfo: result.UserBasicInfo, + Interactions: result.Interactions, + Feeds: result.Feeds, + } + + return response, nil +} diff --git a/xiaohongshu/navigate.go b/xiaohongshu/navigate.go index ba9166a..00791ce 100644 --- a/xiaohongshu/navigate.go +++ b/xiaohongshu/navigate.go @@ -23,3 +23,23 @@ func (n *NavigateAction) ToExplorePage(ctx context.Context) error { return nil } + +func (n *NavigateAction) ToProfilePage(ctx context.Context) error { + page := n.page.Context(ctx) + + // First navigate to explore page + if err := n.ToExplorePage(ctx); err != nil { + return err + } + + page.MustWaitStable() + + // Find and click the "我" channel link in sidebar + profileLink := page.MustElement(`div.main-container li.user.side-bar-component a.link-wrapper span.channel`) + profileLink.MustClick() + + // Wait for navigation to complete + page.MustWaitLoad() + + return nil +} diff --git a/xiaohongshu/user_profile.go b/xiaohongshu/user_profile.go index d60d3d8..df4af88 100644 --- a/xiaohongshu/user_profile.go +++ b/xiaohongshu/user_profile.go @@ -26,6 +26,11 @@ func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken s page.MustNavigate(searchURL) page.MustWaitStable() + return u.extractUserProfileData(page) +} + +// extractUserProfileData 从页面中提取用户资料数据的通用方法 +func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) { page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 @@ -69,3 +74,20 @@ func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken s func makeUserProfileURL(userID, xsecToken string) string { return fmt.Sprintf("https://www.xiaohongshu.com/user/profile/%s?xsec_token=%s&xsec_source=pc_note", userID, xsecToken) } + +func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context) (*UserProfileResponse, error) { + page := u.page.Context(ctx) + + // 创建导航动作 + navigate := NewNavigate(page) + + // 通过侧边栏导航到个人主页 + if err := navigate.ToProfilePage(ctx); err != nil { + return nil, fmt.Errorf("failed to navigate to profile page via sidebar: %w", err) + } + + // 等待页面加载完成并获取 __INITIAL_STATE__ + page.MustWaitStable() + + return u.extractUserProfileData(page) +} From df623caf1807120bf5a6c862c49cae04a7f39788 Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 16 Oct 2025 23:00:57 +0800 Subject: [PATCH 06/13] fix get data panic (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复 data 获取时的循环引用错误 --- errors/errors.go | 6 +++ mcp_server.go | 7 ++-- service.go | 6 ++- xiaohongshu/feed_detail.go | 32 +++++++-------- xiaohongshu/feeds.go | 29 +++++++------- xiaohongshu/like_favorite.go | 31 ++++++++------- xiaohongshu/search.go | 26 +++++++----- xiaohongshu/user_profile.go | 77 +++++++++++++++++++++++++----------- 8 files changed, 129 insertions(+), 85 deletions(-) create mode 100644 errors/errors.go diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..54869ee --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,6 @@ +package errors + +import "errors" + +var ErrNoFeeds = errors.New("没有捕获到 feeds 数据") +var ErrNoFeedDetail = errors.New("没有捕获到 feed 详情数据") diff --git a/mcp_server.go b/mcp_server.go index 8b2df73..d5e31ee 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/base64" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sirupsen/logrus" ) @@ -64,8 +65,8 @@ type LikeFeedArgs struct { // FavoriteFeedArgs 收藏参数 type FavoriteFeedArgs struct { - FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` - XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` + FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` + XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` Unfavorite bool `json:"unfavorite,omitempty" jsonschema:"是否取消收藏,true为取消收藏,false或未设置则为收藏"` } @@ -177,7 +178,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { mcp.AddTool(server, &mcp.Tool{ Name: "user_profile", - Description: "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容", + Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容", }, func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ diff --git a/service.go b/service.go index ce9c368..228fbaf 100644 --- a/service.go +++ b/service.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + "os" + "time" + "github.com/go-rod/rod" "github.com/mattn/go-runewidth" "github.com/sirupsen/logrus" @@ -13,8 +16,6 @@ import ( "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/pkg/downloader" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" - "os" - "time" ) // XiaohongshuService 小红书业务服务 @@ -284,6 +285,7 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, // 获取 Feeds 列表 feeds, err := action.GetFeedsList(ctx) if err != nil { + logrus.Errorf("获取 Feeds 列表失败: %v", err) return nil, err } diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go index 2c996dd..8921498 100644 --- a/xiaohongshu/feed_detail.go +++ b/xiaohongshu/feed_detail.go @@ -7,6 +7,8 @@ import ( "time" "github.com/go-rod/rod" + "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/errors" ) // FeedDetailAction 表示 Feed 详情页动作 @@ -26,39 +28,37 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken // 构建详情页 URL url := makeFeedDetailURL(feedID, xsecToken) + logrus.Infof("打开 feed 详情页: %s", url) + // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() time.Sleep(1 * time.Second) - // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.note && + window.__INITIAL_STATE__.note.noteDetailMap) { + const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap; + return JSON.stringify(noteDetailMap); } return ""; }`).String() if result == "" { - return nil, fmt.Errorf("__INITIAL_STATE__ not found") + return nil, errors.ErrNoFeedDetail } - // 定义响应结构并直接反序列化 - var initialState struct { - Note struct { - NoteDetailMap map[string]struct { - Note FeedDetail `json:"note"` - Comments CommentList `json:"comments"` - } `json:"noteDetailMap"` - } `json:"note"` + var noteDetailMap map[string]struct { + Note FeedDetail `json:"note"` + Comments CommentList `json:"comments"` } - if err := json.Unmarshal([]byte(result), &initialState); err != nil { - return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal noteDetailMap: %w", err) } - // 从 noteDetailMap 中获取对应 feedID 的数据 - noteDetail, exists := initialState.Note.NoteDetailMap[feedID] + noteDetail, exists := noteDetailMap[feedID] if !exists { return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID) } diff --git a/xiaohongshu/feeds.go b/xiaohongshu/feeds.go index 0110a25..63d9bca 100644 --- a/xiaohongshu/feeds.go +++ b/xiaohongshu/feeds.go @@ -7,17 +7,13 @@ import ( "time" "github.com/go-rod/rod" + "github.com/xpzouying/xiaohongshu-mcp/errors" ) type FeedsListAction struct { page *rod.Page } -// FeedsResult 定义页面初始状态结构 -type FeedsResult struct { - Feed FeedData `json:"feed"` -} - func NewFeedsListAction(page *rod.Page) *FeedsListAction { pp := page.Timeout(60 * time.Second) @@ -33,24 +29,27 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) { time.Sleep(1 * time.Second) - // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.feed && + window.__INITIAL_STATE__.feed.feeds) { + const feeds = window.__INITIAL_STATE__.feed.feeds; + const feedsData = feeds.value !== undefined ? feeds.value : feeds._value; + if (feedsData) { + return JSON.stringify(feedsData); + } } return ""; }`).String() if result == "" { - return nil, fmt.Errorf("__INITIAL_STATE__ not found") + return nil, errors.ErrNoFeeds } - // 解析完整的 InitialState - var state FeedsResult - if err := json.Unmarshal([]byte(result), &state); err != nil { - return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + var feeds []Feed + if err := json.Unmarshal([]byte(result), &feeds); err != nil { + return nil, fmt.Errorf("failed to unmarshal feeds: %w", err) } - // 返回 feed.feeds._value - return state.Feed.Feeds.Value, nil + return feeds, nil } diff --git a/xiaohongshu/like_favorite.go b/xiaohongshu/like_favorite.go index 7f49ba9..c483b40 100644 --- a/xiaohongshu/like_favorite.go +++ b/xiaohongshu/like_favorite.go @@ -9,6 +9,7 @@ import ( "github.com/go-rod/rod" "github.com/pkg/errors" "github.com/sirupsen/logrus" + myerrors "github.com/xpzouying/xiaohongshu-mcp/errors" ) // ActionResult 通用动作响应(点赞/收藏等) @@ -213,33 +214,33 @@ func (a *FavoriteAction) toggleFavorite(page *rod.Page, feedID string, targetCol // getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态 func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) { + result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.note && + window.__INITIAL_STATE__.note.noteDetailMap) { + return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap); } return ""; }`).String() if result == "" { - return false, false, fmt.Errorf("__INITIAL_STATE__ not found") + return false, false, myerrors.ErrNoFeedDetail } - var state struct { + // 直接解析为 noteDetailMap + var noteDetailMap map[string]struct { Note struct { - NoteDetailMap map[string]struct { - Note struct { - InteractInfo struct { - Liked bool `json:"liked"` - Collected bool `json:"collected"` - } `json:"interactInfo"` - } `json:"note"` - } `json:"noteDetailMap"` + InteractInfo struct { + Liked bool `json:"liked"` + Collected bool `json:"collected"` + } `json:"interactInfo"` } `json:"note"` } - if err := json.Unmarshal([]byte(result), &state); err != nil { - return false, false, errors.Wrap(err, "unmarshal __INITIAL_STATE__ failed") + if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil { + return false, false, errors.Wrap(err, "unmarshal noteDetailMap failed") } - detail, ok := state.Note.NoteDetailMap[feedID] + detail, ok := noteDetailMap[feedID] if !ok { return false, false, fmt.Errorf("feed %s not in noteDetailMap", feedID) } diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go index 0337379..17fa856 100644 --- a/xiaohongshu/search.go +++ b/xiaohongshu/search.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-rod/rod" + "github.com/xpzouying/xiaohongshu-mcp/errors" ) type SearchResult struct { @@ -190,24 +191,29 @@ func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...Fi page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) } - // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.search && + window.__INITIAL_STATE__.search.feeds) { + const feeds = window.__INITIAL_STATE__.search.feeds; + const feedsData = feeds.value !== undefined ? feeds.value : feeds._value; + if (feedsData) { + return JSON.stringify(feedsData); } - return ""; - }`).String() + } + return ""; + }`).String() if result == "" { - return nil, fmt.Errorf("__INITIAL_STATE__ not found") + return nil, errors.ErrNoFeeds } - var searchResult SearchResult - if err := json.Unmarshal([]byte(result), &searchResult); err != nil { - return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + var feeds []Feed + if err := json.Unmarshal([]byte(result), &feeds); err != nil { + return nil, fmt.Errorf("failed to unmarshal feeds: %w", err) } - return searchResult.Search.Feeds.Value, nil + return feeds, nil } func makeSearchURL(keyword string) string { diff --git a/xiaohongshu/user_profile.go b/xiaohongshu/user_profile.go index df4af88..856220f 100644 --- a/xiaohongshu/user_profile.go +++ b/xiaohongshu/user_profile.go @@ -33,42 +33,71 @@ func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken s func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) { page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) - // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 - result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + userDataResult := page.MustEval(`() => { + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.user && + window.__INITIAL_STATE__.user.userPageData) { + const userPageData = window.__INITIAL_STATE__.user.userPageData; + const data = userPageData.value !== undefined ? userPageData.value : userPageData._value; + if (data) { + return JSON.stringify(data); } - return ""; - }`).String() + } + return ""; + }`).String() - if result == "" { - return nil, fmt.Errorf("__INITIAL_STATE__ not found") + if userDataResult == "" { + return nil, fmt.Errorf("user.userPageData.value not found in __INITIAL_STATE__") } - // 定义响应结构并直接反序列化 - var initialState = struct { - User struct { - UserPageData UserPageData `json:"userPageData"` - Notes struct { - Feeds [][]Feed `json:"_rawValue"` // 帖子为双重数组 - } `json:"notes"` - } `json:"user"` - }{} - if err := json.Unmarshal([]byte(result), &initialState); err != nil { - return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + + // 2. 获取用户帖子:window.__INITIAL_STATE__.user.notes.value + notesResult := page.MustEval(`() => { + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.user && + window.__INITIAL_STATE__.user.notes) { + const notes = window.__INITIAL_STATE__.user.notes; + // 优先使用 value(getter),如果不存在则使用 _value(内部字段) + const data = notes.value !== undefined ? notes.value : notes._value; + if (data) { + return JSON.stringify(data); + } + } + return ""; + }`).String() + + if notesResult == "" { + return nil, fmt.Errorf("user.notes.value not found in __INITIAL_STATE__") } + + // 解析用户信息 + var userPageData struct { + Interactions []UserInteractions `json:"interactions"` + BasicInfo UserBasicInfo `json:"basicInfo"` + } + if err := json.Unmarshal([]byte(userDataResult), &userPageData); err != nil { + return nil, fmt.Errorf("failed to unmarshal userPageData: %w", err) + } + + // 解析帖子数据(帖子为双重数组) + var notesFeeds [][]Feed + if err := json.Unmarshal([]byte(notesResult), ¬esFeeds); err != nil { + return nil, fmt.Errorf("failed to unmarshal notes: %w", err) + } + + // 组装响应 response := &UserProfileResponse{ - UserBasicInfo: initialState.User.UserPageData.RawValue.BasicInfo, - Interactions: initialState.User.UserPageData.RawValue.Interactions, + UserBasicInfo: userPageData.BasicInfo, + Interactions: userPageData.Interactions, } - // 添加用户贴子 - for _, feeds := range initialState.User.Notes.Feeds { + + // 添加用户帖子(展平双重数组) + for _, feeds := range notesFeeds { if len(feeds) != 0 { response.Feeds = append(response.Feeds, feeds...) } } return response, nil - } func makeUserProfileURL(userID, xsecToken string) string { From d11cb1c8332772eb0a3c681e3a0f628a72081ea7 Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 16 Oct 2025 23:09:47 +0800 Subject: [PATCH 07/13] update publish timeout (#245) * update publish timeout * add publish error message --- service.go | 1 + xiaohongshu/publish.go | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/service.go b/service.go index 228fbaf..16dc938 100644 --- a/service.go +++ b/service.go @@ -182,6 +182,7 @@ func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishReq // 执行发布 if err := s.publishContent(ctx, content); err != nil { + logrus.Errorf("发布内容失败: title=%s %v", content.Title, err) return nil, err } diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index a8d1439..25a004c 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -33,7 +33,7 @@ const ( func NewPublishImageAction(page *rod.Page) (*PublishAction, error) { - pp := page.Timeout(180 * time.Second) + pp := page.Timeout(300 * time.Second) pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable() time.Sleep(1 * time.Second) @@ -61,7 +61,15 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent return errors.Wrap(err, "小红书上传图片失败") } - if err := submitPublish(page, content.Title, content.Content, content.Tags); err != nil { + tags := content.Tags + if len(tags) >= 10 { + logrus.Warnf("标签数量超过10,截取前10个标签") + tags = tags[:10] + } + + logrus.Infof("发布内容: title=%s, images=%v, tags=%v", content.Title, len(content.ImagePaths), tags) + + if err := submitPublish(page, content.Title, content.Content, tags); err != nil { return errors.Wrap(err, "小红书发布失败") } @@ -179,20 +187,25 @@ func uploadImages(page *rod.Page, imagesPaths []string) error { pp := page.Timeout(30 * time.Second) // 验证文件路径有效性 + validPaths := make([]string, 0, len(imagesPaths)) for _, path := range imagesPaths { if _, err := os.Stat(path); os.IsNotExist(err) { - return errors.Wrapf(err, "图片文件不存在: %s", path) + logrus.Warnf("图片文件不存在: %s", path) + continue } + validPaths = append(validPaths, path) + + logrus.Infof("获取有效图片:%s", path) } // 等待上传输入框出现 uploadInput := pp.MustElement(".upload-input") // 上传多个文件 - uploadInput.MustSetFiles(imagesPaths...) + uploadInput.MustSetFiles(validPaths...) // 等待并验证上传完成 - return waitForUploadComplete(pp, len(imagesPaths)) + return waitForUploadComplete(pp, len(validPaths)) } // waitForUploadComplete 等待并验证上传完成 From b326d8cc8ce2932df651e8020324ca17969d090a Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 16 Oct 2025 23:32:01 +0800 Subject: [PATCH 08/13] feat: add panic recovery middleware for MCP tools (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加统一的 panic recovery 错误处理机制 实现了类似 Gin middleware 的 panic recovery 机制: - 添加 withPanicRecovery 泛型函数,捕获 handler 中的 panic - 包装所有 11 个 MCP 工具的 handler 函数 - 记录完整的错误日志和堆栈信息 - 向客户端返回友好的错误提示信息 - 保证程序在单个工具出错时不会崩溃 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * update comments --------- Co-authored-by: Claude --- mcp_server.go | 80 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/mcp_server.go b/mcp_server.go index d5e31ee..7bc4898 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -3,6 +3,8 @@ package main import ( "context" "encoding/base64" + "fmt" + "runtime/debug" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sirupsen/logrus" @@ -89,6 +91,38 @@ func InitMCPServer(appServer *AppServer) *mcp.Server { return server } +func withPanicRecovery[T any]( + toolName string, + handler func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error), +) func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error) { + + return func(ctx context.Context, req *mcp.CallToolRequest, args T) (result *mcp.CallToolResult, resp any, err error) { + defer func() { + if r := recover(); r != nil { + logrus.WithFields(logrus.Fields{ + "tool": toolName, + "panic": r, + }).Error("Tool handler panicked") + + logrus.Errorf("Stack trace:\n%s", debug.Stack()) + + result = &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: fmt.Sprintf("工具 %s 执行时发生内部错误: %v\n\n请查看服务端日志获取详细信息。", toolName, r), + }, + }, + IsError: true, + } + resp = nil + err = nil + } + }() + + return handler(ctx, req, args) + } +} + // registerTools 注册所有 MCP 工具 func registerTools(server *mcp.Server, appServer *AppServer) { // 工具 1: 检查登录状态 @@ -97,10 +131,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "check_login_status", Description: "检查小红书登录状态", }, - func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + withPanicRecovery("check_login_status", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { result := appServer.handleCheckLoginStatus(ctx) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 2: 获取登录二维码 @@ -109,10 +143,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "get_login_qrcode", Description: "获取登录二维码(返回 Base64 图片和超时时间)", }, - func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + withPanicRecovery("get_login_qrcode", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { result := appServer.handleGetLoginQrcode(ctx) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 3: 发布内容 @@ -121,7 +155,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "publish_content", Description: "发布小红书图文内容", }, - func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("publish_content", func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) { // 转换参数格式到现有的 handler argsMap := map[string]interface{}{ "title": args.Title, @@ -131,19 +165,19 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handlePublishContent(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 4: 获取Feed列表 mcp.AddTool(server, &mcp.Tool{ Name: "list_feeds", - Description: "获取用户发布的内容列表", + Description: "获取首页 Feeds 列表", }, - func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + withPanicRecovery("list_feeds", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { result := appServer.handleListFeeds(ctx) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 5: 搜索内容 @@ -152,10 +186,10 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "search_feeds", Description: "搜索小红书内容(需要已登录)", }, - func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("search_feeds", func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) { result := appServer.handleSearchFeeds(ctx, args) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 6: 获取Feed详情 @@ -164,14 +198,14 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "get_feed_detail", Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表", }, - func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "feed_id": args.FeedID, "xsec_token": args.XsecToken, } result := appServer.handleGetFeedDetail(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 7: 获取用户主页 @@ -180,14 +214,14 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "user_profile", Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容", }, - func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("user_profile", func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "user_id": args.UserID, "xsec_token": args.XsecToken, } result := appServer.handleUserProfile(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 8: 发表评论 @@ -196,7 +230,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "post_comment_to_feed", Description: "发表评论到小红书笔记", }, - func(ctx context.Context, req *mcp.CallToolRequest, args PostCommentArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("post_comment_to_feed", func(ctx context.Context, req *mcp.CallToolRequest, args PostCommentArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "feed_id": args.FeedID, "xsec_token": args.XsecToken, @@ -204,7 +238,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handlePostComment(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 9: 发布视频(仅本地文件) @@ -213,7 +247,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "publish_with_video", Description: "发布小红书视频内容(仅支持本地单个视频文件)", }, - func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("publish_with_video", func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "title": args.Title, "content": args.Content, @@ -222,7 +256,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handlePublishVideo(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 10: 点赞笔记 @@ -231,7 +265,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "like_feed", Description: "为指定笔记点赞或取消点赞(如已点赞将跳过点赞,如未点赞将跳过取消点赞)", }, - func(ctx context.Context, req *mcp.CallToolRequest, args LikeFeedArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("like_feed", func(ctx context.Context, req *mcp.CallToolRequest, args LikeFeedArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "feed_id": args.FeedID, "xsec_token": args.XsecToken, @@ -239,7 +273,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handleLikeFeed(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 11: 收藏笔记 @@ -248,7 +282,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { Name: "favorite_feed", Description: "收藏指定笔记或取消收藏(如已收藏将跳过收藏,如未收藏将跳过取消收藏)", }, - func(ctx context.Context, req *mcp.CallToolRequest, args FavoriteFeedArgs) (*mcp.CallToolResult, any, error) { + withPanicRecovery("favorite_feed", func(ctx context.Context, req *mcp.CallToolRequest, args FavoriteFeedArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ "feed_id": args.FeedID, "xsec_token": args.XsecToken, @@ -256,7 +290,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handleFavoriteFeed(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) logrus.Infof("Registered %d MCP tools", 11) From c6705f09eb33df7213edfa5ad880c0b0627b289d Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 16 Oct 2025 23:40:56 +0800 Subject: [PATCH 09/13] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 16bc542..287096c 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80 **小红书基础运营知识** - **标题:(非常重要)小红书要求标题不超过 20 个字** +- **正文:(非常重要):正文不能超过 1000 个字** - 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。 - (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。 - Tags:现已支持。添加合适的Tags能带来更多的流量。 From d4116f2a4f41f3ce5aede663e6d6e662cba2c639 Mon Sep 17 00:00:00 2001 From: zy Date: Sun, 19 Oct 2025 00:35:19 +0800 Subject: [PATCH 10/13] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 287096c..c778582 100644 --- a/README.md +++ b/README.md @@ -737,9 +737,11 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 -| 【飞书3群】:扫码进入 | 【微信群 8 群】:扫码进入 | +| 【飞书3群】:扫码进入 | 【微信群 9 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| qrcode_2qun | WechatIMG119 | + + From 0d18d951f710028f3513265499694eb2184877ba Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 23 Oct 2025 01:20:12 +0800 Subject: [PATCH 11/13] Update README with x-mcp tool recommendation Added recommendation for x-mcp tool for easier use. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c778582..98ad540 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ MCP for 小红书/xiaohongshu.com。 **遇到任何问题,务必要先看 [各种疑难杂症](https://github.com/xpzouying/xiaohongshu-mcp/issues/56)**。 +上面的 **疑难杂症** 列表后,还是解决不了你的部署问题,那么强烈推荐使用我写的另外一个工具:[xpzouying/x-mcp](https://github.com/xpzouying/x-mcp),这个工具不需要你进行部署,只需要通过浏览器插件就能驱动你的 MCP,对于非技术同学来说更加友好。 + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=xpzouying/xiaohongshu-mcp&type=Timeline)](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline) 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 12/13] =?UTF-8?q?=E4=BF=AE=E6=94=B9=20filterOptions=20?= =?UTF-8?q?=E8=AE=A9=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) } From 04803b2de4c6eeac4cbf1b103951784f153376d7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:53:16 +0800 Subject: [PATCH 13/13] docs: add hrz394943230 as a contributor for code (#261) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2383412..5121767 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -147,6 +147,15 @@ "contributions": [ "code" ] + }, + { + "login": "hrz394943230", + "name": "hrz", + "avatar_url": "https://avatars.githubusercontent.com/u/28583005?v=4", + "profile": "https://github.com/hrz394943230", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 98ad540..f772b31 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # xiaohongshu-mcp -[![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) MCP for 小红书/xiaohongshu.com。 @@ -777,6 +777,7 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 Carlo
Carlo

💻 + hrz
hrz

💻