重构为Monorepo:拆分xhs/xhh应用与core包并完成双服务部署改造

This commit is contained in:
2026-03-03 16:06:16 +08:00
parent ed7fbdd5c2
commit 2cbd6b28b2
84 changed files with 6332 additions and 7678 deletions
+18 -8
View File
@@ -1,19 +1,29 @@
# Server
PORT=3000
# ============================================================================
# Common runtime settings (both apps)
# ============================================================================
HOST=127.0.0.1
# Browser
HEADLESS=true
# BROWSER_BIN=/path/to/chromium # Optional: custom Chromium binary path
# BROWSER_BIN=/path/to/chromium
# Allow remote access (DANGEROUS - only set if you understand the risk)
# Required only when exposing HOST=0.0.0.0
# ALLOW_REMOTE=yes-i-understand-the-risk
# Logging
# Optional logging
# NODE_ENV=production
# LOG_LEVEL=info
# Notification automation (Xiaohongshu)
# ============================================================================
# mcp-xhs (apps/xhs-mcp)
# ============================================================================
# PORT=9527
# COOKIE_DIR=~/.social-mcp-xhs
# XHS_NOTIFICATION_POLL_ENABLED=true
# XHS_NOTIFICATION_POLL_INTERVAL_SEC=60
# XHS_NOTIFICATION_POLL_MAX_COUNT=20
# ============================================================================
# mcp-xhh (apps/xhh-mcp)
# ============================================================================
# PORT=9528
# COOKIE_DIR=~/.social-mcp-xhh
+2
View File
@@ -1,8 +1,10 @@
node_modules/
dist/
**/dist/
web/node_modules/
web/dist/
*.tsbuildinfo
.pnpm-store/
# Environment
.env
+34 -41
View File
@@ -4,57 +4,54 @@
FROM node:22-slim AS builder
# Proxy for downloading dependencies (passed via --build-arg)
ARG APP_NAME=xhs-mcp
ARG HTTP_PROXY
ARG HTTPS_PROXY
ENV HTTP_PROXY=${HTTP_PROXY} \
HTTPS_PROXY=${HTTPS_PROXY}
# China npm mirror
RUN npm config set registry https://registry.npmmirror.com
RUN corepack enable
RUN pnpm config set registry https://registry.npmmirror.com
WORKDIR /app
# Copy package manifests first (layer caching for dependency install)
COPY package.json package-lock.json ./
# Copy manifests first for better caching
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.json tsconfig.base.json ./
COPY packages/core/package.json packages/core/tsconfig.json packages/core/tsup.config.ts ./packages/core/
COPY apps/xhs-mcp/package.json apps/xhs-mcp/tsconfig.json apps/xhs-mcp/tsup.config.ts ./apps/xhs-mcp/
COPY apps/xhh-mcp/package.json apps/xhh-mcp/tsconfig.json apps/xhh-mcp/tsup.config.ts ./apps/xhh-mcp/
# Install all dependencies (including devDependencies for building)
RUN npm ci
RUN pnpm install --frozen-lockfile
# Install Chromium matching rebrowser-playwright version (NOT playwright)
RUN npx rebrowser-playwright install chromium
# Copy source code
COPY tsconfig.json tsup.config.ts ./
COPY src/ src/
COPY packages ./packages
COPY apps ./apps
# Build the backend
RUN npm run build
# Build the web dashboard
COPY web/ web/
RUN cd web && npm ci && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/
# Remove devDependencies to slim down node_modules for production
RUN npm prune --omit=dev
# Build shared core first, then selected app
RUN pnpm --filter @social/core build
RUN pnpm --filter @social/${APP_NAME} build
# =============================================================================
# Stage 2: Production
# Stage 2: Runtime
# =============================================================================
FROM node:22-slim
# Use China apt mirror
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# Proxy for apt-get (passed via --build-arg)
ARG APP_NAME=xhs-mcp
ARG HTTP_PROXY
ARG HTTPS_PROXY
ENV HTTP_PROXY=${HTTP_PROXY} \
HTTPS_PROXY=${HTTPS_PROXY}
# Install Chromium dependencies required by Playwright/rebrowser-playwright
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# Chromium runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 \
libnspr4 \
@@ -77,41 +74,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1001 appuser \
&& useradd --uid 1001 --gid appuser --shell /bin/sh --create-home appuser
WORKDIR /app
# Copy built artifacts and production dependencies from builder
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appuser /app/package.json ./package.json
COPY --from=builder --chown=appuser:appuser /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appuser /app/packages ./packages
COPY --from=builder --chown=appuser:appuser /app/apps ./apps
# Copy Playwright browsers from builder
COPY --from=builder --chown=appuser:appuser /root/.cache/ms-playwright /home/appuser/.cache/ms-playwright
# Create data directory for cookies and API token
RUN mkdir -p /home/appuser/.social-mcp \
&& chown -R appuser:appuser /home/appuser/.social-mcp
RUN mkdir -p /home/appuser/.social-mcp-xhs /home/appuser/.social-mcp-xhh \
&& chown -R appuser:appuser /home/appuser/.social-mcp-xhs /home/appuser/.social-mcp-xhh
# Switch to non-root user
USER appuser
# Environment defaults
# Clear proxy env from build stage (must not leak into runtime)
ENV HTTP_PROXY= \
HTTPS_PROXY= \
NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000 \
PORT=9527 \
HEADLESS=true \
COOKIE_DIR=/home/appuser/.social-mcp \
COOKIE_DIR=/home/appuser/.social-mcp-xhs \
APP_NAME=${APP_NAME} \
ALLOW_REMOTE=yes-i-understand-the-risk
EXPOSE 3000
EXPOSE 9527
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD node -e "fetch('http://localhost:' + (process.env.PORT || '9527') + '/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "dist/index.js"]
CMD ["sh", "-lc", "node apps/${APP_NAME}/dist/main.js"]
Vendored
+41 -14
View File
@@ -2,7 +2,10 @@ pipeline {
agent any
environment {
APP_NAME = 'social-mcp'
IMAGE_XHS = 'social-mcp-xhs'
IMAGE_XHH = 'social-mcp-xhh'
APP_XHS = 'mcp-xhs'
APP_XHH = 'mcp-xhh'
}
triggers {
@@ -16,33 +19,57 @@ pipeline {
}
}
stage('Build Docker Image') {
stage('Build Docker Images') {
steps {
sh "docker build --build-arg HTTP_PROXY=http://172.17.0.1:7897 --build-arg HTTPS_PROXY=http://172.17.0.1:7897 -t ${APP_NAME}:${BUILD_NUMBER} -t ${APP_NAME}:latest ."
sh "docker build --build-arg APP_NAME=xhs-mcp --build-arg HTTP_PROXY=http://172.17.0.1:7897 --build-arg HTTPS_PROXY=http://172.17.0.1:7897 -t ${IMAGE_XHS}:${BUILD_NUMBER} -t ${IMAGE_XHS}:latest ."
sh "docker build --build-arg APP_NAME=xhh-mcp --build-arg HTTP_PROXY=http://172.17.0.1:7897 --build-arg HTTPS_PROXY=http://172.17.0.1:7897 -t ${IMAGE_XHH}:${BUILD_NUMBER} -t ${IMAGE_XHH}:latest ."
}
}
stage('Deploy') {
steps {
sh """
docker stop ${APP_NAME} || true
docker rm ${APP_NAME} || true
mkdir -p /data/${APP_NAME}
chown 1001:1001 /data/${APP_NAME}
docker stop ${APP_XHS} || true
docker rm ${APP_XHS} || true
docker stop ${APP_XHH} || true
docker rm ${APP_XHH} || true
mkdir -p /data/${APP_XHS}
mkdir -p /data/${APP_XHH}
chown 1001:1001 /data/${APP_XHS}
chown 1001:1001 /data/${APP_XHH}
docker run -d \
--name ${APP_NAME} \
--name ${APP_XHS} \
--network nginx \
-p 3010:3000 \
-p 9527:9527 \
--shm-size=1g \
-v /data/${APP_NAME}:/home/appuser/.social-mcp \
-v /data/${APP_XHS}:/home/appuser/.social-mcp-xhs \
-e NODE_ENV=production \
-e HOST=0.0.0.0 \
-e PORT=3000 \
-e PORT=9527 \
-e HEADLESS=true \
-e COOKIE_DIR=/home/appuser/.social-mcp \
-e COOKIE_DIR=/home/appuser/.social-mcp-xhs \
-e APP_NAME=xhs-mcp \
-e ALLOW_REMOTE=yes-i-understand-the-risk \
--restart unless-stopped \
${APP_NAME}:latest
${IMAGE_XHS}:latest
docker run -d \
--name ${APP_XHH} \
--network nginx \
-p 9528:9528 \
--shm-size=1g \
-v /data/${APP_XHH}:/home/appuser/.social-mcp-xhh \
-e NODE_ENV=production \
-e HOST=0.0.0.0 \
-e PORT=9528 \
-e HEADLESS=true \
-e COOKIE_DIR=/home/appuser/.social-mcp-xhh \
-e APP_NAME=xhh-mcp \
-e ALLOW_REMOTE=yes-i-understand-the-risk \
--restart unless-stopped \
${IMAGE_XHH}:latest
"""
}
}
@@ -50,7 +77,7 @@ pipeline {
post {
success {
echo "Deployed ${APP_NAME} build #${BUILD_NUMBER} successfully"
echo "Deployed ${APP_XHS} and ${APP_XHH} build #${BUILD_NUMBER} successfully"
}
failure {
echo "Build #${BUILD_NUMBER} failed"
+82 -241
View File
@@ -1,286 +1,127 @@
# Social MCP
# Social Auto Hub (Monorepo)
[中文文档](./README.zh-CN.md)
Multi-platform social media automation service that exposes browser-based actions as both MCP (Model Context Protocol) tools and a REST API. Current platform support: **Xiaohongshu** (Little Red Book).
This repository is now a **workspace monorepo** with clear visual separation:
## Features
- `apps/xhs-mcp`: Xiaohongshu MCP service (`xhs_*` tools)
- `apps/xhh-mcp`: Xiaoheihe MCP service (`xhh_*` tools)
- `packages/core`: shared infrastructure (`browser/config/cookie/server/utils`)
- **22 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation)
- **REST API** with Bearer token authentication and per-route rate limiting
- **Browser automation** via `rebrowser-playwright` with per-platform serial queueing
- **Cookie persistence** with file-based storage (`0600`, atomic writes)
- **Notification task state** in SQLite (`$COOKIE_DIR/xiaohongshu/automation.db`) for idempotent auto-reply workflows
- **Web dashboard** (React + Vite) for login, feed exploration, publishing, and API testing
- **Security controls**: timing-safe token comparison, bind-address safety gate, error sanitization, log redaction
- **Docker deployment** support with hardened runtime defaults
- **Plugin architecture** for adding additional social platforms
## Workspace Layout
```text
apps/
xhs-mcp/
xhh-mcp/
packages/
core/
```
## Quick Start
### Prerequisites
- Node.js >= 22.0.0
- Node.js >= 22
- pnpm
### Install and Run (Backend)
### Install
```bash
# Install dependencies
pnpm install
npx rebrowser-playwright install chromium
```
# Install Playwright browser (first time only)
npx playwright install chromium
### Build all packages
# Build backend
```bash
pnpm build
# Start server
pnpm start
```
The server listens on `http://127.0.0.1:9527` by default in local mode.
A REST API Bearer token is printed on startup and persisted at `~/.social-mcp/.api-token`.
### Build With Web Dashboard
### Run XHS service
```bash
# Build backend + web dashboard bundle
pnpm build:all
# Start server (serves dashboard from /)
pnpm start
pnpm start:xhs
```
### Development
Default runtime values:
- `PORT=9527`
- `COOKIE_DIR=~/.social-mcp-xhs`
### Run XHH service
```bash
# Backend watch build
pnpm dev
# Frontend dev server
pnpm dev:web
# Type check
pnpm lint
# Tests
pnpm test
pnpm start:xhh
```
## MCP Integration
Default runtime values:
### Claude Desktop
- `PORT=9528`
- `COOKIE_DIR=~/.social-mcp-xhh`
Add this in `claude_desktop_config.json`:
## Compatibility Root Scripts
Root scripts are kept as forwarding wrappers:
- `pnpm build`
- `pnpm lint`
- `pnpm test`
- `pnpm start:xhs`
- `pnpm start:xhh`
- `pnpm dev:xhs`
- `pnpm dev:xhh`
You can also run package-level commands directly:
```bash
pnpm --filter @social/core build
pnpm --filter @social/xhs-mcp start
pnpm --filter @social/xhh-mcp start
```
## MCP Endpoints
- XHS MCP: `http://127.0.0.1:9527/mcp`
- XHH MCP: `http://127.0.0.1:9528/mcp`
## REST Endpoints
- XHS REST: `http://127.0.0.1:9527/api/xhs/*`
- XHH REST: `http://127.0.0.1:9528/api/xhh/*`
Each service uses its own bearer token file under its `COOKIE_DIR`.
## Claude Desktop Example
```json
{
"mcpServers": {
"social-mcp": {
"url": "http://127.0.0.1:9527/sse"
"mcp-xhs": {
"url": "http://127.0.0.1:9527/mcp"
},
"mcp-xhh": {
"url": "http://127.0.0.1:9528/mcp"
}
}
}
```
### Available MCP Tools (Xiaohongshu)
## Docker
| Tool | Description |
|------|-------------|
| `xhs_check_login` | Check Xiaohongshu login status |
| `xhs_get_login_qrcode` | Get login QR code for phone scanning |
| `xhs_delete_cookies` | Delete cookies and reset login session |
| `xhs_list_feeds` | Get explore page recommended feed list |
| `xhs_search` | Search notes by keyword with filters |
| `xhs_get_feed_detail` | Get note detail (supports passing note URL directly) |
| `xhs_get_sub_comments` | Load sub-comments for a parent comment (keyset cursor pagination) |
| `xhs_get_user_profile` | Get user profile with recent notes (supports passing profile URL directly) |
| `xhs_list_my_notes` | List current account's published notes |
| `xhs_publish_image` | Publish an image note |
| `xhs_publish_video` | Publish a video note |
| `xhs_post_comment` | Post a comment on a note |
| `xhs_reply_comment` | Reply to a comment |
| `xhs_set_like_state` | Set like state on a note (idempotent) |
| `xhs_set_favorite_state` | Set favorite state on a note (idempotent) |
| `xhs_get_unprocessed_notifications` | Get unprocessed notification tasks from local SQLite state |
| `xhs_mark_notification_task` | Manually mark notification task status (new/pending/ignored/replied/failed) |
| `xhs_mark_notification_tasks` | Batch mark notification task statuses |
| `xhs_list_failed_notification_tasks` | List failed notification tasks for triage/retry |
| `xhs_retry_notification_task` | Retry a failed notification task by fingerprint |
| `xhs_retry_notification_tasks` | Batch retry failed notification tasks |
| `xhs_reply_notification` | Reply to a specific notification |
Single Dockerfile supports dual app targets via build arg:
## REST API
- `APP_NAME=xhs-mcp`
- `APP_NAME=xhh-mcp`
All `/api/*` endpoints require:
Compose files already define both services:
- `Authorization: Bearer <token>`
- `Content-Type: application/json` (for POST bodies)
- `docker-compose.yml`
- `deploy/docker-compose.yml`
Example:
## Migration Notes
```bash
# Login status
curl -H "Authorization: Bearer <token>" \
http://127.0.0.1:9527/api/xhs/login/status
# Search notes
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"keyword":"travel","filters":{"sort":"popularity_descending"}}' \
http://127.0.0.1:9527/api/xhs/search
```
### Endpoint List
Read endpoints are limited to **60/min** per IP. Write endpoints are limited to **10/min** per IP.
| Method | Path | Description | Limit |
|--------|------|-------------|-------|
| `GET` | `/api/xhs/login/status` | Check login status | 60/min |
| `GET` | `/api/xhs/login/qrcode` | Get login QR code | 60/min |
| `DELETE` | `/api/xhs/login/cookies` | Delete cookies | 10/min |
| `GET` | `/api/xhs/login/cookie-check` | Check whether cookie file exists | 60/min |
| `GET` | `/api/xhs/feeds` | Get recommended feeds | 60/min |
| `POST` | `/api/xhs/search` | Search notes | 60/min |
| `POST` | `/api/xhs/feeds/detail` | Get note detail | 60/min |
| `POST` | `/api/xhs/feeds/sub-comments` | Load sub-comments for parent comment | 60/min |
| `POST` | `/api/xhs/user/profile` | Get user profile | 60/min |
| `GET` | `/api/xhs/my-notes` | List my published notes | 60/min |
| `POST` | `/api/xhs/publish/image` | Publish image note | 10/min |
| `POST` | `/api/xhs/publish/video` | Publish video note | 10/min |
| `POST` | `/api/xhs/comment` | Post a comment | 10/min |
| `POST` | `/api/xhs/comment/reply` | Reply to a comment | 10/min |
| `POST` | `/api/xhs/like` | Toggle like | 10/min |
| `POST` | `/api/xhs/favorite` | Toggle favorite | 10/min |
| `GET` | `/api/xhs/notifications/comments` | Get comment notifications | 60/min |
| `GET` | `/api/xhs/notifications/unprocessed` | Get unprocessed notification tasks from SQLite | 60/min |
| `POST` | `/api/xhs/notifications/reply` | Reply to notification | 10/min |
### Response Format
```json
{
"success": true,
"data": {}
}
```
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "keyword: Required"
}
}
```
### Public Endpoints (No Bearer Token)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Health check (uptime/memory/plugin status) |
| `GET` | `/sse` | MCP SSE transport |
| `POST` | `/messages` | MCP JSON-RPC message endpoint |
## Docker Deployment
### Using Compose (Recommended)
```bash
cd deploy
docker compose up -d
# View logs
docker compose logs -f
# Find Bearer token in logs
docker compose logs social-mcp | grep "Bearer Token"
```
### Docker Run
```bash
docker build -t social-mcp .
docker run -d \
--name social-mcp \
-p 127.0.0.1:3000:3000 \
--shm-size=1gb \
--memory=2g \
--cpus=2.0 \
--security-opt=no-new-privileges:true \
--cap-drop=ALL \
--read-only \
--tmpfs /tmp:size=512m \
-v social-mcp-data:/home/appuser/.social-mcp \
social-mcp
```
Note: Docker defaults expose port `3000` because container env sets `PORT=3000`.
## Environment Variables
| Variable | Default (local) | Description |
|----------|------------------|-------------|
| `PORT` | `9527` | HTTP server port |
| `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` requires `ALLOW_REMOTE`) |
| `HEADLESS` | `true` | Run browser in headless mode |
| `BROWSER_BIN` | (auto) | Custom Chromium executable path |
| `LOG_LEVEL` | `info` | Pino log level (`debug`, `info`, `warn`, `error`) |
| `NODE_ENV` | `development` | Runtime environment |
| `COOKIE_DIR` | `~/.social-mcp` | Cookie/token storage directory |
| `MAX_QUEUE_DEPTH` | `10` | Max pending operations per platform queue |
| `ALLOW_REMOTE` | (unset) | Must be `yes-i-understand-the-risk` to allow public bind |
| `XHS_NOTIFICATION_POLL_ENABLED` | `true` | Enable periodic notification sync into SQLite |
| `XHS_NOTIFICATION_POLL_INTERVAL_SEC` | `60` | Notification sync interval (seconds, min 15) |
| `XHS_NOTIFICATION_POLL_MAX_COUNT` | `20` | Max notifications fetched per sync |
## Project Structure
```text
social-mcp/
├── src/
│ ├── index.ts
│ ├── server/
│ │ ├── app.ts
│ │ └── middleware.ts
│ ├── browser/
│ │ └── manager.ts
│ ├── cookie/
│ │ └── store.ts
│ ├── config/
│ │ └── index.ts
│ ├── utils/
│ │ ├── logger.ts
│ │ ├── errors.ts
│ │ └── downloader.ts
│ └── platforms/
│ └── xiaohongshu/
│ ├── index.ts
│ ├── routes.ts
│ ├── schemas.ts
│ ├── selectors.ts
│ ├── login.ts
│ ├── feeds.ts
│ ├── search.ts
│ ├── feed-detail.ts
│ ├── user-profile.ts
│ ├── my-notes.ts
│ ├── publish.ts
│ ├── publish-video.ts
│ ├── comment.ts
│ ├── interaction.ts
│ ├── notification.ts
│ ├── notification-state.ts
│ └── notification-sync.ts
└── web/
└── src/
```
## License
ISC
- Old root runtime entry (`src/index.ts`) is removed.
- Service code now lives inside `apps/*`.
- Shared runtime logic now lives inside `packages/core`.
- Lockfile strategy is pnpm-only (`pnpm-lock.yaml`).
+81 -244
View File
@@ -1,287 +1,124 @@
# Social MCP
# Social Auto HubMonorepo
[English README](./README.md)
[English](./README.md)
一个面向社交媒体自动化的服务,既提供 MCPModel Context Protocol)工具能力,也提供 REST API。当前支持平台:**小红书**。
仓库已改造为 **workspace monorepo**,结构上清晰拆分为:
## 功能特性
- `apps/xhs-mcp`:小红书 MCP 服务(`xhs_*`
- `apps/xhh-mcp`:小黑盒 MCP 服务(`xhh_*`
- `packages/core`:共享基础设施(`browser/config/cookie/server/utils`
- 小红书 **22 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
- 带 Bearer Token 鉴权与按路由限流的 REST API
- 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行
- 文件型 Cookie 持久化(`0600` 权限、原子写入)
- 通知任务状态入库 SQLite`$COOKIE_DIR/xiaohongshu/automation.db`),保障自动回复幂等
- Web 控制台(React + Vite):登录、内容浏览、发布、接口测试
- 安全控制:Token 常量时间比对、绑定地址安全门、错误信息脱敏、日志字段脱敏
- 支持 Docker 部署
- 插件化平台架构,便于扩展更多平台
## 目录结构
```text
apps/
xhs-mcp/
xhh-mcp/
packages/
core/
```
## 快速开始
### 前置要求
- Node.js >= 22.0.0
- Node.js >= 22
- pnpm
### 安装与运行(后端)
### 安装依赖
```bash
# 安装依赖
pnpm install
npx rebrowser-playwright install chromium
```
# 首次安装 Playwright Chromium
npx playwright install chromium
### 构建全部包
# 构建后端
```bash
pnpm build
# 启动服务
pnpm start
```
本地默认监听地址:`http://127.0.0.1:9527`
首次启动会在控制台打印 REST API Bearer Token,并保存到 `~/.social-mcp/.api-token`
### 构建并启用 Web 控制台
### 启动小红书服务
```bash
# 构建后端 + 前端
pnpm build:all
# 启动服务(会同时托管 dashboard)
pnpm start
pnpm start:xhs
```
### 开发命令
默认运行参数:
- `PORT=9527`
- `COOKIE_DIR=~/.social-mcp-xhs`
### 启动小黑盒服务
```bash
# 后端 watch 构建
pnpm dev
# 前端开发服务器
pnpm dev:web
# 类型检查
pnpm lint
# 测试
pnpm test
pnpm start:xhh
```
## MCP 集成
默认运行参数:
### Claude Desktop
- `PORT=9528`
- `COOKIE_DIR=~/.social-mcp-xhh`
`claude_desktop_config.json` 中添加:
## 根目录兼容脚本
根脚本保留为转发壳:
- `pnpm build`
- `pnpm lint`
- `pnpm test`
- `pnpm start:xhs`
- `pnpm start:xhh`
- `pnpm dev:xhs`
- `pnpm dev:xhh`
也可以直接按包执行:
```bash
pnpm --filter @social/core build
pnpm --filter @social/xhs-mcp start
pnpm --filter @social/xhh-mcp start
```
## MCP 地址
- XHS MCP: `http://127.0.0.1:9527/mcp`
- XHH MCP: `http://127.0.0.1:9528/mcp`
## REST 地址
- XHS REST: `http://127.0.0.1:9527/api/xhs/*`
- XHH REST: `http://127.0.0.1:9528/api/xhh/*`
两个服务使用各自 `COOKIE_DIR` 下的独立 Bearer token。
## Claude Desktop 接入示例
```json
{
"mcpServers": {
"social-mcp": {
"url": "http://127.0.0.1:9527/sse"
"mcp-xhs": {
"url": "http://127.0.0.1:9527/mcp"
},
"mcp-xhh": {
"url": "http://127.0.0.1:9528/mcp"
}
}
}
```
### MCP 工具列表(小红书)
## Docker
| 工具名 | 说明 |
|------|------|
| `xhs_check_login` | 检查登录状态 |
| `xhs_get_login_qrcode` | 获取扫码登录二维码 |
| `xhs_delete_cookies` | 删除 Cookie 并重置登录状态 |
| `xhs_list_feeds` | 获取推荐流 |
| `xhs_search` | 关键词搜索笔记(支持筛选) |
| `xhs_get_feed_detail` | 获取笔记详情(支持直接传笔记 URL) |
| `xhs_get_sub_comments` | 拉取某条父评论的子评论(Keyset 游标分页) |
| `xhs_get_user_profile` | 获取用户主页及近期笔记(支持直接传主页 URL) |
| `xhs_list_my_notes` | 获取当前账号已发布笔记列表 |
| `xhs_publish_image` | 发布图文笔记 |
| `xhs_publish_video` | 发布视频笔记 |
| `xhs_post_comment` | 发表评论 |
| `xhs_reply_comment` | 回复评论 |
| `xhs_set_like_state` | 设置点赞状态(幂等) |
| `xhs_set_favorite_state` | 设置收藏状态(幂等) |
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed |
| `xhs_mark_notification_tasks` | 批量标记通知任务状态 |
| `xhs_list_failed_notification_tasks` | 获取失败通知任务列表(用于排障/重试) |
| `xhs_retry_notification_task` | 按 fingerprint 重试失败通知任务 |
| `xhs_retry_notification_tasks` | 批量重试失败通知任务 |
| `xhs_reply_notification` | 对通知进行回复 |
单一 Dockerfile 支持通过 `APP_NAME` 构建双目标:
## REST API
- `APP_NAME=xhs-mcp`
- `APP_NAME=xhh-mcp`
所有 `/api/*` 接口都需要:
`docker-compose.yml``deploy/docker-compose.yml` 已配置双服务。
- `Authorization: Bearer <token>`
- `POST` 请求需设置 `Content-Type: application/json`
## 迁移说明
示例:
```bash
# 查询登录状态
curl -H "Authorization: Bearer <token>" \
http://127.0.0.1:9527/api/xhs/login/status
# 搜索笔记
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"keyword":"travel","filters":{"sort":"popularity_descending"}}' \
http://127.0.0.1:9527/api/xhs/search
```
### 接口清单
读接口限流:**60/min**(按 IP
写接口限流:**10/min**(按 IP
| 方法 | 路径 | 说明 | 限流 |
|------|------|------|------|
| `GET` | `/api/xhs/login/status` | 检查登录状态 | 60/min |
| `GET` | `/api/xhs/login/qrcode` | 获取二维码 | 60/min |
| `DELETE` | `/api/xhs/login/cookies` | 删除 Cookie | 10/min |
| `GET` | `/api/xhs/login/cookie-check` | 检查本地 Cookie 文件是否存在 | 60/min |
| `GET` | `/api/xhs/feeds` | 获取推荐流 | 60/min |
| `POST` | `/api/xhs/search` | 搜索笔记 | 60/min |
| `POST` | `/api/xhs/feeds/detail` | 获取笔记详情 | 60/min |
| `POST` | `/api/xhs/feeds/sub-comments` | 拉取子评论 | 60/min |
| `POST` | `/api/xhs/user/profile` | 获取用户主页 | 60/min |
| `GET` | `/api/xhs/my-notes` | 获取我的笔记 | 60/min |
| `POST` | `/api/xhs/publish/image` | 发布图文 | 10/min |
| `POST` | `/api/xhs/publish/video` | 发布视频 | 10/min |
| `POST` | `/api/xhs/comment` | 发表评论 | 10/min |
| `POST` | `/api/xhs/comment/reply` | 回复评论 | 10/min |
| `POST` | `/api/xhs/like` | 点赞切换 | 10/min |
| `POST` | `/api/xhs/favorite` | 收藏切换 | 10/min |
| `GET` | `/api/xhs/notifications/comments` | 获取评论通知 | 60/min |
| `GET` | `/api/xhs/notifications/unprocessed` | 获取本地未处理通知任务 | 60/min |
| `POST` | `/api/xhs/notifications/reply` | 回复通知 | 10/min |
### 返回格式
```json
{
"success": true,
"data": {}
}
```
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "keyword: Required"
}
}
```
### 公开接口(无需鉴权)
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/health` | 健康检查(运行时长/内存/插件状态) |
| `GET` | `/sse` | MCP SSE 连接入口 |
| `POST` | `/messages` | MCP JSON-RPC 消息入口 |
## Docker 部署
### 使用 Compose(推荐)
```bash
cd deploy
docker compose up -d
# 查看日志
docker compose logs -f
# 查找 Bearer Token
docker compose logs social-mcp | grep "Bearer Token"
```
### 直接运行 Docker
```bash
docker build -t social-mcp .
docker run -d \
--name social-mcp \
-p 127.0.0.1:3000:3000 \
--shm-size=1gb \
--memory=2g \
--cpus=2.0 \
--security-opt=no-new-privileges:true \
--cap-drop=ALL \
--read-only \
--tmpfs /tmp:size=512m \
-v social-mcp-data:/home/appuser/.social-mcp \
social-mcp
```
说明:Docker 镜像默认使用 `PORT=3000`
## 环境变量
| 变量 | 默认值(本地) | 说明 |
|------|----------------|------|
| `PORT` | `9527` | HTTP 监听端口 |
| `HOST` | `127.0.0.1` | 绑定地址(`0.0.0.0` 需设置 `ALLOW_REMOTE` |
| `HEADLESS` | `true` | 是否无头运行浏览器 |
| `BROWSER_BIN` | 自动探测 | 自定义 Chromium 路径 |
| `LOG_LEVEL` | `info` | 日志级别(`debug`/`info`/`warn`/`error` |
| `NODE_ENV` | `development` | 运行环境 |
| `COOKIE_DIR` | `~/.social-mcp` | Cookie/Token 存储目录 |
| `MAX_QUEUE_DEPTH` | `10` | 单平台最大排队深度 |
| `ALLOW_REMOTE` | 空 | 仅当值为 `yes-i-understand-the-risk` 时允许公网绑定 |
| `XHS_NOTIFICATION_POLL_ENABLED` | `true` | 是否启用通知定时同步到 SQLite |
| `XHS_NOTIFICATION_POLL_INTERVAL_SEC` | `60` | 通知同步周期(秒,最小 15) |
| `XHS_NOTIFICATION_POLL_MAX_COUNT` | `20` | 每次同步最多拉取通知数 |
## 项目结构
```text
social-mcp/
├── src/
│ ├── index.ts
│ ├── server/
│ │ ├── app.ts
│ │ └── middleware.ts
│ ├── browser/
│ │ └── manager.ts
│ ├── cookie/
│ │ └── store.ts
│ ├── config/
│ │ └── index.ts
│ ├── utils/
│ │ ├── logger.ts
│ │ ├── errors.ts
│ │ └── downloader.ts
│ └── platforms/
│ └── xiaohongshu/
│ ├── index.ts
│ ├── routes.ts
│ ├── schemas.ts
│ ├── selectors.ts
│ ├── login.ts
│ ├── feeds.ts
│ ├── search.ts
│ ├── feed-detail.ts
│ ├── user-profile.ts
│ ├── my-notes.ts
│ ├── publish.ts
│ ├── publish-video.ts
│ ├── comment.ts
│ ├── interaction.ts
│ ├── notification.ts
│ ├── notification-state.ts
│ └── notification-sync.ts
└── web/
└── src/
```
## License
ISC
- 旧的根入口(`src/index.ts`)已移除。
- 服务代码迁移到 `apps/*`
- 共享基础能力迁移到 `packages/core`
- 锁文件策略改为 pnpm`pnpm-lock.yaml`)。
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@social/xhh-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"bin": {
"mcp-xhh": "dist/main.js"
},
"scripts": {
"build": "tsup",
"lint": "tsc --noEmit",
"test": "vitest run",
"start": "PORT=${PORT:-9528} COOKIE_DIR=${COOKIE_DIR:-$HOME/.social-mcp-xhh} node dist/main.js",
"dev": "pnpm build && pnpm start"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.0",
"@social/core": "workspace:*",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
import { startServerWithPlugins } from '@social/core/server/bootstrap.js';
import { xiaoheihePlugin } from './platforms/xiaoheihe/index.js';
startServerWithPlugins([xiaoheihePlugin]);
@@ -0,0 +1,187 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import { detectCaptchaText } from './extractors.js';
const log = logger.child({ module: 'xhh-comment' });
function buildDetailUrl(linkId: string): string {
return `https://www.xiaoheihe.cn/app/bbs/link/${encodeURIComponent(linkId)}`;
}
export async function postComment(
page: Page,
linkId: string,
content: string,
): Promise<{ success: boolean; comment_id?: string }> {
await page.goto(buildDetailUrl(linkId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_000);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on comment page');
}
const ok = await fillCommentInput(page, content);
if (!ok) return { success: false };
const submitted = await clickFirstVisible(page, XHH_SELECTORS.detail.commentSubmit);
if (!submitted) return { success: false };
await page.waitForTimeout(1_500);
const commentId = await page.evaluate(
({ selectors, contentLike }: { selectors: typeof XHH_SELECTORS; contentLike: string }) => {
const nodes = [...document.querySelectorAll<HTMLElement>(selectors.detail.commentItem.join(', '))];
const hit = nodes.find((node) => node.textContent?.includes(contentLike));
if (!hit) return '';
return (
hit.getAttribute('data-comment-id') ||
hit.getAttribute('comment-id') ||
hit.id ||
''
);
},
{ selectors: XHH_SELECTORS, contentLike: content.slice(0, 24) },
);
return {
success: true,
...(commentId ? { comment_id: commentId as string } : {}),
};
}
export async function replyComment(
page: Page,
linkId: string,
commentId: string,
content: string,
): Promise<{ success: boolean; reply_id?: string }> {
await page.goto(buildDetailUrl(linkId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_000);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on reply page');
}
await page.evaluate(
({ selectors, targetCommentId }) => {
const comments = [...document.querySelectorAll<HTMLElement>(selectors.detail.commentItem.join(', '))];
const target = comments.find((node) => {
const id =
node.getAttribute('data-comment-id') ||
node.getAttribute('comment-id') ||
node.id ||
'';
if (id === targetCommentId) return true;
return node.outerHTML.includes(targetCommentId);
});
if (!target) return;
const replyBtn = [...target.querySelectorAll<HTMLElement>('button, [role="button"], .reply-btn, .comment-reply')]
.find((node) => {
const text = (node.textContent ?? '').trim();
const cls = node.className.toString().toLowerCase();
return text.includes('回复') || cls.includes('reply');
}) ?? null;
replyBtn?.click();
},
{ selectors: XHH_SELECTORS, targetCommentId: commentId },
);
await page.waitForTimeout(500);
const ok = await fillCommentInput(page, content);
if (!ok) return { success: false };
const submitted = await clickFirstVisible(page, XHH_SELECTORS.detail.commentSubmit);
if (!submitted) return { success: false };
await page.waitForTimeout(1_500);
const replyId = await page.evaluate(
(
{
selectors,
targetCommentId,
contentLike,
}: { selectors: typeof XHH_SELECTORS; targetCommentId: string; contentLike: string },
) => {
const comments = [...document.querySelectorAll<HTMLElement>(selectors.detail.commentItem.join(', '))];
const target = comments.find((node) => {
const id =
node.getAttribute('data-comment-id') ||
node.getAttribute('comment-id') ||
node.id ||
'';
if (id === targetCommentId) return true;
return node.outerHTML.includes(targetCommentId);
});
if (!target) return '';
const replies = [...target.querySelectorAll<HTMLElement>(selectors.detail.subCommentItem.join(', '))];
const hit = replies.find((node) => node.textContent?.includes(contentLike));
if (!hit) return '';
return (
hit.getAttribute('data-comment-id') ||
hit.getAttribute('comment-id') ||
hit.id ||
''
);
},
{ selectors: XHH_SELECTORS, targetCommentId: commentId, contentLike: content.slice(0, 24) },
);
return {
success: true,
...(replyId ? { reply_id: replyId as string } : {}),
};
}
async function fillCommentInput(page: Page, content: string): Promise<boolean> {
for (const selector of XHH_SELECTORS.detail.commentInput) {
const input = await page.$(selector).catch(() => null);
if (!input) continue;
await input.click().catch(() => {});
await page.waitForTimeout(200);
const isOk = await page
.evaluate(
({ selector, content }) => {
const node = document.querySelector(selector);
if (!node) return false;
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
node.value = content;
node.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
if (node instanceof HTMLElement && node.isContentEditable) {
node.focus();
node.textContent = content;
node.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
return false;
},
{ selector, content },
)
.catch(() => false);
if (isOk) return true;
}
return false;
}
async function clickFirstVisible(page: Page, selectors: readonly string[]): Promise<boolean> {
for (const selector of selectors) {
const clicked = await page
.locator(selector)
.first()
.click({ timeout: 2_000 })
.then(() => true)
.catch(() => false);
if (clicked) return true;
}
log.warn({ selectors }, 'no clickable submit button');
return false;
}
@@ -0,0 +1,66 @@
export interface KeysetCursorPayload {
key: string;
}
export interface KeysetPage<T> {
items: T[];
hasMore: boolean;
nextCursor?: string;
}
export function encodeKeysetCursor(payload: KeysetCursorPayload): string {
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
}
export function decodeKeysetCursor(cursor?: string): KeysetCursorPayload | undefined {
if (!cursor) return undefined;
try {
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
key?: unknown;
};
if (typeof raw.key !== 'string' || raw.key.length === 0) {
throw new Error('Invalid keyset cursor payload');
}
return { key: raw.key };
} catch {
throw new Error('Invalid cursor for keyset pagination');
}
}
export function paginateByKeyset<T>(
items: T[],
maxCount: number,
cursor: KeysetCursorPayload | undefined,
keyOf: (item: T) => string,
): KeysetPage<T> {
if (maxCount <= 0) {
return { items: [], hasMore: false };
}
const start = cursor
? Math.max(0, items.findIndex((item) => keyOf(item) === cursor.key) + 1)
: 0;
const pageItems = items.slice(start, start + maxCount);
const hasMore = start + pageItems.length < items.length;
if (!hasMore || pageItems.length === 0) {
return {
items: pageItems,
hasMore,
};
}
const nextCursor = encodeKeysetCursor({
key: keyOf(pageItems[pageItems.length - 1]!),
});
return {
items: pageItems,
hasMore,
nextCursor,
};
}
@@ -0,0 +1,110 @@
import type { Feed } from './types.js';
export function parseCountString(raw: string | number | null | undefined): number {
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : 0;
}
const text = (raw ?? '').toString().trim().replace(/,/g, '');
if (!text) return 0;
if (text.endsWith('万')) {
const num = Number.parseFloat(text.slice(0, -1));
if (Number.isNaN(num)) return 0;
return Math.round(num * 10_000);
}
const intNum = Number.parseInt(text, 10);
return Number.isNaN(intNum) ? 0 : intNum;
}
export function detectCaptchaText(text: string): boolean {
const haystack = text.toLowerCase();
return (
haystack.includes('captcha') ||
haystack.includes('show_captcha') ||
haystack.includes('验证码') ||
haystack.includes('tencentcaptcha')
);
}
export function extractLinkIdFromUrl(rawUrl: string): string | undefined {
const trimmed = rawUrl.trim();
if (!trimmed) return undefined;
try {
const url = /^https?:\/\//i.test(trimmed)
? new URL(trimmed)
: trimmed.startsWith('/')
? new URL(`https://www.xiaoheihe.cn${trimmed}`)
: new URL(`https://${trimmed}`);
const pathMatch = url.pathname.match(/\/app\/bbs\/link\/(\d+)/);
if (pathMatch?.[1]) return pathMatch[1];
const queryLinkId = url.searchParams.get('link_id') ?? url.searchParams.get('linkid');
return queryLinkId || undefined;
} catch {
return undefined;
}
}
export function extractUserIdFromUrl(rawUrl: string): string | undefined {
const trimmed = rawUrl.trim();
if (!trimmed) return undefined;
try {
const url = /^https?:\/\//i.test(trimmed)
? new URL(trimmed)
: trimmed.startsWith('/')
? new URL(`https://www.xiaoheihe.cn${trimmed}`)
: new URL(`https://${trimmed}`);
const pathMatch = url.pathname.match(/\/app\/user\/profile\/(\d+)/);
if (pathMatch?.[1]) return pathMatch[1];
const queryUserId = url.searchParams.get('userid') ?? url.searchParams.get('user_id');
return queryUserId || undefined;
} catch {
return undefined;
}
}
export function parseFeedsFromHtmlSnapshot(html: string): Feed[] {
const matches = [...html.matchAll(/href="(\/app\/bbs\/link\/\d+)"/g)];
const seen = new Set<string>();
const feeds: Feed[] = [];
for (const m of matches) {
const href = m[1];
if (!href) continue;
const id = extractLinkIdFromUrl(href);
if (!id || seen.has(id)) continue;
seen.add(id);
feeds.push({
id,
title: '',
description: '',
coverUrl: '',
likeCount: 0,
commentCount: 0,
user: {
id: '',
nickname: '',
avatar: '',
},
linkUrl: `https://www.xiaoheihe.cn${href}`,
});
}
return feeds;
}
export function firstNonEmpty(...values: Array<string | null | undefined>): string {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return '';
}
@@ -0,0 +1,266 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import type { Comment, FeedDetail } from './types.js';
import { detectCaptchaText, firstNonEmpty, parseCountString } from './extractors.js';
const log = logger.child({ module: 'xhh-feed-detail' });
function buildDetailUrl(linkId: string): string {
return `https://www.xiaoheihe.cn/app/bbs/link/${encodeURIComponent(linkId)}`;
}
interface RawComment {
id: string;
parentId?: string;
userId: string;
nickname: string;
avatar: string;
content: string;
likeCount: string | number;
createTime: string;
subComments: RawComment[];
}
interface RawDetail {
title: string;
description: string;
images: string[];
likeCount: string | number;
favoriteCount: string | number;
commentCount: string | number;
isLiked: boolean;
isFavorited: boolean;
userId: string;
nickname: string;
avatar: string;
comments: RawComment[];
}
export async function getFeedDetail(page: Page, linkId: string): Promise<FeedDetail> {
const url = buildDetailUrl(linkId);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const bodyText = await page.textContent('body').catch(() => '');
if (bodyText && detectCaptchaText(bodyText)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on feed detail');
}
const raw = await page.evaluate((selectors: typeof XHH_SELECTORS) => {
const pickText = (selector: string): string =>
(document.querySelector(selector)?.textContent ?? '').trim();
const pickFrom = (selectorList: readonly string[]): string => {
for (const selector of selectorList) {
const text = pickText(selector);
if (text) return text;
}
return '';
};
const pickAttr = (selector: string, attr: string): string =>
(document.querySelector(selector)?.getAttribute(attr) ?? '').trim();
const title = pickFrom(selectors.detail.title);
const description = pickFrom(selectors.detail.description);
const images = [...document.querySelectorAll<HTMLImageElement>(selectors.detail.image)]
.map((img) => img.src)
.filter(Boolean);
const likeCount = pickText(selectors.detail.likeCount);
const favoriteCount = pickText(selectors.detail.favoriteCount);
const commentCount = pickText(selectors.detail.commentCount);
const likeBtn = selectors.detail.likeButton
.map((sel: string) => document.querySelector(sel))
.find(Boolean) as Element | undefined;
const favBtn = selectors.detail.favoriteButton
.map((sel: string) => document.querySelector(sel))
.find(Boolean) as Element | undefined;
const isLiked =
Boolean(likeBtn?.getAttribute('aria-pressed') === 'true') ||
Boolean(likeBtn?.className.toString().toLowerCase().includes('active')) ||
Boolean(likeBtn?.innerHTML.toLowerCase().includes('filled'));
const isFavorited =
Boolean(favBtn?.getAttribute('aria-pressed') === 'true') ||
Boolean(favBtn?.className.toString().toLowerCase().includes('active')) ||
Boolean(favBtn?.innerHTML.toLowerCase().includes('filled'));
const userLink = pickAttr(selectors.detail.userLink, 'href');
const nickname = pickFrom(selectors.detail.userName);
const avatar = pickAttr(selectors.detail.userAvatar, 'src');
const commentSelector = selectors.detail.commentItem.join(', ');
const subSelector = selectors.detail.subCommentItem.join(', ');
const comments: RawComment[] = [];
for (const node of document.querySelectorAll<HTMLElement>(commentSelector)) {
const id =
node.getAttribute('data-comment-id') ||
node.getAttribute('comment-id') ||
node.id ||
'';
const authorNode = node.querySelector(selectors.detail.commentAuthor);
const authorLink = authorNode?.getAttribute('href') ?? '';
const userId = authorLink.match(/\/app\/user\/profile\/(\d+)/)?.[1] ?? '';
const nickname = (authorNode?.textContent ?? '').trim();
const avatar = (node.querySelector(selectors.detail.commentAvatar) as HTMLImageElement | null)?.src ?? '';
const content = (node.querySelector(selectors.detail.commentContent)?.textContent ?? '').trim();
const createTime = (node.querySelector(selectors.detail.commentTime)?.textContent ?? '').trim();
const likeCount = (node.querySelector(selectors.detail.commentLikeCount)?.textContent ?? '').trim();
const subComments: RawComment[] = [];
for (const subNode of node.querySelectorAll<HTMLElement>(subSelector)) {
const subId =
subNode.getAttribute('data-comment-id') ||
subNode.getAttribute('comment-id') ||
subNode.id ||
'';
const subAuthorNode = subNode.querySelector(selectors.detail.commentAuthor);
const subAuthorLink = subAuthorNode?.getAttribute('href') ?? '';
const subUserId = subAuthorLink.match(/\/app\/user\/profile\/(\d+)/)?.[1] ?? '';
subComments.push({
id: subId,
parentId: id || undefined,
userId: subUserId,
nickname: (subAuthorNode?.textContent ?? '').trim(),
avatar: (subNode.querySelector(selectors.detail.commentAvatar) as HTMLImageElement | null)?.src ?? '',
content: (subNode.querySelector(selectors.detail.commentContent)?.textContent ?? '').trim(),
createTime: (subNode.querySelector(selectors.detail.commentTime)?.textContent ?? '').trim(),
likeCount: (subNode.querySelector(selectors.detail.commentLikeCount)?.textContent ?? '').trim(),
subComments: [],
});
}
comments.push({
id,
userId,
nickname,
avatar,
content,
likeCount,
createTime,
subComments,
});
}
return {
title,
description,
images,
likeCount,
favoriteCount,
commentCount,
isLiked,
isFavorited,
userId: userLink.match(/\/app\/user\/profile\/(\d+)/)?.[1] ?? '',
nickname,
avatar,
comments,
};
}, XHH_SELECTORS) as RawDetail;
const detail: FeedDetail = {
id: linkId,
title: raw.title,
description: raw.description,
images: raw.images,
likeCount: parseCountString(raw.likeCount),
favoriteCount: parseCountString(raw.favoriteCount),
commentCount: parseCountString(raw.commentCount),
isLiked: raw.isLiked,
isFavorited: raw.isFavorited,
user: {
id: raw.userId,
nickname: raw.nickname,
avatar: raw.avatar,
},
comments: raw.comments.map(normalizeComment),
};
if (!detail.title && !detail.description) {
throw new Error('waiting for selector: xhh detail not found');
}
log.info({ linkId, commentCount: detail.comments.length }, 'xhh feed detail extracted');
return detail;
}
export async function getSubComments(
page: Page,
linkId: string,
commentId: string,
maxCount = 200,
): Promise<Comment[]> {
const url = buildDetailUrl(linkId);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_000);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on sub-comments page');
}
const expandSelector = XHH_SELECTORS.detail.commentExpandReplies;
await page.locator(expandSelector).first().click().catch(() => {});
await page.waitForTimeout(500);
const subComments = await page.evaluate(
({ selectors, targetCommentId }) => {
const commentSelector = selectors.detail.commentItem.join(', ');
const subSelector = selectors.detail.subCommentItem.join(', ');
const comments = [...document.querySelectorAll<HTMLElement>(commentSelector)];
const target = comments.find((node) => {
const id =
node.getAttribute('data-comment-id') ||
node.getAttribute('comment-id') ||
node.id ||
'';
if (id === targetCommentId) return true;
return node.outerHTML.includes(targetCommentId);
});
if (!target) return [] as RawComment[];
const out: RawComment[] = [];
for (const node of target.querySelectorAll<HTMLElement>(subSelector)) {
const authorNode = node.querySelector(selectors.detail.commentAuthor);
const authorLink = authorNode?.getAttribute('href') ?? '';
out.push({
id:
node.getAttribute('data-comment-id') ||
node.getAttribute('comment-id') ||
node.id ||
'',
parentId: targetCommentId,
userId: authorLink.match(/\/app\/user\/profile\/(\d+)/)?.[1] ?? '',
nickname: (authorNode?.textContent ?? '').trim(),
avatar: (node.querySelector(selectors.detail.commentAvatar) as HTMLImageElement | null)?.src ?? '',
content: (node.querySelector(selectors.detail.commentContent)?.textContent ?? '').trim(),
createTime: (node.querySelector(selectors.detail.commentTime)?.textContent ?? '').trim(),
likeCount: (node.querySelector(selectors.detail.commentLikeCount)?.textContent ?? '').trim(),
subComments: [],
});
}
return out;
},
{ selectors: XHH_SELECTORS, targetCommentId: commentId },
);
return subComments.slice(0, maxCount).map(normalizeComment);
}
function normalizeComment(input: RawComment): Comment {
return {
id: firstNonEmpty(input.id, `${Date.now()}-${Math.random()}`),
...(input.parentId ? { parentId: input.parentId } : {}),
userId: input.userId,
nickname: input.nickname,
avatar: input.avatar,
content: input.content,
likeCount: parseCountString(input.likeCount),
createTime: input.createTime,
subComments: input.subComments.map(normalizeComment),
};
}
@@ -0,0 +1,268 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import type { Feed } from './types.js';
import {
detectCaptchaText,
extractLinkIdFromUrl,
firstNonEmpty,
parseCountString,
} from './extractors.js';
const HOME_URL = 'https://www.xiaoheihe.cn/app/bbs/home';
const log = logger.child({ module: 'xhh-feeds' });
interface RawFeedCandidate {
id?: string;
title?: string;
description?: string;
coverUrl?: string;
likeCount?: string | number;
commentCount?: string | number;
userId?: string;
nickname?: string;
avatar?: string;
linkUrl?: string;
}
export async function listFeeds(page: Page): Promise<Feed[]> {
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_500);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on feeds page');
}
const nuxtFeeds = await extractFeedsFromNuxt(page);
const domFeeds = await extractFeedsFromDom(page);
const merged = [...nuxtFeeds, ...domFeeds];
const result = dedupeAndNormalize(merged);
log.info({ count: result.length }, 'xhh feeds extracted');
return result;
}
export async function searchFeeds(page: Page, keyword: string): Promise<Feed[]> {
const targetUrl = `https://www.xiaoheihe.cn/app/bbs/search?keyword=${encodeURIComponent(keyword)}`;
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on search page');
}
const combined = dedupeAndNormalize([
...(await extractFeedsFromNuxt(page)),
...(await extractFeedsFromDom(page)),
]);
if (combined.length > 0) {
return combined.filter((item) => {
const haystack = `${item.title} ${item.description} ${item.user.nickname}`.toLowerCase();
return haystack.includes(keyword.toLowerCase());
});
}
// Fallback: when search route structure changes, use home feeds and filter.
const homeFeeds = await listFeeds(page);
return homeFeeds.filter((item) => {
const haystack = `${item.title} ${item.description} ${item.user.nickname}`.toLowerCase();
return haystack.includes(keyword.toLowerCase());
});
}
async function extractFeedsFromNuxt(page: Page): Promise<RawFeedCandidate[]> {
const data = await page
.evaluate(() => {
const root: unknown =
(window as { __NUXT_DATA__?: unknown }).__NUXT_DATA__ ??
(window as { __NUXT__?: { data?: unknown } }).__NUXT__?.data ??
null;
const out: Array<Record<string, unknown>> = [];
const visited = new Set<unknown>();
function walk(value: unknown): void {
if (!value || typeof value !== 'object') return;
if (visited.has(value)) return;
visited.add(value);
if (Array.isArray(value)) {
for (const item of value) walk(item);
return;
}
const obj = value as Record<string, unknown>;
const id =
(typeof obj['link_id'] === 'string' && obj['link_id']) ||
(typeof obj['linkid'] === 'string' && obj['linkid']) ||
(typeof obj['id'] === 'string' && obj['id']) ||
(typeof obj['post_id'] === 'string' && obj['post_id']) ||
'';
const url =
(typeof obj['link_url'] === 'string' && obj['link_url']) ||
(typeof obj['url'] === 'string' && obj['url']) ||
'';
const title =
(typeof obj['title'] === 'string' && obj['title']) ||
(typeof obj['subject'] === 'string' && obj['subject']) ||
'';
const hasLink = (typeof url === 'string' && url.includes('/app/bbs/link/'));
if (id || hasLink || title) {
out.push(obj);
}
for (const next of Object.values(obj)) {
walk(next);
}
}
walk(root);
return out.slice(0, 500);
})
.catch(() => []);
return (data as Array<Record<string, unknown>>).map((item) => {
const linkUrl = firstNonEmpty(
valueString(item['link_url']),
valueString(item['url']),
valueString(item['jump_url']),
);
const user = (item['user'] ?? item['author']) as Record<string, unknown> | undefined;
return {
id: firstNonEmpty(
valueString(item['link_id']),
valueString(item['linkid']),
valueString(item['post_id']),
valueString(item['id']),
),
title: firstNonEmpty(valueString(item['title']), valueString(item['subject'])),
description: firstNonEmpty(
valueString(item['description']),
valueString(item['content']),
valueString(item['desc']),
),
coverUrl: firstNonEmpty(
valueString(item['cover']),
valueString(item['cover_url']),
valueString(item['image']),
),
likeCount: valueString(item['like_count']) || valueString(item['likes']),
commentCount: valueString(item['comment_count']) || valueString(item['comments']),
userId: firstNonEmpty(
valueString(user?.['userid']),
valueString(user?.['user_id']),
valueString(item['userid']),
),
nickname: firstNonEmpty(
valueString(user?.['nickname']),
valueString(user?.['name']),
valueString(item['nickname']),
),
avatar: firstNonEmpty(
valueString(user?.['avatar']),
valueString(user?.['avatar_url']),
),
linkUrl,
};
});
}
async function extractFeedsFromDom(page: Page): Promise<RawFeedCandidate[]> {
return page
.evaluate((selectors) => {
const anchors = [...document.querySelectorAll<HTMLAnchorElement>(selectors.feed.link)]
.filter((a) => Boolean(a.getAttribute('href')));
const feeds: RawFeedCandidate[] = [];
const cardSelector = selectors.feed.card.join(', ');
const titleSelector = selectors.feed.title.join(', ');
const descSelector = selectors.feed.description.join(', ');
const userNameSelector = selectors.feed.userName.join(', ');
const likeSelector = selectors.feed.likeCount.join(', ');
const commentSelector = selectors.feed.commentCount.join(', ');
for (const link of anchors) {
const href = link.getAttribute('href') ?? '';
const card = link.closest(cardSelector) ?? link.parentElement;
const title = (card?.querySelector(titleSelector)?.textContent ?? '').trim();
const description = (card?.querySelector(descSelector)?.textContent ?? '').trim();
const cover = (card?.querySelector(selectors.feed.cover) as HTMLImageElement | null)?.src ?? '';
const userNode = card?.querySelector(selectors.feed.userLink) as HTMLAnchorElement | null;
const username = (card?.querySelector(userNameSelector)?.textContent ?? '').trim();
const likeCount = (card?.querySelector(likeSelector)?.textContent ?? '').trim();
const commentCount = (card?.querySelector(commentSelector)?.textContent ?? '').trim();
feeds.push({
linkUrl: href,
title,
description,
coverUrl: cover,
userId: userNode?.getAttribute('href') ?? '',
nickname: username,
avatar: (card?.querySelector('img') as HTMLImageElement | null)?.src ?? '',
likeCount,
commentCount,
});
}
return feeds;
}, XHH_SELECTORS)
.catch(() => []);
}
function dedupeAndNormalize(items: RawFeedCandidate[]): Feed[] {
const output: Feed[] = [];
const seen = new Set<string>();
for (const item of items) {
const linkId = firstNonEmpty(item.id, item.linkUrl ? extractLinkIdFromUrl(item.linkUrl) ?? '' : '');
if (!linkId || seen.has(linkId)) continue;
seen.add(linkId);
const linkUrl = normalizeLinkUrl(item.linkUrl, linkId);
const userIdFromHref = item.userId ? extractUserIdFromMaybeHref(item.userId) : '';
output.push({
id: linkId,
title: item.title?.trim() ?? '',
description: item.description?.trim() ?? '',
coverUrl: item.coverUrl?.trim() ?? '',
likeCount: parseCountString(item.likeCount),
commentCount: parseCountString(item.commentCount),
user: {
id: userIdFromHref,
nickname: item.nickname?.trim() ?? '',
avatar: item.avatar?.trim() ?? '',
},
linkUrl,
});
}
return output;
}
function normalizeLinkUrl(rawUrl: string | undefined, linkId: string): string {
const trimmed = rawUrl?.trim() ?? '';
if (!trimmed) return `https://www.xiaoheihe.cn/app/bbs/link/${linkId}`;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith('/')) return `https://www.xiaoheihe.cn${trimmed}`;
return `https://${trimmed}`;
}
function extractUserIdFromMaybeHref(raw: string): string {
const normalized = raw.startsWith('/') ? `https://www.xiaoheihe.cn${raw}` : raw;
return normalized.match(/\/app\/user\/profile\/(\d+)/)?.[1] ?? '';
}
function valueString(value: unknown): string {
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return '';
}
@@ -0,0 +1,389 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Router } from 'express';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import type { PlatformPlugin } from '@social/core/server/app.js';
import { withErrorHandling, type McpToolResult } from '@social/core/utils/errors.js';
import { computeIdempotencyHash, getIdempotencyStore } from '@social/core/utils/idempotency.js';
import { deleteCookies, checkLoginStatus, getLoginQRCode } from './login.js';
import { listFeeds } from './feeds.js';
import { searchFeeds } from './search.js';
import { getFeedDetail, getSubComments } from './feed-detail.js';
import { getUserProfile } from './user-profile.js';
import { listMyPosts } from './my-posts.js';
import { postComment, replyComment } from './comment.js';
import { setFavoriteState, setLikeState } from './interaction.js';
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
import {
CheckLoginSchema,
DeleteCookiesSchema,
GetFeedDetailSchema,
GetLoginQRCodeSchema,
GetSubCommentsSchema,
GetUserProfileSchema,
ListFeedsSchema,
ListMyPostsSchema,
PostCommentSchema,
ReplyCommentSchema,
SearchSchema,
SetFavoriteStateSchema,
SetLikeStateSchema,
} from './schemas.js';
import { createXhhRoutes } from './routes.js';
import { decodeKeysetCursor, paginateByKeyset } from './cursor.js';
import type { Comment } from './types.js';
const PLATFORM = 'xiaoheihe';
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 200;
type McpMeta = Record<string, unknown>;
function ok(data: unknown, meta?: McpMeta): McpToolResult {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
data,
meta: meta ?? {},
}),
}],
};
}
function clampPageSize(maxCount?: number): number {
return Math.min(MAX_PAGE_SIZE, Math.max(1, maxCount ?? DEFAULT_PAGE_SIZE));
}
function paginationMeta(
cursor: string | undefined,
maxCount: number,
returned: number,
nextCursor?: string,
): McpMeta {
return {
pagination: {
mode: 'keyset',
cursor: cursor ?? '',
max_count: maxCount,
returned,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
};
}
async function runWithIdempotency<T>(
toolName: string,
requestId: string | undefined,
inputForHash: unknown,
execute: () => Promise<T>,
): Promise<{ data: T; meta?: McpMeta }> {
if (!requestId) {
return { data: await execute() };
}
const store = getIdempotencyStore();
const inputHash = computeIdempotencyHash(inputForHash);
const existing = store.get(toolName, requestId);
if (existing) {
if (existing.inputHash !== inputHash) {
throw new Error('request_id already used with different parameters');
}
return {
data: existing.responseData as T,
meta: {
request_id: requestId,
idempotent_replay: true,
first_processed_at: existing.createdAt,
},
};
}
const data = await execute();
store.put(toolName, requestId, inputHash, data);
return {
data,
meta: {
request_id: requestId,
idempotent_replay: false,
},
};
}
function compareCommentKey(a: Comment, b: Comment): number {
const timeCmp = a.createTime.localeCompare(b.createTime);
if (timeCmp !== 0) return timeCmp;
return a.id.localeCompare(b.id);
}
export const xiaoheihePlugin: PlatformPlugin = {
name: PLATFORM,
apiNamespace: 'xhh',
registerRoutes(router: Router, browser: BrowserManager): void {
const xhhRouter = createXhhRoutes(browser);
router.use('/', xhhRouter);
},
registerTools(server: McpServer, browser: BrowserManager): void {
server.tool(
'xhh_check_login',
'Check Xiaoheihe login status',
CheckLoginSchema,
async () => withErrorHandling('xhh_check_login', async () => {
const timeoutMs = config.operationTimeouts['login'] ?? config.operationTimeouts['default'] ?? 60_000;
const status = await browser.withPage(PLATFORM, async (page) => checkLoginStatus(page), timeoutMs);
return ok({
logged_in: status.loggedIn,
...(status.username ? { username: status.username } : {}),
...(status.avatar ? { avatar: status.avatar } : {}),
...(status.userId ? { user_id: status.userId } : {}),
});
}),
);
server.tool(
'xhh_get_login_qrcode',
'Get Xiaoheihe login QR code',
GetLoginQRCodeSchema,
async () => withErrorHandling('xhh_get_login_qrcode', async () => {
const qr = await getLoginQRCode(browser);
return ok({
qrcode_data: qr.qrcodeData,
already_logged_in: qr.alreadyLoggedIn,
timeout: qr.timeout,
});
}),
);
server.tool(
'xhh_delete_cookies',
'Delete Xiaoheihe cookies and reset login session',
DeleteCookiesSchema,
async () => withErrorHandling('xhh_delete_cookies', async () => {
await deleteCookies(browser);
return ok({ deleted: true });
}),
);
server.tool(
'xhh_list_feeds',
'List Xiaoheihe feed cards',
ListFeedsSchema,
async (args) => withErrorHandling('xhh_list_feeds', async () => {
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
const feeds = await browser.withPage(PLATFORM, async (page) => listFeeds(page), timeoutMs);
const limit = clampPageSize(args.max_count);
const cursor = decodeKeysetCursor(args.cursor);
const paged = paginateByKeyset(feeds, limit, cursor, (item) => item.id);
return ok(
paged.items,
paginationMeta(args.cursor, limit, paged.items.length, paged.nextCursor),
);
}),
);
server.tool(
'xhh_search',
'Search Xiaoheihe posts by keyword',
SearchSchema,
async (args) => withErrorHandling('xhh_search', async () => {
const timeoutMs = config.operationTimeouts['search'] ?? config.operationTimeouts['default'] ?? 60_000;
const feeds = await browser.withPage(
PLATFORM,
async (page) => searchFeeds(page, args.keyword),
timeoutMs,
);
const limit = clampPageSize(args.max_count);
const cursor = decodeKeysetCursor(args.cursor);
const paged = paginateByKeyset(feeds, limit, cursor, (item) => item.id);
return ok(
paged.items,
paginationMeta(args.cursor, limit, paged.items.length, paged.nextCursor),
);
}),
);
server.tool(
'xhh_get_feed_detail',
'Get Xiaoheihe feed detail with first-screen comments',
GetFeedDetailSchema,
async (args) => withErrorHandling('xhh_get_feed_detail', async () => {
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
const target = resolveFeedTarget({
link_id: args.link_id,
url: args.url,
});
const detail = await browser.withPage(
PLATFORM,
async (page) => getFeedDetail(page, target.linkId),
timeoutMs,
);
const { comments, ...rest } = detail;
return ok({
detail: rest,
first_screen_comments: comments,
});
}),
);
server.tool(
'xhh_get_sub_comments',
'Get sub-comments for a Xiaoheihe comment with keyset pagination',
GetSubCommentsSchema,
async (args) => withErrorHandling('xhh_get_sub_comments', async () => {
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
const loaded = await browser.withPage(
PLATFORM,
async (page) => getSubComments(page, args.link_id, args.comment_id, MAX_PAGE_SIZE),
timeoutMs,
);
const sorted = [...loaded].sort(compareCommentKey);
const limit = clampPageSize(args.max_count);
const cursor = decodeKeysetCursor(args.cursor);
const paged = paginateByKeyset(
sorted,
limit,
cursor,
(item) => `${item.createTime}|${item.id}`,
);
return ok(
paged.items,
paginationMeta(args.cursor, limit, paged.items.length, paged.nextCursor),
);
}),
);
server.tool(
'xhh_get_user_profile',
'Get Xiaoheihe user profile',
GetUserProfileSchema,
async (args) => withErrorHandling('xhh_get_user_profile', async () => {
const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000;
const target = resolveUserTarget({
user_id: args.user_id,
url: args.url,
});
const profile = await browser.withPage(
PLATFORM,
async (page) => getUserProfile(page, target.userId),
timeoutMs,
);
return ok({
profile: {
id: profile.id,
nickname: profile.nickname,
avatar: profile.avatar,
description: profile.description,
follows: profile.follows,
fans: profile.fans,
likes: profile.likes,
},
recent_posts: profile.posts,
});
}),
);
server.tool(
'xhh_list_my_posts',
'List my Xiaoheihe posts',
ListMyPostsSchema,
async (args) => withErrorHandling('xhh_list_my_posts', async () => {
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
const posts = await browser.withPage(
PLATFORM,
async (page) => listMyPosts(page, args.type),
timeoutMs,
);
const limit = clampPageSize(args.max_count);
const cursor = decodeKeysetCursor(args.cursor);
const paged = paginateByKeyset(posts, limit, cursor, (item) => `${item.modifyTime ?? item.createTime ?? ''}|${item.id}`);
return ok(
paged.items,
paginationMeta(args.cursor, limit, paged.items.length, paged.nextCursor),
);
}),
);
server.tool(
'xhh_post_comment',
'Post a comment on Xiaoheihe',
PostCommentSchema,
async (args) => withErrorHandling('xhh_post_comment', async () => {
const timeoutMs = config.operationTimeouts['comment'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await runWithIdempotency(
'xhh_post_comment',
args.request_id,
{
link_id: args.link_id,
content: args.content,
},
async () => browser.withPage(
PLATFORM,
async (page) => postComment(page, args.link_id, args.content),
timeoutMs,
),
);
return ok(result.data, result.meta);
}),
);
server.tool(
'xhh_reply_comment',
'Reply a comment on Xiaoheihe',
ReplyCommentSchema,
async (args) => withErrorHandling('xhh_reply_comment', async () => {
const timeoutMs = config.operationTimeouts['reply'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await runWithIdempotency(
'xhh_reply_comment',
args.request_id,
{
link_id: args.link_id,
comment_id: args.comment_id,
content: args.content,
},
async () => browser.withPage(
PLATFORM,
async (page) => replyComment(page, args.link_id, args.comment_id, args.content),
timeoutMs,
),
);
return ok(result.data, result.meta);
}),
);
server.tool(
'xhh_set_like_state',
'Set like state for a Xiaoheihe post',
SetLikeStateSchema,
async (args) => withErrorHandling('xhh_set_like_state', async () => {
const timeoutMs = config.operationTimeouts['like'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await browser.withPage(
PLATFORM,
async (page) => setLikeState(page, args.link_id, args.liked),
timeoutMs,
);
return ok(result);
}),
);
server.tool(
'xhh_set_favorite_state',
'Set favorite state for a Xiaoheihe post',
SetFavoriteStateSchema,
async (args) => withErrorHandling('xhh_set_favorite_state', async () => {
const timeoutMs = config.operationTimeouts['favorite'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await browser.withPage(
PLATFORM,
async (page) => setFavoriteState(page, args.link_id, args.favorited),
timeoutMs,
);
return ok(result);
}),
);
},
};
@@ -0,0 +1,98 @@
import type { Page } from 'rebrowser-playwright';
import { XHH_SELECTORS } from './selectors.js';
import { detectCaptchaText } from './extractors.js';
function buildDetailUrl(linkId: string): string {
return `https://www.xiaoheihe.cn/app/bbs/link/${encodeURIComponent(linkId)}`;
}
export async function setLikeState(
page: Page,
linkId: string,
targetState: boolean,
): Promise<{ success: boolean; state: boolean; changed: boolean }> {
await page.goto(buildDetailUrl(linkId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_000);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on interaction page');
}
const current = await readButtonState(page, XHH_SELECTORS.detail.likeButton);
if (current === targetState) {
return { success: true, state: current, changed: false };
}
const clicked = await clickAny(page, XHH_SELECTORS.detail.likeButton);
if (!clicked) {
return { success: false, state: current, changed: false };
}
await page.waitForTimeout(700);
const state = await readButtonState(page, XHH_SELECTORS.detail.likeButton);
return {
success: state === targetState,
state,
changed: state !== current,
};
}
export async function setFavoriteState(
page: Page,
linkId: string,
targetState: boolean,
): Promise<{ success: boolean; state: boolean; changed: boolean }> {
await page.goto(buildDetailUrl(linkId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_000);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on interaction page');
}
const current = await readButtonState(page, XHH_SELECTORS.detail.favoriteButton);
if (current === targetState) {
return { success: true, state: current, changed: false };
}
const clicked = await clickAny(page, XHH_SELECTORS.detail.favoriteButton);
if (!clicked) {
return { success: false, state: current, changed: false };
}
await page.waitForTimeout(700);
const state = await readButtonState(page, XHH_SELECTORS.detail.favoriteButton);
return {
success: state === targetState,
state,
changed: state !== current,
};
}
async function clickAny(page: Page, selectors: readonly string[]): Promise<boolean> {
for (const selector of selectors) {
const ok = await page.locator(selector).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
if (ok) return true;
}
return false;
}
async function readButtonState(page: Page, selectors: readonly string[]): Promise<boolean> {
for (const selector of selectors) {
const state = await page
.evaluate((sel) => {
const node = document.querySelector(sel) as HTMLElement | null;
if (!node) return null;
if (node.getAttribute('aria-pressed') === 'true') return true;
const cls = node.className.toString().toLowerCase();
if (cls.includes('active') || cls.includes('selected')) return true;
const html = node.innerHTML.toLowerCase();
if (html.includes('filled') || html.includes('checked')) return true;
return false;
}, selector)
.catch(() => null);
if (typeof state === 'boolean') return state;
}
return false;
}
@@ -0,0 +1,169 @@
import type { Page } from 'rebrowser-playwright';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { cookieStore } from '@social/core/cookie/store.js';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import type { LoginStatus, QRCodeResult } from './types.js';
import { extractUserIdFromUrl, firstNonEmpty } from './extractors.js';
const PLATFORM = 'xiaoheihe';
const HOME_URL = 'https://www.xiaoheihe.cn/app/bbs/home';
const QR_SCAN_TIMEOUT_MS = 4 * 60_000;
const LOGIN_SAFETY_TIMEOUT_MS = 5 * 60_000;
const log = logger.child({ module: 'xhh-login' });
export async function checkLoginStatus(page: Page): Promise<LoginStatus> {
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const indicator = await waitFirstSelector(page, XHH_SELECTORS.login.loggedInIndicators, 4_000);
if (!indicator) {
return { loggedIn: false };
}
const username = firstNonEmpty(
await textFromSelector(page, XHH_SELECTORS.login.username),
await indicator.textContent().catch(() => ''),
);
const avatar = await attrFromSelector(page, XHH_SELECTORS.login.avatar, 'src');
const userLink = await attrFromSelector(page, XHH_SELECTORS.login.userLink, 'href');
const userId = userLink ? extractUserIdFromUrl(userLink) : undefined;
return {
loggedIn: true,
...(username ? { username } : {}),
...(avatar ? { avatar } : {}),
...(userId ? { userId } : {}),
};
}
export async function getLoginQRCode(browser: BrowserManager): Promise<QRCodeResult> {
const { page, release } = await browser.acquirePage(PLATFORM);
const releaseTimer = setTimeout(() => {
void release();
}, LOGIN_SAFETY_TIMEOUT_MS);
if (typeof releaseTimer === 'object' && 'unref' in releaseTimer) {
releaseTimer.unref();
}
try {
await page.goto(HOME_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const status = await checkLoginStatus(page);
if (status.loggedIn) {
await release();
clearTimeout(releaseTimer);
return {
qrcodeData: '',
alreadyLoggedIn: true,
timeout: '0',
};
}
const loginBtn = await page.$(XHH_SELECTORS.login.loginButton).catch(() => null);
if (loginBtn) {
await loginBtn.click().catch(() => {});
await page.waitForTimeout(500);
}
const qrcodeData = await extractQrCodeData(page);
if (!qrcodeData) {
await release();
clearTimeout(releaseTimer);
throw new Error('waiting for selector: xhh login qrcode');
}
waitForLoginAndRelease(page, browser, release).catch((err: unknown) => {
log.warn({ err }, 'background login wait failed');
});
return {
qrcodeData,
alreadyLoggedIn: false,
timeout: '4m',
};
} catch (err) {
clearTimeout(releaseTimer);
await release();
throw err;
}
}
export async function deleteCookies(browser: BrowserManager): Promise<void> {
await cookieStore.delete(PLATFORM);
await browser.clearContext(PLATFORM);
}
async function waitForLoginAndRelease(
page: Page,
browser: BrowserManager,
release: () => Promise<void>,
): Promise<void> {
try {
await waitFirstSelector(page, XHH_SELECTORS.login.loggedInIndicators, QR_SCAN_TIMEOUT_MS);
await browser.saveCookies(PLATFORM);
await browser.clearContext(PLATFORM);
} finally {
await release();
}
}
async function waitFirstSelector(
page: Page,
selectors: readonly string[],
timeout: number,
) {
const started = Date.now();
for (const selector of selectors) {
const remaining = Math.max(1, timeout - (Date.now() - started));
const handle = await page.waitForSelector(selector, { timeout: remaining }).catch(() => null);
if (handle) return handle;
}
return null;
}
async function extractQrCodeData(page: Page): Promise<string> {
for (const selector of XHH_SELECTORS.login.qrCodeImage) {
const data = await page
.evaluate((sel) => {
const node = document.querySelector(sel);
if (!node) return '';
if (node instanceof HTMLImageElement) {
return node.src || '';
}
if (node instanceof HTMLCanvasElement) {
try {
return node.toDataURL();
} catch {
return '';
}
}
return '';
}, selector)
.catch(() => '');
if (data) return data;
}
return '';
}
async function textFromSelector(page: Page, selector: string): Promise<string> {
return page
.$eval(selector, (el) => (el.textContent ?? '').trim())
.catch(() => '');
}
async function attrFromSelector(
page: Page,
selector: string,
attr: string,
): Promise<string> {
return page
.$eval(selector, (el, attrName) => el.getAttribute(attrName) ?? '', attr)
.catch(() => '');
}
@@ -0,0 +1,88 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import type { MyPost, MyPostType } from './types.js';
import { detectCaptchaText, extractLinkIdFromUrl, parseCountString } from './extractors.js';
const URL = 'https://www.xiaoheihe.cn/creator/content_management/home';
const log = logger.child({ module: 'xhh-my-posts' });
const TAB_KEYWORDS: Record<MyPostType, string> = {
all: '全部',
article: '文章',
image_text: '图文',
video: '视频',
};
export async function listMyPosts(
page: Page,
type: MyPostType = 'all',
): Promise<MyPost[]> {
await page.goto(URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on my-posts page');
}
if (type !== 'all') {
const keyword = TAB_KEYWORDS[type];
const tabs = page.locator(XHH_SELECTORS.myPosts.tabButton);
const count = await tabs.count().catch(() => 0);
for (let i = 0; i < count; i += 1) {
const tab = tabs.nth(i);
const tabText = (await tab.textContent().catch(() => '')) ?? '';
if (tabText.includes(keyword)) {
await tab.click().catch(() => {});
await page.waitForTimeout(500);
break;
}
}
}
const rawItems = await page.evaluate((selectors) => {
const container = [...document.querySelectorAll<HTMLElement>(selectors.myPosts.postItem)];
return container.map((node) => {
const linkNode = node.querySelector<HTMLAnchorElement>(selectors.myPosts.postLink);
const href = linkNode?.getAttribute('href') ?? '';
const title = (node.querySelector(selectors.myPosts.title)?.textContent ?? '').trim();
const description = (node.querySelector(selectors.myPosts.description)?.textContent ?? '').trim();
const time = (node.querySelector(selectors.myPosts.time)?.textContent ?? '').trim();
const likeRaw = (node.querySelector('.like-count, .content-list__like-cnt')?.textContent ?? '').trim();
const commentRaw = (node.querySelector('.comment-count, .content-list__comment-cnt')?.textContent ?? '').trim();
const cover = (node.querySelector('img') as HTMLImageElement | null)?.src ?? '';
return { href, title, description, time, likeRaw, commentRaw, cover };
});
}, XHH_SELECTORS);
const posts: MyPost[] = [];
for (const item of rawItems) {
const linkId = extractLinkIdFromUrl(item.href);
if (!linkId) continue;
const linkUrl = item.href.startsWith('http')
? item.href
: `https://www.xiaoheihe.cn${item.href}`;
posts.push({
id: linkId,
type,
title: item.title,
description: item.description,
coverUrl: item.cover,
likeCount: parseCountString(item.likeRaw),
commentCount: parseCountString(item.commentRaw),
user: {
id: '',
nickname: '',
avatar: '',
},
linkUrl,
createTime: item.time,
modifyTime: item.time,
});
}
log.info({ type, count: posts.length }, 'xhh my posts listed');
return posts;
}
@@ -0,0 +1,464 @@
import { Router } from 'express';
import { z, ZodError } from 'zod';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { rateLimiter } from '@social/core/server/middleware.js';
import { classifyError, sanitizeErrorMessage } from '@social/core/utils/errors.js';
import { computeIdempotencyHash, getIdempotencyStore } from '@social/core/utils/idempotency.js';
import { decodeKeysetCursor, paginateByKeyset } from './cursor.js';
import { postComment, replyComment } from './comment.js';
import { getFeedDetail, getSubComments } from './feed-detail.js';
import { listFeeds } from './feeds.js';
import { setFavoriteState, setLikeState } from './interaction.js';
import { checkLoginStatus, deleteCookies, getLoginQRCode } from './login.js';
import { listMyPosts } from './my-posts.js';
import {
GetFeedDetailSchema,
GetSubCommentsSchema,
GetUserProfileSchema,
ListMyPostsSchema,
PostCommentSchema,
ReplyCommentSchema,
SearchSchema,
SetFavoriteStateSchema,
SetLikeStateSchema,
} from './schemas.js';
import { searchFeeds } from './search.js';
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
import { getUserProfile } from './user-profile.js';
const PLATFORM = 'xiaoheihe';
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 200;
const readRateLimiter = rateLimiter({ windowMs: 60_000, maxRequests: 60 });
const writeRateLimiter = rateLimiter({ windowMs: 60_000, maxRequests: 10 });
const SearchBodySchema = z.object({
keyword: SearchSchema.keyword,
max_count: SearchSchema.max_count,
cursor: SearchSchema.cursor,
});
const FeedDetailBodySchema = z.object({
link_id: GetFeedDetailSchema.link_id,
url: GetFeedDetailSchema.url,
});
const SubCommentsBodySchema = z.object({
link_id: GetSubCommentsSchema.link_id,
comment_id: GetSubCommentsSchema.comment_id,
max_count: GetSubCommentsSchema.max_count,
cursor: GetSubCommentsSchema.cursor,
});
const UserProfileBodySchema = z.object({
user_id: GetUserProfileSchema.user_id,
url: GetUserProfileSchema.url,
});
const PostCommentBodySchema = z.object({
request_id: PostCommentSchema.request_id,
link_id: PostCommentSchema.link_id,
content: PostCommentSchema.content,
});
const ReplyCommentBodySchema = z.object({
request_id: ReplyCommentSchema.request_id,
link_id: ReplyCommentSchema.link_id,
comment_id: ReplyCommentSchema.comment_id,
content: ReplyCommentSchema.content,
});
const LikeBodySchema = z.object({
link_id: SetLikeStateSchema.link_id,
liked: SetLikeStateSchema.liked,
});
const FavoriteBodySchema = z.object({
link_id: SetFavoriteStateSchema.link_id,
favorited: SetFavoriteStateSchema.favorited,
});
interface ApiSuccessResponse<T> {
success: true;
data: T;
}
interface ApiErrorResponse {
success: false;
error: {
code: string;
message: string;
};
}
function successResponse<T>(data: T): ApiSuccessResponse<T> {
return { success: true, data };
}
function errorResponse(code: string, message: string): ApiErrorResponse {
return { success: false, error: { code, message } };
}
function clampPageSize(maxCount?: number): number {
return Math.min(MAX_PAGE_SIZE, Math.max(1, maxCount ?? DEFAULT_PAGE_SIZE));
}
async function runWithIdempotency<T>(
toolName: string,
requestId: string | undefined,
inputForHash: unknown,
execute: () => Promise<T>,
): Promise<{ data: T; meta?: Record<string, unknown> }> {
if (!requestId) {
return { data: await execute() };
}
const store = getIdempotencyStore();
const inputHash = computeIdempotencyHash(inputForHash);
const existing = store.get(toolName, requestId);
if (existing) {
if (existing.inputHash !== inputHash) {
throw new Error('request_id already used with different parameters');
}
return {
data: existing.responseData as T,
meta: {
request_id: requestId,
idempotent_replay: true,
first_processed_at: existing.createdAt,
},
};
}
const data = await execute();
store.put(toolName, requestId, inputHash, data);
return {
data,
meta: {
request_id: requestId,
idempotent_replay: false,
},
};
}
export function createXhhRoutes(browser: BrowserManager): Router {
const router = Router();
router.get('/login/status', readRateLimiter, (_req, res) => {
void (async () => {
try {
const timeoutMs = config.operationTimeouts['login'] ?? config.operationTimeouts['default'] ?? 60_000;
const status = await browser.withPage(PLATFORM, async (page) => checkLoginStatus(page), timeoutMs);
res.json(successResponse({
logged_in: status.loggedIn,
...(status.username ? { username: status.username } : {}),
...(status.avatar ? { avatar: status.avatar } : {}),
...(status.userId ? { user_id: status.userId } : {}),
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.get('/login/qrcode', readRateLimiter, (_req, res) => {
void (async () => {
try {
const qr = await getLoginQRCode(browser);
res.json(successResponse({
qrcode_data: qr.qrcodeData,
already_logged_in: qr.alreadyLoggedIn,
timeout: qr.timeout,
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.delete('/login/cookies', writeRateLimiter, (_req, res) => {
void (async () => {
try {
await deleteCookies(browser);
res.json(successResponse({ deleted: true }));
} catch (err) {
handleError(res, err);
}
})();
});
router.get('/feeds', readRateLimiter, (req, res) => {
void (async () => {
try {
const query = z.object({
max_count: z.coerce.number().int().min(1).max(200).optional().default(20),
cursor: z.string().optional(),
}).parse(req.query);
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
const feeds = await browser.withPage(PLATFORM, async (page) => listFeeds(page), timeoutMs);
const limit = clampPageSize(query.max_count);
const paged = paginateByKeyset(feeds, limit, decodeKeysetCursor(query.cursor), (item) => item.id);
res.json(successResponse({
items: paged.items,
pagination: {
mode: 'keyset',
cursor: query.cursor ?? '',
max_count: limit,
returned: paged.items.length,
...(paged.nextCursor ? { next_cursor: paged.nextCursor } : {}),
},
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/search', readRateLimiter, (req, res) => {
void (async () => {
try {
const body = SearchBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['search'] ?? config.operationTimeouts['default'] ?? 60_000;
const items = await browser.withPage(PLATFORM, async (page) => searchFeeds(page, body.keyword), timeoutMs);
const limit = clampPageSize(body.max_count);
const paged = paginateByKeyset(items, limit, decodeKeysetCursor(body.cursor), (item) => item.id);
res.json(successResponse({
items: paged.items,
pagination: {
mode: 'keyset',
cursor: body.cursor ?? '',
max_count: limit,
returned: paged.items.length,
...(paged.nextCursor ? { next_cursor: paged.nextCursor } : {}),
},
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/feeds/detail', readRateLimiter, (req, res) => {
void (async () => {
try {
const body = FeedDetailBodySchema.parse(req.body);
const target = resolveFeedTarget(body);
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
const detail = await browser.withPage(PLATFORM, async (page) => getFeedDetail(page, target.linkId), timeoutMs);
const { comments, ...rest } = detail;
res.json(successResponse({
detail: rest,
first_screen_comments: comments,
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/feeds/sub-comments', readRateLimiter, (req, res) => {
void (async () => {
try {
const body = SubCommentsBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
const loaded = await browser.withPage(
PLATFORM,
async (page) => getSubComments(page, body.link_id, body.comment_id, MAX_PAGE_SIZE),
timeoutMs,
);
const sorted = [...loaded].sort((a, b) => {
const timeCmp = a.createTime.localeCompare(b.createTime);
if (timeCmp !== 0) return timeCmp;
return a.id.localeCompare(b.id);
});
const limit = clampPageSize(body.max_count);
const paged = paginateByKeyset(
sorted,
limit,
decodeKeysetCursor(body.cursor),
(item) => `${item.createTime}|${item.id}`,
);
res.json(successResponse({
items: paged.items,
pagination: {
mode: 'keyset',
cursor: body.cursor ?? '',
max_count: limit,
returned: paged.items.length,
...(paged.nextCursor ? { next_cursor: paged.nextCursor } : {}),
},
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/user/profile', readRateLimiter, (req, res) => {
void (async () => {
try {
const body = UserProfileBodySchema.parse(req.body);
const target = resolveUserTarget(body);
const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000;
const profile = await browser.withPage(PLATFORM, async (page) => getUserProfile(page, target.userId), timeoutMs);
res.json(successResponse({
profile: {
id: profile.id,
nickname: profile.nickname,
avatar: profile.avatar,
description: profile.description,
follows: profile.follows,
fans: profile.fans,
likes: profile.likes,
},
recent_posts: profile.posts,
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.get('/my-posts', readRateLimiter, (req, res) => {
void (async () => {
try {
const query = z.object({
type: ListMyPostsSchema.type,
max_count: z.coerce.number().int().min(1).max(200).optional().default(20),
cursor: z.string().optional(),
}).parse(req.query);
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
const posts = await browser.withPage(PLATFORM, async (page) => listMyPosts(page, query.type), timeoutMs);
const limit = clampPageSize(query.max_count);
const paged = paginateByKeyset(
posts,
limit,
decodeKeysetCursor(query.cursor),
(item) => `${item.modifyTime ?? item.createTime ?? ''}|${item.id}`,
);
res.json(successResponse({
items: paged.items,
pagination: {
mode: 'keyset',
cursor: query.cursor ?? '',
max_count: limit,
returned: paged.items.length,
...(paged.nextCursor ? { next_cursor: paged.nextCursor } : {}),
},
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/comment', writeRateLimiter, (req, res) => {
void (async () => {
try {
const body = PostCommentBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['comment'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await runWithIdempotency(
'xhh_post_comment',
body.request_id,
{
link_id: body.link_id,
content: body.content,
},
async () => browser.withPage(
PLATFORM,
async (page) => postComment(page, body.link_id, body.content),
timeoutMs,
),
);
res.json(successResponse({
...result.data,
...(result.meta ? { meta: result.meta } : {}),
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/comment/reply', writeRateLimiter, (req, res) => {
void (async () => {
try {
const body = ReplyCommentBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['reply'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await runWithIdempotency(
'xhh_reply_comment',
body.request_id,
{
link_id: body.link_id,
comment_id: body.comment_id,
content: body.content,
},
async () => browser.withPage(
PLATFORM,
async (page) => replyComment(page, body.link_id, body.comment_id, body.content),
timeoutMs,
),
);
res.json(successResponse({
...result.data,
...(result.meta ? { meta: result.meta } : {}),
}));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/like/set-state', writeRateLimiter, (req, res) => {
void (async () => {
try {
const body = LikeBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['like'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await browser.withPage(
PLATFORM,
async (page) => setLikeState(page, body.link_id, body.liked),
timeoutMs,
);
res.json(successResponse(result));
} catch (err) {
handleError(res, err);
}
})();
});
router.post('/favorite/set-state', writeRateLimiter, (req, res) => {
void (async () => {
try {
const body = FavoriteBodySchema.parse(req.body);
const timeoutMs = config.operationTimeouts['favorite'] ?? config.operationTimeouts['default'] ?? 60_000;
const result = await browser.withPage(
PLATFORM,
async (page) => setFavoriteState(page, body.link_id, body.favorited),
timeoutMs,
);
res.json(successResponse(result));
} catch (err) {
handleError(res, err);
}
})();
});
return router;
}
function handleError(res: { status: (n: number) => { json: (body: ApiErrorResponse) => void } }, err: unknown): void {
if (err instanceof ZodError) {
const detail = err.issues.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`).join('; ');
res.status(400).json(errorResponse('VALIDATION_ERROR', detail));
return;
}
const e = err instanceof Error ? err : new Error(String(err));
const category = classifyError(e);
const message = sanitizeErrorMessage(e.message);
const statusCode = category === 'AUTH_REQUIRED' ? 401 : 500;
res.status(statusCode).json(errorResponse(category, message));
}
@@ -0,0 +1,82 @@
import { z } from 'zod';
export const CheckLoginSchema = {};
export const GetLoginQRCodeSchema = {};
export const DeleteCookiesSchema = {};
export const ListFeedsSchema = {
max_count: z
.number()
.int()
.min(1)
.max(200)
.optional()
.default(20)
.describe('Maximum number of feeds to return per page (1-200, default 20)'),
cursor: z
.string()
.optional()
.describe('Keyset pagination cursor returned by previous call'),
};
export const SearchSchema = {
keyword: z.string().min(1).describe('Search keyword'),
max_count: ListFeedsSchema.max_count,
cursor: ListFeedsSchema.cursor,
};
export const GetFeedDetailSchema = {
link_id: z.string().optional().describe('Link ID (required when url is absent)'),
url: z.string().optional().describe('Detail page URL (auto-parse link_id)'),
};
export const GetSubCommentsSchema = {
link_id: z.string().describe('Link ID'),
comment_id: z.string().describe('Parent comment ID'),
max_count: ListFeedsSchema.max_count,
cursor: ListFeedsSchema.cursor,
};
export const GetUserProfileSchema = {
user_id: z.string().optional().describe('User ID (required when url is absent)'),
url: z.string().optional().describe('User profile URL (auto-parse user_id)'),
};
export const ListMyPostsSchema = {
type: z
.enum(['all', 'article', 'image_text', 'video'])
.optional()
.default('all')
.describe('Post type filter'),
max_count: ListFeedsSchema.max_count,
cursor: ListFeedsSchema.cursor,
};
export const PostCommentSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key'),
link_id: z.string().describe('Link ID'),
content: z.string().min(1).describe('Comment content'),
};
export const ReplyCommentSchema = {
request_id: PostCommentSchema.request_id,
link_id: z.string().describe('Link ID'),
comment_id: z.string().describe('Target comment ID'),
content: z.string().min(1).describe('Reply content'),
};
export const SetLikeStateSchema = {
link_id: z.string().describe('Link ID'),
liked: z.boolean().describe('Target like state'),
};
export const SetFavoriteStateSchema = {
link_id: z.string().describe('Link ID'),
favorited: z.boolean().describe('Target favorite state'),
};
@@ -0,0 +1,9 @@
import type { Page } from 'rebrowser-playwright';
import type { Feed } from './types.js';
import { searchFeeds as runSearch } from './feeds.js';
export async function searchFeeds(page: Page, keyword: string): Promise<Feed[]> {
return runSearch(page, keyword);
}
@@ -0,0 +1,147 @@
export const XHH_SELECTORS = {
login: {
loggedInIndicators: [
'.user-profile-user-head',
'.user-info .user-name',
'.view-header__user-box',
],
loginButton: '.user-box__login, .login-btn, button:has-text("登录")',
qrCodeImage: [
'#login-qrcode img',
'#login-qrcode canvas',
'.qr-code-wrapper img',
'.qrcode-box img',
'img[src*="qrcode"]',
],
username: '.user-profile-user-head .name, .user-info .user-name',
avatar: '.user-profile-user-head img, .user-info img.user-image',
userLink: 'a[href*="/app/user/profile/"]',
},
feed: {
card: [
'.content-management-home__content',
'.hb-cpt__moment-list-content',
'.related-recommend__link-item--content',
'.bbs-home__content-list > *',
],
link: 'a[href*="/app/bbs/link/"]',
title: [
'.link-item__title',
'.content-list__title',
'.article-title .title',
'.title',
],
description: [
'.link-item__desc',
'.content-list__desc',
'.article-desc',
'.desc',
],
cover: 'img',
userLink: 'a[href*="/app/user/profile/"]',
userName: [
'.list-content__username',
'.user-name',
'.name',
],
likeCount: [
'.content-list__like-cnt',
'.like-count',
'.link-award-num',
],
commentCount: [
'.content-list__comment-cnt',
'.comment-count',
'.comment-num',
],
},
detail: {
title: [
'.link-detail__title',
'.bbs-link__title',
'.article-title .title',
'h1',
],
description: [
'.link-detail__content',
'.bbs-link__content',
'.article-content',
'.description',
],
image: '.article-content img, .bbs-link img, .link-detail img',
userLink: 'a[href*="/app/user/profile/"]',
userName: [
'.user-profile-user-head .name',
'.user-info .user-name',
'.header .name',
],
userAvatar: '.user-profile-user-head img, .user-info img, .header img',
commentItem: [
'.comment-item',
'.bbs-comment-item',
'.link-comment-item',
'.comment__item',
'[id*="comment"]',
],
subCommentItem: [
'.sub-comment-item',
'.reply-item',
'.sub-comment',
'.child-comment',
],
commentAuthor: '.name, .nickname, a[href*="/app/user/profile/"]',
commentAvatar: 'img',
commentContent: '.content, .comment-content, p',
commentTime: '.time, .date, .create-time',
commentLikeCount: '.like-count, .like .count',
commentReplyButton: 'button:has-text("回复"), .reply-btn, .comment-reply',
commentExpandReplies: 'button:has-text("展开"), .show-more, .expand-replies',
likeButton: [
'.engage-bar-style .like-wrapper',
'.like-wrapper',
'button:has(.heybox-bbs_thumbs-up_line_24x24)',
'button:has(.heybox-bbs_thumbs-up_filled_24x24)',
],
favoriteButton: [
'.engage-bar-style .collect-wrapper',
'.collect-wrapper',
'button:has(.heybox-bbs_collect_line_24x24)',
'button:has(.heybox-bbs_collect_filled_24x24)',
],
commentCount: '.content-list__comment-cnt, .comment-count, .comment-num',
likeCount: '.content-list__like-cnt, .like-count, .link-award-num',
favoriteCount: '.favorite-count, .collect-count, .favour-count',
commentInput: [
'textarea[placeholder*="评论"]',
'textarea',
'[contenteditable="true"][placeholder*="评论"]',
'[contenteditable="true"]',
],
commentSubmit: [
'button:has-text("发送")',
'button:has-text("发布")',
'button:has-text("评论")',
'.comment-submit',
'.submit',
],
},
profile: {
nickname: '.user-profile-user-head .name, .user-info .user-name',
avatar: '.user-profile-user-head img, .user-info img.user-image',
description: '.user-profile-user-head .desc, .user-info .user-desc, .signature',
followCount: '.bbs-info-item .value, .follow-num',
postLink: 'a[href*="/app/bbs/link/"]',
},
myPosts: {
tabButton: '.creator-content-management__tabs button',
postItem: '.content-management-home__content',
postLink: 'a[href*="/app/bbs/link/"]',
title: '.link-item__title, .title',
description: '.link-item__desc, .desc',
time: '.time, .date',
},
} as const;
@@ -0,0 +1,52 @@
import { extractLinkIdFromUrl, extractUserIdFromUrl } from './extractors.js';
interface FeedTargetInput {
link_id?: string;
url?: string;
}
interface UserTargetInput {
user_id?: string;
url?: string;
}
export interface FeedTargetResolved {
linkId: string;
}
export interface UserTargetResolved {
userId: string;
}
function normalizeUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
throw new Error('url cannot be empty');
}
return trimmed;
}
export function resolveFeedTarget(input: FeedTargetInput): FeedTargetResolved {
const direct = input.link_id?.trim();
if (direct) return { linkId: direct };
if (input.url) {
const parsed = extractLinkIdFromUrl(normalizeUrl(input.url));
if (parsed) return { linkId: parsed };
}
throw new Error('xhh_get_feed_detail requires link_id or url containing link_id');
}
export function resolveUserTarget(input: UserTargetInput): UserTargetResolved {
const direct = input.user_id?.trim();
if (direct) return { userId: direct };
if (input.url) {
const parsed = extractUserIdFromUrl(normalizeUrl(input.url));
if (parsed) return { userId: parsed };
}
throw new Error('xhh_get_user_profile requires user_id or url containing user_id');
}
@@ -0,0 +1,74 @@
export interface LoginStatus {
loggedIn: boolean;
username?: string;
avatar?: string;
userId?: string;
}
export interface QRCodeResult {
qrcodeData: string;
alreadyLoggedIn: boolean;
timeout: string;
}
export interface FeedUser {
id: string;
nickname: string;
avatar: string;
}
export interface Feed {
id: string;
title: string;
description: string;
coverUrl: string;
likeCount: number;
commentCount: number;
user: FeedUser;
linkUrl: string;
}
export interface Comment {
id: string;
parentId?: string;
userId: string;
nickname: string;
avatar: string;
content: string;
likeCount: number;
createTime: string;
subComments: Comment[];
}
export interface FeedDetail {
id: string;
title: string;
description: string;
images: string[];
likeCount: number;
favoriteCount: number;
commentCount: number;
isLiked: boolean;
isFavorited: boolean;
user: FeedUser;
comments: Comment[];
}
export interface UserProfile {
id: string;
nickname: string;
avatar: string;
description: string;
follows: number;
fans: number;
likes: number;
posts: Feed[];
}
export type MyPostType = 'all' | 'article' | 'image_text' | 'video';
export interface MyPost extends Feed {
type: MyPostType;
createTime?: string;
modifyTime?: string;
}
@@ -0,0 +1,72 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '@social/core/utils/logger.js';
import { XHH_SELECTORS } from './selectors.js';
import type { UserProfile } from './types.js';
import { detectCaptchaText, parseCountString } from './extractors.js';
import { listFeeds } from './feeds.js';
const log = logger.child({ module: 'xhh-user-profile' });
function buildProfileUrl(userId: string): string {
return `https://www.xiaoheihe.cn/app/user/profile/${encodeURIComponent(userId)}`;
}
export async function getUserProfile(page: Page, userId: string): Promise<UserProfile> {
await page.goto(buildProfileUrl(userId), { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_200);
const text = await page.textContent('body').catch(() => '');
if (text && detectCaptchaText(text)) {
throw new Error('CAPTCHA_REQUIRED: captcha detected on user profile page');
}
const raw = await page.evaluate((selectors) => {
const pickText = (selector: string) =>
(document.querySelector(selector)?.textContent ?? '').trim();
const pickAttr = (selector: string, attr: string) =>
(document.querySelector(selector)?.getAttribute(attr) ?? '').trim();
const counters = [...document.querySelectorAll(selectors.profile.followCount)]
.map((node) => (node.textContent ?? '').trim())
.filter(Boolean);
const postLinks = [...document.querySelectorAll<HTMLAnchorElement>(selectors.profile.postLink)]
.map((node) => node.getAttribute('href') ?? '')
.filter(Boolean);
return {
nickname: pickText(selectors.profile.nickname),
avatar: pickAttr(selectors.profile.avatar, 'src'),
description: pickText(selectors.profile.description),
counters,
postLinks,
};
}, XHH_SELECTORS);
const [followRaw, fansRaw, likesRaw] = raw.counters;
const recentPosts = await listFeeds(page).catch(() => []);
const filteredPosts = recentPosts
.filter((item) => item.user.id === userId || raw.postLinks.some((href: string) => href.includes(item.id)))
.slice(0, 20);
const profile: UserProfile = {
id: userId,
nickname: raw.nickname,
avatar: raw.avatar,
description: raw.description,
follows: parseCountString(followRaw),
fans: parseCountString(fansRaw),
likes: parseCountString(likesRaw),
posts: filteredPosts,
};
if (!profile.nickname && !profile.avatar) {
throw new Error('waiting for selector: xhh profile not found');
}
log.info({ userId, posts: profile.posts.length }, 'xhh user profile extracted');
return profile;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
decodeKeysetCursor,
encodeKeysetCursor,
paginateByKeyset,
} from '../src/platforms/xiaoheihe/cursor.js';
describe('xhh keyset cursor', () => {
it('encodes and decodes cursor payload', () => {
const encoded = encodeKeysetCursor({ key: 'abc-123' });
const decoded = decodeKeysetCursor(encoded);
expect(decoded).toEqual({ key: 'abc-123' });
});
it('throws on invalid cursor payload', () => {
expect(() => decodeKeysetCursor('not-base64')).toThrow();
});
it('paginates deterministically without duplicates', () => {
const items = [
{ id: 'a' },
{ id: 'b' },
{ id: 'c' },
{ id: 'd' },
{ id: 'e' },
];
const page1 = paginateByKeyset(items, 2, undefined, (item) => item.id);
expect(page1.items.map((i) => i.id)).toEqual(['a', 'b']);
expect(page1.nextCursor).toBeTruthy();
const page2 = paginateByKeyset(items, 2, decodeKeysetCursor(page1.nextCursor), (item) => item.id);
expect(page2.items.map((i) => i.id)).toEqual(['c', 'd']);
expect(page2.nextCursor).toBeTruthy();
const page3 = paginateByKeyset(items, 2, decodeKeysetCursor(page2.nextCursor), (item) => item.id);
expect(page3.items.map((i) => i.id)).toEqual(['e']);
expect(page3.hasMore).toBe(false);
const combined = [...page1.items, ...page2.items, ...page3.items].map((i) => i.id);
expect(combined).toEqual(['a', 'b', 'c', 'd', 'e']);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
detectCaptchaText,
extractLinkIdFromUrl,
extractUserIdFromUrl,
firstNonEmpty,
parseCountString,
} from '../src/platforms/xiaoheihe/extractors.js';
describe('xhh extractors', () => {
it('parses count strings', () => {
expect(parseCountString('123')).toBe(123);
expect(parseCountString('1.2万')).toBe(12000);
expect(parseCountString('')).toBe(0);
});
it('detects captcha text', () => {
expect(detectCaptchaText('show_captcha')).toBe(true);
expect(detectCaptchaText('请完成验证码')).toBe(true);
expect(detectCaptchaText('normal page')).toBe(false);
});
it('extracts link_id and user_id from url', () => {
expect(extractLinkIdFromUrl('https://www.xiaoheihe.cn/app/bbs/link/123456')).toBe('123456');
expect(extractLinkIdFromUrl('/app/bbs/link/998877')).toBe('998877');
expect(extractUserIdFromUrl('https://www.xiaoheihe.cn/app/user/profile/112233')).toBe('112233');
expect(extractUserIdFromUrl('/app/user/profile/778899')).toBe('778899');
});
it('returns first non-empty value', () => {
expect(firstNonEmpty('', ' ', 'x', 'y')).toBe('x');
expect(firstNonEmpty('', ' ')).toBe('');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import {
GetFeedDetailSchema,
ListFeedsSchema,
PostCommentSchema,
ReplyCommentSchema,
SearchSchema,
SetFavoriteStateSchema,
SetLikeStateSchema,
} from '../src/platforms/xiaoheihe/schemas.js';
describe('xhh schemas', () => {
it('validates list/query boundaries', () => {
const schema = z.object(ListFeedsSchema);
expect(schema.parse({ max_count: 20 }).max_count).toBe(20);
expect(() => schema.parse({ max_count: 0 })).toThrow();
expect(() => schema.parse({ max_count: 201 })).toThrow();
});
it('validates search required keyword', () => {
const schema = z.object(SearchSchema);
expect(() => schema.parse({})).toThrow();
expect(schema.parse({ keyword: 'aaa' }).keyword).toBe('aaa');
});
it('allows feed detail by link_id or url', () => {
const schema = z.object(GetFeedDetailSchema);
expect(schema.parse({ link_id: '123' }).link_id).toBe('123');
expect(schema.parse({ url: 'https://www.xiaoheihe.cn/app/bbs/link/123' }).url).toContain('/app/bbs/link/');
});
it('validates comment payloads', () => {
const postSchema = z.object(PostCommentSchema);
const replySchema = z.object(ReplyCommentSchema);
expect(postSchema.parse({ link_id: '1', content: 'hi' }).content).toBe('hi');
expect(replySchema.parse({ link_id: '1', comment_id: '2', content: 'ok' }).comment_id).toBe('2');
expect(() => postSchema.parse({ link_id: '1', content: '' })).toThrow();
});
it('validates set-state tools', () => {
const likeSchema = z.object(SetLikeStateSchema);
const favSchema = z.object(SetFavoriteStateSchema);
expect(likeSchema.parse({ link_id: '1', liked: true }).liked).toBe(true);
expect(favSchema.parse({ link_id: '1', favorited: false }).favorited).toBe(false);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resolveFeedTarget, resolveUserTarget } from '../src/platforms/xiaoheihe/target-resolver.js';
describe('xhh target resolver', () => {
it('resolves feed target from link_id', () => {
expect(resolveFeedTarget({ link_id: '123' })).toEqual({ linkId: '123' });
});
it('resolves feed target from url', () => {
expect(resolveFeedTarget({ url: 'https://www.xiaoheihe.cn/app/bbs/link/123' })).toEqual({ linkId: '123' });
});
it('throws on invalid feed target', () => {
expect(() => resolveFeedTarget({})).toThrow();
});
it('resolves user target from user_id', () => {
expect(resolveUserTarget({ user_id: '999' })).toEqual({ userId: '999' });
});
it('resolves user target from url', () => {
expect(resolveUserTarget({ url: 'https://www.xiaoheihe.cn/app/user/profile/888' })).toEqual({ userId: '888' });
});
it('throws on invalid user target', () => {
expect(() => resolveUserTarget({})).toThrow();
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/main.ts'],
noExternal: [/^@social\/core/],
external: [
'@modelcontextprotocol/sdk',
/^@modelcontextprotocol\/sdk\//,
'express',
'pino',
'pino-pretty',
'rebrowser-playwright',
'chromium-bidi/lib/cjs/bidiMapper/BidiMapper',
'chromium-bidi/lib/cjs/cdp/CdpConnection',
],
format: ['esm'],
target: 'node22',
outDir: 'dist',
clean: true,
sourcemap: true,
dts: false,
splitting: false,
shims: false,
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@social/core': path.resolve(__dirname, '../../packages/core/src'),
},
},
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@social/xhs-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"bin": {
"mcp-xhs": "dist/main.js"
},
"scripts": {
"build": "tsup",
"lint": "tsc --noEmit",
"test": "vitest run",
"start": "PORT=${PORT:-9527} COOKIE_DIR=${COOKIE_DIR:-$HOME/.social-mcp-xhs} node dist/main.js",
"dev": "pnpm build && pnpm start"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.0",
"@social/core": "workspace:*",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
import { startServerWithPlugins } from '@social/core/server/bootstrap.js';
import { xiaohongshuPlugin } from './platforms/xiaohongshu/index.js';
startServerWithPlugins([xiaohongshuPlugin]);
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
// ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
import type { FeedDetail, Comment } from './types.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import type { Feed } from './types.js';
// ---------------------------------------------------------------------------
@@ -1,14 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Router } from 'express';
import type { BrowserManager } from '../../browser/manager.js';
import { config } from '../../config/index.js';
import { withErrorHandling, type McpToolResult } from '../../utils/errors.js';
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { withErrorHandling, type McpToolResult } from '@social/core/utils/errors.js';
import { resolveMediaInput, cleanupFile } from '@social/core/utils/downloader.js';
import {
getIdempotencyStore,
computeIdempotencyHash,
} from '../../utils/idempotency.js';
} from '@social/core/utils/idempotency.js';
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js';
@@ -54,7 +54,7 @@ import {
RetryNotificationTasksSchema,
} from './schemas.js';
import type { SearchFilters } from './types.js';
import type { PlatformPlugin } from '../../server/app.js';
import type { PlatformPlugin } from '@social/core/server/app.js';
// ---------------------------------------------------------------------------
// Constants
@@ -336,6 +336,7 @@ function resolveReplyTarget(args: {
export const xiaohongshuPlugin: PlatformPlugin = {
name: PLATFORM,
apiNamespace: 'xhs',
// =========================================================================
// REST API routes (Phase 5)
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
// ---------------------------------------------------------------------------
@@ -1,10 +1,10 @@
import { chromium } from 'rebrowser-playwright';
import type { Page, BrowserContext } from 'rebrowser-playwright';
import type { BrowserManager } from '../../browser/manager.js';
import { config } from '../../config/index.js';
import { logger } from '../../utils/logger.js';
import { cookieStore } from '../../cookie/store.js';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { logger } from '@social/core/utils/logger.js';
import { cookieStore } from '@social/core/cookie/store.js';
import { XHS_SELECTORS } from './selectors.js';
import type { LoginStatus, QRCodeResult } from './types.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
// ---------------------------------------------------------------------------
// Constants
@@ -1,10 +1,10 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { config } from '../../config/index.js';
import { logger } from '../../utils/logger.js';
import { config } from '@social/core/config/index.js';
import { logger } from '@social/core/utils/logger.js';
import { DatabaseSync } from '@social/core/utils/sqlite.js';
import type { CommentNotification } from './types.js';
export type NotificationTaskStatus =
@@ -64,7 +64,7 @@ const DB_FILENAME = 'automation.db';
const log = logger.child({ module: 'xhs-notification-state' });
export class NotificationStateStore {
private readonly db: DatabaseSync;
private readonly db: InstanceType<typeof DatabaseSync>;
private readonly dbPath: string;
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
@@ -1,6 +1,6 @@
import type { BrowserManager } from '../../browser/manager.js';
import { config } from '../../config/index.js';
import { logger } from '../../utils/logger.js';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { logger } from '@social/core/utils/logger.js';
import { getCommentNotifications } from './notification.js';
import { getNotificationStateStore, type NotificationUpsertResult } from './notification-state.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
import type { CommentNotification } from './types.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
// ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
// ---------------------------------------------------------------------------
@@ -1,13 +1,13 @@
import { Router } from 'express';
import { z, ZodError } from 'zod';
import type { BrowserManager } from '../../browser/manager.js';
import { config } from '../../config/index.js';
import { logger } from '../../utils/logger.js';
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js';
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
import { rateLimiter } from '../../server/middleware.js';
import { cookieStore } from '../../cookie/store.js';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { logger } from '@social/core/utils/logger.js';
import { classifyError, sanitizeErrorMessage } from '@social/core/utils/errors.js';
import { resolveMediaInput, cleanupFile } from '@social/core/utils/downloader.js';
import { rateLimiter } from '@social/core/server/middleware.js';
import { cookieStore } from '@social/core/cookie/store.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
import type { Feed, SearchFilters } from './types.js';
@@ -1,6 +1,6 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { logger } from '@social/core/utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
import type { UserProfile, Feed } from './types.js';
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/main.ts'],
noExternal: [/^@social\/core/],
external: [
'@modelcontextprotocol/sdk',
/^@modelcontextprotocol\/sdk\//,
'express',
'pino',
'pino-pretty',
'rebrowser-playwright',
'chromium-bidi/lib/cjs/bidiMapper/BidiMapper',
'chromium-bidi/lib/cjs/cdp/CdpConnection',
],
format: ['esm'],
target: 'node22',
outDir: 'dist',
clean: true,
sourcemap: true,
dts: false,
splitting: false,
shims: false,
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@social/core': path.resolve(__dirname, '../../packages/core/src'),
},
},
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+46 -5
View File
@@ -1,10 +1,12 @@
services:
social-mcp:
mcp-xhs:
build:
context: ..
dockerfile: Dockerfile
args:
APP_NAME: xhs-mcp
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:9527:9527"
shm_size: '1gb'
deploy:
resources:
@@ -22,14 +24,53 @@ services:
tmpfs:
- /tmp:size=512m
volumes:
- cookie-data:/home/appuser/.social-mcp
- cookie-data-xhs:/home/appuser/.social-mcp-xhs
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- PORT=9527
- HEADLESS=true
- COOKIE_DIR=/home/appuser/.social-mcp-xhs
- APP_NAME=xhs-mcp
- ALLOW_REMOTE=yes-i-understand-the-risk
restart: unless-stopped
mcp-xhh:
build:
context: ..
dockerfile: Dockerfile
args:
APP_NAME: xhh-mcp
ports:
- "127.0.0.1:9528:9528"
shm_size: '1gb'
deploy:
resources:
limits:
memory: 2g
cpus: '2.0'
reservations:
memory: 1g
cpus: '1.0'
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:size=512m
volumes:
- cookie-data-xhh:/home/appuser/.social-mcp-xhh
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=9528
- HEADLESS=true
- COOKIE_DIR=/home/appuser/.social-mcp-xhh
- APP_NAME=xhh-mcp
- ALLOW_REMOTE=yes-i-understand-the-risk
restart: unless-stopped
volumes:
cookie-data:
cookie-data-xhs:
cookie-data-xhh:
+38 -8
View File
@@ -1,19 +1,49 @@
services:
app:
build: .
image: social-mcp:latest
container_name: social-mcp
mcp-xhs:
build:
context: .
dockerfile: Dockerfile
args:
APP_NAME: xhs-mcp
image: social-mcp-xhs:latest
container_name: mcp-xhs
ports:
- "3010:3000"
- "9527:9527"
shm_size: '1gb'
volumes:
- /data/social-mcp:/home/appuser/.social-mcp
- /data/mcp-xhs:/home/appuser/.social-mcp-xhs
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- PORT=9527
- HEADLESS=true
- COOKIE_DIR=/home/appuser/.social-mcp
- COOKIE_DIR=/home/appuser/.social-mcp-xhs
- APP_NAME=xhs-mcp
- ALLOW_REMOTE=yes-i-understand-the-risk
restart: unless-stopped
networks:
- nginx
mcp-xhh:
build:
context: .
dockerfile: Dockerfile
args:
APP_NAME: xhh-mcp
image: social-mcp-xhh:latest
container_name: mcp-xhh
ports:
- "9528:9528"
shm_size: '1gb'
volumes:
- /data/mcp-xhh:/home/appuser/.social-mcp-xhh
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=9528
- HEADLESS=true
- COOKIE_DIR=/home/appuser/.social-mcp-xhh
- APP_NAME=xhh-mcp
- ALLOW_REMOTE=yes-i-understand-the-risk
restart: unless-stopped
networks:
+63
View File
@@ -0,0 +1,63 @@
# 小黑盒 MCP 实施计划(Monorepo 执行版)
## 1. 目标
在不改变对外协议的前提下,把项目迁移为可视化 monorepo:
- `apps/xhh-mcp` 承载小黑盒服务
- `apps/xhs-mcp` 承载小红书服务
- `packages/core` 承载共享基础设施
## 2. 小黑盒工具范围
已落地工具:
1. `xhh_check_login`
2. `xhh_get_login_qrcode`
3. `xhh_delete_cookies`
4. `xhh_list_feeds`
5. `xhh_search`
6. `xhh_get_feed_detail`
7. `xhh_get_sub_comments`
8. `xhh_get_user_profile`
9. `xhh_list_my_posts`
10. `xhh_post_comment`
11. `xhh_reply_comment`
12. `xhh_set_like_state`
13. `xhh_set_favorite_state`
统一 MCP 响应:`{ success, data, meta }`
## 3. 代码位置
- 小黑盒实现:`apps/xhh-mcp/src/platforms/xiaoheihe/*`
- 共享依赖:`packages/core/src/*`
- 小红书实现:`apps/xhs-mcp/src/platforms/xiaohongshu/*`
## 4. 构建与运行
- workspace`pnpm-workspace.yaml`
- 构建:`pnpm build`
- 启动 xhh`pnpm start:xhh`
- 启动 xhs`pnpm start:xhs`
## 5. 测试
- `apps/xhh-mcp/test/*`
- `apps/xhs-mcp/test/*`
- `packages/core/test/*`
验收命令:
1. `pnpm lint`
2. `pnpm test`
3. `pnpm build`
## 6. 部署
单 Dockerfile 双目标:
- `APP_NAME=xhs-mcp`
- `APP_NAME=xhh-mcp`
compose 与 Jenkins 已改为双服务部署(9527/9528)。
-4041
View File
File diff suppressed because it is too large Load Diff
+13 -42
View File
@@ -1,50 +1,21 @@
{
"name": "social-mcp",
"name": "social-auto-hub",
"version": "0.1.0",
"description": "Multi-platform social media automation MCP service",
"type": "module",
"main": "dist/index.js",
"bin": {
"social-mcp": "dist/index.js"
},
"private": true,
"description": "Monorepo for XHS/XHH MCP services",
"scripts": {
"build": "tsup",
"build:web": "cd web && pnpm build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
"build:all": "pnpm build && pnpm build:web",
"restart": "pnpm build:all && pkill -f 'node dist/index.js' ; sleep 1 && node dist/index.js &",
"dev": "tsup --watch",
"dev:web": "cd web && pnpm dev",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit"
"build": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run build",
"lint": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run lint",
"test": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run test",
"start": "pnpm start:xhs",
"start:xhs": "pnpm --filter @social/xhs-mcp start",
"start:xhh": "pnpm --filter @social/xhh-mcp start",
"dev:xhs": "pnpm --filter @social/xhs-mcp dev",
"dev:xhh": "pnpm --filter @social/xhh-mcp dev",
"restart:xhs": "pnpm --filter @social/xhs-mcp build && (lsof -ti tcp:9527 | xargs kill >/dev/null 2>&1 || true) && pnpm --filter @social/xhs-mcp start",
"restart:xhh": "pnpm --filter @social/xhh-mcp build && (lsof -ti tcp:9528 | xargs kill >/dev/null 2>&1 || true) && pnpm --filter @social/xhh-mcp start"
},
"keywords": [
"mcp",
"social-media",
"automation",
"playwright",
"xiaohongshu"
],
"author": "",
"license": "ISC",
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.0",
"express": "^4.21.0",
"pino": "^9.0.0",
"rebrowser-playwright": "^1.52.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.52.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@social/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/server/app.js",
"exports": {
"./browser/*": "./dist/browser/*",
"./config/*": "./dist/config/*",
"./cookie/*": "./dist/cookie/*",
"./server/*": "./dist/server/*",
"./utils/*": "./dist/utils/*"
},
"scripts": {
"build": "tsup",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.0",
"express": "^4.21.0",
"pino": "^9.0.0",
"rebrowser-playwright": "^1.52.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"pino-pretty": "^13.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
@@ -112,7 +112,7 @@ export const config: AppConfig = {
browserBin: process.env['BROWSER_BIN'] || undefined,
logLevel: envString('LOG_LEVEL', 'info'),
nodeEnv: envString('NODE_ENV', 'development'),
cookieDir: envString('COOKIE_DIR', path.join(os.homedir(), '.social-mcp')),
cookieDir: envString('COOKIE_DIR', path.join(os.homedir(), '.social-mcp-xhs')),
maxQueueDepth: envInt('MAX_QUEUE_DEPTH', 10),
operationTimeouts,
notificationPollEnabled: envBool('XHS_NOTIFICATION_POLL_ENABLED', true),
@@ -1,10 +1,9 @@
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { config } from '../config/index.js';
@@ -40,6 +39,8 @@ const PACKAGE_VERSION = '0.1.0';
export interface PlatformPlugin {
/** Human-readable name used in logs and health-check output. */
name: string;
/** REST namespace mounted at `/api/{apiNamespace}` when registerRoutes exists. */
apiNamespace?: string;
/** Register MCP tools on the shared McpServer instance. */
registerTools(server: McpServer, browser: BrowserManager): void;
@@ -76,11 +77,17 @@ export class AppServer {
private shuttingDown = false;
private readonly plugins: PlatformPlugin[] = [];
/**
* SSE transports keyed by session ID so that POST /messages can route
* incoming JSON-RPC messages to the correct transport instance.
*/
private readonly transports = new Map<string, SSEServerTransport>();
/** SSE transports keyed by session ID for the deprecated `/sse` + `/messages` flow. */
private readonly sseTransports = new Map<string, SSEServerTransport>();
/** Per-session MCP servers backing active SSE sessions. */
private readonly sseSessionServers = new Map<string, McpServer>();
/** Streamable HTTP transports keyed by MCP session ID. */
private readonly streamableTransports = new Map<string, StreamableHTTPServerTransport>();
/** Per-session MCP servers backing active Streamable HTTP sessions. */
private readonly streamableSessionServers = new Map<string, McpServer>();
// -- Constructor ----------------------------------------------------------
@@ -96,7 +103,8 @@ export class AppServer {
{ name: 'social-mcp', version: PACKAGE_VERSION },
);
// 4. SSE transport endpoints (BEFORE body parsing — MCP SDK reads raw body)
// 4. MCP transport endpoints (BEFORE body parsing — MCP SDK reads raw body)
this.setupStreamableHttpEndpoint();
this.setupSseEndpoints();
// 5. Body parsing for non-MCP routes
@@ -128,8 +136,8 @@ export class AppServer {
if (plugin.registerRoutes) {
const router = express.Router();
plugin.registerRoutes(router, browserManager);
// Mount REST API routes under /api/xhs (for xiaohongshu)
this.app.use(`/api/xhs`, router);
const apiNamespace = plugin.apiNamespace ?? plugin.name;
this.app.use(`/api/${apiNamespace}`, router);
}
this.plugins.push(plugin);
@@ -152,9 +160,6 @@ export class AppServer {
}
}
// Serve the web dashboard (static SPA) in production.
this.setupWebDashboard();
// Re-register the error handler so it sits after any plugin routes.
this.app.use(errorHandler);
@@ -177,7 +182,7 @@ export class AppServer {
* Initiate graceful shutdown:
* 1. Set the shutting-down flag (new requests get 503).
* 2. Shut down every plugin.
* 3. Close all SSE transports and the MCP server.
* 3. Close all active MCP transports and servers.
* 4. Close the HTTP server.
*/
async close(): Promise<void> {
@@ -197,15 +202,41 @@ export class AppServer {
}
}
// Close all SSE transports
for (const [sessionId, transport] of this.transports) {
// Close Streamable HTTP transports first.
for (const [sessionId, transport] of this.streamableTransports) {
try {
await transport.close();
} catch (err: unknown) {
logger.warn({ err, sessionId }, 'Error closing Streamable HTTP transport');
}
}
this.streamableTransports.clear();
for (const [sessionId, sessionServer] of this.streamableSessionServers) {
try {
await sessionServer.close();
} catch (err: unknown) {
logger.warn({ err, sessionId }, 'Error closing Streamable HTTP MCP server');
}
}
this.streamableSessionServers.clear();
// Close deprecated SSE transports.
for (const [sessionId, transport] of this.sseTransports) {
try {
await transport.close();
} catch (err: unknown) {
logger.warn({ err, sessionId }, 'Error closing SSE transport');
}
}
this.transports.clear();
this.sseTransports.clear();
for (const [sessionId, sessionServer] of this.sseSessionServers) {
try {
await sessionServer.close();
} catch (err: unknown) {
logger.warn({ err, sessionId }, 'Error closing SSE MCP server');
}
}
this.sseSessionServers.clear();
// Close the MCP server
try {
@@ -227,6 +258,108 @@ export class AppServer {
logger.info('AppServer shut down complete');
}
// -- Private: Streamable HTTP endpoint ------------------------------------
private setupStreamableHttpEndpoint(): void {
this.app.all('/mcp', (req, res) => {
void this.handleStreamableHttpRequest(req, res);
});
}
private async handleStreamableHttpRequest(
req: express.Request,
res: express.Response,
): Promise<void> {
const headerSessionId = this.getFirstHeaderValue(req.headers['mcp-session-id']);
let transport = headerSessionId
? this.streamableTransports.get(headerSessionId)
: undefined;
if (!transport && headerSessionId) {
if (this.sseTransports.has(headerSessionId)) {
this.sendProtocolMismatch(
res,
'Bad Request: Session exists but uses the deprecated SSE transport protocol',
);
return;
}
res.status(404).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Session not found',
},
id: null,
});
return;
}
let createdTransport = false;
let sessionServer: McpServer | null = null;
if (!transport) {
createdTransport = true;
const newTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
logger.info({ sessionId }, 'Streamable HTTP session initialized');
this.streamableTransports.set(sessionId, newTransport);
if (sessionServer) {
this.streamableSessionServers.set(sessionId, sessionServer);
}
},
});
transport = newTransport;
newTransport.onclose = () => {
const sessionId = newTransport.sessionId;
if (!sessionId) return;
logger.info({ sessionId }, 'Streamable HTTP session closed');
this.cleanupStreamableSession(sessionId);
};
sessionServer = this.createSessionMcpServer();
await sessionServer.connect(newTransport);
}
const activeTransport = transport;
try {
await activeTransport.handleRequest(req, res, req.body);
} catch (err: unknown) {
logger.error(
{ err, sessionId: headerSessionId ?? activeTransport.sessionId },
'Error handling /mcp request',
);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
} finally {
// If no session was initialized (bad request / handshake failure), avoid leaks.
if (createdTransport && !activeTransport.sessionId) {
try {
await activeTransport.close();
} catch (err: unknown) {
logger.warn({ err }, 'Error closing ephemeral Streamable HTTP transport');
}
if (sessionServer) {
try {
await sessionServer.close();
} catch (err: unknown) {
logger.warn({ err }, 'Error closing ephemeral Streamable HTTP MCP server');
}
}
}
}
}
// -- Private: SSE endpoints -----------------------------------------------
private setupSseEndpoints(): void {
@@ -237,28 +370,22 @@ export class AppServer {
const transport = new SSEServerTransport('/messages', res);
const sessionId = transport.sessionId;
this.transports.set(sessionId, transport);
this.sseTransports.set(sessionId, transport);
logger.info({ sessionId }, 'SSE transport created');
const perSessionMcp = this.createSessionMcpServer();
this.sseSessionServers.set(sessionId, perSessionMcp);
// Clean up when the client disconnects.
res.on('close', () => {
logger.info({ sessionId }, 'SSE client disconnected');
this.transports.delete(sessionId);
this.cleanupSseSession(sessionId);
});
// Each SSE connection needs its own McpServer instance because the
// MCP SDK only allows one transport per server at a time.
const perSessionMcp = new McpServer(
{ name: 'social-mcp', version: PACKAGE_VERSION },
);
for (const plugin of this.plugins) {
plugin.registerTools(perSessionMcp, browserManager);
}
void perSessionMcp.connect(transport).catch((err: unknown) => {
logger.error({ err, sessionId }, 'Failed to connect SSE transport to MCP server');
this.transports.delete(sessionId);
this.cleanupSseSession(sessionId);
});
});
@@ -271,9 +398,16 @@ export class AppServer {
return;
}
const transport = this.transports.get(sessionId);
const transport = this.sseTransports.get(sessionId);
if (!transport) {
if (this.streamableTransports.has(sessionId)) {
this.sendProtocolMismatch(
res,
'Bad Request: Session exists but uses Streamable HTTP transport protocol',
);
return;
}
res.status(404).json({ error: 'Unknown or expired session' });
return;
}
@@ -305,46 +439,6 @@ export class AppServer {
});
}
// -- Private: Web Dashboard (SPA static files) ----------------------------
private setupWebDashboard(): void {
// Resolve the web dashboard dist directory relative to this file.
// tsup bundles to dist/index.js, so dist/web/ is a sibling.
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const webDir = path.resolve(thisDir, 'web');
if (!fs.existsSync(webDir)) {
logger.debug({ webDir }, 'Web dashboard dist not found, skipping static mount');
return;
}
logger.info({ webDir }, 'Mounting web dashboard');
// Serve static assets
this.app.use(express.static(webDir, { index: false }));
// SPA fallback: any GET that doesn't match /api, /sse, /messages, /health
// returns index.html so client-side routing works.
this.app.get('*', (req, res, next) => {
// Skip API / MCP / health routes
if (
req.path.startsWith('/api/') ||
req.path.startsWith('/sse') ||
req.path.startsWith('/messages') ||
req.path === '/health'
) {
next();
return;
}
const indexPath = path.join(webDir, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
next();
}
});
}
private async buildHealthResponse(): Promise<Record<string, unknown>> {
// Memory usage
const mem = process.memoryUsage();
@@ -355,8 +449,8 @@ export class AppServer {
external: Math.round(mem.external / 1024 / 1024),
};
// Active SSE sessions
const activeSessions = this.transports.size;
// Active MCP sessions (streamable + deprecated SSE)
const activeSessions = this.streamableTransports.size + this.sseTransports.size;
// Plugin health checks
const pluginHealth: Record<string, { healthy: boolean; message?: string }> = {};
@@ -392,4 +486,50 @@ export class AppServer {
memory: memoryMb,
};
}
private createSessionMcpServer(): McpServer {
const sessionServer = new McpServer(
{ name: 'social-mcp', version: PACKAGE_VERSION },
);
for (const plugin of this.plugins) {
plugin.registerTools(sessionServer, browserManager);
}
return sessionServer;
}
private cleanupSseSession(sessionId: string): void {
this.sseTransports.delete(sessionId);
const sessionServer = this.sseSessionServers.get(sessionId);
this.sseSessionServers.delete(sessionId);
if (!sessionServer) return;
void sessionServer.close().catch((err: unknown) => {
logger.warn({ err, sessionId }, 'Error closing SSE MCP server');
});
}
private cleanupStreamableSession(sessionId: string): void {
this.streamableTransports.delete(sessionId);
const sessionServer = this.streamableSessionServers.get(sessionId);
this.streamableSessionServers.delete(sessionId);
if (!sessionServer) return;
void sessionServer.close().catch((err: unknown) => {
logger.warn({ err, sessionId }, 'Error closing Streamable HTTP MCP server');
});
}
private sendProtocolMismatch(res: express.Response, message: string): void {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message,
},
id: null,
});
}
private getFirstHeaderValue(value: string | string[] | undefined): string | undefined {
if (!value) return undefined;
return Array.isArray(value) ? value[0] : value;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { browserManager } from '../browser/manager.js';
import { AppServer, type PlatformPlugin } from '../server/app.js';
import { logger } from '../utils/logger.js';
export function startServerWithPlugins(plugins: PlatformPlugin[]): void {
const appServer = new AppServer();
for (const plugin of plugins) {
appServer.registerPlugin(plugin);
}
appServer.start().catch((err: unknown) => {
logger.fatal({ err }, 'Failed to start server');
process.exit(1);
});
let shuttingDown = false;
async function gracefulShutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'Received shutdown signal — starting graceful shutdown');
const forceExitTimer = setTimeout(() => {
logger.fatal('Graceful shutdown timed out after 45s — forcing exit');
process.exit(1);
}, 45_000);
if (typeof forceExitTimer === 'object' && 'unref' in forceExitTimer) {
forceExitTimer.unref();
}
try {
logger.info('Shutdown step 1/5: draining browser queues');
await Promise.race([
browserManager.drain(),
new Promise<void>((resolve) => setTimeout(resolve, 30_000).unref()),
]);
logger.info('Shutdown step 2/5: closing browser');
await browserManager.close();
logger.info('Shutdown step 3/5: closing HTTP server');
await appServer.close();
logger.info('Shutdown step 4/5: flushing logger');
logger.flush();
logger.info('Shutdown step 5/5: exiting');
process.exit(0);
} catch (err: unknown) {
logger.fatal({ err }, 'Error during graceful shutdown');
process.exit(1);
}
}
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
process.on('unhandledRejection', (reason: unknown) => {
logger.fatal({ err: reason }, 'Unhandled promise rejection');
void gracefulShutdown('unhandledRejection');
});
process.on('uncaughtException', (err: Error) => {
logger.fatal({ err }, 'Uncaught exception');
void gracefulShutdown('uncaughtException');
});
}
@@ -7,6 +7,7 @@ import { logger } from './logger.js';
export enum ErrorCategory {
TIMEOUT = 'TIMEOUT',
AUTH_REQUIRED = 'AUTH_REQUIRED',
CAPTCHA_REQUIRED = 'CAPTCHA_REQUIRED',
SELECTOR_NOT_FOUND = 'SELECTOR_NOT_FOUND',
NETWORK = 'NETWORK',
PLATFORM_ERROR = 'PLATFORM_ERROR',
@@ -21,6 +22,14 @@ export enum ErrorCategory {
export function classifyError(err: Error): ErrorCategory {
const haystack = `${err.name} ${err.message}`.toLowerCase();
if (
haystack.includes('captcha') ||
haystack.includes('show_captcha') ||
haystack.includes('验证码')
) {
return ErrorCategory.CAPTCHA_REQUIRED;
}
// Selector check BEFORE timeout — Playwright's selector timeout message
// is "Timeout waiting for selector ..." which contains both keywords.
// The more specific match must come first.
@@ -35,8 +44,12 @@ export function classifyError(err: Error): ErrorCategory {
return ErrorCategory.TIMEOUT;
}
if (haystack.includes('net::err_')) {
return ErrorCategory.NETWORK;
if (
haystack.includes('net::err_') ||
haystack.includes('platform_error') ||
haystack.includes('平台错误')
) {
return ErrorCategory.PLATFORM_ERROR;
}
if (haystack.includes('login') || haystack.includes('登录')) {
@@ -1,9 +1,9 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { config } from '../config/index.js';
import { DatabaseSync } from './sqlite.js';
interface IdempotencyRow {
tool_name: string;
@@ -47,7 +47,7 @@ export function computeIdempotencyHash(input: unknown): string {
}
export class IdempotencyStore {
private readonly db: DatabaseSync;
private readonly db: InstanceType<typeof DatabaseSync>;
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
+10
View File
@@ -0,0 +1,10 @@
import { createRequire } from 'node:module';
import type { DatabaseSync as DatabaseSyncType } from 'node:sqlite';
const nodeRequire = createRequire(import.meta.url);
const loaded = nodeRequire('node:sqlite') as {
DatabaseSync: typeof DatabaseSyncType;
};
export const DatabaseSync = loaded.DatabaseSync;
@@ -39,9 +39,14 @@ describe('classifyError', () => {
expect(classifyError(err)).toBe(ErrorCategory.TIMEOUT);
});
it('returns NETWORK when message contains "net::err_"', () => {
it('returns PLATFORM_ERROR when message contains "net::err_"', () => {
const err = new Error('net::err_connection_refused');
expect(classifyError(err)).toBe(ErrorCategory.NETWORK);
expect(classifyError(err)).toBe(ErrorCategory.PLATFORM_ERROR);
});
it('returns CAPTCHA_REQUIRED when message contains captcha keyword', () => {
const err = new Error('show_captcha');
expect(classifyError(err)).toBe(ErrorCategory.CAPTCHA_REQUIRED);
});
it('returns AUTH_REQUIRED when message contains "login"', () => {
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src"]
}
@@ -1,13 +1,14 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
entry: ['src/**/*.ts'],
format: ['esm'],
target: 'node22',
outDir: 'dist',
clean: false,
clean: true,
sourcemap: true,
dts: false,
splitting: false,
shims: false,
});
@@ -6,3 +6,4 @@ export default defineConfig({
environment: 'node',
},
});
+2621
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- 'apps/*'
- 'packages/*'
-89
View File
@@ -1,89 +0,0 @@
import { logger } from './utils/logger.js';
import { browserManager } from './browser/manager.js';
import { AppServer } from './server/app.js';
import { xiaohongshuPlugin } from './platforms/xiaohongshu/index.js';
// ---------------------------------------------------------------------------
// Bootstrap
// ---------------------------------------------------------------------------
const appServer = new AppServer();
// -- Platform plugins -------------------------------------------------------
appServer.registerPlugin(xiaohongshuPlugin);
// -- Start ------------------------------------------------------------------
appServer.start().catch((err: unknown) => {
logger.fatal({ err }, 'Failed to start server');
process.exit(1);
});
// ---------------------------------------------------------------------------
// Graceful shutdown
// ---------------------------------------------------------------------------
let shuttingDown = false;
async function gracefulShutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'Received shutdown signal — starting graceful shutdown');
// Safety net: if graceful shutdown takes too long, force exit.
const forceExitTimer = setTimeout(() => {
logger.fatal('Graceful shutdown timed out after 45s — forcing exit');
process.exit(1);
}, 45_000);
// Prevent the safety-net timer from keeping the process alive on its own.
if (typeof forceExitTimer === 'object' && 'unref' in forceExitTimer) {
forceExitTimer.unref();
}
try {
// Step 1: Drain browser queues so in-flight operations finish (max 30s).
logger.info('Shutdown step 1/5: draining browser queues');
await Promise.race([
browserManager.drain(),
new Promise<void>((resolve) => setTimeout(resolve, 30_000).unref()),
]);
// Step 2: Close the browser and all contexts.
logger.info('Shutdown step 2/5: closing browser');
await browserManager.close();
// Step 3: Close the HTTP server (stop accepting new connections).
logger.info('Shutdown step 3/5: closing HTTP server');
await appServer.close();
// Step 4: Flush structured logs so nothing is lost.
logger.info('Shutdown step 4/5: flushing logger');
logger.flush();
// Step 5: Exit cleanly.
logger.info('Shutdown step 5/5: exiting');
process.exit(0);
} catch (err: unknown) {
logger.fatal({ err }, 'Error during graceful shutdown');
process.exit(1);
}
}
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
// ---------------------------------------------------------------------------
// Global error handlers
// ---------------------------------------------------------------------------
process.on('unhandledRejection', (reason: unknown) => {
logger.fatal({ err: reason }, 'Unhandled promise rejection');
void gracefulShutdown('unhandledRejection');
});
process.on('uncaughtException', (err: Error) => {
logger.fatal({ err }, 'Uncaught exception');
void gracefulShutdown('uncaughtException');
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@social/core/*": ["packages/core/src/*"]
},
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
+6 -21
View File
@@ -1,23 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./apps/xhs-mcp" },
{ "path": "./apps/xhh-mcp" }
]
}
-2808
View File
File diff suppressed because it is too large Load Diff