Merge pull request #6 from xpzouying/get-feeds-list

feat: 添加获取小红书首页 Feeds 列表功能
This commit is contained in:
zy
2025-08-12 01:27:07 +08:00
committed by GitHub
11 changed files with 328 additions and 1 deletions

View File

@@ -9,7 +9,10 @@
"Bash(go list:*)",
"Bash(go test:*)",
"Bash(rmdir:*)",
"Bash(rm:*)"
"Bash(rm:*)",
"Bash(gofmt:*)",
"Bash(goimports:*)",
"Bash(chmod:*)"
],
"deny": []
}

6
.gitignore vendored
View File

@@ -31,3 +31,9 @@ go.work.sum
# .idea/
# .vscode/
# .claude/
# Build artifacts
xiaohongshu-mcp
# Test scripts
test_*.sh

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
- 要求每次修改完后,需要帮我格式化 Go 源码文件.
- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除.

View File

@@ -6,6 +6,7 @@ MCP for xiaohongshu.com
1. 登录。第一步必须,小红书需要进行登录。
2. 发布图文。目前只支持发布图文,后续支持更多的发布功能。
3. 获取推荐列表。
Todos

View File

@@ -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{

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

55
xiaohongshu/feeds.go Normal file
View File

@@ -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
}

86
xiaohongshu/feeds_test.go Normal file
View File

@@ -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)
}
}
}
}

83
xiaohongshu/types.go Normal file
View File

@@ -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"` // 视频时长,单位秒
}