Commit 64170d5f authored by 郭阔's avatar 郭阔

Import xiaohongshu-mcp v2.7.3 source

Upstream: xpzouying/xiaohongshu-mcp@3b352aa075d63cd5ebae546c9fe7d7d9b81fa7c9. Documentation demo media and examples are omitted from this source import.
parents
This diff is collapsed.
{
"mcpServers": {
"xiaohongshu-mcp": {
"url": "http://localhost:18060/mcp",
"description": "小红书内容发布服务 - MCP Streamable HTTP"
}
}
}
.git
.idea
.vscode
.claude
.cursor
.github
**/*.log
bin
dist
vendor
Dockerfile
docker-compose.yml
docker
.DS_Store
cookies.json
custom:
- "https://github.com/xpzouying/xiaohongshu-mcp#赞赏支持"
name: Docker Release
on:
push:
tags:
# 只匹配语义化版本 tag(如 v1.2.8、v1.3.0-beta.1),
# 不匹配历史遗留的日期 tag(如 v2026.07.23.0205-85aed8b)
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.2.8)'
required: true
permissions:
contents: read
jobs:
docker:
if: github.repository == 'xpzouying/xiaohongshu-mcp'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# 手动发布也必须构建对应版本的代码。
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }}
persist-credentials: false
# tag 触发时版本号取自 tag 本身;手动触发时取输入参数
- name: Resolve version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
TAG_VERSION: ${{ github.ref_name }}
run: |
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
version="$TAG_VERSION"
else
version="$REQUESTED_VERSION"
fi
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9][a-zA-Z0-9.-]*)?$ ]] || (( ${#version} > 121 )); then
echo "无效的版本号" >&2
exit 1
fi
if [[ ! -f docker/novnc/Dockerfile ]]; then
echo "此版本不包含可选 GUI 镜像,停止发布" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: xpzouying
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v4
with:
registry: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
# 一次构建,同时推送 Docker Hub 和阿里云
- name: Build and push Docker image (AMD64)
id: headless
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
build-args: |
VERSION=${{ steps.version.outputs.version }}
tags: |
xpzouying/xiaohongshu-mcp:${{ steps.version.outputs.version }}
xpzouying/xiaohongshu-mcp:latest
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ steps.version.outputs.version }}
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest
cache-from: type=gha,scope=headless
cache-to: type=gha,scope=headless,mode=max
# BuildKit v0.32 起默认附加 provenance / sbom 证明,其空配置描述符用
# application/vnd.oci.empty.v1+json,阿里云个人版 ACR 不认这个媒体类型,
# 会在推送时 denied。项目没用到这些证明,直接关掉。
provenance: false
sbom: false
# GUI 使用本次构建的无界面镜像 digest,避免版本或标签漂移。
- name: Build and push optional noVNC image (AMD64)
uses: docker/build-push-action@v7
with:
context: ./docker/novnc
file: ./docker/novnc/Dockerfile
push: true
platforms: linux/amd64
build-args: |
MCP_IMAGE=xpzouying/xiaohongshu-mcp@${{ steps.headless.outputs.digest }}
tags: |
xpzouying/xiaohongshu-mcp:${{ steps.version.outputs.version }}-novnc
xpzouying/xiaohongshu-mcp:novnc
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ steps.version.outputs.version }}-novnc
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:novnc
cache-from: type=gha,scope=novnc
cache-to: type=gha,scope=novnc,mode=max
provenance: false
sbom: false
name: Docker Images
on:
pull_request:
paths:
- Dockerfile
- .dockerignore
- docker/**
- .github/workflows/docker-*.yml
push:
branches: [main]
paths:
- Dockerfile
- .dockerignore
- docker/**
- .github/workflows/docker-*.yml
workflow_dispatch:
permissions:
contents: read
jobs:
docker-images:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v4
id: buildx
with:
use: false
# 使用同一份 PR 代码构建两种镜像,测试过程中不登录或推送仓库。
- name: Build default headless image
uses: docker/build-push-action@v7
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
load: true
tags: xhs-mcp:ci
build-args: VERSION=ci
# PR 缓存与正式发布缓存隔离,避免混用不受信任的构建结果。
cache-from: type=gha,scope=ci-headless
cache-to: type=gha,scope=ci-headless,mode=max
provenance: false
sbom: false
- name: Check headless image remains the default
run: |
test "$(docker image inspect xhs-mcp:ci --format '{{json .Config.Cmd}}')" = '["./app"]'
docker run --rm --entrypoint /bin/sh xhs-mcp:ci -c '! command -v Xvfb && ! command -v x11vnc && ! command -v fcitx5'
- name: Build optional noVNC image
# 默认 docker builder 可以读取上一步加载到本机的基础镜像。
run: docker build --builder default --build-arg MCP_IMAGE=xhs-mcp:ci -t xhs-mcp:ci-novnc docker/novnc
- name: Check noVNC startup, access boundaries and recovery
run: python3 docker/novnc/smoke_test.py xhs-mcp:ci-novnc
# 扫描工具只运行在 CI,不进入任何成品镜像;下载文件按固定 SHA256 校验。
- name: Scan images and reject new GUI high or critical vulnerabilities
env:
GRYPE_CHECK_FOR_APP_UPDATE: "false"
GRYPE_DB_CACHE_DIR: ${{ runner.temp }}/grype-db
run: |
mkdir -p "$RUNNER_TEMP/image-audit"
archive="$RUNNER_TEMP/grype.tar.gz"
curl -fsSL --retry 3 https://github.com/anchore/grype/releases/download/v0.121.0/grype_0.121.0_linux_amd64.tar.gz -o "$archive"
printf '%s %s\n' '64e07133d3486115de647a66581461dccf5c732748a0a3119077395565f15519' "$archive" | sha256sum --check --status
tar -xzf "$archive" -C "$RUNNER_TEMP" grype
"$RUNNER_TEMP/grype" db update
export GRYPE_DB_AUTO_UPDATE=false
"$RUNNER_TEMP/grype" docker:xhs-mcp:ci --quiet -o json > "$RUNNER_TEMP/image-audit/headless.json"
"$RUNNER_TEMP/grype" docker:xhs-mcp:ci-novnc --quiet -o json > "$RUNNER_TEMP/image-audit/novnc.json"
python3 docker/novnc/check_vulnerabilities.py "$RUNNER_TEMP/image-audit/headless.json" "$RUNNER_TEMP/image-audit/novnc.json"
- name: Save image vulnerability reports
if: always()
uses: actions/upload-artifact@v7
with:
name: image-security-reports
path: ${{ runner.temp }}/image-audit/*.json
if-no-files-found: ignore
name: Update star history chart
# 每周重新生成 README 的 Star History 图表。
# 背景:GitHub 2026-06-30 起限制 stargazers API,star-history.com 托管图表失效;
# 改用自研 star-history Action 在 CI 内用内置 GITHUB_TOKEN 生成(REST 403 自动回退 GraphQL)。
# main 有 require-PR 保护,图表发布到独立数据分支 star-history,README 引用其 raw URL。
on:
schedule:
- cron: '30 4 * * 1' # 每周一 04:30 UTC
workflow_dispatch: {}
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
if: github.repository == 'xpzouying/xiaohongshu-mcp'
steps:
- uses: actions/checkout@v7
- uses: xpzouying/star-history@v1
with:
branch: star-history
commit-message: 'chore: 更新 Star History 图表'
name: Tag and Release
on:
push:
tags:
# 只匹配语义化版本 tag,不匹配历史遗留的日期 tag
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
type: string
release_notes:
description: 'Release notes (optional)'
required: false
type: string
default: ''
permissions:
contents: write
jobs:
tag-and-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# tag 触发时版本号取自 tag 本身;手动触发时取输入参数
- name: Resolve version
id: version
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
# 校验只在手动触发时需要:tag 触发时格式已由 tag 过滤保证,且 tag 必然已存在
- name: Validate version format
if: github.event_name == 'workflow_dispatch'
run: |
VERSION="${{ steps.version.outputs.version }}"
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?$ ]]; then
echo "Error: Version must follow semantic versioning (e.g., v1.0.0, v1.0.0-beta.1)"
exit 1
fi
# Check if tag already exists
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Error: Tag $VERSION already exists"
exit 1
fi
echo "Version $VERSION is valid"
- name: Set up Go
id: setup_go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Build for multiple platforms
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# 主程序构建
# 禁用 CGO 生成静态二进制;通过 ldflags 注入版本号
# 只构建有内置浏览器的平台,其余平台即使编译出来也无法启动
LDFLAGS="-s -w -X main.version=${VERSION}"
# macOS ARM64 (Apple Silicon)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o xiaohongshu-mcp-darwin-arm64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o xiaohongshu-login-darwin-arm64 ./cmd/login
# Windows x64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o xiaohongshu-mcp-windows-amd64.exe .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o xiaohongshu-login-windows-amd64.exe ./cmd/login
# Linux x64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o xiaohongshu-mcp-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o xiaohongshu-login-linux-amd64 ./cmd/login
- name: Generate changelog
id: changelog
run: |
# 取上一个语义化版本 tag:排除当前版本和自动生成的日期 tag
VERSION="${{ steps.version.outputs.version }}"
PREV_TAG=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' | grep -vx "$VERSION" | sort -V | tail -1 || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found, including all commits"
COMMITS=$(git log --oneline --format="- %s (%h)" | head -20)
else
echo "Generating changelog from $PREV_TAG to HEAD"
COMMITS=$(git log "${PREV_TAG}..HEAD" --oneline --format="- %s (%h)")
fi
# Save to output
{
echo "commits<<EOF"
echo "$COMMITS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create tag and release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
body: |
## 🚀 新版本发布: ${{ steps.version.outputs.version }}
${{ github.event.inputs.release_notes }}
### 📋 更新内容
${{ steps.changelog.outputs.commits }}
---
### 📦 下载说明
**主程序(MCP 服务):**
- **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64`
- **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe`
- **Linux x64**: `xiaohongshu-mcp-linux-amd64`
**登录工具:**
- **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64`
- **Windows x64**: `xiaohongshu-login-windows-amd64.exe`
- **Linux x64**: `xiaohongshu-login-linux-amd64`
### 🔧 使用方法
```bash
# 1. 首先运行登录工具获取 cookie
./xiaohongshu-login-darwin-arm64
# 2. 然后启动 MCP 服务
./xiaohongshu-mcp-darwin-arm64
# 或指定参数
./xiaohongshu-mcp-darwin-arm64 -headless=false
```
### ⚠️ 注意事项
- 首次运行时会自动下载无头浏览器(约 150MB),请确保网络连接正常
- 后续运行无需重复下载浏览器
- 登录工具生成的 cookie 保存在 `~/.xiaohongshu/cookies.json`
### 📊 构建信息
- **Commit**: ${{ github.sha }}
- **Go Version**: ${{ steps.setup_go.outputs.go-version }}
- **Build Time**: ${{ github.event.repository.updated_at }}
files: |
xiaohongshu-mcp-darwin-arm64
xiaohongshu-mcp-windows-amd64.exe
xiaohongshu-mcp-linux-amd64
xiaohongshu-login-darwin-arm64
xiaohongshu-login-windows-amd64.exe
xiaohongshu-login-linux-amd64
name: Test
# 只跑 hermetic 单元测试(无浏览器/无网络)。
# 有头浏览器集成测试用 //go:build integration 隔离:默认不运行,仅编译校验。
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check Go vulnerabilities
run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
- name: Unit tests
run: go test ./...
# 交叉编译检查:发布产物覆盖五个平台,但 CI 默认只在 linux/amd64 上构建,
# 平台相关的编译错误会拖到发版时才暴露。这里只编译不留产物,几秒钟。
- name: Cross-compile check
run: |
for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do
echo "==> $target"
GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build ./...
done
# 集成测试只编译不运行:保证它们始终跟当前 API 一致、不烂掉,
# 但绝不在 CI 启动有头浏览器(-run '^$' 匹配空,一个用例都不跑)。
- name: Compile integration tests (no run)
run: go test -tags integration -run '^$' ./...
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@v7
- 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}/ 的文件不全,自动升级已中止。请检查镜像同步是否完成。"
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
.idea
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
.claude/
# macOS
.DS_Store
# Build artifacts
xiaohongshu-mcp
bin/
# Test scripts
test_*.sh
# Cookies files (contain sensitive login information)
cookies.json
docker/data/
docker/images/
docker/docker-compose.override.yml
# 本地分析/调试产物,不进仓库
debug/
.worktrees/
# .kimi-agent.yml
features:
pr_auto_review: false # PR 打开时自动 Code Review
release_changelog: false # Release 发布时自动更新 CHANGELOG
{
"servers": {
"xiaohongshu-mcp": {
"url": "http://localhost:18060/mcp",
"type": "http"
}
},
"inputs": []
}
# Project Guidelines
## 本地开发规范
- 要求每次修改完后,需要帮我格式化 Go 源码文件.
- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除.
- 所有的feature变更,都需要使用分支进行开发.
- 在我未同意之前, 你不能推送到远程.
- 我需要: 1.本地 review; 2.远程 PR review.
- 不要过度设计, 保持代码的简洁和易读.
- 使用中文注释,一定要简洁明了.专业名词可以用英文.
## 发版规范
- 发版=打语义化 tag `vX.Y.Z` 推上去(触发 Release 与 Docker 镜像),破坏性变更进 major;main 每次推送自动生成的日期 tag `vYYYY.MM.DD.HHMM-sha` 不是发版,别跟它混。
## PR Review 重点
- 重点:PR 代码中如果出现大量的 JS 注入的行为,要检查一下是否是必须的,如果可以用 Go 的 go-rod 替代的话,则直接评论需要用 go-rod 行为替代。
# 贡献指南 | Contributing Guide
感谢你对本项目的关注!为了保证代码质量和 Review 效率,请在提交 PR 前仔细阅读以下规范。
Thank you for your interest! Please read this guide carefully before submitting a PR.
---
## 基本流程 | Basic Workflow
1. Fork 本仓库并创建功能分支
2. 在本地完成开发和测试
3. 提交 PR 并填写清晰的描述
---
## PR 提交规范 | PR Requirements
### 1. 一个 PR 只做一件事 | One PR, One Feature
每个 PR 只包含 **一个功能或一个修复**。多个功能请拆分为多个 PR。
Each PR should contain **only one feature or one fix**. Split multiple features into separate PRs.
### 2. 必须经过验证 | Must Be Verified
**即使代码是 AI 生成的,也必须在本地运行并验证功能正确。** 未经验证的 PR 将直接关闭。
**Even if the code is AI-generated, you must run and verify it locally.** Unverified PRs will be closed.
### 3. 提供演示截图/视频 | Provide Demo
PR 中请附上功能演示的 **截图或录屏**,让 Reviewer 快速理解改动效果。
Please attach **screenshots or screen recordings** to demonstrate the feature.
> **隐私提醒:演示中务必对自己的账号信息进行打码处理!**
>
> **Privacy: Always blur/mask your account info in demos!**
### 4. 禁止大量 JS 注入 | No Excessive JS Injection
本项目使用 [go-rod](https://go-rod.github.io/) 进行浏览器自动化。**严禁通过大量注入 JavaScript 的方式操作页面元素**,应使用 go-rod 提供的 API 操作元素。
违反此规则的 PR **一律不予合并**。
This project uses [go-rod](https://go-rod.github.io/) for browser automation. **Do NOT manipulate page elements by injecting large amounts of JavaScript.** Use go-rod's API instead.
PRs violating this rule **will NOT be merged**.
### 5. 代码规范 | Code Style
- Go 代码需要格式化(`gofmt`)
- 注释使用中文,专业术语可用英文
- 不要过度设计,保持简洁
---
## 提交 Checklist | PR Checklist
提交前请确认:
- [ ] 代码已在本地运行并验证通过
- [ ] 一个 PR 仅包含一个功能/修复
- [ ] 附上演示截图或录屏(账号信息已打码)
- [ ] 没有大量 JS 注入,使用 go-rod API 操作元素
- [ ] 代码已格式化,注释清晰
---
感谢你的贡献!🎉 | Thanks for contributing!
# 赞赏与公益捐赠公开账本
本项目的所有赞赏,将全部用于公益捐赠。
> 本页按月公开记录:收到的赞赏(默认匿名或使用对方指定昵称)、对应捐出、以及捐赠凭证截图(已脱敏)。
> 如需更正/撤回署名,请开 Issue 或通过邮箱联系。
## 摘要
- 累计收到赞赏:¥ 1629.21
- 累计捐赠:¥ 2010
- 最近更新时间:2026-11-05
---
## 维护说明
- **隐私**:默认匿名展示;仅在赞助者明确授权时展示昵称。请在截图中打码/涂抹交易号、手机号、邮箱、二维码关键元素等敏感信息。
- **更正机制**:如有遗漏或需要修改,请开 Issue;所有更动保留在 Git 历史中。
---
## 月度明细
### 2026-11
**本月小结**
- 本月收到赞赏合计:¥ 5.00
- 前期收到赞赏合计:¥ 188.37(4 月、5 月、7 月汇总)。
- 捐出合计:¥ 200.00,2026-11-01 捐赠给中国社会福利基金会「驰援那曲地震」。
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-11-04 | 匿名大侠 | 5.00 | 微信赞赏码 |
<table>
<tr>
<td><img height="400" alt="donation-2026-11-01" src="https://github.com/user-attachments/assets/dff9ad7b-f486-4517-8d61-69e0cfd195ca" /></td>
</tr>
</table>
### 2026-07
**本月小结**
- 收到赞赏合计:¥ 66.60
- 捐出合计:已于 [2026-11-01](#2026-11) 汇总捐赠。
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-07-06 | 匹诺曹 | 66.60 | 赞赏码 |
### 2026-05
**本月小结**
- 收到赞赏合计:¥ 32.97
- 捐出合计:已于 [2026-11-01](#2026-11) 汇总捐赠。
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-05-05 | 计划的一部分 | 9.99 | 赞赏码 |
| 2026-05-08 | 沈迪河 | 3.00 | 赞赏码 |
| 2026-05-12 | 邓昱旻 | 9.99 | 赞赏码 |
| 2026-05-26 | Zora. 卓然 \| 。 | 9.99 | 赞赏码 |
### 2026-04
**本月小结**
- 收到赞赏合计:¥ 88.80
- 捐出合计:已于 [2026-11-01](#2026-11) 汇总捐赠。
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-04-09 | 1900 | 88.80 | 微信红包 |
### 2026-03
**本月小结**
- 收到赞赏合计:¥ 159.91
- 捐出合计:¥ 200.00
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-03-01 | 黄蕾 SQUASH | 9.99 | 赞赏码 |
| 2026-03-04 | 之乎者也 | 9.99 | 赞赏码 |
| 2026-03-05 | 质数的孤独 | 29.99 | 赞赏码 |
| 2026-03-06 | 无名大侠 | 19.99 | 赞赏码 |
| 2026-03-08 | 勇敢的心 | 19.99 | 赞赏码 |
| 2026-03-10 | angiie | 9.99 | 赞赏码 |
| 2026-03-19 | 志颖兄 | 19.99 | 赞赏码 |
| 2026-03-20 | ZzT | 29.99 | 赞赏码 |
| 2026-03-26 | 苏大侠 | 9.99 | 赞赏码 |
<table>
<tr>
<td><img height="400" alt="donation-2026-03-intro" src="https://github.com/user-attachments/assets/25bad146-060a-46f1-a600-e5843c02b233" /></td>
<td><img height="400" alt="donation-2026-03" src="https://github.com/user-attachments/assets/9bc6bbf7-d393-4f81-b39a-f2cf48753b5f" /></td>
</tr>
</table>
### 2026-02
**本月小结**
- 收到赞赏合计:¥ 305.98
- 捐出合计:¥ 310.00,捐赠给腾讯慈善「重疾儿童协助」
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-02-07 | 来自于微信群的小爷 | 29.99 | 赞赏码 |
| 2026-02-09 | Arthur.Morgan | 9.99 | 赞赏码 |
| 2026-02-17 | Jackie | 50.00 | 微信红包 |
| 2026-02-17 | 无名大侠 | 0.01 | 赞赏码 |
| 2026-02-17 | akia | 9.99 | 赞赏码 |
| 2026-02-19 | @_@ | 5.00 | 赞赏码 |
| 2026-02-22 | 小小酷 | 1.00 | 赞赏码 |
| 2026-02-27 | 陈志 | 200.00 | 赞赏码 |
<table>
<tr>
<td><img height="400" alt="donation-2026-02-intro" src="https://github.com/user-attachments/assets/00d75786-d6b3-41b5-9ced-d2fec8b6514e" /></td>
<td><img height="400" alt="donation-2026-02" src="https://github.com/user-attachments/assets/5c1d1cae-3a04-4730-8022-4608f0c6f3e7" /></td>
</tr>
</table>
### 2026-01
**本月小结**
- 收到赞赏合计:¥ 99.98
- 捐出合计:¥ 200.00
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-01-02 | K91431 | 49.99 | 赞赏码 |
| 2026-01-05 | Yancy | 49.99 | 赞赏码 |
<table>
<tr>
<td><img height="400" alt="donation-2026-01" src="https://github.com/user-attachments/assets/ab6eae5e-9e8a-496d-94aa-d3f0c8a1cfc1" /></td>
</tr>
</table>
---
## 历史年度记录
- [2025 年度捐赠记录](./donate/DONATIONS2025.md)
---
## 变更记录
- 2026-11-05:补录 11 月 4 日收到的微信赞赏 ¥ 5.00。
- 2026-11-01:更新 4 月、5 月、7 月赞赏的汇总捐赠记录及凭证。
- 2026-11-01:补录 2026 年 5 月、7 月的 5 笔微信赞赏,合计 ¥ 99.57。
- 2026-03-02:按年度拆分捐赠记录,2025 年数据归档至 `donate/DONATIONS2025.md`。
- 2025-10-26:初始化赞赏记录。汇总 2025 年 9 月、10 月份的赞赏,捐赠给「春蕾计划她们想上学」。
# syntax=docker/dockerfile:1.6
# ---- build stage ----
FROM golang:1.27.3 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 . .
# VERSION 由 CI 通过 --build-arg 传入,本地构建默认 dev
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o /out/app .
# ---- run stage ----
FROM ubuntu:22.04
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 1. 先安装必要工具,然后配置阿里云镜像源
RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \
sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \
sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list
# 2. 安装内置浏览器运行依赖(Chromium 库)和中文字体
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 \
tini \
wget \
xdg-utils \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# 3. 创建目录并设置权限。
RUN mkdir -p /app/data/home /app/data/config /app/images && \
chmod -R 777 /app/data /app/images
# 4. 下载并解压内置浏览器。构建阶段预置,运行时零下载。
# 版本号唯一来源:browser/browser_version.txt(Go 也读它,避免两处漂移)。
# 从自建 CDN 下载中性文件名,并校验 SHA256。
#
# 解压位置必须与 Go 端 EnsureBrowser 找的缓存路径一致:
# $XDG_CACHE_HOME/xiaohongshu-mcp/browser/<版本>/,这样运行时零下载、也不需要任何参数。
# 注意 XDG_CACHE_HOME 指向 /app/cache 而非挂载卷内,否则预置的浏览器会被挂载盖掉。
ENV XDG_CACHE_HOME=/app/cache
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}" && \
BROWSER_DIR="${XDG_CACHE_HOME}/xiaohongshu-mcp/browser/${VER}" && \
mkdir -p "${BROWSER_DIR}" && \
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 "${BROWSER_DIR}" --strip-components=1 && \
rm /tmp/browser.tar.xz /tmp/browser_version.txt && \
test -x "${BROWSER_DIR}/chrome" && \
chmod -R 755 /app/cache
COPY --from=builder /out/app .
ENV HOME=/app/data/home
ENV XDG_CONFIG_HOME=/app/data/config
EXPOSE 18060
# 用 tini 回收浏览器退出后被过继过来的子进程,避免堆积僵尸进程。
# -s 注册为 child subreaper,容器另外带了 init 进程(如 compose 的 init: true)时同样生效
ENTRYPOINT ["/usr/bin/tini", "-s", "--"]
CMD ["./app"]
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sirupsen/logrus"
)
// AppServer 应用服务器结构体,封装所有服务和处理器
type AppServer struct {
xiaohongshuService *XiaohongshuService
mcpServer *mcp.Server
router *gin.Engine
httpServer *http.Server
authToken string
}
// NewAppServer 创建新的应用服务器实例
func NewAppServer(xiaohongshuService *XiaohongshuService, authToken string) *AppServer {
appServer := &AppServer{
xiaohongshuService: xiaohongshuService,
authToken: authToken,
}
// 初始化 MCP Server(需要在创建 appServer 之后,因为工具注册需要访问 appServer)
appServer.mcpServer = InitMCPServer(appServer)
return appServer
}
// Start 启动服务器
func (s *AppServer) Start(port string) error {
s.router = setupRoutes(s)
s.httpServer = &http.Server{
Addr: port,
Handler: s.router,
}
// 启动服务器的 goroutine
go func() {
logrus.Infof("启动 HTTP 服务器: %s", port)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logrus.Errorf("服务器启动失败: %v", err)
os.Exit(1)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logrus.Infof("正在关闭服务器...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
logrus.Warnf("等待连接关闭超时,强制退出: %v", err)
} else {
logrus.Infof("服务器已优雅关闭")
}
return nil
}
package browser
import (
"fmt"
"net/url"
"strings"
"github.com/sirupsen/logrus"
"github.com/xpzouying/headless_browser"
"github.com/xpzouying/xiaohongshu-mcp/cookies"
)
type browserConfig struct {
// fingerprintSeed 固定指纹 seed;>0 时钉死,同账号每次同一套指纹。0 = 每次随机。
fingerprintSeed int
// proxy 代理地址;非空时启用。
proxy string
}
type Option func(*browserConfig)
// 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 {
cred = "***:***"
}
// 直接在原串替换 userinfo,避免 url.String() 把 * 编码成 %2A(日志变乱码)。
return strings.Replace(proxyURL, u.User.String()+"@", cred+"@", 1)
}
func NewBrowser(headless bool, options ...Option) *headless_browser.Browser {
cfg := &browserConfig{}
for _, opt := range options {
opt(cfg)
}
// 只用内置浏览器,没有别的来源。二进制必须显式传给 go-rod,
// 否则 rod 会自行下载一个默认 Chromium:它不是内置浏览器,也不认识下面
// 这些 flag(未知 flag 被静默忽略,日志照样打印 "fingerprint enabled"),
// 属于无声降级。宁可不启动,也不启动一个不对的浏览器。
binPath, err := EnsureBrowser()
if err != nil {
panic(fmt.Sprintf("内置浏览器不可用,拒绝启动: %v", err))
}
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"}),
}
opts = append(opts, headless_browser.WithChromeBinPath(binPath))
// 代理(由调用方经 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
cookiePath := cookies.GetCookiesFilePath()
cookieLoader := cookies.NewLoadCookie(cookiePath)
if data, err := cookieLoader.LoadCookies(); err == nil {
opts = append(opts, headless_browser.WithCookies(string(data)))
logrus.Debugf("loaded cookies from filesuccessfully")
} else {
logrus.Warnf("failed to load cookies: %v", err)
}
return headless_browser.New(opts...)
}
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 无预编译浏览器,暂不支持", 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"+
" 离线环境可手动下载 %s,解压到 %s 后重启。", dlErr, browserURL(asset), cacheDir)
}
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
}
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{}
WithFingerprintSeed(98759)(cfg)
WithProxy("http://127.0.0.1:8080")(cfg)
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, 0, cfg.fingerprintSeed)
assert.Equal(t, "", cfg.proxy)
}
package main
import (
"context"
"encoding/json"
"flag"
"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"
)
func main() {
flag.Parse()
// 登录的时候,需要界面,所以不能无头模式。
// 登录与后续运行共用同一个 seed:首次登录生成并写入会话文件,之后一直复用。
store := cookies.NewLoadCookie(cookies.GetCookiesFilePath())
b := browser.NewBrowser(false,
browser.WithFingerprintSeed(configs.ResolveFingerprintSeed(store)),
browser.WithProxy(configs.ProxyFromEnv()),
)
defer b.Close()
page := b.NewPage()
defer page.Close()
action := xiaohongshu.NewLogin(page)
status, err := action.CheckLoginStatus(context.Background())
if err != nil {
logrus.Fatalf("failed to check login status: %v", err)
}
logrus.Infof("当前登录状态: %v", status)
if status {
return
}
// 开始登录流程
logrus.Info("开始登录流程...")
if err = action.Login(context.Background()); err != nil {
logrus.Fatalf("登录失败: %v", err)
} else {
if err := saveCookies(page); err != nil {
logrus.Fatalf("failed to save cookies: %v", err)
}
}
// 再次检查登录状态确认成功
status, err = action.CheckLoginStatus(context.Background())
if err != nil {
logrus.Fatalf("failed to check login status after login: %v", err)
}
if status {
logrus.Info("登录成功!")
} else {
logrus.Error("登录流程完成但仍未登录")
}
}
func saveCookies(page *rod.Page) error {
cks, err := page.Browser().GetCookies()
if err != nil {
return err
}
data, err := json.Marshal(cks)
if err != nil {
return err
}
cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath())
return cookieLoader.SaveCookies(data)
}
package configs
import (
"os"
"strconv"
"github.com/sirupsen/logrus"
)
var (
useHeadless = true
fingerprintSeed = 0
proxy = ""
)
func InitHeadless(h bool) {
useHeadless = h
}
// IsHeadless 是否无头模式。
func IsHeadless() bool {
return useHeadless
}
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")
}
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())
})
}
package configs
import (
"os"
"path/filepath"
)
const (
ImagesDir = "xiaohongshu_images"
)
func GetImagesPath() string {
return filepath.Join(os.TempDir(), ImagesDir)
}
package configs
import (
"crypto/rand"
"math/big"
"github.com/sirupsen/logrus"
"github.com/xpzouying/xiaohongshu-mcp/cookies"
)
// maxSeed seed 的取值上限,够大以避免碰撞,又不至于溢出。
const maxSeed = 1 << 31
// ResolveFingerprintSeed 决定本次使用的 seed,优先级:
// 环境变量 XHS_FP_SEED > 会话文件里已存的 > 新生成一个并写回。
//
// 环境变量必须排第一:已经用它钉死画像的部署,不能被文件里的值顶掉。
// 生成后一定要写回,否则每次启动都是新的一套,等于没固定。
func ResolveFingerprintSeed(store cookies.Cookier) int {
if seed := FingerprintSeedFromEnv(); seed > 0 {
return seed
}
if seed := store.LoadSeed(); seed > 0 {
return seed
}
seed := newSeed()
if err := store.SaveSeed(seed); err != nil {
// 存不下也照常启动:退化成本次随机,和改动前的行为一致
logrus.Warnf("保存会话 seed 失败,本次使用临时值: %v", err)
}
return seed
}
// newSeed 生成一个新的 seed。用 crypto/rand 而非 math/rand,
// 避免进程启动时机相近的多个实例撞上同一个值。
func newSeed() int {
n, err := rand.Int(rand.Reader, big.NewInt(maxSeed))
if err != nil {
return 1 // 极罕见:熵源不可用时给个确定值,好过 panic
}
return int(n.Int64()) + 1 // +1 保证为正,0 表示"未设置"
}
package configs
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/xpzouying/xiaohongshu-mcp/cookies"
)
// TestResolveFingerprintSeed 校验 seed 取值优先级:环境变量 > 会话文件 > 新生成并写回。
func TestResolveFingerprintSeed(t *testing.T) {
newStore := func(t *testing.T) cookies.Cookier {
t.Helper()
return cookies.NewLoadCookie(filepath.Join(t.TempDir(), "cookies.json"))
}
t.Run("环境变量优先于文件", func(t *testing.T) {
t.Setenv("XHS_FP_SEED", "11111")
store := newStore(t)
assert.NoError(t, store.SaveSeed(22222))
assert.Equal(t, 11111, ResolveFingerprintSeed(store))
})
t.Run("没有环境变量时用文件里的", func(t *testing.T) {
t.Setenv("XHS_FP_SEED", "")
store := newStore(t)
assert.NoError(t, store.SaveSeed(22222))
assert.Equal(t, 22222, ResolveFingerprintSeed(store))
})
t.Run("都没有时生成一个并写回,下次读到同一个", func(t *testing.T) {
t.Setenv("XHS_FP_SEED", "")
store := newStore(t)
got := ResolveFingerprintSeed(store)
assert.Positive(t, got)
// 关键:必须落盘,否则下次启动又换一套
assert.Equal(t, got, store.LoadSeed())
assert.Equal(t, got, ResolveFingerprintSeed(store))
})
}
package cookies
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
)
// sessionFile 是 v2 的文件结构。v1 是一个裸 cookie 数组,没有外层对象。
// cookies 用 RawMessage 原样透传,不解析不重组,避免往返时字段走样。
type sessionFile struct {
Version int `json:"version"`
Seed int `json:"seed,omitempty"`
SavedAt string `json:"saved_at,omitempty"`
Cookies json.RawMessage `json:"cookies"`
}
// localCookiesPath 当前目录下的默认文件名。
const localCookiesPath = "cookies.json"
type Cookier interface {
LoadCookies() ([]byte, error)
SaveCookies(data []byte) error
DeleteCookies() error
// LoadSeed 读取会话绑定的 seed;老格式、文件损坏或未设时返回 0。
LoadSeed() int
// SaveSeed 写入 seed,保留文件中已有的 cookies。
SaveSeed(seed int) error
}
type localCookie struct {
path string
}
func NewLoadCookie(path string) Cookier {
if path == "" {
panic("path is required")
}
return &localCookie{
path: path,
}
}
// LoadCookies 从文件中加载 cookies 数组的原始字节。
// v2 从外层对象里取出 cookies 字段;v1 文件本身就是数组,原样返回。
func (c *localCookie) LoadCookies() ([]byte, error) {
data, err := os.ReadFile(c.path)
if err != nil {
return nil, errors.Wrap(err, "failed to read cookies from tmp file")
}
var f sessionFile
if err := json.Unmarshal(data, &f); err == nil && len(f.Cookies) > 0 {
return f.Cookies, nil
}
return data, nil
}
// LoadSeed 读取会话绑定的 seed。老格式(裸数组)没有这个值,返回 0。
func (c *localCookie) LoadSeed() int {
data, err := os.ReadFile(c.path)
if err != nil {
return 0
}
var f sessionFile
if err := json.Unmarshal(data, &f); err != nil {
return 0
}
return f.Seed
}
// SaveCookies 保存 cookies 到文件中,保留文件里已有的 seed。
func (c *localCookie) SaveCookies(data []byte) error {
return c.write(data, c.LoadSeed())
}
// SaveSeed 写入 seed,保留文件里已有的 cookies。
func (c *localCookie) SaveSeed(seed int) error {
cks, err := c.LoadCookies()
if err != nil {
cks = nil // 文件还不存在:先把 seed 落下来,cookies 之后再补
}
return c.write(cks, seed)
}
// write 以 v2 格式落盘。cookies 用 RawMessage 原样嵌入,不经过结构体往返。
func (c *localCookie) write(cks []byte, seed int) error {
if len(cks) == 0 {
cks = []byte("[]")
}
data, err := json.MarshalIndent(sessionFile{
Version: 2,
Seed: seed,
SavedAt: time.Now().Format(time.RFC3339),
Cookies: json.RawMessage(cks),
}, "", " ")
if err != nil {
return errors.Wrap(err, "marshal session file failed")
}
if dir := filepath.Dir(c.path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return errors.Wrap(err, "create cookies dir failed")
}
}
return os.WriteFile(c.path, data, 0644)
}
// DeleteCookies 删除 cookies 文件。
func (c *localCookie) DeleteCookies() error {
if _, err := os.Stat(c.path); os.IsNotExist(err) {
// 文件不存在,返回 nil(认为已经删除)
return nil
}
return os.Remove(c.path)
}
// GetCookiesFilePath 获取 cookies 文件路径。
// 为了向后兼容,如果旧路径 /tmp/cookies.json 存在,则继续使用;
// 否则使用当前目录下的 cookies.json
func GetCookiesFilePath() string {
// 显式指定优先,无条件——环境里的残留文件不能盖掉用户明说的配置
if path := os.Getenv("COOKIES_PATH"); path != "" {
return path
}
// 本地目录
if _, err := os.Stat(localCookiesPath); err == nil {
return localCookiesPath
}
// 旧路径 /tmp/cookies.json,仅为老用户兜底
oldPath := filepath.Join(os.TempDir(), "cookies.json")
if _, err := os.Stat(oldPath); err == nil {
return oldPath
}
return localCookiesPath
}
package cookies
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
// TestGetCookiesFilePath 校验路径优先级:COOKIES_PATH > 当前目录 > /tmp(旧路径兜底)。
// 用 TMPDIR 重定向 os.TempDir()、t.Chdir 重定向当前目录,做到 hermetic、不碰真实 /tmp。
func TestGetCookiesFilePath(t *testing.T) {
t.Run("显式指定的COOKIES_PATH永远最优先", func(t *testing.T) {
dir := t.TempDir()
t.Setenv("TMPDIR", dir)
t.Setenv("COOKIES_PATH", "/custom/cookies.json")
// 即使 /tmp 下躺着旧文件,也不能盖掉显式配置
assert.NoError(t, os.WriteFile(filepath.Join(dir, "cookies.json"), []byte("[]"), 0644))
assert.Equal(t, "/custom/cookies.json", GetCookiesFilePath())
})
t.Run("未设COOKIES_PATH时本地目录优先于tmp", func(t *testing.T) {
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
t.Setenv("COOKIES_PATH", "")
assert.NoError(t, os.WriteFile(filepath.Join(tmp, "cookies.json"), []byte("[]"), 0644))
cwd := t.TempDir()
t.Chdir(cwd)
assert.NoError(t, os.WriteFile(filepath.Join(cwd, "cookies.json"), []byte("[]"), 0644))
assert.Equal(t, "cookies.json", GetCookiesFilePath())
})
t.Run("本地没有时兜底到tmp旧路径", func(t *testing.T) {
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
t.Setenv("COOKIES_PATH", "")
t.Chdir(t.TempDir()) // 本地目录是空的
oldPath := filepath.Join(tmp, "cookies.json")
assert.NoError(t, os.WriteFile(oldPath, []byte("[]"), 0644))
assert.Equal(t, oldPath, GetCookiesFilePath())
})
t.Run("都不存在时回退当前目录", func(t *testing.T) {
dir := t.TempDir()
t.Setenv("TMPDIR", dir)
t.Setenv("COOKIES_PATH", "")
t.Chdir(t.TempDir())
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)
// 写入后能读回同样的内容(落盘是 v2,排版会变,内容不变)
want := []byte(`[{"name":"web_session","value":"x"}]`)
assert.NoError(t, c.SaveCookies(want))
got, err := c.LoadCookies()
assert.NoError(t, err)
assert.Equal(t, decodeJSON(t, want), decodeJSON(t, got))
// 落盘是 v2 外层对象,不再是裸数组。只看结构,不看排版
onDisk, err := os.ReadFile(path)
assert.NoError(t, err)
var f map[string]any
assert.NoError(t, json.Unmarshal(onDisk, &f))
assert.Equal(t, float64(2), f["version"])
// 删除后文件消失,且再次删除幂等(不报错)
assert.NoError(t, c.DeleteCookies())
assert.NoFileExists(t, path)
assert.NoError(t, c.DeleteCookies())
}
// TestSeed 校验 seed 的持久化与 v1/v2 两种格式的读取。
func TestSeed(t *testing.T) {
t.Run("v1裸数组读不到seed且cookies原样", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "cookies.json")
raw := []byte(`[{"name":"web_session","value":"x"}]`)
assert.NoError(t, os.WriteFile(path, raw, 0644))
c := NewLoadCookie(path)
got, err := c.LoadCookies()
assert.NoError(t, err)
assert.Equal(t, decodeJSON(t, raw), decodeJSON(t, got))
assert.Equal(t, 0, c.LoadSeed())
})
t.Run("存seed后读得回来且cookies内容不走样", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "cookies.json")
c := NewLoadCookie(path)
// 字段顺序刻意打乱、数值用科学计数法,用来暴露"反序列化再序列化"造成的走样
raw := []byte(`[{"value":"x","name":"web_session","expires":1.75e9}]`)
assert.NoError(t, c.SaveCookies(raw))
assert.NoError(t, c.SaveSeed(23088))
assert.Equal(t, 23088, c.LoadSeed())
got, err := c.LoadCookies()
assert.NoError(t, err)
// 比语义不比字节:落盘会重新缩进(只动无意义空白),字段和取值一个都不能变
assert.Equal(t, decodeJSON(t, raw), decodeJSON(t, got))
})
}
// TestSeedRobustness 校验 seed 在异常与并发写入下的表现。
func TestSeedRobustness(t *testing.T) {
t.Run("存cookies不冲掉已有的seed", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "cookies.json")
c := NewLoadCookie(path)
assert.NoError(t, c.SaveSeed(23088))
// 模拟重新登录:cookies 换了一批,seed 必须留着
assert.NoError(t, c.SaveCookies([]byte(`[{"name":"web_session","value":"new"}]`)))
assert.Equal(t, 23088, c.LoadSeed())
})
t.Run("文件损坏时降级为没有seed且不panic", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "cookies.json")
assert.NoError(t, os.WriteFile(path, []byte(`{"version":2,"seed":`), 0644))
assert.Equal(t, 0, NewLoadCookie(path).LoadSeed())
})
t.Run("文件不存在时读seed返回0", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "nope.json")
assert.Equal(t, 0, NewLoadCookie(path).LoadSeed())
})
}
// decodeJSON 把 JSON 解成通用结构,用于只比内容、不比排版。
func decodeJSON(t *testing.T, data []byte) any {
t.Helper()
var v any
assert.NoError(t, json.Unmarshal(data, &v))
return v
}
// TestSaveCookies_CreatesParentDir 保存时父目录不存在也应能落盘。
//
// COOKIES_PATH 指向一个还没创建的目录时,原先会直接写失败。
func TestSaveCookies_CreatesParentDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "deeper", "cookies.json")
c := NewLoadCookie(path)
assert.NoError(t, c.SaveCookies([]byte(`[{"name":"a","value":"b"}]`)))
got, err := c.LoadCookies()
assert.NoError(t, err)
var cks []map[string]string
assert.NoError(t, json.Unmarshal(got, &cks))
assert.Equal(t, "a", cks[0]["name"])
}
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>xhsmcp</string>
<key>ProgramArguments</key>
<array>
<string>{二进制路径}</string>
</array>
<key>WorkingDirectory</key>
<string>{工作路径}</string>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/xhsmcp.log</string>
<key>StandardErrorPath</key>
<string>/tmp/xhsmcp.err</string>
</dict>
</plist>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
*
!Dockerfile
!start.sh
!fcitx5-profile
!rime-default.custom.yaml
start.sh text eol=lf
fcitx5-profile text eol=lf
rime-default.custom.yaml text eol=lf
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment