mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
@@ -2,6 +2,8 @@
|
||||
|
||||
PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和自定义插件搜索。系统设计以性能和可扩展性为核心,支持并发搜索、结果智能排序和网盘类型分类。
|
||||
|
||||
[//]: # (MCP服务文档: [MCP-SERVICE.md](docs/MCP-SERVICE.md))
|
||||
|
||||
|
||||
## 特性([详见系统设计文档](docs/%E7%B3%BB%E7%BB%9F%E5%BC%80%E5%8F%91%E8%AE%BE%E8%AE%A1%E6%96%87%E6%A1%A3.md))
|
||||
|
||||
@@ -11,6 +13,10 @@ PanSou是一个高性能的网盘资源搜索API服务,支持TG搜索和自定
|
||||
- **异步插件系统**:支持通过插件扩展搜索来源,支持"尽快响应,持续处理"的异步搜索模式,解决了某些搜索源响应时间长的问题。详情参考[**插件开发指南**](docs/插件开发指南.md)
|
||||
- **二级缓存**:分片内存+分片磁盘缓存机制,大幅提升重复查询速度和并发性能
|
||||
|
||||
## MCP 服务
|
||||
|
||||
PanSou 还提供了一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的服务,可以将搜索功能集成到 Claude Desktop 等支持 MCP 的应用中。详情请参阅 [MCP 服务文档](docs/MCP-SERVICE.md)。
|
||||
|
||||
## 支持的网盘类型
|
||||
|
||||
百度网盘 (`baidu`)、阿里云盘 (`aliyun`)、夸克网盘 (`quark`)、天翼云盘 (`tianyi`)、UC网盘 (`uc`)、移动云盘 (`mobile`)、115网盘 (`115`)、PikPak (`pikpak`)、迅雷网盘 (`xunlei`)、123网盘 (`123`)、磁力链接 (`magnet`)、电驴链接 (`ed2k`)、其他 (`others`)
|
||||
|
||||
394
docs/MCP-SERVICE.md
Normal file
394
docs/MCP-SERVICE.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# PanSou MCP 服务文档
|
||||
|
||||
## 功能介绍
|
||||
|
||||
PanSou MCP 服务是一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io) 的工具服务,它将 PanSou 网盘搜索 API 的功能封装为可在支持 MCP 的客户端(如 Claude Desktop)中直接调用的工具。
|
||||
|
||||
通过 PanSou MCP 服务,可以直接在 Claude 等 AI 助手中搜索网盘资源,极大地提升了获取网盘资源的便捷性。
|
||||
|
||||
### 核心功能
|
||||
|
||||
1. **搜索网盘资源 (`search_netdisk`)**:
|
||||
- 支持通过关键词搜索网盘资源。
|
||||
- 可指定搜索来源:Telegram 频道、插件或两者结合。
|
||||
- 可过滤结果,仅显示特定类型的网盘链接(如百度网盘、阿里云盘、夸克网盘等)。
|
||||
- 支持强制刷新缓存以获取最新数据。
|
||||
- 支持传递扩展参数给后端插件。
|
||||
- 结果可按详细信息或按网盘类型分组展示。
|
||||
|
||||
2. **检查服务健康状态 (`check_service_health`)**:
|
||||
- 检查所连接的 PanSou 后端服务是否正常运行。
|
||||
- 获取后端服务的配置信息,如可用的 Telegram 频道列表和插件列表。
|
||||
|
||||
3. **启动后端服务 (`start_backend`)**:
|
||||
- 自动启动本地的 PanSou Go 后端服务(如果尚未运行)。
|
||||
- 等待服务完全启动并可用后才开始处理其他请求。
|
||||
|
||||
4. **获取静态资源信息 (`pansou://` URI scheme)**:
|
||||
- 提供可用插件列表、可用频道列表和支持的网盘类型列表等静态信息资源。
|
||||
|
||||
### 架构与部署方式
|
||||
|
||||
PanSou MCP 服务设计为与 PanSou Go 后端服务分离,通过 HTTP API 进行通信。支持以下部署方式:
|
||||
|
||||
- **npx 部署 (TypeScript)**: MCP 服务基于 TypeScript 开发,编译后可以通过 `npx` 命令直接运行。它会自动连接到指定的 PanSou 后端服务。
|
||||
- **Docker 部署**: 使用 Docker 容器运行 PanSou 后端服务,MCP 服务通过 HTTP API 连接到容器化的后端。
|
||||
|
||||
---
|
||||
|
||||
## 安装与部署
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **Node.js**: 确保您的系统已安装 Node.js (版本 >= 18.0.0)。您可以通过在终端运行 `node -v` 来检查版本。
|
||||
2. **Go**: 确保您的系统已安装 Go (版本 >= 1.18)。您可以通过在终端运行 `go version` 来检查版本。
|
||||
|
||||
### 部署步骤
|
||||
|
||||
PanSou 后端服务通常运行在 `http://localhost:8888` (默认地址)。支持以下两种部署方式:
|
||||
|
||||
## 方式一:源码部署
|
||||
|
||||
后端服务和 MCP 服务都需要从源码构建。
|
||||
|
||||
#### 1. 构建并启动 PanSou 后端服务 (Go)
|
||||
|
||||
- 确保系统已安装 Go 1.25.0 或更高版本。
|
||||
- 克隆或确保已有 PanSou Go 项目源码。
|
||||
- 在项目根目录下,打开终端并执行以下命令进行构建:
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell/CMD)
|
||||
go build -o pansou.exe .
|
||||
```
|
||||
|
||||
- 构建完成后,运行生成的可执行文件以启动后端服务:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\pansou.exe
|
||||
```
|
||||
|
||||
服务默认将在 `http://localhost:8888` 启动。
|
||||
|
||||
#### 2. 构建 PanSou MCP 服务 (TypeScript)
|
||||
|
||||
- 确保系统已安装 Node.js (版本 >= 18.0.0)。
|
||||
- 在 `typescript` 目录下,打开终端并执行以下命令来安装依赖并构建项目:
|
||||
|
||||
```bash
|
||||
# 安装 Node.js 依赖
|
||||
npm install
|
||||
|
||||
# 构建 TypeScript 项目
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建完成后,编译后的 JavaScript 文件将位于 `typescript/dist` 目录下。
|
||||
|
||||
- 确保服务成功启动。您可以通过在终端中访问 `http://localhost:8888/api/health` 来检查,应该能看到类似以下的 JSON 响应,表明服务正常:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"plugins_enabled": true,
|
||||
"channels_count": 1,
|
||||
"channels": ["tgsearchers3"],
|
||||
"plugin_count": 16,
|
||||
"plugins": ["pansearch", "panta", ...]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 运行 MCP 服务
|
||||
|
||||
构建完成后,可以通过以下方式之一运行 MCP 服务:
|
||||
|
||||
- **在MCP调用时自动启动** (自动启动):
|
||||
直接浏览后续MCP配置的内容,配置好MCP后,调用时会自动启动后端服务器。
|
||||
|
||||
- **使用 `node` 直接运行** (手动启动):
|
||||
在 PanSou 项目根目录下(包含 `typescript` 文件夹),运行:
|
||||
|
||||
```bash
|
||||
# Windows (CMD/PowerShell)
|
||||
node .\\typescript\\dist\\index.js
|
||||
|
||||
```
|
||||
|
||||
服务启动后,将默认尝试连接到 `http://localhost:8888` 的 PanSou 后端服务。
|
||||
|
||||
如果想要后端服务运行在不同的地址或端口上,需要通过环境变量指定:
|
||||
|
||||
```bash
|
||||
# Windows (CMD)
|
||||
set PANSOU_SERVER_URL=http://your-backend-address:port
|
||||
node .\\typescript\\dist\\index.js
|
||||
|
||||
# Windows (PowerShell)
|
||||
export PANSOU_SERVER_URL=http://your-backend-address:port
|
||||
node ./typescript/dist/index.js
|
||||
```
|
||||
|
||||
#### 4. 示例配置 Cherry Studio(版本1.5.7)
|
||||
|
||||
要在 Cherry Studio 中使用 PanSou MCP 服务,需要将其添加到 Cherry Studio MCP 的配置文件中。
|
||||
|
||||
- 找到 设置中的MCP。
|
||||
- 选择 `添加服务器` 、 `从JSON导入` 。
|
||||
- 加入服务配置(可以直接复制项目根目录下的 `mcp-config.json` 内容):
|
||||
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pansou": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\full\\path\\to\\your\\project\\typescript\\dist\\index.js"
|
||||
],
|
||||
"env": {
|
||||
"PANSOU_SERVER_URL": "http://localhost:8888",
|
||||
"REQUEST_TIMEOUT": "30",
|
||||
"MAX_RESULTS": "50",
|
||||
"DEFAULT_CLOUD_TYPES": "baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others",
|
||||
"AUTO_START_BACKEND": "true",
|
||||
"DOCKER_MODE": "false",
|
||||
"BACKEND_SHUTDOWN_DELAY": "5000",
|
||||
"BACKEND_STARTUP_TIMEOUT": "30000",
|
||||
"IDLE_TIMEOUT": "300000",
|
||||
"ENABLE_IDLE_SHUTDOWN": "true",
|
||||
"PROJECT_ROOT_PATH": "C:\\full\\path\\to\\your\\project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 请将 `C:\\full\\path\\to\\your\\project` 替换为您项目实际的完整路径
|
||||
- 如需强制指定部署模式,可修改 `DOCKER_MODE` 和 `AUTO_START_BACKEND` 参数
|
||||
|
||||
#### 5. 启动 MCP 服务,并在对话界面启用,开始尝试搜索
|
||||
|
||||
<img width="495" height="649" alt="image" src="https://github.com/user-attachments/assets/b8c72649-03e8-4f52-86ba-aa16c4cc3b7e" />
|
||||
|
||||
---
|
||||
|
||||
## 方式二:Docker 部署
|
||||
|
||||
Docker 部署方式更加简单,无需手动构建 Go 后端服务,直接使用预构建的 Docker 镜像。
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **Docker**: 确保您的系统已安装 Docker 和 Docker Compose。
|
||||
2. **Node.js**: 确保您的系统已安装 Node.js (版本 >= 18.0.0),用于运行 MCP 服务。
|
||||
|
||||
### 部署步骤
|
||||
|
||||
#### 1. 启动 Docker 后端服务
|
||||
|
||||
在 PanSou 项目根目录下,使用 Docker Compose 启动后端服务:
|
||||
|
||||
```bash
|
||||
# 启动 Docker 容器
|
||||
docker-compose up -d
|
||||
|
||||
# 检查容器状态
|
||||
docker ps
|
||||
|
||||
# 验证服务是否正常运行
|
||||
curl http://localhost:8888/api/health
|
||||
```
|
||||
|
||||
成功启动后,您应该能看到类似以下的 JSON 响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"plugins_enabled": true,
|
||||
"channels_count": 5,
|
||||
"channels": ["tgsearchers3", "SharePanBaidu", "yunpanxunlei", "tianyifc", "BaiduCloudDisk"],
|
||||
"plugin_count": 16,
|
||||
"plugins": ["pansearch", "panta", ...]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 构建 MCP 服务
|
||||
|
||||
在 `typescript` 目录下构建 MCP 服务:
|
||||
|
||||
```bash
|
||||
cd typescript
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 3. 配置 MCP 服务
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pansou": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\full\\path\\to\\your\\project\\typescript\\dist\\index.js"
|
||||
],
|
||||
"env": {
|
||||
"PANSOU_SERVER_URL": "http://localhost:8888",
|
||||
"REQUEST_TIMEOUT": "30",
|
||||
"MAX_RESULTS": "50",
|
||||
"DEFAULT_CLOUD_TYPES": "baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others",
|
||||
"AUTO_START_BACKEND": "true",
|
||||
"DOCKER_MODE": "true",
|
||||
"BACKEND_SHUTDOWN_DELAY": "5000",
|
||||
"BACKEND_STARTUP_TIMEOUT": "30000",
|
||||
"IDLE_TIMEOUT": "300000",
|
||||
"ENABLE_IDLE_SHUTDOWN": "true",
|
||||
"PROJECT_ROOT_PATH": "C:\\full\\path\\to\\your\\project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**智能检测机制**:
|
||||
|
||||
当 `DOCKER_MODE` 设置为 `"false"` 或未设置时,MCP 服务将自动检测部署模式:
|
||||
|
||||
1. **Docker 容器检测**:检查是否有运行中的 Docker 容器(名称包含 "pansou")
|
||||
2. **源码部署检测**:检查是否存在 Go 可执行文件(pansou.exe/main.exe)
|
||||
3. **服务运行检测**:检查后端服务是否已在运行
|
||||
|
||||
**配置模式**:
|
||||
|
||||
- **自动模式**(推荐):使用默认配置,让服务自动检测部署方式
|
||||
- **强制 Docker 模式**:设置 `"DOCKER_MODE": "true"`
|
||||
- **强制源码模式**:设置 `"DOCKER_MODE": "false"` 且 `"AUTO_START_BACKEND": "true"`
|
||||
- **仅连接模式**:设置 `"AUTO_START_BACKEND": "false"`(适用于手动启动的后端)
|
||||
|
||||
#### 4. 在 MCP 客户端中配置
|
||||
|
||||
将上述配置添加到您的 MCP 客户端(如 Cherry Studio)中,记得将路径替换为实际路径。
|
||||
|
||||
#### 5. 测试 Docker 部署
|
||||
|
||||
您可以手动测试 MCP 服务是否能正确连接到 Docker 后端:
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:DOCKER_MODE='true'
|
||||
$env:PANSOU_SERVER_URL='http://localhost:8888'
|
||||
node .\typescript\dist\index.js
|
||||
```
|
||||
|
||||
成功启动后,您应该看到类似以下的输出:
|
||||
|
||||
```
|
||||
⏱️ 空闲监控已启用,超时时间: 300 秒
|
||||
🔍 检查后端服务状态...
|
||||
✅ 后端服务已在运行
|
||||
🚀 PanSou MCP服务器已启动
|
||||
📡 服务地址: http://localhost:8888
|
||||
```
|
||||
|
||||
### 配置文件说明
|
||||
|
||||
无论是源码部署后端,还是Docker部署后端,都可以用统一的 `mcp-config.json` 配置文件。
|
||||
|
||||
### 部署方式的优势
|
||||
|
||||
**Docker 部署**:
|
||||
1. **简化部署**: 无需手动构建 Go 后端服务
|
||||
2. **环境隔离**: 后端服务运行在独立的容器环境中
|
||||
3. **易于管理**: 可以通过 Docker Compose 轻松启动、停止和重启服务
|
||||
4. **配置灵活**: 通过环境变量轻松调整服务配置
|
||||
|
||||
**源码部署**:
|
||||
1. **完全控制**: 可以自定义构建和配置
|
||||
2. **开发友好**: 便于调试和开发
|
||||
3. **资源效率**: 直接运行,无容器开销
|
||||
|
||||
**智能检测**:
|
||||
1. **自动适配**: 无需手动选择部署模式
|
||||
2. **简化配置**: 一个配置文件适用所有场景
|
||||
3. **错误减少**: 避免配置错误导致的问题
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
如果 MCP 服务无法连接到 Docker 后端,请检查:
|
||||
|
||||
1. **容器状态**: 使用 `docker ps` 确认容器正在运行
|
||||
2. **端口映射**: 确认端口 8888 已正确映射到主机
|
||||
3. **健康检查**: 使用 `curl http://localhost:8888/api/health` 测试后端服务
|
||||
4. **防火墙**: 确认防火墙没有阻止端口 8888
|
||||
|
||||
如果遇到问题,可以查看容器日志:
|
||||
|
||||
```bash
|
||||
# 查看容器日志
|
||||
docker logs pansou
|
||||
|
||||
# 实时查看日志
|
||||
docker logs -f pansou
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 支持的参数
|
||||
|
||||
MCP 服务通过工具调用接收参数。以下是主要工具及其支持的参数:
|
||||
|
||||
### `search_netdisk` 工具
|
||||
|
||||
用于搜索网盘资源。
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 描述 |
|
||||
| :-------------- | :-------------- | :--- | :------------------- | :----------------------------------------------------------- |
|
||||
| `keyword` | string | 是 | - | 搜索关键词,例如 "速度与激情"、"Python教程"。 |
|
||||
| `channels` | array of string | 否 | 配置默认值 | 要搜索的 Telegram 频道列表,例如 `["tgsearchers3", "another_channel"]`。 |
|
||||
| `plugins` | array of string | 否 | 配置默认值或所有插件 | 要使用的搜索插件列表,例如 `["pansearch", "panta"]`。 |
|
||||
| `cloud_types` | array of string | 否 | 无过滤 | 过滤结果,仅返回指定类型的网盘链接。支持的类型有:`baidu`, `aliyun`, `quark`, `tianyi`, `uc`, `mobile`, `115`, `pikpak`, `xunlei`, `123`, `magnet`, `ed2k`, `others`。 |
|
||||
| `source_type` | string | 否 | `"all"` | 数据来源类型。可选值:`"all"` (全部来源), `"tg"` (仅 Telegram), `"plugin"` (仅插件)。 |
|
||||
| `force_refresh` | boolean | 否 | `false` | 是否强制刷新缓存,以获取最新数据。 |
|
||||
| `result_type` | string | 否 | `"merge"` | 返回结果的类型。可选值:`"all"` (返回所有结果), `"results"` (仅返回详细结果), `"merge"` (仅返回按网盘类型分组的结果)。 |
|
||||
| `concurrency` | number | 否 | 自动计算 | 并发搜索的数量。 |
|
||||
| `ext_params` | object | 否 | `{}` | 传递给后端插件的自定义扩展参数,例如 `{"title_en": "Fast and Furious", "is_all": true}`。 |
|
||||
|
||||
---
|
||||
|
||||
### `check_service_health` 工具
|
||||
|
||||
用于检查后端服务健康状态。
|
||||
|
||||
- **参数**: 无
|
||||
|
||||
---
|
||||
|
||||
### `start_backend` 工具
|
||||
|
||||
用于启动本地 PanSou 后端服务。
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 描述 |
|
||||
| :-------------- | :------ | :--- | :------ | :----------------------------------------- |
|
||||
| `force_restart` | boolean | 否 | `false` | 是否强制重启后端服务(即使它已经在运行)。 |
|
||||
|
||||
---
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
您可以通过设置环境变量来配置 MCP 服务的行为:
|
||||
|
||||
| 环境变量 | 描述 | 默认值 |
|
||||
| :--------------------- | :--------------------------------------------------------- | :------------------------ |
|
||||
| `PANSOU_SERVER_URL` | PanSou 后端服务的 URL 地址。 | `http://localhost:8888` |
|
||||
| `REQUEST_TIMEOUT` | HTTP 请求超时时间(秒)。 | `30` |
|
||||
| `MAX_RESULTS` | (内部使用,限制处理结果数量) | `100` |
|
||||
| `DEFAULT_CHANNELS` | 默认搜索的 Telegram 频道列表(逗号分隔)。 | `""` (使用后端默认) |
|
||||
| `DEFAULT_PLUGINS` | 默认使用的搜索插件列表(逗号分隔)。 | `""` (使用后端默认或所有) |
|
||||
| `DEFAULT_CLOUD_TYPES` | 默认的网盘类型过滤器(逗号分隔)。 | `""` (无过滤) |
|
||||
| `AUTO_START_BACKEND` | 是否在 MCP 服务启动时自动尝试启动后端服务。 | `true` |
|
||||
| `DOCKER_MODE` | 部署模式控制。设置为 `true` 强制使用 Docker 模式;设置为 `false` 或未设置时启用智能检测。智能检测将自动识别 Docker 容器、源码部署或运行中的服务。 | `false` (智能检测) |
|
||||
| `PROJECT_ROOT_PATH` | PanSou 后端可执行文件所在的目录路径(用于自动启动)。 | 无 |
|
||||
| `IDLE_TIMEOUT` | 空闲超时时间(毫秒),超过此时间无活动则可能关闭后端服务。 | `300000` (5分钟) |
|
||||
| `ENABLE_IDLE_SHUTDOWN` | 是否启用空闲超时自动关闭后端服务。 | `true` |
|
||||
6
go.mod
6
go.mod
@@ -2,18 +2,16 @@ module pansou
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.11
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/bytedance/sonic v1.13.3
|
||||
github.com/bytedance/sonic v1.14.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
golang.org/x/net v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -4,9 +4,13 @@ github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x0
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
|
||||
55
mcp-config.json
Normal file
55
mcp-config.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"pansou": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:\\full\\path\\to\\your\\project\\typescript\\dist\\index.js"
|
||||
],
|
||||
"env": {
|
||||
"PANSOU_SERVER_URL": "http://localhost:8888",
|
||||
"REQUEST_TIMEOUT": "60",
|
||||
"MAX_RESULTS": "50",
|
||||
"DEFAULT_CLOUD_TYPES": "baidu,aliyun,quark,tianyi,uc,mobile,115,pikpak,xunlei,123,magnet,ed2k,others",
|
||||
"AUTO_START_BACKEND": "true",
|
||||
"DOCKER_MODE": "true",
|
||||
"BACKEND_SHUTDOWN_DELAY": "5000",
|
||||
"BACKEND_STARTUP_TIMEOUT": "30000",
|
||||
"IDLE_TIMEOUT": "300000",
|
||||
"ENABLE_IDLE_SHUTDOWN": "true",
|
||||
"PROJECT_ROOT_PATH": "C:\\full\\path\\to\\your\\project"
|
||||
}
|
||||
}
|
||||
},
|
||||
"_comments": {
|
||||
"description": "PanSou MCP服务统一配置文件",
|
||||
"version": "2.0",
|
||||
"智能模式说明": {
|
||||
"自动检测": "如果DOCKER_MODE未设置或为false,服务将自动检测部署模式",
|
||||
"检测优先级": [
|
||||
"1. 检查是否有运行中的Docker容器(名称包含pansou)",
|
||||
"2. 检查是否存在Go可执行文件(pansou.exe/main.exe)",
|
||||
"3. 检查后端服务是否已在运行"
|
||||
],
|
||||
"环境变量覆盖": "可通过环境变量强制指定模式,如 DOCKER_MODE=true"
|
||||
},
|
||||
"配置说明": {
|
||||
"PANSOU_SERVER_URL": "后端服务地址,默认http://localhost:8888",
|
||||
"REQUEST_TIMEOUT": "请求超时时间(秒),默认30",
|
||||
"MAX_RESULTS": "最大搜索结果数,默认50",
|
||||
"DEFAULT_CLOUD_TYPES": "默认搜索的网盘类型,逗号分隔",
|
||||
"AUTO_START_BACKEND": "是否自动启动后端服务(源码模式),默认true",
|
||||
"DOCKER_MODE": "是否强制使用Docker模式,默认false(自动检测)",
|
||||
"BACKEND_SHUTDOWN_DELAY": "后端服务关闭延迟(毫秒),默认5000",
|
||||
"BACKEND_STARTUP_TIMEOUT": "后端服务启动超时(毫秒),默认30000",
|
||||
"IDLE_TIMEOUT": "空闲超时时间(毫秒),默认300000(5分钟)",
|
||||
"ENABLE_IDLE_SHUTDOWN": "是否启用空闲自动关闭,默认true",
|
||||
"PROJECT_ROOT_PATH": "项目根目录路径,用于查找Go可执行文件"
|
||||
},
|
||||
"使用示例": {
|
||||
"自动模式": "默认配置,自动检测部署方式",
|
||||
"强制Docker模式": "设置 DOCKER_MODE=true",
|
||||
"强制源码模式": "设置 DOCKER_MODE=false 且 AUTO_START_BACKEND=true",
|
||||
"仅连接模式": "设置 AUTO_START_BACKEND=false(适用于手动启动的后端)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ type Response struct {
|
||||
// NewSuccessResponse 创建成功响应
|
||||
func NewSuccessResponse(data interface{}) Response {
|
||||
return Response{
|
||||
Code: 0,
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: data,
|
||||
}
|
||||
@@ -64,4 +64,4 @@ func NewErrorResponse(code int, message string) Response {
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
1046
package-lock.json
generated
Normal file
1046
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.4"
|
||||
}
|
||||
}
|
||||
6274
typescript/package-lock.json
generated
Normal file
6274
typescript/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
typescript/package.json
Normal file
60
typescript/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@pansou/mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for PanSou netdisk search service",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"pansou-mcp-server": "dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"prepare": "npm run build",
|
||||
"test": "jest",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"pansou",
|
||||
"netdisk",
|
||||
"search",
|
||||
"claude"
|
||||
],
|
||||
"author": "PanSou Team",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/fish2018/pansou-mcp.git"
|
||||
},
|
||||
"homepage": "https://github.com/fish2018/pansou-mcp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/fish2018/pansou-mcp/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"jest": "^29.0.0",
|
||||
"ts-jest": "^29.0.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
390
typescript/src/index.ts
Normal file
390
typescript/src/index.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { loadConfig } from './utils/config.js';
|
||||
import { HttpClient } from './utils/http-client.js';
|
||||
import { BackendManager } from './utils/backend-manager.js';
|
||||
import { searchTool, executeSearchTool } from './tools/search.js';
|
||||
import { healthTool, executeHealthTool } from './tools/health.js';
|
||||
import { startBackendTool, executeStartBackendTool } from './tools/start-backend.js';
|
||||
|
||||
/**
|
||||
* PanSou MCP服务器
|
||||
*/
|
||||
class PanSouMCPServer {
|
||||
private server: Server;
|
||||
private httpClient: HttpClient;
|
||||
private backendManager: BackendManager;
|
||||
private config: any;
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'pansou-mcp-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 加载配置
|
||||
this.config = loadConfig();
|
||||
this.httpClient = new HttpClient(this.config);
|
||||
this.backendManager = new BackendManager(this.config, this.httpClient);
|
||||
|
||||
this.setupHandlers();
|
||||
this.setupProcessHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求处理器
|
||||
*/
|
||||
private setupHandlers(): void {
|
||||
// 工具列表处理器
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [healthTool, startBackendTool, searchTool],
|
||||
};
|
||||
});
|
||||
|
||||
// 工具调用处理器
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
// 记录活动,重置空闲计时器
|
||||
this.backendManager.recordActivity();
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'check_service_health':
|
||||
const healthResult = await executeHealthTool(args, this.httpClient);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: healthResult,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case 'start_backend':
|
||||
const startResult = await executeStartBackendTool(args, this.httpClient, this.config);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: startResult,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case 'search_netdisk':
|
||||
const searchResult = await executeSearchTool(args, this.httpClient);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: searchResult,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
throw new McpError(
|
||||
ErrorCode.MethodNotFound,
|
||||
`未知工具: ${name}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`工具执行失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 资源列表处理器
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: 'pansou://plugins',
|
||||
name: '可用插件列表',
|
||||
description: '获取当前可用的搜索插件列表',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'pansou://channels',
|
||||
name: '可用频道列表',
|
||||
description: '获取当前可用的TG频道列表',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'pansou://cloud-types',
|
||||
name: '支持的网盘类型',
|
||||
description: '获取支持的网盘类型列表',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 资源读取处理器
|
||||
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const { uri } = request.params;
|
||||
|
||||
// 记录活动,重置空闲计时器
|
||||
this.backendManager.recordActivity();
|
||||
|
||||
try {
|
||||
switch (uri) {
|
||||
case 'pansou://plugins':
|
||||
return await this.getPluginsResource();
|
||||
|
||||
case 'pansou://channels':
|
||||
return await this.getChannelsResource();
|
||||
|
||||
case 'pansou://cloud-types':
|
||||
return await this.getCloudTypesResource();
|
||||
|
||||
default:
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`未知资源URI: ${uri}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`资源读取失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件资源
|
||||
*/
|
||||
private async getPluginsResource() {
|
||||
try {
|
||||
const healthData = await this.httpClient.checkHealth();
|
||||
|
||||
const plugins = {
|
||||
enabled: healthData.plugins_enabled || false,
|
||||
count: healthData.plugin_count || 0,
|
||||
list: healthData.plugins || [],
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'pansou://plugins',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(plugins, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`获取插件信息失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取频道资源
|
||||
*/
|
||||
private async getChannelsResource() {
|
||||
try {
|
||||
const healthData = await this.httpClient.checkHealth();
|
||||
|
||||
const channels = {
|
||||
count: healthData.channels_count || 0,
|
||||
list: healthData.channels || [],
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'pansou://channels',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(channels, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`获取频道信息失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网盘类型资源
|
||||
*/
|
||||
private async getCloudTypesResource() {
|
||||
const cloudTypes = {
|
||||
supported: [
|
||||
'baidu', // 百度网盘
|
||||
'aliyun', // 阿里云盘
|
||||
'quark', // 夸克网盘
|
||||
'tianyi', // 天翼云盘
|
||||
'uc', // UC网盘
|
||||
'mobile', // 移动云盘
|
||||
'115', // 115网盘
|
||||
'pikpak', // PikPak
|
||||
'xunlei', // 迅雷网盘
|
||||
'123', // 123网盘
|
||||
'magnet', // 磁力链接
|
||||
'ed2k', // 电驴链接
|
||||
'others' // 其他
|
||||
],
|
||||
description: {
|
||||
'baidu': '百度网盘',
|
||||
'aliyun': '阿里云盘',
|
||||
'quark': '夸克网盘',
|
||||
'tianyi': '天翼云盘',
|
||||
'uc': 'UC网盘',
|
||||
'mobile': '移动云盘',
|
||||
'115': '115网盘',
|
||||
'pikpak': 'PikPak',
|
||||
'xunlei': '迅雷网盘',
|
||||
'123': '123网盘',
|
||||
'magnet': '磁力链接',
|
||||
'ed2k': '电驴链接',
|
||||
'others': '其他网盘'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'pansou://cloud-types',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(cloudTypes, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置进程处理器
|
||||
*/
|
||||
private setupProcessHandlers(): void {
|
||||
// 处理优雅关闭
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.error(`\n📡 收到 ${signal} 信号,正在优雅关闭...`);
|
||||
|
||||
if (this.config.autoStartBackend) {
|
||||
// 延迟关闭后端服务
|
||||
this.backendManager.scheduleShutdown();
|
||||
}
|
||||
|
||||
// 等待一小段时间让MCP客户端处理完当前请求
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
// Windows特有的关闭事件
|
||||
if (process.platform === 'win32') {
|
||||
process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// 如果启用了自动启动后端服务
|
||||
if (this.config.autoStartBackend) {
|
||||
console.error('🔍 检查后端服务状态...');
|
||||
|
||||
// 在启动阶段启用静默模式,避免输出网络错误信息
|
||||
this.httpClient.setSilentMode(true);
|
||||
|
||||
const isRunning = await this.backendManager.isBackendRunning();
|
||||
if (!isRunning) {
|
||||
console.error('🚀 自动启动后端服务...');
|
||||
const started = await this.backendManager.startBackend();
|
||||
if (!started) {
|
||||
console.error('❌ 后端服务启动失败,MCP服务器将继续运行但功能可能受限');
|
||||
}
|
||||
} else {
|
||||
console.error('✅ 后端服务已在运行');
|
||||
}
|
||||
|
||||
// 启动完成后关闭静默模式
|
||||
this.httpClient.setSilentMode(false);
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
|
||||
// 输出启动信息到stderr,避免干扰MCP通信
|
||||
console.error('🚀 PanSou MCP服务器已启动');
|
||||
console.error(`📡 服务地址: ${this.config.serverUrl}`);
|
||||
console.error(`⏱️ 请求超时: ${this.config.requestTimeout}ms`);
|
||||
console.error(`📊 最大结果数: ${this.config.maxResults}`);
|
||||
console.error(`🔧 自动启动后端: ${this.config.autoStartBackend ? '启用' : '禁用'}`);
|
||||
// 空闲监控信息已在BackendManager构造函数中显示
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const server = new PanSouMCPServer();
|
||||
await server.start();
|
||||
} catch (error) {
|
||||
console.error('❌ 服务器启动失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理未捕获的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('❌ 未捕获的异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('❌ 未处理的Promise拒绝:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1].endsWith('index.js')) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { PanSouMCPServer };
|
||||
115
typescript/src/tools/health.ts
Normal file
115
typescript/src/tools/health.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { HttpClient } from '../utils/http-client.js';
|
||||
|
||||
/**
|
||||
* 健康检查工具定义
|
||||
*/
|
||||
export const healthTool: Tool = {
|
||||
name: 'check_service_health',
|
||||
description: '检查PanSou服务的健康状态,获取服务信息、可用插件和频道列表',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行健康检查工具
|
||||
*/
|
||||
export async function executeHealthTool(args: unknown, httpClient: HttpClient): Promise<string> {
|
||||
try {
|
||||
// 执行健康检查
|
||||
const healthData = await httpClient.checkHealth();
|
||||
|
||||
// 格式化返回结果
|
||||
return formatHealthResult(healthData, httpClient.getServerUrl());
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return formatErrorResult(error.message, httpClient.getServerUrl());
|
||||
}
|
||||
|
||||
return formatErrorResult(`健康检查失败: ${String(error)}`, httpClient.getServerUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化健康检查结果
|
||||
*/
|
||||
function formatHealthResult(healthData: any, serverUrl: string): string {
|
||||
let output = `🏥 **PanSou服务健康检查**\n\n`;
|
||||
|
||||
// 服务基本信息
|
||||
output += `🌐 **服务地址**: ${serverUrl}\n`;
|
||||
output += `✅ **服务状态**: ${healthData.status === 'ok' ? '正常' : '异常'}\n\n`;
|
||||
|
||||
// 频道信息
|
||||
output += `📺 **TG频道信息**\n`;
|
||||
output += ` 📊 频道数量: ${healthData.channels_count || 0}\n`;
|
||||
if (healthData.channels && healthData.channels.length > 0) {
|
||||
output += ` 📋 可用频道:\n`;
|
||||
healthData.channels.forEach((channel: string, index: number) => {
|
||||
output += ` ${index + 1}. ${channel}\n`;
|
||||
});
|
||||
} else {
|
||||
output += ` ⚠️ 未配置频道\n`;
|
||||
}
|
||||
output += '\n';
|
||||
|
||||
// 插件信息
|
||||
output += `🔌 **插件信息**\n`;
|
||||
output += ` 🔧 插件功能: ${healthData.plugins_enabled ? '已启用' : '已禁用'}\n`;
|
||||
|
||||
if (healthData.plugins_enabled) {
|
||||
output += ` 📊 插件数量: ${healthData.plugin_count || 0}\n`;
|
||||
if (healthData.plugins && healthData.plugins.length > 0) {
|
||||
output += ` 📋 可用插件:\n`;
|
||||
|
||||
// 将插件按行显示,每行最多4个
|
||||
const plugins = healthData.plugins;
|
||||
for (let i = 0; i < plugins.length; i += 4) {
|
||||
const row = plugins.slice(i, i + 4);
|
||||
output += ` ${row.map((plugin: string, idx: number) => `${i + idx + 1}. ${plugin}`).join(' ')}\n`;
|
||||
}
|
||||
} else {
|
||||
output += ` ⚠️ 未发现可用插件\n`;
|
||||
}
|
||||
} else {
|
||||
output += ` ℹ️ 插件功能已禁用\n`;
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
|
||||
// 功能说明
|
||||
output += `💡 **功能说明**\n`;
|
||||
output += ` 🔍 支持搜索多种网盘资源\n`;
|
||||
output += ` 📱 支持TG频道和插件双重搜索\n`;
|
||||
output += ` 🚀 支持并发搜索,提升搜索速度\n`;
|
||||
output += ` 💾 支持缓存机制,避免重复请求\n`;
|
||||
output += ` 🎯 支持按网盘类型过滤结果\n`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化错误结果
|
||||
*/
|
||||
function formatErrorResult(errorMessage: string, serverUrl: string): string {
|
||||
let output = `❌ **PanSou服务健康检查失败**\n\n`;
|
||||
|
||||
output += `🌐 **服务地址**: ${serverUrl}\n`;
|
||||
output += `💥 **错误信息**: ${errorMessage}\n\n`;
|
||||
|
||||
output += `🔧 **可能的解决方案**:\n`;
|
||||
output += ` 1. 检查PanSou服务是否正在运行\n`;
|
||||
output += ` 2. 确认服务地址配置是否正确\n`;
|
||||
output += ` 3. 检查网络连接是否正常\n`;
|
||||
output += ` 4. 查看服务日志获取更多信息\n\n`;
|
||||
|
||||
output += `📖 **配置说明**:\n`;
|
||||
output += ` 可通过环境变量 PANSOU_SERVER_URL 配置服务地址\n`;
|
||||
output += ` 默认地址: http://localhost:8888\n`;
|
||||
|
||||
return output;
|
||||
}
|
||||
267
typescript/src/tools/search.ts
Normal file
267
typescript/src/tools/search.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
import { HttpClient, SearchRequest } from '../utils/http-client.js';
|
||||
import { validateCloudTypes, validateSourceType, validateResultType, SUPPORTED_CLOUD_TYPES, SOURCE_TYPES, RESULT_TYPES } from '../utils/config.js';
|
||||
|
||||
/**
|
||||
* 搜索工具参数验证模式
|
||||
*/
|
||||
const SearchToolArgsSchema = z.object({
|
||||
keyword: z.string().min(1, '搜索关键词不能为空'),
|
||||
channels: z.array(z.string()).optional().describe('TG频道列表,如: ["tgsearchers3", "xxx"]'),
|
||||
plugins: z.array(z.string()).optional().describe('插件列表,如: ["pansearch", "panta"]'),
|
||||
cloud_types: z.array(z.string()).optional().describe(`网盘类型过滤,支持: ${SUPPORTED_CLOUD_TYPES.join(', ')}`),
|
||||
source_type: z.enum(['all', 'tg', 'plugin']).optional().default('all').describe('数据来源类型'),
|
||||
force_refresh: z.boolean().optional().default(false).describe('强制刷新缓存'),
|
||||
result_type: z.enum(['all', 'results', 'merge']).optional().default('merge').describe('结果类型'),
|
||||
concurrency: z.number().int().positive().optional().describe('并发搜索数量'),
|
||||
ext_params: z.record(z.any()).optional().describe('扩展参数,传递给插件的自定义参数')
|
||||
});
|
||||
|
||||
export type SearchToolArgs = z.infer<typeof SearchToolArgsSchema>;
|
||||
|
||||
/**
|
||||
* 搜索工具定义
|
||||
*/
|
||||
export const searchTool: Tool = {
|
||||
name: 'search_netdisk',
|
||||
description: '搜索网盘资源,支持多种网盘类型和搜索来源。可以搜索电影、电视剧、软件、文档等各类资源。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
description: '搜索关键词,如:"速度与激情"、"Python教程"、"Office 2021"等'
|
||||
},
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'TG频道列表,指定要搜索的Telegram频道。不指定则使用默认配置的频道'
|
||||
},
|
||||
plugins: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '插件列表,指定要使用的搜索插件。不指定则使用所有可用插件'
|
||||
},
|
||||
cloud_types: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [...SUPPORTED_CLOUD_TYPES]
|
||||
},
|
||||
description: `网盘类型过滤,只返回指定类型的网盘链接。支持: ${SUPPORTED_CLOUD_TYPES.join(', ')}`
|
||||
},
|
||||
source_type: {
|
||||
type: 'string',
|
||||
enum: [...SOURCE_TYPES],
|
||||
default: 'all',
|
||||
description: '数据来源类型:all(全部来源)、tg(仅Telegram)、plugin(仅插件)'
|
||||
},
|
||||
force_refresh: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: '强制刷新缓存,获取最新数据'
|
||||
},
|
||||
result_type: {
|
||||
type: 'string',
|
||||
enum: [...RESULT_TYPES],
|
||||
default: 'merge',
|
||||
description: '结果类型:all(返回所有结果)、results(仅返回results)、merge(仅返回按网盘类型分组的结果)'
|
||||
},
|
||||
concurrency: {
|
||||
type: 'number',
|
||||
description: '并发搜索数量,不指定则自动计算'
|
||||
},
|
||||
ext_params: {
|
||||
type: 'object',
|
||||
description: '扩展参数,用于传递给插件的自定义参数,如: {"title_en": "Fast and Furious", "is_all": true}'
|
||||
}
|
||||
},
|
||||
required: ['keyword']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行搜索工具
|
||||
*/
|
||||
export async function executeSearchTool(args: unknown, httpClient: HttpClient): Promise<string> {
|
||||
try {
|
||||
// 参数验证
|
||||
const validatedArgs = SearchToolArgsSchema.parse(args);
|
||||
|
||||
// 验证网盘类型
|
||||
let cloudTypes: string[] | undefined;
|
||||
if (validatedArgs.cloud_types) {
|
||||
cloudTypes = validateCloudTypes(validatedArgs.cloud_types);
|
||||
}
|
||||
|
||||
// 验证数据来源类型
|
||||
const sourceType = validateSourceType(validatedArgs.source_type);
|
||||
|
||||
// 验证结果类型
|
||||
const resultType = validateResultType(validatedArgs.result_type);
|
||||
|
||||
// 检查后端服务状态
|
||||
const isHealthy = await httpClient.checkHealth();
|
||||
if (!isHealthy) {
|
||||
throw new Error('后端服务未运行,请先启动后端服务。');
|
||||
}
|
||||
|
||||
// 构建搜索请求
|
||||
const searchRequest: SearchRequest = {
|
||||
kw: validatedArgs.keyword,
|
||||
channels: validatedArgs.channels,
|
||||
plugins: validatedArgs.plugins,
|
||||
cloud_types: cloudTypes as any,
|
||||
src: sourceType,
|
||||
refresh: validatedArgs.force_refresh,
|
||||
res: resultType,
|
||||
conc: validatedArgs.concurrency,
|
||||
ext: validatedArgs.ext_params
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const result = await httpClient.search(searchRequest);
|
||||
|
||||
// 格式化返回结果
|
||||
return formatSearchResult(result, validatedArgs.keyword, resultType);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
throw new Error(`参数验证失败: ${errorMessages}`);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error('搜索过程中发生错误:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
originalArgs: args
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`搜索失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*/
|
||||
function formatSearchResult(result: any, keyword: string, resultType: string): string {
|
||||
const { total, results, merged_by_type } = result;
|
||||
|
||||
let output = `🔍 搜索关键词: "${keyword}"\n`;
|
||||
output += `📊 找到 ${total} 个结果\n\n`;
|
||||
|
||||
if (resultType === 'merge' && merged_by_type) {
|
||||
// 按网盘类型分组显示
|
||||
output += formatMergedResults(merged_by_type);
|
||||
} else if (resultType === 'results' && results) {
|
||||
// 显示详细结果
|
||||
output += formatDetailedResults(results);
|
||||
} else if (resultType === 'all') {
|
||||
// 显示所有信息
|
||||
if (merged_by_type) {
|
||||
output += "## 📁 按网盘类型分组\n";
|
||||
output += formatMergedResults(merged_by_type);
|
||||
}
|
||||
if (results && results.length > 0) {
|
||||
output += "\n## 📋 详细结果\n";
|
||||
output += formatDetailedResults(results.slice(0, 10)); // 限制显示前10个详细结果
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化按网盘类型分组的结果
|
||||
*/
|
||||
function formatMergedResults(mergedByType: Record<string, any[]>): string {
|
||||
let output = '';
|
||||
|
||||
const typeNames: Record<string, string> = {
|
||||
'baidu': '🔵 百度网盘',
|
||||
'aliyun': '🟠 阿里云盘',
|
||||
'quark': '🟣 夸克网盘',
|
||||
'tianyi': '🔴 天翼云盘',
|
||||
'uc': '🟡 UC网盘',
|
||||
'mobile': '🟢 移动云盘',
|
||||
'115': '⚫ 115网盘',
|
||||
'pikpak': '🟤 PikPak',
|
||||
'xunlei': '🔶 迅雷网盘',
|
||||
'123': '🟦 123网盘',
|
||||
'magnet': '🧲 磁力链接',
|
||||
'ed2k': '🔗 电驴链接',
|
||||
'others': '📦 其他'
|
||||
};
|
||||
|
||||
for (const [type, links] of Object.entries(mergedByType)) {
|
||||
if (links && links.length > 0) {
|
||||
const typeName = typeNames[type] || `📁 ${type}`;
|
||||
output += `### ${typeName} (${links.length}个)\n`;
|
||||
|
||||
links.slice(0, 5).forEach((link: any, index: number) => {
|
||||
output += `${index + 1}. **${link.note || '未知标题'}**\n`;
|
||||
output += ` 🔗 链接: ${link.url}\n`;
|
||||
if (link.password) {
|
||||
output += ` 🔑 密码: ${link.password}\n`;
|
||||
}
|
||||
if (link.source) {
|
||||
output += ` 📍 来源: ${link.source}\n`;
|
||||
}
|
||||
output += ` 📅 时间: ${new Date(link.datetime).toLocaleString('zh-CN')}\n\n`;
|
||||
});
|
||||
|
||||
if (links.length > 5) {
|
||||
output += ` ... 还有 ${links.length - 5} 个结果\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化详细结果
|
||||
*/
|
||||
function formatDetailedResults(results: any[]): string {
|
||||
let output = '';
|
||||
|
||||
results.forEach((result: any, index: number) => {
|
||||
output += `### ${index + 1}. ${result.title || '未知标题'}\n`;
|
||||
output += `📺 频道: ${result.channel}\n`;
|
||||
output += `📅 时间: ${new Date(result.datetime).toLocaleString('zh-CN')}\n`;
|
||||
|
||||
if (result.content && result.content !== result.title) {
|
||||
const content = result.content.length > 200 ? result.content.substring(0, 200) + '...' : result.content;
|
||||
output += `📝 内容: ${content}\n`;
|
||||
}
|
||||
|
||||
if (result.tags && result.tags.length > 0) {
|
||||
output += `🏷️ 标签: ${result.tags.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (result.links && result.links.length > 0) {
|
||||
output += `🔗 网盘链接:\n`;
|
||||
result.links.forEach((link: any, linkIndex: number) => {
|
||||
output += ` ${linkIndex + 1}. [${link.type.toUpperCase()}] ${link.url}`;
|
||||
if (link.password) {
|
||||
output += ` (密码: ${link.password})`;
|
||||
}
|
||||
output += '\n';
|
||||
});
|
||||
}
|
||||
|
||||
if (result.images && result.images.length > 0) {
|
||||
output += `🖼️ 图片: ${result.images.length}张\n`;
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
131
typescript/src/tools/start-backend.ts
Normal file
131
typescript/src/tools/start-backend.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { BackendManager } from '../utils/backend-manager.js';
|
||||
import { HttpClient } from '../utils/http-client.js';
|
||||
import { Config } from '../utils/config.js';
|
||||
|
||||
/**
|
||||
* 启动后端服务工具定义
|
||||
*/
|
||||
export const startBackendTool: Tool = {
|
||||
name: 'start_backend',
|
||||
description: '启动PanSou后端服务。如果后端服务未运行,此工具将启动它并等待服务完全可用。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
force_restart: {
|
||||
type: 'boolean',
|
||||
description: '是否强制重启后端服务(即使已在运行)',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动后端服务工具参数接口
|
||||
*/
|
||||
interface StartBackendArgs {
|
||||
force_restart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行启动后端服务工具
|
||||
*/
|
||||
export async function executeStartBackendTool(
|
||||
args: unknown,
|
||||
httpClient?: HttpClient,
|
||||
config?: Config
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 参数验证
|
||||
const params = args as StartBackendArgs;
|
||||
const forceRestart = params?.force_restart || false;
|
||||
|
||||
console.log('🚀 启动后端服务工具被调用');
|
||||
|
||||
// 如果没有提供依赖项,则创建默认实例
|
||||
if (!config) {
|
||||
const { loadConfig } = await import('../utils/config.js');
|
||||
config = loadConfig();
|
||||
}
|
||||
|
||||
if (!httpClient) {
|
||||
const { HttpClient } = await import('../utils/http-client.js');
|
||||
httpClient = new HttpClient(config);
|
||||
}
|
||||
|
||||
// 创建后端管理器
|
||||
const backendManager = new BackendManager(config, httpClient);
|
||||
|
||||
// 检查当前服务状态
|
||||
httpClient.setSilentMode(true);
|
||||
const isHealthy = await httpClient.testConnection();
|
||||
httpClient.setSilentMode(false);
|
||||
|
||||
if (isHealthy && !forceRestart) {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: '后端服务已在运行',
|
||||
status: 'already_running',
|
||||
service_url: config.serverUrl
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
if (isHealthy && forceRestart) {
|
||||
console.log('🔄 强制重启后端服务...');
|
||||
}
|
||||
|
||||
console.log('🚀 正在启动后端服务...');
|
||||
const started = await backendManager.startBackend();
|
||||
|
||||
if (!started) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
message: '后端服务启动失败',
|
||||
status: 'start_failed',
|
||||
error: '无法启动后端服务,请检查配置和权限'
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// 等待服务完全启动并进行健康检查
|
||||
console.log('⏳ 等待服务完全启动...');
|
||||
const maxRetries = 10;
|
||||
let retries = 0;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒
|
||||
const healthy = await httpClient.testConnection();
|
||||
|
||||
if (healthy) {
|
||||
console.log('✅ 后端服务启动成功并通过健康检查');
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: '后端服务启动成功',
|
||||
status: 'started',
|
||||
service_url: config.serverUrl,
|
||||
startup_time: `${retries + 1}秒`
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
retries++;
|
||||
console.log(`🔍 健康检查重试 ${retries}/${maxRetries}...`);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
message: '后端服务启动超时',
|
||||
status: 'timeout',
|
||||
error: '服务启动后未能通过健康检查,可能需要更多时间或存在配置问题'
|
||||
}, null, 2);
|
||||
|
||||
} catch (error) {
|
||||
console.error('启动后端服务时发生错误:', error);
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
message: '启动后端服务时发生错误',
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}, null, 2);
|
||||
}
|
||||
}
|
||||
148
typescript/src/utils/activity-monitor.ts
Normal file
148
typescript/src/utils/activity-monitor.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 活动监控器 - 跟踪MCP工具调用活动
|
||||
*/
|
||||
export class ActivityMonitor {
|
||||
private lastActivityTime: number;
|
||||
private idleTimeout: number;
|
||||
private enableIdleShutdown: boolean;
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
private onIdleCallback: (() => void) | null = null;
|
||||
|
||||
constructor(idleTimeout: number = 300000, enableIdleShutdown: boolean = true) {
|
||||
this.lastActivityTime = Date.now();
|
||||
this.idleTimeout = idleTimeout;
|
||||
this.enableIdleShutdown = enableIdleShutdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录活动
|
||||
*/
|
||||
recordActivity(): void {
|
||||
this.lastActivityTime = Date.now();
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后活动时间
|
||||
*/
|
||||
getLastActivityTime(): number {
|
||||
return this.lastActivityTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取空闲时间(毫秒)
|
||||
*/
|
||||
getIdleTime(): number {
|
||||
return Date.now() - this.lastActivityTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否空闲超时
|
||||
*/
|
||||
isIdleTimeout(): boolean {
|
||||
return this.getIdleTime() >= this.idleTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置空闲回调函数
|
||||
*/
|
||||
setOnIdleCallback(callback: () => void): void {
|
||||
this.onIdleCallback = callback;
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置空闲计时器
|
||||
*/
|
||||
private resetIdleTimer(): void {
|
||||
if (!this.enableIdleShutdown || !this.onIdleCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除现有计时器
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
|
||||
// 设置新的计时器
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (this.onIdleCallback) {
|
||||
console.log(`[ActivityMonitor] 检测到空闲超时 (${this.idleTimeout}ms),触发空闲回调`);
|
||||
this.onIdleCallback();
|
||||
}
|
||||
}, this.idleTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监控
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
this.onIdleCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(idleTimeout: number, enableIdleShutdown: boolean): void {
|
||||
this.idleTimeout = idleTimeout;
|
||||
this.enableIdleShutdown = enableIdleShutdown;
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态信息
|
||||
*/
|
||||
getStatus(): {
|
||||
lastActivityTime: number;
|
||||
idleTime: number;
|
||||
idleTimeout: number;
|
||||
enableIdleShutdown: boolean;
|
||||
isIdleTimeout: boolean;
|
||||
} {
|
||||
return {
|
||||
lastActivityTime: this.lastActivityTime,
|
||||
idleTime: this.getIdleTime(),
|
||||
idleTimeout: this.idleTimeout,
|
||||
enableIdleShutdown: this.enableIdleShutdown,
|
||||
isIdleTimeout: this.isIdleTimeout()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 全局活动监控器实例
|
||||
let globalActivityMonitor: ActivityMonitor | null = null;
|
||||
|
||||
/**
|
||||
* 获取全局活动监控器实例
|
||||
*/
|
||||
export function getActivityMonitor(): ActivityMonitor {
|
||||
if (!globalActivityMonitor) {
|
||||
throw new Error('活动监控器未初始化,请先调用 initializeActivityMonitor');
|
||||
}
|
||||
return globalActivityMonitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化全局活动监控器
|
||||
*/
|
||||
export function initializeActivityMonitor(idleTimeout: number, enableIdleShutdown: boolean): ActivityMonitor {
|
||||
if (globalActivityMonitor) {
|
||||
globalActivityMonitor.stop();
|
||||
}
|
||||
globalActivityMonitor = new ActivityMonitor(idleTimeout, enableIdleShutdown);
|
||||
return globalActivityMonitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止全局活动监控器
|
||||
*/
|
||||
export function stopActivityMonitor(): void {
|
||||
if (globalActivityMonitor) {
|
||||
globalActivityMonitor.stop();
|
||||
globalActivityMonitor = null;
|
||||
}
|
||||
}
|
||||
469
typescript/src/utils/backend-manager.ts
Normal file
469
typescript/src/utils/backend-manager.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { HttpClient } from './http-client.js';
|
||||
import { Config } from './config.js';
|
||||
import { ActivityMonitor } from './activity-monitor.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* 后端服务管理器
|
||||
* 负责自动启动、停止和监控PanSou Go后端服务
|
||||
*/
|
||||
export class BackendManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private config: Config;
|
||||
private httpClient: HttpClient;
|
||||
private shutdownTimeout: NodeJS.Timeout | null = null;
|
||||
private isShuttingDown = false;
|
||||
private readonly SHUTDOWN_DELAY = 5000; // 5秒延迟关闭
|
||||
private readonly STARTUP_TIMEOUT = 30000; // 30秒启动超时
|
||||
private readonly HEALTH_CHECK_INTERVAL = 1000; // 1秒健康检查间隔
|
||||
private activityMonitor: ActivityMonitor | null = null;
|
||||
|
||||
constructor(config: Config, httpClient: HttpClient) {
|
||||
this.config = config;
|
||||
this.httpClient = httpClient;
|
||||
|
||||
// 初始化活动监控器
|
||||
if (this.config.enableIdleShutdown) {
|
||||
this.activityMonitor = new ActivityMonitor(
|
||||
this.config.idleTimeout,
|
||||
this.config.enableIdleShutdown
|
||||
);
|
||||
|
||||
// 设置空闲监控回调
|
||||
this.activityMonitor.setOnIdleCallback(async () => {
|
||||
console.error('⏰ 检测到空闲超时,自动关闭后端服务');
|
||||
await this.stopBackend();
|
||||
// 退出整个进程
|
||||
process.exit(0);
|
||||
});
|
||||
console.error(`⏱️ 空闲监控已启用,超时时间: ${this.config.idleTimeout / 1000} 秒`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查后端服务是否正在运行
|
||||
*/
|
||||
async isBackendRunning(): Promise<boolean> {
|
||||
try {
|
||||
return await this.httpClient.testConnection();
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能检测Docker容器状态
|
||||
*/
|
||||
private async detectDockerContainer(): Promise<boolean> {
|
||||
try {
|
||||
// 检查Docker是否可用
|
||||
await execAsync('docker --version');
|
||||
|
||||
// 检查是否有运行中的pansou容器
|
||||
const { stdout } = await execAsync('docker ps --format "{{.Names}}" --filter "name=pansou"');
|
||||
const runningContainers = stdout.trim().split('\n').filter(name => name.includes('pansou'));
|
||||
|
||||
if (runningContainers.length > 0) {
|
||||
console.error(`🐳 检测到运行中的Docker容器: ${runningContainers.join(', ')}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// Docker不可用或没有容器运行
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能检测部署模式
|
||||
* @returns 'docker' | 'source' | 'unknown'
|
||||
*/
|
||||
private async detectDeploymentMode(): Promise<'docker' | 'source' | 'unknown'> {
|
||||
// 1. 首先检查是否有Docker容器运行
|
||||
const hasDockerContainer = await this.detectDockerContainer();
|
||||
if (hasDockerContainer) {
|
||||
return 'docker';
|
||||
}
|
||||
|
||||
// 2. 检查是否有Go可执行文件
|
||||
const execPath = await this.findGoExecutable();
|
||||
if (execPath) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
// 3. 检查服务是否已经在运行(可能是手动启动的)
|
||||
this.httpClient.setSilentMode(true);
|
||||
const isRunning = await this.isBackendRunning();
|
||||
this.httpClient.setSilentMode(false);
|
||||
|
||||
if (isRunning) {
|
||||
console.error('✅ 检测到后端服务已在运行(可能是手动启动)');
|
||||
return 'source'; // 假设是源码模式
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找Go可执行文件路径
|
||||
*/
|
||||
private async findGoExecutable(): Promise<string | null> {
|
||||
// 优先使用配置中的项目根目录
|
||||
const configProjectRoot = this.config.projectRootPath;
|
||||
|
||||
const possiblePaths: string[] = [];
|
||||
|
||||
// 如果配置了项目根目录,直接在该目录下查找
|
||||
if (configProjectRoot) {
|
||||
possiblePaths.push(
|
||||
path.join(configProjectRoot, 'pansou.exe'),
|
||||
path.join(configProjectRoot, 'main.exe')
|
||||
);
|
||||
} else {
|
||||
// 仅在没有配置项目根目录时才使用备用路径
|
||||
possiblePaths.push(
|
||||
// 当前工作目录
|
||||
path.join(process.cwd(), 'pansou.exe'),
|
||||
path.join(process.cwd(), 'main.exe'),
|
||||
// 上级目录(如果MCP在子目录中)
|
||||
path.join(process.cwd(), '..', 'pansou.exe'),
|
||||
path.join(process.cwd(), '..', 'main.exe')
|
||||
);
|
||||
}
|
||||
|
||||
console.error('🔍 查找后端可执行文件...');
|
||||
if (configProjectRoot) {
|
||||
console.error(`📂 使用配置的项目根目录: ${configProjectRoot}`);
|
||||
} else {
|
||||
console.error(`📂 当前工作目录: ${process.cwd()}`);
|
||||
}
|
||||
|
||||
for (const execPath of possiblePaths) {
|
||||
try {
|
||||
await fs.access(execPath);
|
||||
console.error(`✅ 找到可执行文件: ${execPath}`);
|
||||
return execPath;
|
||||
} catch {
|
||||
// 静默跳过未找到的路径
|
||||
}
|
||||
}
|
||||
|
||||
console.error('❌ 未找到可执行文件');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动后端服务
|
||||
*/
|
||||
async startBackend(): Promise<boolean> {
|
||||
if (this.process) {
|
||||
console.error('⚠️ 后端服务已在运行中');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 智能检测部署模式(如果未明确配置Docker模式)
|
||||
let effectiveDockerMode = this.config.dockerMode;
|
||||
|
||||
if (!effectiveDockerMode) {
|
||||
console.error('🔍 正在智能检测部署模式...');
|
||||
const detectedMode = await this.detectDeploymentMode();
|
||||
|
||||
switch (detectedMode) {
|
||||
case 'docker':
|
||||
console.error('🐳 智能检测:使用Docker部署模式');
|
||||
effectiveDockerMode = true;
|
||||
break;
|
||||
case 'source':
|
||||
console.error('📦 智能检测:使用源码部署模式');
|
||||
effectiveDockerMode = false;
|
||||
break;
|
||||
case 'unknown':
|
||||
console.error('❓ 无法检测部署模式,使用默认源码模式');
|
||||
effectiveDockerMode = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
console.error(`⚙️ 使用配置指定的模式: ${effectiveDockerMode ? 'Docker' : '源码'}`);
|
||||
}
|
||||
|
||||
// Docker模式处理
|
||||
if (effectiveDockerMode) {
|
||||
console.error('🐳 Docker模式已启用,正在检查后端服务连接...');
|
||||
|
||||
// Docker模式下进行重试检查,因为容器可能需要时间启动
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 2000; // 2秒
|
||||
|
||||
this.httpClient.setSilentMode(true);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const isRunning = await this.isBackendRunning();
|
||||
if (isRunning) {
|
||||
this.httpClient.setSilentMode(false);
|
||||
console.error('✅ Docker模式下后端服务连接成功');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
console.error(`🔄 连接尝试 ${i + 1}/${maxRetries} 失败,${retryDelay/1000}秒后重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
this.httpClient.setSilentMode(false);
|
||||
console.error('❌ Docker模式下后端服务连接失败');
|
||||
console.error('请确保Docker容器正在运行:');
|
||||
console.error(' docker-compose up -d');
|
||||
console.error('或检查Docker容器状态:');
|
||||
console.error(' docker ps');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 源码模式:首先检查是否已有服务在运行
|
||||
this.httpClient.setSilentMode(true);
|
||||
const isRunning = await this.isBackendRunning();
|
||||
this.httpClient.setSilentMode(false);
|
||||
|
||||
if (isRunning) {
|
||||
console.error('✅ 检测到后端服务已在运行');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 查找Go可执行文件
|
||||
const execPath = await this.findGoExecutable();
|
||||
if (!execPath) {
|
||||
console.error('❌ 未找到PanSou后端可执行文件');
|
||||
console.error('如果您使用Docker部署,请在MCP配置中设置 DOCKER_MODE=true');
|
||||
console.error('如果您使用源码部署,请确保在项目根目录下存在以下文件之一:');
|
||||
console.error(' - pansou.exe / pansou');
|
||||
console.error(' - main.exe / main');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error(`🚀 启动后端服务: ${execPath}`);
|
||||
|
||||
try {
|
||||
// 启动Go服务
|
||||
this.process = spawn(execPath, [], {
|
||||
cwd: path.dirname(execPath),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
// 监听进程事件
|
||||
this.process.on('error', (error) => {
|
||||
console.error('❌ 后端服务启动失败:', error.message);
|
||||
console.error('错误详情:', error);
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code, signal) => {
|
||||
if (!this.isShuttingDown) {
|
||||
console.error(`⚠️ 后端服务意外退出 (code: ${code}, signal: ${signal})`);
|
||||
}
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
// 添加进程启动确认
|
||||
console.error(`📋 进程PID: ${this.process.pid}`);
|
||||
console.error(`📂 工作目录: ${path.dirname(execPath)}`);
|
||||
console.error(`⚙️ 启动参数: ${execPath}`);
|
||||
|
||||
// 给进程一点时间启动
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 捕获输出(用于调试)
|
||||
if (this.process.stdout) {
|
||||
this.process.stdout.on('data', (data) => {
|
||||
console.error('Backend stdout:', data.toString().trim());
|
||||
});
|
||||
}
|
||||
|
||||
if (this.process.stderr) {
|
||||
this.process.stderr.on('data', (data) => {
|
||||
console.error('Backend stderr:', data.toString().trim());
|
||||
});
|
||||
}
|
||||
|
||||
// 等待服务启动
|
||||
const started = await this.waitForBackendReady();
|
||||
if (started) {
|
||||
console.error('✅ 后端服务启动成功');
|
||||
|
||||
// 空闲监控已在构造函数中设置
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ 后端服务启动超时');
|
||||
await this.stopBackend();
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 启动后端服务时发生错误:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待后端服务就绪
|
||||
*/
|
||||
private async waitForBackendReady(): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 在等待期间启用静默模式,避免输出网络错误
|
||||
const originalSilentMode = this.httpClient.isSilentMode();
|
||||
this.httpClient.setSilentMode(true);
|
||||
|
||||
try {
|
||||
while (Date.now() - startTime < this.STARTUP_TIMEOUT) {
|
||||
if (await this.isBackendRunning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查进程是否还在运行
|
||||
if (!this.process || this.process.killed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, this.HEALTH_CHECK_INTERVAL));
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
// 恢复原始静默模式状态
|
||||
this.httpClient.setSilentMode(originalSilentMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后端服务
|
||||
*/
|
||||
async stopBackend(): Promise<void> {
|
||||
if (!this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('🛑 正在停止后端服务...');
|
||||
this.isShuttingDown = true;
|
||||
|
||||
try {
|
||||
// 尝试优雅关闭
|
||||
this.process.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.process) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// 强制杀死进程
|
||||
if (this.process && !this.process.killed) {
|
||||
console.error('⚠️ 强制终止后端服务');
|
||||
this.process.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
this.process.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.error('✅ 后端服务已停止');
|
||||
} catch (error) {
|
||||
console.error('❌ 停止后端服务时发生错误:', error);
|
||||
} finally {
|
||||
this.process = null;
|
||||
this.isShuttingDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟停止后端服务
|
||||
*/
|
||||
scheduleShutdown(): void {
|
||||
if (this.shutdownTimeout) {
|
||||
clearTimeout(this.shutdownTimeout);
|
||||
}
|
||||
|
||||
console.error(`⏰ 将在 ${this.SHUTDOWN_DELAY / 1000} 秒后关闭后端服务`);
|
||||
|
||||
this.shutdownTimeout = setTimeout(async () => {
|
||||
await this.stopBackend();
|
||||
this.shutdownTimeout = null;
|
||||
}, this.SHUTDOWN_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消计划的关闭
|
||||
*/
|
||||
cancelShutdown(): void {
|
||||
if (this.shutdownTimeout) {
|
||||
clearTimeout(this.shutdownTimeout);
|
||||
this.shutdownTimeout = null;
|
||||
console.error('⏸️ 取消后端服务关闭计划');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取后端服务状态
|
||||
*/
|
||||
getStatus(): {
|
||||
processRunning: boolean;
|
||||
serviceReachable: boolean;
|
||||
pid?: number;
|
||||
} {
|
||||
return {
|
||||
processRunning: this.process !== null && !this.process.killed,
|
||||
serviceReachable: false, // 需要异步检查
|
||||
pid: this.process?.pid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录活动(重置空闲计时器)
|
||||
*/
|
||||
recordActivity(): void {
|
||||
if (this.activityMonitor) {
|
||||
this.activityMonitor.recordActivity();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动监控状态
|
||||
*/
|
||||
getActivityStatus(): any {
|
||||
return this.activityMonitor ? this.activityMonitor.getStatus() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
this.cancelShutdown();
|
||||
if (this.activityMonitor) {
|
||||
this.activityMonitor.stop();
|
||||
this.activityMonitor = null;
|
||||
}
|
||||
await this.stopBackend();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建后端管理器实例
|
||||
*/
|
||||
export function createBackendManager(config: Config, httpClient: HttpClient): BackendManager {
|
||||
return new BackendManager(config, httpClient);
|
||||
}
|
||||
70
typescript/src/utils/config.ts
Normal file
70
typescript/src/utils/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { ConfigSchema } from './validators.js';
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
/**
|
||||
* 解析逗号分隔的字符串为数组
|
||||
*/
|
||||
function parseCommaSeparated(value: string | undefined): string[] {
|
||||
if (!value || value.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
return value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境变量加载配置
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
const rawConfig = {
|
||||
serverUrl: process.env.PANSOU_SERVER_URL,
|
||||
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) * 1000 : undefined,
|
||||
maxResults: process.env.MAX_RESULTS ? parseInt(process.env.MAX_RESULTS) : undefined,
|
||||
maxConcurrentRequests: process.env.MAX_CONCURRENT_REQUESTS ? parseInt(process.env.MAX_CONCURRENT_REQUESTS) : undefined,
|
||||
enableCache: process.env.ENABLE_CACHE === 'true',
|
||||
defaultChannels: parseCommaSeparated(process.env.DEFAULT_CHANNELS),
|
||||
defaultPlugins: parseCommaSeparated(process.env.DEFAULT_PLUGINS),
|
||||
defaultCloudTypes: parseCommaSeparated(process.env.DEFAULT_CLOUD_TYPES),
|
||||
logLevel: process.env.LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug' | undefined,
|
||||
// 后端服务自动管理配置
|
||||
autoStartBackend: process.env.AUTO_START_BACKEND !== 'false', // 默认为true,除非明确设置为false
|
||||
backendShutdownDelay: process.env.BACKEND_SHUTDOWN_DELAY ? parseInt(process.env.BACKEND_SHUTDOWN_DELAY) : undefined,
|
||||
backendStartupTimeout: process.env.BACKEND_STARTUP_TIMEOUT ? parseInt(process.env.BACKEND_STARTUP_TIMEOUT) : undefined,
|
||||
// 空闲超时配置
|
||||
idleTimeout: process.env.IDLE_TIMEOUT ? parseInt(process.env.IDLE_TIMEOUT) : undefined,
|
||||
enableIdleShutdown: process.env.ENABLE_IDLE_SHUTDOWN !== 'false', // 默认为true,除非明确设置为false
|
||||
// 项目根目录路径
|
||||
projectRootPath: process.env.PROJECT_ROOT_PATH,
|
||||
// Docker部署模式
|
||||
dockerMode: process.env.DOCKER_MODE === 'true'
|
||||
};
|
||||
|
||||
// 移除undefined值,让zod使用默认值
|
||||
const cleanConfig = Object.fromEntries(
|
||||
Object.entries(rawConfig).filter(([_, value]) => value !== undefined)
|
||||
);
|
||||
|
||||
try {
|
||||
return ConfigSchema.parse(cleanConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('配置验证失败:', error.errors);
|
||||
throw new Error(`配置验证失败: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 从validators模块重新导出类型和验证函数
|
||||
export {
|
||||
SUPPORTED_CLOUD_TYPES,
|
||||
SOURCE_TYPES,
|
||||
RESULT_TYPES,
|
||||
type CloudType,
|
||||
type SourceType,
|
||||
type ResultType,
|
||||
validateCloudTypes,
|
||||
validateSourceType,
|
||||
validateResultType
|
||||
} from './validators.js';
|
||||
286
typescript/src/utils/http-client.ts
Normal file
286
typescript/src/utils/http-client.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { Config, validateCloudTypes } from './config.js';
|
||||
import { CloudType, SourceType, ResultType } from './config.js';
|
||||
|
||||
/**
|
||||
* 搜索请求参数
|
||||
*/
|
||||
export interface SearchRequest {
|
||||
kw: string; // 搜索关键词
|
||||
channels?: string[]; // 搜索的频道列表
|
||||
conc?: number; // 并发搜索数量
|
||||
refresh?: boolean; // 强制刷新,不使用缓存
|
||||
res?: ResultType; // 结果类型
|
||||
src?: SourceType; // 数据来源类型
|
||||
plugins?: string[]; // 指定搜索的插件列表
|
||||
cloud_types?: CloudType[]; // 指定返回的网盘类型列表
|
||||
ext?: Record<string, any>; // 扩展参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 网盘链接
|
||||
*/
|
||||
export interface Link {
|
||||
type: string;
|
||||
url: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果项
|
||||
*/
|
||||
export interface SearchResult {
|
||||
message_id: string;
|
||||
unique_id: string;
|
||||
channel: string;
|
||||
datetime: string;
|
||||
title: string;
|
||||
content: string;
|
||||
links: Link[];
|
||||
tags?: string[];
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并后的网盘链接
|
||||
*/
|
||||
export interface MergedLink {
|
||||
url: string;
|
||||
password: string;
|
||||
note: string;
|
||||
datetime: string;
|
||||
source?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按网盘类型分组的合并链接
|
||||
*/
|
||||
export type MergedLinks = Record<string, MergedLink[]>;
|
||||
|
||||
/**
|
||||
* 搜索响应数据
|
||||
*/
|
||||
export interface SearchResponseData {
|
||||
total: number;
|
||||
results?: SearchResult[];
|
||||
merged_by_type?: MergedLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* API响应格式
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查响应
|
||||
*/
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
plugins_enabled: boolean;
|
||||
channels: string[];
|
||||
channels_count: number;
|
||||
plugin_count?: number;
|
||||
plugins?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP客户端类
|
||||
*/
|
||||
export class HttpClient {
|
||||
private client: AxiosInstance;
|
||||
private config: Config;
|
||||
private silentMode: boolean = false;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.client = axios.create({
|
||||
baseURL: config.serverUrl,
|
||||
timeout: config.requestTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'PanSou-MCP-Server/1.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.config.logLevel === 'debug') {
|
||||
console.log(`[HTTP] 请求: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
if (config.data) {
|
||||
console.log(`[HTTP] 请求数据:`, config.data);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[HTTP] 请求错误:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
if (this.config.logLevel === 'debug') {
|
||||
console.log(`[HTTP] 响应: ${response.status} ${response.config.url}`);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (!this.silentMode) {
|
||||
if (error.response) {
|
||||
console.error(`[HTTP] 响应错误: ${error.response.status} ${error.response.statusText}`);
|
||||
if (this.config.logLevel === 'debug') {
|
||||
console.error('[HTTP] 错误详情:', error.response.data);
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('[HTTP] 网络错误: 无法连接到服务器');
|
||||
} else {
|
||||
console.error('[HTTP] 请求配置错误:', error.message);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索网盘资源
|
||||
*/
|
||||
async search(params: SearchRequest): Promise<SearchResponseData> {
|
||||
try {
|
||||
// 参数验证
|
||||
if (!params.kw || params.kw.trim() === '') {
|
||||
throw new Error('搜索关键词不能为空');
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
const requestData: SearchRequest = {
|
||||
kw: params.kw.trim(),
|
||||
channels: params.channels || this.config.defaultChannels,
|
||||
conc: params.conc,
|
||||
refresh: params.refresh || false,
|
||||
res: params.res || 'merge',
|
||||
src: params.src || 'all',
|
||||
plugins: params.plugins || this.config.defaultPlugins,
|
||||
cloud_types: params.cloud_types ? validateCloudTypes(params.cloud_types.map(String)) : this.config.defaultCloudTypes,
|
||||
ext: params.ext || {}
|
||||
};
|
||||
|
||||
// 清理空数组
|
||||
if (requestData.channels && requestData.channels.length === 0) {
|
||||
delete requestData.channels;
|
||||
}
|
||||
if (requestData.plugins && requestData.plugins.length === 0) {
|
||||
delete requestData.plugins;
|
||||
}
|
||||
if (requestData.cloud_types && requestData.cloud_types.length === 0) {
|
||||
delete requestData.cloud_types;
|
||||
}
|
||||
|
||||
const response: AxiosResponse<ApiResponse<SearchResponseData>> = await this.client.post('/api/search', requestData);
|
||||
|
||||
// 兼容不同版本的响应格式:源码版本使用200,Docker版本使用0
|
||||
if (response.data.code !== 200 && response.data.code !== 0) {
|
||||
throw new Error(response.data.message || '搜索请求失败');
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error('服务器返回数据为空');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.data?.message) {
|
||||
throw new Error(`搜索失败: ${error.response.data.message}`);
|
||||
} else if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error(`无法连接到PanSou服务器 (${this.config.serverUrl})。请确保服务器正在运行。`);
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
throw new Error('请求超时,请稍后重试');
|
||||
} else {
|
||||
throw new Error(`网络错误: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务健康状态
|
||||
*/
|
||||
async checkHealth(): Promise<HealthResponse> {
|
||||
try {
|
||||
const response: AxiosResponse<HealthResponse> = await this.client.get('/api/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
throw new Error(`无法连接到PanSou服务器 (${this.config.serverUrl})。请确保服务器正在运行。`);
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
throw new Error('健康检查超时');
|
||||
} else {
|
||||
throw new Error(`健康检查失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.checkHealth();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器URL
|
||||
*/
|
||||
getServerUrl(): string {
|
||||
return this.config.serverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
|
||||
// 更新axios实例配置
|
||||
this.client.defaults.baseURL = this.config.serverUrl;
|
||||
this.client.defaults.timeout = this.config.requestTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置静默模式
|
||||
*/
|
||||
setSilentMode(silent: boolean): void {
|
||||
this.silentMode = silent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静默模式状态
|
||||
*/
|
||||
isSilentMode(): boolean {
|
||||
return this.silentMode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建HTTP客户端实例
|
||||
*/
|
||||
export function createHttpClient(config: Config): HttpClient {
|
||||
return new HttpClient(config);
|
||||
}
|
||||
102
typescript/src/utils/validators.ts
Normal file
102
typescript/src/utils/validators.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 支持的网盘类型列表
|
||||
*/
|
||||
export const SUPPORTED_CLOUD_TYPES = [
|
||||
'baidu', // 百度网盘
|
||||
'aliyun', // 阿里云盘
|
||||
'quark', // 夸克网盘
|
||||
'tianyi', // 天翼云盘
|
||||
'uc', // UC网盘
|
||||
'mobile', // 移动云盘
|
||||
'115', // 115网盘
|
||||
'pikpak', // PikPak
|
||||
'xunlei', // 迅雷网盘
|
||||
'123', // 123网盘
|
||||
'magnet', // 磁力链接
|
||||
'ed2k', // 电驴链接
|
||||
'others' // 其他
|
||||
] as const;
|
||||
|
||||
export type CloudType = typeof SUPPORTED_CLOUD_TYPES[number];
|
||||
|
||||
/**
|
||||
* 支持的数据来源类型
|
||||
*/
|
||||
export const SOURCE_TYPES = ['all', 'tg', 'plugin'] as const;
|
||||
export type SourceType = typeof SOURCE_TYPES[number];
|
||||
|
||||
/**
|
||||
* 支持的结果类型
|
||||
*/
|
||||
export const RESULT_TYPES = ['all', 'results', 'merge'] as const;
|
||||
export type ResultType = typeof RESULT_TYPES[number];
|
||||
|
||||
/**
|
||||
* 配置验证模式
|
||||
*/
|
||||
export const ConfigSchema = z.object({
|
||||
serverUrl: z.string().url().default('http://localhost:8888'),
|
||||
requestTimeout: z.number().positive().default(30000),
|
||||
maxResults: z.number().positive().default(100),
|
||||
maxConcurrentRequests: z.number().positive().default(5),
|
||||
enableCache: z.boolean().default(false),
|
||||
defaultChannels: z.array(z.string()).default([]),
|
||||
defaultPlugins: z.array(z.string()).default([]),
|
||||
defaultCloudTypes: z.array(z.enum(['baidu', 'aliyun', 'quark', 'tianyi', 'uc', 'mobile', '115', 'pikpak', 'xunlei', '123', 'magnet', 'ed2k', 'others'])).default([]),
|
||||
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
||||
// 后端服务自动管理配置
|
||||
autoStartBackend: z.boolean().default(true),
|
||||
backendShutdownDelay: z.number().positive().default(5000),
|
||||
backendStartupTimeout: z.number().positive().default(30000),
|
||||
// 空闲超时配置(毫秒)
|
||||
idleTimeout: z.number().positive().default(300000), // 默认5分钟
|
||||
enableIdleShutdown: z.boolean().default(true),
|
||||
// 项目根目录路径
|
||||
projectRootPath: z.string().optional(),
|
||||
// Docker部署模式(当设置为true时,不会尝试启动本地进程)
|
||||
dockerMode: z.boolean().default(false)
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证网盘类型
|
||||
*/
|
||||
export function validateCloudTypes(cloudTypes: string[]): CloudType[] {
|
||||
const validTypes: CloudType[] = [];
|
||||
const invalidTypes: string[] = [];
|
||||
|
||||
for (const type of cloudTypes) {
|
||||
if (SUPPORTED_CLOUD_TYPES.includes(type as CloudType)) {
|
||||
validTypes.push(type as CloudType);
|
||||
} else {
|
||||
invalidTypes.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidTypes.length > 0) {
|
||||
throw new Error(`不支持的网盘类型: ${invalidTypes.join(', ')}。支持的类型: ${SUPPORTED_CLOUD_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
return validTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据来源类型
|
||||
*/
|
||||
export function validateSourceType(sourceType: string): SourceType {
|
||||
if (!SOURCE_TYPES.includes(sourceType as SourceType)) {
|
||||
throw new Error(`不支持的数据来源类型: ${sourceType}。支持的类型: ${SOURCE_TYPES.join(', ')}`);
|
||||
}
|
||||
return sourceType as SourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证结果类型
|
||||
*/
|
||||
export function validateResultType(resultType: string): ResultType {
|
||||
if (!RESULT_TYPES.includes(resultType as ResultType)) {
|
||||
throw new Error(`不支持的结果类型: ${resultType}。支持的类型: ${RESULT_TYPES.join(', ')}`);
|
||||
}
|
||||
return resultType as ResultType;
|
||||
}
|
||||
41
typescript/tsconfig.json
Normal file
41
typescript/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": false,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user