diff --git a/handlers_api.go b/handlers_api.go index a7dcd04..1a6d11c 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -199,6 +199,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{ diff --git a/mcp_handlers.go b/mcp_handlers.go index aebbe25..f6cc1e1 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -486,3 +486,39 @@ 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}}} +} diff --git a/mcp_server.go b/mcp_server.go index 2c7e4b8..e6d7166 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -50,6 +50,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:"回复内容"` +} + // LikeFavoriteArgs 点赞/收藏参数 type LikeFavoriteArgs struct { FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` @@ -196,7 +205,33 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, ) - // 工具 9: 发布视频(仅本地文件) + // 工具 9: 回复评论 + 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 + }, + ) + + // 工具 10: 发布视频(仅本地文件) mcp.AddTool(server, &mcp.Tool{ Name: "publish_with_video", @@ -214,7 +249,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, ) - // 工具 10: 点赞笔记 + // 工具 11: 点赞笔记 mcp.AddTool(server, &mcp.Tool{ Name: "like_feed", @@ -230,7 +265,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, ) - // 工具 11: 收藏笔记 + // 工具 12: 收藏笔记 mcp.AddTool(server, &mcp.Tool{ Name: "favorite_feed", @@ -246,7 +281,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, ) - logrus.Infof("Registered %d MCP tools", 11) + logrus.Infof("Registered %d MCP tools", 12) } // convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式 diff --git a/routes.go b/routes.go index c709943..aa15106 100644 --- a/routes.go +++ b/routes.go @@ -47,6 +47,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) } return router diff --git a/service.go b/service.go index ab909d2..b3cfd72 100644 --- a/service.go +++ b/service.go @@ -22,9 +22,9 @@ type XiaohongshuService struct{} // ActionResult 通用动作响应(点赞/收藏等) type ActionResult struct { - FeedID string `json:"feed_id"` - Success bool `json:"success"` - Message string `json:"message"` + FeedID string `json:"feed_id"` + Success bool `json:"success"` + Message string `json:"message"` } // NewXiaohongshuService 创建小红书服务实例 @@ -390,6 +390,29 @@ func (s *XiaohongshuService) PostCommentToFeed(ctx context.Context, feedID, xsec return &PostCommentResponse{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 +} + // LikeFeed 点赞笔记 func (s *XiaohongshuService) LikeFeed(ctx context.Context, feedID, xsecToken string) (*ActionResult, error) { b := newBrowser() diff --git a/types.go b/types.go index afd5f07..96a2738 100644 --- a/types.go +++ b/types.go @@ -58,6 +58,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"` diff --git a/xiaohongshu/comment_feed.go b/xiaohongshu/comment_feed.go index eb953e2..5aee894 100644 --- a/xiaohongshu/comment_feed.go +++ b/xiaohongshu/comment_feed.go @@ -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" ) @@ -21,30 +23,677 @@ 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) - // 构建详情页 URL url := makeFeedDetailURL(feedID, xsecToken) - logrus.Infof("Opening feed detail page: %s", url) - // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() - - time.Sleep(1 * time.Second) - + time.Sleep(3 * time.Second) // 增加等待时间确保页面完全加载 + + // 等待评论容器加载 + waitForCommentsContainer(page) + 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) - + time.Sleep(2 * time.Second) // 增加等待时间 submitButton := page.MustElement("div.bottom button.submit") submitButton.MustClick() - - time.Sleep(1 * time.Second) - + time.Sleep(2 * time.Second) // 增加等待时间确保提交完成 return nil } + +// ReplyToComment 回复指定评论 +func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToken, commentID, userID, content string) error { + page := f.page.Context(ctx).Timeout(60 * time.Second) + url := makeFeedDetailURL(feedID, xsecToken) + logrus.Infof("Opening feed detail page for reply: %s", url) + page.MustNavigate(url) + page.MustWaitDOMStable() + time.Sleep(3 * time.Second) // 增加等待时间确保页面完全加载 + + // 等待评论容器加载 + waitForCommentsContainer(page) + + // 确保评论区域可见 + ensureCommentsVisible(page) + + // 额外等待确保评论内容加载完成 + time.Sleep(2 * time.Second) + + // 尝试多次查找评论元素 + var commentEl *rod.Element + var err error + for attempt := 0; attempt < 5; attempt++ { // 增加尝试次数 + commentEl, err = findCommentElement(page, commentID, userID) + if err == nil { + break + } + logrus.Warnf("Attempt %d: Failed to find comment: %v", attempt+1, err) + time.Sleep(2 * time.Second) // 增加等待时间 + ensureCommentsVisible(page) + scrollComments(page) // 每次尝试后滚动 + } + + if err != nil { + return fmt.Errorf("无法找到评论: %w", err) + } + + // 滚动到评论位置 + _, _ = commentEl.Eval(`() => { try { this.scrollIntoView({behavior: "instant", block: "center"}); } catch (e) {} return true }`) + time.Sleep(1 * time.Second) // 增加等待时间 + + // 尝试多次点击回复按钮 + var replyBtn *rod.Element + for attempt := 0; attempt < 5; attempt++ { // 增加尝试次数 + replyBtn, err = findReplyButton(commentEl) + if err == nil { + if tryClickChainForComment(replyBtn) { + break + } + } + logrus.Warnf("Attempt %d: Failed to click reply button: %v", attempt+1, err) + time.Sleep(1 * time.Second) // 增加等待时间 + } + + if err != nil || replyBtn == nil { + return fmt.Errorf("无法点击回复按钮") + } + + time.Sleep(2 * time.Second) // 增加等待时间确保回复输入框出现 + + // 查找回复输入框 + inputEl, err := findReplyInput(page, commentEl) + if err != nil { + return fmt.Errorf("无法找到回复输入框: %w", err) + } + + // 聚焦并输入内容 + if _, evalErr := inputEl.Eval(`() => { try { this.focus(); } catch (e) {} return true }`); evalErr != nil { + logrus.Warnf("focus reply input failed: %v", evalErr) + } + + inputEl.MustInput(content) + time.Sleep(500 * time.Millisecond) // 增加等待时间 + + // 查找并点击提交按钮 + submitBtn, err := findSubmitButton(page) + if err != nil { + return fmt.Errorf("无法找到提交按钮: %w", err) + } + + if !tryClickChainForComment(submitBtn) { + return fmt.Errorf("点击回复提交按钮失败") + } + + time.Sleep(3 * time.Second) // 增加等待时间确保回复提交完成 + return nil +} + +func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) { + var lastErr error + + // 首先尝试确保评论区域可见 + ensureCommentsVisible(page) + + for attempt := 0; attempt < 20; attempt++ { // 增加尝试次数 + logrus.Infof("查找评论,尝试次数: %d", attempt+1) + el, err := locateCommentElement(page, commentID, userID) + if err == nil && el != nil { + logrus.Infof("成功找到评论") + return el, nil + } + if err != nil { + lastErr = err + } + + // 每3次尝试后进行一次更彻底的滚动 + if attempt%3 == 0 { + // 更彻底的滚动策略 + performFullScroll(page) + } else { + // 常规滚动 + if !scrollComments(page) { + logrus.Infof("滚动到底部,无法继续滚动") + break + } + } + time.Sleep(800 * time.Millisecond) // 增加等待时间 + } + + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("未找到评论: %s", buildIdentifier(commentID, userID)) +} + +func locateCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) { + // 首先在comments-container内查找 + if commentsContainer, err := page.Element(".comments-container"); err == nil && commentsContainer != nil { + if commentID != "" { + if el, err := locateCommentElementByCommentIDInContainer(commentsContainer, commentID); err == nil && el != nil { + return el, nil + } + } + if userID != "" { + if el, err := locateCommentElementByUserIDInContainer(commentsContainer, userID); err == nil && el != nil { + return el, nil + } + } + } + + // 如果在comments-container内没有找到,尝试在整个页面查找 + if commentID != "" { + if el, err := locateCommentElementByCommentID(page, commentID); err == nil && el != nil { + return el, nil + } + } + if userID != "" { + if el, err := locateCommentElementByUserID(page, userID); err == nil && el != nil { + return el, nil + } + } + + identifier := buildIdentifier(commentID, userID) + if identifier == "" { + return nil, fmt.Errorf("未提供评论标识") + } + return nil, fmt.Errorf("未找到评论: %s", identifier) +} + +func locateCommentElementByCommentID(page *rod.Page, commentID string) (*rod.Element, error) { + if commentID == "" { + return nil, fmt.Errorf("评论ID为空") + } + + // 首先尝试直接通过ID查找(根据HTML结构中的id="comment-68d9df3e0000000002015818") + idSelector := fmt.Sprintf("#comment-%s", commentID) + if el, err := page.Element(idSelector); err == nil && el != nil { + return el, nil + } + + // 尝试其他data属性 + selectors := []string{ + fmt.Sprintf(`[data-comment-id="%s"]`, commentID), + fmt.Sprintf(`[data-comment_id="%s"]`, commentID), + fmt.Sprintf(`[data-commentid="%s"]`, commentID), + fmt.Sprintf(`[data-id="%s"]`, commentID), + fmt.Sprintf(`[comment-id="%s"]`, commentID), + } + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + return el, nil + } + } + + return nil, fmt.Errorf("未找到评论ID: %s", commentID) +} + +func locateCommentElementByUserID(page *rod.Page, userID string) (*rod.Element, error) { + if userID == "" { + return nil, fmt.Errorf("用户ID为空") + } + + selectors := []string{ + fmt.Sprintf(`[data-user-id="%s"]`, userID), + fmt.Sprintf(`[data-user_id="%s"]`, userID), + fmt.Sprintf(`[data-userid="%s"]`, userID), + fmt.Sprintf(`[data-uid="%s"]`, userID), + fmt.Sprintf(`a[data-user-id="%s"]`, userID), + fmt.Sprintf(`a[href*="%s"]`, userID), + } + + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + // 使用JavaScript查找父级评论元素 + jsCode := `() => { + let current = this; + while (current) { + if (current.classList && (current.classList.contains('comment-item') || current.classList.contains('comment'))) { + return current; + } + current = current.parentElement; + } + return this; + }` + if _, err := el.Eval(jsCode); err == nil { + return el, nil + } + return el, nil + } + } + + return nil, fmt.Errorf("未找到用户ID: %s", userID) +} + +// 在指定容器内查找评论元素 +func locateCommentElementByCommentIDInContainer(container *rod.Element, commentID string) (*rod.Element, error) { + if commentID == "" { + return nil, fmt.Errorf("评论ID为空") + } + + // 首先尝试直接通过ID查找 + idSelector := fmt.Sprintf("#comment-%s", commentID) + if el, err := container.Element(idSelector); err == nil && el != nil { + return el, nil + } + + // 尝试其他data属性 + selectors := []string{ + fmt.Sprintf(`[data-comment-id="%s"]`, commentID), + fmt.Sprintf(`[data-comment_id="%s"]`, commentID), + fmt.Sprintf(`[data-commentid="%s"]`, commentID), + fmt.Sprintf(`[data-id="%s"]`, commentID), + fmt.Sprintf(`[comment-id="%s"]`, commentID), + } + for _, selector := range selectors { + if el, err := container.Element(selector); err == nil && el != nil { + return el, nil + } + } + + return nil, fmt.Errorf("在容器内未找到评论ID: %s", commentID) +} + +// 在指定容器内通过用户ID查找评论元素 +func locateCommentElementByUserIDInContainer(container *rod.Element, userID string) (*rod.Element, error) { + if userID == "" { + return nil, fmt.Errorf("用户ID为空") + } + + selectors := []string{ + fmt.Sprintf(`[data-user-id="%s"]`, userID), + fmt.Sprintf(`[data-user_id="%s"]`, userID), + fmt.Sprintf(`[data-userid="%s"]`, userID), + fmt.Sprintf(`[data-uid="%s"]`, userID), + fmt.Sprintf(`a[data-user-id="%s"]`, userID), + fmt.Sprintf(`a[href*="%s"]`, userID), + } + + for _, selector := range selectors { + if el, err := container.Element(selector); err == nil && el != nil { + // 找到用户链接,返回其父级评论元素 + if parent, err := el.Element(".comment-item"); err == nil && parent != nil { + return parent, nil + } + if parent, err := el.Element(".comment"); err == nil && parent != nil { + return parent, nil + } + return el, nil + } + } + + return nil, fmt.Errorf("在容器内未找到用户ID: %s", userID) +} + +// 等待评论容器加载完成 +func waitForCommentsContainer(page *rod.Page) { + jsCode := `() => { + // 等待comments-container元素出现 + let attempts = 0; + const maxAttempts = 10; + + const checkContainer = () => { + const container = document.querySelector('.comments-container'); + if (container) { + // 检查容器内是否有评论内容 + const comments = container.querySelectorAll('.comment-item, .comment'); + return comments.length > 0; + } + return false; + }; + + // 定期检查评论容器是否加载完成 + const interval = setInterval(() => { + attempts++; + if (checkContainer() || attempts >= maxAttempts) { + clearInterval(interval); + } + }, 500); + + return checkContainer(); + }` + + page.Eval(jsCode) + time.Sleep(2 * time.Second) // 等待检查完成 +} + +func ensureCommentsVisible(page *rod.Page) { + // 专门针对comments-container元素的JavaScript代码 + jsCode := `() => { + // 查找comments-container元素 + const commentsContainer = document.querySelector('.comments-container'); + + // 如果找到comments-container,尝试滚动到视图中并在其内部滚动 + if (commentsContainer) { + // 先滚动到视图中 + commentsContainer.scrollIntoView({behavior: 'instant', block: 'start'}); + + // 等待一下再在容器内部滚动 + setTimeout(() => { + // 在comments-container内部滚动以显示评论 + if (commentsContainer.scrollHeight > commentsContainer.clientHeight) { + const maxScroll = commentsContainer.scrollHeight - commentsContainer.clientHeight; + if (maxScroll > 0) { + // 滚动到一半位置 + commentsContainer.scrollTop = Math.min(maxScroll, commentsContainer.clientHeight * 0.5); + } + } + }, 200); + + return true; + } + + return false; + }` + + page.Eval(jsCode) + time.Sleep(1 * time.Second) +} + +func scrollComments(page *rod.Page) bool { + scrollJS := `() => { + let scrolled = false; + + // 专门查找comments-container元素 + const commentsContainer = document.querySelector('.comments-container'); + + if (commentsContainer) { + const maxScroll = commentsContainer.scrollHeight - commentsContainer.clientHeight; + if (maxScroll > 0 && commentsContainer.scrollTop < maxScroll) { + // 滚动更多内容 + const delta = Math.max(commentsContainer.clientHeight * 0.8, 400); + commentsContainer.scrollTop = Math.min(maxScroll, commentsContainer.scrollTop + delta); + scrolled = true; + } + } + + return scrolled; + }` + res, err := page.Eval(scrollJS) + if err != nil { + logrus.Warnf("scroll comments failed: %v", err) + return false + } + if res == nil { + return false + } + return res.Value.Bool() +} + +// performFullScroll 执行更彻底的滚动策略 +func performFullScroll(page *rod.Page) { + logrus.Infof("执行彻底滚动策略") + + // 策略1: 滚动到评论容器的不同位置 + scrollPositionsJS := `() => { + const commentsContainer = document.querySelector('.comments-container'); + if (!commentsContainer) return false; + + const maxScroll = commentsContainer.scrollHeight - commentsContainer.clientHeight; + if (maxScroll <= 0) return false; + + // 根据当前滚动位置决定下一步滚动 + const currentScroll = commentsContainer.scrollTop; + const scrollRatio = currentScroll / maxScroll; + + if (scrollRatio < 0.3) { + // 滚动到30%位置 + commentsContainer.scrollTop = maxScroll * 0.3; + } else if (scrollRatio < 0.6) { + // 滚动到60%位置 + commentsContainer.scrollTop = maxScroll * 0.6; + } else if (scrollRatio < 0.9) { + // 滚动到90%位置 + commentsContainer.scrollTop = maxScroll * 0.9; + } else { + // 滚动到底部 + commentsContainer.scrollTop = maxScroll; + } + + return true; + }` + + if _, err := page.Eval(scrollPositionsJS); err != nil { + logrus.Warnf("彻底滚动失败: %v", err) + } + +} + +func buildIdentifier(commentID, userID string) string { + if commentID != "" && userID != "" { + return fmt.Sprintf("comment_id=%s / user_id=%s", commentID, userID) + } + if commentID != "" { + return commentID + } + return userID +} + +func findReplyButton(commentEl *rod.Element) (*rod.Element, error) { + logrus.Infof("开始查找回复按钮...") + + // 在right区域内查找interactions + right, err := commentEl.Element(".right") + if err != nil { + logrus.Errorf("未找到.right区域") + return nil, fmt.Errorf("未找到.right区域") + } + + interactions, err := right.Element(".interactions") + if err != nil { + logrus.Errorf("未找到.interactions区域") + return nil, fmt.Errorf("未找到.interactions区域") + } + + // 选择器列表 + selectors := []string{ + ".reply", // 回复容器(最通用) + ":nth-child(2)", // 第二个子元素(单评论) + ".reply-icon", // 回复图标 + ".reds-icon.reply-icon", // 带类的回复图标 + ".reply.icon-container", // 回复图标容器 + } + + // 在interactions区域内查找 + for _, selector := range selectors { + if el, err := interactions.Element(selector); err == nil && el != nil { + logrus.Infof("通过选择器 %s 找到回复按钮", selector) + return el, nil + } + } + + logrus.Errorf("未找到回复按钮") + return nil, fmt.Errorf("未找到回复按钮") +} + +// verifyClickSuccess 验证点击是否真的成功(检查是否出现了回复输入框) +func verifyClickSuccess(clickedEl *rod.Element) bool { + // 获取页面实例 + page := clickedEl.Page() + + // 检查是否出现了回复输入框 + selectors := []string{ + "div.input-box div.content-edit p.content-input", + "div.input-box [contenteditable='true']", + "[contenteditable='true']", + "textarea", + "input[type='text']", + } + + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + // 检查元素是否可见 + if visible, _ := el.Visible(); visible { + logrus.Infof("验证成功:找到可见的回复输入框 (%s)", selector) + return true + } + } + } + + // 使用JavaScript检查是否有新的输入框出现 + jsCode := `() => { + // 查找所有可编辑元素 + const editables = document.querySelectorAll('[contenteditable="true"], textarea, input[type="text"]'); + for (const el of editables) { + // 检查元素是否可见 + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + // 检查元素是否在视口中 + const inViewport = rect.top >= 0 && rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + if (inViewport) { + console.log('找到可见的输入元素:', el); + return true; + } + } + } + return false; + }` + + if result, err := page.Eval(jsCode); err == nil && result != nil { + if result.Value.Bool() { + logrus.Infof("JavaScript验证成功:找到可见的输入元素") + return true + } + } + + logrus.Infof("验证失败:没有找到回复输入框") + return false +} + +func findReplyInput(page *rod.Page, commentEl *rod.Element) (*rod.Element, error) { + activeEditableJS := `() => { + const active = document.activeElement; + if (active && active.getAttribute && active.getAttribute('contenteditable') === 'true') { + return active; + } + return null; + }` + if el, err := page.ElementByJS(rod.Eval(activeEditableJS)); err == nil && el != nil { + return el, nil + } + selectors := []string{ + "div.input-box div.content-edit p.content-input", // 原有选择器 + "div.input-box [contenteditable='true']", // 通用输入框 + "[contenteditable='true']", // 任何可编辑元素 + "textarea", // 备用textarea + "input[type='text']", // 备用text输入框 + "[data-role='reply-input'] [contenteditable='true']", + } + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + return el, nil + } + } + // 尝试在评论内部寻找可编辑区域 + if el, err := commentEl.Element("[contenteditable='true']"); err == nil && el != nil { + return el, nil + } + // 最后尝试:等待一下再查找,可能是动态加载的 + time.Sleep(1 * time.Second) + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + return el, nil + } + } + return nil, fmt.Errorf("未找到回复输入框") +} + +func tryClickChainForComment(el *rod.Element) bool { + if el == nil { + logrus.Errorf("要点击的元素为空") + return false + } + + // 获取元素信息用于调试 + text, _ := el.Text() + class, _ := el.Attribute("class") + tag, _ := el.Describe(0, false) + logrus.Infof("准备点击元素 - 文本: '%s', 类: '%s', 标签: %s", text, class, tag) + + // 检查元素是否可见和可点击 + visible, _ := el.Visible() + logrus.Infof("元素可见性: %v", visible) + + // 滚动到元素位置 + _, _ = el.Eval(`() => { try { this.scrollIntoView({behavior: "instant", block: "center"}); } catch (e) {} return true }`) + time.Sleep(500 * time.Millisecond) + + // 只使用直接点击方式 + clickMethods := []struct { + name string + fn func(*rod.Element) bool + }{ + {"直接点击", func(e *rod.Element) bool { + if err := e.Click(proto.InputMouseButtonLeft, 1); err != nil { + logrus.Warnf("直接点击失败: %v", err) + return false + } + logrus.Infof("直接点击成功") + return true + }}, + } + + for i, method := range clickMethods { + logrus.Infof("尝试点击方法 %d: %s", i+1, method.name) + if method.fn(el) { + // 点击后等待一下,检查是否有反应 + time.Sleep(1 * time.Second) + + // 验证点击是否真的成功(检查是否出现了回复输入框) + success := verifyClickSuccess(el) + if success { + logrus.Infof("点击方法 %s 执行成功且有效", method.name) + return true + } else { + logrus.Warnf("点击方法 %s 执行成功但无效(没有出现回复输入框)", method.name) + // 继续尝试下一种方法 + } + } + } + + logrus.Errorf("所有点击方法都失败") + return false +} + +func findSubmitButton(page *rod.Page) (*rod.Element, error) { + selectors := []string{ + "div.bottom button.submit", + "button.submit", + "button.reds-button", + "button[type='submit']", + "button:contains('回复')", + "button:contains('发布')", + "button:contains('发送')", + } + for _, selector := range selectors { + if el, err := page.Element(selector); err == nil && el != nil { + disabled, _ := el.Attribute("disabled") + if disabled == nil { + return el, nil + } + } + } + // 使用JS查找包含特定文本的按钮 + jsCode := `() => { + const buttons = document.querySelectorAll('button'); + for (const btn of buttons) { + const text = btn.textContent || btn.innerText || ''; + if (text.includes('回复') || text.includes('发布') || text.includes('发送')) { + const disabled = btn.getAttribute('disabled'); + if (!disabled) { + return btn; + } + } + } + return null; + }` + if el, err := page.ElementByJS(rod.Eval(jsCode)); err == nil && el != nil { + return el, nil + } + return nil, fmt.Errorf("未找到回复发布按钮") +} \ No newline at end of file