6
errors/errors.go
Normal file
6
errors/errors.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNoFeeds = errors.New("没有捕获到 feeds 数据")
|
||||||
|
var ErrNoFeedDetail = errors.New("没有捕获到 feed 详情数据")
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -177,7 +178,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
mcp.AddTool(server,
|
mcp.AddTool(server,
|
||||||
&mcp.Tool{
|
&mcp.Tool{
|
||||||
Name: "user_profile",
|
Name: "user_profile",
|
||||||
Description: "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
|
Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
|
||||||
},
|
},
|
||||||
func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) {
|
func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -13,8 +16,6 @@ import (
|
|||||||
"github.com/xpzouying/xiaohongshu-mcp/cookies"
|
"github.com/xpzouying/xiaohongshu-mcp/cookies"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
|
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// XiaohongshuService 小红书业务服务
|
// XiaohongshuService 小红书业务服务
|
||||||
@@ -284,6 +285,7 @@ func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse,
|
|||||||
// 获取 Feeds 列表
|
// 获取 Feeds 列表
|
||||||
feeds, err := action.GetFeedsList(ctx)
|
feeds, err := action.GetFeedsList(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logrus.Errorf("获取 Feeds 列表失败: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FeedDetailAction 表示 Feed 详情页动作
|
// FeedDetailAction 表示 Feed 详情页动作
|
||||||
@@ -26,39 +28,37 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
|
|||||||
// 构建详情页 URL
|
// 构建详情页 URL
|
||||||
url := makeFeedDetailURL(feedID, xsecToken)
|
url := makeFeedDetailURL(feedID, xsecToken)
|
||||||
|
|
||||||
|
logrus.Infof("打开 feed 详情页: %s", url)
|
||||||
|
|
||||||
// 导航到详情页
|
// 导航到详情页
|
||||||
page.MustNavigate(url)
|
page.MustNavigate(url)
|
||||||
page.MustWaitDOMStable()
|
page.MustWaitDOMStable()
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.note &&
|
||||||
|
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||||
|
const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap;
|
||||||
|
return JSON.stringify(noteDetailMap);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义响应结构并直接反序列化
|
var noteDetailMap map[string]struct {
|
||||||
var initialState struct {
|
|
||||||
Note struct {
|
|
||||||
NoteDetailMap map[string]struct {
|
|
||||||
Note FeedDetail `json:"note"`
|
Note FeedDetail `json:"note"`
|
||||||
Comments CommentList `json:"comments"`
|
Comments CommentList `json:"comments"`
|
||||||
} `json:"noteDetailMap"`
|
|
||||||
} `json:"note"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(result), &initialState); err != nil {
|
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal noteDetailMap: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 noteDetailMap 中获取对应 feedID 的数据
|
noteDetail, exists := noteDetailMap[feedID]
|
||||||
noteDetail, exists := initialState.Note.NoteDetailMap[feedID]
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID)
|
return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeedsListAction struct {
|
type FeedsListAction struct {
|
||||||
page *rod.Page
|
page *rod.Page
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedsResult 定义页面初始状态结构
|
|
||||||
type FeedsResult struct {
|
|
||||||
Feed FeedData `json:"feed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFeedsListAction(page *rod.Page) *FeedsListAction {
|
func NewFeedsListAction(page *rod.Page) *FeedsListAction {
|
||||||
pp := page.Timeout(60 * time.Second)
|
pp := page.Timeout(60 * time.Second)
|
||||||
|
|
||||||
@@ -33,24 +29,27 @@ func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) {
|
|||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(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 "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeeds
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析完整的 InitialState
|
var feeds []Feed
|
||||||
var state FeedsResult
|
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 feed.feeds._value
|
return feeds, nil
|
||||||
return state.Feed.Feeds.Value, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
myerrors "github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActionResult 通用动作响应(点赞/收藏等)
|
// ActionResult 通用动作响应(点赞/收藏等)
|
||||||
@@ -213,33 +214,33 @@ func (a *FavoriteAction) toggleFavorite(page *rod.Page, feedID string, targetCol
|
|||||||
|
|
||||||
// getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态
|
// getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态
|
||||||
func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) {
|
func (a *interactAction) getInteractState(page *rod.Page, feedID string) (liked bool, collected bool, err error) {
|
||||||
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
window.__INITIAL_STATE__.note &&
|
||||||
|
window.__INITIAL_STATE__.note.noteDetailMap) {
|
||||||
|
return JSON.stringify(window.__INITIAL_STATE__.note.noteDetailMap);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return false, false, fmt.Errorf("__INITIAL_STATE__ not found")
|
return false, false, myerrors.ErrNoFeedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
var state struct {
|
// 直接解析为 noteDetailMap
|
||||||
Note struct {
|
var noteDetailMap map[string]struct {
|
||||||
NoteDetailMap map[string]struct {
|
|
||||||
Note struct {
|
Note struct {
|
||||||
InteractInfo struct {
|
InteractInfo struct {
|
||||||
Liked bool `json:"liked"`
|
Liked bool `json:"liked"`
|
||||||
Collected bool `json:"collected"`
|
Collected bool `json:"collected"`
|
||||||
} `json:"interactInfo"`
|
} `json:"interactInfo"`
|
||||||
} `json:"note"`
|
} `json:"note"`
|
||||||
} `json:"noteDetailMap"`
|
|
||||||
} `json:"note"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(result), &state); err != nil {
|
if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil {
|
||||||
return false, false, errors.Wrap(err, "unmarshal __INITIAL_STATE__ failed")
|
return false, false, errors.Wrap(err, "unmarshal noteDetailMap failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, ok := state.Note.NoteDetailMap[feedID]
|
detail, ok := noteDetailMap[feedID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false, fmt.Errorf("feed %s not in noteDetailMap", feedID)
|
return false, false, fmt.Errorf("feed %s not in noteDetailMap", feedID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -190,24 +191,29 @@ func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...Fi
|
|||||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
|
||||||
result := page.MustEval(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__) {
|
if (window.__INITIAL_STATE__ &&
|
||||||
return JSON.stringify(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 "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, errors.ErrNoFeeds
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResult SearchResult
|
var feeds []Feed
|
||||||
if err := json.Unmarshal([]byte(result), &searchResult); err != nil {
|
if err := json.Unmarshal([]byte(result), &feeds); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal feeds: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchResult.Search.Feeds.Value, nil
|
return feeds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeSearchURL(keyword string) string {
|
func makeSearchURL(keyword string) string {
|
||||||
|
|||||||
@@ -33,42 +33,71 @@ func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken s
|
|||||||
func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) {
|
func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) {
|
||||||
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
|
||||||
|
|
||||||
// 获取 window.__INITIAL_STATE__ 并转换为 JSON 字符串
|
userDataResult := page.MustEval(`() => {
|
||||||
result := page.MustEval(`() => {
|
if (window.__INITIAL_STATE__ &&
|
||||||
if (window.__INITIAL_STATE__) {
|
window.__INITIAL_STATE__.user &&
|
||||||
return JSON.stringify(window.__INITIAL_STATE__);
|
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 "";
|
return "";
|
||||||
}`).String()
|
}`).String()
|
||||||
|
|
||||||
if result == "" {
|
if userDataResult == "" {
|
||||||
return nil, fmt.Errorf("__INITIAL_STATE__ not found")
|
return nil, fmt.Errorf("user.userPageData.value not found in __INITIAL_STATE__")
|
||||||
}
|
}
|
||||||
// 定义响应结构并直接反序列化
|
|
||||||
var initialState = struct {
|
// 2. 获取用户帖子:window.__INITIAL_STATE__.user.notes.value
|
||||||
User struct {
|
notesResult := page.MustEval(`() => {
|
||||||
UserPageData UserPageData `json:"userPageData"`
|
if (window.__INITIAL_STATE__ &&
|
||||||
Notes struct {
|
window.__INITIAL_STATE__.user &&
|
||||||
Feeds [][]Feed `json:"_rawValue"` // 帖子为双重数组
|
window.__INITIAL_STATE__.user.notes) {
|
||||||
} `json:"notes"`
|
const notes = window.__INITIAL_STATE__.user.notes;
|
||||||
} `json:"user"`
|
// 优先使用 value(getter),如果不存在则使用 _value(内部字段)
|
||||||
}{}
|
const data = notes.value !== undefined ? notes.value : notes._value;
|
||||||
if err := json.Unmarshal([]byte(result), &initialState); err != nil {
|
if (data) {
|
||||||
return nil, fmt.Errorf("failed to unmarshal __INITIAL_STATE__: %w", err)
|
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{
|
response := &UserProfileResponse{
|
||||||
UserBasicInfo: initialState.User.UserPageData.RawValue.BasicInfo,
|
UserBasicInfo: userPageData.BasicInfo,
|
||||||
Interactions: initialState.User.UserPageData.RawValue.Interactions,
|
Interactions: userPageData.Interactions,
|
||||||
}
|
}
|
||||||
// 添加用户贴子
|
|
||||||
for _, feeds := range initialState.User.Notes.Feeds {
|
// 添加用户帖子(展平双重数组)
|
||||||
|
for _, feeds := range notesFeeds {
|
||||||
if len(feeds) != 0 {
|
if len(feeds) != 0 {
|
||||||
response.Feeds = append(response.Feeds, feeds...)
|
response.Feeds = append(response.Feeds, feeds...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUserProfileURL(userID, xsecToken string) string {
|
func makeUserProfileURL(userID, xsecToken string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user