修复绑定滑动评论区事件及添加使用说明 (#324)
* fix: 修复滑动绑定事件评论 * fix: fix * fix: fix * fix: 修复没有评论的场景 * fix * fix: fix --------- Co-authored-by: chekayo <9827969+chekayo@user.noreply.gitee.com>
This commit is contained in:
@@ -47,11 +47,11 @@ type FilterOption struct {
|
|||||||
type FeedDetailArgs struct {
|
type FeedDetailArgs struct {
|
||||||
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
||||||
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
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仅返回前10条一级评论(默认),true滚动加载更多评论"`
|
||||||
ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"是否点击'更多回复'按钮 (默认: false)"`
|
Limit int `json:"limit,omitempty" jsonschema:"【仅当load_all_comments为true时生效】限制加载的一级评论数量。例如20表示最多加载20条,默认20"`
|
||||||
MaxRepliesThreshold int `json:"max_replies_threshold,omitempty" jsonschema:"回复数量阈值,超过此数量的'更多'按钮将被跳过 (0表示不跳过任何, 默认: 10)"`
|
ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"【仅当load_all_comments为true时生效】是否展开二级回复。true展开子评论,false不展开(默认)"`
|
||||||
MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载一级评论数(0表示加载所有一级评论, 默认: 0)"`
|
ReplyLimit int `json:"reply_limit,omitempty" jsonschema:"【仅当click_more_replies为true时生效】跳过回复数过多的评论。例如10表示跳过超过10条回复的,默认10"`
|
||||||
ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"滚动速度: 'slow'|'normal'|'fast' (默认: 'normal')"`
|
ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"【仅当load_all_comments为true时生效】滚动速度slow慢速、normal正常、fast快速"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserProfileArgs 获取用户主页的参数
|
// UserProfileArgs 获取用户主页的参数
|
||||||
@@ -226,18 +226,38 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
mcp.AddTool(server,
|
mcp.AddTool(server,
|
||||||
&mcp.Tool{
|
&mcp.Tool{
|
||||||
Name: "get_feed_detail",
|
Name: "get_feed_detail",
|
||||||
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表",
|
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表。默认返回前10条一级评论,如需更多评论请设置load_all_comments=true",
|
||||||
},
|
},
|
||||||
withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
|
withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
"load_all_comments": args.LoadAllComments,
|
"load_all_comments": args.LoadAllComments,
|
||||||
"click_more_replies": args.ClickMoreReplies,
|
|
||||||
"max_replies_threshold": args.MaxRepliesThreshold,
|
|
||||||
"max_comment_items": args.MaxCommentItems,
|
|
||||||
"scroll_speed": args.ScrollSpeed,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只有当 load_all_comments=true 时,才处理其他参数
|
||||||
|
if args.LoadAllComments {
|
||||||
|
argsMap["click_more_replies"] = args.ClickMoreReplies
|
||||||
|
|
||||||
|
// 设置评论数量限制,默认20
|
||||||
|
limit := args.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
argsMap["max_comment_items"] = limit
|
||||||
|
|
||||||
|
// 设置回复数量阈值,默认10
|
||||||
|
replyLimit := args.ReplyLimit
|
||||||
|
if replyLimit <= 0 {
|
||||||
|
replyLimit = 10
|
||||||
|
}
|
||||||
|
argsMap["max_replies_threshold"] = replyLimit
|
||||||
|
|
||||||
|
if args.ScrollSpeed != "" {
|
||||||
|
argsMap["scroll_speed"] = args.ScrollSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/avast/retry-go/v4"
|
"github.com/avast/retry-go/v4"
|
||||||
@@ -156,6 +157,11 @@ func (cl *commentLoader) load() error {
|
|||||||
scrollToCommentsArea(cl.page)
|
scrollToCommentsArea(cl.page)
|
||||||
sleepRandom(humanDelayRange.min, humanDelayRange.max)
|
sleepRandom(humanDelayRange.min, humanDelayRange.max)
|
||||||
|
|
||||||
|
// 检查是否没有评论
|
||||||
|
if cl.checkNoComments() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for cl.stats.attempts = 0; cl.stats.attempts < maxAttempts; cl.stats.attempts++ {
|
for cl.stats.attempts = 0; cl.stats.attempts < maxAttempts; cl.stats.attempts++ {
|
||||||
logrus.Debugf("=== 尝试 %d/%d ===", cl.stats.attempts+1, maxAttempts)
|
logrus.Debugf("=== 尝试 %d/%d ===", cl.stats.attempts+1, maxAttempts)
|
||||||
|
|
||||||
@@ -191,6 +197,14 @@ func (cl *commentLoader) calculateMaxAttempts() int {
|
|||||||
return defaultMaxAttempts
|
return defaultMaxAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cl *commentLoader) checkNoComments() bool {
|
||||||
|
if checkNoCommentsArea(cl.page) {
|
||||||
|
logrus.Infof("✓ 检测到无评论区域(这是一片荒地),跳过加载")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (cl *commentLoader) checkComplete() bool {
|
func (cl *commentLoader) checkComplete() bool {
|
||||||
if checkEndContainer(cl.page) {
|
if checkEndContainer(cl.page) {
|
||||||
currentCount := getCommentCount(cl.page)
|
currentCount := getCommentCount(cl.page)
|
||||||
@@ -246,21 +260,18 @@ func (cl *commentLoader) updateState(currentCount int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cl *commentLoader) shouldStopAtTarget(currentCount int) bool {
|
func (cl *commentLoader) shouldStopAtTarget(currentCount int) bool {
|
||||||
if cl.config.MaxCommentItems <= 0 || currentCount < cl.config.MaxCommentItems {
|
// 如果未设置最大评论数,或者还未达到目标,继续加载
|
||||||
|
if cl.config.MaxCommentItems <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if cl.state.stagnantChecks >= stagnantCheckThreshold {
|
// 如果已达到或超过目标评论数,立即停止
|
||||||
logrus.Infof("✓ 已达到目标评论数: %d/%d (停滞%d次), 停止加载",
|
if currentCount >= cl.config.MaxCommentItems {
|
||||||
currentCount, cl.config.MaxCommentItems, cl.state.stagnantChecks)
|
logrus.Infof("✓ 已达到目标评论数: %d/%d, 停止加载",
|
||||||
|
currentCount, cl.config.MaxCommentItems)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if cl.state.stagnantChecks > 0 {
|
|
||||||
logrus.Debugf("已达目标数 %d/%d,再确认 %d 次...",
|
|
||||||
currentCount, cl.config.MaxCommentItems, stagnantCheckThreshold-cl.state.stagnantChecks)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,24 +537,47 @@ func calculateScrollDelta(viewportHeight int, baseRatio float64) float64 {
|
|||||||
|
|
||||||
func scrollToCommentsArea(page *rod.Page) {
|
func scrollToCommentsArea(page *rod.Page) {
|
||||||
logrus.Info("滚动到评论区...")
|
logrus.Info("滚动到评论区...")
|
||||||
page.MustEval(`() => {
|
|
||||||
const container = document.querySelector('.comments-container');
|
// 先定位到评论区
|
||||||
if (container) {
|
if el, err := page.Timeout(2 * time.Second).Element(".comments-container"); err == nil {
|
||||||
container.scrollIntoView({behavior: 'smooth', block: 'start'});
|
el.MustScrollIntoView()
|
||||||
}
|
}
|
||||||
}`)
|
// 等待滚动完成
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// 触发一次小滚动,激活懒加载机制
|
||||||
|
smartScroll(page, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartScroll 智能滚动:触发滚轮事件以正确触发懒加载
|
||||||
|
func smartScroll(page *rod.Page, delta float64) {
|
||||||
|
page.MustEval(`(delta) => {
|
||||||
|
// 查找滚动目标元素
|
||||||
|
let targetElement = document.querySelector('.note-scroller')
|
||||||
|
|| document.querySelector('.interaction-container')
|
||||||
|
|| document.documentElement;
|
||||||
|
|
||||||
|
// 触发滚轮事件(关键!这样才能触发懒加载)
|
||||||
|
const wheelEvent = new WheelEvent('wheel', {
|
||||||
|
deltaY: delta,
|
||||||
|
deltaMode: 0, // 像素模式
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
view: window
|
||||||
|
});
|
||||||
|
targetElement.dispatchEvent(wheelEvent);
|
||||||
|
}`, delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToLastComment(page *rod.Page) {
|
func scrollToLastComment(page *rod.Page) {
|
||||||
page.MustEval(`() => {
|
// 获取所有主评论元素
|
||||||
const container = document.querySelector('.comments-container');
|
elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment")
|
||||||
if (!container) return;
|
if err != nil || len(elements) == 0 {
|
||||||
const comments = container.querySelectorAll('.parent-comment');
|
return
|
||||||
if (comments.length > 0) {
|
|
||||||
const lastComment = comments[comments.length - 1];
|
|
||||||
lastComment.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
||||||
}
|
}
|
||||||
}`)
|
// 滚动到最后一个评论
|
||||||
|
lastComment := elements[len(elements)-1]
|
||||||
|
lastComment.MustScrollIntoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== DOM 查询 ==========
|
// ========== DOM 查询 ==========
|
||||||
@@ -583,13 +617,12 @@ func getCommentCount(page *rod.Page) int {
|
|||||||
// 使用retry-go来处理可能的DOM查询失败
|
// 使用retry-go来处理可能的DOM查询失败
|
||||||
err := retry.Do(
|
err := retry.Do(
|
||||||
func() error {
|
func() error {
|
||||||
evalResult := page.MustEval(`() => {
|
// 使用 Go 获取评论元素
|
||||||
const container = document.querySelector('.comments-container');
|
elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment")
|
||||||
if (!container) return 0;
|
if err != nil {
|
||||||
return container.querySelectorAll('.parent-comment').length;
|
return err
|
||||||
}`)
|
}
|
||||||
|
result = len(elements)
|
||||||
result = evalResult.Int()
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
retry.Attempts(3),
|
retry.Attempts(3),
|
||||||
@@ -614,17 +647,31 @@ func getTotalCommentCount(page *rod.Page) int {
|
|||||||
// 使用retry-go来处理可能的DOM查询失败
|
// 使用retry-go来处理可能的DOM查询失败
|
||||||
err := retry.Do(
|
err := retry.Do(
|
||||||
func() error {
|
func() error {
|
||||||
evalResult := page.MustEval(`() => {
|
// 使用 Go 获取总评论数元素
|
||||||
const container = document.querySelector('.comments-container');
|
totalEl, err := page.Timeout(2 * time.Second).Element(".comments-container .total")
|
||||||
if (!container) return 0;
|
if err != nil {
|
||||||
const totalEl = container.querySelector('.total');
|
return err
|
||||||
if (!totalEl) return 0;
|
}
|
||||||
const text = (totalEl.textContent || '').replace(/\s+/g, '');
|
|
||||||
const match = text.match(/共(\d+)条评论/);
|
// 获取文本内容
|
||||||
return match ? parseInt(match[1], 10) : 0;
|
text, err := totalEl.Text()
|
||||||
}`)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用正则提取数字
|
||||||
|
re := regexp.MustCompile(`共(\d+)条评论`)
|
||||||
|
matches := re.FindStringSubmatch(text)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
count, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result = count
|
||||||
|
} else {
|
||||||
|
result = 0
|
||||||
|
}
|
||||||
|
|
||||||
result = evalResult.Int()
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
retry.Attempts(3),
|
retry.Attempts(3),
|
||||||
@@ -643,20 +690,49 @@ func getTotalCommentCount(page *rod.Page) int {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkNoCommentsArea(page *rod.Page) bool {
|
||||||
|
// 查找无评论区域
|
||||||
|
noCommentsEl, err := page.Timeout(2 * time.Second).Element(".no-comments-text")
|
||||||
|
if err != nil {
|
||||||
|
// 未找到无评论元素,说明有评论或评论区正常
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文本内容
|
||||||
|
text, err := noCommentsEl.Text()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含"这是一片荒地"等关键词
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
return strings.Contains(text, "这是一片荒地")
|
||||||
|
}
|
||||||
|
|
||||||
func checkEndContainer(page *rod.Page) bool {
|
func checkEndContainer(page *rod.Page) bool {
|
||||||
var result bool
|
var result bool
|
||||||
|
|
||||||
// 使用retry-go来处理可能的DOM查询失败
|
// 使用retry-go来处理可能的DOM查询失败
|
||||||
err := retry.Do(
|
err := retry.Do(
|
||||||
func() error {
|
func() error {
|
||||||
evalResult := page.MustEval(`() => {
|
// 使用 Go 查找结束容器
|
||||||
const endContainer = document.querySelector('.end-container');
|
endEl, err := page.Timeout(2 * time.Second).Element(".end-container")
|
||||||
if (!endContainer) return false;
|
if err != nil {
|
||||||
const text = (endContainer.textContent || '').trim().toUpperCase();
|
// 未找到元素,说明未到底部
|
||||||
return text.includes('THE END') || text.includes('THEEND');
|
result = false
|
||||||
}`)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
result = evalResult.Bool()
|
// 获取文本内容
|
||||||
|
text, err := endEl.Text()
|
||||||
|
if err != nil {
|
||||||
|
result = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为大写并检查
|
||||||
|
textUpper := strings.ToUpper(strings.TrimSpace(text))
|
||||||
|
result = strings.Contains(textUpper, "THE END") || strings.Contains(textUpper, "THEEND")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
retry.Attempts(3),
|
retry.Attempts(3),
|
||||||
@@ -680,73 +756,49 @@ func checkEndContainer(page *rod.Page) bool {
|
|||||||
func checkPageAccessible(page *rod.Page) error {
|
func checkPageAccessible(page *rod.Page) error {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
// 使用retry-go来处理可能的DOM查询失败
|
// 查找错误提示容器
|
||||||
err := retry.Do(
|
wrapperEl, err := page.Timeout(2 * time.Second).Element(".access-wrapper, .error-wrapper, .not-found-wrapper, .blocked-wrapper")
|
||||||
func() error {
|
if err != nil {
|
||||||
result := page.MustEval(`() => {
|
// 未找到错误容器,说明页面可访问
|
||||||
const wrapper = document.querySelector('.access-wrapper, .error-wrapper, .not-found-wrapper, .blocked-wrapper');
|
return nil
|
||||||
if (!wrapper) return null;
|
}
|
||||||
|
|
||||||
const text = wrapper.textContent || wrapper.innerText || '';
|
// 获取文本内容
|
||||||
const keywords = [
|
text, err := wrapperEl.Text()
|
||||||
'当前笔记暂时无法浏览',
|
if err != nil {
|
||||||
'该内容因违规已被删除',
|
// 无法获取文本,假设页面可访问
|
||||||
'该笔记已被删除',
|
return nil
|
||||||
'内容不存在',
|
}
|
||||||
'笔记不存在',
|
|
||||||
'已失效',
|
|
||||||
'私密笔记',
|
|
||||||
'仅作者可见',
|
|
||||||
'因用户设置,你无法查看',
|
|
||||||
'因违规无法查看'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const kw of keywords) {
|
// 检查关键词
|
||||||
if (text.includes(kw)) {
|
keywords := []string{
|
||||||
return kw;
|
"当前笔记暂时无法浏览",
|
||||||
|
"该内容因违规已被删除",
|
||||||
|
"该笔记已被删除",
|
||||||
|
"内容不存在",
|
||||||
|
"笔记不存在",
|
||||||
|
"已失效",
|
||||||
|
"私密笔记",
|
||||||
|
"仅作者可见",
|
||||||
|
"因用户设置,你无法查看",
|
||||||
|
"因违规无法查看",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if strings.Contains(text, kw) {
|
||||||
|
logrus.Warnf("笔记不可访问: %s", kw)
|
||||||
|
return fmt.Errorf("笔记不可访问: %s", kw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.trim()) {
|
// 如果有文本但不匹配关键词,返回未知错误
|
||||||
return '未知错误: ' + text.trim();
|
trimmedText := strings.TrimSpace(text)
|
||||||
}
|
if trimmedText != "" {
|
||||||
return null;
|
logrus.Warnf("笔记不可访问(未知原因): %s", trimmedText)
|
||||||
}`)
|
return fmt.Errorf("笔记不可访问: %s", trimmedText)
|
||||||
|
|
||||||
rawJSON, marshalErr := result.MarshalJSON()
|
|
||||||
if marshalErr != nil {
|
|
||||||
return fmt.Errorf("无法序列化页面状态检查结果: %w", marshalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(rawJSON) != "null" {
|
|
||||||
var reason string
|
|
||||||
if unmarshalErr := json.Unmarshal(rawJSON, &reason); unmarshalErr == nil {
|
|
||||||
logrus.Warnf("笔记不可访问: %s", reason)
|
|
||||||
return fmt.Errorf("笔记不可访问: %s", reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawReason := string(rawJSON)
|
|
||||||
logrus.Warnf("笔记不可访问,且无法解析原因: %s", rawReason)
|
|
||||||
return fmt.Errorf("笔记不可访问,无法解析原因: %s", rawReason)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
retry.Attempts(3),
|
|
||||||
retry.Delay(200*time.Millisecond),
|
|
||||||
retry.MaxJitter(300*time.Millisecond),
|
|
||||||
retry.OnRetry(func(n uint, err error) {
|
|
||||||
logrus.Debugf("页面可访问性检查重试 #%d: %v", n, err)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// If the error is nil, it means no access issue was found
|
|
||||||
if err == nil {
|
|
||||||
return nil // Page is accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the original error from the retry operation
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 数据提取 ==========
|
// ========== 数据提取 ==========
|
||||||
|
|||||||
Reference in New Issue
Block a user