Merge pull request #6 from xpzouying/get-feeds-list
feat: 添加获取小红书首页 Feeds 列表功能
This commit is contained in:
@@ -9,7 +9,10 @@
|
|||||||
"Bash(go list:*)",
|
"Bash(go list:*)",
|
||||||
"Bash(go test:*)",
|
"Bash(go test:*)",
|
||||||
"Bash(rmdir:*)",
|
"Bash(rmdir:*)",
|
||||||
"Bash(rm:*)"
|
"Bash(rm:*)",
|
||||||
|
"Bash(gofmt:*)",
|
||||||
|
"Bash(goimports:*)",
|
||||||
|
"Bash(chmod:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -31,3 +31,9 @@ go.work.sum
|
|||||||
# .idea/
|
# .idea/
|
||||||
# .vscode/
|
# .vscode/
|
||||||
# .claude/
|
# .claude/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
xiaohongshu-mcp
|
||||||
|
|
||||||
|
# Test scripts
|
||||||
|
test_*.sh
|
||||||
|
|||||||
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- 要求每次修改完后,需要帮我格式化 Go 源码文件.
|
||||||
|
- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除.
|
||||||
@@ -6,6 +6,7 @@ MCP for xiaohongshu.com
|
|||||||
|
|
||||||
1. 登录。第一步必须,小红书需要进行登录。
|
1. 登录。第一步必须,小红书需要进行登录。
|
||||||
2. 发布图文。目前只支持发布图文,后续支持更多的发布功能。
|
2. 发布图文。目前只支持发布图文,后续支持更多的发布功能。
|
||||||
|
3. 获取推荐列表。
|
||||||
|
|
||||||
Todos:
|
Todos:
|
||||||
|
|
||||||
|
|||||||
14
handlers.go
14
handlers.go
@@ -90,6 +90,20 @@ func publishHandler(c *gin.Context) {
|
|||||||
respondSuccess(c, result, "发布成功")
|
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 健康检查
|
// healthHandler 健康检查
|
||||||
func healthHandler(c *gin.Context) {
|
func healthHandler(c *gin.Context) {
|
||||||
respondSuccess(c, map[string]any{
|
respondSuccess(c, map[string]any{
|
||||||
|
|||||||
@@ -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 请求
|
// handleMCPRequest 处理 MCP 请求
|
||||||
func handleMCPRequest(w http.ResponseWriter, r *http.Request) {
|
func handleMCPRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
var req JSONRPCRequest
|
var req JSONRPCRequest
|
||||||
@@ -193,6 +228,14 @@ func handleToolsList(w http.ResponseWriter, req JSONRPCRequest) {
|
|||||||
"required": []string{"title", "content", "images"},
|
"required": []string{"title", "content", "images"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "list_feeds",
|
||||||
|
"description": "获取小红书首页Feeds列表",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
@@ -220,6 +263,8 @@ func handleToolsCall(w http.ResponseWriter, r *http.Request, req JSONRPCRequest)
|
|||||||
result = handleCheckLoginStatus(ctx)
|
result = handleCheckLoginStatus(ctx)
|
||||||
case "publish_content":
|
case "publish_content":
|
||||||
result = handlePublishContent(ctx, toolCall.Arguments)
|
result = handlePublishContent(ctx, toolCall.Arguments)
|
||||||
|
case "list_feeds":
|
||||||
|
result = handleListFeeds(ctx)
|
||||||
default:
|
default:
|
||||||
logrus.Warnf("不支持的工具: %s", toolCall.Name)
|
logrus.Warnf("不支持的工具: %s", toolCall.Name)
|
||||||
sendJSONRPCError(w, req.ID, -32601, "Tool not found", nil)
|
sendJSONRPCError(w, req.ID, -32601, "Tool not found", nil)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ func setupRouter() *gin.Engine {
|
|||||||
api.GET("/login/status", checkLoginStatusHandler)
|
api.GET("/login/status", checkLoginStatusHandler)
|
||||||
|
|
||||||
api.POST("/publish", publishHandler)
|
api.POST("/publish", publishHandler)
|
||||||
|
|
||||||
|
// Feeds 相关路由
|
||||||
|
api.GET("/feeds/list", listFeedsHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
29
service.go
29
service.go
@@ -39,6 +39,12 @@ type PublishResponse struct {
|
|||||||
PostID string `json:"post_id,omitempty"`
|
PostID string `json:"post_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeedsListResponse Feeds列表响应
|
||||||
|
type FeedsListResponse struct {
|
||||||
|
Feeds []xiaohongshu.Feed `json:"feeds"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
// CheckLoginStatus 检查登录状态
|
// CheckLoginStatus 检查登录状态
|
||||||
func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatusResponse, error) {
|
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)
|
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
55
xiaohongshu/feeds.go
Normal 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
86
xiaohongshu/feeds_test.go
Normal 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
83
xiaohongshu/types.go
Normal 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"` // 视频时长,单位秒
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user