diff --git a/xiaohongshu/comment_feed.go b/xiaohongshu/comment_feed.go index c3f48bf..f0e9e96 100644 --- a/xiaohongshu/comment_feed.go +++ b/xiaohongshu/comment_feed.go @@ -27,18 +27,16 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, 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) - } - + // 导航到详情页 + 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) @@ -87,568 +85,118 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke url := makeFeedDetailURL(feedID, xsecToken) logrus.Infof("打开 feed 详情页进行回复: %s", url) + // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() - time.Sleep(3 * time.Second) + time.Sleep(1 * time.Second) + + // 检测页面是否可访问 + if err := checkPageAccessible(page); err != nil { + return err + } // 等待评论容器加载 - waitForCommentsContainer(page) time.Sleep(2 * time.Second) - // 使用新的查找逻辑(完全在 JS 中执行) - commentEl, err := findCommentElementNew(page, commentID, userID) + // 使用 Go 实现的查找逻辑 + commentEl, err := findCommentElement(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) + // 滚动到评论位置 + logrus.Info("滚动到评论位置...") + commentEl.MustScrollIntoView() + time.Sleep(1 * time.Second) - // 往下多滚动一点 - page.MustEval(`() => window.scrollBy(0, 150)`) - time.Sleep(500 * time.Millisecond) - } - - logrus.Info("滚动完成,准备点击回复按钮") + logrus.Info("准备点击回复按钮") // 查找并点击回复按钮 - replyBtn, err := findReplyButton(commentEl) + replyBtn, err := commentEl.Element(".right .interactions .reply") if err != nil { return fmt.Errorf("无法找到回复按钮: %w", err) } - if !tryClickChainForComment(replyBtn) { - return fmt.Errorf("点击回复按钮失败") + if err := replyBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击回复按钮失败: %w", err) } - time.Sleep(2 * time.Second) + time.Sleep(1 * time.Second) // 查找回复输入框 - inputEl, err := findReplyInput(page, commentEl) + inputEl, err := page.Element("div.input-box div.content-edit p.content-input") 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) + // 输入内容 + if err := inputEl.Input(content); err != nil { + return fmt.Errorf("输入回复内容失败: %w", err) } - inputEl.MustInput(content) time.Sleep(500 * time.Millisecond) // 查找并点击提交按钮 - submitBtn, err := findSubmitButton(page) + submitBtn, err := page.Element("div.bottom button.submit") if err != nil { return fmt.Errorf("无法找到提交按钮: %w", err) } - if !tryClickChainForComment(submitBtn) { - return fmt.Errorf("点击回复提交按钮失败") + if err := submitBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击提交按钮失败: %w", err) } - time.Sleep(3 * time.Second) + time.Sleep(2 * time.Second) + logrus.Infof("回复评论成功") return nil } -func findCommentElementNew(page *rod.Page, commentID, userID string) (*rod.Element, error) { - logrus.Infof("🔍 开始查找评论(新方法)- commentID: %s, userID: %s", commentID, userID) +// findCommentElement 查找指定评论元素(Go 实现,减少 JS 代码) +func findCommentElement(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 maxAttempts = 50 + const scrollInterval = 800 * time.Millisecond - 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); - } + // 先滚动到评论区 + page.MustEval(`() => { + const container = document.querySelector('.comments-container'); + if (container) { + container.scrollIntoView({behavior: 'smooth', block: 'start'}); } - - 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 - } + for attempt := 0; attempt < maxAttempts; attempt++ { + logrus.Debugf("查找尝试 %d/%d", attempt+1, maxAttempts) - 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 { + // 优先通过 commentID 查找 + if commentID != "" { + selector := fmt.Sprintf("#comment-%s", commentID) + if el, err := page.Element(selector); err == nil { + logrus.Infof("✓ 通过 commentID 找到评论: %s", commentID) return el, nil } } + + // 通过 userID 查找 + if userID != "" { + elements, err := page.Elements(".comment-item, .comment") + if err == nil { + for _, el := range elements { + userEl, err := el.Element(fmt.Sprintf(`[data-user-id="%s"]`, userID)) + if err == nil && userEl != nil { + logrus.Infof("✓ 通过 userID 找到评论: %s", userID) + return el, nil + } + } + } + } + + // 滚动页面 + page.MustEval(`() => window.scrollBy(0, window.innerHeight * 0.8)`) + time.Sleep(scrollInterval) } - return nil, fmt.Errorf("未找到回复发布按钮") + + return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 尝试次数: %d", commentID, userID, maxAttempts) } diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go index 85d560a..ce9efaa 100644 --- a/xiaohongshu/feed_detail.go +++ b/xiaohongshu/feed_detail.go @@ -85,11 +85,15 @@ func (f *FeedDetailAction) GetFeedDetailWithConfig(ctx context.Context, feedID, // checkPageAccessible 检查页面是否可访问 func checkPageAccessible(page *rod.Page) error { + // 等待页面稳定,确保错误提示已加载 + time.Sleep(500 * time.Millisecond) + unavailableResult := page.MustEval(`() => { const wrapper = document.querySelector('.access-wrapper, .error-wrapper, .not-found-wrapper, .blocked-wrapper'); if (!wrapper) return null; - const text = wrapper.textContent || ''; + // 获取所有文本内容(包括子元素) + const text = wrapper.textContent || wrapper.innerText || ''; const keywords = [ '当前笔记暂时无法浏览', '该内容因违规已被删除', @@ -105,9 +109,15 @@ func checkPageAccessible(page *rod.Page) error { for (const kw of keywords) { if (text.includes(kw)) { - return kw.trim(); + return kw; } } + + // 如果找到了 wrapper 但没有匹配关键词,返回完整文本用于调试 + if (text.trim()) { + return '未知错误: ' + text.trim(); + } + return null; }`)