From a7d1f2f7f45e0b1c27de67c8f8a19131ba321725 Mon Sep 17 00:00:00 2001 From: zy Date: Mon, 20 Jul 2026 15:01:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=86=85=E7=BD=AE=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E8=87=AA=E5=8A=A8=E4=B8=8B=E8=BD=BD=20+=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E6=8B=9F=E4=BA=BA=E5=8C=96=20+=20=E5=86=99=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=AF=9A=E5=AE=9E=E6=A0=A1=E9=AA=8C=20(#737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置浏览器:首次运行自动从自建 CDN 下载并校验 SHA256,免手动装 Chrome; Docker 构建时烤入镜像;支持 Windows 运行画像;每账号可固定 XHS_FP_SEED。 交互拟人化:评论/回复、点赞/收藏、发布图文与视频、feed 浏览、搜索筛选的 点击/输入/停顿改为更接近真人的时序与轨迹(log-normal 延迟、逐字输入、 贝塞尔鼠标),统一走 humanize 包,预留 Provider 注入点。 诚实校验:写操作提交后回读真实结果(评论是否渲染、点赞状态是否翻转、 发布是否跳转发布成功页),消除“点了没报错就算成功”的假成功; check_login_status 加超时保护;原创声明失败不再静默照发。 分发:内置浏览器走自建 CDN(cdn.one-world.ai)。 Co-authored-by: Claude Fable 5 --- .github/workflows/aliyun-docker-release.yml | 13 - .github/workflows/docker-release.yml | 13 - .github/workflows/test.yml | 33 ++ .github/workflows/update-browser.yml | 78 +++++ .gitignore | 3 + Dockerfile | 41 ++- Dockerfile.arm64 | 119 ------- README.md | 2 +- README_EN.md | 2 +- browser/browser.go | 50 ++- browser/browser_download.go | 332 ++++++++++++++++++++ browser/browser_test.go | 48 +++ browser/browser_version.txt | 1 + build/find_cloakbrowser_binary.py | 74 ----- cmd/login/main.go | 23 +- configs/browser.go | 47 +++ configs/browser_test.go | 52 +++ cookies/cookies_test.go | 62 ++++ docker/README.md | 4 +- docker/docker-compose.yml | 2 +- go.mod | 3 +- go.sum | 8 +- humanize/delay.go | 22 ++ humanize/humanize_test.go | 96 ++++++ humanize/input.go | 76 +++++ humanize/mouse.go | 70 +++++ humanize/provider.go | 87 +++++ main.go | 16 +- mcp_handlers.go | 2 +- pkg/downloader/images_test.go | 9 +- service.go | 6 +- xiaohongshu/comment_feed.go | 97 ++++-- xiaohongshu/feed_detail.go | 147 ++++----- xiaohongshu/feeds.go | 37 ++- xiaohongshu/feeds_test.go | 7 +- xiaohongshu/like_favorite.go | 129 ++++---- xiaohongshu/login.go | 3 +- xiaohongshu/navigate.go | 5 +- xiaohongshu/publish.go | 219 +++++++------ xiaohongshu/publish_test.go | 6 +- xiaohongshu/publish_video.go | 22 +- xiaohongshu/search.go | 14 +- xiaohongshu/search_integration_test.go | 65 ++++ xiaohongshu/search_test.go | 61 ---- xiaohongshu/user_profile.go | 4 +- 45 files changed, 1551 insertions(+), 659 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/update-browser.yml delete mode 100644 Dockerfile.arm64 create mode 100644 browser/browser_download.go create mode 100644 browser/browser_test.go create mode 100644 browser/browser_version.txt delete mode 100644 build/find_cloakbrowser_binary.py create mode 100644 configs/browser_test.go create mode 100644 cookies/cookies_test.go create mode 100644 humanize/delay.go create mode 100644 humanize/humanize_test.go create mode 100644 humanize/input.go create mode 100644 humanize/mouse.go create mode 100644 humanize/provider.go create mode 100644 xiaohongshu/search_integration_test.go diff --git a/.github/workflows/aliyun-docker-release.yml b/.github/workflows/aliyun-docker-release.yml index 735cd57..dcff677 100644 --- a/.github/workflows/aliyun-docker-release.yml +++ b/.github/workflows/aliyun-docker-release.yml @@ -40,16 +40,3 @@ jobs: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/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: | - crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64 - crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest-arm64 - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 99e4551..57678fa 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -38,17 +38,4 @@ jobs: 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/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7ca00df --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +# 只跑 hermetic 单元测试(无浏览器/无网络)。 +# 有头浏览器集成测试用 //go:build integration 隔离:默认不运行,仅编译校验。 +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Unit tests + run: go test ./... + + # 集成测试只编译不运行:保证它们始终跟当前 API 一致、不烂掉, + # 但绝不在 CI 启动有头浏览器(-run '^$' 匹配空,一个用例都不跑)。 + - name: Compile integration tests (no run) + run: go test -tags integration -run '^$' ./... diff --git a/.github/workflows/update-browser.yml b/.github/workflows/update-browser.yml new file mode 100644 index 0000000..f5da646 --- /dev/null +++ b/.github/workflows/update-browser.yml @@ -0,0 +1,78 @@ +name: Update browser version + +# 每周检测内置浏览器是否有新版本(读自建 CDN 的 latest 指针,公开 repo 不引用上游)。 +# 有新版 + CDN 三平台文件就绪 → 直接提交 browser/browser_version.txt 到 main(不开 PR); +# 文件未就绪 → 开 Issue 提醒。版本号单一来源,Go 与 Dockerfile 都读它。 + +on: + schedule: + - cron: '0 4 * * 1' # 每周一 04:00 UTC,晚于镜像同步 + workflow_dispatch: {} + +permissions: + contents: write + issues: write + +env: + CDN_BASE: https://cdn.one-world.ai/browsers + +jobs: + update: + runs-on: ubuntu-latest + if: github.repository == 'xpzouying/xiaohongshu-mcp' + steps: + - uses: actions/checkout@v4 + + - name: 检测最新版本(读 CDN latest 指针) + id: check + run: | + LATEST=$(curl -fsS "${CDN_BASE}/latest.txt" | tr -d '[:space:]' || true) + CURRENT=$(tr -d '[:space:]' < browser/browser_version.txt) + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + if [ -n "$LATEST" ] && [ "$LATEST" != "$CURRENT" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "无更新(current=$CURRENT latest=$LATEST)" + fi + + - name: 校验 CDN 三平台文件就绪 + id: verify + if: steps.check.outputs.changed == 'true' + env: + LATEST: ${{ steps.check.outputs.latest }} + run: | + OK=true + for f in linux-x64.tar.xz macos-arm64.dmg windows-x64.zip SHA256SUMS; do + code=$(curl -s -o /dev/null -w '%{http_code}' -I "${CDN_BASE}/${LATEST}/${f}") + if [ "$code" != "200" ]; then echo "缺失: ${f} (HTTP ${code})"; OK=false; fi + done + echo "ok=$OK" >> "$GITHUB_OUTPUT" + + - name: 校验通过 → 提交到 main + if: steps.check.outputs.changed == 'true' && steps.verify.outputs.ok == 'true' + env: + LATEST: ${{ steps.check.outputs.latest }} + CURRENT: ${{ steps.check.outputs.current }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + printf '%s\n' "$LATEST" > browser/browser_version.txt + git add browser/browser_version.txt + git commit -m "chore: bump browser ${CURRENT} -> ${LATEST}" + git push origin HEAD:main + + - name: 校验失败 → 开 Issue + if: steps.check.outputs.changed == 'true' && steps.verify.outputs.ok == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST: ${{ steps.check.outputs.latest }} + CURRENT: ${{ steps.check.outputs.current }} + run: | + TITLE="浏览器 ${LATEST} CDN 文件未就绪,未自动升级" + if [ -n "$(gh issue list --state open --search "$TITLE" --json number --jq '.[0].number')" ]; then + echo "Issue 已存在,跳过"; exit 0 + fi + gh issue create --title "$TITLE" \ + --body "检测到新版本 ${LATEST}(当前 ${CURRENT}),但 CDN 上 browsers/${LATEST}/ 的文件不全,自动升级已中止。请检查镜像同步是否完成。" diff --git a/.gitignore b/.gitignore index c6ce4d6..f218405 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ test_*.sh cookies.json docker/data/ docker/images/ + +# 本地分析/调试产物,不进仓库 +debug/ diff --git a/Dockerfile b/Dockerfile index 3a934cb..f340850 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ 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. 安装 CloakBrowser Chromium 运行依赖、Python 和中文字体 +# 2. 安装内置浏览器运行依赖(Chromium 库)和中文字体 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ @@ -74,41 +74,36 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxss1 \ libxtst6 \ lsb-release \ - python3 \ - python3-pip \ wget \ xdg-utils \ + xz-utils \ && rm -rf /var/lib/apt/lists/* -# 3. 创建共享目录并设置权限。 -# /opt/cloakbrowser 保存构建阶段预下载的浏览器,不能放到运行时会被 volume 覆盖的 /app/data。 -RUN mkdir -p /opt/cloakbrowser/home /opt/cloakbrowser/cache /opt/cloakbrowser/config \ +# 3. 创建目录并设置权限。/opt/browser 保存构建阶段预置的浏览器。 +RUN mkdir -p /opt/browser \ /app/data/home /app/data/cache /app/data/config /app/images && \ - chmod -R 755 /opt/cloakbrowser && \ + chmod -R 755 /opt/browser && \ chmod -R 777 /app/data /app/images -# 4. 安装 CloakBrowser 并在构建阶段预下载 Chromium,避免容器运行时下载失败。 -COPY build/find_cloakbrowser_binary.py /usr/local/bin/find_cloakbrowser_binary.py -ARG CLOAKBROWSER_VERSION=0.3.31 -RUN --mount=type=cache,target=/root/.cache/pip \ - python3 -m pip install "cloakbrowser[geoip]==${CLOAKBROWSER_VERSION}" -RUN --mount=type=cache,target=/var/cache/cloakbrowser-home/.cloakbrowser \ - HOME=/var/cache/cloakbrowser-home \ - XDG_CACHE_HOME=/var/cache/cloakbrowser-cache \ - XDG_CONFIG_HOME=/var/cache/cloakbrowser-config \ - python3 /usr/local/bin/find_cloakbrowser_binary.py \ - --install-home /opt/cloakbrowser/home \ - --link /usr/local/bin/cloak-chromium -RUN \ - test -x /usr/local/bin/cloak-chromium +# 4. 下载并解压内置浏览器。构建阶段预置,运行时零下载。 +# 版本号唯一来源:browser/browser_version.txt(Go 也读它,避免两处漂移)。 +# 从自建 CDN 下载中性文件名,并校验 SHA256。 +COPY browser/browser_version.txt /tmp/browser_version.txt +RUN VER="$(cat /tmp/browser_version.txt | tr -d '[:space:]')" && \ + BASE="https://cdn.one-world.ai/browsers/${VER}" && \ + curl -fsSL -o /tmp/browser.tar.xz "${BASE}/linux-x64.tar.xz" && \ + curl -fsSL "${BASE}/SHA256SUMS" | grep " linux-x64.tar.xz$" | awk '{print $1" /tmp/browser.tar.xz"}' | sha256sum -c - && \ + tar -xJf /tmp/browser.tar.xz -C /opt/browser --strip-components=1 && \ + rm /tmp/browser.tar.xz /tmp/browser_version.txt && \ + test -x /opt/browser/chrome COPY --from=builder /out/app . -# 5. 设置默认 CloakBrowser Chromium 路径(rod 会用) +# 5. 设置内置浏览器路径(rod 通过 ROD_BROWSER_BIN 启动它) ENV HOME=/app/data/home ENV XDG_CACHE_HOME=/app/data/cache ENV XDG_CONFIG_HOME=/app/data/config -ENV ROD_BROWSER_BIN=/usr/local/bin/cloak-chromium +ENV ROD_BROWSER_BIN=/opt/browser/chrome EXPOSE 18060 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 deleted file mode 100644 index f4a4828..0000000 --- a/Dockerfile.arm64 +++ /dev/null @@ -1,119 +0,0 @@ -# syntax=docker/dockerfile:1.6 - -# Dockerfile for ARM64 architecture. -# Uses CloakBrowser Chromium instead of Google Chrome or go-rod runtime downloads. - -# ---- 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. 安装 CloakBrowser Chromium 运行依赖、Python 和中文字体 -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - fonts-liberation \ - fonts-noto-color-emoji \ - fonts-unifont \ - fonts-freefont-ttf \ - fonts-wqy-zenhei \ - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libc6 \ - libcairo2 \ - libcairo-gobject2 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libexpat1 \ - libfontconfig1 \ - libgdk-pixbuf-2.0-0 \ - 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 \ - libxkbcommon0 \ - libxrandr2 \ - libxrender1 \ - libxshmfence1 \ - libxss1 \ - libxtst6 \ - lsb-release \ - python3 \ - python3-pip \ - wget \ - xdg-utils \ - && rm -rf /var/lib/apt/lists/* - -# 3. 创建共享目录并设置权限。 -# /opt/cloakbrowser 保存构建阶段预下载的浏览器,不能放到运行时会被 volume 覆盖的 /app/data。 -RUN mkdir -p /opt/cloakbrowser/home /opt/cloakbrowser/cache /opt/cloakbrowser/config \ - /app/data/home /app/data/cache /app/data/config /app/images && \ - chmod -R 755 /opt/cloakbrowser && \ - chmod -R 777 /app/data /app/images - -# 4. 安装 CloakBrowser 并在构建阶段预下载 Chromium,避免容器运行时下载失败。 -COPY build/find_cloakbrowser_binary.py /usr/local/bin/find_cloakbrowser_binary.py -ARG CLOAKBROWSER_VERSION=0.3.31 -RUN --mount=type=cache,target=/root/.cache/pip \ - python3 -m pip install "cloakbrowser[geoip]==${CLOAKBROWSER_VERSION}" -RUN --mount=type=cache,target=/var/cache/cloakbrowser-home/.cloakbrowser \ - HOME=/var/cache/cloakbrowser-home \ - XDG_CACHE_HOME=/var/cache/cloakbrowser-cache \ - XDG_CONFIG_HOME=/var/cache/cloakbrowser-config \ - python3 /usr/local/bin/find_cloakbrowser_binary.py \ - --install-home /opt/cloakbrowser/home \ - --link /usr/local/bin/cloak-chromium -RUN \ - test -x /usr/local/bin/cloak-chromium - -COPY --from=builder /out/app . - -# 5. 设置默认 CloakBrowser Chromium 路径(rod 会用) -ENV HOME=/app/data/home -ENV XDG_CACHE_HOME=/app/data/cache -ENV XDG_CONFIG_HOME=/app/data/config -ENV ROD_BROWSER_BIN=/usr/local/bin/cloak-chromium - -EXPOSE 18060 - -CMD ["./app"] diff --git a/README.md b/README.md index 0a0bd60..dca841e 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ docker build -t xpzouying/xiaohongshu-mcp . Docker 版本会自动: -- 配置 CloakBrowser Chromium 和中文字体 +- 配置内置浏览器和中文字体 - 挂载 `./data` 用于存储 cookies 和运行数据目录 - 挂载 `./images` 用于存储发布的图片 - 暴露 18060 端口供 MCP 连接 diff --git a/README_EN.md b/README_EN.md index 312c305..91f0eb7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -410,7 +410,7 @@ docker build -t xpzouying/xiaohongshu-mcp . The Docker version automatically: -- Configures CloakBrowser Chromium and Chinese fonts +- Configures the built-in browser and Chinese fonts - Mounts `./data` for storing cookies and runtime data directories - Mounts `./images` for storing publish images - Exposes port 18060 for MCP connection diff --git a/browser/browser.go b/browser/browser.go index b483428..894ee62 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -2,7 +2,7 @@ package browser import ( "net/url" - "os" + "strings" "github.com/sirupsen/logrus" "github.com/xpzouying/headless_browser" @@ -11,6 +11,10 @@ import ( type browserConfig struct { binPath string + // fingerprintSeed 固定指纹 seed;>0 时钉死,同账号每次同一套指纹。0 = 每次随机。 + fingerprintSeed int + // proxy 代理地址;非空时启用。 + proxy string } type Option func(*browserConfig) @@ -21,18 +25,33 @@ func WithBinPath(binPath string) Option { } } +// WithProxy 设置代理(http/https/socks5)。空字符串视为不启用。 +func WithProxy(proxy string) Option { + return func(c *browserConfig) { + c.proxy = proxy + } +} + +// WithFingerprintSeed 固定指纹 seed(同账号绑定一套稳定指纹)。 +// seed<=0 视为未设,回退每次随机。 +func WithFingerprintSeed(seed int) Option { + return func(c *browserConfig) { + c.fingerprintSeed = seed + } +} + // maskProxyCredentials masks username and password in proxy URL for safe logging. func maskProxyCredentials(proxyURL string) string { u, err := url.Parse(proxyURL) if err != nil || u.User == nil { return proxyURL } + cred := "***" if _, hasPassword := u.User.Password(); hasPassword { - u.User = url.UserPassword("***", "***") - } else { - u.User = url.User("***") + cred = "***:***" } - return u.String() + // 直接在原串替换 userinfo,避免 url.String() 把 * 编码成 %2A(日志变乱码)。 + return strings.Replace(proxyURL, u.User.String()+"@", cred+"@", 1) } func NewBrowser(headless bool, options ...Option) *headless_browser.Browser { @@ -43,15 +62,28 @@ func NewBrowser(headless bool, options ...Option) *headless_browser.Browser { opts := []headless_browser.Option{ headless_browser.WithHeadless(headless), + // 用内置浏览器的默认配置,不强制 UA。 + headless_browser.WithFingerprint(""), // 空 = 按运行 OS 自动:Linux→windows,mac→macos + headless_browser.WithStealthJS(false), + headless_browser.WithLanguage("zh-CN"), // 面向小红书 + // 品牌报 Chrome。 + // 注:hardware-concurrency 不设,交给 seed 派生。 + headless_browser.WithExtraFlags(map[string]string{"fingerprint-brand": "Chrome"}), } if cfg.binPath != "" { opts = append(opts, headless_browser.WithChromeBinPath(cfg.binPath)) } - // Read proxy from environment variable - if proxy := os.Getenv("XHS_PROXY"); proxy != "" { - opts = append(opts, headless_browser.WithProxy(proxy)) - logrus.Infof("Using proxy: %s", maskProxyCredentials(proxy)) + // 代理(由调用方经 Option 传入,env 读取放在入口层)。 + if cfg.proxy != "" { + opts = append(opts, headless_browser.WithProxy(cfg.proxy)) + logrus.Infof("Using proxy: %s", maskProxyCredentials(cfg.proxy)) + } + + // 固定指纹 seed(由调用方经 Option 传入,env 读取放在入口层)。 + if cfg.fingerprintSeed > 0 { + opts = append(opts, headless_browser.WithFingerprintSeed(cfg.fingerprintSeed)) + logrus.Infof("fingerprint seed pinned: %d", cfg.fingerprintSeed) } // 加载 cookies diff --git a/browser/browser_download.go b/browser/browser_download.go new file mode 100644 index 0000000..144b185 --- /dev/null +++ b/browser/browser_download.go @@ -0,0 +1,332 @@ +package browser + +import ( + "archive/tar" + "archive/zip" + "bufio" + "crypto/sha256" + _ "embed" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/ulikunitz/xz" +) + +// 内置浏览器的下载分发地址。 +const browserCDNBase = "https://cdn.one-world.ai/browsers" + +// browserVersion 是内置浏览器的唯一版本源。升级只改 browser_version.txt 一处,Go 与 Dockerfile 同读。 +// +//go:embed browser_version.txt +var browserVersionRaw string + +var browserVersion = strings.TrimSpace(browserVersionRaw) + +func browserURL(name string) string { + return browserCDNBase + "/" + browserVersion + "/" + name +} + +// platformAsset 返回当前 OS/arch 对应的下载文件名与解压后二进制文件名。 +// 第三个返回值为 false 表示当前平台无预编译二进制。 +func platformAsset() (assetName, binName string, ok bool) { + switch runtime.GOOS { + case "darwin": + if runtime.GOARCH != "arm64" { + return "", "", false + } + return "macos-arm64.dmg", "Chromium", true + case "linux": + if runtime.GOARCH != "amd64" { + return "", "", false + } + return "linux-x64.tar.xz", "chrome", true + case "windows": + if runtime.GOARCH != "amd64" { + return "", "", false + } + return "windows-x64.zip", "chrome.exe", true + } + return "", "", false +} + +func browserCacheDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(base, "xiaohongshu-mcp", "browser", browserVersion), nil +} + +// EnsureBrowser 确保本地存在内置浏览器二进制,返回其路径。 +// 已缓存则直接返回;否则下载 → 校验 SHA256 → 解压。当前平台无预编译二进制时返回 error。 +func EnsureBrowser() (string, error) { + asset, binName, ok := platformAsset() + if !ok { + return "", fmt.Errorf("当前平台 %s/%s 无预编译浏览器,请手动指定 --bin", runtime.GOOS, runtime.GOARCH) + } + + cacheDir, err := browserCacheDir() + if err != nil { + return "", err + } + + // 已缓存:遍历查找二进制 + if bin := findBinary(cacheDir, binName); bin != "" { + return bin, nil + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", err + } + + // 下载(重试 3 次) + logrus.Infof("首次运行:下载内置浏览器 %s(%s,约 140-190MB,仅一次)...", browserVersion, asset) + archivePath := filepath.Join(cacheDir, asset) + var dlErr error + for attempt := 1; attempt <= 3; attempt++ { + if dlErr = downloadFile(browserURL(asset), archivePath); dlErr == nil { + break + } + logrus.Warnf("下载失败(第 %d/3 次): %v", attempt, dlErr) + _ = os.Remove(archivePath) + time.Sleep(2 * time.Second) + } + if dlErr != nil { + return "", fmt.Errorf("下载内置浏览器失败: %w\n"+ + " 浏览器是反检测核心,缺它等于裸奔,故不继续。请任选其一:\n"+ + " 1) 检查网络后重试;\n"+ + " 2) 手动下载 %s,解压后用 --bin 指定其中的浏览器二进制。", dlErr, browserURL(asset)) + } + defer os.Remove(archivePath) + + // 校验 SHA256(本地已成分发点,必须校验完整性) + if err := verifySHA256(archivePath, asset); err != nil { + return "", fmt.Errorf("校验失败: %w", err) + } + + logrus.Infof("解压内置浏览器 ...") + if err := extractArchive(archivePath, cacheDir); err != nil { + return "", fmt.Errorf("解压失败: %w", err) + } + + bin := findBinary(cacheDir, binName) + if bin == "" { + return "", fmt.Errorf("解压后未找到二进制 %s", binName) + } + logrus.Infof("内置浏览器就绪: %s", bin) + return bin, nil +} + +// verifySHA256 下载同目录的 SHA256SUMS,校验 asset 的哈希。 +func verifySHA256(archivePath, asset string) error { + want, err := fetchExpectedSHA(asset) + if err != nil { + return err + } + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("%s SHA256 不匹配:期望 %s,实际 %s", asset, want, got) + } + return nil +} + +func fetchExpectedSHA(asset string) (string, error) { + resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(browserURL("SHA256SUMS")) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("获取 SHA256SUMS: HTTP %d", resp.StatusCode) + } + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + // 格式:␠␠ + fields := strings.Fields(sc.Text()) + if len(fields) == 2 && fields[1] == asset { + return fields[0], nil + } + } + return "", fmt.Errorf("SHA256SUMS 中未找到 %s", asset) +} + +func findBinary(dir, binName string) string { + var found string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if filepath.Base(path) == binName { + found = path + return io.EOF // 提前结束 + } + return nil + }) + if found != "" { + if err := os.Chmod(found, 0o755); err != nil { + logrus.Debugf("chmod %s: %v", found, err) + } + } + return found +} + +func downloadFile(url, dst string) error { + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, url) + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} + +func extractArchive(archivePath, destDir string) error { + switch { + case strings.HasSuffix(archivePath, ".tar.xz"): + return extractTarXz(archivePath, destDir) + case strings.HasSuffix(archivePath, ".zip"): + return extractZip(archivePath, destDir) + case strings.HasSuffix(archivePath, ".dmg"): + return extractDmg(archivePath, destDir) + } + return fmt.Errorf("不支持的压缩格式: %s", archivePath) +} + +func extractTarXz(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + xzr, err := xz.NewReader(f) + if err != nil { + return err + } + tr := tar.NewReader(xzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Join(destDir, hdr.Name) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + case tar.TypeSymlink: + _ = os.MkdirAll(filepath.Dir(target), 0o755) + _ = os.Symlink(hdr.Linkname, target) + } + } + return nil +} + +func extractZip(archivePath, destDir string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer zr.Close() + for _, zf := range zr.File { + target := filepath.Join(destDir, zf.Name) + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(out, rc) + out.Close() + rc.Close() + if err != nil { + return err + } + } + return nil +} + +func extractDmg(archivePath, destDir string) error { + mountPoint, err := os.MkdirTemp("", "bx-dmg-") + if err != nil { + return err + } + defer os.RemoveAll(mountPoint) + + if out, err := exec.Command("hdiutil", "attach", archivePath, "-nobrowse", "-mountpoint", mountPoint).CombinedOutput(); err != nil { + return fmt.Errorf("hdiutil attach: %v: %s", err, out) + } + defer exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run() + + var appPath string + entries, _ := os.ReadDir(mountPoint) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".app") { + appPath = filepath.Join(mountPoint, e.Name()) + break + } + } + if appPath == "" { + return fmt.Errorf("dmg 内未找到 .app") + } + dstApp := filepath.Join(destDir, filepath.Base(appPath)) + if out, err := exec.Command("cp", "-R", appPath, dstApp).CombinedOutput(); err != nil { + return fmt.Errorf("拷贝 .app: %v: %s", err, out) + } + _ = exec.Command("xattr", "-dr", "com.apple.quarantine", dstApp).Run() + return nil +} diff --git a/browser/browser_test.go b/browser/browser_test.go new file mode 100644 index 0000000..669bcff --- /dev/null +++ b/browser/browser_test.go @@ -0,0 +1,48 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestMaskProxyCredentials 校验代理日志脱敏:绝不能把用户名/密码打进日志。 +func TestMaskProxyCredentials(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "空字符串", input: "", want: ""}, + {name: "无认证信息原样返回", input: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080"}, + {name: "用户名+密码都脱敏", input: "http://user:pass@host:8080", want: "http://***:***@host:8080"}, + {name: "仅用户名脱敏", input: "http://user@host:8080", want: "http://***@host:8080"}, + {name: "socks5带认证", input: "socks5://alice:secret@127.0.0.1:1080", want: "socks5://***:***@127.0.0.1:1080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, maskProxyCredentials(tt.input)) + }) + } +} + +// TestOptions 校验 Option 正确写入 browserConfig(New+Option 的接线)。 +func TestOptions(t *testing.T) { + cfg := &browserConfig{} + WithBinPath("/opt/browser/chrome")(cfg) + WithFingerprintSeed(98759)(cfg) + WithProxy("http://127.0.0.1:8080")(cfg) + + assert.Equal(t, "/opt/browser/chrome", cfg.binPath) + assert.Equal(t, 98759, cfg.fingerprintSeed) + assert.Equal(t, "http://127.0.0.1:8080", cfg.proxy) +} + +// TestOptions_Defaults 未传 Option 时各字段为零值(回退随机 seed / 不设代理)。 +func TestOptions_Defaults(t *testing.T) { + cfg := &browserConfig{} + assert.Equal(t, "", cfg.binPath) + assert.Equal(t, 0, cfg.fingerprintSeed) + assert.Equal(t, "", cfg.proxy) +} diff --git a/browser/browser_version.txt b/browser/browser_version.txt new file mode 100644 index 0000000..f88156e --- /dev/null +++ b/browser/browser_version.txt @@ -0,0 +1 @@ +148.0.7778.215 diff --git a/build/find_cloakbrowser_binary.py b/build/find_cloakbrowser_binary.py deleted file mode 100644 index c739f09..0000000 --- a/build/find_cloakbrowser_binary.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare CloakBrowser Chromium for Docker images.""" - -import argparse -import os -import shutil -from pathlib import Path - -from cloakbrowser import ensure_binary - - -def disable_nonessential_build_hooks() -> None: - """Disable CloakBrowser side effects that are not needed while building images.""" - # Docker builds only need the binary. CloakBrowser's welcome output and - # background update check have caused ARM64/QEMU build exits to segfault. - globals_ = ensure_binary.__globals__ - globals_["_show_welcome"] = lambda: None - globals_["_maybe_trigger_update_check"] = lambda: None - - -def find_binary() -> Path: - disable_nonessential_build_hooks() - binary = Path(ensure_binary()).expanduser() - if not binary.is_file() or not os.access(binary, os.X_OK): - raise SystemExit(f"CloakBrowser Chromium binary is not executable: {binary}") - - return binary - - -def copy_to_install_home(binary: Path, install_home: Path) -> Path: - source_home = Path.home() - source_cache = source_home / ".cloakbrowser" - if not source_cache.is_dir(): - raise SystemExit(f"CloakBrowser cache directory not found: {source_cache}") - - try: - relative_binary = binary.relative_to(source_home) - except ValueError as exc: - raise SystemExit(f"CloakBrowser binary is outside HOME: {binary}") from exc - - install_home.mkdir(parents=True, exist_ok=True) - target_cache = install_home / ".cloakbrowser" - if target_cache.exists(): - shutil.rmtree(target_cache) - shutil.copytree(source_cache, target_cache, symlinks=True) - - installed_binary = install_home / relative_binary - if not installed_binary.is_file() or not os.access(installed_binary, os.X_OK): - raise SystemExit(f"Installed CloakBrowser binary is not executable: {installed_binary}") - - return installed_binary - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--install-home", help="Copy CloakBrowser cache into this HOME") - parser.add_argument("--link", help="Create or replace a symlink to the binary") - args = parser.parse_args() - - binary = find_binary() - if args.install_home: - binary = copy_to_install_home(binary, Path(args.install_home)) - - print(binary) - - if args.link: - link = Path(args.link) - if link.exists() or link.is_symlink(): - link.unlink() - link.symlink_to(binary) - - -if __name__ == "__main__": - main() diff --git a/cmd/login/main.go b/cmd/login/main.go index 5a3fd96..12434e3 100644 --- a/cmd/login/main.go +++ b/cmd/login/main.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "flag" + "os" "github.com/go-rod/rod" "github.com/sirupsen/logrus" "github.com/xpzouying/xiaohongshu-mcp/browser" + "github.com/xpzouying/xiaohongshu-mcp/configs" "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" ) @@ -19,8 +21,25 @@ func main() { flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径") flag.Parse() - // 登录的时候,需要界面,所以不能无头模式 - b := browser.NewBrowser(false, browser.WithBinPath(binPath)) + if binPath == "" { + binPath = os.Getenv("ROD_BROWSER_BIN") + } + // 未指定浏览器:自动准备内置浏览器,失败即退出。 + if binPath == "" { + bin, err := browser.EnsureBrowser() + if err != nil { + logrus.Fatalf("%v", err) + } + binPath = bin + } + + // 登录的时候,需要界面,所以不能无头模式。 + // 登录与后续运行用相同的固定指纹(若设了 XHS_FP_SEED)。 + b := browser.NewBrowser(false, + browser.WithBinPath(binPath), + browser.WithFingerprintSeed(configs.FingerprintSeedFromEnv()), + browser.WithProxy(configs.ProxyFromEnv()), + ) defer b.Close() page := b.NewPage() diff --git a/configs/browser.go b/configs/browser.go index 34f4538..1713ec7 100644 --- a/configs/browser.go +++ b/configs/browser.go @@ -1,9 +1,20 @@ package configs +import ( + "os" + "strconv" + + "github.com/sirupsen/logrus" +) + var ( useHeadless = true binPath = "" + + fingerprintSeed = 0 + + proxy = "" ) func InitHeadless(h bool) { @@ -22,3 +33,39 @@ func SetBinPath(b string) { func GetBinPath() string { return binPath } + +func SetFingerprintSeed(s int) { + fingerprintSeed = s +} + +func FingerprintSeed() int { + return fingerprintSeed +} + +// FingerprintSeedFromEnv 从 XHS_FP_SEED 环境变量解析固定 seed。 +// 未设或非法返回 0(回退随机)。env 读取集中在配置层,浏览器工厂只收 Option。 +func FingerprintSeedFromEnv() int { + s := os.Getenv("XHS_FP_SEED") + if s == "" { + return 0 + } + seed, err := strconv.Atoi(s) + if err != nil || seed <= 0 { + logrus.Warnf("invalid XHS_FP_SEED=%q, ignored (fallback to random seed)", s) + return 0 + } + return seed +} + +func SetProxy(p string) { + proxy = p +} + +func Proxy() string { + return proxy +} + +// ProxyFromEnv 从 XHS_PROXY 环境变量读取代理地址。env 读取集中在配置层。 +func ProxyFromEnv() string { + return os.Getenv("XHS_PROXY") +} diff --git a/configs/browser_test.go b/configs/browser_test.go new file mode 100644 index 0000000..b4343df --- /dev/null +++ b/configs/browser_test.go @@ -0,0 +1,52 @@ +package configs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFingerprintSeedFromEnv 校验 XHS_FP_SEED 解析:非法/非正数一律回退 0(随机)。 +func TestFingerprintSeedFromEnv(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "未设返回0", env: "", want: 0}, + {name: "合法seed", env: "98759", want: 98759}, + {name: "非数字回退0", env: "abc", want: 0}, + {name: "零回退0", env: "0", want: 0}, + {name: "负数回退0", env: "-5", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("XHS_FP_SEED", tt.env) + assert.Equal(t, tt.want, FingerprintSeedFromEnv()) + }) + } +} + +func TestProxyFromEnv(t *testing.T) { + t.Run("未设为空", func(t *testing.T) { + t.Setenv("XHS_PROXY", "") + assert.Equal(t, "", ProxyFromEnv()) + }) + t.Run("读取代理地址", func(t *testing.T) { + t.Setenv("XHS_PROXY", "socks5://127.0.0.1:1080") + assert.Equal(t, "socks5://127.0.0.1:1080", ProxyFromEnv()) + }) +} + +// TestSetGet 校验 seed/proxy 的存取;用后恢复全局状态避免污染其他测试。 +func TestSetGet(t *testing.T) { + origSeed, origProxy := FingerprintSeed(), Proxy() + t.Cleanup(func() { SetFingerprintSeed(origSeed); SetProxy(origProxy) }) + + SetFingerprintSeed(12345) + assert.Equal(t, 12345, FingerprintSeed()) + + SetProxy("http://proxy:3128") + assert.Equal(t, "http://proxy:3128", Proxy()) +} diff --git a/cookies/cookies_test.go b/cookies/cookies_test.go new file mode 100644 index 0000000..93bcad7 --- /dev/null +++ b/cookies/cookies_test.go @@ -0,0 +1,62 @@ +package cookies + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestGetCookiesFilePath 校验路径优先级:旧路径(/tmp/cookies.json) > COOKIES_PATH > 当前目录。 +// 用 TMPDIR 重定向 os.TempDir() 到测试目录,做到 hermetic、不碰真实 /tmp。 +func TestGetCookiesFilePath(t *testing.T) { + t.Run("旧路径存在时优先返回旧路径", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + t.Setenv("COOKIES_PATH", "/should/not/be/used.json") + + oldPath := filepath.Join(dir, "cookies.json") + assert.NoError(t, os.WriteFile(oldPath, []byte("[]"), 0644)) + + assert.Equal(t, oldPath, GetCookiesFilePath()) + }) + + t.Run("旧路径不存在时用COOKIES_PATH", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + t.Setenv("COOKIES_PATH", "/custom/cookies.json") + + assert.Equal(t, "/custom/cookies.json", GetCookiesFilePath()) + }) + + t.Run("都不存在时回退当前目录", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + t.Setenv("COOKIES_PATH", "") + + assert.Equal(t, "cookies.json", GetCookiesFilePath()) + }) +} + +// TestLoadSaveDeleteCookies 校验 cookie 文件存取往返与删除的幂等。 +func TestLoadSaveDeleteCookies(t *testing.T) { + path := filepath.Join(t.TempDir(), "cookies.json") + c := NewLoadCookie(path) + + // 未写入时读取应报错 + _, err := c.LoadCookies() + assert.Error(t, err) + + // 写入后能原样读回 + want := []byte(`[{"name":"web_session","value":"x"}]`) + assert.NoError(t, c.SaveCookies(want)) + got, err := c.LoadCookies() + assert.NoError(t, err) + assert.Equal(t, want, got) + + // 删除后文件消失,且再次删除幂等(不报错) + assert.NoError(t, c.DeleteCookies()) + assert.NoFileExists(t, path) + assert.NoError(t, c.DeleteCookies()) +} diff --git a/docker/README.md b/docker/README.md index 539779f..8f4079b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -6,7 +6,7 @@ - 启动后,会产生一个 `images/` 目录,用于存储发布的图片。它会挂载到 Docker 容器里面。 如果要使用本地图片发布的话,请确保图片拷贝到 `./images/` 目录下,并且让 MCP 在发布的时候,指定文件夹为:`/app/images`,否则一定失败。 -- Docker 镜像内置 CloakBrowser Chromium,并在构建阶段预下载浏览器。请挂载 `./data:/app/data`,用于持久化 cookies 和运行数据目录。 +- Docker 镜像内置浏览器,并在构建阶段预下载。请挂载 `./data:/app/data`,用于持久化 cookies 和运行数据目录。 ## 1. 获取 Docker 镜像 @@ -109,7 +109,7 @@ docker run -e XHS_PROXY=http://user:pass@proxy:port xpzouying/xiaohongshu-mcp ```yaml environment: - - ROD_BROWSER_BIN=/usr/local/bin/cloak-chromium + - ROD_BROWSER_BIN=/opt/browser/chrome - COOKIES_PATH=/app/data/cookies.json - HOME=/app/data/home - XDG_CACHE_HOME=/app/data/cache diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 26cae82..2f2ce05 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,7 @@ services: - ./data:/app/data - ./images:/app/images environment: - - ROD_BROWSER_BIN=/usr/local/bin/cloak-chromium + - ROD_BROWSER_BIN=/opt/browser/chrome - COOKIES_PATH=/app/data/cookies.json - HOME=/app/data/home - XDG_CACHE_HOME=/app/data/cache diff --git a/go.mod b/go.mod index daf58b9..1837e21 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,8 @@ require ( github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 - github.com/xpzouying/headless_browser v0.3.0 + github.com/ulikunitz/xz v0.5.15 + github.com/xpzouying/headless_browser v0.4.0 ) require ( diff --git a/go.sum b/go.sum index fd5d27b..3598ea9 100644 --- a/go.sum +++ b/go.sum @@ -81,10 +81,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS 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= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/xpzouying/headless_browser v0.2.0 h1:EmuHXDVzx0tAevHJUdETs8iT/eK+QqrLiybvGd1xZDA= -github.com/xpzouying/headless_browser v0.2.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc= -github.com/xpzouying/headless_browser v0.3.0 h1:ila/Kmei1dvBbP71SXEQuWfLuvjCw5HMqsgOzK39xn0= -github.com/xpzouying/headless_browser v0.3.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/xpzouying/headless_browser v0.4.0 h1:THzdZbouc8vBs9GWn5OJ7K1RabBt6sGvbLbePXKu57Y= +github.com/xpzouying/headless_browser v0.4.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= diff --git a/humanize/delay.go b/humanize/delay.go new file mode 100644 index 0000000..54ce36c --- /dev/null +++ b/humanize/delay.go @@ -0,0 +1,22 @@ +package humanize + +import ( + "context" + "time" +) + +// Delay 按 action 对应的分布采样一个时延并阻塞等待,期间可被 ctx 取消。 +func Delay(ctx context.Context, action Action) { + dist, ok := defaultProvider.Timing()[action] + if !ok { + dist = defaultProvider.Timing()[AfterClick] // 未知动作回退到一个保守默认 + } + + t := time.NewTimer(dist.Sample()) + defer t.Stop() + + select { + case <-t.C: + case <-ctx.Done(): + } +} diff --git a/humanize/humanize_test.go b/humanize/humanize_test.go new file mode 100644 index 0000000..10dc4b6 --- /dev/null +++ b/humanize/humanize_test.go @@ -0,0 +1,96 @@ +package humanize + +import ( + "context" + "testing" + "time" + + "github.com/go-rod/rod/lib/proto" + "github.com/stretchr/testify/assert" +) + +func TestLogNormal_sample(t *testing.T) { + d := LogNormal{Mu: 0, Sigma: 0.5, Min: 100 * time.Millisecond, Max: 10 * time.Second} + + // norm=0 → exp(0)=1s(未触边界) + assert.Equal(t, time.Second, d.sample(0)) + + // 单调递增:norm 越大时延越长 + assert.Less(t, d.sample(-1), d.sample(0)) + assert.Less(t, d.sample(0), d.sample(1)) +} + +func TestLogNormal_clamp(t *testing.T) { + d := LogNormal{Mu: 0, Sigma: 1, Min: 500 * time.Millisecond, Max: 2 * time.Second} + + // exp(-100)≈0 → 下限 + assert.Equal(t, d.Min, d.sample(-100)) + // exp(100) 极大 → 上限(且不因 float64→Duration 溢出而出错) + assert.Equal(t, d.Max, d.sample(100)) +} + +func TestLogNormal_noMax(t *testing.T) { + // Max<=0 表示不设上限 + d := LogNormal{Mu: 0, Sigma: 1, Min: 0, Max: 0} + // exp(3)≈20s,不被 clamp + assert.Greater(t, d.sample(3), 15*time.Second) +} + +// TestDefaultProvider_Timing 校验默认时延表:动作齐全、参数自洽(Min 40 { + steps = 40 + } + + c1, c2 := curveControlPoints(start, target, dist) + perStep := time.Duration(5+rand.Intn(5)) * time.Millisecond + + i := 0 + return mouse.MoveAlong(func() (proto.Point, bool) { + i++ + if i >= steps { + return target, true // 最后一步精确落到目标 + } + p := cubicBezier(start, c1, c2, target, easeInOut(float64(i)/float64(steps))) + time.Sleep(perStep) + return p, false + }) +} + +func curveControlPoints(a, b proto.Point, dist float64) (proto.Point, proto.Point) { + nx, ny := -(b.Y-a.Y)/dist, (b.X-a.X)/dist // 单位垂直向量 + off := dist * (0.05 + rand.Float64()*0.10) // 弧度偏移:距离的 5%~15% + if rand.Intn(2) == 0 { + off = -off + } + c1 := proto.Point{X: a.X + (b.X-a.X)/3 + nx*off, Y: a.Y + (b.Y-a.Y)/3 + ny*off} + c2 := proto.Point{X: a.X + (b.X-a.X)*2/3 + nx*off*0.5, Y: a.Y + (b.Y-a.Y)*2/3 + ny*off*0.5} + return c1, c2 +} + +func cubicBezier(p0, p1, p2, p3 proto.Point, t float64) proto.Point { + u := 1 - t + w0, w1, w2, w3 := u*u*u, 3*u*u*t, 3*u*t*t, t*t*t + return proto.Point{ + X: w0*p0.X + w1*p1.X + w2*p2.X + w3*p3.X, + Y: w0*p0.Y + w1*p1.Y + w2*p2.Y + w3*p3.Y, + } +} + +func easeInOut(t float64) float64 { + if t < 0.5 { + return 2 * t * t + } + return 1 - math.Pow(-2*t+2, 2)/2 +} diff --git a/humanize/provider.go b/humanize/provider.go new file mode 100644 index 0000000..7caeb97 --- /dev/null +++ b/humanize/provider.go @@ -0,0 +1,87 @@ +// Package humanize 封装交互行为的统一原语(延迟、输入、点击)。 +// +// 约定:业务代码统一调 humanize.*,不直接调 time.Sleep / rod 的 Mouse.MoveTo / +// elem.Input 等裸原语。 +// +// 行为参数走 Provider 接口:DefaultProvider 提供一套静态默认,可用 SetProvider 替换。 +package humanize + +import ( + "math" + "math/rand" + "time" +) + +// Action 标识一类停顿场景。 +type Action string + +const ( + AfterClick Action = "after_click" // 点击后 + AfterType Action = "after_type" // 输入后 + AfterNavigate Action = "after_navigate" // 页面跳转/加载后 + BetweenScroll Action = "between_scroll" // 连续滚动之间 + BeforeSubmit Action = "before_submit" // 提交前 + BeforeClick Action = "before_click" // 移到元素上、点击前 + Reading Action = "reading" // 浏览内容时的驻留 + Keystroke Action = "keystroke" // 逐字符输入的字间间隔 +) + +// LogNormal 描述一个右偏时延分布:ln(时延/秒) ~ Normal(Mu, Sigma),采样后 clamp 到 [Min, Max]。 +type LogNormal struct { + Mu, Sigma float64 // 对 ln(秒) 的正态参数;median = exp(Mu) + Min, Max time.Duration // clamp 边界(Max<=0 表示不设上限) +} + +// sample 是纯函数核心:给定一个标准正态样本 norm,返回 clamp 后的时延。 +// 拆出来是为了可确定性单测(喂已知 norm 值)。 +func (l LogNormal) sample(norm float64) time.Duration { + secs := math.Exp(l.Mu + l.Sigma*norm) + // 先在"秒"量级上处理上限:secs 极大时直接 float64→Duration 会溢出成负值、 + // 破坏 clamp,所以在转换前就拦掉。 + if l.Max > 0 && secs >= l.Max.Seconds() { + return l.Max + } + d := time.Duration(secs * float64(time.Second)) + if d < l.Min { + return l.Min + } + return d +} + +// Sample 用全局 rng 采样一个时延(Go 1.20+ 全局 rand 已自动播种且并发安全)。 +func (l LogNormal) Sample() time.Duration { + return l.sample(rand.NormFloat64()) +} + +type TimingProfile map[Action]LogNormal + +type Provider interface { + Timing() TimingProfile +} + +type DefaultProvider struct{} + +func (DefaultProvider) Timing() TimingProfile { + return TimingProfile{ + // median ≈ exp(Mu) 秒 + AfterClick: {Mu: -0.92, Sigma: 0.35, Min: 150 * time.Millisecond, Max: 2 * time.Second}, // ~0.4s + AfterType: {Mu: -0.51, Sigma: 0.40, Min: 200 * time.Millisecond, Max: 3 * time.Second}, // ~0.6s + AfterNavigate: {Mu: 0.41, Sigma: 0.45, Min: 600 * time.Millisecond, Max: 6 * time.Second}, // ~1.5s + BetweenScroll: {Mu: -0.22, Sigma: 0.40, Min: 250 * time.Millisecond, Max: 3 * time.Second}, // ~0.8s + BeforeSubmit: {Mu: 0.0, Sigma: 0.40, Min: 400 * time.Millisecond, Max: 4 * time.Second}, // ~1.0s + BeforeClick: {Mu: -1.61, Sigma: 0.45, Min: 80 * time.Millisecond, Max: 1 * time.Second}, // ~0.2s + Reading: {Mu: -0.36, Sigma: 0.40, Min: 300 * time.Millisecond, Max: 3 * time.Second}, // ~0.7s + Keystroke: {Mu: -2.12, Sigma: 0.50, Min: 30 * time.Millisecond, Max: 400 * time.Millisecond}, // ~120ms/字 + } +} + +// defaultProvider 是包级默认 Provider。一账号一进程,进程级 Provider 即该账号的行为参数。 +// 启动时可用 SetProvider 注入替换,业务代码零改动。 +var defaultProvider Provider = DefaultProvider{} + +// SetProvider 注入行为参数 Provider。传 nil 忽略,保证任何时候都有可用的 Provider。 +func SetProvider(p Provider) { + if p != nil { + defaultProvider = p + } +} diff --git a/main.go b/main.go index 718c9fb..c25d2ba 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/browser" "github.com/xpzouying/xiaohongshu-mcp/configs" ) @@ -22,14 +23,21 @@ func main() { if len(binPath) == 0 { binPath = os.Getenv("ROD_BROWSER_BIN") } - if binPath != "" { - logrus.Infof("using browser binary: %s", binPath) - } else { - logrus.Infof("browser binary is not configured; rod will auto-detect or download Chromium") + // 未显式指定浏览器:自动准备内置浏览器,失败即退出。 + if binPath == "" { + bin, err := browser.EnsureBrowser() + if err != nil { + logrus.Fatalf("%v", err) + } + binPath = bin } + logrus.Infof("using browser binary: %s", binPath) configs.InitHeadless(headless) configs.SetBinPath(binPath) + // 入口层读 env、解析成固定指纹 seed 和代理,经 configs 透传给浏览器工厂。 + configs.SetFingerprintSeed(configs.FingerprintSeedFromEnv()) + configs.SetProxy(configs.ProxyFromEnv()) // 初始化服务 xiaohongshuService := NewXiaohongshuService() diff --git a/mcp_handlers.go b/mcp_handlers.go index 52151ca..b8ea08f 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -47,7 +47,7 @@ func (s *AppServer) handleCheckLoginStatus(ctx context.Context) *MCPToolResult { if status.IsLoggedIn { resultText = fmt.Sprintf("✅ 已登录\n用户名: %s\n\n你可以使用其他功能了。", status.Username) } else { - resultText = fmt.Sprintf("❌ 未登录\n\n请使用 get_login_qrcode 工具获取二维码进行登录。") + resultText = "❌ 未登录\n\n请使用 get_login_qrcode 工具获取二维码进行登录。" } return &MCPToolResult{ diff --git a/pkg/downloader/images_test.go b/pkg/downloader/images_test.go index b263d0a..2693623 100644 --- a/pkg/downloader/images_test.go +++ b/pkg/downloader/images_test.go @@ -35,9 +35,8 @@ func TestIsImageURL(t *testing.T) { } func TestNewImageDownloader(t *testing.T) { - tempDir := os.TempDir() - testPath := filepath.Join(tempDir, "test_downloader") - defer os.RemoveAll(testPath) + // 子目录尚不存在,用于验证 NewImageDownloader 会创建它;t.TempDir 自动清理。 + testPath := filepath.Join(t.TempDir(), "test_downloader") downloader := NewImageDownloader(testPath) @@ -56,7 +55,7 @@ func TestNewImageDownloader(t *testing.T) { } func TestImageDownloader_isValidImageURL(t *testing.T) { - downloader := NewImageDownloader(os.TempDir()) + downloader := NewImageDownloader(t.TempDir()) tests := []struct { url string @@ -80,7 +79,7 @@ func TestImageDownloader_isValidImageURL(t *testing.T) { } func TestImageDownloader_generateFileName(t *testing.T) { - downloader := NewImageDownloader(os.TempDir()) + downloader := NewImageDownloader(t.TempDir()) url := "https://example.com/image.jpg" extension := "jpg" diff --git a/service.go b/service.go index 8ef5735..c4495d0 100644 --- a/service.go +++ b/service.go @@ -546,7 +546,11 @@ func (s *XiaohongshuService) ReplyCommentToFeed(ctx context.Context, feedID, xse } func newBrowser() *headless_browser.Browser { - return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath())) + return browser.NewBrowser(configs.IsHeadless(), + browser.WithBinPath(configs.GetBinPath()), + browser.WithFingerprintSeed(configs.FingerprintSeed()), + browser.WithProxy(configs.Proxy()), + ) } func saveCookies(page *rod.Page) error { diff --git a/xiaohongshu/comment_feed.go b/xiaohongshu/comment_feed.go index 198b069..e9691aa 100644 --- a/xiaohongshu/comment_feed.go +++ b/xiaohongshu/comment_feed.go @@ -6,8 +6,8 @@ import ( "time" "github.com/go-rod/rod" - "github.com/go-rod/rod/lib/proto" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) // CommentFeedAction 表示 Feed 评论动作 @@ -31,7 +31,7 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterNavigate) // 检测页面是否可访问 if err := checkPageAccessible(page); err != nil { @@ -44,10 +44,11 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, return fmt.Errorf("未找到评论输入框,该帖子可能不支持评论或网页端不可访问: %w", err) } - if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(elem); err != nil { logrus.Warnf("Failed to click comment input box: %v", err) return fmt.Errorf("无法点击评论输入框: %w", err) } + humanize.Delay(ctx, humanize.AfterClick) elem2, err := page.Element("div.input-box div.content-edit p.content-input") if err != nil { @@ -55,12 +56,12 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, return fmt.Errorf("未找到评论输入区域: %w", err) } - if err := elem2.Input(content); err != nil { + if err := humanize.Type(ctx, elem2, content); err != nil { logrus.Warnf("Failed to input comment content: %v", err) return fmt.Errorf("无法输入评论内容: %w", err) } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterType) submitButton, err := page.Element("div.bottom button.submit") if err != nil { @@ -68,17 +69,47 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, return fmt.Errorf("未找到提交按钮: %w", err) } - if err := submitButton.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(submitButton); err != nil { logrus.Warnf("Failed to click submit button: %v", err) return fmt.Errorf("无法点击提交按钮: %w", err) } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterClick) - logrus.Infof("Comment posted successfully to feed: %s", feedID) + // 就地校验:提交后评论应在评论区渲染出现;未出现则判定失败,避免假成功。 + if !waitCommentRendered(page, content, 4*time.Second) { + logrus.Warnf("评论提交后未在评论区渲染,判定未成功: feed=%s", feedID) + return fmt.Errorf("评论未确认成功:提交后未在评论区出现(可能账号被限制或发送失败),feed: %s", feedID) + } + + logrus.Infof("Comment posted and verified to feed: %s", feedID) return nil } +// commentRendered 就地读当前页评论区 DOM,判断指定文本的评论是否已渲染出现。 +// 不重新导航、不滚动——只读已加载的 .comments-container 的可见文本。 +func commentRendered(page *rod.Page, content string) bool { + res, err := page.Eval(`(txt) => { + const c = document.querySelector('.comments-container'); + return c ? c.innerText.includes(txt) : false; + }`, content) + if err != nil { + return false + } + return res.Value.Bool() +} + +func waitCommentRendered(page *rod.Page, content string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if commentRendered(page, content) { + return true + } + time.Sleep(300 * time.Millisecond) + } + return false +} + // ReplyToComment 回复指定评论 func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToken, commentID, userID, content string) error { // 增加超时时间,因为需要滚动查找评论 @@ -90,18 +121,17 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterNavigate) // 检测页面是否可访问 if err := checkPageAccessible(page); err != nil { return err } - // 等待评论容器加载 time.Sleep(2 * time.Second) // 使用 Go 实现的查找逻辑 - commentEl, err := findCommentElement(page, commentID, userID) + commentEl, err := findCommentElement(ctx, page, commentID, userID) if err != nil { return fmt.Errorf("无法找到评论: %w", err) } @@ -109,7 +139,7 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke // 滚动到评论位置 logrus.Info("滚动到评论位置...") commentEl.MustScrollIntoView() - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.BetweenScroll) logrus.Info("准备点击回复按钮") @@ -119,11 +149,11 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke return fmt.Errorf("无法找到回复按钮: %w", err) } - if err := replyBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(replyBtn); err != nil { return fmt.Errorf("点击回复按钮失败: %w", err) } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterClick) // 查找回复输入框 inputEl, err := page.Element("div.input-box div.content-edit p.content-input") @@ -132,11 +162,11 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke } // 输入内容 - if err := inputEl.Input(content); err != nil { + if err := humanize.Type(ctx, inputEl, content); err != nil { return fmt.Errorf("输入回复内容失败: %w", err) } - time.Sleep(500 * time.Millisecond) + humanize.Delay(ctx, humanize.AfterType) // 查找并点击提交按钮 submitBtn, err := page.Element("div.bottom button.submit") @@ -144,25 +174,31 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke return fmt.Errorf("无法找到提交按钮: %w", err) } - if err := submitBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(submitBtn); err != nil { return fmt.Errorf("点击提交按钮失败: %w", err) } - time.Sleep(2 * time.Second) - logrus.Infof("回复评论成功") + humanize.Delay(ctx, humanize.AfterClick) + + // 就地校验:回复应在评论区渲染出现,否则判定未成功。 + if !waitCommentRendered(page, content, 4*time.Second) { + logrus.Warnf("回复提交后未在评论区渲染,判定未成功: feed=%s", feedID) + return fmt.Errorf("回复未确认成功:提交后未在评论区出现(可能账号被限制或发送失败)") + } + + logrus.Infof("回复评论成功并已确认") return nil } // findCommentElement 查找指定评论元素(参考 feed_detail.go 的滚动逻辑) -func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) { +func findCommentElement(ctx context.Context, page *rod.Page, commentID, userID string) (*rod.Element, error) { logrus.Infof("开始查找评论 - commentID: %s, userID: %s", commentID, userID) const maxAttempts = 100 - const scrollInterval = 800 * time.Millisecond // 先滚动到评论区 scrollToCommentsArea(page) - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.BetweenScroll) var lastCommentCount = 0 stagnantChecks := 0 @@ -181,7 +217,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, // === 2. 获取当前评论数量 === currentCount := getCommentCount(page) logrus.Infof("当前评论数: %d", currentCount) - + if currentCount != lastCommentCount { logrus.Infof("✓ 评论数增加: %d -> %d", lastCommentCount, currentCount) lastCommentCount = currentCount @@ -202,7 +238,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, // === 4. 先滚动到最后一个评论(触发懒加载)=== if currentCount > 0 { logrus.Infof("滚动到最后一个评论(共 %d 条)", currentCount) - + // 使用 Go 获取所有评论元素 elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment, .comment-item, .comment") if err == nil && len(elements) > 0 { @@ -215,7 +251,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, } else { logrus.Warnf("未找到评论元素: %v", err) } - time.Sleep(300 * time.Millisecond) + time.Sleep(300 * time.Millisecond) // 技术 settle:等懒加载渲染 } // === 5. 继续向下滚动 === @@ -224,14 +260,14 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, if err != nil { logrus.Warnf("滚动失败: %v", err) } - time.Sleep(500 * time.Millisecond) + time.Sleep(500 * time.Millisecond) // 技术 settle:等滚动后内容加载 // === 6. 滚动后立即查找(边滚动边查找)=== // 优先通过 commentID 查找(使用 Timeout 避免长时间等待) if commentID != "" { selector := fmt.Sprintf("#comment-%s", commentID) logrus.Infof("尝试通过 commentID 查找: %s", selector) - + // 使用 Timeout 避免长时间等待 el, err := page.Timeout(2 * time.Second).Element(selector) if err == nil && el != nil { @@ -244,7 +280,7 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, // 通过 userID 查找 if userID != "" { logrus.Infof("尝试通过 userID 查找: %s", userID) - + // 使用 Timeout 避免长时间等待 elements, err := page.Timeout(2 * time.Second).Elements(".comment-item, .comment, .parent-comment") if err == nil && len(elements) > 0 { @@ -262,11 +298,10 @@ func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, logrus.Infof("获取评论元素失败或超时: %v", err) } } - + logrus.Infof("本次尝试未找到目标评论,继续下一轮...") - // === 7. 等待内容加载 === - time.Sleep(scrollInterval) + humanize.Delay(ctx, humanize.BetweenScroll) } return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 尝试次数: %d", commentID, userID, maxAttempts) diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go index de25953..94ec586 100644 --- a/xiaohongshu/feed_detail.go +++ b/xiaohongshu/feed_detail.go @@ -12,36 +12,20 @@ import ( "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" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) // ========== 配置常量 ========== 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} + defaultMaxAttempts = 500 + stagnantLimit = 20 + minScrollDelta = 10 + maxClickPerRound = 3 + largeScrollTrigger = 5 // 停滞多少次后触发大滚动 + buttonClickInterval = 3 // 每隔多少次尝试点击一次按钮 + finalSprintPushCount = 15 ) // ========== 数据结构 ========== @@ -102,18 +86,23 @@ func (f *FeedDetailAction) GetFeedDetailWithConfig(ctx context.Context, feedID, logrus.Errorf("页面导航失败: %v", err) return nil, err } - sleepRandom(1000, 1000) + humanize.Delay(ctx, humanize.AfterNavigate) if err := checkPageAccessible(page); err != nil { return nil, err } if loadAllComments { - if err := f.loadAllCommentsWithConfig(page, config); err != nil { + if err := f.loadAllCommentsWithConfig(ctx, page, config); err != nil { logrus.Warnf("加载全部评论失败: %v", err) } } + // ctx 已取消时直接返回,避免在已取消的 page 上执行 MustEval 触发 panic + if err := ctx.Err(); err != nil { + return nil, err + } + return f.extractFeedDetail(page, feedID) } @@ -138,7 +127,7 @@ type loadState struct { stagnantChecks int } -func (f *FeedDetailAction) loadAllCommentsWithConfig(page *rod.Page, config CommentLoadConfig) error { +func (f *FeedDetailAction) loadAllCommentsWithConfig(ctx context.Context, page *rod.Page, config CommentLoadConfig) error { loader := &commentLoader{ page: page, config: config, @@ -146,16 +135,15 @@ func (f *FeedDetailAction) loadAllCommentsWithConfig(page *rod.Page, config Comm state: &loadState{}, } - return loader.load() + return loader.load(ctx) } -func (cl *commentLoader) load() error { +func (cl *commentLoader) load(ctx context.Context) error { maxAttempts := cl.calculateMaxAttempts() - scrollInterval := getScrollInterval(cl.config.ScrollSpeed) logrus.Info("开始加载评论...") scrollToCommentsArea(cl.page) - sleepRandom(humanDelayRange.min, humanDelayRange.max) + humanize.Delay(ctx, humanize.BetweenScroll) // 检查是否没有评论 if cl.checkNoComments() { @@ -163,14 +151,20 @@ func (cl *commentLoader) load() error { } for cl.stats.attempts = 0; cl.stats.attempts < maxAttempts; cl.stats.attempts++ { + // 协作取消点:ctx 取消后干净退出,避免空转直到撞上 MustEval panic + if err := ctx.Err(); err != nil { + logrus.Infof("上下文已取消,停止加载评论: %v", err) + return err + } + logrus.Debugf("=== 尝试 %d/%d ===", cl.stats.attempts+1, maxAttempts) - if cl.checkComplete() { + if cl.checkComplete(ctx) { return nil } if cl.shouldClickButtons() { - cl.clickButtonsWithRetry() + cl.clickButtonsWithRetry(ctx) } currentCount := getCommentCount(cl.page) @@ -180,13 +174,13 @@ func (cl *commentLoader) load() error { return nil } - cl.performScroll() - cl.handleStagnation() + cl.performScroll(ctx) + cl.handleStagnation(ctx) - time.Sleep(scrollInterval) + humanize.Delay(ctx, humanize.BetweenScroll) } - cl.performFinalSprint() + cl.performFinalSprint(ctx) return nil } @@ -205,11 +199,11 @@ func (cl *commentLoader) checkNoComments() bool { return false } -func (cl *commentLoader) checkComplete() bool { +func (cl *commentLoader) checkComplete(ctx context.Context) bool { if checkEndContainer(cl.page) { currentCount := getCommentCount(cl.page) logrus.Infof("✓ 检测到 'THE END' 元素,已滑动到底部") - sleepRandom(humanDelayRange.min, humanDelayRange.max) + humanize.Delay(ctx, humanize.BetweenScroll) logrus.Infof("✓ 加载完成: %d 条评论, 尝试次数: %d, 点击: %d, 跳过: %d", currentCount, cl.stats.attempts+1, cl.stats.totalClicked, cl.stats.totalSkipped) return true @@ -221,23 +215,23 @@ 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) +func (cl *commentLoader) clickButtonsWithRetry(ctx context.Context) { + clicked, skipped := clickShowMoreButtonsSmart(ctx, 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) + humanize.Delay(ctx, humanize.Reading) // 重试一轮 - clicked2, skipped2 := clickShowMoreButtonsSmart(cl.page, cl.config.MaxRepliesThreshold) + clicked2, skipped2 := clickShowMoreButtonsSmart(ctx, 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) + humanize.Delay(ctx, humanize.Reading) } } } @@ -275,11 +269,11 @@ func (cl *commentLoader) shouldStopAtTarget(currentCount int) bool { return false } -func (cl *commentLoader) performScroll() { +func (cl *commentLoader) performScroll(ctx context.Context) { currentCount := getCommentCount(cl.page) if currentCount > 0 { scrollToLastComment(cl.page) - sleepRandom(postScrollRange.min, postScrollRange.max) + time.Sleep(400 * time.Millisecond) // 技术 settle:等 scrollIntoView 动画落位 } largeMode := cl.state.stagnantChecks >= largeScrollTrigger @@ -288,7 +282,7 @@ func (cl *commentLoader) performScroll() { pushCount = 3 + rand.Intn(3) } - _, scrollDelta, currentScrollTop := humanScroll(cl.page, cl.config.ScrollSpeed, largeMode, pushCount) + _, scrollDelta, currentScrollTop := humanScroll(ctx, cl.page, cl.config.ScrollSpeed, largeMode, pushCount) if scrollDelta < minScrollDelta || currentScrollTop == cl.state.lastScrollTop { cl.state.stagnantChecks++ @@ -301,10 +295,10 @@ func (cl *commentLoader) performScroll() { } } -func (cl *commentLoader) handleStagnation() { +func (cl *commentLoader) handleStagnation(ctx context.Context) { if cl.state.stagnantChecks >= stagnantLimit { logrus.Infof("停滞过多,尝试大冲刺...") - humanScroll(cl.page, cl.config.ScrollSpeed, true, 10) + humanScroll(ctx, cl.page, cl.config.ScrollSpeed, true, 10) cl.state.stagnantChecks = 0 if checkEndContainer(cl.page) { @@ -314,9 +308,9 @@ func (cl *commentLoader) handleStagnation() { } } -func (cl *commentLoader) performFinalSprint() { +func (cl *commentLoader) performFinalSprint(ctx context.Context) { logrus.Infof("达到最大尝试次数,最后冲刺...") - humanScroll(cl.page, cl.config.ScrollSpeed, true, finalSprintPushCount) + humanScroll(ctx, cl.page, cl.config.ScrollSpeed, true, finalSprintPushCount) currentCount := getCommentCount(cl.page) hasEnd := checkEndContainer(cl.page) @@ -324,31 +318,9 @@ func (cl *commentLoader) performFinalSprint() { 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) { +func clickShowMoreButtonsSmart(ctx context.Context, page *rod.Page, maxRepliesThreshold int) (clicked, skipped int) { elements, err := page.Elements(".show-more") if err != nil { return 0, 0 @@ -377,7 +349,7 @@ func clickShowMoreButtonsSmart(page *rod.Page, maxRepliesThreshold int) (clicked continue } - if clickElementWithHumanBehavior(page, el, text) { + if clickElementWithHumanBehavior(ctx, page, el, text) { clicked++ clickedInRound++ } @@ -411,7 +383,7 @@ func shouldSkipButton(text string, threshold int, regex *regexp.Regexp) bool { return false } -func clickElementWithHumanBehavior(page *rod.Page, el *rod.Element, text string) bool { +func clickElementWithHumanBehavior(ctx context.Context, page *rod.Page, el *rod.Element, text string) bool { var clickSuccess bool // 使用retry-go进行点击操作重试 @@ -424,23 +396,21 @@ func clickElementWithHumanBehavior(page *rod.Page, el *rod.Element, text string) } catch (e) {} }`) - sleepRandom(reactionTimeRange.min, reactionTimeRange.max) + humanize.Delay(ctx, humanize.Reading) - // 鼠标悬停 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) + humanize.Delay(ctx, humanize.BeforeClick) } // 点击 - if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(el); err != nil { return err // 返回错误以触发重试 } - // 模拟人类阅读时间 - sleepRandom(readTimeRange.min, readTimeRange.max) + humanize.Delay(ctx, humanize.Reading) clickSuccess = true return nil }, @@ -466,7 +436,7 @@ func clickElementWithHumanBehavior(page *rod.Page, el *rod.Element, text string) // ========== 滚动相关 ========== -func humanScroll(page *rod.Page, speed string, largeMode bool, pushCount int) (bool, int, int) { +func humanScroll(ctx context.Context, page *rod.Page, speed string, largeMode bool, pushCount int) (bool, int, int) { beforeTop := getScrollTop(page) viewportHeight := page.MustEval(`() => window.innerHeight`).Int() @@ -483,7 +453,7 @@ func humanScroll(page *rod.Page, speed string, largeMode bool, pushCount int) (b scrollDelta := calculateScrollDelta(viewportHeight, baseRatio) page.MustEval(`(delta) => { window.scrollBy(0, delta); }`, scrollDelta) - sleepRandom(scrollWaitRange.min, scrollWaitRange.max) + time.Sleep(150 * time.Millisecond) // 技术 settle:等 scrollBy 后懒加载渲染,再读 scrollTop currentScrollTop = getScrollTop(page) deltaThisTime := currentScrollTop - beforeTop @@ -496,13 +466,13 @@ func humanScroll(page *rod.Page, speed string, largeMode bool, pushCount int) (b beforeTop = currentScrollTop if i < pushCount-1 { - sleepRandom(humanDelayRange.min, humanDelayRange.max) + humanize.Delay(ctx, humanize.BetweenScroll) } } if !scrolled && pushCount > 0 { page.MustEval(`() => window.scrollTo(0, document.body.scrollHeight)`) - sleepRandom(postScrollRange.min, postScrollRange.max) + time.Sleep(400 * time.Millisecond) // 技术 settle:等 scrollTo 落位 currentScrollTop = getScrollTop(page) actualDelta = currentScrollTop - beforeTop + actualDelta scrolled = actualDelta > 5 @@ -542,8 +512,8 @@ func scrollToCommentsArea(page *rod.Page) { if el, err := page.Timeout(2 * time.Second).Element(".comments-container"); err == nil { el.MustScrollIntoView() } - // 等待滚动完成 - time.Sleep(500 * time.Millisecond) + // 等 scrollIntoView 动画落位 + time.Sleep(400 * time.Millisecond) // 触发一次小滚动,激活懒加载机制 smartScroll(page, 100) @@ -754,6 +724,7 @@ func checkEndContainer(page *rod.Page) bool { // ========== 页面检查 ========== func checkPageAccessible(page *rod.Page) error { + // 等错误提示 UI 渲染出来再检查 time.Sleep(500 * time.Millisecond) // 查找错误提示容器 diff --git a/xiaohongshu/feeds.go b/xiaohongshu/feeds.go index 63d9bca..5582ae6 100644 --- a/xiaohongshu/feeds.go +++ b/xiaohongshu/feeds.go @@ -25,22 +25,33 @@ func NewFeedsListAction(page *rod.Page) *FeedsListAction { // GetFeedsList 获取页面的 Feed 列表数据 func (f *FeedsListAction) GetFeedsList(ctx context.Context) ([]Feed, error) { - page := f.page.Context(ctx) + // 重设超时:.Context(ctx) 会替换掉构造函数里 Timeout(60s) 的 deadline + page := f.page.Context(ctx).Timeout(60 * time.Second) - time.Sleep(1 * time.Second) - - result := page.MustEval(`() => { - if (window.__INITIAL_STATE__ && - window.__INITIAL_STATE__.feed && - window.__INITIAL_STATE__.feed.feeds) { - const feeds = window.__INITIAL_STATE__.feed.feeds; - const feedsData = feeds.value !== undefined ? feeds.value : feeds._value; - if (feedsData) { - return JSON.stringify(feedsData); + readFeeds := func() string { + return page.MustEval(`() => { + if (window.__INITIAL_STATE__ && + window.__INITIAL_STATE__.feed && + window.__INITIAL_STATE__.feed.feeds) { + const feeds = window.__INITIAL_STATE__.feed.feeds; + const feedsData = feeds.value !== undefined ? feeds.value : feeds._value; + if (feedsData) { + return JSON.stringify(feedsData); + } } + return ""; + }`).String() + } + + // 轮询等 __INITIAL_STATE__.feed 注水就绪(替代固定 1s,治偶发 ErrNoFeeds) + var result string + deadline := time.Now().Add(8 * time.Second) + for { + if result = readFeeds(); result != "" || time.Now().After(deadline) { + break } - return ""; - }`).String() + time.Sleep(300 * time.Millisecond) + } if result == "" { return nil, errors.ErrNoFeeds diff --git a/xiaohongshu/feeds_test.go b/xiaohongshu/feeds_test.go index 669ee4b..c88d59b 100644 --- a/xiaohongshu/feeds_test.go +++ b/xiaohongshu/feeds_test.go @@ -1,3 +1,7 @@ +//go:build integration + +// 集成测试:起有头浏览器 + 触网 + 需登录态,默认 go test 不编译不运行。 +// 手动跑:go test -tags integration ./xiaohongshu/ -run TestGetFeedsList package xiaohongshu import ( @@ -11,9 +15,6 @@ import ( ) func TestGetFeedsList(t *testing.T) { - - t.Skip("SKIP: 测试发布") - b := browser.NewBrowser(false) defer b.Close() diff --git a/xiaohongshu/like_favorite.go b/xiaohongshu/like_favorite.go index c483b40..7cc6952 100644 --- a/xiaohongshu/like_favorite.go +++ b/xiaohongshu/like_favorite.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" myerrors "github.com/xpzouying/xiaohongshu-mcp/errors" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) // ActionResult 通用动作响应(点赞/收藏等) @@ -50,14 +51,60 @@ func (a *interactAction) preparePage(ctx context.Context, actionType interactAct page.MustNavigate(url) page.MustWaitDOMStable() - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterNavigate) return page } -func (a *interactAction) performClick(page *rod.Page, selector string) { - element := page.MustElement(selector) - element.MustClick() +func (a *interactAction) performClick(page *rod.Page, selector string) error { + element, err := page.Element(selector) + if err != nil { + return fmt.Errorf("未找到交互元素 %s: %w", selector, err) + } + return humanize.Click(element) +} + +// stateOf 从交互状态里取目标字段(点赞取 liked,收藏取 collected)。 +type stateOf func(liked, collected bool) bool + +// toggleInteract 点击交互按钮并轮询校验状态是否变为 want;最多两次点击。 +// 到达即成功;始终未变或无法读状态则返回 error——消除"点了没报错就算成功"的假阳性。 +func (a *interactAction) toggleInteract(page *rod.Page, feedID, selector string, want bool, actionType interactActionType, pick stateOf) error { + for attempt := 1; attempt <= 2; attempt++ { + if err := a.performClick(page, selector); err != nil { + return fmt.Errorf("%s点击失败: %w", actionType, err) + } + + ok, err := a.waitInteractState(page, feedID, want, pick, 4*time.Second) + if err != nil { + return fmt.Errorf("%s后无法确认状态: %w", actionType, err) + } + if ok { + logrus.Infof("feed %s %s成功(第%d次点击)", feedID, actionType, attempt) + return nil + } + logrus.Warnf("feed %s %s第%d次点击后状态未变,重试", feedID, actionType, attempt) + } + return fmt.Errorf("feed %s %s失败:点击后状态始终未变为预期", feedID, actionType) +} + +// waitInteractState 轮询 __INITIAL_STATE__ 的交互状态,直到 pick()==want 或超时。 +// 状态回写快则立即返回、慢则等满 timeout。 +func (a *interactAction) waitInteractState(page *rod.Page, feedID string, want bool, pick stateOf, timeout time.Duration) (bool, error) { + deadline := time.Now().Add(timeout) + for { + liked, collected, err := a.getInteractState(page, feedID) + if err != nil { + return false, err + } + if pick(liked, collected) == want { + return true, nil + } + if time.Now().After(deadline) { + return false, nil + } + time.Sleep(500 * time.Millisecond) + } } // LikeAction 负责处理点赞相关交互 @@ -90,7 +137,8 @@ func (a *LikeAction) perform(ctx context.Context, feedID, xsecToken string, targ liked, _, err := a.getInteractState(page, feedID) if err != nil { logrus.Warnf("failed to read interact state: %v (continue to try clicking)", err) - return a.toggleLike(page, feedID, targetLiked, actionType) + return a.toggleInteract(page, feedID, SelectorLikeButton, targetLiked, actionType, + func(liked, collected bool) bool { return liked }) } if targetLiked && liked { @@ -102,38 +150,8 @@ func (a *LikeAction) perform(ctx context.Context, feedID, xsecToken string, targ return nil } - return a.toggleLike(page, feedID, targetLiked, actionType) -} - -func (a *LikeAction) toggleLike(page *rod.Page, feedID string, targetLiked bool, actionType interactActionType) error { - a.performClick(page, SelectorLikeButton) - time.Sleep(3 * time.Second) - - liked, _, err := a.getInteractState(page, feedID) - if err != nil { - logrus.Warnf("验证%s状态失败: %v", actionType, err) - return nil - } - if liked == targetLiked { - logrus.Infof("feed %s %s成功", feedID, actionType) - return nil - } - - logrus.Warnf("feed %s %s可能未成功,状态未变化,尝试再次点击", feedID, actionType) - a.performClick(page, SelectorLikeButton) - time.Sleep(2 * time.Second) - - liked, _, err = a.getInteractState(page, feedID) - if err != nil { - logrus.Warnf("第二次验证%s状态失败: %v", actionType, err) - return nil - } - if liked == targetLiked { - logrus.Infof("feed %s 第二次点击%s成功", feedID, actionType) - return nil - } - - return nil + return a.toggleInteract(page, feedID, SelectorLikeButton, targetLiked, actionType, + func(liked, collected bool) bool { return liked }) } // FavoriteAction 负责处理收藏相关交互 @@ -166,7 +184,8 @@ func (a *FavoriteAction) perform(ctx context.Context, feedID, xsecToken string, _, collected, err := a.getInteractState(page, feedID) if err != nil { logrus.Warnf("failed to read interact state: %v (continue to try clicking)", err) - return a.toggleFavorite(page, feedID, targetCollected, actionType) + return a.toggleInteract(page, feedID, SelectorCollectButton, targetCollected, actionType, + func(liked, collected bool) bool { return collected }) } if targetCollected && collected { @@ -178,38 +197,8 @@ func (a *FavoriteAction) perform(ctx context.Context, feedID, xsecToken string, return nil } - return a.toggleFavorite(page, feedID, targetCollected, actionType) -} - -func (a *FavoriteAction) toggleFavorite(page *rod.Page, feedID string, targetCollected bool, actionType interactActionType) error { - a.performClick(page, SelectorCollectButton) - time.Sleep(3 * time.Second) - - _, collected, err := a.getInteractState(page, feedID) - if err != nil { - logrus.Warnf("验证%s状态失败: %v", actionType, err) - return nil - } - if collected == targetCollected { - logrus.Infof("feed %s %s成功", feedID, actionType) - return nil - } - - logrus.Warnf("feed %s %s可能未成功,状态未变化,尝试再次点击", feedID, actionType) - a.performClick(page, SelectorCollectButton) - time.Sleep(2 * time.Second) - - _, collected, err = a.getInteractState(page, feedID) - if err != nil { - logrus.Warnf("第二次验证%s状态失败: %v", actionType, err) - return nil - } - if collected == targetCollected { - logrus.Infof("feed %s 第二次点击%s成功", feedID, actionType) - return nil - } - - return nil + return a.toggleInteract(page, feedID, SelectorCollectButton, targetCollected, actionType, + func(liked, collected bool) bool { return collected }) } // getInteractState 从 __INITIAL_STATE__ 读取笔记的点赞/收藏状态 diff --git a/xiaohongshu/login.go b/xiaohongshu/login.go index dd22797..5c7191f 100644 --- a/xiaohongshu/login.go +++ b/xiaohongshu/login.go @@ -17,7 +17,8 @@ func NewLogin(page *rod.Page) *LoginAction { } func (a *LoginAction) CheckLoginStatus(ctx context.Context) (bool, error) { - pp := a.page.Context(ctx) + // 加超时保护:只是查登录态的快速检查,不应无限挂(登录扫码的等待在 Login/WaitForLogin 里) + pp := a.page.Context(ctx).Timeout(30 * time.Second) pp.MustNavigate("https://www.xiaohongshu.com/explore").MustWaitLoad() time.Sleep(1 * time.Second) diff --git a/xiaohongshu/navigate.go b/xiaohongshu/navigate.go index 00791ce..e12b88a 100644 --- a/xiaohongshu/navigate.go +++ b/xiaohongshu/navigate.go @@ -2,6 +2,7 @@ package xiaohongshu import ( "context" + "time" "github.com/go-rod/rod" ) @@ -15,7 +16,7 @@ func NewNavigate(page *rod.Page) *NavigateAction { } func (n *NavigateAction) ToExplorePage(ctx context.Context) error { - page := n.page.Context(ctx) + page := n.page.Context(ctx).Timeout(60 * time.Second) // 加超时保护,避免 MustNavigate/MustWaitStable 无限挂 page.MustNavigate("https://www.xiaohongshu.com/explore"). MustWaitLoad(). @@ -25,7 +26,7 @@ func (n *NavigateAction) ToExplorePage(ctx context.Context) error { } func (n *NavigateAction) ToProfilePage(ctx context.Context) error { - page := n.page.Context(ctx) + page := n.page.Context(ctx).Timeout(60 * time.Second) // 加超时保护,避免 MustNavigate/MustWaitStable 无限挂 // First navigate to explore page if err := n.ToExplorePage(ctx); err != nil { diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index f111d73..5574084 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -14,6 +14,7 @@ import ( "github.com/go-rod/rod/lib/proto" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) // PublishImageContent 发布图文内容 @@ -74,7 +75,8 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent return errors.New("图片不能为空") } - page := p.page.Context(ctx) + // 重设超时:.Context(ctx) 会替换掉 NewPublishImageAction 里 Timeout(300s) 的 deadline + page := p.page.Context(ctx).Timeout(300 * time.Second) if err := uploadImages(page, content.ImagePaths); err != nil { return errors.Wrap(err, "小红书上传图片失败") @@ -88,7 +90,7 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v, original=%v, visibility=%s, products=%v", content.Title, len(content.ImagePaths), tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products) - if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products); err != nil { + if err := submitPublish(ctx, page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products); err != nil { return errors.Wrap(err, "小红书发布失败") } @@ -272,39 +274,39 @@ func waitForUploadComplete(page *rod.Page, expectedCount int) error { return errors.Errorf("第%d张图片上传超时(60s),请检查网络连接和图片大小", expectedCount) } -func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string, products []string) error { +func submitPublish(ctx context.Context, page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string, products []string) error { titleElem, err := page.Element("div.d-input input") if err != nil { return errors.Wrap(err, "查找标题输入框失败") } - if err := titleElem.Input(title); err != nil { + if err := humanize.Type(ctx, titleElem, title); err != nil { return errors.Wrap(err, "输入标题失败") } // 检查标题长度 - time.Sleep(500 * time.Millisecond) + humanize.Delay(ctx, humanize.AfterType) if err := checkTitleMaxLength(page); err != nil { return err } slog.Info("检查标题长度:通过") - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterType) contentElem, ok := getContentElement(page) if !ok { return errors.New("没有找到内容输入框") } - if err := contentElem.Input(content); err != nil { + if err := humanize.Type(ctx, contentElem, content); err != nil { return errors.Wrap(err, "输入正文失败") } if err := waitAndClickTitleInput(titleElem); err != nil { return err } - if err := inputTags(contentElem, tags); err != nil { + if err := inputTags(ctx, contentElem, tags); err != nil { return err } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterType) // 检查正文长度 if err := checkContentMaxLength(page); err != nil { @@ -325,13 +327,12 @@ func submitPublish(page *rod.Page, title, content string, tags []string, schedul return errors.Wrap(err, "设置可见范围失败") } - // 处理原创声明 + // 处理原创声明:显式请求了原创但设置失败 → 报错中止,不静默发成非原创(避免"以为原创其实不是") if isOriginal { if err := setOriginal(page); err != nil { - slog.Warn("设置原创声明失败,继续发布", "error", err) - } else { - slog.Info("已声明原创") + return errors.Wrap(err, "设置原创声明失败(已请求原创,中止发布)") } + slog.Info("已声明原创") } // 绑定商品 @@ -343,8 +344,25 @@ func submitPublish(page *rod.Page, title, content string, tags []string, schedul return err } - time.Sleep(3 * time.Second) - return nil + // 校验发布真的成功:成功后创作平台会跳转离开发布页;未跳转则判定失败, + // 消除"点了发布按钮就算成功"的假阳性。 + return waitPublishSuccess(page, 15*time.Second) +} + +// waitPublishSuccess 轮询等待发布成功的信号:小红书发布成功后会跳转离开发布表单页 +// (URL 不再含 /publish/publish)。超时仍未跳转 → 判定发布失败。 +func waitPublishSuccess(page *rod.Page, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if info, err := page.Info(); err == nil && !strings.Contains(info.URL, "/publish/publish") { + slog.Info("发布成功,已跳转离开发布页", "url", info.URL) + return nil + } + if time.Now().After(deadline) { + return errors.New("发布未确认成功:点击发布后未跳转离开发布页(可能校验未过或被拦截)") + } + time.Sleep(500 * time.Millisecond) + } } type publishButton struct { @@ -596,7 +614,7 @@ func getContentElement(page *rod.Page) (*rod.Element, bool) { return nil, false } -func inputTags(contentElem *rod.Element, tags []string) error { +func inputTags(ctx context.Context, contentElem *rod.Element, tags []string) error { if len(tags) == 0 { return nil } @@ -626,27 +644,25 @@ func inputTags(contentElem *rod.Element, tags []string) error { for _, tag := range tags { tag = strings.TrimLeft(tag, "#") - if err := inputTag(contentElem, tag); err != nil { + if err := inputTag(ctx, contentElem, tag); err != nil { return errors.Wrapf(err, "输入标签[%s]失败", tag) } } return nil } -func inputTag(contentElem *rod.Element, tag string) error { +func inputTag(ctx context.Context, contentElem *rod.Element, tag string) error { + // 输入 # 触发话题联想(控制字符,直接 Input) if err := contentElem.Input("#"); err != nil { return errors.Wrap(err, "输入#失败") } - time.Sleep(200 * time.Millisecond) + time.Sleep(200 * time.Millisecond) // 技术等待:等联想下拉框弹出 - for _, char := range tag { - if err := contentElem.Input(string(char)); err != nil { - return errors.Wrapf(err, "输入字符[%c]失败", char) - } - time.Sleep(50 * time.Millisecond) + if err := humanize.Type(ctx, contentElem, tag); err != nil { + return errors.Wrap(err, "输入标签内容失败") } - time.Sleep(1 * time.Second) + time.Sleep(1 * time.Second) // 技术等待:等联想结果刷新 page := contentElem.Page() topicContainer, err := page.Element("#creator-editor-topic-container") @@ -661,13 +677,11 @@ func inputTag(contentElem *rod.Element, tag string) error { return contentElem.Input(" ") } - if err := firstItem.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := humanize.Click(firstItem); err != nil { return errors.Wrap(err, "点击标签联想选项失败") } slog.Info("成功点击标签联想选项", "tag", tag) - time.Sleep(200 * time.Millisecond) - - time.Sleep(500 * time.Millisecond) // 等待标签处理完成 + time.Sleep(500 * time.Millisecond) // 技术等待:等标签处理完成 return nil } @@ -948,91 +962,97 @@ func setOriginal(page *rod.Page) error { return errors.New("未找到原创声明选项") } -// confirmOriginalDeclaration 处理原创声明确认弹窗 +// confirmOriginalDeclaration 交互(勾选须知、点声明按钮)走 go-rod 点击; +// 仅用只读 Eval 读取 checkbox 勾选态(不产生交互,无法用属性判断的自定义组件才用)。 func confirmOriginalDeclaration(page *rod.Page) error { - // 等待确认弹窗出现 - time.Sleep(800 * time.Millisecond) + time.Sleep(800 * time.Millisecond) // 技术等待:等确认弹窗渲染 - // 使用 JavaScript 直接处理弹窗,更可靠 - result, err := page.Eval(` - () => { - // 查找包含"原创声明须知"的 footer 区域 - const footers = document.querySelectorAll('div.footer'); - for (const footer of footers) { - // 检查是否包含原创声明相关内容 - if (!footer.textContent.includes('原创声明须知')) { - continue; - } + if footer, err := findFooterByText(page, "原创声明须知"); err != nil { + slog.Warn("未找到原创声明确认弹窗的 footer", "error", err) + } else if err := checkFooterCheckbox(footer); err != nil { + slog.Warn("勾选原创声明须知失败", "error", err) + } - // 找到 checkbox 并勾选 - const checkbox = footer.querySelector('div.d-checkbox input[type="checkbox"]'); - if (checkbox && !checkbox.checked) { - checkbox.click(); - console.log('已勾选原创声明须知 checkbox'); - } + time.Sleep(500 * time.Millisecond) // 技术等待:勾选后等"声明原创"按钮变可用 - // 等待一下让按钮变为可用 - return 'found_footer'; - } - return 'footer_not_found'; - } - `) + footer, err := findFooterByText(page, "声明原创") if err != nil { - slog.Warn("执行查找弹窗脚本失败", "error", err) - } else if result.Value.String() == "footer_not_found" { - slog.Warn("未找到原创声明确认弹窗的 footer") + return errors.Wrap(err, "未找到声明原创弹窗") } - time.Sleep(500 * time.Millisecond) - - // 再次使用 JavaScript 点击声明原创按钮 - result2, err := page.Eval(` - () => { - const footers = document.querySelectorAll('div.footer'); - for (const footer of footers) { - if (!footer.textContent.includes('声明原创')) { - continue; - } - - // 找到声明原创按钮 - const btn = footer.querySelector('button.custom-button'); - if (btn) { - // 检查是否禁用 - if (btn.classList.contains('disabled') || btn.disabled) { - // 尝试再次勾选 checkbox - const checkbox = footer.querySelector('div.d-checkbox input[type="checkbox"]'); - if (checkbox && !checkbox.checked) { - checkbox.click(); - } - return 'button_disabled'; - } - btn.click(); - return 'clicked'; - } - } - return 'button_not_found'; - } - `) + btn, err := footer.Element("button.custom-button") if err != nil { - return errors.Wrap(err, "执行点击按钮脚本失败") + return errors.Wrap(err, "未找到声明原创按钮") } - status := result2.Value.String() - slog.Info("原创声明确认结果", "status", status) - - if status == "button_not_found" { - return errors.New("未找到声明原创按钮") - } - if status == "button_disabled" { - return errors.New("声明原创按钮仍处于禁用状态") + if isButtonDisabled(btn) { + // 兜底:按钮仍禁用,可能须知未勾上,再勾一次 + if err := checkFooterCheckbox(footer); err != nil { + slog.Warn("二次勾选须知失败", "error", err) + } + time.Sleep(300 * time.Millisecond) + if isButtonDisabled(btn) { + return errors.New("声明原创按钮仍处于禁用状态") + } } + if err := humanize.Click(btn); err != nil { + return errors.Wrap(err, "点击声明原创按钮失败") + } slog.Info("已成功点击声明原创按钮") time.Sleep(300 * time.Millisecond) - return nil } +func findFooterByText(page *rod.Page, keyword string) (*rod.Element, error) { + footers, err := page.Elements("div.footer") + if err != nil { + return nil, errors.Wrap(err, "查找弹窗 footer 失败") + } + for _, footer := range footers { + text, err := footer.Text() + if err != nil { + continue + } + if strings.Contains(text, keyword) { + return footer, nil + } + } + return nil, errors.Errorf("未找到包含%q的弹窗 footer", keyword) +} + +// checkFooterCheckbox 勾选 footer 内的自定义 checkbox(未勾选时才点)。 +func checkFooterCheckbox(footer *rod.Element) error { + cb, err := footer.Element("div.d-checkbox") + if err != nil { + return errors.Wrap(err, "未找到须知 checkbox") + } + + // 只读判断当前是否已勾选(隐藏 input.checked 或 simulator 上的 checked 态) + checked, err := cb.Eval(`() => { + const input = this.querySelector('input[type="checkbox"]'); + return (input && input.checked) || this.querySelector('.checked') !== null; + }`) + if err != nil { + return errors.Wrap(err, "读取 checkbox 状态失败") + } + if checked.Value.Bool() { + return nil + } + + return humanize.Click(cb) +} + +func isButtonDisabled(btn *rod.Element) bool { + if disabled, _ := btn.Attribute("disabled"); disabled != nil { + return true + } + if cls, _ := btn.Attribute("class"); cls != nil && hasExactClass(*cls, "disabled") { + return true + } + return false +} + // bindProducts 绑定商品到发布内容 func bindProducts(page *rod.Page, products []string) error { if len(products) == 0 { @@ -1066,7 +1086,7 @@ func bindProducts(page *rod.Page, products []string) error { // 点击保存按钮 slog.Info("准备点击保存按钮") - if err := clickModalSaveButton(page, modal); err != nil { + if err := clickModalSaveButton(modal); err != nil { return errors.Wrap(err, "点击保存按钮失败") } slog.Info("保存按钮点击完成,开始等待弹窗关闭") @@ -1238,7 +1258,6 @@ func searchAndSelectProduct(page *rod.Page, modal *rod.Element, keyword string) return errors.Wrap(err, "点击商品选择框失败") } - // 6. 随机延迟模拟人为操作(800-1500ms) randomDelay := 800 + rand.Intn(700) time.Sleep(time.Duration(randomDelay) * time.Millisecond) @@ -1247,7 +1266,7 @@ func searchAndSelectProduct(page *rod.Page, modal *rod.Element, keyword string) } // clickModalSaveButton 点击保存按钮 -func clickModalSaveButton(page *rod.Page, modal *rod.Element) error { +func clickModalSaveButton(modal *rod.Element) error { // 查找保存按钮(参考工作代码:直接查找并点击,不强制要求找到) btn, err := modal.Element(".goods-selected-footer button") if err == nil && btn != nil { diff --git a/xiaohongshu/publish_test.go b/xiaohongshu/publish_test.go index e5248fc..720aa09 100644 --- a/xiaohongshu/publish_test.go +++ b/xiaohongshu/publish_test.go @@ -1,3 +1,7 @@ +//go:build integration + +// 集成测试:起有头浏览器 + 需登录态,默认 go test 不编译不运行。 +// 发布是写操作,额外保留 t.Skip 双保险,避免 -tags integration 时误发帖。 package xiaohongshu import ( @@ -12,7 +16,7 @@ import ( func TestPublish(t *testing.T) { - t.Skip("SKIP: 测试发布") + t.Skip("SKIP: 写操作(发帖),手动去掉此行才真正执行") b := browser.NewBrowser(false) defer b.Close() diff --git a/xiaohongshu/publish_video.go b/xiaohongshu/publish_video.go index de987db..f64a72c 100644 --- a/xiaohongshu/publish_video.go +++ b/xiaohongshu/publish_video.go @@ -9,6 +9,7 @@ import ( "github.com/go-rod/rod" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) // PublishVideoContent 发布视频内容 @@ -56,13 +57,14 @@ func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoCo return errors.New("视频不能为空") } - page := p.page.Context(ctx) + // 重设超时:.Context(ctx) 会替换掉 NewPublishVideoAction 里 Timeout(300s) 的 deadline + page := p.page.Context(ctx).Timeout(300 * time.Second) if err := uploadVideo(page, content.VideoPath); err != nil { return errors.Wrap(err, "小红书上传视频失败") } - if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility, content.Products); err != nil { + if err := submitPublishVideo(ctx, page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility, content.Products); err != nil { return errors.Wrap(err, "小红书发布失败") } return nil @@ -99,33 +101,33 @@ func uploadVideo(page *rod.Page, videoPath string) error { } // submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交) -func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string, products []string) error { +func submitPublishVideo(ctx context.Context, page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string, products []string) error { // 标题 titleElem, err := page.Element("div.d-input input") if err != nil { return errors.Wrap(err, "查找标题输入框失败") } - if err := titleElem.Input(title); err != nil { + if err := humanize.Type(ctx, titleElem, title); err != nil { return errors.Wrap(err, "输入标题失败") } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterType) // 正文 + 标签 contentElem, ok := getContentElement(page) if !ok { return errors.New("没有找到内容输入框") } - if err := contentElem.Input(content); err != nil { + if err := humanize.Type(ctx, contentElem, content); err != nil { return errors.Wrap(err, "输入正文失败") } if err := waitAndClickTitleInput(titleElem); err != nil { return err } - if err := inputTags(contentElem, tags); err != nil { + if err := inputTags(ctx, contentElem, tags); err != nil { return err } - time.Sleep(1 * time.Second) + humanize.Delay(ctx, humanize.AfterType) // 处理定时发布 if scheduleTime != nil { @@ -149,6 +151,6 @@ func submitPublishVideo(page *rod.Page, title, content string, tags []string, sc return err } - time.Sleep(3 * time.Second) - return nil + // 校验发布真的成功(成功跳转离开发布页),未跳转判失败——消除假成功 + return waitPublishSuccess(page, 15*time.Second) } diff --git a/xiaohongshu/search.go b/xiaohongshu/search.go index 8fc8762..f798355 100644 --- a/xiaohongshu/search.go +++ b/xiaohongshu/search.go @@ -9,6 +9,7 @@ import ( "github.com/go-rod/rod" "github.com/xpzouying/xiaohongshu-mcp/errors" + "github.com/xpzouying/xiaohongshu-mcp/humanize" ) type SearchResult struct { @@ -166,7 +167,9 @@ func NewSearchAction(page *rod.Page) *SearchAction { } func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...FilterOption) ([]Feed, error) { - page := s.page.Context(ctx) + // 注意 .Context(ctx) 会替换掉 NewSearchAction 里设的 60s deadline,必须在其后重新 Timeout, + // 否则搜索页不 stable 时 MustWaitStable/MustWait 会永久挂起(无 deadline 可依赖)。 + page := s.page.Context(ctx).Timeout(60 * time.Second) searchURL := makeSearchURL(keyword) page.MustNavigate(searchURL) @@ -196,16 +199,21 @@ func (s *SearchAction) Search(ctx context.Context, keyword string, filters ...Fi // 悬停在筛选按钮上 filterButton := page.MustElement(`div.filter`) filterButton.MustHover() + humanize.Delay(ctx, humanize.BeforeClick) // 等待筛选面板出现 page.MustWait(`() => document.querySelector('div.filter-panel') !== null`) - // 应用所有筛选条件 + // 用 ClickNoWait:筛选面板是 hover 浮层,rod 的 WaitInteractable 会误判被遮挡而死等; + // ClickNoWait 移进面板内选项(维持 hover、面板不关)再点。 for _, filter := range allInternalFilters { selector := fmt.Sprintf(`div.filter-panel div.filters:nth-child(%d) div.tags:nth-child(%d)`, filter.FiltersIndex, filter.TagsIndex) option := page.MustElement(selector) - option.MustClick() + humanize.Delay(ctx, humanize.BeforeClick) + if err := humanize.ClickNoWait(option); err != nil { + return nil, fmt.Errorf("点击筛选选项失败: %w", err) + } } // 等待页面更新 diff --git a/xiaohongshu/search_integration_test.go b/xiaohongshu/search_integration_test.go new file mode 100644 index 0000000..c43e943 --- /dev/null +++ b/xiaohongshu/search_integration_test.go @@ -0,0 +1,65 @@ +//go:build integration + +// 集成测试:起有头浏览器 + 触网 + 需登录态,默认 go test 不编译不运行。 +// 手动跑:go test -tags integration ./xiaohongshu/ -run TestSearch +package xiaohongshu + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xpzouying/xiaohongshu-mcp/browser" +) + +func TestSearch(t *testing.T) { + b := browser.NewBrowser(false) + defer b.Close() + + page := b.NewPage() + defer func() { + _ = page.Close() + }() + + action := NewSearchAction(page) + + feeds, err := action.Search(context.Background(), "Kimi") + require.NoError(t, err) + require.NotEmpty(t, feeds, "feeds should not be empty") + + fmt.Printf("成功获取到 %d 个 Feed\n", len(feeds)) + + for _, feed := range feeds { + fmt.Printf("Feed ID: %s\n", feed.ID) + fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) + } +} + +func TestSearchWithFilters(t *testing.T) { + b := browser.NewBrowser(false) + defer b.Close() + + page := b.NewPage() + defer func() { + _ = page.Close() + }() + + action := NewSearchAction(page) + + filter := FilterOption{ + NoteType: "图文", + PublishTime: "一天内", + } + + feeds, err := action.Search(context.Background(), "dn432", filter) + require.NoError(t, err) + require.NotEmpty(t, feeds, "feeds should not be empty") + + fmt.Printf("成功获取到 %d 个筛选后的 Feed\n", len(feeds)) + + for _, feed := range feeds { + fmt.Printf("Feed ID: %s\n", feed.ID) + fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) + } +} diff --git a/xiaohongshu/search_test.go b/xiaohongshu/search_test.go index 5049498..dfa6337 100644 --- a/xiaohongshu/search_test.go +++ b/xiaohongshu/search_test.go @@ -1,72 +1,11 @@ package xiaohongshu import ( - "context" - "fmt" "testing" "github.com/stretchr/testify/require" - "github.com/xpzouying/xiaohongshu-mcp/browser" ) -func TestSearch(t *testing.T) { - - t.Skip("SKIP: 测试发布") - - b := browser.NewBrowser(false) - defer b.Close() - - page := b.NewPage() - defer func() { - _ = page.Close() - }() - - action := NewSearchAction(page) - - feeds, err := action.Search(context.Background(), "Kimi") - require.NoError(t, err) - require.NotEmpty(t, feeds, "feeds should not be empty") - - fmt.Printf("成功获取到 %d 个 Feed\n", len(feeds)) - - for _, feed := range feeds { - fmt.Printf("Feed ID: %s\n", feed.ID) - fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) - } -} - -func TestSearchWithFilters(t *testing.T) { - - //t.Skip("SKIP: 测试筛选功能") - - b := browser.NewBrowser(false) - defer b.Close() - - page := b.NewPage() - defer func() { - _ = page.Close() - }() - - action := NewSearchAction(page) - - // 使用新的 FilterOption 结构 - filter := FilterOption{ - NoteType: "图文", - PublishTime: "一天内", - } - - feeds, err := action.Search(context.Background(), "dn432", filter) - require.NoError(t, err) - require.NotEmpty(t, feeds, "feeds should not be empty") - - fmt.Printf("成功获取到 %d 个筛选后的 Feed\n", len(feeds)) - - for _, feed := range feeds { - fmt.Printf("Feed ID: %s\n", feed.ID) - fmt.Printf("Feed Title: %s\n", feed.NoteCard.DisplayTitle) - } -} - func TestFilterValidation(t *testing.T) { // 测试有效的筛选选项转换 validFilter := FilterOption{ diff --git a/xiaohongshu/user_profile.go b/xiaohongshu/user_profile.go index 856220f..3f56031 100644 --- a/xiaohongshu/user_profile.go +++ b/xiaohongshu/user_profile.go @@ -20,7 +20,7 @@ func NewUserProfileAction(page *rod.Page) *UserProfileAction { // UserProfile 获取用户基本信息及帖子 func (u *UserProfileAction) UserProfile(ctx context.Context, userID, xsecToken string) (*UserProfileResponse, error) { - page := u.page.Context(ctx) + page := u.page.Context(ctx).Timeout(60 * time.Second) // 重设被 .Context 清掉的 deadline searchURL := makeUserProfileURL(userID, xsecToken) page.MustNavigate(searchURL) @@ -105,7 +105,7 @@ func makeUserProfileURL(userID, xsecToken string) string { } func (u *UserProfileAction) GetMyProfileViaSidebar(ctx context.Context) (*UserProfileResponse, error) { - page := u.page.Context(ctx) + page := u.page.Context(ctx).Timeout(60 * time.Second) // 重设被 .Context 清掉的 deadline // 创建导航动作 navigate := NewNavigate(page)