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 {