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>
This commit is contained in:
zy
2025-09-30 01:42:37 +08:00
committed by GitHub
parent 85852e3277
commit 4d809f1e84
5 changed files with 147 additions and 103 deletions

40
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Docker Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
default: 'v1.0.0'
permissions:
contents: read
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: xpzouying
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}
xpzouying/xiaohongshu-mcp:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -142,6 +142,10 @@ jobs:
./xiaohongshu-mcp-darwin-arm64 ./xiaohongshu-mcp-darwin-arm64
``` ```
### 🐳 Docker 镜像
Docker 镜像需要手动触发构建,请到 Actions 页面运行 "Docker Release" workflow。
### 📊 构建信息 ### 📊 构建信息
- **Commit**: ${{ github.sha }} - **Commit**: ${{ github.sha }}

View File

@@ -132,59 +132,59 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
// handlePublishVideo 处理发布视频内容(仅本地单个视频文件) // handlePublishVideo 处理发布视频内容(仅本地单个视频文件)
func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]interface{}) *MCPToolResult { func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 发布视频内容(本地)") logrus.Info("MCP: 发布视频内容(本地)")
title, _ := args["title"].(string) title, _ := args["title"].(string)
content, _ := args["content"].(string) content, _ := args["content"].(string)
videoPath, _ := args["video"].(string) videoPath, _ := args["video"].(string)
tagsInterface, _ := args["tags"].([]interface{}) tagsInterface, _ := args["tags"].([]interface{})
var tags []string var tags []string
for _, tag := range tagsInterface { for _, tag := range tagsInterface {
if tagStr, ok := tag.(string); ok { if tagStr, ok := tag.(string); ok {
tags = append(tags, tagStr) tags = append(tags, tagStr)
} }
} }
if videoPath == "" { if videoPath == "" {
return &MCPToolResult{ return &MCPToolResult{
Content: []MCPContent{{ Content: []MCPContent{{
Type: "text", Type: "text",
Text: "发布失败: 缺少本地视频文件路径", Text: "发布失败: 缺少本地视频文件路径",
}}, }},
IsError: true, IsError: true,
} }
} }
logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d", title, len(tags)) logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d", title, len(tags))
// 构建发布请求 // 构建发布请求
req := &PublishVideoRequest{ req := &PublishVideoRequest{
Title: title, Title: title,
Content: content, Content: content,
Video: videoPath, Video: videoPath,
Tags: tags, Tags: tags,
} }
// 执行发布 // 执行发布
result, err := s.xiaohongshuService.PublishVideo(ctx, req) result, err := s.xiaohongshuService.PublishVideo(ctx, req)
if err != nil { if err != nil {
return &MCPToolResult{ return &MCPToolResult{
Content: []MCPContent{{ Content: []MCPContent{{
Type: "text", Type: "text",
Text: "发布失败: " + err.Error(), Text: "发布失败: " + err.Error(),
}}, }},
IsError: true, IsError: true,
} }
} }
resultText := fmt.Sprintf("视频发布成功: %+v", result) resultText := fmt.Sprintf("视频发布成功: %+v", result)
return &MCPToolResult{ return &MCPToolResult{
Content: []MCPContent{{ Content: []MCPContent{{
Type: "text", Type: "text",
Text: resultText, Text: resultText,
}}, }},
} }
} }
// handleListFeeds 处理获取Feeds列表 // handleListFeeds 处理获取Feeds列表

View File

@@ -20,10 +20,10 @@ type PublishContentArgs struct {
// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件) // PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件)
type PublishVideoArgs struct { type PublishVideoArgs struct {
Title string `json:"title" jsonschema:"内容标题小红书限制最多20个中文字或英文单词"` Title string `json:"title" jsonschema:"内容标题小红书限制最多20个中文字或英文单词"`
Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容所有话题标签都用tags参数来生成和提供即可"` Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容所有话题标签都用tags参数来生成和提供即可"`
Video string `json:"video" jsonschema:"本地视频绝对路径(仅支持单个视频文件,如:/Users/user/video.mp4"` Video string `json:"video" jsonschema:"本地视频绝对路径(仅支持单个视频文件,如:/Users/user/video.mp4"`
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
} }
// SearchFeedsArgs 搜索内容的参数 // SearchFeedsArgs 搜索内容的参数

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"github.com/go-rod/rod" "github.com/go-rod/rod"
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@@ -14,6 +13,7 @@ import (
"github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/cookies"
"github.com/xpzouying/xiaohongshu-mcp/pkg/downloader" "github.com/xpzouying/xiaohongshu-mcp/pkg/downloader"
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
"os"
"time" "time"
) )
@@ -48,28 +48,28 @@ type LoginQrcodeResponse struct {
// PublishResponse 发布响应 // PublishResponse 发布响应
type PublishResponse struct { type PublishResponse struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Images int `json:"images"` Images int `json:"images"`
Status string `json:"status"` Status string `json:"status"`
PostID string `json:"post_id,omitempty"` PostID string `json:"post_id,omitempty"`
} }
// PublishVideoRequest 发布视频请求(仅支持本地单个视频文件) // PublishVideoRequest 发布视频请求(仅支持本地单个视频文件)
type PublishVideoRequest struct { type PublishVideoRequest struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
Video string `json:"video" binding:"required"` Video string `json:"video" binding:"required"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
} }
// PublishVideoResponse 发布视频响应 // PublishVideoResponse 发布视频响应
type PublishVideoResponse struct { type PublishVideoResponse struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Video string `json:"video"` Video string `json:"video"`
Status string `json:"status"` Status string `json:"status"`
PostID string `json:"post_id,omitempty"` PostID string `json:"post_id,omitempty"`
} }
// FeedsListResponse Feeds列表响应 // FeedsListResponse Feeds列表响应
@@ -219,55 +219,55 @@ func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohon
// PublishVideo 发布视频(本地文件) // PublishVideo 发布视频(本地文件)
func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideoRequest) (*PublishVideoResponse, error) { func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideoRequest) (*PublishVideoResponse, error) {
// 标题长度校验 // 标题长度校验
if titleWidth := runewidth.StringWidth(req.Title); titleWidth > 40 { if titleWidth := runewidth.StringWidth(req.Title); titleWidth > 40 {
return nil, fmt.Errorf("标题长度超过限制") return nil, fmt.Errorf("标题长度超过限制")
} }
// 本地视频文件校验 // 本地视频文件校验
if req.Video == "" { if req.Video == "" {
return nil, fmt.Errorf("必须提供本地视频文件") return nil, fmt.Errorf("必须提供本地视频文件")
} }
if _, err := os.Stat(req.Video); err != nil { if _, err := os.Stat(req.Video); err != nil {
return nil, fmt.Errorf("视频文件不存在或不可访问: %v", err) return nil, fmt.Errorf("视频文件不存在或不可访问: %v", err)
} }
// 构建发布内容 // 构建发布内容
content := xiaohongshu.PublishVideoContent{ content := xiaohongshu.PublishVideoContent{
Title: req.Title, Title: req.Title,
Content: req.Content, Content: req.Content,
Tags: req.Tags, Tags: req.Tags,
VideoPath: req.Video, VideoPath: req.Video,
} }
// 执行发布 // 执行发布
if err := s.publishVideo(ctx, content); err != nil { if err := s.publishVideo(ctx, content); err != nil {
return nil, err return nil, err
} }
resp := &PublishVideoResponse{ resp := &PublishVideoResponse{
Title: req.Title, Title: req.Title,
Content: req.Content, Content: req.Content,
Video: req.Video, Video: req.Video,
Status: "发布完成", Status: "发布完成",
} }
return resp, nil return resp, nil
} }
// publishVideo 执行视频发布 // publishVideo 执行视频发布
func (s *XiaohongshuService) publishVideo(ctx context.Context, content xiaohongshu.PublishVideoContent) error { func (s *XiaohongshuService) publishVideo(ctx context.Context, content xiaohongshu.PublishVideoContent) error {
b := newBrowser() b := newBrowser()
defer b.Close() defer b.Close()
page := b.NewPage() page := b.NewPage()
defer page.Close() defer page.Close()
action, err := xiaohongshu.NewPublishVideoAction(page) action, err := xiaohongshu.NewPublishVideoAction(page)
if err != nil { if err != nil {
return err return err
} }
return action.PublishVideo(ctx, content) return action.PublishVideo(ctx, content)
} }
// ListFeeds 获取Feeds列表 // ListFeeds 获取Feeds列表