diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c0c7eea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.idea +.vscode +.claude +.cursor +.github +**/*.log +bin +dist +vendor +Dockerfile +docker-compose.yml +docker +.DS_Store + +cookies.json diff --git a/.gitignore b/.gitignore index 9a5a605..d85f07a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ *.so *.dylib +.idea + # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7af20d6 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/cookies/cookies.go b/cookies/cookies.go index 235275d..aa52324 100644 --- a/cookies/cookies.go +++ b/cookies/cookies.go @@ -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 } diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..c0baf58 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,34 @@ +# Docker 使用说明 + + +- [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 +``` diff --git a/docker/sample/docker-compose.yml b/docker/sample/docker-compose.yml new file mode 100644 index 0000000..baad113 --- /dev/null +++ b/docker/sample/docker-compose.yml @@ -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" \ No newline at end of file diff --git a/handlers_api.go b/handlers_api.go index 8f0b227..3366fb3 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -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 diff --git a/main.go b/main.go index 7c5e07b..789ca46 100644 --- a/main.go +++ b/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) diff --git a/mcp_handlers.go b/mcp_handlers.go index 8986876..f6932c0 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -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: 发布内容") diff --git a/routes.go b/routes.go index c476ae6..3985942 100644 --- a/routes.go +++ b/routes.go @@ -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) diff --git a/service.go b/service.go index 3d02658..f6e6965 100644 --- a/service.go +++ b/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) +} diff --git a/streamable_http.go b/streamable_http.go index 81ef6b9..e16ea43 100644 --- a/streamable_http.go +++ b/streamable_http.go @@ -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": diff --git a/types.go b/types.go index ebecdf4..3adca49 100644 --- a/types.go +++ b/types.go @@ -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详情请求 diff --git a/xiaohongshu/login.go b/xiaohongshu/login.go index 0c8c9f7..dd22797 100644 --- a/xiaohongshu/login.go +++ b/xiaohongshu/login.go @@ -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 + } + } + } +}