diff --git a/.all-contributorsrc b/.all-contributorsrc index a875179..5121767 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -138,6 +138,24 @@ "contributions": [ "code" ] + }, + { + "login": "a67793581", + "name": "Carlo", + "avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4", + "profile": "https://carlo-blog.aiju.fun/", + "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 f7b1738..f772b31 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-16-orange.svg?style=flat-square)](#contributors-) MCP for 小红书/xiaohongshu.com。 @@ -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) @@ -190,6 +192,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80 **小红书基础运营知识** - **标题:(非常重要)小红书要求标题不超过 20 个字** +- **正文:(非常重要):正文不能超过 1000 个字** - 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。 - (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。 - Tags:现已支持。添加合适的Tags能带来更多的流量。 @@ -736,9 +739,13 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 -| 【飞书二群】:扫码进入 | 【微信群 8 群】:扫码进入 | +| 【飞书3群】:扫码进入 | 【微信群 9 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| qrcode_2qun | WechatIMG119 | + + + + ## 🙏 致谢贡献者 ✨ @@ -768,6 +775,10 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 lmxdawn
lmxdawn

💻 haikow
haikow

💻 + + Carlo
Carlo

💻 + hrz
hrz

💻 + 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 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/handlers_api.go b/handlers_api.go index 1a6d11c..cbd0ebd 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()) @@ -228,3 +247,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/mcp_handlers.go b/mcp_handlers.go index 9e89a3e..ff4ff58 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,18 @@ func (s *AppServer) handleSearchFeeds(ctx context.Context, args map[string]inter } } - logrus.Infof("MCP: 搜索Feeds - 关键词: %s", keyword) + logrus.Infof("MCP: 搜索Feeds - 关键词: %s", args.Keyword) - result, err := s.xiaohongshuService.SearchFeeds(ctx, 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, filter) if err != nil { return &MCPToolResult{ Content: []MCPContent{{ @@ -402,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 { @@ -419,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 = "取消点赞" @@ -438,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 { @@ -455,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 c190c65..71a39bd 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" @@ -28,7 +30,17 @@ type PublishVideoArgs struct { // SearchFeedsArgs 搜索内容的参数 type SearchFeedsArgs struct { - Keyword string `json:"keyword" jsonschema:"搜索关键词"` + Keyword string `json:"keyword" jsonschema:"搜索关键词"` + Filters FilterOption `json:"filters,omitempty" jsonschema:"筛选选项"` +} + +// FilterOption 筛选选项结构体 +type FilterOption struct { + 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详情的参数 @@ -68,8 +80,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或未设置则为收藏"` } @@ -92,6 +104,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: 检查登录状态 @@ -100,10 +144,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: 获取登录二维码 @@ -112,10 +156,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: 发布内容 @@ -124,7 +168,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, @@ -134,19 +178,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: 搜索内容 @@ -155,13 +199,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) { - argsMap := map[string]interface{}{ - "keyword": args.Keyword, - } - result := appServer.handleSearchFeeds(ctx, argsMap) + 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详情 @@ -170,30 +211,30 @@ 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: 获取用户主页 mcp.AddTool(server, &mcp.Tool{ Name: "user_profile", - Description: "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容", + 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: 发表评论 @@ -202,7 +243,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, @@ -210,7 +251,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handlePostComment(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 9: 回复评论 @@ -245,7 +286,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, @@ -254,7 +295,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handlePublishVideo(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 11: 点赞笔记 @@ -263,7 +304,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, @@ -271,7 +312,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { } result := appServer.handleLikeFeed(ctx, argsMap) return convertToMCPResult(result), nil, nil - }, + }), ) // 工具 12: 收藏笔记 @@ -280,7 +321,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, @@ -288,7 +329,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) diff --git a/routes.go b/routes.go index aa15106..556ea1e 100644 --- a/routes.go +++ b/routes.go @@ -44,10 +44,12 @@ 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) api.POST("/feeds/comment/reply", appServer.replyCommentHandler) + api.GET("/user/me", appServer.myProfileHandler) } return router diff --git a/service.go b/service.go index 23bf100..a3fa71e 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 小红书业务服务 @@ -181,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 } @@ -284,6 +286,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 } @@ -295,7 +298,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 +307,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 } @@ -484,3 +487,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/types.go b/types.go index 298dae4..f622cfb 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,omitempty"` +} + // FeedDetailResponse Feed详情响应 type FeedDetailResponse struct { FeedID string `json:"feed_id"` 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/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/publish.go b/xiaohongshu/publish.go index d9844e8..c7635c4 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -35,7 +35,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) @@ -68,7 +68,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, "小红书发布失败") } @@ -186,20 +194,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 等待并验证上传完成 diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go index 436bcb8..8fc8762 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 { @@ -16,6 +17,144 @@ type SearchResult struct { } `json:"search"` } +// FilterOption 筛选选项结构体 +type FilterOption struct { + 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:"位置距离: 不限|同城|附近,默认为'不限'"` +} + +// 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: "最新"}, + {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: "附近"}, + }, +} + +// 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 +} + +// 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] + 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 +} + type SearchAction struct { page *rod.Page } @@ -26,7 +165,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,24 +174,69 @@ func (s *SearchAction) Search(ctx context.Context, keyword string) ([]Feed, erro page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) - // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 - result := page.MustEval(`() => { - if (window.__INITIAL_STATE__) { - return JSON.stringify(window.__INITIAL_STATE__); + // 如果有筛选条件,则应用筛选 + if len(filters) > 0 { + // 将所有 FilterOption 转换为内部筛选选项 + var allInternalFilters []internalFilterOption + for _, filter := range filters { + internalFilters, err := convertToInternalFilters(filter) + if err != nil { + return nil, fmt.Errorf("筛选选项转换失败: %w", err) } - return ""; - }`).String() + allInternalFilters = append(allInternalFilters, internalFilters...) + } + + // 验证所有内部筛选选项 + for _, filter := range allInternalFilters { + if err := validateInternalFilterOption(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 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) + option.MustClick() + } + + // 等待页面更新 + page.MustWaitStable() + // 重新等待 __INITIAL_STATE__ 更新 + page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) + } + + result := page.MustEval(`() => { + 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() 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 { @@ -61,5 +245,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..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) @@ -32,3 +34,72 @@ 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 func() { + _ = page.Close() + }() + + action := NewSearchAction(page) + + // 使用新的 FilterOption 结构 + filter := FilterOption{ + NoteType: "图文", + PublishTime: "一天内", + } + + feeds, err := action.Search(context.Background(), "dn432", filter) + require.NoError(t, err) + require.NotEmpty(t, feeds, "feeds should not be empty") + + fmt.Printf("成功获取到 %d 个筛选后的 Feed\n", len(feeds)) + + for _, feed := range feeds { + fmt.Printf("Feed ID: %s\n", feed.ID) + fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) + } +} + +func TestFilterValidation(t *testing.T) { + // 测试有效的筛选选项转换 + validFilter := FilterOption{ + NoteType: "图文", + PublishTime: "一天内", + } + internalFilters, err := convertToInternalFilters(validFilter) + require.NoError(t, err) + require.Len(t, internalFilters, 2) + + // 验证转换后的内部筛选选项 + for _, filter := range internalFilters { + err := validateInternalFilterOption(filter) + require.NoError(t, err) + } + + // 测试无效的筛选值 + 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) +} diff --git a/xiaohongshu/user_profile.go b/xiaohongshu/user_profile.go index d60d3d8..856220f 100644 --- a/xiaohongshu/user_profile.go +++ b/xiaohongshu/user_profile.go @@ -26,46 +26,97 @@ 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 字符串 - 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 { 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) +}