diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 99d753b..2d4c349 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,10 @@ "Bash(go list:*)", "Bash(go test:*)", "Bash(rmdir:*)", - "Bash(rm:*)" + "Bash(rm:*)", + "Bash(gofmt:*)", + "Bash(goimports:*)", + "Bash(chmod:*)" ], "deny": [] } diff --git a/.gitignore b/.gitignore index f14d158..21b7dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ go.work.sum # .idea/ # .vscode/ # .claude/ + +# Build artifacts +xiaohongshu-mcp + +# Test scripts +test_*.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04d7161 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +- 要求每次修改完后,需要帮我格式化 Go 源码文件. +- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除. \ No newline at end of file diff --git a/README.md b/README.md index 3db7910..b2f0bae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ MCP for xiaohongshu.com 1. 登录。第一步必须,小红书需要进行登录。 2. 发布图文。目前只支持发布图文,后续支持更多的发布功能。 +3. 获取推荐列表。 Todos: diff --git a/handlers.go b/handlers.go index a98cf6e..947865a 100644 --- a/handlers.go +++ b/handlers.go @@ -90,6 +90,20 @@ func publishHandler(c *gin.Context) { respondSuccess(c, result, "发布成功") } +// listFeedsHandler 获取Feeds列表 +func listFeedsHandler(c *gin.Context) { + // 获取 Feeds 列表 + result, err := xiaohongshuService.ListFeeds(c.Request.Context()) + if err != nil { + respondError(c, http.StatusInternalServerError, "LIST_FEEDS_FAILED", + "获取Feeds列表失败", err.Error()) + return + } + + c.Set("account", "ai-report") + respondSuccess(c, result, "获取Feeds列表成功") +} + // healthHandler 健康检查 func healthHandler(c *gin.Context) { respondSuccess(c, map[string]any{ diff --git a/mcp_server.go b/mcp_server.go index c4648be..43ac34f 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -118,6 +118,41 @@ func handlePublishContent(ctx context.Context, args map[string]interface{}) *MCP } } +// handleListFeeds 处理获取Feeds列表 +func handleListFeeds(ctx context.Context) *MCPToolResult { + logrus.Info("MCP: 获取Feeds列表") + + result, err := xiaohongshuService.ListFeeds(ctx) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "获取Feeds列表失败: " + err.Error(), + }}, + IsError: true, + } + } + + // 格式化输出,转换为JSON字符串 + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: fmt.Sprintf("获取Feeds列表成功,但序列化失败: %v", err), + }}, + IsError: true, + } + } + + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(jsonData), + }}, + } +} + // handleMCPRequest 处理 MCP 请求 func handleMCPRequest(w http.ResponseWriter, r *http.Request) { var req JSONRPCRequest @@ -193,6 +228,14 @@ func handleToolsList(w http.ResponseWriter, req JSONRPCRequest) { "required": []string{"title", "content", "images"}, }, }, + { + "name": "list_feeds", + "description": "获取小红书首页Feeds列表", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, } result := map[string]interface{}{ @@ -220,6 +263,8 @@ func handleToolsCall(w http.ResponseWriter, r *http.Request, req JSONRPCRequest) result = handleCheckLoginStatus(ctx) case "publish_content": result = handlePublishContent(ctx, toolCall.Arguments) + case "list_feeds": + result = handleListFeeds(ctx) default: logrus.Warnf("不支持的工具: %s", toolCall.Name) sendJSONRPCError(w, req.ID, -32601, "Tool not found", nil) diff --git a/server.go b/server.go index b9d9e00..f1e98e2 100644 --- a/server.go +++ b/server.go @@ -40,6 +40,9 @@ func setupRouter() *gin.Engine { api.GET("/login/status", checkLoginStatusHandler) api.POST("/publish", publishHandler) + + // Feeds 相关路由 + api.GET("/feeds/list", listFeedsHandler) } return router diff --git a/service.go b/service.go index b2cb52a..104dd53 100644 --- a/service.go +++ b/service.go @@ -39,6 +39,12 @@ type PublishResponse struct { PostID string `json:"post_id,omitempty"` } +// FeedsListResponse Feeds列表响应 +type FeedsListResponse struct { + Feeds []xiaohongshu.Feed `json:"feeds"` + Count int `json:"count"` +} + // CheckLoginStatus 检查登录状态 func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatusResponse, error) { // 使用全局单例浏览器创建新页面 @@ -109,3 +115,26 @@ func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohon // 执行发布 return action.Publish(ctx, content) } + +// ListFeeds 获取Feeds列表 +func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, error) { + // 使用全局单例浏览器创建新页面 + page := browser.NewPage() + defer page.Close() + + // 创建 Feeds 列表 action + action := xiaohongshu.NewFeedsListAction(page) + + // 获取 Feeds 列表 + feeds, err := action.GetFeedsList(ctx) + if err != nil { + return nil, err + } + + response := &FeedsListResponse{ + Feeds: feeds, + Count: len(feeds), + } + + return response, nil +} diff --git a/xiaohongshu/feeds.go b/xiaohongshu/feeds.go new file mode 100644 index 0000000..d3bf0ab --- /dev/null +++ b/xiaohongshu/feeds.go @@ -0,0 +1,55 @@ +package xiaohongshu + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/go-rod/rod" +) + +type FeedsListAction struct { + page *rod.Page +} + +// InitialState 定义页面初始状态结构 +type InitialState struct { + Feed FeedData `json:"feed"` +} + +func NewFeedsListAction(page *rod.Page) *FeedsListAction { + pp := page.Timeout(60 * time.Second) + + pp.MustNavigate("https://www.xiaohongshu.com") + pp.MustWaitLoad() + pp.MustWait(`() => window.__INITIAL_STATE__ !== undefined`) + + return &FeedsListAction{page: pp} +} + +// GetFeedsList 获取页面的 Feed 列表数据 +func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) { + page := f.page.Context(ctx) + + // 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串 + result := page.MustEval(`() => { + if (window.__INITIAL_STATE__) { + return JSON.stringify(window.__INITIAL_STATE__); + } + return ""; + }`).String() + + if result == "" { + return nil, fmt.Errorf("__INITIAL_STATE__ not found") + } + + // 解析完整的 InitialState + var state InitialState + if err := json.Unmarshal([]byte(result), &state); err != nil { + return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err) + } + + // 返回 feed.feeds._value + return state.Feed.Feeds.Value, nil +} diff --git a/xiaohongshu/feeds_test.go b/xiaohongshu/feeds_test.go new file mode 100644 index 0000000..bf7fac9 --- /dev/null +++ b/xiaohongshu/feeds_test.go @@ -0,0 +1,86 @@ +package xiaohongshu + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xpzouying/xiaohongshu-mcp/browser" +) + +func TestGetFeedsList(t *testing.T) { + + t.Skip("SKIP: 测试发布") + + _ = browser.Init(false) + defer browser.Close() + + page := browser.NewPage() + defer page.Close() + + // NewFeedsListAction 内部已经处理导航 + action := NewFeedsListAction(page) + + feeds, err := action.GetFeedsList(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, feeds, "feeds should not be empty") + + fmt.Printf("成功获取到 %d 个 Feed\n", len(feeds)) + + // 验证 JSON 结构完整性 + for i, feed := range feeds { + // 验证必填字段 + require.NotEmpty(t, feed.ID, "Feed ID should not be empty") + require.NotEmpty(t, feed.ModelType, "ModelType should not be empty") + require.NotEmpty(t, feed.XsecToken, "XsecToken should not be empty") + require.NotEmpty(t, feed.TrackID, "TrackID should not be empty") + require.NotEmpty(t, feed.NoteCard.Type, "NoteCard Type should not be empty") + require.NotEmpty(t, feed.NoteCard.DisplayTitle, "DisplayTitle should not be empty") + require.NotEmpty(t, feed.NoteCard.User.UserID, "User ID should not be empty") + require.NotEmpty(t, feed.NoteCard.User.Nickname, "User nickname should not be empty") + + // 如果是视频类型,检查视频信息 + if feed.NoteCard.Type == "video" { + require.NotNil(t, feed.NoteCard.Video, "Video info should not be nil for video type") + if feed.NoteCard.Video != nil { + require.True(t, feed.NoteCard.Video.Capa.Duration > 0, "Video duration should be greater than 0") + } + } + + // 只对第一个 Feed 进行完整 JSON 序列化检查 + if i == 0 { + // 序列化为 JSON + jsonData, err := json.MarshalIndent(feed, "", " ") + require.NoError(t, err, "Failed to marshal feed") + + fmt.Printf("\n第一个 Feed 的完整 JSON 结构:\n%s\n", string(jsonData)) + + // 反序列化检查 + var checkFeed Feed + err = json.Unmarshal(jsonData, &checkFeed) + require.NoError(t, err, "Failed to unmarshal feed") + + // 比较序列化前后是否一致 + require.Equal(t, feed.ID, checkFeed.ID) + require.Equal(t, feed.ModelType, checkFeed.ModelType) + require.Equal(t, feed.NoteCard.Type, checkFeed.NoteCard.Type) + } + + // 打印前3个 Feed 的信息 + if i < 3 { + fmt.Printf("\nFeed %d 基本信息:\n", i+1) + fmt.Printf(" ID: %s\n", feed.ID) + fmt.Printf(" ModelType: %s\n", feed.ModelType) + fmt.Printf(" 标题: %s\n", feed.NoteCard.DisplayTitle) + fmt.Printf(" 类型: %s\n", feed.NoteCard.Type) + fmt.Printf(" 作者: %s (@%s)\n", feed.NoteCard.User.Nickname, feed.NoteCard.User.UserID) + fmt.Printf(" 点赞数: %s\n", feed.NoteCard.InteractInfo.LikedCount) + fmt.Printf(" 封面尺寸: %dx%d\n", feed.NoteCard.Cover.Width, feed.NoteCard.Cover.Height) + if feed.NoteCard.Type == "video" && feed.NoteCard.Video != nil { + fmt.Printf(" 视频时长: %d秒\n", feed.NoteCard.Video.Capa.Duration) + } + } + } +} diff --git a/xiaohongshu/types.go b/xiaohongshu/types.go new file mode 100644 index 0000000..9390cd5 --- /dev/null +++ b/xiaohongshu/types.go @@ -0,0 +1,83 @@ +package xiaohongshu + +// 小红书 Feed 相关的数据结构定义 + +// FeedResponse 表示从 __INITIAL_STATE__ 中获取的完整 Feed 响应 +type FeedResponse struct { + Feed FeedData `json:"feed"` +} + +// FeedData 表示 feed 数据结构 +type FeedData struct { + Feeds FeedsValue `json:"feeds"` +} + +// FeedsValue 表示 feeds 的值结构 +type FeedsValue struct { + Value []Feed `json:"_value"` +} + +// Feed 表示单个 Feed 项目 +type Feed struct { + XsecToken string `json:"xsecToken"` + ID string `json:"id"` + ModelType string `json:"modelType"` + NoteCard NoteCard `json:"noteCard"` + TrackID string `json:"trackId"` + Ignore bool `json:"ignore"` + Index int `json:"index"` + Exposed bool `json:"exposed"` + SSRRendered bool `json:"ssrRendered"` +} + +// NoteCard 表示笔记卡片信息 +type NoteCard struct { + Type string `json:"type"` + DisplayTitle string `json:"displayTitle"` + User User `json:"user"` + InteractInfo InteractInfo `json:"interactInfo"` + Cover Cover `json:"cover"` + Video *Video `json:"video,omitempty"` // 视频内容,可能为空 +} + +// User 表示用户信息 +type User struct { + UserID string `json:"userId"` + Nickname string `json:"nickname"` + NickName string `json:"nickName"` + Avatar string `json:"avatar"` + XsecToken string `json:"xsecToken"` +} + +// InteractInfo 表示互动信息 +type InteractInfo struct { + Liked bool `json:"liked"` + LikedCount string `json:"likedCount"` +} + +// Cover 表示封面信息 +type Cover struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + FileID string `json:"fileId"` + URLPre string `json:"urlPre"` + URLDefault string `json:"urlDefault"` + InfoList []ImageInfo `json:"infoList"` +} + +// ImageInfo 表示图片信息 +type ImageInfo struct { + ImageScene string `json:"imageScene"` + URL string `json:"url"` +} + +// Video 表示视频信息 +type Video struct { + Capa VideoCapability `json:"capa"` +} + +// VideoCapability 表示视频能力信息 +type VideoCapability struct { + Duration int `json:"duration"` // 视频时长,单位秒 +}