mirror of
https://github.com/xpzouying/xiaohongshu-mcp.git
synced 2026-08-28 17:45:51 +08:00
feat: 内置浏览器自动下载 + 交互拟人化 + 写操作诚实校验 (#737)
内置浏览器:首次运行自动从自建 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 '^$' ./...
|
||||
@@ -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}/ 的文件不全,自动升级已中止。请检查镜像同步是否完成。"
|
||||
@@ -44,3 +44,6 @@ test_*.sh
|
||||
cookies.json
|
||||
docker/data/
|
||||
docker/images/
|
||||
|
||||
# 本地分析/调试产物,不进仓库
|
||||
debug/
|
||||
|
||||
+18
-23
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -427,7 +427,7 @@ docker build -t xpzouying/xiaohongshu-mcp .
|
||||
|
||||
Docker 版本会自动:
|
||||
|
||||
- 配置 CloakBrowser Chromium 和中文字体
|
||||
- 配置内置浏览器和中文字体
|
||||
- 挂载 `./data` 用于存储 cookies 和运行数据目录
|
||||
- 挂载 `./images` 用于存储发布的图片
|
||||
- 暴露 18060 端口供 MCP 连接
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+41
-9
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
// 格式:<hash>␠␠<filename>
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
148.0.7778.215
|
||||
@@ -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()
|
||||
+21
-2
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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():
|
||||
}
|
||||
}
|
||||
@@ -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<Max、median 落在区间)。
|
||||
func TestDefaultProvider_Timing(t *testing.T) {
|
||||
tp := DefaultProvider{}.Timing()
|
||||
|
||||
for _, action := range []Action{AfterClick, AfterType, AfterNavigate, BetweenScroll, BeforeSubmit, BeforeClick, Reading} {
|
||||
dist, ok := tp[action]
|
||||
assert.True(t, ok, "缺少动作 %s 的时延分布", action)
|
||||
assert.Greater(t, dist.Max, dist.Min, "%s: Max 应大于 Min", action)
|
||||
|
||||
// median = sample(0),应落在 [Min, Max] 内(分布参数合理性)
|
||||
median := dist.sample(0)
|
||||
assert.GreaterOrEqual(t, median, dist.Min, "%s: median 不应小于 Min", action)
|
||||
assert.LessOrEqual(t, median, dist.Max, "%s: median 不应大于 Max", action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelay_RespectsContextCancel 校验 Delay 能被 ctx 取消,不傻等满时长。
|
||||
func TestDelay_RespectsContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
start := time.Now()
|
||||
Delay(ctx, AfterNavigate) // AfterNavigate 最短也有 600ms,取消后应几乎立即返回
|
||||
assert.Less(t, time.Since(start), 100*time.Millisecond, "已取消的 ctx 应让 Delay 立即返回")
|
||||
}
|
||||
|
||||
// TestCubicBezier_Endpoints 贝塞尔曲线两端点应精确落在 p0/p3。
|
||||
func TestCubicBezier_Endpoints(t *testing.T) {
|
||||
p0 := proto.Point{X: 0, Y: 0}
|
||||
p1 := proto.Point{X: 10, Y: 50}
|
||||
p2 := proto.Point{X: 90, Y: 50}
|
||||
p3 := proto.Point{X: 100, Y: 0}
|
||||
|
||||
assert.Equal(t, p0, cubicBezier(p0, p1, p2, p3, 0)) // t=0 → 起点
|
||||
assert.Equal(t, p3, cubicBezier(p0, p1, p2, p3, 1)) // t=1 → 终点
|
||||
|
||||
// 中段应落在包围盒内(不跑飞)
|
||||
mid := cubicBezier(p0, p1, p2, p3, 0.5)
|
||||
assert.Greater(t, mid.X, 0.0)
|
||||
assert.Less(t, mid.X, 100.0)
|
||||
}
|
||||
|
||||
// TestEaseInOut 缓动函数:端点固定、中点对称、单调不减。
|
||||
func TestEaseInOut(t *testing.T) {
|
||||
assert.Equal(t, 0.0, easeInOut(0))
|
||||
assert.Equal(t, 1.0, easeInOut(1))
|
||||
assert.InDelta(t, 0.5, easeInOut(0.5), 1e-9) // 中点对称
|
||||
assert.Less(t, easeInOut(0.25), easeInOut(0.75))
|
||||
}
|
||||
|
||||
// TestDelay_UnknownActionFallback 未知动作应回退而非 panic 或零等待。
|
||||
func TestDelay_UnknownActionFallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
assert.NotPanics(t, func() {
|
||||
Delay(ctx, Action("nonexistent"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
func Click(elem *rod.Element) error {
|
||||
pt, err := elem.WaitInteractable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mouse := elem.Page().Mouse
|
||||
if err := moveMouseCurved(mouse, *pt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := elem.WaitEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mouse.Click(proto.InputMouseButtonLeft, 1)
|
||||
}
|
||||
|
||||
// ClickNoWait 移到元素中心再点击,跳过 rod 的 WaitInteractable 遮挡重试。
|
||||
//
|
||||
// 专用于 hover 浮层里的选项:浮层的悬停特性会让 WaitInteractable 误判"被遮挡"而死等
|
||||
// (见搜索筛选面板);而"从触发元素移进浮层内选项"这段移动本身恰好维持 :hover、
|
||||
// 让浮层保持打开。因此这里直接取元素中心、移动过去、点击,不做遮挡检查。
|
||||
// 前提:调用前浮层已打开(如已 Hover 触发元素)。
|
||||
func ClickNoWait(elem *rod.Element) error {
|
||||
shape, err := elem.Shape()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(shape.Quads) == 0 {
|
||||
return errors.New("元素无可点击区域")
|
||||
}
|
||||
|
||||
q := shape.Quads[0] // 8 个值 = 4 个角点 (x,y);对角线中点即中心
|
||||
center := proto.Point{X: (q[0] + q[4]) / 2, Y: (q[1] + q[5]) / 2}
|
||||
|
||||
mouse := elem.Page().Mouse
|
||||
if err := moveMouseCurved(mouse, center); err != nil {
|
||||
return err
|
||||
}
|
||||
return mouse.Click(proto.InputMouseButtonLeft, 1)
|
||||
}
|
||||
|
||||
// Type 逐字符输入文本,字间带间隔。
|
||||
func Type(ctx context.Context, elem *rod.Element, text string) error {
|
||||
dist := defaultProvider.Timing()[Keystroke]
|
||||
|
||||
for _, r := range text {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := elem.Input(string(r)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := time.NewTimer(dist.Sample())
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package humanize
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
func moveMouseCurved(mouse *rod.Mouse, target proto.Point) error {
|
||||
start := mouse.Position()
|
||||
dx, dy := target.X-start.X, target.Y-start.Y
|
||||
dist := math.Hypot(dx, dy)
|
||||
|
||||
// 距离极近:直接到位
|
||||
if dist < 6 {
|
||||
return mouse.MoveTo(target)
|
||||
}
|
||||
|
||||
steps := int(math.Round(dist / 10))
|
||||
if steps < 10 {
|
||||
steps = 10
|
||||
}
|
||||
if steps > 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+66
-31
@@ -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)
|
||||
|
||||
+59
-88
@@ -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)
|
||||
|
||||
// 查找错误提示容器
|
||||
|
||||
+24
-13
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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__ 读取笔记的点赞/收藏状态
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+119
-100
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+11
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待页面更新
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user