mirror of
https://github.com/xpzouying/xiaohongshu-mcp.git
synced 2026-08-28 17:45:51 +08:00
c17940fda6
* refactor(humanize): 修正 Click 内部一处延迟的位置 BeforeClick 的语义与实际调用位置不符:调用方先 Delay 再 Click, 而移动发生在 Click 内部。改为在 Click 内部处理。 单测钉住新参数的下限。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(notification): 支持通知的读取、回复与点赞 新增四个能力,MCP 工具与 HTTP 接口同步提供: - get_unread_count GET /api/v1/notifications/unread - list_notifications GET/POST /api/v1/notifications/list - reply_notification POST /api/v1/notifications/reply - like_notification POST /api/v1/notifications/like 列表返回评论者、内容,以及关联笔记的 feed_id / xsec_token,可接 get_feed_detail。状态非正常的条目会被过滤,数量计入 filtered; 关联对象不是笔记时不给出 feed_id。 页面取数由 Go 结构体解析,并补充报文样例单测:字段名对不上时 json.Unmarshal 不报错只给零值,这类回归编译和现有用例都拦不住。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
package xiaohongshu
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/xpzouying/xiaohongshu-mcp/humanize"
|
|
)
|
|
|
|
// NotificationReplyResult 通知回复的结果。
|
|
type NotificationReplyResult struct {
|
|
CommentID string `json:"comment_id"`
|
|
Nickname string `json:"nickname"`
|
|
FeedID string `json:"feed_id,omitempty"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Reply 回复一条评论。
|
|
func (n *NotificationAction) Reply(ctx context.Context, commentID, content string) (*NotificationReplyResult, error) {
|
|
if strings.TrimSpace(commentID) == "" {
|
|
return nil, fmt.Errorf("缺少 comment_id")
|
|
}
|
|
if strings.TrimSpace(content) == "" {
|
|
return nil, fmt.Errorf("回复内容不能为空")
|
|
}
|
|
|
|
page := n.page.Timeout(3 * time.Minute)
|
|
|
|
page.MustNavigate("https://www.xiaohongshu.com/notification").MustWaitLoad()
|
|
humanize.Delay(ctx, humanize.AfterNavigate)
|
|
|
|
target, index, err := n.locate(ctx, page, commentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items, err := page.Elements(`.tabs-content-container > .container`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("未找到通知条目: %w", err)
|
|
}
|
|
if index >= len(items) {
|
|
return nil, fmt.Errorf("通知条目渲染数(%d)少于目标位置(%d),页面可能未加载完", len(items), index)
|
|
}
|
|
item := items[index]
|
|
|
|
humanize.Delay(ctx, humanize.Reading)
|
|
|
|
replyBtn, err := item.Element(`.action-reply`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("该通知没有回复入口(评论可能已删除或不可回复): %w", err)
|
|
}
|
|
|
|
humanize.Delay(ctx, humanize.BeforeClick)
|
|
if err := humanize.Click(replyBtn); err != nil {
|
|
return nil, fmt.Errorf("无法点击回复: %w", err)
|
|
}
|
|
humanize.Delay(ctx, humanize.AfterClick)
|
|
|
|
input, err := item.Element(`textarea.comment-input`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("回复输入框未出现: %w", err)
|
|
}
|
|
|
|
if err := verifyReplyTarget(input, target.from().Nickname); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := humanize.Type(ctx, input, content); err != nil {
|
|
return nil, fmt.Errorf("无法输入回复内容: %w", err)
|
|
}
|
|
humanize.Delay(ctx, humanize.AfterType)
|
|
|
|
submit, err := item.Element(`button.submit`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("未找到发送按钮: %w", err)
|
|
}
|
|
|
|
humanize.Delay(ctx, humanize.BeforeSubmit)
|
|
if err := humanize.Click(submit); err != nil {
|
|
return nil, fmt.Errorf("无法点击发送: %w", err)
|
|
}
|
|
|
|
if err := waitReplyAccepted(item, 8*time.Second); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
humanize.Delay(ctx, humanize.AfterInteract)
|
|
|
|
logrus.Infof("通知回复成功: comment=%s", commentID)
|
|
return &NotificationReplyResult{
|
|
CommentID: commentID,
|
|
Nickname: target.from().Nickname,
|
|
FeedID: target.Item.ID,
|
|
Content: content,
|
|
}, nil
|
|
}
|
|
|
|
// locate 找到目标评论在当前分区里的位置,必要时滚动加载。
|
|
func (n *NotificationAction) locate(ctx context.Context, page *rod.Page, commentID string) (*rawNotification, int, error) {
|
|
const maxRounds = 20
|
|
|
|
for range maxRounds {
|
|
payload, err := n.readTab(page, TabMentions)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
for i, r := range payload.MessageList {
|
|
if r.Comment.ID != commentID {
|
|
continue
|
|
}
|
|
if !r.visible() {
|
|
return nil, 0, fmt.Errorf("该评论已删除或不可见,不能回复: %s", commentID)
|
|
}
|
|
return &r, i, nil
|
|
}
|
|
|
|
if !payload.HasMore {
|
|
return nil, 0, fmt.Errorf("未找到评论 %s,它可能不在「评论和@」里或已被清理", commentID)
|
|
}
|
|
|
|
if err := page.Mouse.Scroll(0, 800, 5); err != nil {
|
|
return nil, 0, fmt.Errorf("滚动查找失败: %w", err)
|
|
}
|
|
humanize.Delay(ctx, humanize.BetweenScroll)
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
return nil, 0, fmt.Errorf("翻找 %d 轮仍未定位到评论 %s", maxRounds, commentID)
|
|
}
|
|
|
|
// verifyReplyTarget 用输入框的 placeholder 核对回复对象。
|
|
func verifyReplyTarget(input *rod.Element, nickname string) error {
|
|
placeholder, err := input.Attribute("placeholder")
|
|
if err != nil || placeholder == nil {
|
|
return fmt.Errorf("读不到回复框提示文字,无法确认回复对象,已中止")
|
|
}
|
|
if nickname != "" && !strings.Contains(*placeholder, nickname) {
|
|
return fmt.Errorf("回复对象不符:期望 %q,实际提示为 %q,已中止", nickname, *placeholder)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitReplyAccepted 等待回复提交完成。
|
|
func waitReplyAccepted(item *rod.Element, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
has, _, err := item.Has(`textarea.comment-input`)
|
|
if err == nil && !has {
|
|
return nil
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("回复未确认成功:发送后输入框仍未收起(可能被限制或发送失败)")
|
|
}
|