feat: 支持返回登录二维码与 Docker 部署 (#155)

* feat: 支持返回登录二维码与 Docker 部署

* feat: 完善扫码登录功能

* fix: 修复当存在已经登录的情况,上层还会启动 goroutine的问题,并把 mcp 的返回增加为图片格式
This commit is contained in:
lmxdawn
2025-09-25 19:44:01 +08:00
committed by GitHub
parent cc5038decd
commit a8a2743a51
14 changed files with 314 additions and 5 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.idea
.vscode
.claude
.cursor
.github
**/*.log
bin
dist
vendor
Dockerfile
docker-compose.yml
docker
.DS_Store
cookies.json

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@
*.so
*.dylib
.idea
# Test binary, built with `go test -c`
*.test

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# ---- build stage ----
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app .
# ---- run stage ----
FROM debian:bookworm-slim
WORKDIR /app
# 1. 装 Chromium + 依赖(无头模式运行 rod
RUN apt-get update && \
apt-get install -y --no-install-recommends \
chromium \
ca-certificates \
fonts-liberation \
fonts-noto-cjk \
libasound2 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libnss3 \
libxshmfence1 \
wget \
tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/app .
# 2. 设置默认 Chromium 路径rod 会用)
ENV ROD_BROWSER_BIN=/usr/bin/chromium
EXPOSE 18060
CMD ["./app"]

View File

@@ -56,6 +56,11 @@ func GetCookiesFilePath() string {
return oldPath
}
path := os.Getenv("COOKIES_PATH") // 判断环境变量
if path == "" {
path = "cookies.json" // fallback本地调试时用当前目录
}
// 文件不存在,使用新路径(当前目录)
return "cookies.json"
return path
}

34
docker/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Docker 使用说明
<!-- TOC depthFrom:2 -->
- [1. 自己构建镜像](#1-自己构建镜像)
- [2. 手动 Docker Compose](#2-手动 Docker Compose)
## 1. 自己构建镜像
可以使用源码自己构建镜像,如下:
在有项目的Dockerfile的目录运行
`docker build -t xpzouying/xiaohongshu-mcp .`
`xpzouying/xiaohongshu-mcp`为镜像名称和版本,可以自己起个名字
## 2. 手动 Docker Compose
```
# 启动 docker-compose
docker compose up -d
# 停止 docker-compose
docker compose stop
# 查看实时日志
docker logs -f xpzouying/xiaohongshu-mcp
# 进入容器
docker exec -it xpzouying/xiaohongshu-mcp /bin/bash
# 手动更新容器
docker compose pull && docker compose up -d
```

View File

@@ -0,0 +1,13 @@
services:
xiaohongshu-mcp:
image: xpzouying/xiaohongshu-mcp
container_name: xiaohongshu-mcp
restart: unless-stopped
tty: true
volumes:
- ./data:/app/data
environment:
- ROD_BROWSER_BIN=/usr/bin/chromium # ← 无头浏览器
- COOKIES_PATH=/app/data/cookies.json # ← 程序读取/写入这个路径
ports:
- "18060:18060"

View File

@@ -48,6 +48,19 @@ func (s *AppServer) checkLoginStatusHandler(c *gin.Context) {
respondSuccess(c, status, "检查登录状态成功")
}
// getLoginQrcodeHandler 处理 [GET /api/login/qrcode] 请求。
// 用于生成并返回登录二维码Base64 图片 + 超时时间),供前端展示给用户扫码登录。
func (s *AppServer) getLoginQrcodeHandler(c *gin.Context) {
result, err := s.xiaohongshuService.GetLoginQrcode(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "STATUS_CHECK_FAILED",
"获取登录二维码失败", err.Error())
return
}
respondSuccess(c, result, "获取登录二维码成功")
}
// publishHandler 发布内容
func (s *AppServer) publishHandler(c *gin.Context) {
var req PublishRequest

View File

@@ -2,6 +2,7 @@ package main
import (
"flag"
"os"
"github.com/sirupsen/logrus"
"github.com/xpzouying/xiaohongshu-mcp/configs"
@@ -16,6 +17,10 @@ func main() {
flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径")
flag.Parse()
if len(binPath) == 0 {
binPath = os.Getenv("ROD_BROWSER_BIN")
}
configs.InitHeadless(headless)
configs.SetBinPath(binPath)

View File

@@ -4,8 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/sirupsen/logrus"
"strings"
"time"
)
// MCP 工具处理函数
@@ -34,6 +35,46 @@ func (s *AppServer) handleCheckLoginStatus(ctx context.Context) *MCPToolResult {
}
}
// handleGetLoginQrcode 处理获取登录二维码请求。
// 返回二维码图片的 Base64 编码和超时时间,供前端展示扫码登录。
func (s *AppServer) handleGetLoginQrcode(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 获取登录扫码图片")
result, err := s.xiaohongshuService.GetLoginQrcode(ctx)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "获取登录扫码图片失败: " + err.Error()}},
IsError: true,
}
}
if result.IsLoggedIn {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "你当前已处于登录状态"}},
}
}
now := time.Now()
deadline := func() string {
d, err := time.ParseDuration(result.Timeout)
if err != nil {
return now.Format("2006-01-02 15:04:05")
}
return now.Add(d).Format("2006-01-02 15:04:05")
}()
// 已登录:文本 + 图片
contents := []MCPContent{
{Type: "text", Text: "请用小红书 App 在 " + deadline + " 前扫码登录 👇"},
{
Type: "image",
MimeType: "image/png",
Data: strings.TrimPrefix(result.Img, "data:image/png;base64,"),
},
}
return &MCPToolResult{Content: contents}
}
// handlePublishContent 处理发布内容
func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 发布内容")

View File

@@ -29,6 +29,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
api := router.Group("/api/v1")
{
api.GET("/login/status", appServer.checkLoginStatusHandler)
api.GET("/login/qrcode", appServer.getLoginQrcodeHandler)
api.POST("/publish", appServer.publishHandler)
api.GET("/feeds/list", appServer.listFeedsHandler)
api.GET("/feeds/search", appServer.searchFeedsHandler)

View File

@@ -2,14 +2,18 @@ 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"
"time"
)
// XiaohongshuService 小红书业务服务
@@ -34,6 +38,13 @@ type LoginStatusResponse struct {
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"`
@@ -79,6 +90,54 @@ func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatus
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) {
// 验证标题长度
@@ -266,3 +325,18 @@ func (s *XiaohongshuService) PostCommentToFeed(ctx context.Context, feedID, xsec
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)
}

View File

@@ -164,6 +164,14 @@ func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse {
"properties": map[string]interface{}{},
},
},
{
"name": "get_login_qrcode",
"description": "获取登录二维码(返回 Base64 图片和超时时间)",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
"name": "publish_content",
"description": "发布小红书图文内容",
@@ -311,6 +319,8 @@ func (s *AppServer) processToolCall(ctx context.Context, request *JSONRPCRequest
switch toolName {
case "check_login_status":
result = s.handleCheckLoginStatus(ctx)
case "get_login_qrcode":
result = s.handleGetLoginQrcode(ctx)
case "publish_content":
result = s.handlePublishContent(ctx, toolArgs)
case "list_feeds":

View File

@@ -57,8 +57,10 @@ type MCPToolResult struct {
// MCPContent MCP 内容
type MCPContent struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text"`
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
// FeedDetailRequest Feed详情请求

View File

@@ -55,3 +55,47 @@ func (a *LoginAction) Login(ctx context.Context) error {
return nil
}
func (a *LoginAction) FetchQrcodeImage(ctx context.Context) (string, bool, error) {
pp := a.page.Context(ctx)
// 导航到小红书首页,这会触发二维码弹窗
pp.MustNavigate("https://www.xiaohongshu.com/explore").MustWaitLoad()
// 等待一小段时间让页面完全加载
time.Sleep(2 * time.Second)
// 检查是否已经登录
if exists, _, _ := pp.Has(".main-container .user .link-wrapper .channel"); exists {
return "", true, nil
}
// 获取二维码图片
src, err := pp.MustElement(".login-container .qrcode-img").Attribute("src")
if err != nil {
return "", false, errors.Wrap(err, "get qrcode src failed")
}
if src == nil || len(*src) == 0 {
return "", false, errors.New("qrcode src is empty")
}
return *src, false, nil
}
func (a *LoginAction) WaitForLogin(ctx context.Context) bool {
pp := a.page.Context(ctx)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return false
case <-ticker.C:
el, err := pp.Element(".main-container .user .link-wrapper .channel")
if err == nil && el != nil {
return true
}
}
}
}