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
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.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
|
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, "检查登录状态成功")
|
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 发布内容
|
// publishHandler 发布内容
|
||||||
func (s *AppServer) publishHandler(c *gin.Context) {
|
func (s *AppServer) publishHandler(c *gin.Context) {
|
||||||
var req PublishRequest
|
var req PublishRequest
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/configs"
|
"github.com/xpzouying/xiaohongshu-mcp/configs"
|
||||||
@@ -16,6 +17,10 @@ func main() {
|
|||||||
flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径")
|
flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if len(binPath) == 0 {
|
||||||
|
binPath = os.Getenv("ROD_BROWSER_BIN")
|
||||||
|
}
|
||||||
|
|
||||||
configs.InitHeadless(headless)
|
configs.InitHeadless(headless)
|
||||||
configs.SetBinPath(binPath)
|
configs.SetBinPath(binPath)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MCP 工具处理函数
|
// 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 处理发布内容
|
// handlePublishContent 处理发布内容
|
||||||
func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]interface{}) *MCPToolResult {
|
func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]interface{}) *MCPToolResult {
|
||||||
logrus.Info("MCP: 发布内容")
|
logrus.Info("MCP: 发布内容")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
|
|||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
api.GET("/login/status", appServer.checkLoginStatusHandler)
|
api.GET("/login/status", appServer.checkLoginStatusHandler)
|
||||||
|
api.GET("/login/qrcode", appServer.getLoginQrcodeHandler)
|
||||||
api.POST("/publish", appServer.publishHandler)
|
api.POST("/publish", appServer.publishHandler)
|
||||||
api.GET("/feeds/list", appServer.listFeedsHandler)
|
api.GET("/feeds/list", appServer.listFeedsHandler)
|
||||||
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
||||||
|
|||||||
76
service.go
76
service.go
@@ -2,14 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/go-rod/rod"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/xpzouying/headless_browser"
|
"github.com/xpzouying/headless_browser"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/browser"
|
"github.com/xpzouying/xiaohongshu-mcp/browser"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/configs"
|
"github.com/xpzouying/xiaohongshu-mcp/configs"
|
||||||
|
"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"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XiaohongshuService 小红书业务服务
|
// XiaohongshuService 小红书业务服务
|
||||||
@@ -34,6 +38,13 @@ type LoginStatusResponse struct {
|
|||||||
Username string `json:"username,omitempty"`
|
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 发布响应
|
// PublishResponse 发布响应
|
||||||
type PublishResponse struct {
|
type PublishResponse struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -79,6 +90,54 @@ func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatus
|
|||||||
return response, nil
|
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 发布内容
|
// PublishContent 发布内容
|
||||||
func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishRequest) (*PublishResponse, error) {
|
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 {
|
func newBrowser() *headless_browser.Browser {
|
||||||
return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath()))
|
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{}{},
|
"properties": map[string]interface{}{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "get_login_qrcode",
|
||||||
|
"description": "获取登录二维码(返回 Base64 图片和超时时间)",
|
||||||
|
"inputSchema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "publish_content",
|
"name": "publish_content",
|
||||||
"description": "发布小红书图文内容",
|
"description": "发布小红书图文内容",
|
||||||
@@ -311,6 +319,8 @@ func (s *AppServer) processToolCall(ctx context.Context, request *JSONRPCRequest
|
|||||||
switch toolName {
|
switch toolName {
|
||||||
case "check_login_status":
|
case "check_login_status":
|
||||||
result = s.handleCheckLoginStatus(ctx)
|
result = s.handleCheckLoginStatus(ctx)
|
||||||
|
case "get_login_qrcode":
|
||||||
|
result = s.handleGetLoginQrcode(ctx)
|
||||||
case "publish_content":
|
case "publish_content":
|
||||||
result = s.handlePublishContent(ctx, toolArgs)
|
result = s.handlePublishContent(ctx, toolArgs)
|
||||||
case "list_feeds":
|
case "list_feeds":
|
||||||
|
|||||||
6
types.go
6
types.go
@@ -57,8 +57,10 @@ type MCPToolResult struct {
|
|||||||
|
|
||||||
// MCPContent MCP 内容
|
// MCPContent MCP 内容
|
||||||
type MCPContent struct {
|
type MCPContent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Data string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedDetailRequest Feed详情请求
|
// FeedDetailRequest Feed详情请求
|
||||||
|
|||||||
@@ -55,3 +55,47 @@ func (a *LoginAction) Login(ctx context.Context) error {
|
|||||||
|
|
||||||
return nil
|
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