Files
xiaohongshu-mcp/service.go
zy 4d809f1e84 feat: add manual Docker release workflow (#187)
- 新增 docker-release.yml workflow,支持手动触发 Docker 镜像构建
- 从 release.yml 中移除自动 Docker 构建,避免镜像膨胀
- 使用语义化版本号策略(如 v1.0.0)
- 支持多平台构建(linux/amd64, linux/arm64)
- 硬编码公开信息,只需配置 DOCKERHUB_TOKEN secret
- 自动标记版本号和 latest 标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-30 01:42:37 +08:00

414 lines
9.7 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"
"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"
"os"
"time"
)
// 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"`
}
// 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 {
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 {
return nil, err
}
response := &FeedsListResponse{
Feeds: feeds,
Count: len(feeds),
}
return response, nil
}
func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string) (*FeedsListResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewSearchAction(page)
feeds, err := action.Search(ctx, keyword)
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) (*FeedDetailResponse, error) {
b := newBrowser()
defer b.Close()
page := b.NewPage()
defer page.Close()
// 创建 Feed 详情 action
action := xiaohongshu.NewFeedDetailAction(page)
// 获取 Feed 详情
result, err := action.GetFeedDetail(ctx, feedID, xsecToken)
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()
// 创建 Feed 评论 action
action := xiaohongshu.NewCommentFeedAction(page)
// 发表评论
err := action.PostComment(ctx, feedID, xsecToken, content)
if err != nil {
return nil, err
}
response := &PostCommentResponse{
FeedID: feedID,
Success: true,
Message: "评论发表成功",
}
return response, 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)
}