feat(profile): 主页支持按 tab 读取笔记 / 收藏 / 点赞 (#790)

get_my_profile 与 user_profile 新增 tab 参数:note(默认) / fav / liked,
也接受中文「笔记 / 收藏 / 点赞」。

各 tab 的内容存在各自的下标里,改为只取当前 tab 的那一份——原先把所有
下标展平合并,多 tab 场景下会把不同 tab 的内容混在一起。

tab 与请求不符时报错:参数不被接受时页面会落在默认 tab 且不报错,
不拦住会把默认 tab 的内容当成结果返回。

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zy
2026-08-02 16:00:14 +08:00
committed by GitHub
parent c17940fda6
commit d7f176a66f
7 changed files with 202 additions and 44 deletions
+2 -2
View File
@@ -206,7 +206,7 @@ func (s *AppServer) userProfileHandler(c *gin.Context) {
return
}
result, err := s.xiaohongshuService.UserProfile(c.Request.Context(), req.UserID, req.XsecToken)
result, err := s.xiaohongshuService.UserProfile(c.Request.Context(), req.UserID, req.XsecToken, req.Tab)
if err != nil {
respondError(c, http.StatusInternalServerError, "GET_USER_PROFILE_FAILED",
"获取用户主页失败", err.Error())
@@ -319,7 +319,7 @@ func healthHandler(c *gin.Context) {
// myProfileHandler 我的信息
func (s *AppServer) myProfileHandler(c *gin.Context) {
// 获取当前登录用户信息
result, err := s.xiaohongshuService.GetMyProfile(c.Request.Context())
result, err := s.xiaohongshuService.GetMyProfile(c.Request.Context(), c.Query("tab"))
if err != nil {
respondError(c, http.StatusInternalServerError, "GET_MY_PROFILE_FAILED",
"获取我的主页失败", err.Error())
+6 -4
View File
@@ -489,7 +489,9 @@ func (s *AppServer) handleUserProfile(ctx context.Context, args map[string]any)
logrus.Infof("MCP: 获取用户主页 - User ID: %s", userID)
result, err := s.xiaohongshuService.UserProfile(ctx, userID, xsecToken)
tab, _ := args["tab"].(string)
result, err := s.xiaohongshuService.UserProfile(ctx, userID, xsecToken, tab)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
@@ -722,10 +724,10 @@ func (s *AppServer) handleReplyComment(ctx context.Context, args map[string]inte
}
// handleGetMyProfile 获取当前登录用户主页
func (s *AppServer) handleGetMyProfile(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 获取我的主页")
func (s *AppServer) handleGetMyProfile(ctx context.Context, tab string) *MCPToolResult {
logrus.Infof("MCP: 获取我的主页 tab=%s", tab)
result, err := s.xiaohongshuService.GetMyProfile(ctx)
result, err := s.xiaohongshuService.GetMyProfile(ctx, tab)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
+11 -4
View File
@@ -68,6 +68,12 @@ type FeedDetailArgs struct {
type UserProfileArgs struct {
UserID string `json:"user_id" jsonschema:"小红书用户ID,从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
Tab string `json:"tab,omitempty" jsonschema:"主页 tab: note(笔记,默认)|fav(收藏)|liked(点赞)。收藏和点赞可能被对方设为不公开"`
}
// MyProfileArgs 我的主页参数
type MyProfileArgs struct {
Tab string `json:"tab,omitempty" jsonschema:"主页 tab: note(笔记,默认)|fav(收藏)|liked(点赞)"`
}
// PostCommentArgs 发表评论的参数
@@ -327,7 +333,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
mcp.AddTool(server,
&mcp.Tool{
Name: "user_profile",
Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量,以及指定 tab 下的内容。tab 可选 note(笔记,默认)、fav(收藏)、liked(点赞),后两者可能被对方设为不公开",
Annotations: &mcp.ToolAnnotations{
Title: "User Profile",
ReadOnlyHint: true,
@@ -337,6 +343,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
argsMap := map[string]interface{}{
"user_id": args.UserID,
"xsec_token": args.XsecToken,
"tab": args.Tab,
}
result := appServer.handleUserProfile(ctx, argsMap)
return convertToMCPResult(result), nil, nil
@@ -465,14 +472,14 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
mcp.AddTool(server,
&mcp.Tool{
Name: "get_my_profile",
Description: "获取当前登录用户的主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
Description: "获取当前登录用户的主页,返回用户基本信息,关注、粉丝、获赞量,以及指定 tab 下的内容。tab 可选 note(自己发的笔记,默认)、fav(自己收藏的)、liked(自己点赞的)",
Annotations: &mcp.ToolAnnotations{
Title: "Get My Profile",
ReadOnlyHint: true,
},
},
withPanicRecovery("get_my_profile", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleGetMyProfile(ctx)
withPanicRecovery("get_my_profile", func(ctx context.Context, req *mcp.CallToolRequest, args MyProfileArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleGetMyProfile(ctx, args.Tab)
return convertToMCPResult(result), nil, nil
}),
)
+14 -5
View File
@@ -442,7 +442,12 @@ func (s *XiaohongshuService) GetFeedDetailWithConfig(ctx context.Context, feedID
}
// UserProfile 获取用户信息
func (s *XiaohongshuService) UserProfile(ctx context.Context, userID, xsecToken string) (*UserProfileResponse, error) {
func (s *XiaohongshuService) UserProfile(ctx context.Context, userID, xsecToken, tab string) (*UserProfileResponse, error) {
parsed, err := xiaohongshu.ParseProfileTab(tab)
if err != nil {
return nil, err
}
b := newBrowser()
defer b.Close()
@@ -451,7 +456,7 @@ func (s *XiaohongshuService) UserProfile(ctx context.Context, userID, xsecToken
action := xiaohongshu.NewUserProfileAction(page)
result, err := action.UserProfile(ctx, userID, xsecToken)
result, err := action.UserProfile(ctx, userID, xsecToken, parsed)
if err != nil {
return nil, err
}
@@ -648,13 +653,17 @@ func withBrowserPage(fn func(*rod.Page) error) error {
}
// GetMyProfile 获取当前登录用户的个人信息
func (s *XiaohongshuService) GetMyProfile(ctx context.Context) (*UserProfileResponse, error) {
func (s *XiaohongshuService) GetMyProfile(ctx context.Context, tab string) (*UserProfileResponse, error) {
parsed, err := xiaohongshu.ParseProfileTab(tab)
if err != nil {
return nil, err
}
var result *xiaohongshu.UserProfileResponse
var err error
err = withBrowserPage(func(page *rod.Page) error {
action := xiaohongshu.NewUserProfileAction(page)
result, err = action.GetMyProfileViaSidebar(ctx)
result, err = action.GetMyProfileViaSidebar(ctx, parsed)
return err
})
+1
View File
@@ -101,6 +101,7 @@ type ReplyCommentResponse struct {
type UserProfileRequest struct {
UserID string `json:"user_id" binding:"required"`
XsecToken string `json:"xsec_token" binding:"required"`
Tab string `json:"tab,omitempty"`
}
// LikeFeedRequest 点赞/取消点赞请求
+102 -29
View File
@@ -4,11 +4,42 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-rod/rod"
"github.com/xpzouying/xiaohongshu-mcp/humanize"
)
// ProfileTab 个人主页的子 tab。
type ProfileTab string
const (
TabNotes ProfileTab = "note"
TabFavorites ProfileTab = "fav"
TabLiked ProfileTab = "liked"
)
// ParseProfileTab 解析 tab 名,空值默认为「笔记」。
func ParseProfileTab(s string) (ProfileTab, error) {
switch strings.TrimSpace(strings.ToLower(s)) {
case "", "note", "notes", "笔记":
return TabNotes, nil
case "fav", "favorites", "favorite", "收藏":
return TabFavorites, nil
case "liked", "like", "点赞":
return TabLiked, nil
}
return "", fmt.Errorf("未知的主页 tab %q,可选:note / fav / liked", s)
}
// tabLabel 子 tab 对应的页面文字。
var tabLabel = map[ProfileTab]string{
TabNotes: "笔记",
TabFavorites: "收藏",
TabLiked: "点赞",
}
type UserProfileAction struct {
page *rod.Page
}
@@ -18,19 +49,19 @@ func NewUserProfileAction(page *rod.Page) *UserProfileAction {
return &UserProfileAction{page: pp}
}
// UserProfile 获取用户基本信息及帖子
func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken string) (*UserProfileResponse, error) {
// UserProfile 获取用户基本信息及指定 tab 下的帖子
func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken string, tab ProfileTab) (*UserProfileResponse, error) {
page := u.page.Context(ctx).Timeout(60 * time.Second) // 重设被 .Context 清掉的 deadline
searchURL := makeUserProfileURL(userID, xsecToken)
searchURL := makeUserProfileURL(userID, xsecToken, tab)
page.MustNavigate(searchURL)
page.MustWaitStable()
return u.extractUserProfileData(page)
return u.extractUserProfileData(page, tab)
}
// extractUserProfileData 从页面中提取用户资料数据的通用方法
func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfileResponse, error) {
func (u *UserProfileAction) extractUserProfileData(page *rod.Page, tab ProfileTab) (*UserProfileResponse, error) {
page.MustWait(`() => window.__INITIAL_STATE__ !== undefined`)
userDataResult := page.MustEval(`() => {
@@ -50,19 +81,15 @@ func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfile
return nil, fmt.Errorf("user.userPageData.value not found in __INITIAL_STATE__")
}
// 2. 获取用户帖子:window.__INITIAL_STATE__.user.notes.value
// 2. 获取用户帖子及当前 tabwindow.__INITIAL_STATE__.user
notesResult := page.MustEval(`() => {
if (window.__INITIAL_STATE__ &&
window.__INITIAL_STATE__.user &&
window.__INITIAL_STATE__.user.notes) {
const notes = window.__INITIAL_STATE__.user.notes;
// 优先使用 valuegetter),如果不存在则使用 _value(内部字段)
const data = notes.value !== undefined ? notes.value : notes._value;
if (data) {
return JSON.stringify(data);
}
}
return "";
const u = window.__INITIAL_STATE__ && window.__INITIAL_STATE__.user;
if (!u || !u.notes) return "";
const unwrap = (o) => (o && o.value !== undefined) ? o.value : (o && o._value);
const notes = unwrap(u.notes);
if (!notes) return "";
const active = unwrap(u.activeTab) || {};
return JSON.stringify({notes: notes, index: active.index || 0, query: active.query || ""});
}`).String()
if notesResult == "" {
@@ -78,33 +105,47 @@ func (u *UserProfileAction) extractUserProfileData(page *rod.Page) (*UserProfile
return nil, fmt.Errorf("failed to unmarshal userPageData: %w", err)
}
// 解析帖子数据(帖子为双重数组)
var notesFeeds [][]Feed
if err := json.Unmarshal([]byte(notesResult), &notesFeeds); err != nil {
var notesData struct {
Notes [][]Feed `json:"notes"`
Index int `json:"index"`
Query string `json:"query"`
}
if err := json.Unmarshal([]byte(notesResult), &notesData); err != nil {
return nil, fmt.Errorf("failed to unmarshal notes: %w", err)
}
// tab 不符时报错,避免把别的 tab 的内容当成结果返回
want := tab
if want == "" {
want = TabNotes
}
if notesData.Query != "" && ProfileTab(notesData.Query) != want {
return nil, fmt.Errorf("当前 tab 为 %q,与请求的 %q 不符", notesData.Query, want)
}
// 组装响应
response := &UserProfileResponse{
UserBasicInfo: userPageData.BasicInfo,
Interactions: userPageData.Interactions,
}
// 添加用户帖子(展平双重数组)
for _, feeds := range notesFeeds {
if len(feeds) != 0 {
response.Feeds = append(response.Feeds, feeds...)
}
// 每个 tab 的内容存在各自的下标里,只取当前 tab 的,避免混入其他 tab
if notesData.Index >= 0 && notesData.Index < len(notesData.Notes) {
response.Feeds = append(response.Feeds, notesData.Notes[notesData.Index]...)
}
return response, nil
}
func makeUserProfileURL(userID, xsecToken string) string {
return fmt.Sprintf("https://www.xiaohongshu.com/user/profile/%s?xsec_token=%s&xsec_source=pc_note", userID, xsecToken)
func makeUserProfileURL(userID, xsecToken string, tab ProfileTab) string {
url := fmt.Sprintf("https://www.xiaohongshu.com/user/profile/%s?xsec_token=%s&xsec_source=pc_note", userID, xsecToken)
if tab != "" && tab != TabNotes {
url += fmt.Sprintf("&tab=%s&subTab=note", tab)
}
return url
}
func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context) (*UserProfileResponse, error) {
func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context, tab ProfileTab) (*UserProfileResponse, error) {
page := u.page.Context(ctx).Timeout(60 * time.Second) // 重设被 .Context 清掉的 deadline
// 创建导航动作
@@ -118,5 +159,37 @@ func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context) (*UserPr
// 等待页面加载完成并获取 __INITIAL_STATE__
page.MustWaitStable()
return u.extractUserProfileData(page)
if err := u.selectTab(ctx, page, tab); err != nil {
return nil, err
}
return u.extractUserProfileData(page, tab)
}
// selectTab 切到目标子 tab。「笔记」是默认 tab,无需点击。
func (u *UserProfileAction) selectTab(ctx context.Context, page *rod.Page, tab ProfileTab) error {
if tab == "" || tab == TabNotes {
return nil
}
label := tabLabel[tab]
elems, err := page.Elements(`.reds-tab-item.sub-tab-list`)
if err != nil {
return fmt.Errorf("未找到主页子 tab: %w", err)
}
for _, elem := range elems {
text, err := elem.Text()
if err != nil || strings.TrimSpace(text) != label {
continue
}
humanize.Delay(ctx, humanize.BeforeClick)
if err := humanize.Click(elem); err != nil {
return fmt.Errorf("切换到 %s 失败: %w", label, err)
}
humanize.Delay(ctx, humanize.AfterClick)
page.MustWaitStable()
return nil
}
return fmt.Errorf("未找到子 tab %q", label)
}
+66
View File
@@ -0,0 +1,66 @@
package xiaohongshu
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseProfileTab(t *testing.T) {
cases := []struct {
in string
want ProfileTab
wantErr bool
}{
{"", TabNotes, false},
{"note", TabNotes, false},
{"notes", TabNotes, false},
{"笔记", TabNotes, false},
{"fav", TabFavorites, false},
{"FAVORITES", TabFavorites, false},
{"收藏", TabFavorites, false},
{" liked ", TabLiked, false},
{"like", TabLiked, false},
{"点赞", TabLiked, false},
{"unknown", "", true},
{"favourite", "", true},
}
for _, c := range cases {
got, err := ParseProfileTab(c.in)
if c.wantErr {
assert.Error(t, err, "input=%q", c.in)
continue
}
require.NoError(t, err, "input=%q", c.in)
assert.Equal(t, c.want, got, "input=%q", c.in)
}
}
func TestMakeUserProfileURL(t *testing.T) {
base := makeUserProfileURL("uid1", "tok1", TabNotes)
assert.Contains(t, base, "/user/profile/uid1")
assert.Contains(t, base, "xsec_token=tok1")
assert.NotContains(t, base, "tab=", "默认 tab 不应带 tab 参数")
// 空值等同默认
assert.Equal(t, base, makeUserProfileURL("uid1", "tok1", ""))
fav := makeUserProfileURL("uid1", "tok1", TabFavorites)
assert.Contains(t, fav, "tab=fav")
assert.Contains(t, fav, "subTab=note")
liked := makeUserProfileURL("uid1", "tok1", TabLiked)
assert.Contains(t, liked, "tab=liked")
}
// tabLabel 要覆盖全部 tab,缺一个会让切换时找不到目标而报错。
func TestTabLabelCoverage(t *testing.T) {
for _, tab := range []ProfileTab{TabNotes, TabFavorites, TabLiked} {
label, ok := tabLabel[tab]
assert.True(t, ok, "tab %q 缺少页面文字", tab)
assert.NotEmpty(t, strings.TrimSpace(label))
}
}