Files
xiaohongshu-mcp/xiaohongshu/comment_feed.go
chekayo a169db603b refactor(comment_feed): streamline comment reply process and enhance error handling
- Removed redundant waiting times and improved the logic for finding comment elements and reply buttons.
- Simplified the reply button search by consolidating selectors and enhancing error messages.
- Improved the overall readability of the code by removing unnecessary comments and whitespace.
- Ensured better handling of potential errors during the comment reply process.
2025-10-28 02:02:08 +08:00

561 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package xiaohongshu
import (
"context"
"fmt"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"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)
// 导航到详情页
if err := page.Navigate(url); err != nil {
logrus.Warnf("Failed to navigate to feed detail page: %v", err)
return fmt.Errorf("无法打开帖子详情页,该帖子可能在网页端不可访问: %w", err)
}
if err := page.WaitStable(2 * time.Second); err != nil {
logrus.Warnf("Failed to wait for page stable: %v", err)
return fmt.Errorf("页面加载超时,该帖子可能在网页端不可访问: %w", err)
}
time.Sleep(1 * time.Second)
// 查找评论输入框
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)
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)
}
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 {
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 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
}
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),
}
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 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) {
if commentEl == nil {
return nil, fmt.Errorf("评论元素为空")
}
selector := ".right .interactions .reply"
btn, err := commentEl.Element(selector)
if err != nil || btn == nil {
logrus.Warnf("未找到回复按钮,选择器: %s, err: %v", selector, err)
return nil, fmt.Errorf("未找到回复按钮")
}
logrus.Infof("通过选择器 %s 找到回复按钮", selector)
return btn, nil
}
// verifyClickSuccess 验证点击是否真的成功(检查是否出现了回复输入框)
func verifyClickSuccess(clickedEl *rod.Element) bool {
// 获取页面实例
page := clickedEl.Page()
// 检查是否出现了回复输入框
selectors := []string{
"div.input-box div.content-edit p.content-input",
}
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
}
}
}
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", // 原有选择器
}
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()
classAttr, _ := el.Attribute("class")
class := ""
if classAttr != nil {
class = *classAttr
}
tagName := ""
if desc, err := el.Describe(0, false); err == nil && desc != nil {
tagName = desc.NodeName
}
logrus.Infof("准备点击元素 - 文本: '%s', 类: '%s', 标签: %s", text, class, tagName)
// 检查元素是否可见和可点击
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",
}
for _, selector := range selectors {
if el, err := page.Element(selector); err == nil && el != nil {
disabled, _ := el.Attribute("disabled")
if disabled == nil {
return el, nil
}
}
}
return nil, fmt.Errorf("未找到回复发布按钮")
}