feat: 添加小红书Feed评论功能 (#50)

实现通过HTTP GIN API和MCP API发表评论到小红书Feed的功能:
- 新增POST /api/v1/feeds/comment端点
- 新增post_comment_to_feed MCP工具
- 添加PostCommentRequest和PostCommentResponse类型
- 实现PostCommentToFeed服务方法
- 新增CommentFeedAction用于浏览器自动化操作

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zy
2025-09-09 23:13:05 +08:00
committed by GitHub
parent 8fdb461f8f
commit cd28e4064e
8 changed files with 204 additions and 1 deletions

View File

@@ -124,6 +124,27 @@ func (s *AppServer) getFeedDetailHandler(c *gin.Context) {
respondSuccess(c, result, "获取Feed详情成功")
}
// postCommentHandler 发表评论到Feed
func (s *AppServer) postCommentHandler(c *gin.Context) {
var req PostCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_REQUEST",
"请求参数错误", err.Error())
return
}
// 发表评论
result, err := s.xiaohongshuService.PostCommentToFeed(c.Request.Context(), req.FeedID, req.XsecToken, req.Content)
if err != nil {
respondError(c, http.StatusInternalServerError, "POST_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

@@ -223,3 +223,65 @@ func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any
}},
}
}
// handlePostComment 处理发表评论到Feed
func (s *AppServer) handlePostComment(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 发表评论到Feed")
// 解析参数
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,
}
}
content, ok := args["content"].(string)
if !ok || content == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: 缺少content参数",
}},
IsError: true,
}
}
logrus.Infof("MCP: 发表评论 - Feed ID: %s, 内容长度: %d", feedID, len(content))
// 发表评论
result, err := s.xiaohongshuService.PostCommentToFeed(ctx, feedID, xsecToken, content)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: " + err.Error(),
}},
IsError: true,
}
}
// 返回成功结果只包含feed_id
resultText := fmt.Sprintf("评论发表成功 - Feed ID: %s", result.FeedID)
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: resultText,
}},
}
}

View File

@@ -33,6 +33,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
api.GET("/feeds/list", appServer.listFeedsHandler)
api.GET("/feeds/search", appServer.searchFeedsHandler)
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
api.POST("/feeds/comment", appServer.postCommentHandler)
}
return router

View File

@@ -192,3 +192,30 @@ func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToke
return response, nil
}
// PostCommentToFeed 发表评论到Feed
func (s *XiaohongshuService) PostCommentToFeed(ctx context.Context, feedID, xsecToken, content string) (*PostCommentResponse, error) {
// 使用非无头模式以便查看操作过程
b := browser.NewBrowser(false)
defer b.Close()
page := b.NewPage()
defer page.Close()
// 创建 Feed 评论 action
action := xiaohongshu.NewCommentFeedAction(page)
// 发表评论
err := action.PostComment(ctx, feedID, xsecToken, content)
if err != nil {
return nil, err
}
response := &PostCommentResponse{
FeedID: feedID,
Success: true,
Message: "评论发表成功",
}
return response, nil
}

View File

@@ -233,6 +233,28 @@ func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse {
"required": []string{"feed_id", "xsec_token"},
},
},
{
"name": "post_comment_to_feed",
"description": "发表评论到小红书笔记",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"feed_id": map[string]interface{}{
"type": "string",
"description": "小红书笔记ID从Feed列表获取",
},
"xsec_token": map[string]interface{}{
"type": "string",
"description": "访问令牌从Feed列表的xsecToken字段获取",
},
"content": map[string]interface{}{
"type": "string",
"description": "评论内容",
},
},
"required": []string{"feed_id", "xsec_token", "content"},
},
},
}
return &JSONRPCResponse{
@@ -275,6 +297,8 @@ func (s *AppServer) processToolCall(ctx context.Context, request *JSONRPCRequest
result = s.handleSearchFeeds(ctx, toolArgs)
case "get_feed_detail":
result = s.handleGetFeedDetail(ctx, toolArgs)
case "post_comment_to_feed":
result = s.handlePostComment(ctx, toolArgs)
default:
return &JSONRPCResponse{
JSONRPC: "2.0",

View File

@@ -72,3 +72,17 @@ type FeedDetailResponse struct {
FeedID string `json:"feed_id"`
Data any `json:"data"`
}
// PostCommentRequest 发表评论请求
type PostCommentRequest struct {
FeedID string `json:"feed_id" binding:"required"`
XsecToken string `json:"xsec_token" binding:"required"`
Content string `json:"content" binding:"required"`
}
// PostCommentResponse 发表评论响应
type PostCommentResponse struct {
FeedID string `json:"feed_id"`
Success bool `json:"success"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,50 @@
package xiaohongshu
import (
"context"
"time"
"github.com/go-rod/rod"
"github.com/sirupsen/logrus"
)
// CommentFeedAction 表示 Feed 评论动作
type CommentFeedAction struct {
page *rod.Page
}
// NewCommentFeedAction 创建 Feed 评论动作
func NewCommentFeedAction(page *rod.Page) *CommentFeedAction {
return &CommentFeedAction{page: page}
}
// PostComment 发表评论到 Feed
func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, content string) error {
page := f.page.Context(ctx).Timeout(60 * time.Second)
// 构建详情页 URL
url := makeFeedDetailURL(feedID, xsecToken)
logrus.Infof("Opening feed detail page: %s", url)
// 导航到详情页
page.MustNavigate(url)
page.MustWaitDOMStable()
time.Sleep(1 * time.Second)
elem := page.MustElement("div.input-box div.content-edit span")
elem.MustClick()
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()
time.Sleep(1 * time.Second)
return nil
}

View File

@@ -25,7 +25,7 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
page := f.page.Context(ctx).Timeout(60 * time.Second)
// 构建详情页 URL
url := fmt.Sprintf("https://www.xiaohongshu.com/explore/%s?xsec_token=%s&xsec_source=pc_feed", feedID, xsecToken)
url := makeFeedDetailURL(feedID, xsecToken)
// 导航到详情页
page.MustNavigate(url)
@@ -75,3 +75,7 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
Comments: noteDetail.Comments,
}, nil
}
func makeFeedDetailURL(feedID, xsecToken string) string {
return fmt.Sprintf("https://www.xiaohongshu.com/explore/%s?xsec_token=%s&xsec_source=pc_feed", feedID, xsecToken)
}