feat: 支持返回登录二维码与 Docker 部署 (#155)
* feat: 支持返回登录二维码与 Docker 部署 * feat: 完善扫码登录功能 * fix: 修复当存在已经登录的情况,上层还会启动 goroutine的问题,并把 mcp 的返回增加为图片格式
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
2
.gitignore
vendored
@@ -8,6 +8,8 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
.idea
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal 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"]
|
||||
|
||||
@@ -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
34
docker/README.md
Normal 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
|
||||
```
|
||||
13
docker/sample/docker-compose.yml
Normal file
13
docker/sample/docker-compose.yml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
5
main.go
5
main.go
@@ -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)
|
||||
|
||||
|
||||
@@ -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: 发布内容")
|
||||
|
||||
@@ -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)
|
||||
|
||||
76
service.go
76
service.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
2
types.go
2
types.go
@@ -59,6 +59,8 @@ type MCPToolResult struct {
|
||||
type MCPContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// FeedDetailRequest Feed详情请求
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user