Merge pull request #322 from haikow/feature/comment-feed-logic

Feature/comment feed logic
This commit is contained in:
zy
2025-12-07 18:02:39 +08:00
committed by GitHub
10 changed files with 415 additions and 23 deletions

2
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/xpzouying/xiaohongshu-mcp
go 1.24.0
require (
github.com/avast/retry-go/v4 v4.7.0
github.com/gin-gonic/gin v1.10.1
github.com/go-rod/rod v0.116.2
github.com/h2non/filetype v1.1.3
@@ -15,7 +16,6 @@ require (
)
require (
github.com/avast/retry-go/v4 v4.7.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect

2
go.sum
View File

@@ -79,8 +79,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=

View File

@@ -250,6 +250,26 @@ func (s *AppServer) postCommentHandler(c *gin.Context) {
respondSuccess(c, result, result.Message)
}
// replyCommentHandler 回复指定评论
func (s *AppServer) replyCommentHandler(c *gin.Context) {
var req ReplyCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_REQUEST",
"请求参数错误", err.Error())
return
}
result, err := s.xiaohongshuService.ReplyCommentToFeed(c.Request.Context(), req.FeedID, req.XsecToken, req.CommentID, req.UserID, req.Content)
if err != nil {
respondError(c, http.StatusInternalServerError, "REPLY_COMMENT_FAILED",
"回复评论失败", err.Error())
return
}
c.Set("account", "ai-report")
respondSuccess(c, result, result.Message)
}
// healthHandler 健康检查
func healthHandler(c *gin.Context) {
respondSuccess(c, map[string]any{

View File

@@ -622,3 +622,77 @@ func (s *AppServer) handlePostComment(ctx context.Context, args map[string]inter
}},
}
}
// handleReplyComment 处理回复评论
func (s *AppServer) handleReplyComment(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 回复评论")
// 解析参数
feedID, ok := args["feed_id"].(string)
if !ok || feedID == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "回复评论失败: 缺少feed_id参数",
}},
IsError: true,
}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "回复评论失败: 缺少xsec_token参数",
}},
IsError: true,
}
}
commentID, _ := args["comment_id"].(string)
userID, _ := args["user_id"].(string)
if commentID == "" && userID == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "回复评论失败: 缺少comment_id或user_id参数",
}},
IsError: true,
}
}
content, ok := args["content"].(string)
if !ok || content == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "回复评论失败: 缺少content参数",
}},
IsError: true,
}
}
logrus.Infof("MCP: 回复评论 - Feed ID: %s, Comment ID: %s, User ID: %s, 内容长度: %d", feedID, commentID, userID, len(content))
// 回复评论
result, err := s.xiaohongshuService.ReplyCommentToFeed(ctx, feedID, xsecToken, commentID, userID, content)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "回复评论失败: " + err.Error(),
}},
IsError: true,
}
}
// 返回成功结果
responseText := fmt.Sprintf("评论回复成功 - Feed ID: %s, Comment ID: %s, User ID: %s", result.FeedID, result.TargetCommentID, result.TargetUserID)
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: responseText,
}},
}
}

View File

@@ -47,10 +47,10 @@ type FilterOption struct {
type FeedDetailArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌从Feed列表的xsecToken字段获取"`
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论默认false仅返回首批评论"`
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论默认false仅返回首批前十条一级评论)"`
ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"是否点击'更多回复'按钮 (默认: false)"`
MaxRepliesThreshold int `json:"max_replies_threshold,omitempty" jsonschema:"回复数量阈值,超过此数量的'更多'按钮将被跳过 (0表示不跳过任何, 默认: 10)"`
MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载评论数0表示加载所有, 默认: 0"`
MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载一级评论数0表示加载所有一级评论, 默认: 0"`
ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"滚动速度: 'slow'|'normal'|'fast' (默认: 'normal')"`
}
@@ -67,6 +67,15 @@ type PostCommentArgs struct {
Content string `json:"content" jsonschema:"评论内容"`
}
// ReplyCommentArgs 回复评论的参数
type ReplyCommentArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌从Feed列表的xsecToken字段获取"`
CommentID string `json:"comment_id,omitempty" jsonschema:"目标评论ID从评论列表获取"`
UserID string `json:"user_id,omitempty" jsonschema:"目标评论用户ID从评论列表获取"`
Content string `json:"content" jsonschema:"回复内容"`
}
// LikeFeedArgs 点赞参数
type LikeFeedArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID从Feed列表获取"`
@@ -267,7 +276,33 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}),
)
// 工具 10: 发布视频(仅本地文件)
// 工具 10: 回复评论
mcp.AddTool(server,
&mcp.Tool{
Name: "reply_comment_in_feed",
Description: "回复小红书笔记下的指定评论",
},
func(ctx context.Context, req *mcp.CallToolRequest, args ReplyCommentArgs) (*mcp.CallToolResult, any, error) {
if args.CommentID == "" && args.UserID == "" {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: "缺少 comment_id 或 user_id"}},
}, nil, nil
}
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
"comment_id": args.CommentID,
"user_id": args.UserID,
"content": args.Content,
}
result := appServer.handleReplyComment(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 11: 发布视频(仅本地文件)
mcp.AddTool(server,
&mcp.Tool{
Name: "publish_with_video",
@@ -285,7 +320,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}),
)
// 工具 11: 点赞笔记
// 工具 12: 点赞笔记
mcp.AddTool(server,
&mcp.Tool{
Name: "like_feed",
@@ -302,7 +337,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}),
)
// 工具 12: 收藏笔记
// 工具 13: 收藏笔记
mcp.AddTool(server,
&mcp.Tool{
Name: "favorite_feed",

View File

@@ -49,6 +49,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
api.POST("/user/profile", appServer.userProfileHandler)
api.POST("/feeds/comment", appServer.postCommentHandler)
api.POST("/feeds/comment/reply", appServer.replyCommentHandler)
api.GET("/user/me", appServer.myProfileHandler)
}

View File

@@ -458,6 +458,29 @@ func (s *XiaohongshuService) UnfavoriteFeed(ctx context.Context, feedID, xsecTok
return &ActionResult{FeedID: feedID, Success: true, Message: "取消收藏成功或未收藏"}, nil
}
// ReplyCommentToFeed 回复指定评论
func (s *XiaohongshuService) ReplyCommentToFeed(ctx context.Context, feedID, xsecToken, commentID, userID, content string) (*ReplyCommentResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewCommentFeedAction(page)
if err := action.ReplyToComment(ctx, feedID, xsecToken, commentID, userID, content); err != nil {
return nil, err
}
return &ReplyCommentResponse{
FeedID: feedID,
TargetCommentID: commentID,
TargetUserID: userID,
Success: true,
Message: "评论回复成功",
}, nil
}
func newBrowser() *headless_browser.Browser {
return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath()))
}

View File

@@ -40,7 +40,7 @@ type CommentLoadConfig struct {
ClickMoreReplies bool `json:"click_more_replies,omitempty"`
// 回复数量阈值,超过这个数量的"更多"按钮将被跳过0表示不跳过任何
MaxRepliesThreshold int `json:"max_replies_threshold,omitempty"`
// 最大加载评论数comment-item数量0表示加载所有
// 最大加载评论数(.parent-comment数量0表示加载所有
MaxCommentItems int `json:"max_comment_items,omitempty"`
// 滚动速度等级: slow(慢速), normal(正常), fast(快速)
ScrollSpeed string `json:"scroll_speed,omitempty"`
@@ -79,6 +79,24 @@ type PostCommentResponse struct {
Message string `json:"message"`
}
// ReplyCommentRequest 回复评论请求
type ReplyCommentRequest struct {
FeedID string `json:"feed_id" binding:"required"`
XsecToken string `json:"xsec_token" binding:"required"`
CommentID string `json:"comment_id" binding:"required_without=UserID"`
UserID string `json:"user_id" binding:"required_without=CommentID"`
Content string `json:"content" binding:"required"`
}
// ReplyCommentResponse 回复评论响应
type ReplyCommentResponse struct {
FeedID string `json:"feed_id"`
TargetCommentID string `json:"target_comment_id,omitempty"`
TargetUserID string `json:"target_user_id,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
}
// UserProfileRequest 用户主页请求
type UserProfileRequest struct {
UserID string `json:"user_id" binding:"required"`

View File

@@ -2,9 +2,11 @@ package xiaohongshu
import (
"context"
"fmt"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/sirupsen/logrus"
)
@@ -20,31 +22,252 @@ func NewCommentFeedAction(page *rod.Page) *CommentFeedAction {
// PostComment 发表评论到 Feed
func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, content string) error {
page := f.page.Context(ctx).Timeout(60 * time.Second)
// 不使用 Context(ctx),避免继承外部 context 的超时
page := f.page.Timeout(60 * time.Second)
// 构建详情页 URL
url := makeFeedDetailURL(feedID, xsecToken)
logrus.Infof("Opening feed detail page: %s", url)
logrus.Infof("打开 feed 详情页: %s", url)
// 导航到详情页
page.MustNavigate(url)
page.MustWaitDOMStable()
time.Sleep(1 * time.Second)
// 检测页面是否可访问
if err := checkPageAccessible(page); err != nil {
return err
}
elem, err := page.Element("div.input-box div.content-edit span")
if err != nil {
logrus.Warnf("Failed to find comment input box: %v", err)
return fmt.Errorf("未找到评论输入框,该帖子可能不支持评论或网页端不可访问: %w", err)
}
if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil {
logrus.Warnf("Failed to click comment input box: %v", err)
return fmt.Errorf("无法点击评论输入框: %w", err)
}
elem2, err := page.Element("div.input-box div.content-edit p.content-input")
if err != nil {
logrus.Warnf("Failed to find comment input field: %v", err)
return fmt.Errorf("未找到评论输入区域: %w", err)
}
if err := elem2.Input(content); err != nil {
logrus.Warnf("Failed to input comment content: %v", err)
return fmt.Errorf("无法输入评论内容: %w", err)
}
time.Sleep(1 * time.Second)
elem := page.MustElement("div.input-box div.content-edit span")
elem.MustClick()
submitButton, err := page.Element("div.bottom button.submit")
if err != nil {
logrus.Warnf("Failed to find submit button: %v", err)
return fmt.Errorf("未找到提交按钮: %w", err)
}
elem2 := page.MustElement("div.input-box div.content-edit p.content-input")
elem2.MustInput(content)
time.Sleep(1 * time.Second)
submitButton := page.MustElement("div.bottom button.submit")
submitButton.MustClick()
if err := submitButton.Click(proto.InputMouseButtonLeft, 1); err != nil {
logrus.Warnf("Failed to click submit button: %v", err)
return fmt.Errorf("无法点击提交按钮: %w", err)
}
time.Sleep(1 * time.Second)
logrus.Infof("Comment posted successfully to feed: %s", feedID)
return nil
}
// ReplyToComment 回复指定评论
func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToken, commentID, userID, content string) error {
// 增加超时时间,因为需要滚动查找评论
// 注意:不使用 Context(ctx),避免继承外部 context 的超时
page := f.page.Timeout(5 * time.Minute)
url := makeFeedDetailURL(feedID, xsecToken)
logrus.Infof("打开 feed 详情页进行回复: %s", url)
// 导航到详情页
page.MustNavigate(url)
page.MustWaitDOMStable()
time.Sleep(1 * time.Second)
// 检测页面是否可访问
if err := checkPageAccessible(page); err != nil {
return err
}
// 等待评论容器加载
time.Sleep(2 * time.Second)
// 使用 Go 实现的查找逻辑
commentEl, err := findCommentElement(page, commentID, userID)
if err != nil {
return fmt.Errorf("无法找到评论: %w", err)
}
// 滚动到评论位置
logrus.Info("滚动到评论位置...")
commentEl.MustScrollIntoView()
time.Sleep(1 * time.Second)
logrus.Info("准备点击回复按钮")
// 查找并点击回复按钮
replyBtn, err := commentEl.Element(".right .interactions .reply")
if err != nil {
return fmt.Errorf("无法找到回复按钮: %w", err)
}
if err := replyBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击回复按钮失败: %w", err)
}
time.Sleep(1 * time.Second)
// 查找回复输入框
inputEl, err := page.Element("div.input-box div.content-edit p.content-input")
if err != nil {
return fmt.Errorf("无法找到回复输入框: %w", err)
}
// 输入内容
if err := inputEl.Input(content); err != nil {
return fmt.Errorf("输入回复内容失败: %w", err)
}
time.Sleep(500 * time.Millisecond)
// 查找并点击提交按钮
submitBtn, err := page.Element("div.bottom button.submit")
if err != nil {
return fmt.Errorf("无法找到提交按钮: %w", err)
}
if err := submitBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击提交按钮失败: %w", err)
}
time.Sleep(2 * time.Second)
logrus.Infof("回复评论成功")
return nil
}
// findCommentElement 查找指定评论元素(参考 feed_detail.go 的滚动逻辑)
func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) {
logrus.Infof("开始查找评论 - commentID: %s, userID: %s", commentID, userID)
const maxAttempts = 100
const scrollInterval = 800 * time.Millisecond
// 先滚动到评论区
scrollToCommentsArea(page)
time.Sleep(1 * time.Second)
var lastCommentCount = 0
stagnantChecks := 0
logrus.Infof("开始循环查找,最大尝试次数: %d", maxAttempts)
for attempt := 0; attempt < maxAttempts; attempt++ {
logrus.Infof("=== 查找尝试 %d/%d ===", attempt+1, maxAttempts)
// === 1. 检查是否到达底部 ===
if checkEndContainer(page) {
logrus.Info("已到达评论底部,未找到目标评论")
break
}
// === 2. 获取当前评论数量 ===
currentCount := getCommentCount(page)
logrus.Infof("当前评论数: %d", currentCount)
if currentCount != lastCommentCount {
logrus.Infof("✓ 评论数增加: %d -> %d", lastCommentCount, currentCount)
lastCommentCount = currentCount
stagnantChecks = 0
} else {
stagnantChecks++
if stagnantChecks%5 == 0 {
logrus.Infof("评论数停滞 %d 次", stagnantChecks)
}
}
// === 3. 停滞检测 ===
if stagnantChecks >= 10 {
logrus.Info("评论数量停滞超过10次可能已加载完所有评论")
break
}
// === 4. 先滚动到最后一个评论(触发懒加载)===
if currentCount > 0 {
logrus.Infof("滚动到最后一个评论(共 %d 条)", currentCount)
// 使用 Go 获取所有评论元素
elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment, .comment-item, .comment")
if err == nil && len(elements) > 0 {
// 滚动到最后一个评论
lastComment := elements[len(elements)-1]
err := lastComment.ScrollIntoView()
if err != nil {
logrus.Warnf("滚动到最后一个评论失败: %v", err)
}
} else {
logrus.Warnf("未找到评论元素: %v", err)
}
time.Sleep(300 * time.Millisecond)
}
// === 5. 继续向下滚动 ===
logrus.Infof("继续向下滚动...")
_, err := page.Eval(`() => { window.scrollBy(0, window.innerHeight * 0.8); return true; }`)
if err != nil {
logrus.Warnf("滚动失败: %v", err)
}
time.Sleep(500 * time.Millisecond)
// === 6. 滚动后立即查找(边滚动边查找)===
// 优先通过 commentID 查找(使用 Timeout 避免长时间等待)
if commentID != "" {
selector := fmt.Sprintf("#comment-%s", commentID)
logrus.Infof("尝试通过 commentID 查找: %s", selector)
// 使用 Timeout 避免长时间等待
el, err := page.Timeout(2 * time.Second).Element(selector)
if err == nil && el != nil {
logrus.Infof("✓ 通过 commentID 找到评论: %s (尝试 %d 次)", commentID, attempt+1)
return el, nil
}
logrus.Infof("未找到 commentID (2秒超时)")
}
// 通过 userID 查找
if userID != "" {
logrus.Infof("尝试通过 userID 查找: %s", userID)
// 使用 Timeout 避免长时间等待
elements, err := page.Timeout(2 * time.Second).Elements(".comment-item, .comment, .parent-comment")
if err == nil && len(elements) > 0 {
logrus.Infof("找到 %d 个评论元素", len(elements))
for i, el := range elements {
// 快速检查,不等待
userEl, err := el.Timeout(500 * time.Millisecond).Element(fmt.Sprintf(`[data-user-id="%s"]`, userID))
if err == nil && userEl != nil {
logrus.Infof("✓ 通过 userID 在第 %d 个元素中找到评论: %s (尝试 %d 次)", i+1, userID, attempt+1)
return el, nil
}
}
logrus.Infof("在 %d 个元素中未找到匹配的 userID", len(elements))
} else {
logrus.Infof("获取评论元素失败或超时: %v", err)
}
}
logrus.Infof("本次尝试未找到目标评论,继续下一轮...")
// === 7. 等待内容加载 ===
time.Sleep(scrollInterval)
}
return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 尝试次数: %d", commentID, userID, maxAttempts)
}

View File

@@ -433,4 +433,4 @@ func isElementVisible(elem *rod.Element) bool {
}
return visible
}
}