Merge pull request #322 from haikow/feature/comment-feed-logic
Feature/comment feed logic
This commit is contained in:
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module github.com/xpzouying/xiaohongshu-mcp
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/h2non/filetype v1.1.3
|
||||
@@ -15,7 +16,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go/v4 v4.7.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -79,8 +79,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
|
||||
@@ -250,6 +250,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{
|
||||
|
||||
@@ -622,3 +622,77 @@ 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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ type FilterOption struct {
|
||||
type FeedDetailArgs struct {
|
||||
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
||||
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
||||
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批评论)"`
|
||||
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批前十条一级评论)"`
|
||||
ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"是否点击'更多回复'按钮 (默认: false)"`
|
||||
MaxRepliesThreshold int `json:"max_replies_threshold,omitempty" jsonschema:"回复数量阈值,超过此数量的'更多'按钮将被跳过 (0表示不跳过任何, 默认: 10)"`
|
||||
MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载评论数(0表示加载所有, 默认: 0)"`
|
||||
MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载一级评论数(0表示加载所有一级评论, 默认: 0)"`
|
||||
ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"滚动速度: 'slow'|'normal'|'fast' (默认: 'normal')"`
|
||||
}
|
||||
|
||||
@@ -67,6 +67,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:"回复内容"`
|
||||
}
|
||||
|
||||
// LikeFeedArgs 点赞参数
|
||||
type LikeFeedArgs struct {
|
||||
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
||||
@@ -267,7 +276,33 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
||||
}),
|
||||
)
|
||||
|
||||
// 工具 10: 发布视频(仅本地文件)
|
||||
// 工具 10: 回复评论
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
// 工具 11: 发布视频(仅本地文件)
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "publish_with_video",
|
||||
@@ -285,7 +320,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
||||
}),
|
||||
)
|
||||
|
||||
// 工具 11: 点赞笔记
|
||||
// 工具 12: 点赞笔记
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "like_feed",
|
||||
@@ -302,7 +337,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
||||
}),
|
||||
)
|
||||
|
||||
// 工具 12: 收藏笔记
|
||||
// 工具 13: 收藏笔记
|
||||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "favorite_feed",
|
||||
|
||||
@@ -49,6 +49,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)
|
||||
api.GET("/user/me", appServer.myProfileHandler)
|
||||
}
|
||||
|
||||
|
||||
23
service.go
23
service.go
@@ -458,6 +458,29 @@ func (s *XiaohongshuService) UnfavoriteFeed(ctx context.Context, feedID, xsecTok
|
||||
return &ActionResult{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
|
||||
}
|
||||
|
||||
func newBrowser() *headless_browser.Browser {
|
||||
return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath()))
|
||||
}
|
||||
|
||||
20
types.go
20
types.go
@@ -40,7 +40,7 @@ type CommentLoadConfig struct {
|
||||
ClickMoreReplies bool `json:"click_more_replies,omitempty"`
|
||||
// 回复数量阈值,超过这个数量的"更多"按钮将被跳过(0表示不跳过任何)
|
||||
MaxRepliesThreshold int `json:"max_replies_threshold,omitempty"`
|
||||
// 最大加载评论数(comment-item数量),0表示加载所有
|
||||
// 最大加载评论数(.parent-comment数量),0表示加载所有
|
||||
MaxCommentItems int `json:"max_comment_items,omitempty"`
|
||||
// 滚动速度等级: slow(慢速), normal(正常), fast(快速)
|
||||
ScrollSpeed string `json:"scroll_speed,omitempty"`
|
||||
@@ -79,6 +79,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"`
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -20,31 +22,252 @@ 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)
|
||||
// 不使用 Context(ctx),避免继承外部 context 的超时
|
||||
page := f.page.Timeout(60 * time.Second)
|
||||
|
||||
// 构建详情页 URL
|
||||
url := makeFeedDetailURL(feedID, xsecToken)
|
||||
|
||||
logrus.Infof("Opening feed detail page: %s", url)
|
||||
logrus.Infof("打开 feed 详情页: %s", url)
|
||||
|
||||
// 导航到详情页
|
||||
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)
|
||||
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)
|
||||
|
||||
elem := page.MustElement("div.input-box div.content-edit span")
|
||||
elem.MustClick()
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
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 {
|
||||
// 增加超时时间,因为需要滚动查找评论
|
||||
// 注意:不使用 Context(ctx),避免继承外部 context 的超时
|
||||
page := f.page.Timeout(5 * time.Minute)
|
||||
url := makeFeedDetailURL(feedID, xsecToken)
|
||||
logrus.Infof("打开 feed 详情页进行回复: %s", url)
|
||||
|
||||
// 导航到详情页
|
||||
page.MustNavigate(url)
|
||||
page.MustWaitDOMStable()
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 检测页面是否可访问
|
||||
if err := checkPageAccessible(page); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 等待评论容器加载
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 使用 Go 实现的查找逻辑
|
||||
commentEl, err := findCommentElement(page, commentID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法找到评论: %w", err)
|
||||
}
|
||||
|
||||
// 滚动到评论位置
|
||||
logrus.Info("滚动到评论位置...")
|
||||
commentEl.MustScrollIntoView()
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
logrus.Info("准备点击回复按钮")
|
||||
|
||||
// 查找并点击回复按钮
|
||||
replyBtn, err := commentEl.Element(".right .interactions .reply")
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法找到回复按钮: %w", err)
|
||||
}
|
||||
|
||||
if err := replyBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return fmt.Errorf("点击回复按钮失败: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 查找回复输入框
|
||||
inputEl, err := page.Element("div.input-box div.content-edit p.content-input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法找到回复输入框: %w", err)
|
||||
}
|
||||
|
||||
// 输入内容
|
||||
if err := inputEl.Input(content); err != nil {
|
||||
return fmt.Errorf("输入回复内容失败: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 查找并点击提交按钮
|
||||
submitBtn, err := page.Element("div.bottom button.submit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法找到提交按钮: %w", err)
|
||||
}
|
||||
|
||||
if err := submitBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return fmt.Errorf("点击提交按钮失败: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
logrus.Infof("回复评论成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// findCommentElement 查找指定评论元素(参考 feed_detail.go 的滚动逻辑)
|
||||
func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) {
|
||||
logrus.Infof("开始查找评论 - commentID: %s, userID: %s", commentID, userID)
|
||||
|
||||
const maxAttempts = 100
|
||||
const scrollInterval = 800 * time.Millisecond
|
||||
|
||||
// 先滚动到评论区
|
||||
scrollToCommentsArea(page)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
var lastCommentCount = 0
|
||||
stagnantChecks := 0
|
||||
|
||||
logrus.Infof("开始循环查找,最大尝试次数: %d", maxAttempts)
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
logrus.Infof("=== 查找尝试 %d/%d ===", attempt+1, maxAttempts)
|
||||
|
||||
// === 1. 检查是否到达底部 ===
|
||||
if checkEndContainer(page) {
|
||||
logrus.Info("已到达评论底部,未找到目标评论")
|
||||
break
|
||||
}
|
||||
|
||||
// === 2. 获取当前评论数量 ===
|
||||
currentCount := getCommentCount(page)
|
||||
logrus.Infof("当前评论数: %d", currentCount)
|
||||
|
||||
if currentCount != lastCommentCount {
|
||||
logrus.Infof("✓ 评论数增加: %d -> %d", lastCommentCount, currentCount)
|
||||
lastCommentCount = currentCount
|
||||
stagnantChecks = 0
|
||||
} else {
|
||||
stagnantChecks++
|
||||
if stagnantChecks%5 == 0 {
|
||||
logrus.Infof("评论数停滞 %d 次", stagnantChecks)
|
||||
}
|
||||
}
|
||||
|
||||
// === 3. 停滞检测 ===
|
||||
if stagnantChecks >= 10 {
|
||||
logrus.Info("评论数量停滞超过10次,可能已加载完所有评论")
|
||||
break
|
||||
}
|
||||
|
||||
// === 4. 先滚动到最后一个评论(触发懒加载)===
|
||||
if currentCount > 0 {
|
||||
logrus.Infof("滚动到最后一个评论(共 %d 条)", currentCount)
|
||||
|
||||
// 使用 Go 获取所有评论元素
|
||||
elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment, .comment-item, .comment")
|
||||
if err == nil && len(elements) > 0 {
|
||||
// 滚动到最后一个评论
|
||||
lastComment := elements[len(elements)-1]
|
||||
err := lastComment.ScrollIntoView()
|
||||
if err != nil {
|
||||
logrus.Warnf("滚动到最后一个评论失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
logrus.Warnf("未找到评论元素: %v", err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// === 5. 继续向下滚动 ===
|
||||
logrus.Infof("继续向下滚动...")
|
||||
_, err := page.Eval(`() => { window.scrollBy(0, window.innerHeight * 0.8); return true; }`)
|
||||
if err != nil {
|
||||
logrus.Warnf("滚动失败: %v", err)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// === 6. 滚动后立即查找(边滚动边查找)===
|
||||
// 优先通过 commentID 查找(使用 Timeout 避免长时间等待)
|
||||
if commentID != "" {
|
||||
selector := fmt.Sprintf("#comment-%s", commentID)
|
||||
logrus.Infof("尝试通过 commentID 查找: %s", selector)
|
||||
|
||||
// 使用 Timeout 避免长时间等待
|
||||
el, err := page.Timeout(2 * time.Second).Element(selector)
|
||||
if err == nil && el != nil {
|
||||
logrus.Infof("✓ 通过 commentID 找到评论: %s (尝试 %d 次)", commentID, attempt+1)
|
||||
return el, nil
|
||||
}
|
||||
logrus.Infof("未找到 commentID (2秒超时)")
|
||||
}
|
||||
|
||||
// 通过 userID 查找
|
||||
if userID != "" {
|
||||
logrus.Infof("尝试通过 userID 查找: %s", userID)
|
||||
|
||||
// 使用 Timeout 避免长时间等待
|
||||
elements, err := page.Timeout(2 * time.Second).Elements(".comment-item, .comment, .parent-comment")
|
||||
if err == nil && len(elements) > 0 {
|
||||
logrus.Infof("找到 %d 个评论元素", len(elements))
|
||||
for i, el := range elements {
|
||||
// 快速检查,不等待
|
||||
userEl, err := el.Timeout(500 * time.Millisecond).Element(fmt.Sprintf(`[data-user-id="%s"]`, userID))
|
||||
if err == nil && userEl != nil {
|
||||
logrus.Infof("✓ 通过 userID 在第 %d 个元素中找到评论: %s (尝试 %d 次)", i+1, userID, attempt+1)
|
||||
return el, nil
|
||||
}
|
||||
}
|
||||
logrus.Infof("在 %d 个元素中未找到匹配的 userID", len(elements))
|
||||
} else {
|
||||
logrus.Infof("获取评论元素失败或超时: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof("本次尝试未找到目标评论,继续下一轮...")
|
||||
|
||||
// === 7. 等待内容加载 ===
|
||||
time.Sleep(scrollInterval)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 尝试次数: %d", commentID, userID, maxAttempts)
|
||||
}
|
||||
|
||||
@@ -433,4 +433,4 @@ func isElementVisible(elem *rod.Element) bool {
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user