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:
40
.github/workflows/docker-release.yml
vendored
Normal file
40
.github/workflows/docker-release.yml
vendored
Normal 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
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
@@ -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列表
|
||||||
|
|||||||
@@ -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 搜索内容的参数
|
||||||
|
|||||||
106
service.go
106
service.go
@@ -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列表
|
||||||
|
|||||||
Reference in New Issue
Block a user