From adbfc4321a8404c82eab78e2b780d58b0a09bffc Mon Sep 17 00:00:00 2001 From: zy Date: Sun, 26 Oct 2025 21:36:07 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8D=90=E8=B5=A0?= =?UTF-8?q?=E5=90=8D=E5=8D=95=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增加捐赠名单 * update docs: add intro header * Update donation amounts and summary in DONATIONS.md --- DONATIONS.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 42 ++++++++++++++++++----------------- 2 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 DONATIONS.md diff --git a/DONATIONS.md b/DONATIONS.md new file mode 100644 index 0000000..8974995 --- /dev/null +++ b/DONATIONS.md @@ -0,0 +1,63 @@ +# 赞赏与公益捐赠公开账本 + +本项目的所有赞赏,将全部用于公益捐赠。 + +> 本页按月公开记录:收到的赞赏(默认匿名或使用对方指定昵称)、对应捐出、以及捐赠凭证截图(已脱敏)。 +> 如需更正/撤回署名,请开 Issue 或通过邮箱联系。 + +## 摘要 + +- 累计收到赞赏:¥ 179.92 +- 累计捐赠:¥ 200 +- 最近更新时间:2025-10-26 + +--- + +## 维护说明 + +- **隐私**:默认匿名展示;仅在赞助者明确授权时展示昵称。请在截图中打码/涂抹交易号、手机号、邮箱、二维码关键元素等敏感信息。 +- **更正机制**:如有遗漏或需要修改,请开 Issue;所有更动保留在 Git 历史中。 + +--- + +## 月度明细 + +### 2025-10 + +**本月小结** + +- 收到赞赏合计:¥ 109.93 +- 捐出合计:¥ 200.00。 9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。 + +PixPin_2025-10-26_21-34-08 + + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-10-11 | Sijin Yang | 29.99 | 赞赏码 | +| 2025-10-13 | Sijin Yang | 29.99 | 赞赏码 | +| 2025-10-16 | RESOLUTION | 9.99 | 赞赏码 | +| 2025-10-17 | Sijin Yang | 9.99 | 赞赏码 | +| 2025-10-19 | 无名大侠 | 9.99 | 赞赏码 | +| 2025-10-22 | Sijin Yang | 9.99 | 赞赏码 | +| 2025-10-22 | 无名大侠 | 9.99 | 赞赏码 | + +### 2025-09 + +**本月小结** + +- 收到赞赏合计:¥ 69.99 +- 捐出合计:9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-09-23 | 米爸 | 50.00 | 微信红包 | +| 2025-09-27 | 麦子 | 19.99 | 赞赏码 | + +--- + +## 变更记录 + +- 2025-10-26:初始化赞赏记录。汇总 2025 年 9 月、10 月份的赞赏,捐赠给「春蕾计划她们想上学」。 diff --git a/README.md b/README.md index f772b31..a93b766 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # xiaohongshu-mcp + [![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) + MCP for 小红书/xiaohongshu.com。 @@ -16,6 +18,23 @@ MCP for 小红书/xiaohongshu.com。 [![Star History Chart](https://api.star-history.com/svg?repos=xpzouying/xiaohongshu-mcp&type=Timeline)](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline) +## 赞赏支持 + +本项目所有的赞赏都会用于慈善捐赠。所有的慈善捐赠记录,请参考 [DONATIONS.md](./DONATIONS.md)。 + +**捐赠时,请备注 MCP 以及名字。** +如需更正/撤回署名,请开 Issue 或通过邮箱联系。 + +**支付宝(不展示二维码):** + +通过支付宝向 **xpzouying@gmail.com** 赞赏。 + +**微信:** + +WeChat Pay QR + +## 项目简介 + **主要功能** > 💡 **提示:** 点击下方功能标题可展开查看视频演示 @@ -195,7 +214,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80 - **正文:(非常重要):正文不能超过 1000 个字** - 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。 - (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。 -- Tags:现已支持。添加合适的Tags能带来更多的流量。 +- Tags:现已支持。添加合适的 Tags 能带来更多的流量。 - 根据本人实操,小红书每天的发帖量应该是 **50 篇**。 - **(非常重要)小红书的同一个账号不允许在多个网页端登录**,如果你登录了当前 xiaohongshu-mcp 后,就不要再在其他的网页端登录该账号,否则就会把当前 MCP 的账号“踢出登录”。你可以使用移动 App 端进行查看当前账号信息。 @@ -327,6 +346,7 @@ docker build -t xpzouying/xiaohongshu-mcp . **4. 配置说明** Docker 版本会自动: + - 配置 Chrome 浏览器和中文字体 - 挂载 `./data` 用于存储 cookies - 挂载 `./images` 用于存储发布的图片 @@ -736,18 +756,12 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 **重要:在群里问问题之前,请一定要先仔细看完 README 文档以及查看 Issues。** - -| 【飞书3群】:扫码进入 | 【微信群 9 群】:扫码进入 | +| 【飞书 3 群】:扫码进入 | 【微信群 9 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | qrcode_2qun | WechatIMG119 | - - - - - ## 🙏 致谢贡献者 ✨ 感谢以下所有为本项目做出贡献的朋友!(排名不分先后) @@ -794,15 +808,3 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 | [
@wanpengxie](https://github.com/wanpengxie) | 本项目遵循 [all-contributors](https://github.com/all-contributors/all-contributors) 规范。欢迎任何形式的贡献! - -## 赞赏支持 - -欢迎请作者喝杯咖啡~(随缘支持,感谢!) - -**支付宝(不展示二维码):** - -通过支付宝向 **xpzouying@gmail.com** 赞赏。 - -**微信:** - -WeChat Pay QR From 7cf35fc4aeb83b2ba4e69d4e6289afdebee68e4a Mon Sep 17 00:00:00 2001 From: zy Date: Sun, 26 Oct 2025 21:45:29 +0800 Subject: [PATCH 02/18] Add donation badges to README Added donation badges to README for transparency. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a93b766..8c386b9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ +[![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![获得赞赏](https://img.shields.io/badge/Received-CNY%20179.92-blue?style=flat-square)](./DONATIONS.md) + MCP for 小红书/xiaohongshu.com。 - 我的博客文章:[haha.ai/xiaohongshu-mcp](https://www.haha.ai/xiaohongshu-mcp) From 11a937b84f42414d8a30ba26a02ad174c9de26fa Mon Sep 17 00:00:00 2001 From: Ctrlz <143257420+ctrlz526@users.noreply.github.com> Date: Sun, 2 Nov 2025 19:01:48 +0800 Subject: [PATCH 03/18] feat: add delete cookies functionality for login reset (#275) --- cookies/cookies.go | 10 ++++++++++ handlers_api.go | 17 +++++++++++++++++ mcp_handlers.go | 32 +++++++++++++++++++++++++++++++- mcp_server.go | 32 ++++++++++++++++++++++---------- routes.go | 1 + service.go | 7 +++++++ 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/cookies/cookies.go b/cookies/cookies.go index aa52324..fde7f04 100644 --- a/cookies/cookies.go +++ b/cookies/cookies.go @@ -10,6 +10,7 @@ import ( type Cookier interface { LoadCookies() ([]byte, error) SaveCookies(data []byte) error + DeleteCookies() error } type localCookie struct { @@ -42,6 +43,15 @@ func (c *localCookie) SaveCookies(data []byte) error { return os.WriteFile(c.path, data, 0644) } +// DeleteCookies 删除 cookies 文件。 +func (c *localCookie) DeleteCookies() error { + if _, err := os.Stat(c.path); os.IsNotExist(err) { + // 文件不存在,返回 nil(认为已经删除) + return nil + } + return os.Remove(c.path) +} + // GetCookiesFilePath 获取 cookies 文件路径。 // 为了向后兼容,如果旧路径 /tmp/cookies.json 存在,则继续使用; // 否则使用当前目录下的 cookies.json diff --git a/handlers_api.go b/handlers_api.go index 0dffaca..77b434f 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -3,6 +3,7 @@ package main import ( "net/http" + "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" "github.com/gin-gonic/gin" @@ -63,6 +64,22 @@ func (s *AppServer) getLoginQrcodeHandler(c *gin.Context) { respondSuccess(c, result, "获取登录二维码成功") } +// deleteCookiesHandler 删除 cookies,重置登录状态 +func (s *AppServer) deleteCookiesHandler(c *gin.Context) { + err := s.xiaohongshuService.DeleteCookies(c.Request.Context()) + if err != nil { + respondError(c, http.StatusInternalServerError, "DELETE_COOKIES_FAILED", + "删除 cookies 失败", err.Error()) + return + } + + cookiePath := cookies.GetCookiesFilePath() + respondSuccess(c, map[string]interface{}{ + "cookie_path": cookiePath, + "message": "Cookies 已成功删除,登录状态已重置。下次操作时需要重新登录。", + }, "删除 cookies 成功") +} + // publishHandler 发布内容 func (s *AppServer) publishHandler(c *gin.Context) { var req PublishRequest diff --git a/mcp_handlers.go b/mcp_handlers.go index 8e2c032..88c25dc 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" "strings" "time" @@ -27,7 +28,14 @@ func (s *AppServer) handleCheckLoginStatus(ctx context.Context) *MCPToolResult { } } - resultText := fmt.Sprintf("登录状态检查成功: %+v", status) + // 根据 IsLoggedIn 判断并返回友好的提示 + var resultText string + if status.IsLoggedIn { + resultText = fmt.Sprintf("✅ 已登录\n用户名: %s\n\n你可以使用其他功能了。", status.Username) + } else { + resultText = fmt.Sprintf("❌ 未登录\n\n请使用 get_login_qrcode 工具获取二维码进行登录。") + } + return &MCPToolResult{ Content: []MCPContent{{ Type: "text", @@ -76,6 +84,28 @@ func (s *AppServer) handleGetLoginQrcode(ctx context.Context) *MCPToolResult { return &MCPToolResult{Content: contents} } +// handleDeleteCookies 处理删除 cookies 请求,用于登录重置 +func (s *AppServer) handleDeleteCookies(ctx context.Context) *MCPToolResult { + logrus.Info("MCP: 删除 cookies,重置登录状态") + + err := s.xiaohongshuService.DeleteCookies(ctx) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{Type: "text", Text: "删除 cookies 失败: " + err.Error()}}, + IsError: true, + } + } + + cookiePath := cookies.GetCookiesFilePath() + resultText := fmt.Sprintf("Cookies 已成功删除,登录状态已重置。\n\n删除的文件路径: %s\n\n下次操作时,需要重新登录。", cookiePath) + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: resultText, + }}, + } +} + // handlePublishContent 处理发布内容 func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]interface{}) *MCPToolResult { logrus.Info("MCP: 发布内容") diff --git a/mcp_server.go b/mcp_server.go index 9237059..d72ffb0 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -153,7 +153,19 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 3: 发布内容 + // 工具 3: 删除 cookies(登录重置) + mcp.AddTool(server, + &mcp.Tool{ + Name: "delete_cookies", + Description: "删除 cookies 文件,重置登录状态。删除后需要重新登录。", + }, + withPanicRecovery("delete_cookies", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + result := appServer.handleDeleteCookies(ctx) + return convertToMCPResult(result), nil, nil + }), + ) + + // 工具 4: 发布内容 mcp.AddTool(server, &mcp.Tool{ Name: "publish_content", @@ -172,7 +184,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 4: 获取Feed列表 + // 工具 5: 获取Feed列表 mcp.AddTool(server, &mcp.Tool{ Name: "list_feeds", @@ -184,7 +196,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 5: 搜索内容 + // 工具 6: 搜索内容 mcp.AddTool(server, &mcp.Tool{ Name: "search_feeds", @@ -196,7 +208,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 6: 获取Feed详情 + // 工具 7: 获取Feed详情 mcp.AddTool(server, &mcp.Tool{ Name: "get_feed_detail", @@ -212,7 +224,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 7: 获取用户主页 + // 工具 8: 获取用户主页 mcp.AddTool(server, &mcp.Tool{ Name: "user_profile", @@ -228,7 +240,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 8: 发表评论 + // 工具 9: 发表评论 mcp.AddTool(server, &mcp.Tool{ Name: "post_comment_to_feed", @@ -245,7 +257,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 9: 发布视频(仅本地文件) + // 工具 10: 发布视频(仅本地文件) mcp.AddTool(server, &mcp.Tool{ Name: "publish_with_video", @@ -263,7 +275,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 10: 点赞笔记 + // 工具 11: 点赞笔记 mcp.AddTool(server, &mcp.Tool{ Name: "like_feed", @@ -280,7 +292,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 11: 收藏笔记 + // 工具 12: 收藏笔记 mcp.AddTool(server, &mcp.Tool{ Name: "favorite_feed", @@ -297,7 +309,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - logrus.Infof("Registered %d MCP tools", 11) + logrus.Infof("Registered %d MCP tools", 12) } // convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式 diff --git a/routes.go b/routes.go index 58ff566..f8b31ed 100644 --- a/routes.go +++ b/routes.go @@ -40,6 +40,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { { api.GET("/login/status", appServer.checkLoginStatusHandler) api.GET("/login/qrcode", appServer.getLoginQrcodeHandler) + api.DELETE("/login/cookies", appServer.deleteCookiesHandler) api.POST("/publish", appServer.publishHandler) api.POST("/publish_video", appServer.publishVideoHandler) api.GET("/feeds/list", appServer.listFeedsHandler) diff --git a/service.go b/service.go index 16dc938..cd502fd 100644 --- a/service.go +++ b/service.go @@ -86,6 +86,13 @@ type UserProfileResponse struct { Feeds []xiaohongshu.Feed `json:"feeds"` } +// DeleteCookies 删除 cookies 文件,用于登录重置 +func (s *XiaohongshuService) DeleteCookies(ctx context.Context) error { + cookiePath := cookies.GetCookiesFilePath() + cookieLoader := cookies.NewLoadCookie(cookiePath) + return cookieLoader.DeleteCookies() +} + // CheckLoginStatus 检查登录状态 func (s *XiaohongshuService) CheckLoginStatus(ctx context.Context) (*LoginStatusResponse, error) { b := newBrowser() From 4dbb01811ed541b89e507ed2247cb9fd236a30ca Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 19:04:14 +0800 Subject: [PATCH 04/18] docs: add ctrlz526 as a contributor for code (#277) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 5 ++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 5121767..28a58a8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -156,6 +156,15 @@ "contributions": [ "code" ] + }, + { + "login": "ctrlz526", + "name": "Ctrlz", + "avatar_url": "https://avatars.githubusercontent.com/u/143257420?v=4", + "profile": "https://github.com/ctrlz526", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 8c386b9..01dfd2f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # xiaohongshu-mcp - -[![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) - +[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) [![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) @@ -795,6 +793,7 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 Carlo
Carlo

💻 hrz
hrz

💻 + Ctrlz
Ctrlz

💻 From fb7c94e36ce0850f9a35b2ce64bdf961003a316f Mon Sep 17 00:00:00 2001 From: zy Date: Sun, 2 Nov 2025 19:08:03 +0800 Subject: [PATCH 05/18] Update WeChat group QR code in README --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 01dfd2f..1bef343 100644 --- a/README.md +++ b/README.md @@ -759,9 +759,12 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 -| 【飞书 3 群】:扫码进入 | 【微信群 9 群】:扫码进入 | +| 【飞书 3 群】:扫码进入 | 【微信群 10 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| qrcode_2qun | WechatIMG119 | + + + ## 🙏 致谢贡献者 ✨ From 19471706ad020ba4fe59c2bc79669e3ab5213d9a Mon Sep 17 00:00:00 2001 From: zy Date: Wed, 5 Nov 2025 23:27:40 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20ARM64=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E7=9A=84=20Docker=20=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Dockerfile.arm64 用于构建 ARM64 架构镜像,与现有 amd64 镜像互不影响。 主要变更: - 新建 Dockerfile.arm64,使用 Chromium 替代 Google Chrome - 移除 GOARCH 硬编码,支持多架构构建 - 更新 GitHub Actions 工作流,添加独立的 ARM64 构建步骤 - ARM64 镜像使用独立标签:version-arm64 和 latest-arm64 技术说明: - Google Chrome 不支持 Linux ARM64 - go-rod 会自动从 Playwright CDN 下载 ARM64 版 Chromium - 两个架构的镜像构建相互独立,失败风险隔离 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/workflows/docker-release.yml | 16 ++++- Dockerfile.arm64 | 88 ++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.arm64 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index e8ee901..99e4551 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -27,14 +27,28 @@ jobs: username: xpzouying password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push Docker image + - name: Build and push Docker image (AMD64) uses: docker/build-push-action@v5 with: context: . + file: ./Dockerfile push: true platforms: linux/amd64 tags: | xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }} xpzouying/xiaohongshu-mcp:latest cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push Docker image (ARM64) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.arm64 + push: true + platforms: linux/arm64 + tags: | + xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64 + xpzouying/xiaohongshu-mcp:latest-arm64 + cache-from: type=gha cache-to: type=gha,mode=max \ No newline at end of file diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 0000000..1f8f395 --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,88 @@ +# Dockerfile for ARM64 architecture +# This Dockerfile uses Chromium (auto-downloaded by go-rod) instead of Google Chrome +# because Google Chrome does not provide official Linux ARM64 builds. + +# ---- build stage ---- +FROM golang:1.24 AS builder + +WORKDIR /src +# 配置 Go 模块代理为国内源 +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=sum.golang.google.cn + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# 移除 GOARCH 硬编码,让构建系统根据目标平台自动选择架构 +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app . + +# ---- run stage ---- +FROM ubuntu:22.04 + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +# 1. 先安装必要工具,然后配置阿里云镜像源 +RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \ + sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \ + sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list + +# 2. 安装 Chromium 运行所需的依赖库 +# 注意:不安装 Google Chrome,因为它不支持 ARM64 +# go-rod 会在首次运行时自动从 Playwright CDN 下载 ARM64 版本的 Chromium +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libc6 \ + libcairo2 \ + libcups2 \ + libdbus-1-3 \ + libexpat1 \ + libfontconfig1 \ + libgbm1 \ + libgcc1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libstdc++6 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxss1 \ + libxtst6 \ + lsb-release \ + wget \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/app . + +# 3. 创建共享目录并设置权限 +RUN mkdir -p /app/images && \ + chmod 777 /app/images + +# 4. 不设置 ROD_BROWSER_BIN 环境变量 +# go-rod 会自动检测并下载适合 ARM64 架构的 Chromium 浏览器 +# Chromium 下载源:https://playwright.azureedge.net/builds/chromium/ + +EXPOSE 18060 + +CMD ["./app"] From 2397e5ffb2bf35032892699f2b825928eb575db8 Mon Sep 17 00:00:00 2001 From: zy Date: Wed, 5 Nov 2025 23:49:07 +0800 Subject: [PATCH 07/18] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Docker=20Pul?= =?UTF-8?q?ls=20=E5=BE=BD=E7=AB=A0=E5=88=B0=20README=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 README 顶部添加 Docker Hub 下载次数徽章,方便用户查看项目热度。 徽章特性: - 使用 flat-square 风格与现有徽章保持一致 - 添加 Docker logo 图标 - 点击可跳转到 Docker Hub 仓库 - 自动显示镜像总下载次数 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1bef343..5cc724e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) [![获得赞赏](https://img.shields.io/badge/Received-CNY%20179.92-blue?style=flat-square)](./DONATIONS.md) +[![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) MCP for 小红书/xiaohongshu.com。 From ad2495bbfec01cb078f94e67ea2fed649daef9af Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 6 Nov 2025 00:17:11 +0800 Subject: [PATCH 08/18] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E7=89=88=20README=20=E4=B8=8E=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=89=88=E4=BF=9D=E6=8C=81=E5=90=8C=E6=AD=A5=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新贡献者徽章:6 → 17 - 新增捐赠和 Docker Pulls 徽章 - 添加疑难杂症链接和 x-mcp 工具推荐 - 添加赞赏支持章节 - 更新基础运营知识(正文1000字限制、Tags支持状态) - 更新教程列表(Claude Code + Kimi K2、AnythingLLM) - 更新社区群信息(飞书3群、微信10群) - 扩展贡献者列表至17人 --- README_EN.md | 88 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/README_EN.md b/README_EN.md index 80df687..cdedcec 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,19 +1,42 @@ # xiaohongshu-mcp - -[![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors-) - +[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) +[![Donated](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![Received](https://img.shields.io/badge/Received-CNY%20179.92-blue?style=flat-square)](./DONATIONS.md) +[![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + MCP for RedNote (Xiaohongshu) platform. - My blog article: [haha.ai/xiaohongshu-mcp](https://www.haha.ai/xiaohongshu-mcp) +**If you encounter any issues, be sure to check [Common Issues and Solutions](https://github.com/xpzouying/xiaohongshu-mcp/issues/56) first.** + +After checking the **Common Issues** list, if you still can't resolve your deployment problems, we strongly recommend using another tool I've created: [xpzouying/x-mcp](https://github.com/xpzouying/x-mcp). This tool doesn't require deployment - you only need a browser extension to drive your MCP, making it more user-friendly for non-technical users. + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=xpzouying/xiaohongshu-mcp&type=Timeline)](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline) +## Appreciation and Support + +All donations received for this project will be used for charitable giving. For all charitable donation records, please refer to [DONATIONS.md](./DONATIONS.md). + +**When donating, please note "MCP" and your name.** +If you need to correct/withdraw your name attribution, please open an Issue or contact via email. + +**Alipay (QR code not displayed):** + +Donate via Alipay to **xpzouying@gmail.com**. + +**WeChat:** + +WeChat Pay QR + +## Project Overview + **Main Features** > 💡 **Tip:** Click on the feature titles below to expand and view video demonstrations @@ -190,9 +213,10 @@ Get RedNote user's personal profile information, including basic user informatio **RedNote Basic Operation Knowledge** - **Title: (Very Important) RedNote requires titles to not exceed 20 characters** -- Currently only supports image-text posting: From a recommendation perspective, image-text posts get better traffic than pure text. -- (Low priority) Video and pure text support can be considered. 1. I personally feel these two would greatly increase operation complexity; 2. These two types have low value in my use scenarios. -- Tags: Will be supported soon. +- **Content: (Very Important) Content cannot exceed 1000 characters** +- Currently supports both image-text and video posting: From a recommendation perspective, image-text posts get better traffic than video or pure text. +- (Low priority) Pure text support can be considered. 1. I personally feel pure text would greatly increase operation complexity; 2. Pure text has low value in my use scenarios. +- Tags: Now supported. Adding appropriate tags can bring more traffic. - According to my practical experience, RedNote should allow **50 posts** per day. - **(Very Important) RedNote does not allow the same account to login on multiple web platforms**. If you login to the current xiaohongshu-mcp, don't login to that account on other web platforms, otherwise it will "kick out" the current MCP account login. You can use the mobile app to check current account information. @@ -718,6 +742,8 @@ Use xiaohongshu-mcp's video publishing feature. 1. **[n8n Complete Integration Tutorial](./examples/n8n/README.md)** - Workflow automation platform integration 2. **[Cherry Studio Complete Configuration Tutorial](./examples/cherrystudio/README.md)** - Perfect AI client integration +3. **[Claude Code + Kimi K2 Integration Tutorial](./examples/claude-code/claude-code-kimi-k2.md)** - If Claude Code's barrier is too high, then integrate with Kimi domestic LLM! +4. **[AnythingLLM Complete Guide](./examples/anythingLLM/readme.md)** - AnythingLLM is an all-in-one multimodal AI client that supports workflow definition, multiple LLMs, and plugin extensions. > 🎯 **Tip**: Click the links above to view detailed step-by-step tutorials for quick setup of various integration solutions! > @@ -725,38 +751,13 @@ Use xiaohongshu-mcp's video publishing feature. ## 4. RedNote MCP Community Group -Since the project has just started, there will be many issues. Let's create a group to discuss problems together and contribute to the open source project. ~~Scan my WeChat QR code to join the technical discussion group~~. +**Important: Before asking questions in the group, please make sure to read the README documentation thoroughly and check Issues first.** -Due to too many people adding WeChat, WeChat banned my account for being "in an unsafe network environment." (Not sure if it's because of too many people, possibly triggering WeChat's telecom fraud safety detection. Tried: 1. Real-name verification; 2. Bank card binding; 3. Manual appeal; none worked.) + -Switched to Feishu group, scan QR code to join directly - -
-【Feishu Group 1】Full - -![1757903591605_副本](https://github.com/user-attachments/assets/63ad53b9-6e5d-4117-ba61-90a223494501) - -
- -
- 【WeChat Group 1】Full - - WechatIMG119 - -
- -
- 【WeChat Group 2】Full - - WechatIMG119 - -
- - - -| 【Feishu Group 2】: Scan to join | 【WeChat Group 3】: Scan to join | -| ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| 【Feishu Group 3】: Scan to join | 【WeChat Group 10】: Scan to join | +| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| qrcode_2qun | WechatIMG119 | ## 🙏 Thanks to Contributors ✨ @@ -774,6 +775,21 @@ Thanks to all friends who have contributed to this project! (In no particular or Duong Tran
Duong Tran

💻 Angiin
Angiin

💻 Henan Mu
Henan Mu

💻 + Journey
Journey

💻 + + + Eve Yu
Eve Yu

💻 + CooperGuo
CooperGuo

💻 + Banghao Chi
Banghao Chi

💻 + varz1
varz1

💻 + Melo Y Guan
Melo Y Guan

💻 + lmxdawn
lmxdawn

💻 + haikow
haikow

💻 + + + Carlo
Carlo

💻 + hrz
hrz

💻 + Ctrlz
Ctrlz

💻 From 869541b1172407927177c826a486cc8a89893c09 Mon Sep 17 00:00:00 2001 From: zy Date: Sat, 8 Nov 2025 20:17:36 +0800 Subject: [PATCH 09/18] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20CLAUDE.md?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=BB=A3=E7=A0=81=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=92=8C=E6=B3=A8=E9=87=8A=E8=A6=81=E6=B1=82=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增关于代码设计的简洁性要求 - 强调使用中文注释的清晰性和专业性 --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2d6c6b1..e67c825 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,6 @@ - 测试过程中产生的脚本和build中间文件,如果没有必要,则删除. - 所有的feature变更,都需要使用分支进行开发. - 在我未同意之前, 你不能推送到远程. -- 我需要: 1.本地 review; 2.远程 PR review. \ No newline at end of file +- 我需要: 1.本地 review; 2.远程 PR review. +- 不要过度设计, 保持代码的简洁和易读. +- 使用中文注释,一定要简洁明了.专业名词可以用英文. \ No newline at end of file From 40fa09538f47cb76a225e45a7ed5cbbd9f7a3566 Mon Sep 17 00:00:00 2001 From: Angiin Date: Wed, 12 Nov 2025 00:57:43 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20#2.5=20=E5=B8=B8?= =?UTF-8?q?=E8=A7=81=E9=97=AE=E9=A2=98=E7=AD=94=E7=96=91=E9=83=A8=E5=88=86?= =?UTF-8?q?=E3=80=82=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为文档增加了 常见问题大一部分(后续会接着更新) --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 5cc724e..929346e 100644 --- a/README.md +++ b/README.md @@ -739,6 +739,43 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 xiaohongshu-mcp 发布结果 +### 2.5. 💬 MCP 使用常见问题解答 + +--- + +**Q:** 为什么检查登录用户名显示 `xiaghgngshu-mcp`? +**A:** 用户名是写死的。 + +--- + +**Q:** 显示发布成功后,但实际上没有显示? +**A:** 排查步骤如下: +1. 使用 **非无头模式** 重新发布一次。 +2. 更换 **不同的内容** 重新发布。 +3. 登录网页版小红书,查看账号是否被 **风控限制网页版发布**。 +4. 检查 **图片大小** 是否过大。 +5. 确认 **图片路径中没有中文字符**。 +6. 若使用网络图片地址,请确认 **图片链接可正常访问**。 + +--- + +**Q:** 在设备上运行 MCP 程序出现闪退如何解决? +**A:** +1. 建议 **从源码安装**。 +2. 或使用 **Docker 安装 xiaohongshu-mcp**,教程参考: + - [使用 Docker 安装 xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp#:~:text=%E6%96%B9%E5%BC%8F%E4%B8%89%EF%BC%9A%E4%BD%BF%E7%94%A8%20Docker%20%E5%AE%B9%E5%99%A8%EF%BC%88%E6%9C%80%E7%AE%80%E5%8D%95%EF%BC%89) + - [X-MCP 项目页面](https://github.com/xpzouying/x-mcp/) + +--- + +**Q:** 使用 `http://localhost:18060/mcp` 进行 MCP 验证时提示无法连接? +**A:** +- 在 **Docker 环境** 下,请使用 + 👉 [http://host.docker.internal:18060/mcp](http://host.docker.internal:18060/mcp) +- 在 **非 Docker 环境** 下,请使用 **本机 IPv4 地址** 访问。 + +--- + ## 3. 🌟 实战案例展示 (Community Showcases) > 💡 **强烈推荐查看**:这些都是社区贡献者的真实使用案例,包含详细的配置步骤和实战经验! From 7dc1a731c7fe1c24260918d9df55c71fcb1b49ab Mon Sep 17 00:00:00 2001 From: zy Date: Sun, 16 Nov 2025 23:54:36 +0800 Subject: [PATCH 11/18] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 929346e..9ad14ac 100644 --- a/README.md +++ b/README.md @@ -799,8 +799,7 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 | 【飞书 3 群】:扫码进入 | 【微信群 10 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | - +| qrcode_2qun | WechatIMG119 | From 2f8aa1d7ee529fe4d24b6f6d926460eab244d1ee Mon Sep 17 00:00:00 2001 From: flippancy <757410523@qq.com> Date: Sun, 16 Nov 2025 23:57:47 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=9B=BE=E7=89=87=E6=8F=90=E4=BA=A4=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5=E4=B8=8B=E6=8E=92=E5=BA=8F=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复不同类型图片提交的情况下排序的问题 * fix:去掉索引,按顺序检查图片下载然后加入新的数组 --- pkg/downloader/processor.go | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/downloader/processor.go b/pkg/downloader/processor.go index e7a8603..b2fcda8 100644 --- a/pkg/downloader/processor.go +++ b/pkg/downloader/processor.go @@ -22,29 +22,25 @@ func NewImageProcessor() *ImageProcessor { // 支持两种输入格式: // 1. URL格式 (http/https开头) - 自动下载到本地 // 2. 本地文件路径 - 直接使用 +// 保持原始图片顺序,如果下载失败直接返回错误 func (p *ImageProcessor) ProcessImages(images []string) ([]string, error) { - var localPaths []string - var urlsToDownload []string + localPaths := make([]string, 0, len(images)) - // 分离URL和本地路径 + // 按顺序处理每张图片 for _, image := range images { if IsImageURL(image) { - urlsToDownload = append(urlsToDownload, image) + // URL图片:立即下载,失败直接返回错误 + localPath, err := p.downloader.DownloadImage(image) + if err != nil { + return nil, fmt.Errorf("下载图片失败 %s: %w", image, err) + } + localPaths = append(localPaths, localPath) } else { - // 本地路径直接添加 + // 本地路径直接使用 localPaths = append(localPaths, image) } } - // 批量下载URL图片 - if len(urlsToDownload) > 0 { - downloadedPaths, err := p.downloader.DownloadImages(urlsToDownload) - if err != nil { - return nil, fmt.Errorf("failed to download images: %w", err) - } - localPaths = append(localPaths, downloadedPaths...) - } - if len(localPaths) == 0 { return nil, fmt.Errorf("no valid images found") } From c7b1d90f7dfcf20e448a76df8187fca5a372a31f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:59:12 +0800 Subject: [PATCH 13/18] docs: add flippancy as a contributor for code (#299) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 28a58a8..ddf2300 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -165,6 +165,15 @@ "contributions": [ "code" ] + }, + { + "login": "flippancy", + "name": "flippancy", + "avatar_url": "https://avatars.githubusercontent.com/u/6467703?v=4", + "profile": "https://github.com/flippancy", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 9ad14ac..5310293 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # xiaohongshu-mcp -[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-18-orange.svg?style=flat-square)](#contributors-) [![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) @@ -834,6 +834,7 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 Carlo
Carlo

💻 hrz
hrz

💻 Ctrlz
Ctrlz

💻 + flippancy
flippancy

💻 From 1e2659646d0f0728c4a80f8261afc151fb6974c8 Mon Sep 17 00:00:00 2001 From: zy Date: Mon, 24 Nov 2025 00:34:58 +0800 Subject: [PATCH 14/18] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A02025=E5=B9=B411?= =?UTF-8?q?=E6=9C=88=E8=B5=9E=E8=B5=8F=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增3笔赞赏:勇敢的心(9.99)、Sijin Yang(99.99)、cym(29.99) - 累计收到赞赏更新为 ¥319.89 - 更新 README 徽章数据 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- DONATIONS.md | 18 ++++++++++++++++-- README.md | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/DONATIONS.md b/DONATIONS.md index 8974995..4102cd2 100644 --- a/DONATIONS.md +++ b/DONATIONS.md @@ -7,9 +7,9 @@ ## 摘要 -- 累计收到赞赏:¥ 179.92 +- 累计收到赞赏:¥ 319.89 - 累计捐赠:¥ 200 -- 最近更新时间:2025-10-26 +- 最近更新时间:2025-11-24 --- @@ -22,6 +22,20 @@ ## 月度明细 +### 2025-11 + +**本月小结** + +- 收到赞赏合计:¥ 139.97 +- 捐出合计:暂未捐出,将于月底统一捐赠。 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-11-05 | 勇敢的心 | 9.99 | 赞赏码 | +| 2025-11-10 | Sijin Yang | 99.99 | 赞赏码 | +| 2025-11-17 | cym | 29.99 | 赞赏码 | + ### 2025-10 **本月小结** diff --git a/README.md b/README.md index 5310293..4df1bb5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) -[![获得赞赏](https://img.shields.io/badge/Received-CNY%20179.92-blue?style=flat-square)](./DONATIONS.md) +[![获得赞赏](https://img.shields.io/badge/Received-CNY%20319.89-blue?style=flat-square)](./DONATIONS.md) [![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) MCP for 小红书/xiaohongshu.com。 From f92cbb69aa9418e62e2661850979a2e2ee80a2f6 Mon Sep 17 00:00:00 2001 From: zy Date: Mon, 24 Nov 2025 00:45:48 +0800 Subject: [PATCH 15/18] Update WeChat group QR code in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4df1bb5..9d01073 100644 --- a/README.md +++ b/README.md @@ -797,9 +797,9 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 -| 【飞书 3 群】:扫码进入 | 【微信群 10 群】:扫码进入 | +| 【飞书 3 群】:扫码进入 | 【微信群 11 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | +| qrcode_2qun | WechatIMG119 | From 61903692b977c4eefd5d78417be8823b16b5c0ef Mon Sep 17 00:00:00 2001 From: zy Date: Wed, 3 Dec 2025 23:31:24 +0800 Subject: [PATCH 16/18] Update WeChat QR code in README --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9d01073..3fb176f 100644 --- a/README.md +++ b/README.md @@ -799,8 +799,7 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 | 【飞书 3 群】:扫码进入 | 【微信群 11 群】:扫码进入 | | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| qrcode_2qun | WechatIMG119 | - +| qrcode_2qun | WechatIMG119 | ## 🙏 致谢贡献者 ✨ From 6bef53500283624c25255accd5681d659d11bd40 Mon Sep 17 00:00:00 2001 From: zy Date: Thu, 4 Dec 2025 00:14:50 +0800 Subject: [PATCH 17/18] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A02025=E5=B9=B411?= =?UTF-8?q?=E6=9C=88/12=E6=9C=88=E8=B5=9E=E8=B5=8F=E8=AE=B0=E5=BD=95=20(#3?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 添加2025年11月/12月赞赏记录 - 11月新增:一虎君 +10.00,Sijin Yang +99.99 - 12月新增:源 +39.90 - 累计收到赞赏更新为 ¥469.78 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Update donation summary and total amount * docs: 更新累计捐赠金额至 ¥600 - 11月捐赠 ¥400 给香港大浦火灾善款 - 累计捐赠:¥200 → ¥600 - 更新日期:2025-12-04 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- DONATIONS.md | 27 ++++++++++++++++++++++----- README.md | 4 ++-- README_EN.md | 4 ++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/DONATIONS.md b/DONATIONS.md index 4102cd2..510c186 100644 --- a/DONATIONS.md +++ b/DONATIONS.md @@ -7,9 +7,9 @@ ## 摘要 -- 累计收到赞赏:¥ 319.89 -- 累计捐赠:¥ 200 -- 最近更新时间:2025-11-24 +- 累计收到赞赏:¥ 469.78 +- 累计捐赠:¥ 600 +- 最近更新时间:2025-12-04 --- @@ -22,12 +22,27 @@ ## 月度明细 +### 2025-12 + +**本月小结** + +- 收到赞赏合计:¥ 39.90 +- 捐出合计:暂未捐出,将于月底统一捐赠。 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-12-03 | 源 | 39.90 | 微信红包 | + ### 2025-11 **本月小结** -- 收到赞赏合计:¥ 139.97 -- 捐出合计:暂未捐出,将于月底统一捐赠。 +- 收到赞赏合计:¥ 249.96 +- 捐出合计:¥ 400.00。守望相助,驰援香江:为香港大浦火灾同胞筹集善款! + +PixPin_2025-10-26_21-34-08 + **收到的赞赏** | 日期 | 昵称 | 金额 | 备注 | @@ -35,6 +50,8 @@ | 2025-11-05 | 勇敢的心 | 9.99 | 赞赏码 | | 2025-11-10 | Sijin Yang | 99.99 | 赞赏码 | | 2025-11-17 | cym | 29.99 | 赞赏码 | +| 2025-11-26 | 一虎君 | 10.00 | 赞赏码 | +| 2025-11-26 | Sijin Yang | 99.99 | 赞赏码 | ### 2025-10 diff --git a/README.md b/README.md index 3fb176f..9df4499 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-18-orange.svg?style=flat-square)](#contributors-) -[![已捐赠](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) -[![获得赞赏](https://img.shields.io/badge/Received-CNY%20319.89-blue?style=flat-square)](./DONATIONS.md) +[![已捐赠](https://img.shields.io/badge/Donated-CNY%20600.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![获得赞赏](https://img.shields.io/badge/Received-CNY%20469.78-blue?style=flat-square)](./DONATIONS.md) [![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) MCP for 小红书/xiaohongshu.com。 diff --git a/README_EN.md b/README_EN.md index cdedcec..dbcab63 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,8 +4,8 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) -[![Donated](https://img.shields.io/badge/Donated-CNY%20200.00-brightgreen?style=flat-square)](./DONATIONS.md) -[![Received](https://img.shields.io/badge/Received-CNY%20179.92-blue?style=flat-square)](./DONATIONS.md) +[![Donated](https://img.shields.io/badge/Donated-CNY%20600.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![Received](https://img.shields.io/badge/Received-CNY%20469.78-blue?style=flat-square)](./DONATIONS.md) [![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) MCP for RedNote (Xiaohongshu) platform. From 13ac2e39c3667158afc77cf26b56c4b97a82b3e6 Mon Sep 17 00:00:00 2001 From: haikow Date: Sun, 7 Dec 2025 15:04:14 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E8=AF=A6=E6=83=85=E5=86=85=E5=AE=B9=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20(#301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add like and favorite functionality for feeds - Implemented handleLikeFeed and handleFavoriteFeed methods in mcp_handlers.go to manage liking and favoriting feeds. - Added LikeFavoriteArgs struct in mcp_server.go for handling parameters. - Registered new MCP tools for liking and favoriting feeds in registerTools function. - Introduced LikeFeed and FavoriteFeed methods in XiaohongshuService to interact with the respective actions. - Created LikeFavoriteAction in a new file to encapsulate the logic for liking and favoriting feeds on the Xiaohongshu platform. * "优化评论反馈逻辑:简化回复按钮查找和点击流程 * "Fix-build-errors" * refactor: streamline like and favorite actions in LikeFavoriteAction - Introduced a generic method `performInteractAction` to handle both liking and favoriting feeds, reducing code duplication. - Updated logging to reflect the action type being performed (like or favorite). - Enhanced state verification after interaction to ensure accurate feedback on success or failure. - Removed the `clickLastMatch` function, simplifying the interaction logic. * "Add-unlike-and-unfavorite-functionality" * "Refactor-performInteractAction-function" * "Refactor-split-LikeFavoriteAction-into-separate-actions" * "Add-content-length-validation-for-publish" * refactor: improve comment posting logic with enhanced error handling and stability checks - Updated the PostComment method to include error handling for navigation and element interactions. - Replaced sleep calls with more reliable wait mechanisms to ensure page stability. - Added checks for the presence of input elements and improved logging for better debugging. * feat: add reply comment functionality for Xiaohongshu feeds - Implemented handleReplyComment method in mcp_handlers.go to manage replying to comments on feeds. - Introduced ReplyCommentArgs struct in mcp_server.go for handling parameters related to comment replies. - Registered a new MCP tool for replying to comments in the registerTools function. - Added ReplyCommentToFeed method in service.go to interact with the Xiaohongshu platform for comment replies. - Enhanced error handling for missing parameters in the reply process. * refactor: enhance reply comment functionality with improved error handling and response structure - Simplified error handling in handleReplyComment to check for both comment_id and user_id simultaneously. - Updated response message to include both Comment ID and User ID upon successful reply. - Modified ReplyCommentArgs struct to make comment_id and user_id optional. - Renamed MCP tool for replying to comments for clarity. * feat(feed): Migrate loadAllComments feature for GetFeedDetail * fix * fix * fix * fix * fix: 添加更多自定义选项操作 * fix * fix:优化代码结构 * chore: update dependencies and implement retry logic for page interactions --------- Co-authored-by: chekayo <9827969+chekayo@user.noreply.gitee.com> --- go.mod | 3 +- go.sum | 4 + handlers_api.go | 19 +- mcp_handlers.go | 70 +++- mcp_server.go | 18 +- service.go | 9 +- types.go | 18 +- xiaohongshu/feed_detail.go | 793 +++++++++++++++++++++++++++++++++++-- 8 files changed, 892 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index d1bc3ba..0f9e121 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,12 @@ require ( github.com/modelcontextprotocol/go-sdk v0.7.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/xpzouying/headless_browser v0.2.0 ) require ( + github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect diff --git a/go.sum b/go.sum index 25fc399..a6c928f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= +github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -79,6 +81,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= diff --git a/handlers_api.go b/handlers_api.go index 77b434f..31b6afc 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -181,8 +181,23 @@ func (s *AppServer) getFeedDetailHandler(c *gin.Context) { return } - // 获取 Feed 详情 - result, err := s.xiaohongshuService.GetFeedDetail(c.Request.Context(), req.FeedID, req.XsecToken) + var result *FeedDetailResponse + var err error + + if req.CommentConfig != nil { + // 使用配置参数 + config := xiaohongshu.CommentLoadConfig{ + ClickMoreReplies: req.CommentConfig.ClickMoreReplies, + MaxRepliesThreshold: req.CommentConfig.MaxRepliesThreshold, + MaxCommentItems: req.CommentConfig.MaxCommentItems, + ScrollSpeed: req.CommentConfig.ScrollSpeed, + } + result, err = s.xiaohongshuService.GetFeedDetailWithConfig(c.Request.Context(), req.FeedID, req.XsecToken, req.LoadAllComments, config) + } else { + // 使用默认配置 + result, err = s.xiaohongshuService.GetFeedDetail(c.Request.Context(), req.FeedID, req.XsecToken, req.LoadAllComments) + } + if err != nil { respondError(c, http.StatusInternalServerError, "GET_FEED_DETAIL_FAILED", "获取Feed详情失败", err.Error()) diff --git a/mcp_handlers.go b/mcp_handlers.go index 88c25dc..a99073d 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -4,11 +4,13 @@ import ( "context" "encoding/json" "fmt" + "strconv" + "strings" + "time" + "github.com/sirupsen/logrus" "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" - "strings" - "time" ) // MCP 工具处理函数 @@ -35,7 +37,7 @@ func (s *AppServer) handleCheckLoginStatus(ctx context.Context) *MCPToolResult { } else { resultText = fmt.Sprintf("❌ 未登录\n\n请使用 get_login_qrcode 工具获取二维码进行登录。") } - + return &MCPToolResult{ Content: []MCPContent{{ Type: "text", @@ -336,9 +338,67 @@ func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any } } - logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s", feedID) + loadAll := false + if raw, ok := args["load_all_comments"]; ok { + switch v := raw.(type) { + case bool: + loadAll = v + case string: + if parsed, err := strconv.ParseBool(v); err == nil { + loadAll = parsed + } + case float64: + loadAll = v != 0 + } + } - result, err := s.xiaohongshuService.GetFeedDetail(ctx, feedID, xsecToken) + // 解析评论配置参数,如果未提供则使用默认值 + config := xiaohongshu.DefaultCommentLoadConfig() + + if raw, ok := args["click_more_replies"]; ok { + switch v := raw.(type) { + case bool: + config.ClickMoreReplies = v + case string: + if parsed, err := strconv.ParseBool(v); err == nil { + config.ClickMoreReplies = parsed + } + } + } + + if raw, ok := args["max_replies_threshold"]; ok { + switch v := raw.(type) { + case float64: + config.MaxRepliesThreshold = int(v) + case string: + if parsed, err := strconv.Atoi(v); err == nil { + config.MaxRepliesThreshold = parsed + } + case int: + config.MaxRepliesThreshold = v + } + } + + if raw, ok := args["max_comment_items"]; ok { + switch v := raw.(type) { + case float64: + config.MaxCommentItems = int(v) + case string: + if parsed, err := strconv.Atoi(v); err == nil { + config.MaxCommentItems = parsed + } + case int: + config.MaxCommentItems = v + } + } + + if raw, ok := args["scroll_speed"].(string); ok && raw != "" { + config.ScrollSpeed = raw + } + + logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s, loadAllComments=%v, config=%+v", feedID, loadAll, config) + + result, err := s.xiaohongshuService.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAll, config) if err != nil { return &MCPToolResult{ Content: []MCPContent{{ diff --git a/mcp_server.go b/mcp_server.go index d72ffb0..19cda7d 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -45,8 +45,13 @@ type FilterOption struct { // FeedDetailArgs 获取Feed详情的参数 type FeedDetailArgs struct { - FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` - XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` + FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` + XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` + LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批评论)"` + ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"是否点击'更多回复'按钮 (默认: false)"` + MaxRepliesThreshold int `json:"max_replies_threshold,omitempty" jsonschema:"回复数量阈值,超过此数量的'更多'按钮将被跳过 (0表示不跳过任何, 默认: 10)"` + MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载评论数(0表示加载所有, 默认: 0)"` + ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"滚动速度: 'slow'|'normal'|'fast' (默认: 'normal')"` } // UserProfileArgs 获取用户主页的参数 @@ -216,8 +221,13 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ - "feed_id": args.FeedID, - "xsec_token": args.XsecToken, + "feed_id": args.FeedID, + "xsec_token": args.XsecToken, + "load_all_comments": args.LoadAllComments, + "click_more_replies": args.ClickMoreReplies, + "max_replies_threshold": args.MaxRepliesThreshold, + "max_comment_items": args.MaxCommentItems, + "scroll_speed": args.ScrollSpeed, } result := appServer.handleGetFeedDetail(ctx, argsMap) return convertToMCPResult(result), nil, nil diff --git a/service.go b/service.go index cd502fd..c5e07b6 100644 --- a/service.go +++ b/service.go @@ -328,7 +328,12 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, fi } // GetFeedDetail 获取Feed详情 -func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { +func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool) (*FeedDetailResponse, error) { + return s.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAllComments, xiaohongshu.DefaultCommentLoadConfig()) +} + +// GetFeedDetailWithConfig 使用配置获取Feed详情 +func (s *XiaohongshuService) GetFeedDetailWithConfig(ctx context.Context, feedID, xsecToken string, loadAllComments bool, config xiaohongshu.CommentLoadConfig) (*FeedDetailResponse, error) { b := newBrowser() defer b.Close() @@ -339,7 +344,7 @@ func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToke action := xiaohongshu.NewFeedDetailAction(page) // 获取 Feed 详情 - result, err := action.GetFeedDetail(ctx, feedID, xsecToken) + result, err := action.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAllComments, config) if err != nil { return nil, err } diff --git a/types.go b/types.go index 96d9790..0ea075f 100644 --- a/types.go +++ b/types.go @@ -34,10 +34,24 @@ type MCPContent struct { Data string `json:"data"` } +// CommentLoadConfig 评论加载配置 +type CommentLoadConfig struct { + // 是否点击"更多回复"按钮 + ClickMoreReplies bool `json:"click_more_replies,omitempty"` + // 回复数量阈值,超过这个数量的"更多"按钮将被跳过(0表示不跳过任何) + MaxRepliesThreshold int `json:"max_replies_threshold,omitempty"` + // 最大加载评论数(comment-item数量),0表示加载所有 + MaxCommentItems int `json:"max_comment_items,omitempty"` + // 滚动速度等级: slow(慢速), normal(正常), fast(快速) + ScrollSpeed string `json:"scroll_speed,omitempty"` +} + // FeedDetailRequest Feed详情请求 type FeedDetailRequest struct { - FeedID string `json:"feed_id" binding:"required"` - XsecToken string `json:"xsec_token" binding:"required"` + FeedID string `json:"feed_id" binding:"required"` + XsecToken string `json:"xsec_token" binding:"required"` + LoadAllComments bool `json:"load_all_comments,omitempty"` + CommentConfig *CommentLoadConfig `json:"comment_config,omitempty"` } type SearchFeedsRequest struct { diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go index 8921498..5fbdd34 100644 --- a/xiaohongshu/feed_detail.go +++ b/xiaohongshu/feed_detail.go @@ -4,65 +4,806 @@ import ( "context" "encoding/json" "fmt" + "math/rand" + "regexp" + "strconv" "time" + "github.com/avast/retry-go/v4" "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" "github.com/sirupsen/logrus" "github.com/xpzouying/xiaohongshu-mcp/errors" ) -// FeedDetailAction 表示 Feed 详情页动作 +// ========== 配置常量 ========== +const ( + defaultMaxAttempts = 500 + stagnantLimit = 20 + minScrollDelta = 10 + maxClickPerRound = 3 + stagnantCheckThreshold = 2 // 达到目标后需要停滞几次才确认 + largeScrollTrigger = 5 // 停滞多少次后触发大滚动 + buttonClickInterval = 3 // 每隔多少次尝试点击一次按钮 + finalSprintPushCount = 15 +) + +// 延迟时间配置(毫秒) +type delayConfig struct { + min, max int +} + +var ( + humanDelayRange = delayConfig{300, 700} + reactionTimeRange = delayConfig{300, 800} + hoverTimeRange = delayConfig{100, 300} + readTimeRange = delayConfig{500, 1200} + shortReadRange = delayConfig{600, 1200} + scrollWaitRange = delayConfig{100, 200} + postScrollRange = delayConfig{300, 500} +) + +// ========== 数据结构 ========== + +type CommentLoadConfig struct { + ClickMoreReplies bool + MaxRepliesThreshold int + MaxCommentItems int + ScrollSpeed string +} + +func DefaultCommentLoadConfig() CommentLoadConfig { + return CommentLoadConfig{ + ClickMoreReplies: false, + MaxRepliesThreshold: 10, + MaxCommentItems: 0, + ScrollSpeed: "normal", + } +} + type FeedDetailAction struct { page *rod.Page } -// NewFeedDetailAction 创建 Feed 详情页动作 func NewFeedDetailAction(page *rod.Page) *FeedDetailAction { return &FeedDetailAction{page: page} } -// GetFeedDetail 获取 Feed 详情页数据 -func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { - page := f.page.Context(ctx).Timeout(60 * time.Second) +// ========== 主要业务逻辑 ========== - // 构建详情页 URL +func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool, config CommentLoadConfig) (*FeedDetailResponse, error) { + return f.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAllComments, config) +} + +func (f *FeedDetailAction) GetFeedDetailWithConfig(ctx context.Context, feedID, xsecToken string, loadAllComments bool, config CommentLoadConfig) (*FeedDetailResponse, error) { + page := f.page.Context(ctx).Timeout(10 * time.Minute) url := makeFeedDetailURL(feedID, xsecToken) - + logrus.Infof("打开 feed 详情页: %s", url) - - // 导航到详情页 - page.MustNavigate(url) - page.MustWaitDOMStable() - time.Sleep(1 * time.Second) - - result := page.MustEval(`() => { - if (window.__INITIAL_STATE__ && - window.__INITIAL_STATE__.note && - window.__INITIAL_STATE__.note.noteDetailMap) { - const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap; - return JSON.stringify(noteDetailMap); + logrus.Infof("配置: 点击更多=%v, 回复阈值=%d, 最大评论数=%d, 滚动速度=%s", + config.ClickMoreReplies, config.MaxRepliesThreshold, config.MaxCommentItems, config.ScrollSpeed) + + // 使用retry-go处理页面导航和DOM稳定等待 + err := retry.Do( + func() error { + page.MustNavigate(url) + page.MustWaitDOMStable() + return nil + }, + retry.Attempts(3), + retry.Delay(500*time.Millisecond), + retry.MaxJitter(1000*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("页面导航重试 #%d: %v", n, err) + }), + ) + if err != nil { + logrus.Errorf("页面导航失败: %v", err) + return nil, err + } + sleepRandom(1000, 1000) + + if err := checkPageAccessible(page); err != nil { + return nil, err + } + + if loadAllComments { + if err := f.loadAllCommentsWithConfig(page, config); err != nil { + logrus.Warnf("加载全部评论失败: %v", err) } - return ""; - }`).String() + } + + return f.extractFeedDetail(page, feedID) +} +// ========== 评论加载器 ========== + +type commentLoader struct { + page *rod.Page + config CommentLoadConfig + stats *loadStats + state *loadState +} + +type loadStats struct { + totalClicked int + totalSkipped int + attempts int +} + +type loadState struct { + lastCount int + lastScrollTop int + stagnantChecks int +} + +func (f *FeedDetailAction) loadAllCommentsWithConfig(page *rod.Page, config CommentLoadConfig) error { + loader := &commentLoader{ + page: page, + config: config, + stats: &loadStats{}, + state: &loadState{}, + } + + return loader.load() +} + +func (cl *commentLoader) load() error { + maxAttempts := cl.calculateMaxAttempts() + scrollInterval := getScrollInterval(cl.config.ScrollSpeed) + + logrus.Info("开始加载评论...") + scrollToCommentsArea(cl.page) + sleepRandom(humanDelayRange.min, humanDelayRange.max) + + for cl.stats.attempts = 0; cl.stats.attempts < maxAttempts; cl.stats.attempts++ { + logrus.Debugf("=== 尝试 %d/%d ===", cl.stats.attempts+1, maxAttempts) + + if cl.checkComplete() { + return nil + } + + if cl.shouldClickButtons() { + cl.clickButtonsWithRetry() + } + + currentCount := getCommentCount(cl.page) + cl.updateState(currentCount) + + if cl.shouldStopAtTarget(currentCount) { + return nil + } + + cl.performScroll() + cl.handleStagnation() + + time.Sleep(scrollInterval) + } + + cl.performFinalSprint() + return nil +} + +func (cl *commentLoader) calculateMaxAttempts() int { + if cl.config.MaxCommentItems > 0 { + return cl.config.MaxCommentItems * 3 + } + return defaultMaxAttempts +} + +func (cl *commentLoader) checkComplete() bool { + if checkEndContainer(cl.page) { + currentCount := getCommentCount(cl.page) + logrus.Infof("✓ 检测到 'THE END' 元素,已滑动到底部") + sleepRandom(humanDelayRange.min, humanDelayRange.max) + logrus.Infof("✓ 加载完成: %d 条评论, 尝试次数: %d, 点击: %d, 跳过: %d", + currentCount, cl.stats.attempts+1, cl.stats.totalClicked, cl.stats.totalSkipped) + return true + } + return false +} + +func (cl *commentLoader) shouldClickButtons() bool { + return cl.config.ClickMoreReplies && cl.stats.attempts%buttonClickInterval == 0 +} + +func (cl *commentLoader) clickButtonsWithRetry() { + clicked, skipped := clickShowMoreButtonsSmart(cl.page, cl.config.MaxRepliesThreshold) + if clicked > 0 || skipped > 0 { + cl.stats.totalClicked += clicked + cl.stats.totalSkipped += skipped + logrus.Infof("点击'更多': %d 个, 跳过: %d 个, 累计点击: %d, 累计跳过: %d", + clicked, skipped, cl.stats.totalClicked, cl.stats.totalSkipped) + + sleepRandom(readTimeRange.min, readTimeRange.max) + + // 重试一轮 + clicked2, skipped2 := clickShowMoreButtonsSmart(cl.page, cl.config.MaxRepliesThreshold) + if clicked2 > 0 || skipped2 > 0 { + cl.stats.totalClicked += clicked2 + cl.stats.totalSkipped += skipped2 + logrus.Infof("第 2 轮: 点击 %d, 跳过 %d", clicked2, skipped2) + sleepRandom(shortReadRange.min, shortReadRange.max) + } + } +} + +func (cl *commentLoader) updateState(currentCount int) { + totalCount := getTotalCommentCount(cl.page) + logrus.Debugf("当前评论: %d, 目标: %d", currentCount, totalCount) + + if currentCount != cl.state.lastCount { + logrus.Infof("✓ 评论增加: %d -> %d (+%d)", + cl.state.lastCount, currentCount, currentCount-cl.state.lastCount) + cl.state.lastCount = currentCount + cl.state.stagnantChecks = 0 + } else { + cl.state.stagnantChecks++ + if cl.state.stagnantChecks%5 == 0 { + logrus.Debugf("评论停滞 %d 次", cl.state.stagnantChecks) + } + } +} + +func (cl *commentLoader) shouldStopAtTarget(currentCount int) bool { + if cl.config.MaxCommentItems <= 0 || currentCount < cl.config.MaxCommentItems { + return false + } + + if cl.state.stagnantChecks >= stagnantCheckThreshold { + logrus.Infof("✓ 已达到目标评论数: %d/%d (停滞%d次), 停止加载", + currentCount, cl.config.MaxCommentItems, cl.state.stagnantChecks) + return true + } + + if cl.state.stagnantChecks > 0 { + logrus.Debugf("已达目标数 %d/%d,再确认 %d 次...", + currentCount, cl.config.MaxCommentItems, stagnantCheckThreshold-cl.state.stagnantChecks) + } + + return false +} + +func (cl *commentLoader) performScroll() { + currentCount := getCommentCount(cl.page) + if currentCount > 0 { + scrollToLastComment(cl.page) + sleepRandom(postScrollRange.min, postScrollRange.max) + } + + largeMode := cl.state.stagnantChecks >= largeScrollTrigger + pushCount := 1 + if largeMode { + pushCount = 3 + rand.Intn(3) + } + + _, scrollDelta, currentScrollTop := humanScroll(cl.page, cl.config.ScrollSpeed, largeMode, pushCount) + + if scrollDelta < minScrollDelta || currentScrollTop == cl.state.lastScrollTop { + cl.state.stagnantChecks++ + if cl.state.stagnantChecks%5 == 0 { + logrus.Debugf("滚动停滞 %d 次", cl.state.stagnantChecks) + } + } else { + cl.state.stagnantChecks = 0 + cl.state.lastScrollTop = currentScrollTop + } +} + +func (cl *commentLoader) handleStagnation() { + if cl.state.stagnantChecks >= stagnantLimit { + logrus.Infof("停滞过多,尝试大冲刺...") + humanScroll(cl.page, cl.config.ScrollSpeed, true, 10) + cl.state.stagnantChecks = 0 + + if checkEndContainer(cl.page) { + currentCount := getCommentCount(cl.page) + logrus.Infof("✓ 到达底部,评论数: %d", currentCount) + } + } +} + +func (cl *commentLoader) performFinalSprint() { + logrus.Infof("达到最大尝试次数,最后冲刺...") + humanScroll(cl.page, cl.config.ScrollSpeed, true, finalSprintPushCount) + + currentCount := getCommentCount(cl.page) + hasEnd := checkEndContainer(cl.page) + logrus.Infof("✓ 加载结束: %d 条评论, 点击: %d, 跳过: %d, 到达底部: %v", + currentCount, cl.stats.totalClicked, cl.stats.totalSkipped, hasEnd) +} + +// ========== 工具函数 ========== + +func sleepRandom(minMs, maxMs int) { + if maxMs <= minMs { + time.Sleep(time.Duration(minMs) * time.Millisecond) + return + } + delay := time.Duration(minMs+rand.Intn(maxMs-minMs)) * time.Millisecond + time.Sleep(delay) +} + +func getScrollInterval(speed string) time.Duration { + switch speed { + case "slow": + return time.Duration(1200+rand.Intn(300)) * time.Millisecond + case "fast": + return time.Duration(300+rand.Intn(100)) * time.Millisecond + default: // normal + return time.Duration(600+rand.Intn(200)) * time.Millisecond + } +} + +// ========== 按钮点击 ========== + +func clickShowMoreButtonsSmart(page *rod.Page, maxRepliesThreshold int) (clicked, skipped int) { + elements, err := page.Elements(".show-more") + if err != nil { + return 0, 0 + } + + replyCountRegex := regexp.MustCompile(`展开\s*(\d+)\s*条回复`) + maxClick := maxClickPerRound + rand.Intn(maxClickPerRound) + clickedInRound := 0 + + for _, el := range elements { + if clickedInRound >= maxClick { + break + } + + if !isElementClickable(el) { + continue + } + + text, err := el.Text() + if err != nil { + continue + } + + if shouldSkipButton(text, maxRepliesThreshold, replyCountRegex) { + skipped++ + continue + } + + if clickElementWithHumanBehavior(page, el, text) { + clicked++ + clickedInRound++ + } + } + + return clicked, skipped +} + +func isElementClickable(el *rod.Element) bool { + visible, err := el.Visible() + if err != nil || !visible { + return false + } + + box, err := el.Shape() + return err == nil && len(box.Quads) > 0 +} + +func shouldSkipButton(text string, threshold int, regex *regexp.Regexp) bool { + if threshold <= 0 { + return false + } + + matches := regex.FindStringSubmatch(text) + if len(matches) > 1 { + if replyCount, err := strconv.Atoi(matches[1]); err == nil && replyCount > threshold { + logrus.Debugf("跳过'%s'(回复数 %d > 阈值 %d)", text, replyCount, threshold) + return true + } + } + return false +} + +func clickElementWithHumanBehavior(page *rod.Page, el *rod.Element, text string) bool { + var clickSuccess bool + + // 使用retry-go进行点击操作重试 + err := retry.Do( + func() error { + // 滚动到元素 + el.MustEval(`() => { + try { + this.scrollIntoView({behavior: 'smooth', block: 'center'}); + } catch (e) {} + }`) + + sleepRandom(reactionTimeRange.min, reactionTimeRange.max) + + // 鼠标悬停 + if box, err := el.Shape(); err == nil && len(box.Quads) > 0 { + x := float64(box.Quads[0][0]+box.Quads[0][4]) / 2 + y := float64(box.Quads[0][1]+box.Quads[0][5]) / 2 + page.Mouse.MustMoveTo(x, y) + sleepRandom(hoverTimeRange.min, hoverTimeRange.max) + } + + // 点击 + if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + return err // 返回错误以触发重试 + } + + // 模拟人类阅读时间 + sleepRandom(readTimeRange.min, readTimeRange.max) + clickSuccess = true + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxJitter(200*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("点击重试 #%d: %s, 错误: %v", n, text, err) + }), + ) + + if err != nil { + logrus.Debugf("点击失败 '%s': %v", text, err) + return false + } + + if clickSuccess { + logrus.Debugf("点击了'%s'", text) + } + + return clickSuccess +} + +// ========== 滚动相关 ========== + +func humanScroll(page *rod.Page, speed string, largeMode bool, pushCount int) (bool, int, int) { + beforeTop := getScrollTop(page) + viewportHeight := page.MustEval(`() => window.innerHeight`).Int() + + baseRatio := getScrollRatio(speed) + if largeMode { + baseRatio *= 2.0 + } + + scrolled := false + actualDelta := 0 + currentScrollTop := beforeTop + + for i := 0; i < max(1, pushCount); i++ { + scrollDelta := calculateScrollDelta(viewportHeight, baseRatio) + page.MustEval(`(delta) => { window.scrollBy(0, delta); }`, scrollDelta) + + sleepRandom(scrollWaitRange.min, scrollWaitRange.max) + + currentScrollTop = getScrollTop(page) + deltaThisTime := currentScrollTop - beforeTop + actualDelta += deltaThisTime + + if deltaThisTime > 5 { + scrolled = true + } + + beforeTop = currentScrollTop + + if i < pushCount-1 { + sleepRandom(humanDelayRange.min, humanDelayRange.max) + } + } + + if !scrolled && pushCount > 0 { + page.MustEval(`() => window.scrollTo(0, document.body.scrollHeight)`) + sleepRandom(postScrollRange.min, postScrollRange.max) + currentScrollTop = getScrollTop(page) + actualDelta = currentScrollTop - beforeTop + actualDelta + scrolled = actualDelta > 5 + } + + if scrolled { + logrus.Debugf("滚动: %d -> %d (Δ%d, large=%v, push=%d)", + beforeTop-actualDelta, currentScrollTop, actualDelta, largeMode, pushCount) + } + + return scrolled, actualDelta, currentScrollTop +} + +func getScrollRatio(speed string) float64 { + switch speed { + case "slow": + return 0.5 + case "fast": + return 0.9 + default: // normal + return 0.7 + } +} + +func calculateScrollDelta(viewportHeight int, baseRatio float64) float64 { + scrollDelta := float64(viewportHeight) * (baseRatio + rand.Float64()*0.2) + if scrollDelta < 400 { + scrollDelta = 400 + } + return scrollDelta + float64(rand.Intn(100)-50) +} + +func scrollToCommentsArea(page *rod.Page) { + logrus.Info("滚动到评论区...") + page.MustEval(`() => { + const container = document.querySelector('.comments-container'); + if (container) { + container.scrollIntoView({behavior: 'smooth', block: 'start'}); + } + }`) +} + +func scrollToLastComment(page *rod.Page) { + page.MustEval(`() => { + const container = document.querySelector('.comments-container'); + if (!container) return; + const comments = container.querySelectorAll('.parent-comment'); + if (comments.length > 0) { + const lastComment = comments[comments.length - 1]; + lastComment.scrollIntoView({behavior: 'smooth', block: 'center'}); + } + }`) +} + +// ========== DOM 查询 ========== + +func getScrollTop(page *rod.Page) int { + var result int + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + evalResult := page.MustEval(`() => { + return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; + }`) + + result = evalResult.Int() + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxJitter(200*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("获取滚动位置重试 #%d: %v", n, err) + }), + ) + + if err != nil { + logrus.Warnf("获取滚动位置失败: %v", err) + return 0 // 失败时返回0 + } + + return result +} + +func getCommentCount(page *rod.Page) int { + var result int + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + evalResult := page.MustEval(`() => { + const container = document.querySelector('.comments-container'); + if (!container) return 0; + return container.querySelectorAll('.parent-comment').length; + }`) + + result = evalResult.Int() + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxJitter(200*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("获取评论计数重试 #%d: %v", n, err) + }), + ) + + if err != nil { + logrus.Warnf("获取评论计数失败: %v", err) + return 0 // 失败时返回0 + } + + return result +} + +func getTotalCommentCount(page *rod.Page) int { + var result int + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + evalResult := page.MustEval(`() => { + const container = document.querySelector('.comments-container'); + if (!container) return 0; + const totalEl = container.querySelector('.total'); + if (!totalEl) return 0; + const text = (totalEl.textContent || '').replace(/\s+/g, ''); + const match = text.match(/共(\d+)条评论/); + return match ? parseInt(match[1], 10) : 0; + }`) + + result = evalResult.Int() + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxJitter(200*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("获取总评论计数重试 #%d: %v", n, err) + }), + ) + + if err != nil { + logrus.Warnf("获取总评论计数失败: %v", err) + return 0 // 失败时返回0 + } + + return result +} + +func checkEndContainer(page *rod.Page) bool { + var result bool + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + evalResult := page.MustEval(`() => { + const endContainer = document.querySelector('.end-container'); + if (!endContainer) return false; + const text = (endContainer.textContent || '').trim().toUpperCase(); + return text.includes('THE END') || text.includes('THEEND'); + }`) + + result = evalResult.Bool() + return nil + }, + retry.Attempts(3), + retry.Delay(100*time.Millisecond), + retry.MaxJitter(200*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("检查结束容器重试 #%d: %v", n, err) + }), + ) + + if err != nil { + logrus.Warnf("检查结束容器失败: %v", err) + return false // 失败时返回false + } + + return result +} + +// ========== 页面检查 ========== + +func checkPageAccessible(page *rod.Page) error { + time.Sleep(500 * time.Millisecond) + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + result := page.MustEval(`() => { + const wrapper = document.querySelector('.access-wrapper, .error-wrapper, .not-found-wrapper, .blocked-wrapper'); + if (!wrapper) return null; + + const text = wrapper.textContent || wrapper.innerText || ''; + const keywords = [ + '当前笔记暂时无法浏览', + '该内容因违规已被删除', + '该笔记已被删除', + '内容不存在', + '笔记不存在', + '已失效', + '私密笔记', + '仅作者可见', + '因用户设置,你无法查看', + '因违规无法查看' + ]; + + for (const kw of keywords) { + if (text.includes(kw)) { + return kw; + } + } + + if (text.trim()) { + return '未知错误: ' + text.trim(); + } + return null; + }`) + + rawJSON, marshalErr := result.MarshalJSON() + if marshalErr != nil { + return fmt.Errorf("无法序列化页面状态检查结果: %w", marshalErr) + } + + if string(rawJSON) != "null" { + var reason string + if unmarshalErr := json.Unmarshal(rawJSON, &reason); unmarshalErr == nil { + logrus.Warnf("笔记不可访问: %s", reason) + return fmt.Errorf("笔记不可访问: %s", reason) + } + + rawReason := string(rawJSON) + logrus.Warnf("笔记不可访问,且无法解析原因: %s", rawReason) + return fmt.Errorf("笔记不可访问,无法解析原因: %s", rawReason) + } + + return nil + }, + retry.Attempts(3), + retry.Delay(200*time.Millisecond), + retry.MaxJitter(300*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("页面可访问性检查重试 #%d: %v", n, err) + }), + ) + + // If the error is nil, it means no access issue was found + if err == nil { + return nil // Page is accessible + } + + // Return the original error from the retry operation + return err +} + +// ========== 数据提取 ========== + +func (f *FeedDetailAction) extractFeedDetail(page *rod.Page, feedID string) (*FeedDetailResponse, error) { + var result string + + // 使用retry-go来处理可能的DOM查询失败 + err := retry.Do( + func() error { + evalResult := page.MustEval(`() => { + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.note && + window.__INITIAL_STATE__.note.noteDetailMap) { + const noteDetailMap = window.__INITIAL_STATE__.note.noteDetailMap; + return JSON.stringify(noteDetailMap); + } + return ""; + }`).String() + + if evalResult != "" { + result = evalResult + return nil + } + return fmt.Errorf("无法获取初始状态数据") + }, + retry.Attempts(3), + retry.Delay(200*time.Millisecond), + retry.MaxJitter(300*time.Millisecond), + retry.OnRetry(func(n uint, err error) { + logrus.Debugf("提取Feed详情重试 #%d: %v", n, err) + }), + ) + + if err != nil { + logrus.Errorf("提取Feed详情失败: %v", err) + return nil, fmt.Errorf("提取Feed详情失败: %w", err) + } + if result == "" { return nil, errors.ErrNoFeedDetail } - + var noteDetailMap map[string]struct { Note FeedDetail `json:"note"` Comments CommentList `json:"comments"` } - + if err := json.Unmarshal([]byte(result), ¬eDetailMap); err != nil { return nil, fmt.Errorf("failed to unmarshal noteDetailMap: %w", err) } - + noteDetail, exists := noteDetailMap[feedID] if !exists { return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID) } - + return &FeedDetailResponse{ Note: noteDetail.Note, Comments: noteDetail.Comments, @@ -71,4 +812,4 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken func makeFeedDetailURL(feedID, xsecToken string) string { return fmt.Sprintf("https://www.xiaohongshu.com/explore/%s?xsec_token=%s&xsec_source=pc_feed", feedID, xsecToken) -} +} \ No newline at end of file