Compare commits
18 Commits
64dbc45265
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ea8e0ef87 | |||
| d91da9d5fd | |||
| 9d3a07ef97 | |||
| 5d3aa03694 | |||
| d71a80d9b6 | |||
| fab07d4288 | |||
| db3079d078 | |||
| 8a63786064 | |||
| 2cbd6b28b2 | |||
| ed7fbdd5c2 | |||
| 68f42a9fd4 | |||
| ceaad6f15b | |||
| a7672d0430 | |||
| 892e76f7ed | |||
| 5e0543668e | |||
| 237b528f08 | |||
| d2d96de857 | |||
| eddc8718d0 |
+22
-7
@@ -1,14 +1,29 @@
|
|||||||
# Server
|
# ============================================================================
|
||||||
PORT=3000
|
# Common runtime settings (both apps)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
|
|
||||||
# Browser
|
|
||||||
HEADLESS=true
|
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
|
# ALLOW_REMOTE=yes-i-understand-the-risk
|
||||||
|
|
||||||
# Logging
|
# Optional logging
|
||||||
# NODE_ENV=production
|
# NODE_ENV=production
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-xhs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone http://172.17.0.1:3000/kurihada/social-mcp.git .
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build --build-arg APP_NAME=xhs-mcp -t social-mcp-xhs:latest .
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
docker stop mcp-xhs || true
|
||||||
|
docker rm mcp-xhs || true
|
||||||
|
mkdir -p /data/mcp-xhs && chown 1001:1001 /data/mcp-xhs
|
||||||
|
docker run -d \
|
||||||
|
--name mcp-xhs \
|
||||||
|
--network nginx \
|
||||||
|
-p 9527:9527 \
|
||||||
|
--shm-size=1g \
|
||||||
|
-v /data/mcp-xhs:/home/appuser/.social-mcp-xhs \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e HOST=0.0.0.0 \
|
||||||
|
-e PORT=9527 \
|
||||||
|
-e HEADLESS=true \
|
||||||
|
-e COOKIE_DIR=/home/appuser/.social-mcp-xhs \
|
||||||
|
-e APP_NAME=xhs-mcp \
|
||||||
|
-e ALLOW_REMOTE=yes-i-understand-the-risk \
|
||||||
|
--restart unless-stopped \
|
||||||
|
social-mcp-xhs:latest
|
||||||
|
|
||||||
|
build-xhh:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone http://172.17.0.1:3000/kurihada/social-mcp.git .
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build --build-arg APP_NAME=xhh-mcp -t social-mcp-xhh:latest .
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
docker stop mcp-xhh || true
|
||||||
|
docker rm mcp-xhh || true
|
||||||
|
mkdir -p /data/mcp-xhh && chown 1001:1001 /data/mcp-xhh
|
||||||
|
docker run -d \
|
||||||
|
--name mcp-xhh \
|
||||||
|
--network nginx \
|
||||||
|
-p 9528:9528 \
|
||||||
|
--shm-size=1g \
|
||||||
|
-v /data/mcp-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 \
|
||||||
|
social-mcp-xhh:latest
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
**/dist/
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
+34
-41
@@ -4,57 +4,54 @@
|
|||||||
|
|
||||||
FROM node:22-slim AS builder
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
# Proxy for downloading dependencies (passed via --build-arg)
|
ARG APP_NAME=xhs-mcp
|
||||||
ARG HTTP_PROXY
|
ARG HTTP_PROXY
|
||||||
ARG HTTPS_PROXY
|
ARG HTTPS_PROXY
|
||||||
|
|
||||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||||
HTTPS_PROXY=${HTTPS_PROXY}
|
HTTPS_PROXY=${HTTPS_PROXY}
|
||||||
|
|
||||||
# China npm mirror
|
RUN corepack enable
|
||||||
RUN npm config set registry https://registry.npmmirror.com
|
RUN pnpm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package manifests first (layer caching for dependency install)
|
# Copy manifests first for better caching
|
||||||
COPY package.json package-lock.json ./
|
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 pnpm install --frozen-lockfile
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Install Chromium matching rebrowser-playwright version (NOT playwright)
|
# Install Chromium matching rebrowser-playwright version (NOT playwright)
|
||||||
RUN npx rebrowser-playwright install chromium
|
RUN npx rebrowser-playwright install chromium
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY tsconfig.json tsup.config.ts ./
|
COPY packages ./packages
|
||||||
COPY src/ src/
|
COPY apps ./apps
|
||||||
|
|
||||||
# Build the backend
|
# Build shared core first, then selected app
|
||||||
RUN npm run build
|
RUN pnpm --filter @social/core build
|
||||||
|
RUN pnpm --filter @social/${APP_NAME} 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
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 2: Production
|
# Stage 2: Runtime
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# Use China apt mirror
|
ARG APP_NAME=xhs-mcp
|
||||||
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 HTTP_PROXY
|
ARG HTTP_PROXY
|
||||||
ARG HTTPS_PROXY
|
ARG HTTPS_PROXY
|
||||||
|
|
||||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||||
HTTPS_PROXY=${HTTPS_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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libnspr4 \
|
libnspr4 \
|
||||||
@@ -77,41 +74,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fonts-noto-cjk \
|
fonts-noto-cjk \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN groupadd --gid 1001 appuser \
|
RUN groupadd --gid 1001 appuser \
|
||||||
&& useradd --uid 1001 --gid appuser --shell /bin/sh --create-home appuser
|
&& useradd --uid 1001 --gid appuser --shell /bin/sh --create-home appuser
|
||||||
|
|
||||||
WORKDIR /app
|
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/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
|
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-xhs /home/appuser/.social-mcp-xhh \
|
||||||
RUN mkdir -p /home/appuser/.social-mcp \
|
&& chown -R appuser:appuser /home/appuser/.social-mcp-xhs /home/appuser/.social-mcp-xhh
|
||||||
&& chown -R appuser:appuser /home/appuser/.social-mcp
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Environment defaults
|
|
||||||
# Clear proxy env from build stage (must not leak into runtime)
|
|
||||||
ENV HTTP_PROXY= \
|
ENV HTTP_PROXY= \
|
||||||
HTTPS_PROXY= \
|
HTTPS_PROXY= \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=3000 \
|
PORT=9527 \
|
||||||
HEADLESS=true \
|
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
|
ALLOW_REMOTE=yes-i-understand-the-risk
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 9527
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
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
@@ -2,7 +2,10 @@ pipeline {
|
|||||||
agent any
|
agent any
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
APP_NAME = 'social-mcp'
|
IMAGE_XHS = 'social-mcp-xhs'
|
||||||
|
IMAGE_XHH = 'social-mcp-xhh'
|
||||||
|
APP_XHS = 'mcp-xhs'
|
||||||
|
APP_XHH = 'mcp-xhh'
|
||||||
}
|
}
|
||||||
|
|
||||||
triggers {
|
triggers {
|
||||||
@@ -16,33 +19,57 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build Docker Image') {
|
stage('Build Docker Images') {
|
||||||
steps {
|
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') {
|
stage('Deploy') {
|
||||||
steps {
|
steps {
|
||||||
sh """
|
sh """
|
||||||
docker stop ${APP_NAME} || true
|
docker stop ${APP_XHS} || true
|
||||||
docker rm ${APP_NAME} || true
|
docker rm ${APP_XHS} || true
|
||||||
mkdir -p /data/${APP_NAME}
|
docker stop ${APP_XHH} || true
|
||||||
chown 1001:1001 /data/${APP_NAME}
|
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 \
|
docker run -d \
|
||||||
--name ${APP_NAME} \
|
--name ${APP_XHS} \
|
||||||
--network nginx \
|
--network nginx \
|
||||||
-p 3010:3000 \
|
-p 9527:9527 \
|
||||||
--shm-size=1g \
|
--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 NODE_ENV=production \
|
||||||
-e HOST=0.0.0.0 \
|
-e HOST=0.0.0.0 \
|
||||||
-e PORT=3000 \
|
-e PORT=9527 \
|
||||||
-e HEADLESS=true \
|
-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 \
|
-e ALLOW_REMOTE=yes-i-understand-the-risk \
|
||||||
--restart unless-stopped \
|
--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 {
|
post {
|
||||||
success {
|
success {
|
||||||
echo "Deployed ${APP_NAME} build #${BUILD_NUMBER} successfully"
|
echo "Deployed ${APP_XHS} and ${APP_XHH} build #${BUILD_NUMBER} successfully"
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
echo "Build #${BUILD_NUMBER} failed"
|
echo "Build #${BUILD_NUMBER} failed"
|
||||||
|
|||||||
@@ -1,247 +1,127 @@
|
|||||||
# Social MCP
|
# Social Auto Hub (Monorepo)
|
||||||
|
|
||||||
Multi-platform social media automation service that exposes browser-based actions as both MCP (Model Context Protocol) tools and a REST API. Currently supports **Xiaohongshu** (Little Red Book).
|
[中文文档](./README.zh-CN.md)
|
||||||
|
|
||||||
## Features
|
This repository is now a **workspace monorepo** with clear visual separation:
|
||||||
|
|
||||||
- **13 MCP tools** for Xiaohongshu: login management, content browsing, publishing, and interactions
|
- `apps/xhs-mcp`: Xiaohongshu MCP service (`xhs_*` tools)
|
||||||
- **REST API** with Bearer token authentication and rate limiting
|
- `apps/xhh-mcp`: Xiaoheihe MCP service (`xhh_*` tools)
|
||||||
- **Browser automation** via rebrowser-playwright with anti-detection patches
|
- `packages/core`: shared infrastructure (`browser/config/cookie/server/utils`)
|
||||||
- **Cookie persistence** with file-based storage (0600 permissions, atomic writes)
|
|
||||||
- **Security**: DNS rebinding protection, Host header validation, error message sanitization, log redaction
|
## Workspace Layout
|
||||||
- **Docker support** with hardened configuration (non-root user, read-only filesystem, resource limits)
|
|
||||||
- **Plugin architecture** for adding new platforms
|
```text
|
||||||
|
apps/
|
||||||
|
xhs-mcp/
|
||||||
|
xhh-mcp/
|
||||||
|
packages/
|
||||||
|
core/
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js >= 22.0.0
|
- Node.js >= 22
|
||||||
- pnpm
|
- pnpm
|
||||||
|
|
||||||
### Install and Run
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
npx rebrowser-playwright install chromium
|
||||||
# Install Playwright browsers (first time only)
|
|
||||||
npx playwright install chromium
|
|
||||||
|
|
||||||
# Build
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
pnpm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on `http://127.0.0.1:3000` by default. A REST API Bearer token is printed to the console on first startup and saved to `~/.social-mcp/.api-token`.
|
### Build all packages
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Watch mode (rebuilds on file changes)
|
pnpm build
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# Type check without emitting
|
|
||||||
pnpm lint
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
pnpm test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP Integration
|
### Run XHS service
|
||||||
|
|
||||||
### Claude Desktop
|
```bash
|
||||||
|
pnpm start:xhs
|
||||||
|
```
|
||||||
|
|
||||||
Add the following to your Claude Desktop configuration file (`claude_desktop_config.json`):
|
Default runtime values:
|
||||||
|
|
||||||
|
- `PORT=9527`
|
||||||
|
- `COOKIE_DIR=~/.social-mcp-xhs`
|
||||||
|
|
||||||
|
### Run XHH service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:xhh
|
||||||
|
```
|
||||||
|
|
||||||
|
Default runtime values:
|
||||||
|
|
||||||
|
- `PORT=9528`
|
||||||
|
- `COOKIE_DIR=~/.social-mcp-xhh`
|
||||||
|
|
||||||
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"social-mcp": {
|
"mcp-xhs": {
|
||||||
"url": "http://127.0.0.1:3000/sse"
|
"url": "http://127.0.0.1:9527/mcp"
|
||||||
|
},
|
||||||
|
"mcp-xhh": {
|
||||||
|
"url": "http://127.0.0.1:9528/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available MCP Tools
|
## Docker
|
||||||
|
|
||||||
| Tool | Description |
|
Single Dockerfile supports dual app targets via build arg:
|
||||||
|------|-------------|
|
|
||||||
| `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 with content, images, stats, comments |
|
|
||||||
| `xhs_get_user_profile` | Get user profile with bio, stats, recent 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_like` | Like or unlike a note |
|
|
||||||
| `xhs_favorite` | Favorite or unfavorite a note |
|
|
||||||
|
|
||||||
## REST API
|
- `APP_NAME=xhs-mcp`
|
||||||
|
- `APP_NAME=xhh-mcp`
|
||||||
|
|
||||||
All REST endpoints require a `Bearer` token in the `Authorization` header. The token is generated on first startup and printed to the console.
|
Compose files already define both services:
|
||||||
|
|
||||||
```bash
|
- `docker-compose.yml`
|
||||||
# Example: check login status
|
- `deploy/docker-compose.yml`
|
||||||
curl -H "Authorization: Bearer <token>" http://127.0.0.1:3000/api/xhs/login/status
|
|
||||||
|
|
||||||
# Example: search notes
|
## Migration 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:3000/api/xhs/search
|
|
||||||
```
|
|
||||||
|
|
||||||
### Endpoints
|
- Old root runtime entry (`src/index.ts`) is removed.
|
||||||
|
- Service code now lives inside `apps/*`.
|
||||||
| Method | Path | Description | Rate Limit |
|
- Shared runtime logic now lives inside `packages/core`.
|
||||||
|--------|------|-------------|------------|
|
- Lockfile strategy is pnpm-only (`pnpm-lock.yaml`).
|
||||||
| `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/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/user/profile` | Get user profile | 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` | Like/unlike a note | 10/min |
|
|
||||||
| `POST` | `/api/xhs/favorite` | Favorite/unfavorite a note | 10/min |
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
All REST responses follow a consistent JSON format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// Success
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": { ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": {
|
|
||||||
"code": "VALIDATION_ERROR",
|
|
||||||
"message": "keyword: Required"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other Endpoints (no auth required)
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `GET` | `/health` | Health check (memory, uptime, plugin status) |
|
|
||||||
| `GET` | `/sse` | MCP SSE transport |
|
|
||||||
| `POST` | `/messages` | MCP JSON-RPC messages |
|
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
### Using Docker Compose (recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd deploy
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# The API token is printed in the logs on first start
|
|
||||||
docker compose logs social-mcp | grep "Bearer Token"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Docker directly
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -t social-mcp .
|
|
||||||
|
|
||||||
# Run with required settings
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: The `--shm-size=1gb` flag is required. Chromium uses `/dev/shm` for shared memory and the default 64MB causes crashes.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `PORT` | `3000` | 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` | Environment (`production` disables pretty logs) |
|
|
||||||
| `COOKIE_DIR` | `~/.social-mcp` | Directory for cookie and token storage |
|
|
||||||
| `MAX_QUEUE_DEPTH` | `10` | Max pending operations per platform queue |
|
|
||||||
| `ALLOW_REMOTE` | (unset) | Set to `yes-i-understand-the-risk` to allow `HOST=0.0.0.0` |
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
social-mcp/
|
|
||||||
├── package.json
|
|
||||||
├── tsconfig.json
|
|
||||||
├── tsup.config.ts
|
|
||||||
├── Dockerfile
|
|
||||||
├── deploy/
|
|
||||||
│ └── docker-compose.yml
|
|
||||||
├── src/
|
|
||||||
│ ├── index.ts # Entry point: bootstrap, plugin registration, graceful shutdown
|
|
||||||
│ ├── server/
|
|
||||||
│ │ ├── app.ts # AppServer: Express + MCP lifecycle
|
|
||||||
│ │ └── middleware.ts # DNS rebinding guard, bearer auth, rate limiter, error handler
|
|
||||||
│ ├── browser/
|
|
||||||
│ │ └── manager.ts # BrowserManager: browser lifecycle, serial queues, backpressure
|
|
||||||
│ ├── cookie/
|
|
||||||
│ │ └── store.ts # CookieStore: per-platform cookie persistence (0600, atomic writes)
|
|
||||||
│ ├── config/
|
|
||||||
│ │ └── index.ts # Environment-based configuration
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── logger.ts # Pino logger with deep redaction
|
|
||||||
│ │ ├── errors.ts # Error classification, sanitization, MCP error wrapper
|
|
||||||
│ │ └── downloader.ts # Media file download and path validation
|
|
||||||
│ └── platforms/
|
|
||||||
│ └── xiaohongshu/
|
|
||||||
│ ├── index.ts # PlatformPlugin: MCP tool + REST route registration
|
|
||||||
│ ├── routes.ts # REST API route handlers
|
|
||||||
│ ├── schemas.ts # Zod schemas for tool/API parameter validation
|
|
||||||
│ ├── types.ts # Domain types (Feed, Comment, UserProfile, etc.)
|
|
||||||
│ ├── selectors.ts # CSS selector constants
|
|
||||||
│ ├── login.ts # Login management (QR code, status check)
|
|
||||||
│ ├── feeds.ts # Explore page feed extraction
|
|
||||||
│ ├── search.ts # Search with filters
|
|
||||||
│ ├── feed-detail.ts # Note detail + comment loading
|
|
||||||
│ ├── user-profile.ts # User profile extraction
|
|
||||||
│ ├── publish.ts # Image note publishing
|
|
||||||
│ ├── publish-video.ts # Video note publishing
|
|
||||||
│ ├── comment.ts # Comment and reply posting
|
|
||||||
│ └── interaction.ts # Like and favorite toggling
|
|
||||||
└── tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
ISC
|
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Social Auto Hub(Monorepo)
|
||||||
|
|
||||||
|
[English](./README.md)
|
||||||
|
|
||||||
|
仓库已改造为 **workspace monorepo**,结构上清晰拆分为:
|
||||||
|
|
||||||
|
- `apps/xhs-mcp`:小红书 MCP 服务(`xhs_*`)
|
||||||
|
- `apps/xhh-mcp`:小黑盒 MCP 服务(`xhh_*`)
|
||||||
|
- `packages/core`:共享基础设施(`browser/config/cookie/server/utils`)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
xhs-mcp/
|
||||||
|
xhh-mcp/
|
||||||
|
packages/
|
||||||
|
core/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js >= 22
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
npx rebrowser-playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建全部包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动小红书服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:xhs
|
||||||
|
```
|
||||||
|
|
||||||
|
默认运行参数:
|
||||||
|
|
||||||
|
- `PORT=9527`
|
||||||
|
- `COOKIE_DIR=~/.social-mcp-xhs`
|
||||||
|
|
||||||
|
### 启动小黑盒服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:xhh
|
||||||
|
```
|
||||||
|
|
||||||
|
默认运行参数:
|
||||||
|
|
||||||
|
- `PORT=9528`
|
||||||
|
- `COOKIE_DIR=~/.social-mcp-xhh`
|
||||||
|
|
||||||
|
## 根目录兼容脚本
|
||||||
|
|
||||||
|
根脚本保留为转发壳:
|
||||||
|
|
||||||
|
- `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": {
|
||||||
|
"mcp-xhs": {
|
||||||
|
"url": "http://127.0.0.1:9527/mcp"
|
||||||
|
},
|
||||||
|
"mcp-xhh": {
|
||||||
|
"url": "http://127.0.0.1:9528/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
单一 Dockerfile 支持通过 `APP_NAME` 构建双目标:
|
||||||
|
|
||||||
|
- `APP_NAME=xhs-mcp`
|
||||||
|
- `APP_NAME=xhh-mcp`
|
||||||
|
|
||||||
|
`docker-compose.yml` 和 `deploy/docker-compose.yml` 已配置双服务。
|
||||||
|
|
||||||
|
## 迁移说明
|
||||||
|
|
||||||
|
- 旧的根入口(`src/index.ts`)已移除。
|
||||||
|
- 服务代码迁移到 `apps/*`。
|
||||||
|
- 共享基础能力迁移到 `packages/core`。
|
||||||
|
- 锁文件策略改为 pnpm(`pnpm-lock.yaml`)。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { startServerWithPlugins } from '@social/core/server/bootstrap.js';
|
||||||
|
import { xiaohongshuPlugin } from './platforms/xiaohongshu/index.js';
|
||||||
|
|
||||||
|
startServerWithPlugins([xiaohongshuPlugin]);
|
||||||
|
|
||||||
+165
-9
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,7 +38,7 @@ export async function postComment(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
xsecToken: string,
|
xsecToken: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean; comment_id?: string }> {
|
||||||
log.info({ feedId }, 'Posting comment on note');
|
log.info({ feedId }, 'Posting comment on note');
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -91,11 +91,16 @@ export async function postComment(
|
|||||||
|
|
||||||
// Check for the comment text in the page to verify success.
|
// Check for the comment text in the page to verify success.
|
||||||
const pageContent = await page.content();
|
const pageContent = await page.content();
|
||||||
const success = pageContent.includes(content.slice(0, 20));
|
const textHit = pageContent.includes(content.slice(0, 20));
|
||||||
|
const commentId = await extractTopLevelCommentId(page, content);
|
||||||
|
const success = textHit || !!commentId;
|
||||||
|
|
||||||
log.info({ feedId, success }, 'Comment post complete');
|
log.info({ feedId, success, commentId }, 'Comment post complete');
|
||||||
|
|
||||||
return { success };
|
return {
|
||||||
|
success,
|
||||||
|
...(commentId ? { comment_id: commentId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -120,7 +125,7 @@ export async function replyComment(
|
|||||||
content: string,
|
content: string,
|
||||||
commentId?: string,
|
commentId?: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean; reply_id?: string }> {
|
||||||
log.info({ feedId, commentId, userId }, 'Replying to comment on note');
|
log.info({ feedId, commentId, userId }, 'Replying to comment on note');
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -212,11 +217,16 @@ export async function replyComment(
|
|||||||
await page.waitForTimeout(SUBMIT_SETTLE_MS);
|
await page.waitForTimeout(SUBMIT_SETTLE_MS);
|
||||||
|
|
||||||
const pageContent = await page.content();
|
const pageContent = await page.content();
|
||||||
const success = pageContent.includes(content.slice(0, 20));
|
const textHit = pageContent.includes(content.slice(0, 20));
|
||||||
|
const replyId = await extractReplyCommentId(page, content, commentId);
|
||||||
|
const success = textHit || !!replyId;
|
||||||
|
|
||||||
log.info({ feedId, commentId, success }, 'Reply post complete');
|
log.info({ feedId, commentId, success, replyId }, 'Reply post complete');
|
||||||
|
|
||||||
return { success };
|
return {
|
||||||
|
success,
|
||||||
|
...(replyId ? { reply_id: replyId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -320,3 +330,149 @@ async function submitComment(page: Page): Promise<boolean> {
|
|||||||
await submitBtn.click();
|
await submitBtn.click();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeText(input: string): string {
|
||||||
|
return input.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommentElementId(value: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.replace(/^comment-/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchContent(candidate: string, target: string): boolean {
|
||||||
|
const c = normalizeText(candidate);
|
||||||
|
const t = normalizeText(target);
|
||||||
|
if (!c || !t) return false;
|
||||||
|
return c === t || c.includes(t) || t.includes(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractTopLevelCommentId(
|
||||||
|
page: Page,
|
||||||
|
content: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const fromStore = await page.evaluate((targetContent: string) => {
|
||||||
|
const match = (candidate: unknown): boolean => {
|
||||||
|
const c = String(candidate ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
const t = String(targetContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!c || !t) return false;
|
||||||
|
return c === t || c.includes(t) || t.includes(c);
|
||||||
|
};
|
||||||
|
const normalizeId = (id: unknown): string =>
|
||||||
|
String(id ?? '').replace(/^comment-/, '').trim();
|
||||||
|
|
||||||
|
const state = (window as unknown as Record<string, unknown>).__INITIAL_STATE__ as
|
||||||
|
Record<string, unknown> | undefined;
|
||||||
|
const note = state?.note as Record<string, unknown> | undefined;
|
||||||
|
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
if (!map) return null;
|
||||||
|
|
||||||
|
for (const entry of Object.values(map)) {
|
||||||
|
const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | undefined;
|
||||||
|
if (!comments?.list) continue;
|
||||||
|
for (const one of comments.list) {
|
||||||
|
if (match(one.content)) {
|
||||||
|
const id = normalizeId(one.id);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, content).catch(() => null);
|
||||||
|
|
||||||
|
if (typeof fromStore === 'string' && fromStore) {
|
||||||
|
return fromStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await page.$$('.parent-comment .comment-item, [id^="comment-"]');
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const text = (await candidate.$eval('.content', (el) => el.textContent ?? '').catch(() => ''));
|
||||||
|
if (!matchContent(text, content)) continue;
|
||||||
|
|
||||||
|
const id = parseCommentElementId(
|
||||||
|
await candidate.getAttribute('id').catch(() => null),
|
||||||
|
) || parseCommentElementId(
|
||||||
|
await candidate.getAttribute('data-comment-id').catch(() => null),
|
||||||
|
) || parseCommentElementId(
|
||||||
|
await candidate.getAttribute('data-id').catch(() => null),
|
||||||
|
);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractReplyCommentId(
|
||||||
|
page: Page,
|
||||||
|
content: string,
|
||||||
|
parentCommentId?: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const fromStore = await page.evaluate(
|
||||||
|
(args: { targetContent: string; parentCommentId?: string }) => {
|
||||||
|
const match = (candidate: unknown): boolean => {
|
||||||
|
const c = String(candidate ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
const t = String(args.targetContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!c || !t) return false;
|
||||||
|
return c === t || c.includes(t) || t.includes(c);
|
||||||
|
};
|
||||||
|
const normalizeId = (id: unknown): string =>
|
||||||
|
String(id ?? '').replace(/^comment-/, '').trim();
|
||||||
|
|
||||||
|
const state = (window as unknown as Record<string, unknown>).__INITIAL_STATE__ as
|
||||||
|
Record<string, unknown> | undefined;
|
||||||
|
const note = state?.note as Record<string, unknown> | undefined;
|
||||||
|
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
if (!map) return null;
|
||||||
|
|
||||||
|
for (const entry of Object.values(map)) {
|
||||||
|
const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | undefined;
|
||||||
|
if (!comments?.list) continue;
|
||||||
|
for (const parent of comments.list) {
|
||||||
|
const parentId = normalizeId(parent.id);
|
||||||
|
if (args.parentCommentId && parentId !== args.parentCommentId) continue;
|
||||||
|
|
||||||
|
const subs = (parent.subComments ?? parent.sub_comments ?? []) as Array<Record<string, unknown>>;
|
||||||
|
for (const sub of subs) {
|
||||||
|
if (match(sub.content)) {
|
||||||
|
const id = normalizeId(sub.id);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
{ targetContent: content, parentCommentId },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (typeof fromStore === 'string' && fromStore) {
|
||||||
|
return fromStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentCandidates = parentCommentId
|
||||||
|
? await page.$$(
|
||||||
|
`[id="comment-${parentCommentId}"], [data-comment-id="${parentCommentId}"], [data-id="${parentCommentId}"]`,
|
||||||
|
)
|
||||||
|
: await page.$$('.parent-comment');
|
||||||
|
|
||||||
|
for (const parent of parentCandidates) {
|
||||||
|
const replyItems = await parent.$$('.sub-comment-item, [id^="comment-"]');
|
||||||
|
for (const item of replyItems) {
|
||||||
|
const text = await item
|
||||||
|
.$eval('.content', (el) => el.textContent ?? '')
|
||||||
|
.catch(() => item.textContent().catch(() => ''));
|
||||||
|
if (!matchContent(text ?? '', content)) continue;
|
||||||
|
|
||||||
|
const id = parseCommentElementId(
|
||||||
|
await item.getAttribute('id').catch(() => null),
|
||||||
|
) || parseCommentElementId(
|
||||||
|
await item.getAttribute('data-comment-id').catch(() => null),
|
||||||
|
) || parseCommentElementId(
|
||||||
|
await item.getAttribute('data-id').catch(() => null),
|
||||||
|
);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
|
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
|
||||||
import type { FeedDetail, Comment } from './types.js';
|
import type { FeedDetail, Comment } from './types.js';
|
||||||
@@ -297,7 +297,9 @@ export async function getSubComments(
|
|||||||
const parentIndex = await page.evaluate((cid: string) => {
|
const parentIndex = await page.evaluate((cid: string) => {
|
||||||
const parents = document.querySelectorAll('.parent-comment');
|
const parents = document.querySelectorAll('.parent-comment');
|
||||||
for (let i = 0; i < parents.length; i++) {
|
for (let i = 0; i < parents.length; i++) {
|
||||||
const item = parents[i].querySelector('.comment-item');
|
const parent = parents.item(i);
|
||||||
|
if (!parent) continue;
|
||||||
|
const item = parent.querySelector('.comment-item');
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
const id =
|
const id =
|
||||||
item.getAttribute('id')?.replace(/^comment-/, '') ??
|
item.getAttribute('id')?.replace(/^comment-/, '') ??
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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';
|
import type { Feed } from './types.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
File diff suppressed because it is too large
Load Diff
+76
-5
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -48,6 +48,11 @@ async function readState(page: Page, btnSelector: string, activeHref: string): P
|
|||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openFeedOverlay(page: Page, feedId: string, xsecToken: string): Promise<void> {
|
||||||
|
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// toggleLike — pure toggle, clicks the like button once
|
// toggleLike — pure toggle, clicks the like button once
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -59,8 +64,7 @@ export async function toggleLike(
|
|||||||
): Promise<{ success: boolean; liked: boolean }> {
|
): Promise<{ success: boolean; liked: boolean }> {
|
||||||
log.info({ feedId }, 'Toggling like on note');
|
log.info({ feedId }, 'Toggling like on note');
|
||||||
|
|
||||||
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
|
await openFeedOverlay(page, feedId, xsecToken);
|
||||||
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
|
|
||||||
|
|
||||||
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
|
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
|
||||||
if (!clicked) {
|
if (!clicked) {
|
||||||
@@ -86,8 +90,7 @@ export async function toggleFavorite(
|
|||||||
): Promise<{ success: boolean; favorited: boolean }> {
|
): Promise<{ success: boolean; favorited: boolean }> {
|
||||||
log.info({ feedId }, 'Toggling favorite on note');
|
log.info({ feedId }, 'Toggling favorite on note');
|
||||||
|
|
||||||
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
|
await openFeedOverlay(page, feedId, xsecToken);
|
||||||
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
|
|
||||||
|
|
||||||
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
|
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
|
||||||
if (!clicked) {
|
if (!clicked) {
|
||||||
@@ -101,3 +104,71 @@ export async function toggleFavorite(
|
|||||||
log.info({ feedId, favorited }, 'Favorite toggle complete');
|
log.info({ feedId, favorited }, 'Favorite toggle complete');
|
||||||
return { success: true, favorited };
|
return { success: true, favorited };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// setLikeState / setFavoriteState — idempotent state-setting operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function setLikeState(
|
||||||
|
page: Page,
|
||||||
|
feedId: string,
|
||||||
|
xsecToken: string,
|
||||||
|
targetLiked: boolean,
|
||||||
|
): Promise<{ success: boolean; liked: boolean; changed: boolean }> {
|
||||||
|
log.info({ feedId, targetLiked }, 'Setting like state on note');
|
||||||
|
|
||||||
|
await openFeedOverlay(page, feedId, xsecToken);
|
||||||
|
|
||||||
|
const currentLiked = await readState(page, '.engage-bar-style .like-wrapper', '#liked');
|
||||||
|
if (currentLiked === targetLiked) {
|
||||||
|
return { success: true, liked: currentLiked, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
|
||||||
|
if (!clicked) {
|
||||||
|
log.warn('Like button not found in note detail overlay');
|
||||||
|
return { success: false, liked: currentLiked, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(TOGGLE_SETTLE_MS);
|
||||||
|
const liked = await readState(page, '.engage-bar-style .like-wrapper', '#liked');
|
||||||
|
return {
|
||||||
|
success: liked === targetLiked,
|
||||||
|
liked,
|
||||||
|
changed: liked !== currentLiked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFavoriteState(
|
||||||
|
page: Page,
|
||||||
|
feedId: string,
|
||||||
|
xsecToken: string,
|
||||||
|
targetFavorited: boolean,
|
||||||
|
): Promise<{ success: boolean; favorited: boolean; changed: boolean }> {
|
||||||
|
log.info({ feedId, targetFavorited }, 'Setting favorite state on note');
|
||||||
|
|
||||||
|
await openFeedOverlay(page, feedId, xsecToken);
|
||||||
|
|
||||||
|
const currentFavorited = await readState(
|
||||||
|
page,
|
||||||
|
'.engage-bar-style .collect-wrapper',
|
||||||
|
'#collected',
|
||||||
|
);
|
||||||
|
if (currentFavorited === targetFavorited) {
|
||||||
|
return { success: true, favorited: currentFavorited, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
|
||||||
|
if (!clicked) {
|
||||||
|
log.warn('Favorite button not found in note detail overlay');
|
||||||
|
return { success: false, favorited: currentFavorited, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(TOGGLE_SETTLE_MS);
|
||||||
|
const favorited = await readState(page, '.engage-bar-style .collect-wrapper', '#collected');
|
||||||
|
return {
|
||||||
|
success: favorited === targetFavorited,
|
||||||
|
favorited,
|
||||||
|
changed: favorited !== currentFavorited,
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
import { chromium } from 'rebrowser-playwright';
|
import { chromium } from 'rebrowser-playwright';
|
||||||
import type { Page, BrowserContext } from 'rebrowser-playwright';
|
import type { Page, BrowserContext } from 'rebrowser-playwright';
|
||||||
|
|
||||||
import type { BrowserManager } from '../../browser/manager.js';
|
import type { BrowserManager } from '@social/core/browser/manager.js';
|
||||||
import { config } from '../../config/index.js';
|
import { config } from '@social/core/config/index.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '@social/core/utils/logger.js';
|
||||||
import { cookieStore } from '../../cookie/store.js';
|
import { cookieStore } from '@social/core/cookie/store.js';
|
||||||
import { XHS_SELECTORS } from './selectors.js';
|
import { XHS_SELECTORS } from './selectors.js';
|
||||||
import type { LoginStatus, QRCodeResult } from './types.js';
|
import type { LoginStatus, QRCodeResult } from './types.js';
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
import type { Page } from 'rebrowser-playwright';
|
||||||
|
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '@social/core/utils/logger.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
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 =
|
||||||
|
| 'new'
|
||||||
|
| 'pending'
|
||||||
|
| 'replied'
|
||||||
|
| 'failed'
|
||||||
|
| 'ignored';
|
||||||
|
|
||||||
|
interface NotificationRow {
|
||||||
|
fingerprint: string;
|
||||||
|
user_id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
time: string;
|
||||||
|
feed_id: string;
|
||||||
|
xsec_token: string;
|
||||||
|
note_image: string;
|
||||||
|
status: NotificationTaskStatus;
|
||||||
|
first_seen_at: number;
|
||||||
|
last_seen_at: number;
|
||||||
|
retry_count: number;
|
||||||
|
last_attempt_at: number | null;
|
||||||
|
replied_at: number | null;
|
||||||
|
reply_content: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationTask {
|
||||||
|
fingerprint: string;
|
||||||
|
notification: CommentNotification;
|
||||||
|
status: NotificationTaskStatus;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
retryCount: number;
|
||||||
|
lastAttemptAt?: string;
|
||||||
|
repliedAt?: string;
|
||||||
|
replyContent?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationUpsertResult {
|
||||||
|
fetched: number;
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationKeysetCursor {
|
||||||
|
firstSeenAt: number;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM = 'xiaohongshu';
|
||||||
|
const DB_FILENAME = 'automation.db';
|
||||||
|
const log = logger.child({ module: 'xhs-notification-state' });
|
||||||
|
|
||||||
|
export class NotificationStateStore {
|
||||||
|
private readonly db: InstanceType<typeof DatabaseSync>;
|
||||||
|
private readonly dbPath: string;
|
||||||
|
|
||||||
|
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
|
||||||
|
const dir = path.join(baseDir, PLATFORM);
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
this.dbPath = path.join(dir, dbFilename);
|
||||||
|
|
||||||
|
this.db = new DatabaseSync(this.dbPath);
|
||||||
|
this.db.exec('PRAGMA journal_mode = WAL;');
|
||||||
|
this.db.exec('PRAGMA synchronous = NORMAL;');
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_tasks (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
nickname TEXT NOT NULL,
|
||||||
|
avatar TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
feed_id TEXT NOT NULL,
|
||||||
|
xsec_token TEXT NOT NULL,
|
||||||
|
note_image TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
first_seen_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at INTEGER,
|
||||||
|
replied_at INTEGER,
|
||||||
|
reply_content TEXT,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_tasks_status_first_seen
|
||||||
|
ON notification_tasks(status, first_seen_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_tasks_user_content_status
|
||||||
|
ON notification_tasks(user_id, content, status);
|
||||||
|
`);
|
||||||
|
|
||||||
|
log.info({ dbPath: this.dbPath }, 'Notification state store initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFingerprint(notification: CommentNotification): string {
|
||||||
|
const payload = [
|
||||||
|
notification.feedId,
|
||||||
|
notification.userId,
|
||||||
|
notification.content.trim(),
|
||||||
|
notification.time.trim(),
|
||||||
|
notification.type.trim(),
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertNotifications(notifications: CommentNotification[]): NotificationUpsertResult {
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
return { fetched: 0, inserted: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
const selectStmt = this.db.prepare(
|
||||||
|
'SELECT fingerprint FROM notification_tasks WHERE fingerprint = ?',
|
||||||
|
);
|
||||||
|
const insertStmt = this.db.prepare(`
|
||||||
|
INSERT INTO notification_tasks (
|
||||||
|
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||||
|
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?)
|
||||||
|
`);
|
||||||
|
const updateStmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET
|
||||||
|
nickname = ?,
|
||||||
|
avatar = ?,
|
||||||
|
type = ?,
|
||||||
|
time = ?,
|
||||||
|
feed_id = ?,
|
||||||
|
xsec_token = ?,
|
||||||
|
note_image = ?,
|
||||||
|
last_seen_at = ?
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const n of notifications) {
|
||||||
|
const fp = this.buildFingerprint(n);
|
||||||
|
const exists = selectStmt.get(fp) as { fingerprint: string } | undefined;
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
insertStmt.run(
|
||||||
|
fp,
|
||||||
|
n.userId,
|
||||||
|
n.nickname,
|
||||||
|
n.avatar,
|
||||||
|
n.content,
|
||||||
|
n.type,
|
||||||
|
n.time,
|
||||||
|
n.feedId,
|
||||||
|
n.xsecToken,
|
||||||
|
n.noteImage,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
} else {
|
||||||
|
updateStmt.run(
|
||||||
|
n.nickname,
|
||||||
|
n.avatar,
|
||||||
|
n.type,
|
||||||
|
n.time,
|
||||||
|
n.feedId,
|
||||||
|
n.xsecToken,
|
||||||
|
n.noteImage,
|
||||||
|
now,
|
||||||
|
fp,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetched: notifications.length,
|
||||||
|
inserted,
|
||||||
|
updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listByStatuses(
|
||||||
|
statuses: NotificationTaskStatus[],
|
||||||
|
maxCount: number,
|
||||||
|
offset = 0,
|
||||||
|
): NotificationTask[] {
|
||||||
|
if (statuses.length === 0) return [];
|
||||||
|
|
||||||
|
const placeholders = statuses.map(() => '?').join(', ');
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||||
|
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||||
|
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||||
|
FROM notification_tasks
|
||||||
|
WHERE status IN (${placeholders})
|
||||||
|
ORDER BY first_seen_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
OFFSET ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(query);
|
||||||
|
const rows = stmt.all(...statuses, maxCount, offset) as unknown as NotificationRow[];
|
||||||
|
return rows.map((r) => this.rowToTask(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
listByStatusesKeyset(
|
||||||
|
statuses: NotificationTaskStatus[],
|
||||||
|
maxCount: number,
|
||||||
|
cursor?: NotificationKeysetCursor,
|
||||||
|
): { tasks: NotificationTask[]; hasMore: boolean; nextCursor?: NotificationKeysetCursor } {
|
||||||
|
if (statuses.length === 0 || maxCount <= 0) {
|
||||||
|
return { tasks: [], hasMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = statuses.map(() => '?').join(', ');
|
||||||
|
const condition = cursor
|
||||||
|
? `
|
||||||
|
AND (
|
||||||
|
first_seen_at > ?
|
||||||
|
OR (first_seen_at = ? AND fingerprint > ?)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||||
|
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||||
|
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||||
|
FROM notification_tasks
|
||||||
|
WHERE status IN (${placeholders})
|
||||||
|
${condition}
|
||||||
|
ORDER BY first_seen_at ASC, fingerprint ASC
|
||||||
|
LIMIT ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(query);
|
||||||
|
const limitWithSentinel = maxCount + 1;
|
||||||
|
const rows = cursor
|
||||||
|
? stmt.all(
|
||||||
|
...statuses,
|
||||||
|
cursor.firstSeenAt,
|
||||||
|
cursor.firstSeenAt,
|
||||||
|
cursor.fingerprint,
|
||||||
|
limitWithSentinel,
|
||||||
|
) as unknown as NotificationRow[]
|
||||||
|
: stmt.all(...statuses, limitWithSentinel) as unknown as NotificationRow[];
|
||||||
|
|
||||||
|
const hasMore = rows.length > maxCount;
|
||||||
|
const pageRows = hasMore ? rows.slice(0, maxCount) : rows;
|
||||||
|
const tasks = pageRows.map((r) => this.rowToTask(r));
|
||||||
|
|
||||||
|
if (!hasMore || pageRows.length === 0) {
|
||||||
|
return { tasks, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = pageRows[pageRows.length - 1]!;
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
hasMore,
|
||||||
|
nextCursor: {
|
||||||
|
firstSeenAt: last.first_seen_at,
|
||||||
|
fingerprint: last.fingerprint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
countByStatuses(statuses: NotificationTaskStatus[]): number {
|
||||||
|
if (statuses.length === 0) return 0;
|
||||||
|
|
||||||
|
const placeholders = statuses.map(() => '?').join(', ');
|
||||||
|
const query = `
|
||||||
|
SELECT COUNT(1) AS count
|
||||||
|
FROM notification_tasks
|
||||||
|
WHERE status IN (${placeholders})
|
||||||
|
`;
|
||||||
|
const stmt = this.db.prepare(query);
|
||||||
|
const row = stmt.get(...statuses) as { count?: number } | undefined;
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getByFingerprint(fingerprint: string): NotificationTask | null {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT
|
||||||
|
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||||
|
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||||
|
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||||
|
FROM notification_tasks
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = stmt.get(fingerprint) as NotificationRow | undefined;
|
||||||
|
return row ? this.rowToTask(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
findOpenFingerprint(userId: string, content: string): string | null {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT fingerprint
|
||||||
|
FROM notification_tasks
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND content = ?
|
||||||
|
AND status IN ('new', 'failed', 'pending')
|
||||||
|
ORDER BY first_seen_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = stmt.get(userId, content) as { fingerprint: string } | undefined;
|
||||||
|
return row?.fingerprint ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markPending(fingerprint: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET status = 'pending', last_attempt_at = ?, error_message = NULL
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
stmt.run(now, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
markReplied(fingerprint: string, replyContent: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET
|
||||||
|
status = 'replied',
|
||||||
|
replied_at = ?,
|
||||||
|
last_attempt_at = ?,
|
||||||
|
reply_content = ?,
|
||||||
|
error_message = NULL
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
stmt.run(now, now, replyContent, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
markFailed(fingerprint: string, errorMessage: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET
|
||||||
|
status = 'failed',
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
last_attempt_at = ?,
|
||||||
|
error_message = ?
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
stmt.run(now, errorMessage, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
markIgnored(fingerprint: string, reason?: string): void {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET status = 'ignored', error_message = ?
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
stmt.run(reason ?? 'Ignored by operator', fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(
|
||||||
|
fingerprint: string,
|
||||||
|
status: NotificationTaskStatus,
|
||||||
|
note?: string,
|
||||||
|
): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE notification_tasks
|
||||||
|
SET
|
||||||
|
status = ?,
|
||||||
|
last_attempt_at = CASE WHEN ? IN ('pending', 'failed', 'replied') THEN ? ELSE last_attempt_at END,
|
||||||
|
replied_at = CASE WHEN ? = 'replied' THEN ? ELSE replied_at END,
|
||||||
|
error_message = CASE WHEN ? = 'failed' THEN COALESCE(?, 'Marked as failed') WHEN ? = 'ignored' THEN COALESCE(?, 'Ignored by operator') ELSE error_message END,
|
||||||
|
reply_content = CASE WHEN ? = 'replied' THEN COALESCE(?, reply_content) ELSE reply_content END
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
`);
|
||||||
|
stmt.run(
|
||||||
|
status,
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
status,
|
||||||
|
note ?? null,
|
||||||
|
status,
|
||||||
|
note ?? null,
|
||||||
|
status,
|
||||||
|
note ?? null,
|
||||||
|
fingerprint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToTask(row: NotificationRow): NotificationTask {
|
||||||
|
return {
|
||||||
|
fingerprint: row.fingerprint,
|
||||||
|
notification: {
|
||||||
|
userId: row.user_id,
|
||||||
|
nickname: row.nickname,
|
||||||
|
avatar: row.avatar,
|
||||||
|
content: row.content,
|
||||||
|
type: row.type,
|
||||||
|
time: row.time,
|
||||||
|
feedId: row.feed_id,
|
||||||
|
xsecToken: row.xsec_token,
|
||||||
|
noteImage: row.note_image,
|
||||||
|
},
|
||||||
|
status: row.status,
|
||||||
|
firstSeenAt: new Date(row.first_seen_at).toISOString(),
|
||||||
|
lastSeenAt: new Date(row.last_seen_at).toISOString(),
|
||||||
|
retryCount: row.retry_count,
|
||||||
|
...(row.last_attempt_at ? { lastAttemptAt: new Date(row.last_attempt_at).toISOString() } : {}),
|
||||||
|
...(row.replied_at ? { repliedAt: new Date(row.replied_at).toISOString() } : {}),
|
||||||
|
...(row.reply_content ? { replyContent: row.reply_content } : {}),
|
||||||
|
...(row.error_message ? { errorMessage: row.error_message } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let storeSingleton: NotificationStateStore | null = null;
|
||||||
|
|
||||||
|
export function getNotificationStateStore(): NotificationStateStore {
|
||||||
|
if (!storeSingleton) {
|
||||||
|
storeSingleton = new NotificationStateStore();
|
||||||
|
}
|
||||||
|
return storeSingleton;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const PLATFORM = 'xiaohongshu';
|
||||||
|
const log = logger.child({ module: 'xhs-notification-sync' });
|
||||||
|
|
||||||
|
export async function syncCommentNotifications(
|
||||||
|
browser: BrowserManager,
|
||||||
|
maxCount = config.notificationPollMaxCount,
|
||||||
|
): Promise<NotificationUpsertResult> {
|
||||||
|
const timeoutMs =
|
||||||
|
config.operationTimeouts['feed_detail'] ??
|
||||||
|
config.operationTimeouts['default'] ??
|
||||||
|
60_000;
|
||||||
|
|
||||||
|
const notifications = await browser.withPage(
|
||||||
|
PLATFORM,
|
||||||
|
async (page) => getCommentNotifications(page, maxCount),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = getNotificationStateStore().upsertNotifications(notifications);
|
||||||
|
if (result.fetched > 0) {
|
||||||
|
log.info(result, 'Notifications synced to state store');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XhsNotificationPoller {
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private running = false;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
start(browser: BrowserManager): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
if (!config.notificationPollEnabled) {
|
||||||
|
log.info('Notification poller disabled by config');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncCommentNotifications(browser, config.notificationPollMaxCount);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
// Login expiration and empty unread state are expected in daily usage.
|
||||||
|
log.debug({ message }, 'Notification poll tick skipped');
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void tick();
|
||||||
|
}, config.notificationPollIntervalMs);
|
||||||
|
|
||||||
|
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
||||||
|
this.timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
intervalMs: config.notificationPollIntervalMs,
|
||||||
|
maxCount: config.notificationPollMaxCount,
|
||||||
|
},
|
||||||
|
'Notification poller started',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
this.running = false;
|
||||||
|
log.info('Notification poller stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xhsNotificationPoller = new XhsNotificationPoller();
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
import type { CommentNotification } from './types.js';
|
import type { CommentNotification } from './types.js';
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
+291
-19
@@ -1,13 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { z, ZodError } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
|
|
||||||
import type { BrowserManager } from '../../browser/manager.js';
|
import type { BrowserManager } from '@social/core/browser/manager.js';
|
||||||
import { config } from '../../config/index.js';
|
import { config } from '@social/core/config/index.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '@social/core/utils/logger.js';
|
||||||
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js';
|
import { classifyError, sanitizeErrorMessage } from '@social/core/utils/errors.js';
|
||||||
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
|
import { resolveMediaInput, cleanupFile } from '@social/core/utils/downloader.js';
|
||||||
import { rateLimiter } from '../../server/middleware.js';
|
import { rateLimiter } from '@social/core/server/middleware.js';
|
||||||
import { cookieStore } from '../../cookie/store.js';
|
import { cookieStore } from '@social/core/cookie/store.js';
|
||||||
|
|
||||||
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
||||||
import { listFeeds } from './feeds.js';
|
import { listFeeds } from './feeds.js';
|
||||||
@@ -20,6 +20,13 @@ import { listMyNotes } from './my-notes.js';
|
|||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||||
import { getCommentNotifications, replyNotification } from './notification.js';
|
import { getCommentNotifications, replyNotification } from './notification.js';
|
||||||
|
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
|
||||||
|
import {
|
||||||
|
getNotificationStateStore,
|
||||||
|
type NotificationTask,
|
||||||
|
type NotificationTaskStatus,
|
||||||
|
} from './notification-state.js';
|
||||||
|
import { syncCommentNotifications } from './notification-sync.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SearchSchema,
|
SearchSchema,
|
||||||
@@ -34,6 +41,7 @@ import {
|
|||||||
FavoriteSchema,
|
FavoriteSchema,
|
||||||
GetCommentNotificationsSchema,
|
GetCommentNotificationsSchema,
|
||||||
ReplyNotificationSchema,
|
ReplyNotificationSchema,
|
||||||
|
GetUnprocessedNotificationsSchema,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
|
|
||||||
import type { SearchFilters } from './types.js';
|
import type { SearchFilters } from './types.js';
|
||||||
@@ -65,6 +73,7 @@ const SearchBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FeedDetailBodySchema = z.object({
|
const FeedDetailBodySchema = z.object({
|
||||||
|
url: GetFeedDetailSchema.url,
|
||||||
feed_id: GetFeedDetailSchema.feed_id,
|
feed_id: GetFeedDetailSchema.feed_id,
|
||||||
xsec_token: GetFeedDetailSchema.xsec_token,
|
xsec_token: GetFeedDetailSchema.xsec_token,
|
||||||
});
|
});
|
||||||
@@ -74,9 +83,11 @@ const SubCommentsBodySchema = z.object({
|
|||||||
xsec_token: GetSubCommentsSchema.xsec_token,
|
xsec_token: GetSubCommentsSchema.xsec_token,
|
||||||
comment_id: GetSubCommentsSchema.comment_id,
|
comment_id: GetSubCommentsSchema.comment_id,
|
||||||
max_count: GetSubCommentsSchema.max_count,
|
max_count: GetSubCommentsSchema.max_count,
|
||||||
|
cursor: GetSubCommentsSchema.cursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
const UserProfileBodySchema = z.object({
|
const UserProfileBodySchema = z.object({
|
||||||
|
url: GetUserProfileSchema.url,
|
||||||
user_id: GetUserProfileSchema.user_id,
|
user_id: GetUserProfileSchema.user_id,
|
||||||
xsec_token: GetUserProfileSchema.xsec_token,
|
xsec_token: GetUserProfileSchema.xsec_token,
|
||||||
});
|
});
|
||||||
@@ -119,11 +130,19 @@ const GetCommentNotificationsQuerySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ReplyNotificationBodySchema = z.object({
|
const ReplyNotificationBodySchema = z.object({
|
||||||
|
fingerprint: ReplyNotificationSchema.fingerprint,
|
||||||
user_id: ReplyNotificationSchema.user_id,
|
user_id: ReplyNotificationSchema.user_id,
|
||||||
comment_content: ReplyNotificationSchema.comment_content,
|
comment_content: ReplyNotificationSchema.comment_content,
|
||||||
reply_content: ReplyNotificationSchema.reply_content,
|
reply_content: ReplyNotificationSchema.reply_content,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const GetUnprocessedNotificationsQuerySchema = z.object({
|
||||||
|
max_count: GetUnprocessedNotificationsSchema.max_count,
|
||||||
|
cursor: GetUnprocessedNotificationsSchema.cursor,
|
||||||
|
sync: GetUnprocessedNotificationsSchema.sync,
|
||||||
|
statuses: GetUnprocessedNotificationsSchema.statuses,
|
||||||
|
});
|
||||||
|
|
||||||
const LikeBodySchema = z.object({
|
const LikeBodySchema = z.object({
|
||||||
feed_id: LikeSchema.feed_id,
|
feed_id: LikeSchema.feed_id,
|
||||||
xsec_token: LikeSchema.xsec_token,
|
xsec_token: LikeSchema.xsec_token,
|
||||||
@@ -161,6 +180,79 @@ function errorResponse(code: string, message: string): ApiErrorResponse {
|
|||||||
return { success: false, error: { code, message } };
|
return { success: false, error: { code, message } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeNotificationCursor(cursor: { firstSeenAt: number; fingerprint: string }): string {
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
first_seen_at: cursor.firstSeenAt,
|
||||||
|
fingerprint: cursor.fingerprint,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeNotificationCursor(cursor?: string): { firstSeenAt: number; fingerprint: string } | undefined {
|
||||||
|
if (!cursor) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
|
||||||
|
first_seen_at?: unknown;
|
||||||
|
fingerprint?: unknown;
|
||||||
|
};
|
||||||
|
const firstSeenAt = raw.first_seen_at;
|
||||||
|
const fingerprint = raw.fingerprint;
|
||||||
|
if (
|
||||||
|
typeof firstSeenAt !== 'number' ||
|
||||||
|
!Number.isInteger(firstSeenAt) ||
|
||||||
|
firstSeenAt < 0 ||
|
||||||
|
typeof fingerprint !== 'string' ||
|
||||||
|
fingerprint.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid notification cursor payload');
|
||||||
|
}
|
||||||
|
return { firstSeenAt, fingerprint };
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid cursor for notification keyset pagination');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSubCommentCursor(cursor: { createTime: string; replyId: string }): string {
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
create_time: cursor.createTime,
|
||||||
|
reply_id: cursor.replyId,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSubCommentCursor(cursor?: string): { createTime: string; replyId: string } | undefined {
|
||||||
|
if (!cursor) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
|
||||||
|
create_time?: unknown;
|
||||||
|
reply_id?: unknown;
|
||||||
|
};
|
||||||
|
const createTime = raw.create_time;
|
||||||
|
const replyId = raw.reply_id;
|
||||||
|
if (
|
||||||
|
typeof createTime !== 'string' ||
|
||||||
|
createTime.length === 0 ||
|
||||||
|
typeof replyId !== 'string' ||
|
||||||
|
replyId.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid sub-comment cursor payload');
|
||||||
|
}
|
||||||
|
return { createTime, replyId };
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid cursor for sub-comment keyset pagination');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSubCommentKey(a: { createTime: string; id: string }, b: { createTime: string; id: string }): number {
|
||||||
|
const timeCmp = a.createTime.localeCompare(b.createTime);
|
||||||
|
if (timeCmp !== 0) return timeCmp;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Rate limiters
|
// Rate limiters
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -323,6 +415,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = FeedDetailBodySchema.parse(req.body);
|
const body = FeedDetailBodySchema.parse(req.body);
|
||||||
|
const target = resolveFeedTarget({
|
||||||
|
url: body.url,
|
||||||
|
feed_id: body.feed_id,
|
||||||
|
xsec_token: body.xsec_token,
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['feed_detail'] ??
|
config.operationTimeouts['feed_detail'] ??
|
||||||
@@ -332,7 +429,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
const detail = await browser.withPage(
|
const detail = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getFeedDetail(page, body.feed_id, body.xsec_token),
|
getFeedDetail(page, target.feedId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -350,20 +447,66 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = SubCommentsBodySchema.parse(req.body);
|
const body = SubCommentsBodySchema.parse(req.body);
|
||||||
|
const limit = Math.min(200, Math.max(1, body.max_count));
|
||||||
|
const keysetCursor = decodeSubCommentCursor(body.cursor);
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['feed_detail'] ??
|
config.operationTimeouts['feed_detail'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
60_000;
|
60_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
const allLoaded = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getSubComments(page, body.feed_id, body.xsec_token, body.comment_id, body.max_count),
|
getSubComments(page, body.feed_id, body.xsec_token, body.comment_id, 200),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
const sorted = [...allLoaded].sort((a, b) =>
|
||||||
|
compareSubCommentKey(
|
||||||
|
{ createTime: a.createTime, id: a.id },
|
||||||
|
{ createTime: b.createTime, id: b.id },
|
||||||
|
));
|
||||||
|
|
||||||
|
const startIndex = keysetCursor
|
||||||
|
? sorted.findIndex((item) =>
|
||||||
|
compareSubCommentKey(
|
||||||
|
{ createTime: item.createTime, id: item.id },
|
||||||
|
{ createTime: keysetCursor.createTime, id: keysetCursor.replyId },
|
||||||
|
) > 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const start = startIndex < 0 ? sorted.length : startIndex;
|
||||||
|
const pageItems = sorted.slice(start, start + limit);
|
||||||
|
const hasMore = start + pageItems.length < sorted.length;
|
||||||
|
const nextCursor = hasMore && pageItems.length > 0
|
||||||
|
? encodeSubCommentCursor({
|
||||||
|
createTime: pageItems[pageItems.length - 1]!.createTime,
|
||||||
|
replyId: pageItems[pageItems.length - 1]!.id,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
res.json(successResponse({
|
||||||
|
items: pageItems,
|
||||||
|
pagination: {
|
||||||
|
mode: 'keyset',
|
||||||
|
cursor: body.cursor ?? null,
|
||||||
|
max_count: limit,
|
||||||
|
returned: pageItems.length,
|
||||||
|
has_more: hasMore,
|
||||||
|
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
||||||
|
},
|
||||||
|
}) as ApiResponse<{
|
||||||
|
items: typeof pageItems;
|
||||||
|
pagination: {
|
||||||
|
mode: 'keyset';
|
||||||
|
cursor: string | null;
|
||||||
|
max_count: number;
|
||||||
|
returned: number;
|
||||||
|
has_more: boolean;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
}>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
@@ -377,6 +520,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = UserProfileBodySchema.parse(req.body);
|
const body = UserProfileBodySchema.parse(req.body);
|
||||||
|
const target = resolveUserTarget({
|
||||||
|
url: body.url,
|
||||||
|
user_id: body.user_id,
|
||||||
|
xsec_token: body.xsec_token,
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['user_profile'] ??
|
config.operationTimeouts['user_profile'] ??
|
||||||
@@ -386,7 +534,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
const profile = await browser.withPage(
|
const profile = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getUserProfile(page, body.user_id, body.xsec_token),
|
getUserProfile(page, target.userId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -654,6 +802,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getNotificationStateStore().upsertNotifications(notifications);
|
||||||
res.json(successResponse(notifications) as ApiResponse<typeof notifications>);
|
res.json(successResponse(notifications) as ApiResponse<typeof notifications>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
@@ -668,20 +817,143 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = ReplyNotificationBodySchema.parse(req.body);
|
const body = ReplyNotificationBodySchema.parse(req.body);
|
||||||
|
const store = getNotificationStateStore();
|
||||||
|
|
||||||
|
const target = (() => {
|
||||||
|
if (body.fingerprint) {
|
||||||
|
const task = store.getByFingerprint(body.fingerprint);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Notification task not found: ${body.fingerprint}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fingerprint: body.fingerprint,
|
||||||
|
userId: task.notification.userId,
|
||||||
|
commentContent: task.notification.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.user_id || !body.comment_content) {
|
||||||
|
throw new Error('Either fingerprint or both user_id and comment_content are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = store.findOpenFingerprint(body.user_id, body.comment_content) ?? undefined;
|
||||||
|
return {
|
||||||
|
...(fp ? { fingerprint: fp } : {}),
|
||||||
|
userId: body.user_id,
|
||||||
|
commentContent: body.comment_content,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const targetFingerprint = target.fingerprint;
|
||||||
|
if (targetFingerprint) {
|
||||||
|
store.markPending(targetFingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['reply'] ??
|
config.operationTimeouts['reply'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
20_000;
|
20_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
try {
|
||||||
PLATFORM,
|
const result = await browser.withPage(
|
||||||
async (page) =>
|
PLATFORM,
|
||||||
replyNotification(page, body.user_id, body.comment_content, body.reply_content),
|
async (page) =>
|
||||||
timeoutMs,
|
replyNotification(page, target.userId, target.commentContent, body.reply_content),
|
||||||
);
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
res.json(successResponse(result) as ApiResponse<typeof result>);
|
if (targetFingerprint) {
|
||||||
|
if (result.success) {
|
||||||
|
store.markReplied(targetFingerprint, body.reply_content);
|
||||||
|
} else {
|
||||||
|
store.markFailed(
|
||||||
|
targetFingerprint,
|
||||||
|
'Reply action returned success=false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(successResponse({
|
||||||
|
...result,
|
||||||
|
...(targetFingerprint ? { fingerprint: targetFingerprint } : {}),
|
||||||
|
}) as ApiResponse<typeof result & { fingerprint?: string }>);
|
||||||
|
} catch (err) {
|
||||||
|
if (targetFingerprint) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
store.markFailed(targetFingerprint, message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /notifications/unprocessed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/notifications/unprocessed', readRateLimiter, (req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const statusesFromQuery = (() => {
|
||||||
|
const raw = req.query.statuses;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (Array.isArray(raw)) return raw.flatMap((s) => String(s).split(','));
|
||||||
|
return String(raw).split(',');
|
||||||
|
})()
|
||||||
|
?.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
const parsed = GetUnprocessedNotificationsQuerySchema.parse({
|
||||||
|
max_count: req.query.max_count ? Number(req.query.max_count) : undefined,
|
||||||
|
cursor: req.query.cursor ? String(req.query.cursor) : undefined,
|
||||||
|
sync: req.query.sync === undefined
|
||||||
|
? undefined
|
||||||
|
: ['1', 'true', 'yes'].includes(String(req.query.sync).toLowerCase()),
|
||||||
|
statuses: statusesFromQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
let syncResult: { fetched: number; inserted: number; updated: number } | null = null;
|
||||||
|
if (parsed.sync) {
|
||||||
|
syncResult = await syncCommentNotifications(browser, parsed.max_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses: NotificationTaskStatus[] =
|
||||||
|
parsed.statuses && parsed.statuses.length > 0
|
||||||
|
? parsed.statuses
|
||||||
|
: ['new', 'failed'];
|
||||||
|
|
||||||
|
const store = getNotificationStateStore();
|
||||||
|
const keysetCursor = decodeNotificationCursor(parsed.cursor);
|
||||||
|
const pageResult = store.listByStatusesKeyset(statuses, parsed.max_count, keysetCursor);
|
||||||
|
const nextCursor = pageResult.nextCursor
|
||||||
|
? encodeNotificationCursor(pageResult.nextCursor)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
res.json(successResponse({
|
||||||
|
tasks: pageResult.tasks,
|
||||||
|
pagination: {
|
||||||
|
mode: 'keyset',
|
||||||
|
cursor: parsed.cursor ?? null,
|
||||||
|
max_count: parsed.max_count,
|
||||||
|
returned: pageResult.tasks.length,
|
||||||
|
has_more: pageResult.hasMore,
|
||||||
|
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
||||||
|
},
|
||||||
|
...(syncResult ? { synced: syncResult } : {}),
|
||||||
|
}) as ApiResponse<{
|
||||||
|
tasks: NotificationTask[];
|
||||||
|
pagination: {
|
||||||
|
mode: 'keyset';
|
||||||
|
cursor: string | null;
|
||||||
|
max_count: number;
|
||||||
|
returned: number;
|
||||||
|
has_more: boolean;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
synced?: { fetched: number; inserted: number; updated: number };
|
||||||
|
}>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MCP tool parameter schemas for Xiaohongshu tools.
|
||||||
|
//
|
||||||
|
// Phase 2 tools (login) have no parameters — their schemas are empty objects.
|
||||||
|
// Phase 3/4 schemas are defined here so that the full tool surface is
|
||||||
|
// established upfront and types can be inferred with z.infer<>.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// -- Phase 2: Login management (3 tools) -----------------------------------
|
||||||
|
|
||||||
|
/** xhs_check_login — no parameters. */
|
||||||
|
export const CheckLoginSchema = {};
|
||||||
|
|
||||||
|
/** xhs_get_login_qrcode — no parameters. */
|
||||||
|
export const GetLoginQRCodeSchema = {};
|
||||||
|
|
||||||
|
/** xhs_delete_cookies — no parameters. */
|
||||||
|
export const DeleteCookiesSchema = {};
|
||||||
|
|
||||||
|
// -- Phase 3: Content browsing (4 tools) -----------------------------------
|
||||||
|
|
||||||
|
/** xhs_list_feeds — no parameters. */
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_search */
|
||||||
|
export const SearchSchema = {
|
||||||
|
keyword: z.string().describe('Search keyword'),
|
||||||
|
filters: z
|
||||||
|
.object({
|
||||||
|
sort: z
|
||||||
|
.enum(['general', 'time_descending', 'popularity_descending'])
|
||||||
|
.optional()
|
||||||
|
.describe('Sort order'),
|
||||||
|
type: z
|
||||||
|
.enum(['all', 'note', 'video'])
|
||||||
|
.optional()
|
||||||
|
.describe('Content type filter'),
|
||||||
|
time: z
|
||||||
|
.enum(['all', 'day', 'week', 'half_year'])
|
||||||
|
.optional()
|
||||||
|
.describe('Time range filter'),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe('Optional search filters'),
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of search results to return per page (1–200, default 20)'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Pagination cursor returned by previous call'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_get_feed_detail */
|
||||||
|
export const GetFeedDetailSchema = {
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional note URL (auto-parses feed_id and xsec_token)'),
|
||||||
|
feed_id: z.string().optional().describe('Feed (note) ID (required when url not provided)'),
|
||||||
|
xsec_token: z.string().optional().describe('Security token for the feed (required when url not provided)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_get_sub_comments */
|
||||||
|
export const GetSubCommentsSchema = {
|
||||||
|
feed_id: z.string().describe('Feed (note) ID'),
|
||||||
|
xsec_token: z.string().describe('Security token for the feed'),
|
||||||
|
comment_id: z.string().describe('Parent comment ID whose sub-comments to load'),
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of sub-comments to return per page (1–200, default 20)'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_get_user_profile */
|
||||||
|
export const GetUserProfileSchema = {
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional user profile URL (auto-parses user_id and xsec_token)'),
|
||||||
|
user_id: z.string().optional().describe('User ID (required when url not provided)'),
|
||||||
|
xsec_token: z.string().optional().describe('Security token for the user page (required when url not provided)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Phase 4: Content publishing (2 tools) ---------------------------------
|
||||||
|
|
||||||
|
/** xhs_publish_image */
|
||||||
|
export const PublishImageSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for publish request'),
|
||||||
|
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||||
|
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||||
|
images: z
|
||||||
|
.array(z.string())
|
||||||
|
.min(1)
|
||||||
|
.max(18, 'Maximum 18 images per note')
|
||||||
|
.describe('Array of local file paths or HTTP/HTTPS URLs (1–18 images)'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||||
|
schedule_at: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('ISO 8601 datetime for scheduled publishing'),
|
||||||
|
is_original: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe('Mark as original content'),
|
||||||
|
visibility: z
|
||||||
|
.enum(['public', 'private', 'friends'])
|
||||||
|
.optional()
|
||||||
|
.default('public')
|
||||||
|
.describe('Visibility setting'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_publish_video */
|
||||||
|
export const PublishVideoSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for publish request'),
|
||||||
|
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
||||||
|
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
||||||
|
video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
||||||
|
schedule_at: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('ISO 8601 datetime for scheduled publishing'),
|
||||||
|
visibility: z
|
||||||
|
.enum(['public', 'private', 'friends'])
|
||||||
|
.optional()
|
||||||
|
.default('public')
|
||||||
|
.describe('Visibility setting'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Phase 4: Interactions (4 tools) ---------------------------------------
|
||||||
|
|
||||||
|
/** xhs_post_comment */
|
||||||
|
export const PostCommentSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for comment request'),
|
||||||
|
feed_id: z.string().describe('Feed ID to comment on'),
|
||||||
|
xsec_token: z.string().describe('Security token for the feed'),
|
||||||
|
content: z.string().min(1).describe('Comment text'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_reply_comment */
|
||||||
|
export const ReplyCommentSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for reply request'),
|
||||||
|
feed_id: z.string().describe('Feed ID'),
|
||||||
|
xsec_token: z.string().describe('Security token for the feed'),
|
||||||
|
comment_id: z.string().optional().describe('Comment ID to reply to'),
|
||||||
|
user_id: z.string().optional().describe('User ID of the comment author'),
|
||||||
|
content: z.string().min(1).describe('Reply text'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_set_like_state */
|
||||||
|
export const SetLikeStateSchema = {
|
||||||
|
feed_id: z.string().describe('Feed ID to set like state'),
|
||||||
|
xsec_token: z.string().describe('Security token for the feed'),
|
||||||
|
liked: z.boolean().describe('Target like state'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Legacy schema used by REST toggle endpoint. */
|
||||||
|
export const LikeSchema = {
|
||||||
|
feed_id: SetLikeStateSchema.feed_id,
|
||||||
|
xsec_token: SetLikeStateSchema.xsec_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_list_my_notes */
|
||||||
|
export const ListMyNotesSchema = {
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of notes to return per page (1–200, default 20)'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Phase 5: Notifications & automation -----------------------------------
|
||||||
|
|
||||||
|
/** xhs_get_comment_notifications */
|
||||||
|
export const GetCommentNotificationsSchema = {
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of notifications to return (1–50, default 20)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_reply_notification */
|
||||||
|
export const ReplyNotificationSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for notification reply'),
|
||||||
|
fingerprint: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional notification fingerprint from xhs_get_unprocessed_notifications'),
|
||||||
|
user_id: z.string().optional().describe('User ID of the comment author (fallback when fingerprint is absent)'),
|
||||||
|
comment_content: z.string().optional().describe('Original comment content to match the notification (fallback when fingerprint is absent)'),
|
||||||
|
reply_content: z.string().min(1).describe('Reply text to send'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_get_unprocessed_notifications */
|
||||||
|
export const GetUnprocessedNotificationsSchema = {
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of unprocessed notifications to return (1–200, default 20)'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Keyset pagination cursor returned by previous call'),
|
||||||
|
statuses: z
|
||||||
|
.array(z.enum(['new', 'pending', 'failed']))
|
||||||
|
.optional()
|
||||||
|
.describe('Statuses to include. Defaults to ["new", "failed"]'),
|
||||||
|
sync: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
.describe('Whether to sync latest notifications from Xiaohongshu before querying local state'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_mark_notification_task */
|
||||||
|
export const MarkNotificationTaskSchema = {
|
||||||
|
fingerprint: z.string().describe('Notification task fingerprint'),
|
||||||
|
status: z
|
||||||
|
.enum(['new', 'pending', 'ignored', 'replied', 'failed'])
|
||||||
|
.describe('Target status for this notification task'),
|
||||||
|
note: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional note/reason (used as reply_content for replied, or error_message for failed/ignored)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_list_failed_notification_tasks */
|
||||||
|
export const ListFailedNotificationTasksSchema = {
|
||||||
|
max_count: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.default(20)
|
||||||
|
.describe('Maximum number of failed tasks to return (1–200, default 20)'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Pagination cursor returned by previous call'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_retry_notification_task */
|
||||||
|
export const RetryNotificationTaskSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for retry request'),
|
||||||
|
fingerprint: z.string().describe('Notification task fingerprint to retry'),
|
||||||
|
reply_content: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional override reply text. If omitted, uses stored reply_content from previous attempt.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_mark_notification_tasks */
|
||||||
|
export const MarkNotificationTasksSchema = {
|
||||||
|
tasks: z
|
||||||
|
.array(z.object({
|
||||||
|
fingerprint: z.string(),
|
||||||
|
status: z.enum(['new', 'pending', 'ignored', 'replied', 'failed']),
|
||||||
|
note: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.describe('Batch of task status updates (1–100 items)'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_retry_notification_tasks */
|
||||||
|
export const RetryNotificationTasksSchema = {
|
||||||
|
request_id: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional idempotency key for batch retry request'),
|
||||||
|
tasks: z
|
||||||
|
.array(z.object({
|
||||||
|
fingerprint: z.string(),
|
||||||
|
reply_content: z.string().min(1).optional(),
|
||||||
|
}))
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.describe('Batch of failed tasks to retry (1–100 items)'),
|
||||||
|
continue_on_error: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
.describe('Continue processing remaining tasks after one task fails'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** xhs_set_favorite_state */
|
||||||
|
export const SetFavoriteStateSchema = {
|
||||||
|
feed_id: z.string().describe('Feed ID to set favorite state'),
|
||||||
|
xsec_token: z.string().describe('Security token for the feed'),
|
||||||
|
favorited: z.boolean().describe('Target favorite state'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Legacy schema used by REST toggle endpoint. */
|
||||||
|
export const FavoriteSchema = {
|
||||||
|
feed_id: SetFavoriteStateSchema.feed_id,
|
||||||
|
xsec_token: SetFavoriteStateSchema.xsec_token,
|
||||||
|
};
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
|
||||||
import type { Feed, SearchFilters } from './types.js';
|
import type { Feed, SearchFilters } from './types.js';
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
interface FeedTargetInput {
|
||||||
|
feed_id?: string;
|
||||||
|
xsec_token?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserTargetInput {
|
||||||
|
user_id?: string;
|
||||||
|
xsec_token?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedTargetResolved {
|
||||||
|
feedId: string;
|
||||||
|
xsecToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserTargetResolved {
|
||||||
|
userId: string;
|
||||||
|
xsecToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXhsUrl(rawUrl: string): URL {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('url cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return new URL(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('/')) {
|
||||||
|
return new URL(`https://www.xiaohongshu.com${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`https://${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFeedIdFromPath(pathname: string): string | undefined {
|
||||||
|
const patterns = [
|
||||||
|
/\/explore\/([a-zA-Z0-9_-]+)/,
|
||||||
|
/\/discovery\/item\/([a-zA-Z0-9_-]+)/,
|
||||||
|
/\/note\/([a-zA-Z0-9_-]+)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pathname.match(pattern);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUserIdFromPath(pathname: string): string | undefined {
|
||||||
|
const match = pathname.match(/\/user\/profile\/([a-zA-Z0-9_-]+)/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXsecToken(url: URL): string | undefined {
|
||||||
|
return url.searchParams.get('xsec_token') ?? url.searchParams.get('xsecToken') ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFeedTarget(input: FeedTargetInput): FeedTargetResolved {
|
||||||
|
let feedId = input.feed_id?.trim();
|
||||||
|
let xsecToken = input.xsec_token?.trim();
|
||||||
|
|
||||||
|
if (input.url) {
|
||||||
|
const parsed = parseXhsUrl(input.url);
|
||||||
|
feedId = feedId || extractFeedIdFromPath(parsed.pathname);
|
||||||
|
xsecToken = xsecToken || extractXsecToken(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedId || !xsecToken) {
|
||||||
|
throw new Error('xhs_get_feed_detail requires either url with feed_id/xsec_token, or both feed_id and xsec_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { feedId, xsecToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUserTarget(input: UserTargetInput): UserTargetResolved {
|
||||||
|
let userId = input.user_id?.trim();
|
||||||
|
let xsecToken = input.xsec_token?.trim();
|
||||||
|
|
||||||
|
if (input.url) {
|
||||||
|
const parsed = parseXhsUrl(input.url);
|
||||||
|
userId = userId || extractUserIdFromPath(parsed.pathname);
|
||||||
|
xsecToken = xsecToken || extractXsecToken(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId || !xsecToken) {
|
||||||
|
throw new Error('xhs_get_user_profile requires either url with user_id/xsec_token, or both user_id and xsec_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId, xsecToken };
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Page } from 'rebrowser-playwright';
|
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 { XHS_SELECTORS } from './selectors.js';
|
||||||
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
|
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
|
||||||
import type { UserProfile, Feed } from './types.js';
|
import type { UserProfile, Feed } from './types.js';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { NotificationStateStore } from '../src/platforms/xiaohongshu/notification-state.js';
|
||||||
|
import type { CommentNotification } from '../src/platforms/xiaohongshu/types.js';
|
||||||
|
|
||||||
|
function makeNotification(overrides?: Partial<CommentNotification>): CommentNotification {
|
||||||
|
return {
|
||||||
|
userId: 'u1',
|
||||||
|
nickname: 'tester',
|
||||||
|
avatar: 'https://example.com/a.png',
|
||||||
|
content: '你好',
|
||||||
|
type: '评论了你的笔记',
|
||||||
|
time: '1分钟前',
|
||||||
|
feedId: 'feed123',
|
||||||
|
xsecToken: 'token123',
|
||||||
|
noteImage: 'https://example.com/note.png',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('notification-state store', () => {
|
||||||
|
it('upserts notifications and tracks status transitions', async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'social-mcp-notif-'));
|
||||||
|
try {
|
||||||
|
const store = new NotificationStateStore(tempDir, 'test.db');
|
||||||
|
const n1 = makeNotification();
|
||||||
|
|
||||||
|
const first = store.upsertNotifications([n1]);
|
||||||
|
expect(first).toEqual({ fetched: 1, inserted: 1, updated: 0 });
|
||||||
|
|
||||||
|
const second = store.upsertNotifications([n1]);
|
||||||
|
expect(second).toEqual({ fetched: 1, inserted: 0, updated: 1 });
|
||||||
|
|
||||||
|
const openTasks = store.listByStatuses(['new'], 10);
|
||||||
|
expect(openTasks).toHaveLength(1);
|
||||||
|
expect(openTasks[0]?.notification.userId).toBe('u1');
|
||||||
|
|
||||||
|
const fp = store.findOpenFingerprint('u1', '你好');
|
||||||
|
expect(fp).toBeTypeOf('string');
|
||||||
|
|
||||||
|
if (!fp) {
|
||||||
|
throw new Error('fingerprint should not be null');
|
||||||
|
}
|
||||||
|
|
||||||
|
store.markPending(fp);
|
||||||
|
expect(store.listByStatuses(['pending'], 10)).toHaveLength(1);
|
||||||
|
|
||||||
|
store.markFailed(fp, 'network error');
|
||||||
|
const failed = store.listByStatuses(['failed'], 10);
|
||||||
|
expect(failed).toHaveLength(1);
|
||||||
|
expect(failed[0]?.retryCount).toBe(1);
|
||||||
|
expect(failed[0]?.errorMessage).toBe('network error');
|
||||||
|
|
||||||
|
store.markReplied(fp, '收到,感谢反馈');
|
||||||
|
const replied = store.listByStatuses(['replied'], 10);
|
||||||
|
expect(replied).toHaveLength(1);
|
||||||
|
expect(replied[0]?.replyContent).toBe('收到,感谢反馈');
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
social-mcp:
|
mcp-xhs:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
APP_NAME: xhs-mcp
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:9527:9527"
|
||||||
shm_size: '1gb'
|
shm_size: '1gb'
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -22,14 +24,53 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=512m
|
- /tmp:size=512m
|
||||||
volumes:
|
volumes:
|
||||||
- cookie-data:/home/appuser/.social-mcp
|
- cookie-data-xhs:/home/appuser/.social-mcp-xhs
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=9527
|
||||||
- HEADLESS=true
|
- 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
|
- ALLOW_REMOTE=yes-i-understand-the-risk
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
cookie-data:
|
cookie-data-xhs:
|
||||||
|
cookie-data-xhh:
|
||||||
|
|||||||
+38
-8
@@ -1,19 +1,49 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
mcp-xhs:
|
||||||
build: .
|
build:
|
||||||
image: social-mcp:latest
|
context: .
|
||||||
container_name: social-mcp
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
APP_NAME: xhs-mcp
|
||||||
|
image: social-mcp-xhs:latest
|
||||||
|
container_name: mcp-xhs
|
||||||
ports:
|
ports:
|
||||||
- "3010:3000"
|
- "9527:9527"
|
||||||
shm_size: '1gb'
|
shm_size: '1gb'
|
||||||
volumes:
|
volumes:
|
||||||
- /data/social-mcp:/home/appuser/.social-mcp
|
- /data/mcp-xhs:/home/appuser/.social-mcp-xhs
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=9527
|
||||||
- HEADLESS=true
|
- 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
|
- ALLOW_REMOTE=yes-i-understand-the-risk
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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)。
|
||||||
Generated
-4041
File diff suppressed because it is too large
Load Diff
+13
-42
@@ -1,50 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "social-mcp",
|
"name": "social-auto-hub",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Multi-platform social media automation MCP service",
|
"private": true,
|
||||||
"type": "module",
|
"description": "Monorepo for XHS/XHH MCP services",
|
||||||
"main": "dist/index.js",
|
|
||||||
"bin": {
|
|
||||||
"social-mcp": "dist/index.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run build",
|
||||||
"build:web": "cd web && pnpm build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
|
"lint": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run lint",
|
||||||
"build:all": "pnpm build && pnpm build:web",
|
"test": "pnpm -r --filter \"./packages/*\" --filter \"./apps/*\" run test",
|
||||||
"restart": "pnpm build:all && pkill -f 'node dist/index.js' ; sleep 1 && node dist/index.js &",
|
"start": "pnpm start:xhs",
|
||||||
"dev": "tsup --watch",
|
"start:xhs": "pnpm --filter @social/xhs-mcp start",
|
||||||
"dev:web": "cd web && pnpm dev",
|
"start:xhh": "pnpm --filter @social/xhh-mcp start",
|
||||||
"start": "node dist/index.js",
|
"dev:xhs": "pnpm --filter @social/xhs-mcp dev",
|
||||||
"test": "vitest run",
|
"dev:xhh": "pnpm --filter @social/xhh-mcp dev",
|
||||||
"test:watch": "vitest",
|
"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",
|
||||||
"lint": "tsc --noEmit"
|
"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": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -93,6 +93,12 @@ export interface AppConfig {
|
|||||||
maxQueueDepth: number;
|
maxQueueDepth: number;
|
||||||
/** Per-operation-type timeout in ms */
|
/** Per-operation-type timeout in ms */
|
||||||
operationTimeouts: Record<string, number>;
|
operationTimeouts: Record<string, number>;
|
||||||
|
/** Whether the XHS notification poller is enabled */
|
||||||
|
notificationPollEnabled: boolean;
|
||||||
|
/** Poll interval for XHS notifications (ms) */
|
||||||
|
notificationPollIntervalMs: number;
|
||||||
|
/** Max notifications fetched per poll */
|
||||||
|
notificationPollMaxCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -106,7 +112,10 @@ export const config: AppConfig = {
|
|||||||
browserBin: process.env['BROWSER_BIN'] || undefined,
|
browserBin: process.env['BROWSER_BIN'] || undefined,
|
||||||
logLevel: envString('LOG_LEVEL', 'info'),
|
logLevel: envString('LOG_LEVEL', 'info'),
|
||||||
nodeEnv: envString('NODE_ENV', 'development'),
|
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),
|
maxQueueDepth: envInt('MAX_QUEUE_DEPTH', 10),
|
||||||
operationTimeouts,
|
operationTimeouts,
|
||||||
|
notificationPollEnabled: envBool('XHS_NOTIFICATION_POLL_ENABLED', true),
|
||||||
|
notificationPollIntervalMs: Math.max(15, envInt('XHS_NOTIFICATION_POLL_INTERVAL_SEC', 60)) * 1000,
|
||||||
|
notificationPollMaxCount: Math.max(1, Math.min(50, envInt('XHS_NOTIFICATION_POLL_MAX_COUNT', 20))),
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { config } from '../config/index.js';
|
import { config } from '../config/index.js';
|
||||||
@@ -40,6 +39,8 @@ const PACKAGE_VERSION = '0.1.0';
|
|||||||
export interface PlatformPlugin {
|
export interface PlatformPlugin {
|
||||||
/** Human-readable name used in logs and health-check output. */
|
/** Human-readable name used in logs and health-check output. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** REST namespace mounted at `/api/{apiNamespace}` when registerRoutes exists. */
|
||||||
|
apiNamespace?: string;
|
||||||
|
|
||||||
/** Register MCP tools on the shared McpServer instance. */
|
/** Register MCP tools on the shared McpServer instance. */
|
||||||
registerTools(server: McpServer, browser: BrowserManager): void;
|
registerTools(server: McpServer, browser: BrowserManager): void;
|
||||||
@@ -76,11 +77,17 @@ export class AppServer {
|
|||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
private readonly plugins: PlatformPlugin[] = [];
|
private readonly plugins: PlatformPlugin[] = [];
|
||||||
|
|
||||||
/**
|
/** SSE transports keyed by session ID for the deprecated `/sse` + `/messages` flow. */
|
||||||
* SSE transports keyed by session ID so that POST /messages can route
|
private readonly sseTransports = new Map<string, SSEServerTransport>();
|
||||||
* incoming JSON-RPC messages to the correct transport instance.
|
|
||||||
*/
|
/** Per-session MCP servers backing active SSE sessions. */
|
||||||
private readonly transports = new Map<string, SSEServerTransport>();
|
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 ----------------------------------------------------------
|
// -- Constructor ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -96,7 +103,8 @@ export class AppServer {
|
|||||||
{ name: 'social-mcp', version: PACKAGE_VERSION },
|
{ 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();
|
this.setupSseEndpoints();
|
||||||
|
|
||||||
// 5. Body parsing for non-MCP routes
|
// 5. Body parsing for non-MCP routes
|
||||||
@@ -128,8 +136,8 @@ export class AppServer {
|
|||||||
if (plugin.registerRoutes) {
|
if (plugin.registerRoutes) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
plugin.registerRoutes(router, browserManager);
|
plugin.registerRoutes(router, browserManager);
|
||||||
// Mount REST API routes under /api/xhs (for xiaohongshu)
|
const apiNamespace = plugin.apiNamespace ?? plugin.name;
|
||||||
this.app.use(`/api/xhs`, router);
|
this.app.use(`/api/${apiNamespace}`, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.plugins.push(plugin);
|
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.
|
// Re-register the error handler so it sits after any plugin routes.
|
||||||
this.app.use(errorHandler);
|
this.app.use(errorHandler);
|
||||||
|
|
||||||
@@ -177,7 +182,7 @@ export class AppServer {
|
|||||||
* Initiate graceful shutdown:
|
* Initiate graceful shutdown:
|
||||||
* 1. Set the shutting-down flag (new requests get 503).
|
* 1. Set the shutting-down flag (new requests get 503).
|
||||||
* 2. Shut down every plugin.
|
* 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.
|
* 4. Close the HTTP server.
|
||||||
*/
|
*/
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
@@ -197,15 +202,41 @@ export class AppServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all SSE transports
|
// Close Streamable HTTP transports first.
|
||||||
for (const [sessionId, transport] of this.transports) {
|
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 {
|
try {
|
||||||
await transport.close();
|
await transport.close();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.warn({ err, sessionId }, 'Error closing SSE transport');
|
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
|
// Close the MCP server
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +258,108 @@ export class AppServer {
|
|||||||
logger.info('AppServer shut down complete');
|
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: SSE endpoints -----------------------------------------------
|
||||||
|
|
||||||
private setupSseEndpoints(): void {
|
private setupSseEndpoints(): void {
|
||||||
@@ -237,28 +370,22 @@ export class AppServer {
|
|||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
const sessionId = transport.sessionId;
|
const sessionId = transport.sessionId;
|
||||||
|
|
||||||
this.transports.set(sessionId, transport);
|
this.sseTransports.set(sessionId, transport);
|
||||||
|
|
||||||
logger.info({ sessionId }, 'SSE transport created');
|
logger.info({ sessionId }, 'SSE transport created');
|
||||||
|
|
||||||
|
const perSessionMcp = this.createSessionMcpServer();
|
||||||
|
this.sseSessionServers.set(sessionId, perSessionMcp);
|
||||||
|
|
||||||
// Clean up when the client disconnects.
|
// Clean up when the client disconnects.
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
logger.info({ sessionId }, 'SSE client disconnected');
|
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) => {
|
void perSessionMcp.connect(transport).catch((err: unknown) => {
|
||||||
logger.error({ err, sessionId }, 'Failed to connect SSE transport to MCP server');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = this.transports.get(sessionId);
|
const transport = this.sseTransports.get(sessionId);
|
||||||
|
|
||||||
if (!transport) {
|
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' });
|
res.status(404).json({ error: 'Unknown or expired session' });
|
||||||
return;
|
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>> {
|
private async buildHealthResponse(): Promise<Record<string, unknown>> {
|
||||||
// Memory usage
|
// Memory usage
|
||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
@@ -355,8 +449,8 @@ export class AppServer {
|
|||||||
external: Math.round(mem.external / 1024 / 1024),
|
external: Math.round(mem.external / 1024 / 1024),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active SSE sessions
|
// Active MCP sessions (streamable + deprecated SSE)
|
||||||
const activeSessions = this.transports.size;
|
const activeSessions = this.streamableTransports.size + this.sseTransports.size;
|
||||||
|
|
||||||
// Plugin health checks
|
// Plugin health checks
|
||||||
const pluginHealth: Record<string, { healthy: boolean; message?: string }> = {};
|
const pluginHealth: Record<string, { healthy: boolean; message?: string }> = {};
|
||||||
@@ -392,4 +486,50 @@ export class AppServer {
|
|||||||
memory: memoryMb,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
export enum ErrorCategory {
|
||||||
TIMEOUT = 'TIMEOUT',
|
TIMEOUT = 'TIMEOUT',
|
||||||
AUTH_REQUIRED = 'AUTH_REQUIRED',
|
AUTH_REQUIRED = 'AUTH_REQUIRED',
|
||||||
|
CAPTCHA_REQUIRED = 'CAPTCHA_REQUIRED',
|
||||||
SELECTOR_NOT_FOUND = 'SELECTOR_NOT_FOUND',
|
SELECTOR_NOT_FOUND = 'SELECTOR_NOT_FOUND',
|
||||||
NETWORK = 'NETWORK',
|
NETWORK = 'NETWORK',
|
||||||
PLATFORM_ERROR = 'PLATFORM_ERROR',
|
PLATFORM_ERROR = 'PLATFORM_ERROR',
|
||||||
@@ -21,6 +22,14 @@ export enum ErrorCategory {
|
|||||||
export function classifyError(err: Error): ErrorCategory {
|
export function classifyError(err: Error): ErrorCategory {
|
||||||
const haystack = `${err.name} ${err.message}`.toLowerCase();
|
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
|
// Selector check BEFORE timeout — Playwright's selector timeout message
|
||||||
// is "Timeout waiting for selector ..." which contains both keywords.
|
// is "Timeout waiting for selector ..." which contains both keywords.
|
||||||
// The more specific match must come first.
|
// The more specific match must come first.
|
||||||
@@ -35,8 +44,12 @@ export function classifyError(err: Error): ErrorCategory {
|
|||||||
return ErrorCategory.TIMEOUT;
|
return ErrorCategory.TIMEOUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haystack.includes('net::err_')) {
|
if (
|
||||||
return ErrorCategory.NETWORK;
|
haystack.includes('net::err_') ||
|
||||||
|
haystack.includes('platform_error') ||
|
||||||
|
haystack.includes('平台错误')
|
||||||
|
) {
|
||||||
|
return ErrorCategory.PLATFORM_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haystack.includes('login') || haystack.includes('登录')) {
|
if (haystack.includes('login') || haystack.includes('登录')) {
|
||||||
@@ -123,9 +136,12 @@ export async function withErrorHandling(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
tool: toolName,
|
success: false,
|
||||||
error: category,
|
error: {
|
||||||
message: sanitized,
|
tool: toolName,
|
||||||
|
code: category,
|
||||||
|
message: sanitized,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { config } from '../config/index.js';
|
||||||
|
import { DatabaseSync } from './sqlite.js';
|
||||||
|
|
||||||
|
interface IdempotencyRow {
|
||||||
|
tool_name: string;
|
||||||
|
request_id: string;
|
||||||
|
input_hash: string;
|
||||||
|
response_json: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdempotencyRecord {
|
||||||
|
toolName: string;
|
||||||
|
requestId: string;
|
||||||
|
inputHash: string;
|
||||||
|
responseData: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_FILENAME = 'idempotency.db';
|
||||||
|
|
||||||
|
function canonicalize(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(canonicalize);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(input).sort()) {
|
||||||
|
out[key] = canonicalize(input[key]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeIdempotencyHash(input: unknown): string {
|
||||||
|
const canonical = canonicalize(input);
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(canonical))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdempotencyStore {
|
||||||
|
private readonly db: InstanceType<typeof DatabaseSync>;
|
||||||
|
|
||||||
|
constructor(baseDir = config.cookieDir, dbFilename = DB_FILENAME) {
|
||||||
|
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||||
|
const dbPath = path.join(baseDir, dbFilename);
|
||||||
|
this.db = new DatabaseSync(dbPath);
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_requests (
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (tool_name, request_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(toolName: string, requestId: string): IdempotencyRecord | null {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT tool_name, request_id, input_hash, response_json, created_at
|
||||||
|
FROM idempotency_requests
|
||||||
|
WHERE tool_name = ? AND request_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const row = stmt.get(toolName, requestId) as IdempotencyRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolName: row.tool_name,
|
||||||
|
requestId: row.request_id,
|
||||||
|
inputHash: row.input_hash,
|
||||||
|
responseData: JSON.parse(row.response_json),
|
||||||
|
createdAt: new Date(row.created_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
put(
|
||||||
|
toolName: string,
|
||||||
|
requestId: string,
|
||||||
|
inputHash: string,
|
||||||
|
responseData: unknown,
|
||||||
|
): void {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO idempotency_requests (
|
||||||
|
tool_name, request_id, input_hash, response_json, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(
|
||||||
|
toolName,
|
||||||
|
requestId,
|
||||||
|
inputHash,
|
||||||
|
JSON.stringify(responseData),
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton: IdempotencyStore | null = null;
|
||||||
|
|
||||||
|
export function getIdempotencyStore(): IdempotencyStore {
|
||||||
|
if (!singleton) {
|
||||||
|
singleton = new IdempotencyStore();
|
||||||
|
}
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
@@ -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);
|
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');
|
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"', () => {
|
it('returns AUTH_REQUIRED when message contains "login"', () => {
|
||||||
@@ -129,9 +134,10 @@ describe('withErrorHandling', () => {
|
|||||||
expect(result.content).toHaveLength(1);
|
expect(result.content).toHaveLength(1);
|
||||||
|
|
||||||
const payload = JSON.parse(result.content[0]!.text);
|
const payload = JSON.parse(result.content[0]!.text);
|
||||||
expect(payload.tool).toBe('publish_post');
|
expect(payload.success).toBe(false);
|
||||||
expect(payload.error).toBe(ErrorCategory.TIMEOUT);
|
expect(payload.error.tool).toBe('publish_post');
|
||||||
expect(typeof payload.message).toBe('string');
|
expect(payload.error.code).toBe(ErrorCategory.TIMEOUT);
|
||||||
|
expect(typeof payload.error.message).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps non-Error throws into an Error', async () => {
|
it('wraps non-Error throws into an Error', async () => {
|
||||||
@@ -142,8 +148,9 @@ describe('withErrorHandling', () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
|
|
||||||
const payload = JSON.parse(result.content[0]!.text);
|
const payload = JSON.parse(result.content[0]!.text);
|
||||||
expect(payload.tool).toBe('my_tool');
|
expect(payload.success).toBe(false);
|
||||||
expect(payload.error).toBe(ErrorCategory.INTERNAL);
|
expect(payload.error.tool).toBe('my_tool');
|
||||||
expect(payload.message).toContain('raw string error');
|
expect(payload.error.code).toBe(ErrorCategory.INTERNAL);
|
||||||
|
expect(payload.error.message).toContain('raw string error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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';
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/**/*.ts'],
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
clean: false,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
dts: false,
|
dts: false,
|
||||||
splitting: false,
|
splitting: false,
|
||||||
shims: false,
|
shims: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6,3 +6,4 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Generated
+2621
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
|
- 'packages/*'
|
||||||
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
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 } from '../../utils/errors.js';
|
|
||||||
import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
|
|
||||||
import { checkLoginStatus, getLoginQRCode, deleteCookies } 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 { publishImageNote } from './publish.js';
|
|
||||||
import { publishVideoNote } from './publish-video.js';
|
|
||||||
import { listMyNotes } from './my-notes.js';
|
|
||||||
import { postComment, replyComment } from './comment.js';
|
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
|
||||||
import { getCommentNotifications, replyNotification } from './notification.js';
|
|
||||||
import { createXhsRoutes } from './routes.js';
|
|
||||||
import {
|
|
||||||
CheckLoginSchema,
|
|
||||||
GetLoginQRCodeSchema,
|
|
||||||
DeleteCookiesSchema,
|
|
||||||
ListFeedsSchema,
|
|
||||||
SearchSchema,
|
|
||||||
GetFeedDetailSchema,
|
|
||||||
GetSubCommentsSchema,
|
|
||||||
GetUserProfileSchema,
|
|
||||||
PublishImageSchema,
|
|
||||||
PublishVideoSchema,
|
|
||||||
ListMyNotesSchema,
|
|
||||||
PostCommentSchema,
|
|
||||||
ReplyCommentSchema,
|
|
||||||
LikeSchema,
|
|
||||||
FavoriteSchema,
|
|
||||||
GetCommentNotificationsSchema,
|
|
||||||
ReplyNotificationSchema,
|
|
||||||
} from './schemas.js';
|
|
||||||
import type { SearchFilters } from './types.js';
|
|
||||||
import type { PlatformPlugin } from '../../server/app.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const PLATFORM = 'xiaohongshu';
|
|
||||||
|
|
||||||
/** Maximum file size for video uploads (500 MB). */
|
|
||||||
const VIDEO_MAX_SIZE_MB = 500;
|
|
||||||
|
|
||||||
/** Maximum file size for image uploads (20 MB — default in validateMediaPath). */
|
|
||||||
const IMAGE_MAX_SIZE_MB = 20;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PlatformPlugin implementation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const xiaohongshuPlugin: PlatformPlugin = {
|
|
||||||
name: PLATFORM,
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// REST API routes (Phase 5)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
registerRoutes(router: Router, browser: BrowserManager): void {
|
|
||||||
const xhsRouter = createXhsRoutes(browser);
|
|
||||||
router.use('/', xhsRouter);
|
|
||||||
},
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// MCP tools
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
registerTools(server: McpServer, browser: BrowserManager): void {
|
|
||||||
// =====================================================================
|
|
||||||
// Phase 2: Login management (3 tools)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_check_login
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_check_login',
|
|
||||||
'Check Xiaohongshu login status',
|
|
||||||
CheckLoginSchema,
|
|
||||||
async () => {
|
|
||||||
return withErrorHandling('xhs_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 {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(status),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_get_login_qrcode
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_get_login_qrcode',
|
|
||||||
'Get Xiaohongshu login QR code (user scans with phone)',
|
|
||||||
GetLoginQRCodeSchema,
|
|
||||||
async () => {
|
|
||||||
return withErrorHandling('xhs_get_login_qrcode', async () => {
|
|
||||||
const result = await getLoginQRCode(browser);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_delete_cookies
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_delete_cookies',
|
|
||||||
'Delete Xiaohongshu cookies and reset login session',
|
|
||||||
DeleteCookiesSchema,
|
|
||||||
async () => {
|
|
||||||
return withErrorHandling('xhs_delete_cookies', async () => {
|
|
||||||
await deleteCookies(browser);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify({ success: true, message: 'Cookies deleted' }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Phase 3: Content browsing (4 tools)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_list_feeds
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_list_feeds',
|
|
||||||
'Get Xiaohongshu explore page recommended feed list',
|
|
||||||
ListFeedsSchema,
|
|
||||||
async () => {
|
|
||||||
return withErrorHandling('xhs_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,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(feeds),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_search
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_search',
|
|
||||||
'Search Xiaohongshu notes by keyword with optional filters (sort, type, time range)',
|
|
||||||
SearchSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_search', async () => {
|
|
||||||
const timeoutMs = config.operationTimeouts['search'] ?? config.operationTimeouts['default'] ?? 60_000;
|
|
||||||
|
|
||||||
const filters: SearchFilters | undefined = args.filters
|
|
||||||
? {
|
|
||||||
sort: args.filters.sort,
|
|
||||||
type: args.filters.type,
|
|
||||||
time: args.filters.time,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const feeds = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) => searchFeeds(page, args.keyword, filters),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(feeds),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_get_feed_detail
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_get_feed_detail',
|
|
||||||
'Get Xiaohongshu note detail including content, images, stats, and first-screen comments (use xhs_get_sub_comments to load full replies)',
|
|
||||||
GetFeedDetailSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_get_feed_detail', async () => {
|
|
||||||
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
|
|
||||||
|
|
||||||
const detail = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
getFeedDetail(page, args.feed_id, args.xsec_token),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(detail),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_get_sub_comments
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_get_sub_comments',
|
|
||||||
'Load all sub-comments (replies) for a specific parent comment on a Xiaohongshu note',
|
|
||||||
GetSubCommentsSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_get_sub_comments', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['feed_detail'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
60_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, args.max_count),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_get_user_profile
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_get_user_profile',
|
|
||||||
'Get Xiaohongshu user profile information including bio, stats, and recent notes',
|
|
||||||
GetUserProfileSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_get_user_profile', async () => {
|
|
||||||
const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000;
|
|
||||||
|
|
||||||
const profile = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
getUserProfile(page, args.user_id, args.xsec_token),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(profile),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// My published notes (1 tool)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_list_my_notes
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_list_my_notes',
|
|
||||||
'List your published notes on Xiaohongshu from the creator center',
|
|
||||||
ListMyNotesSchema,
|
|
||||||
async () => {
|
|
||||||
return withErrorHandling('xhs_list_my_notes', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['feed_list'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
60_000;
|
|
||||||
|
|
||||||
const notes = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) => listMyNotes(page),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(notes) }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Phase 4: Content publishing (2 tools)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_publish_image
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_publish_image',
|
|
||||||
'Publish an image note on Xiaohongshu. Provide local file paths for images.',
|
|
||||||
PublishImageSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_publish_image', async () => {
|
|
||||||
// Resolve all images (local path or URL download) before acquiring browser.
|
|
||||||
const resolved: Array<{ path: string; temporary: boolean }> = [];
|
|
||||||
for (const img of args.images) {
|
|
||||||
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
|
|
||||||
}
|
|
||||||
const validatedPaths = resolved.map((r) => r.path);
|
|
||||||
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['publish'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
300_000;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
publishImageNote(page, args.title, args.content, validatedPaths, {
|
|
||||||
tags: args.tags,
|
|
||||||
scheduleAt: args.schedule_at,
|
|
||||||
isOriginal: args.is_original,
|
|
||||||
visibility: args.visibility,
|
|
||||||
}),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
for (const r of resolved) {
|
|
||||||
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_publish_video
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_publish_video',
|
|
||||||
'Publish a video note on Xiaohongshu. Provide a local file path for the video.',
|
|
||||||
PublishVideoSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_publish_video', async () => {
|
|
||||||
// Resolve video (local path or URL download) before acquiring browser.
|
|
||||||
const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
|
|
||||||
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['publish'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
300_000;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
publishVideoNote(page, args.title, args.content, resolvedVideo.path, {
|
|
||||||
tags: args.tags,
|
|
||||||
scheduleAt: args.schedule_at,
|
|
||||||
visibility: args.visibility,
|
|
||||||
}),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Phase 4: Interactions (4 tools)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_post_comment
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_post_comment',
|
|
||||||
'Post a comment on a Xiaohongshu note',
|
|
||||||
PostCommentSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_post_comment', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['comment'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
20_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
postComment(page, args.feed_id, args.xsec_token, args.content),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_reply_comment
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_reply_comment',
|
|
||||||
'Reply to a comment on a Xiaohongshu note',
|
|
||||||
ReplyCommentSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_reply_comment', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['reply'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
20_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
replyComment(
|
|
||||||
page,
|
|
||||||
args.feed_id,
|
|
||||||
args.xsec_token,
|
|
||||||
args.content,
|
|
||||||
args.comment_id,
|
|
||||||
args.user_id,
|
|
||||||
),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_like
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_like',
|
|
||||||
'Toggle like on a Xiaohongshu note',
|
|
||||||
LikeSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_like', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['like'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
15_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
toggleLike(page, args.feed_id, args.xsec_token),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_favorite
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_favorite',
|
|
||||||
'Toggle favorite on a Xiaohongshu note',
|
|
||||||
FavoriteSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_favorite', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['favorite'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
15_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
toggleFavorite(page, args.feed_id, args.xsec_token),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: JSON.stringify(result),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Notifications (2 tools)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_get_comment_notifications
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_get_comment_notifications',
|
|
||||||
'Get comment and @ notifications from Xiaohongshu notification page',
|
|
||||||
GetCommentNotificationsSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_get_comment_notifications', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['feed_detail'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
60_000;
|
|
||||||
|
|
||||||
const notifications = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
getCommentNotifications(page, args.max_count),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(notifications) }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// xhs_reply_notification
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
server.tool(
|
|
||||||
'xhs_reply_notification',
|
|
||||||
'Reply to a comment notification inline on the Xiaohongshu notification page',
|
|
||||||
ReplyNotificationSchema,
|
|
||||||
async (args) => {
|
|
||||||
return withErrorHandling('xhs_reply_notification', async () => {
|
|
||||||
const timeoutMs =
|
|
||||||
config.operationTimeouts['reply'] ??
|
|
||||||
config.operationTimeouts['default'] ??
|
|
||||||
20_000;
|
|
||||||
|
|
||||||
const result = await browser.withPage(
|
|
||||||
PLATFORM,
|
|
||||||
async (page) =>
|
|
||||||
replyNotification(page, args.user_id, args.comment_content, args.reply_content),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MCP tool parameter schemas for all 13 Xiaohongshu tools.
|
|
||||||
//
|
|
||||||
// Phase 2 tools (login) have no parameters — their schemas are empty objects.
|
|
||||||
// Phase 3/4 schemas are defined here so that the full tool surface is
|
|
||||||
// established upfront and types can be inferred with z.infer<>.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// -- Phase 2: Login management (3 tools) -----------------------------------
|
|
||||||
|
|
||||||
/** xhs_check_login — no parameters. */
|
|
||||||
export const CheckLoginSchema = {};
|
|
||||||
|
|
||||||
/** xhs_get_login_qrcode — no parameters. */
|
|
||||||
export const GetLoginQRCodeSchema = {};
|
|
||||||
|
|
||||||
/** xhs_delete_cookies — no parameters. */
|
|
||||||
export const DeleteCookiesSchema = {};
|
|
||||||
|
|
||||||
// -- Phase 3: Content browsing (4 tools) -----------------------------------
|
|
||||||
|
|
||||||
/** xhs_list_feeds — no parameters. */
|
|
||||||
export const ListFeedsSchema = {};
|
|
||||||
|
|
||||||
/** xhs_search */
|
|
||||||
export const SearchSchema = {
|
|
||||||
keyword: z.string().describe('Search keyword'),
|
|
||||||
filters: z
|
|
||||||
.object({
|
|
||||||
sort: z
|
|
||||||
.enum(['general', 'time_descending', 'popularity_descending'])
|
|
||||||
.optional()
|
|
||||||
.describe('Sort order'),
|
|
||||||
type: z
|
|
||||||
.enum(['all', 'note', 'video'])
|
|
||||||
.optional()
|
|
||||||
.describe('Content type filter'),
|
|
||||||
time: z
|
|
||||||
.enum(['all', 'day', 'week', 'half_year'])
|
|
||||||
.optional()
|
|
||||||
.describe('Time range filter'),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.describe('Optional search filters'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_get_feed_detail */
|
|
||||||
export const GetFeedDetailSchema = {
|
|
||||||
feed_id: z.string().describe('Feed (note) ID'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_get_sub_comments */
|
|
||||||
export const GetSubCommentsSchema = {
|
|
||||||
feed_id: z.string().describe('Feed (note) ID'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
comment_id: z.string().describe('Parent comment ID whose sub-comments to load'),
|
|
||||||
max_count: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(200)
|
|
||||||
.optional()
|
|
||||||
.default(20)
|
|
||||||
.describe('Maximum number of sub-comments to load (1–200, default 20)'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_get_user_profile */
|
|
||||||
export const GetUserProfileSchema = {
|
|
||||||
user_id: z.string().describe('User ID'),
|
|
||||||
xsec_token: z.string().describe('Security token for the user page'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Phase 4: Content publishing (2 tools) ---------------------------------
|
|
||||||
|
|
||||||
/** xhs_publish_image */
|
|
||||||
export const PublishImageSchema = {
|
|
||||||
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
|
||||||
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
|
||||||
images: z
|
|
||||||
.array(z.string())
|
|
||||||
.min(1)
|
|
||||||
.max(18, 'Maximum 18 images per note')
|
|
||||||
.describe('Array of local file paths or HTTP/HTTPS URLs (1–18 images)'),
|
|
||||||
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
|
||||||
schedule_at: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('ISO 8601 datetime for scheduled publishing'),
|
|
||||||
is_original: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(false)
|
|
||||||
.describe('Mark as original content'),
|
|
||||||
visibility: z
|
|
||||||
.enum(['public', 'private', 'friends'])
|
|
||||||
.optional()
|
|
||||||
.default('public')
|
|
||||||
.describe('Visibility setting'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_publish_video */
|
|
||||||
export const PublishVideoSchema = {
|
|
||||||
title: z.string().min(1).max(20, 'Title must be ≤ 20 characters').describe('Note title (max 20 chars)'),
|
|
||||||
content: z.string().max(1000, 'Content must be ≤ 1000 characters').describe('Note body text (max 1000 chars)'),
|
|
||||||
video: z.string().describe('Local file path or HTTP/HTTPS URL for the video'),
|
|
||||||
tags: z.array(z.string()).optional().describe('Hashtags to attach'),
|
|
||||||
schedule_at: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('ISO 8601 datetime for scheduled publishing'),
|
|
||||||
visibility: z
|
|
||||||
.enum(['public', 'private', 'friends'])
|
|
||||||
.optional()
|
|
||||||
.default('public')
|
|
||||||
.describe('Visibility setting'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Phase 4: Interactions (4 tools) ---------------------------------------
|
|
||||||
|
|
||||||
/** xhs_post_comment */
|
|
||||||
export const PostCommentSchema = {
|
|
||||||
feed_id: z.string().describe('Feed ID to comment on'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
content: z.string().min(1).describe('Comment text'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_reply_comment */
|
|
||||||
export const ReplyCommentSchema = {
|
|
||||||
feed_id: z.string().describe('Feed ID'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
comment_id: z.string().optional().describe('Comment ID to reply to'),
|
|
||||||
user_id: z.string().optional().describe('User ID of the comment author'),
|
|
||||||
content: z.string().min(1).describe('Reply text'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_like */
|
|
||||||
export const LikeSchema = {
|
|
||||||
feed_id: z.string().describe('Feed ID to toggle like'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_list_my_notes — no parameters. */
|
|
||||||
export const ListMyNotesSchema = {};
|
|
||||||
|
|
||||||
// -- Phase 5: Notifications (2 tools) --------------------------------------
|
|
||||||
|
|
||||||
/** xhs_get_comment_notifications */
|
|
||||||
export const GetCommentNotificationsSchema = {
|
|
||||||
max_count: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(50)
|
|
||||||
.optional()
|
|
||||||
.default(20)
|
|
||||||
.describe('Maximum number of notifications to return (1–50, default 20)'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_reply_notification */
|
|
||||||
export const ReplyNotificationSchema = {
|
|
||||||
user_id: z.string().describe('User ID of the comment author (from notification)'),
|
|
||||||
comment_content: z.string().describe('Original comment content to match the notification'),
|
|
||||||
reply_content: z.string().min(1).describe('Reply text to send'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** xhs_favorite */
|
|
||||||
export const FavoriteSchema = {
|
|
||||||
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
|
||||||
};
|
|
||||||
@@ -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
@@ -1,23 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"target": "ES2022",
|
"references": [
|
||||||
"module": "ESNext",
|
{ "path": "./packages/core" },
|
||||||
"moduleResolution": "bundler",
|
{ "path": "./apps/xhs-mcp" },
|
||||||
"lib": ["ES2022"],
|
{ "path": "./apps/xhh-mcp" }
|
||||||
"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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-2808
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user