Files
xiaohongshu-mcp/xiaohongshu/comment_feed.go
2025-11-24 02:05:30 +08:00

655 lines
19 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 := makeFeedDetailURL(feedID, xsecToken)
logrus.Infof("打开 feed 详情页: %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(5 * time.Minute)
url := makeFeedDetailURL(feedID, xsecToken)
logrus.Infof("打开 feed 详情页进行回复: %s", url)
page.MustNavigate(url)
page.MustWaitDOMStable()
time.Sleep(3 * time.Second)
// 等待评论容器加载
waitForCommentsContainer(page)
time.Sleep(2 * time.Second)
// 使用新的查找逻辑(完全在 JS 中执行)
commentEl, err := findCommentElementNew(page, commentID, userID)
if err != nil {
return fmt.Errorf("无法找到评论: %w", err)
}
// 多次滚动确保可见
for i := 0; i < 3; i++ {
logrus.Infof("第 %d 次滚动到评论位置...", i+1)
_, _ = commentEl.Eval(`() => {
this.scrollIntoView({behavior: "instant", block: "center"});
return true
}`)
time.Sleep(1500 * time.Millisecond)
// 往下多滚动一点
page.MustEval(`() => window.scrollBy(0, 150)`)
time.Sleep(500 * time.Millisecond)
}
logrus.Info("滚动完成,准备点击回复按钮")
// 查找并点击回复按钮
replyBtn, err := findReplyButton(commentEl)
if err != nil {
return fmt.Errorf("无法找到回复按钮: %w", err)
}
if !tryClickChainForComment(replyBtn) {
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 findCommentElementNew(page *rod.Page, commentID, userID string) (*rod.Element, error) {
logrus.Infof("🔍 开始查找评论(新方法)- commentID: %s, userID: %s", commentID, userID)
// 修改 JS找到后记录元素的 ID
findCommentJS := fmt.Sprintf(`async () => {
const INTERVAL_MS = 900;
const STAGNANT_LIMIT = 8;
const NO_CHANGE_SCROLL_LIMIT = 3;
const DELTA_MIN = 480;
const SCROLL_TIMEOUT = 900;
const MAX_ATTEMPTS = 100;
const CLICK_MORE_INTERVAL = 2;
const CLICK_WAIT_TIME = 300;
const TARGET_COMMENT_ID = %q;
const TARGET_USER_ID = %q;
console.log('开始查找评论 - TARGET_COMMENT_ID:', TARGET_COMMENT_ID, 'TARGET_USER_ID:', TARGET_USER_ID);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollRoot = () => document.scrollingElement || document.documentElement || document.body;
const getContainer = () => document.querySelector('.comments-container');
const clickShowMoreButtons = () => {
let clickedCount = 0;
const elements = document.querySelectorAll('.show-more');
elements.forEach((el) => {
try {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const isVisible = (
rect.height > 0 &&
rect.width > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.top < window.innerHeight + 500 &&
rect.bottom > -500
);
if (isVisible) {
el.click();
clickedCount++;
}
} catch (err) {
console.debug('点击失败', err);
}
});
return clickedCount;
};
// === 修改:返回元素的稳定标识符 ===
const findTargetComment = () => {
// 优先通过 commentID 查找
if (TARGET_COMMENT_ID) {
const byId = document.querySelector('#comment-' + TARGET_COMMENT_ID);
if (byId) {
console.log('通过 commentID 找到评论:', TARGET_COMMENT_ID);
// 返回包含完整信息的对象
return {
element: byId,
selector: '#comment-' + TARGET_COMMENT_ID,
commentId: TARGET_COMMENT_ID
};
}
}
// 通过 userID 查找
if (TARGET_USER_ID) {
const allComments = document.querySelectorAll('.comment-item, .comment');
for (const comment of allComments) {
const userIdEl = comment.querySelector('[data-user-id="' + TARGET_USER_ID + '"]');
if (userIdEl) {
console.log('通过 userID 找到评论:', TARGET_USER_ID);
// 尝试获取评论的 ID
const commentId = comment.id;
if (commentId) {
return {
element: comment,
selector: '#' + commentId,
commentId: commentId.replace('comment-', '')
};
} else {
// 如果没有 ID给它添加一个唯一标识
const uniqueId = 'xhs-found-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
comment.id = uniqueId;
return {
element: comment,
selector: '#' + uniqueId,
commentId: null
};
}
}
}
}
return null;
};
// ... (保留原有的滚动逻辑) ...
const getScrollMetrics = (el) => {
if (!el) {
return { top: 0, max: 0, client: window.innerHeight };
}
if (el === window || el === document || el === document.body || el === document.documentElement) {
const root = scrollRoot();
return {
top: root.scrollTop,
max: Math.max(root.scrollHeight - root.clientHeight, 0),
client: root.clientHeight || window.innerHeight
};
}
return {
top: el.scrollTop,
max: Math.max(el.scrollHeight - el.clientHeight, 0),
client: el.clientHeight
};
};
const setScrollTop = (el, value) => {
if (!el) return;
if (el === window || el === document || el === document.body || el === document.documentElement) {
const root = scrollRoot();
root.scrollTop = value;
window.scrollTo(0, value);
return;
}
el.scrollTop = value;
};
const dispatchWheel = (el, delta) => {
if (!el) return;
try {
const wheel = new WheelEvent('wheel', {
deltaY: delta,
bubbles: true,
cancelable: true
});
el.dispatchEvent(wheel);
el.dispatchEvent(new Event('scroll', { bubbles: true }));
} catch (err) {
console.debug('dispatchWheel error', err);
}
};
const findScrollTarget = () => {
const container = getContainer();
const candidates = new Set();
if (container) {
let current = container;
while (current) {
if (current instanceof HTMLElement) {
candidates.add(current);
}
current = current.parentElement;
}
}
candidates.add(document.body);
candidates.add(document.documentElement);
const weighted = Array.from(candidates).map((node) => {
const style = window.getComputedStyle(node);
const overflowY = style.overflowY;
const scrollable = node.scrollHeight - node.clientHeight > 40;
const hasScrollStyle = /auto|scroll|overlay/i.test(overflowY);
const weight =
(node.contains(container) ? 1000 : 0) +
(node === container ? 800 : 0) +
(hasScrollStyle ? 400 : 0) +
(scrollable ? 300 : 0) -
(node === document.body || node === document.documentElement ? 50 : 0);
if (scrollable || hasScrollStyle || node === document.body || node === document.documentElement) {
return { node, weight };
}
return null;
}).filter(Boolean);
weighted.sort((a, b) => b.weight - a.weight);
return weighted.length > 0 ? weighted[0].node : scrollRoot();
};
const performScroll = (target) => {
const scrollTarget = target || findScrollTarget();
if (!scrollTarget) {
window.scrollBy(0, window.innerHeight * 0.8);
return;
}
const metrics = getScrollMetrics(scrollTarget);
const beforeTop = metrics.top;
const desired = metrics.max > 0
? Math.min(metrics.top + Math.max(metrics.client * 0.85, DELTA_MIN), metrics.max)
: metrics.top + Math.max(metrics.client * 0.85, DELTA_MIN);
const applied = Math.max(0, desired - metrics.top);
setScrollTop(scrollTarget, desired);
dispatchWheel(scrollTarget, applied);
const afterTop = getScrollMetrics(scrollTarget).top;
if (Math.abs(afterTop - beforeTop) < 5 && scrollTarget !== scrollRoot()) {
const root = scrollRoot();
const rootBefore = root.scrollTop;
root.scrollTop = rootBefore + applied;
window.scrollBy(0, applied);
dispatchWheel(root, applied);
}
};
// 主查找逻辑
let lastScrollTop = 0;
let stagnantChecks = 0;
let noScrollChangeCount = 0;
let totalClickedButtons = 0;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const container = getContainer();
if (!container) {
await sleep(300);
continue;
}
if (attempt %% CLICK_MORE_INTERVAL === 0) {
const clicked = clickShowMoreButtons();
if (clicked > 0) {
totalClickedButtons += clicked;
console.log('点击了 ' + clicked + ' 个"更多"按钮,累计: ' + totalClickedButtons);
await sleep(CLICK_WAIT_TIME);
await sleep(200);
const clicked2 = clickShowMoreButtons();
if (clicked2 > 0) {
totalClickedButtons += clicked2;
console.log('二次检查点击了 ' + clicked2 + ' 个"更多"按钮');
await sleep(CLICK_WAIT_TIME);
}
const foundInfo = findTargetComment();
if (foundInfo) {
console.log('点击"更多"后找到评论,总共点击了 ' + totalClickedButtons + ' 个按钮');
return {
status: 'found',
attempts: attempt + 1,
clickedButtons: totalClickedButtons,
selector: foundInfo.selector,
commentId: foundInfo.commentId
};
}
}
}
const foundInfo = findTargetComment();
if (foundInfo) {
console.log('找到评论,尝试次数: ' + (attempt + 1) + ',总共点击了 ' + totalClickedButtons + ' 个按钮');
return {
status: 'found',
attempts: attempt + 1,
clickedButtons: totalClickedButtons,
selector: foundInfo.selector,
commentId: foundInfo.commentId
};
}
const target = findScrollTarget();
const beforeTop = getScrollMetrics(target).top;
performScroll(target);
await sleep(SCROLL_TIMEOUT);
const afterTop = getScrollMetrics(target).top;
if (Math.abs(afterTop - beforeTop) < 5) {
noScrollChangeCount += 1;
} else {
noScrollChangeCount = 0;
lastScrollTop = afterTop;
}
if (noScrollChangeCount >= NO_CHANGE_SCROLL_LIMIT) {
return { status: 'not_found', reason: 'no-scroll-change', attempts: attempt + 1, clickedButtons: totalClickedButtons };
}
if (INTERVAL_MS > SCROLL_TIMEOUT) {
await sleep(INTERVAL_MS - SCROLL_TIMEOUT);
}
}
return { status: 'not_found', reason: 'timeout', attempts: MAX_ATTEMPTS, clickedButtons: totalClickedButtons };
}`, commentID, userID)
// 执行 JS
result, err := page.Eval(findCommentJS)
if err != nil {
logrus.Errorf("执行查找评论 JS 失败: %v", err)
return nil, fmt.Errorf("执行查找评论 JS 失败: %w", err)
}
// 解析结果
resultJSON, err := page.ObjectToJSON(result)
if err != nil {
logrus.Errorf("无法将结果转换为 JSON: %v", err)
return nil, fmt.Errorf("无法将结果转换为 JSON: %w", err)
}
status := resultJSON.Get("status").Str()
reason := resultJSON.Get("reason").Str()
attempts := resultJSON.Get("attempts").Int()
clickedButtons := resultJSON.Get("clickedButtons").Int()
selector := resultJSON.Get("selector").Str()
logrus.Infof("查找结果: status=%s, reason=%s, attempts=%d, clickedButtons=%d, selector=%s",
status, reason, attempts, clickedButtons, selector)
if status != "found" {
return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 原因: %s, 尝试次数: %d, 点击按钮: %d",
commentID, userID, reason, attempts, clickedButtons)
}
// === 关键修改:使用返回的稳定选择器而不是临时标记 ===
el, err := page.Element(selector)
if err != nil {
logrus.Errorf("找到评论但无法获取元素,选择器: %s, 错误: %v", selector, err)
// 如果稳定选择器失败,尝试重新查找
logrus.Info("尝试通过 commentID 重新查找...")
if commentID != "" {
fallbackSelector := fmt.Sprintf("#comment-%s", commentID)
el, err = page.Element(fallbackSelector)
if err == nil {
logrus.Infof("通过备用选择器 %s 成功找到元素", fallbackSelector)
return el, nil
}
}
return nil, fmt.Errorf("找到评论但无法获取元素: %w", err)
}
logrus.Infof("✓ 成功获取评论元素,选择器: %s", selector)
return el, nil
}
func waitForCommentsContainer(page *rod.Page) {
jsCode := `() => {
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 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
}
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)
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
logrus.Warnf("点击失败: %v", err)
return false
}
logrus.Infof("点击成功")
time.Sleep(1 * time.Second)
success := verifyClickSuccess(el)
if success {
logrus.Infof("点击执行成功且有效")
return true
}
logrus.Warnf("点击执行成功但无效(没有出现回复输入框)")
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("未找到回复发布按钮")
}