Files
xiaohongshu-mcp/service.go
haikow 13ac2e39c3 修复完善笔记详情内容加载 (#301)
* feat: add like and favorite functionality for feeds

- Implemented handleLikeFeed and handleFavoriteFeed methods in mcp_handlers.go to manage liking and favoriting feeds.
- Added LikeFavoriteArgs struct in mcp_server.go for handling parameters.
- Registered new MCP tools for liking and favoriting feeds in registerTools function.
- Introduced LikeFeed and FavoriteFeed methods in XiaohongshuService to interact with the respective actions.
- Created LikeFavoriteAction in a new file to encapsulate the logic for liking and favoriting feeds on the Xiaohongshu platform.

* "优化评论反馈逻辑:简化回复按钮查找和点击流程

* "Fix-build-errors"

* refactor: streamline like and favorite actions in LikeFavoriteAction

- Introduced a generic method `performInteractAction` to handle both liking and favoriting feeds, reducing code duplication.
- Updated logging to reflect the action type being performed (like or favorite).
- Enhanced state verification after interaction to ensure accurate feedback on success or failure.
- Removed the `clickLastMatch` function, simplifying the interaction logic.

* "Add-unlike-and-unfavorite-functionality"

* "Refactor-performInteractAction-function"

* "Refactor-split-LikeFavoriteAction-into-separate-actions"

* "Add-content-length-validation-for-publish"

* refactor: improve comment posting logic with enhanced error handling and stability checks

- Updated the PostComment method to include error handling for navigation and element interactions.
- Replaced sleep calls with more reliable wait mechanisms to ensure page stability.
- Added checks for the presence of input elements and improved logging for better debugging.

* feat: add reply comment functionality for Xiaohongshu feeds

- Implemented handleReplyComment method in mcp_handlers.go to manage replying to comments on feeds.
- Introduced ReplyCommentArgs struct in mcp_server.go for handling parameters related to comment replies.
- Registered a new MCP tool for replying to comments in the registerTools function.
- Added ReplyCommentToFeed method in service.go to interact with the Xiaohongshu platform for comment replies.
- Enhanced error handling for missing parameters in the reply process.

* refactor: enhance reply comment functionality with improved error handling and response structure

- Simplified error handling in handleReplyComment to check for both comment_id and user_id simultaneously.
- Updated response message to include both Comment ID and User ID upon successful reply.
- Modified ReplyCommentArgs struct to make comment_id and user_id optional.
- Renamed MCP tool for replying to comments for clarity.

* feat(feed): Migrate loadAllComments feature for GetFeedDetail

* fix

* fix

* fix

* fix

* fix: 添加更多自定义选项操作

* fix

* fix:优化代码结构

* chore: update dependencies and implement retry logic for page interactions

---------

Co-authored-by: chekayo <9827969+chekayo@user.noreply.gitee.com>
2025-12-07 15:04:14 +08:00

514 lines
13 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 main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/go-rod/rod"
"github.com/mattn/go-runewidth"
"github.com/sirupsen/logrus"
"github.com/xpzouying/headless_browser"
"github.com/xpzouying/xiaohongshu-mcp/browser"
"github.com/xpzouying/xiaohongshu-mcp/configs"
"github.com/xpzouying/xiaohongshu-mcp/cookies"
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
)
// XiaohongshuService 小红书业务服务
type XiaohongshuService struct{}
// NewXiaohongshuService 创建小红书服务实例
func NewXiaohongshuService() *XiaohongshuService {
return &XiaohongshuService{}
}
// PublishRequest 发布请求
type PublishRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Images []string `json:"images" binding:"required,min=1"`
Tags []string `json:"tags,omitempty"`
}
// LoginStatusResponse 登录状态响应
type LoginStatusResponse struct {
IsLoggedIn bool `json:"is_logged_in"`
Username string `json:"username,omitempty"`
}
// LoginQrcodeResponse 登录扫码二维码
type LoginQrcodeResponse struct {
Timeout string `json:"timeout"`
IsLoggedIn bool `json:"is_logged_in"`
Img string `json:"img,omitempty"`
}
// PublishResponse 发布响应
type PublishResponse struct {
Title string `json:"title"`
Content string `json:"content"`
Images int `json:"images"`
Status string `json:"status"`
PostID string `json:"post_id,omitempty"`
}
// PublishVideoRequest 发布视频请求(仅支持本地单个视频文件)
type PublishVideoRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Video string `json:"video" binding:"required"`
Tags []string `json:"tags,omitempty"`
}
// PublishVideoResponse 发布视频响应
type PublishVideoResponse struct {
Title string `json:"title"`
Content string `json:"content"`
Video string `json:"video"`
Status string `json:"status"`
PostID string `json:"post_id,omitempty"`
}
// FeedsListResponse Feeds列表响应
type FeedsListResponse struct {
Feeds []xiaohongshu.Feed `json:"feeds"`
Count int `json:"count"`
}
// UserProfileResponse 用户主页响应
type UserProfileResponse struct {
UserBasicInfo xiaohongshu.UserBasicInfo `json:"userBasicInfo"`
Interactions []xiaohongshu.UserInteractions `json:"interactions"`
Feeds []xiaohongshu.Feed `json:"feeds"`
}
// DeleteCookies 删除 cookies 文件,用于登录重置
func (s *XiaohongshuService) DeleteCookies(ctx context.Context) error {
cookiePath := cookies.GetCookiesFilePath()
cookieLoader := cookies.NewLoadCookie(cookiePath)
return cookieLoader.DeleteCookies()
}
// CheckLoginStatus 检查登录状态
func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatusResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
loginAction := xiaohongshu.NewLogin(page)
isLoggedIn, err := loginAction.CheckLoginStatus(ctx)
if err != nil {
return nil, err
}
response := &LoginStatusResponse{
IsLoggedIn: isLoggedIn,
Username: configs.Username,
}
return response, nil
}
// GetLoginQrcode 获取登录的扫码二维码
func (s *XiaohongshuService) GetLoginQrcode(ctx context.Context) (*LoginQrcodeResponse, error) {
b := newBrowser()
page := b.NewPage()
deferFunc := func() {
_ = page.Close()
b.Close()
}
loginAction := xiaohongshu.NewLogin(page)
img, loggedIn, err := loginAction.FetchQrcodeImage(ctx)
if err != nil || loggedIn {
defer deferFunc()
}
if err != nil {
return nil, err
}
timeout := 4 * time.Minute
if !loggedIn {
go func() {
ctxTimeout, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
defer deferFunc()
if loginAction.WaitForLogin(ctxTimeout) {
if er := saveCookies(page); er != nil {
logrus.Errorf("failed to save cookies: %v", er)
}
}
}()
}
return &LoginQrcodeResponse{
Timeout: func() string {
if loggedIn {
return "0s"
}
return timeout.String()
}(),
Img: img,
IsLoggedIn: loggedIn,
}, nil
}
// PublishContent 发布内容
func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishRequest) (*PublishResponse, error) {
// 验证标题长度
// 小红书限制最大40个单位长度
// 中文/日文/韩文占2个单位英文/数字占1个单位
if titleWidth := runewidth.StringWidth(req.Title); titleWidth > 40 {
return nil, fmt.Errorf("标题长度超过限制")
}
// 处理图片下载URL图片或使用本地路径
imagePaths, err := s.processImages(req.Images)
if err != nil {
return nil, err
}
// 构建发布内容
content := xiaohongshu.PublishImageContent{
Title: req.Title,
Content: req.Content,
Tags: req.Tags,
ImagePaths: imagePaths,
}
// 执行发布
if err := s.publishContent(ctx, content); err != nil {
logrus.Errorf("发布内容失败: title=%s %v", content.Title, err)
return nil, err
}
response := &PublishResponse{
Title: req.Title,
Content: req.Content,
Images: len(imagePaths),
Status: "发布完成",
}
return response, nil
}
// processImages 处理图片列表支持URL下载和本地路径
func (s *XiaohongshuService) processImages(images []string) ([]string, error) {
processor := downloader.NewImageProcessor()
return processor.ProcessImages(images)
}
// publishContent 执行内容发布
func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohongshu.PublishImageContent) error {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action, err := xiaohongshu.NewPublishImageAction(page)
if err != nil {
return err
}
// 执行发布
return action.Publish(ctx, content)
}
// PublishVideo 发布视频(本地文件)
func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideoRequest) (*PublishVideoResponse, error) {
// 标题长度校验
if titleWidth := runewidth.StringWidth(req.Title); titleWidth > 40 {
return nil, fmt.Errorf("标题长度超过限制")
}
// 本地视频文件校验
if req.Video == "" {
return nil, fmt.Errorf("必须提供本地视频文件")
}
if _, err := os.Stat(req.Video); err != nil {
return nil, fmt.Errorf("视频文件不存在或不可访问: %v", err)
}
// 构建发布内容
content := xiaohongshu.PublishVideoContent{
Title: req.Title,
Content: req.Content,
Tags: req.Tags,
VideoPath: req.Video,
}
// 执行发布
if err := s.publishVideo(ctx, content); err != nil {
return nil, err
}
resp := &PublishVideoResponse{
Title: req.Title,
Content: req.Content,
Video: req.Video,
Status: "发布完成",
}
return resp, nil
}
// publishVideo 执行视频发布
func (s *XiaohongshuService) publishVideo(ctx context.Context, content xiaohongshu.PublishVideoContent) error {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action, err := xiaohongshu.NewPublishVideoAction(page)
if err != nil {
return err
}
return action.PublishVideo(ctx, content)
}
// ListFeeds 获取Feeds列表
func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
// 创建 Feeds 列表 action
action := xiaohongshu.NewFeedsListAction(page)
// 获取 Feeds 列表
feeds, err := action.GetFeedsList(ctx)
if err != nil {
logrus.Errorf("获取 Feeds 列表失败: %v", err)
return nil, err
}
response := &FeedsListResponse{
Feeds: feeds,
Count: len(feeds),
}
return response, nil
}
func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, filters ...xiaohongshu.FilterOption) (*FeedsListResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewSearchAction(page)
feeds, err := action.Search(ctx, keyword, filters...)
if err != nil {
return nil, err
}
response := &FeedsListResponse{
Feeds: feeds,
Count: len(feeds),
}
return response, nil
}
// GetFeedDetail 获取Feed详情
func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool) (*FeedDetailResponse, error) {
return s.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAllComments, xiaohongshu.DefaultCommentLoadConfig())
}
// GetFeedDetailWithConfig 使用配置获取Feed详情
func (s *XiaohongshuService) GetFeedDetailWithConfig(ctx context.Context, feedID, xsecToken string, loadAllComments bool, config xiaohongshu.CommentLoadConfig) (*FeedDetailResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
// 创建 Feed 详情 action
action := xiaohongshu.NewFeedDetailAction(page)
// 获取 Feed 详情
result, err := action.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAllComments, config)
if err != nil {
return nil, err
}
response := &FeedDetailResponse{
FeedID: feedID,
Data: result,
}
return response, nil
}
// UserProfile 获取用户信息
func (s *XiaohongshuService) UserProfile(ctx context.Context, userID, xsecToken string) (*UserProfileResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewUserProfileAction(page)
result, err := action.UserProfile(ctx, userID, xsecToken)
if err != nil {
return nil, err
}
response := &UserProfileResponse{
UserBasicInfo: result.UserBasicInfo,
Interactions: result.Interactions,
Feeds: result.Feeds,
}
return response, nil
}
// PostCommentToFeed 发表评论到Feed
func (s *XiaohongshuService) PostCommentToFeed(ctx context.Context, feedID, xsecToken, content string) (*PostCommentResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewCommentFeedAction(page)
if err := action.PostComment(ctx, feedID, xsecToken, content); err != nil {
return nil, err
}
return &PostCommentResponse{FeedID: feedID, Success: true, Message: "评论发表成功"}, nil
}
// LikeFeed 点赞笔记
func (s *XiaohongshuService) LikeFeed(ctx context.Context, feedID, xsecToken string) (*ActionResult, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewLikeAction(page)
if err := action.Like(ctx, feedID, xsecToken); err != nil {
return nil, err
}
return &ActionResult{FeedID: feedID, Success: true, Message: "点赞成功或已点赞"}, nil
}
// UnlikeFeed 取消点赞笔记
func (s *XiaohongshuService) UnlikeFeed(ctx context.Context, feedID, xsecToken string) (*ActionResult, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewLikeAction(page)
if err := action.Unlike(ctx, feedID, xsecToken); err != nil {
return nil, err
}
return &ActionResult{FeedID: feedID, Success: true, Message: "取消点赞成功或未点赞"}, nil
}
// FavoriteFeed 收藏笔记
func (s *XiaohongshuService) FavoriteFeed(ctx context.Context, feedID, xsecToken string) (*ActionResult, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewFavoriteAction(page)
if err := action.Favorite(ctx, feedID, xsecToken); err != nil {
return nil, err
}
return &ActionResult{FeedID: feedID, Success: true, Message: "收藏成功或已收藏"}, nil
}
// UnfavoriteFeed 取消收藏笔记
func (s *XiaohongshuService) UnfavoriteFeed(ctx context.Context, feedID, xsecToken string) (*ActionResult, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewFavoriteAction(page)
if err := action.Unfavorite(ctx, feedID, xsecToken); err != nil {
return nil, err
}
return &ActionResult{FeedID: feedID, Success: true, Message: "取消收藏成功或未收藏"}, nil
}
func newBrowser() *headless_browser.Browser {
return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath()))
}
func saveCookies(page *rod.Page) error {
cks, err := page.Browser().GetCookies()
if err != nil {
return err
}
data, err := json.Marshal(cks)
if err != nil {
return err
}
cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath())
return cookieLoader.SaveCookies(data)
}
// withBrowserPage 执行需要浏览器页面的操作的通用函数
func withBrowserPage(fn func(*rod.Page) error) error {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
return fn(page)
}
// GetMyProfile 获取当前登录用户的个人信息
func (s *XiaohongshuService) GetMyProfile(ctx context.Context) (*UserProfileResponse, error) {
var result *xiaohongshu.UserProfileResponse
var err error
err = withBrowserPage(func(page *rod.Page) error {
action := xiaohongshu.NewUserProfileAction(page)
result, err = action.GetMyProfileViaSidebar(ctx)
return err
})
if err != nil {
return nil, err
}
response := &UserProfileResponse{
UserBasicInfo: result.UserBasicInfo,
Interactions: result.Interactions,
Feeds: result.Feeds,
}
return response, nil
}