commit 480f224ab6689f2fa1cf3ac51c50c70fd0b97d21 Author: theluyuan <1162963624@qq.com> Date: Thu Oct 30 23:05:24 2025 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b0ea803 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,68 @@ +# 排除不需要复制到Docker镜像的文件和目录 + +# Node相关 +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 系统文件 +.DS_Store +Thumbs.db + +# IDE和编辑器 +.vscode +.idea +*.swp +*.swo + +# Git +.git +.gitignore + +# 日志文件 +logs +*.log + +# 临时文件 +tmp +temp + +# Docker相关 +Dockerfile +.dockerignore +docker-compose.yml +docker-compose.override.yml + +# 文档 +README.md +*.md + +# 测试 +test +tests +__tests__ +coverage +.nyc_output + +# 缓存 +.cache +.npm + +# 环境变量文件 +.env +.env.local +.env.development +.env.test +.env.production + +# 数据库文件(通常在容器内创建) +*.db +*.sqlite +*.sqlite3 +data/ + +# 其他 +*.pid +*.seed +*.pid.lock \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3979214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +proxies.db +logs +data \ No newline at end of file diff --git a/DOCKER_README.md b/DOCKER_README.md new file mode 100644 index 0000000..80ba895 --- /dev/null +++ b/DOCKER_README.md @@ -0,0 +1,227 @@ +# 代理IP管理系统 Docker 部署指南 + +## 🐳 Docker 快速开始 + +### 方式一:使用 Docker Compose(推荐) + +1. **克隆项目** +```bash +git clone +cd dailiip +``` + +2. **构建并启动容器** +```bash +docker-compose up -d +``` + +3. **查看日志** +```bash +docker-compose logs -f proxy-manager +``` + +4. **访问应用** +- 浏览器访问:http://localhost:3000 +- API地址:http://localhost:3000/api + +### 方式二:使用 Docker 命令 + +1. **构建镜像** +```bash +docker build -t proxy-ip-manager . +``` + +2. **运行容器** +```bash +# 基本运行 +docker run -d -p 3000:3000 --name proxy-manager proxy-ip-manager + +# 带数据持久化 +docker run -d \ + -p 3000:3000 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/logs:/app/logs \ + --name proxy-manager \ + --restart unless-stopped \ + proxy-ip-manager +``` + +## 📁 目录结构 + +``` +dailiip/ +├── Dockerfile # Docker构建文件 +├── docker-compose.yml # Docker Compose配置 +├── .dockerignore # Docker忽略文件 +├── src/ # 源代码 +├── public/ # 静态文件 +├── data/ # 数据库文件(运行时创建) +└── logs/ # 日志文件(运行时创建) +``` + +## ⚙️ 环境变量 + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| NODE_ENV | production | 运行环境 | +| PORT | 3000 | 应用端口 | + +## 📊 数据持久化 + +### Docker Compose 方式 +数据库文件会自动保存到 `./data` 目录,日志保存到 `./logs` 目录。 + +### Docker 命令方式 +使用 `-v` 参数挂载本地目录: + +```bash +-v /path/to/your/data:/app/data +-v /path/to/your/logs:/app/logs +``` + +## 🔧 常用命令 + +### Docker Compose +```bash +# 启动服务 +docker-compose up -d + +# 停止服务 +docker-compose down + +# 查看日志 +docker-compose logs -f + +# 重新构建 +docker-compose up -d --build + +# 进入容器 +docker-compose exec proxy-manager sh +``` + +### Docker 命令 +```bash +# 查看运行状态 +docker ps + +# 查看日志 +docker logs proxy-manager + +# 进入容器 +docker exec -it proxy-manager sh + +# 停止容器 +docker stop proxy-manager + +# 启动容器 +docker start proxy-manager + +# 删除容器 +docker rm proxy-manager +``` + +## 🔍 健康检查 + +容器包含内置的健康检查,每30秒检查一次: + +```bash +# 查看健康状态 +docker inspect proxy-manager | grep Health -A 10 + +# 查看健康检查日志 +docker inspect proxy-manager | grep Log -A 5 +``` + +## 🚨 故障排除 + +### 1. 容器无法启动 +```bash +# 查看详细错误日志 +docker-compose logs proxy-manager + +# 检查端口是否被占用 +netstat -tlnp | grep 3000 +``` + +### 2. 数据库问题 +```bash +# 检查数据目录权限 +ls -la data/ + +# 重新创建数据目录 +sudo rm -rf data logs +mkdir -p data logs +sudo chown -R 1000:1000 data logs +``` + +### 3. 性能优化 +```bash +# 限制内存使用 +docker run -d --memory=512m proxy-ip-manager + +# 设置CPU限制 +docker run -d --cpus=0.5 proxy-ip-manager +``` + +## 📈 监控 + +### 使用健康检查API +```bash +curl http://localhost:3000/api/health +``` + +### 查看资源使用情况 +```bash +# 查看容器资源使用 +docker stats proxy-manager + +# 查看磁盘使用 +docker system df +``` + +## 🔄 更新应用 + +```bash +# 拉取最新代码 +git pull + +# 重新构建并启动 +docker-compose up -d --build +``` + +## 🐛 调试模式 + +如需调试,可以临时修改环境变量: + +```bash +# 修改 docker-compose.yml +environment: + - NODE_ENV=development + - DEBUG=proxy:* + +# 或使用 Docker 命令 +docker run -d -e NODE_ENV=development -e DEBUG=proxy:* proxy-ip-manager +``` + +## 🔒 安全建议 + +1. **使用非root用户运行**:Dockerfile中已配置 +2. **限制网络访问**:根据需要配置防火墙 +3. **定期更新镜像**: + ```bash + docker pull node:24.11.0-alpine + docker-compose up -d --build + ``` +4. **备份数据**: + ```bash + # 备份数据库 + tar -czf backup-$(date +%Y%m%d).tar.gz data/ logs/ + ``` + +## 📞 支持 + +如遇问题,请检查: +1. Docker和Docker Compose版本是否兼容 +2. 端口3000是否被占用 +3. 磁盘空间是否充足 +4. 查看容器日志获取详细错误信息 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..edb1a3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# 使用官方Node.js运行时作为基础镜像 +FROM node:24.11.0-alpine + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV NODE_ENV=production +ENV PORT=3000 + +# 安装系统依赖(用于数据库编译) +RUN apk add --no-cache \ + sqlite \ + sqlite-dev \ + python3 \ + make \ + g++ + +# 复制package.json和package-lock.json(如果存在) +COPY package*.json ./ + +# 安装项目依赖 +RUN npm ci --only=production && npm cache clean --force + +# 复制项目文件 +COPY . . + +# 创建数据目录并设置权限 +RUN mkdir -p /app/data && \ + chown -R node:node /app + +# 切换到非root用户 +USER node + +# 暴露端口 +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" + +# 启动应用 +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..be8de36 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# 代理IP抓取和验证服务 + +一个基于Node.js的代理IP自动抓取、验证和管理系统,提供RESTful API接口和定时任务调度。 + +## 功能特性 + +- 🕷️ **自动抓取**: 从快代理网站抓取免费代理IP +- ✅ **自动验证**: 通过访问百度验证代理可用性 +- 🗄️ **数据存储**: 使用SQLite存储代理数据 +- 🔄 **定时任务**: 自动抓取和验证维护代理池 +- 🚀 **RESTful API**: 完整的API接口服务 +- 📊 **统计监控**: 实时代理池状态统计 +- 🛡️ **错误处理**: 完善的错误处理和重试机制 + +## 项目结构 + +``` +dailiip/ +├── src/ +│ ├── database/ +│ │ ├── db.js # 数据库连接管理 +│ │ └── models/ +│ │ └── proxy.js # 代理数据模型 +│ ├── services/ +│ │ ├── scraper.js # 代理抓取服务 +│ │ ├── validator.js # 代理验证服务 +│ │ └── scheduler.js # 定时任务调度器 +│ ├── routes/ +│ │ └── proxies.js # API路由 +│ └── app.js # 应用入口 +├── config/ +│ └── database.js # 数据库配置 +├── logs/ # 日志目录 +├── proxies.db # SQLite数据库 +├── package.json +└── README.md +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +npm install +``` + +### 2. 启动服务 + +```bash +npm start +``` + +服务将在 `http://localhost:3000` 启动 + +### 3. 验证服务 + +访问 `http://localhost:3000/health` 检查服务状态 + +## API接口 + +### 基础信息 + +- **基础URL**: `http://localhost:3000` +- **Content-Type**: `application/json` + +### 接口列表 + +#### 1. 获取服务信息 +``` +GET / +``` + +#### 2. 健康检查 +``` +GET /health +``` + +#### 3. 获取代理列表 +``` +GET /api/proxies?limit=100&offset=0&sortBy=response_time&order=ASC +``` +- `limit`: 限制数量 (默认100) +- `offset`: 偏移量 (默认0) +- `sortBy`: 排序字段 (response_time, created_at, updated_at, ip, port) +- `order`: 排序方式 (ASC, DESC) + +#### 4. 获取随机代理 +``` +GET /api/proxies/random?count=1 +``` +- `count`: 返回数量 (1-10) + +#### 5. 获取统计信息 +``` +GET /api/proxies/stats +``` + +#### 6. 验证单个代理 +``` +POST /api/proxies/verify +{ + "ip": "192.168.1.1", + "port": 8080 +} +``` + +#### 7. 批量验证代理 +``` +POST /api/proxies/verify-batch +{ + "proxies": [ + {"ip": "192.168.1.1", "port": 8080}, + {"ip": "192.168.1.2", "port": 8080} + ] +} +``` + +#### 8. 手动触发抓取 +``` +POST /api/proxies/scrape +{ + "pages": 5 +} +``` + +#### 9. 手动触发验证 +``` +POST /api/proxies/validate-all +{ + "limit": 50 +} +``` + +#### 10. 搜索代理 +``` +GET /api/proxies/search?ip=192.168.1.1&port=8080 +``` + +#### 11. 清理无效代理 +``` +DELETE /api/proxies/cleanup +``` + +## 定时任务 + +系统自动执行以下定时任务: + +### 抓取任务 +- **频率**: 每小时整点执行 +- **功能**: 抓取快代理网站前5页的代理IP +- **网址**: https://www.kuaidaili.com/free/inha/ + +### 验证任务 +- **频率**: 每10分钟执行一次 +- **功能**: 验证数据库中的代理IP可用性 +- **验证方式**: 通过访问 https://www.baidu.com +- **超时时间**: 8秒 + +### 健康检查 +- **频率**: 每小时30分执行 +- **功能**: 检查代理池健康状态 +- **应急处理**: 可用代理少于10个时触发紧急抓取 + +## 数据库结构 + +### proxies 表 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | INTEGER | 主键,自增 | +| ip | TEXT | IP地址 | +| port | INTEGER | 端口号 | +| location | TEXT | 地理位置 | +| speed | INTEGER | 响应速度(毫秒) | +| last_check_time | TEXT | 最后验证时间 | +| is_valid | INTEGER | 是否可用 (1:可用, 0:不可用) | +| response_time | INTEGER | 验证响应时间 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +**唯一约束**: (ip, port) + +## 使用示例 + +### 获取随机代理 + +```bash +curl http://localhost:3000/api/proxies/random +``` + +响应: +```json +{ + "success": true, + "data": { + "id": 1, + "ip": "101.132.134.160", + "port": 8888, + "location": "上海市", + "is_valid": 1, + "response_time": 150, + "created_at": "2025-10-30 12:00:00", + "updated_at": "2025-10-30 12:30:00" + } +} +``` + +### 验证代理 + +```bash +curl -X POST http://localhost:3000/api/proxies/verify \ + -H "Content-Type: application/json" \ + -d '{"ip":"101.132.134.160","port":8888}' +``` + +响应: +```json +{ + "success": true, + "data": { + "ip": "101.132.134.160", + "port": 8888, + "isValid": true, + "responseTime": 150, + "error": null + } +} +``` + +### 获取统计信息 + +```bash +curl http://localhost:3000/api/proxies/stats +``` + +响应: +```json +{ + "success": true, + "data": { + "total": 50, + "valid": 30, + "invalid": 20, + "validRate": "60.00%" + } +} +``` + +## 配置说明 + +### 环境变量 + +- `PORT`: 服务端口 (默认: 3000) + +### 数据库配置 + +数据库配置文件: `config/database.js` + +```javascript +const dbConfig = { + development: { + dialect: 'sqlite', + storage: path.join(__dirname, '../proxies.db') + } +}; +``` + +## 日志和监控 + +- 服务启动后会输出详细的执行日志 +- 通过 `/health` 接口可查看系统状态 +- 支持优雅关闭,保证数据完整性 + +## 注意事项 + +1. **合法使用**: 请遵守网站服务条款,合理使用代理IP +2. **频率控制**: 内置请求延迟和重试机制,避免对目标网站造成压力 +3. **数据质量**: 免费代理IP质量参差不齐,建议仅用于测试和学习 +4. **资源占用**: 定时任务会占用一定的系统资源 + +## 开发和部署 + +### 开发模式 +```bash +npm run dev +``` + +### 生产部署 + +建议使用 PM2 进行进程管理: + +```bash +npm install -g pm2 +pm2 start src/app.js --name "proxy-service" +pm2 logs proxy-service +pm2 status proxy-service +``` + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交Issue和Pull Request! \ No newline at end of file diff --git a/config/database.js b/config/database.js new file mode 100644 index 0000000..ae37333 --- /dev/null +++ b/config/database.js @@ -0,0 +1,10 @@ +const path = require('path'); + +const dbConfig = { + development: { + dialect: 'sqlite', + storage: path.join(__dirname, '../proxies.db') + } +}; + +module.exports = dbConfig; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a3b85c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + proxy-manager: + build: . + container_name: proxy-ip-manager + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + volumes: + # 持久化数据库数据 + - ./data:/app/data + # 持久化日志文件 + - ./logs:/app/logs + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + default: + name: proxy-manager-network \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2e697cd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2625 @@ +{ + "name": "dailiip", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dailiip", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.1", + "cheerio": "^1.1.2", + "express": "^5.1.0", + "node-cron": "^4.2.1", + "sqlite3": "^5.1.7", + "user-agent": "^1.0.4" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.79.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.79.0.tgz", + "integrity": "sha512-Pr/5KdBQGG8TirdkS0qN3B+f3eo8zTOfZQWAxHoJqopMz2/uvRnG+S4fWu/6AZxKei2CP2p/psdQ5HFC2Ap5BA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/user-agent": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/user-agent/-/user-agent-1.0.4.tgz", + "integrity": "sha512-NPTnJ89e6ttUK+Q3ZQ6aMFo4+4HAdvsb39IypyRw/bPjE/F8TjeVpB8uqFPnUCVbI6247qPryd8OLpkEYuOwWg==", + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bbc7e8 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "dailiip", + "version": "1.0.0", + "description": "代理IP抓取、验证和管理服务", + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "node src/app.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "keywords": ["proxy", "ip", "scraper", "validator", "api"], + "dependencies": { + "axios": "^1.13.1", + "cheerio": "^1.1.2", + "express": "^5.1.0", + "node-cron": "^4.2.1", + "sqlite3": "^5.1.7", + "user-agent": "^1.0.4" + } +} diff --git a/public/css/dashboard.css b/public/css/dashboard.css new file mode 100644 index 0000000..f3dae42 --- /dev/null +++ b/public/css/dashboard.css @@ -0,0 +1,363 @@ +/* 仪表板样式 */ + +body { + background-color: #f8f9fa; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +/* 导航栏样式 */ +.navbar-brand { + font-weight: bold; + font-size: 1.2rem; +} + +.nav-link.active { + font-weight: bold; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 0.25rem; +} + +/* 卡片样式 */ +.card { + border: none; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + transition: box-shadow 0.15s ease-in-out; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.card-header { + background-color: #fff; + border-bottom: 1px solid #dee2e6; + font-weight: 600; +} + +/* 状态卡片动画 */ +.card-body h3 { + transition: transform 0.3s ease; +} + +.card:hover h3 { + transform: scale(1.05); +} + +/* 按钮样式 */ +.btn { + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.15s ease-in-out; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); +} + +.btn:active { + transform: translateY(0); +} + +/* 表格样式 */ +.table { + font-size: 0.875rem; +} + +.table th { + border-top: none; + font-weight: 600; + color: #495057; + background-color: #f8f9fa; +} + +.table td { + vertical-align: middle; +} + +/* 状态徽章 */ +.status-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 1rem; + font-weight: 500; +} + +.status-success { + background-color: #d1e7dd; + color: #0f5132; +} + +.status-warning { + background-color: #fff3cd; + color: #664d03; +} + +.status-danger { + background-color: #f8d7da; + color: #842029; +} + +.status-info { + background-color: #cff4fc; + color: #055160; +} + +/* 加载动画 */ +.spinner-border-sm { + width: 1rem; + height: 1rem; +} + +/* 提示消息样式 */ +.alert { + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* 图表容器 */ +.chart-container { + position: relative; + height: 300px; +} + +/* 时间显示 */ +#currentTime { + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +/* 系统信息卡片 */ +.system-info-item { + padding: 0.5rem 0; + border-bottom: 1px solid #dee2e6; +} + +.system-info-item:last-child { + border-bottom: none; +} + +.system-info-label { + color: #6c757d; + font-size: 0.875rem; + font-weight: 500; +} + +.system-info-value { + color: #212529; + font-weight: 600; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .container-fluid { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .card-body { + padding: 1rem; + } + + .btn { + font-size: 0.875rem; + padding: 0.5rem 1rem; + } + + .table { + font-size: 0.8rem; + } + + h3 { + font-size: 1.5rem !important; + } +} + +/* 历史记录表格 */ +.history-table { + font-size: 0.8rem; +} + +.history-table .task-name { + font-weight: 500; + color: #495057; +} + +.history-table .status-badge { + min-width: 60px; + text-align: center; +} + +.history-table .duration { + font-family: 'Courier New', monospace; + color: #6c757d; +} + +/* 快速操作面板 */ +.quick-actions .btn { + margin-bottom: 0.5rem; +} + +.quick-actions .btn i { + margin-right: 0.5rem; +} + +/* 下次执行时间 */ +.next-run-time { + font-size: 0.8rem; + color: #6c757d; + font-family: 'Courier New', monospace; +} + +/* 系统状态指示器 */ +.status-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 0.5rem; +} + +.status-indicator.online { + background-color: #198754; + animation: pulse 2s infinite; +} + +.status-indicator.offline { + background-color: #dc3545; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(25, 135, 84, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); + } +} + +/* 内存使用条 */ +.memory-bar { + height: 4px; + background-color: #e9ecef; + border-radius: 2px; + overflow: hidden; + margin-top: 0.25rem; +} + +.memory-bar-fill { + height: 100%; + background-color: #198754; + transition: width 0.3s ease; +} + +/* 工具提示样式 */ +.tooltip-inner { + font-size: 0.875rem; + border-radius: 0.375rem; +} + +/* 页面加载动画 */ +.page-loading { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.page-loading .spinner-border { + width: 3rem; + height: 3rem; +} + +/* 数据表格空状态 */ +.empty-state { + text-align: center; + padding: 2rem; + color: #6c757d; +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h6 { + margin-bottom: 0.5rem; + color: #495057; +} + +/* 悬浮操作按钮 */ +.fab { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + z-index: 1000; + transition: all 0.3s ease; +} + +.fab:hover { + transform: scale(1.1); + box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.2); +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + body { + background-color: #212529; + color: #fff; + } + + .card { + background-color: #343a40; + color: #fff; + } + + .card-header { + background-color: #495057; + border-bottom-color: #6c757d; + } + + .table { + color: #fff; + } + + .table th { + background-color: #495057; + border-color: #6c757d; + } + + .navbar-dark .navbar-text { + color: rgba(255, 255, 255, 0.75); + } +} \ No newline at end of file diff --git a/public/history.html b/public/history.html new file mode 100644 index 0000000..4ececc4 --- /dev/null +++ b/public/history.html @@ -0,0 +1,395 @@ + + + + + + 代理IP管理系统 - 执行历史 + + + + + + + + + +
+ +
+
+
+
+

-

+

总任务数

+
+
+
+
+
+
+

-

+

成功任务

+
+
+
+
+
+
+

-

+

失败任务

+
+
+
+
+
+
+

-

+

成功率

+
+
+
+
+ + + + + +
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ 任务执行历史 +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + +
任务类型任务名称状态开始时间结束时间执行时长结果摘要操作
+
+ 加载中... +
+

正在加载任务历史...

+
+
+ + + +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ 系统日志 +
+
+
+
+ + + + + + + + + + + + + + + +
时间级别分类消息来源
+
+ 加载中... +
+

正在加载系统日志...

+
+
+ + + +
+
+
+ + +
+ +
+
+
+
+
任务执行趋势(7天)
+
+
+ +
+
+
+
+
+
+
日志级别分布(7天)
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ 详细统计 +
+
+
+
+
+
任务统计
+
+
+
+ 加载中... +
+
+
+
+
+
日志统计
+
+
+
+ 加载中... +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2af4e71 --- /dev/null +++ b/public/index.html @@ -0,0 +1,268 @@ + + + + + + 代理IP管理系统 - 仪表板 + + + + + + + + + +
+ +
+
+
+
+
+
+
总代理数量
+

-

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
可用代理
+

-

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
无效代理
+

-

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
可用率
+

-

+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 快速操作 +
+
+
+
+ + + + +
+ +
+ +
+ 下次执行时间 +
+ 抓取: - + 验证: - +
+
+
+
+
+ + +
+
+
+
+ 代理数量趋势(7天) +
+
+
+ +
+
+
+
+ + +
+ +
+
+
+
+ 最近抓取历史 +
+
+
+
+
+
+ 加载中... +
+
+
+
+
+
+ + +
+
+
+
+ 最近验证历史 +
+
+
+
+
+
+ 加载中... +
+
+
+
+
+
+
+ + +
+
+
+
+
+ 系统信息 +
+
+
+
+
+ 运行时间 +
-
+
+
+ 内存使用 +
-
+
+
+ 今日抓取 +
-
+
+
+ 今日验证 +
-
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/public/js/dashboard.js b/public/js/dashboard.js new file mode 100644 index 0000000..a143670 --- /dev/null +++ b/public/js/dashboard.js @@ -0,0 +1,590 @@ +// 仪表板JavaScript + +class Dashboard { + constructor() { + this.charts = {}; + this.refreshInterval = null; + this.init(); + } + + async init() { + console.log('初始化仪表板...'); + + // 初始化图表 + this.initCharts(); + + // 加载初始数据 + await this.loadDashboardData(); + + // 启动自动刷新 + this.startAutoRefresh(); + + // 更新时间显示 + this.updateCurrentTime(); + setInterval(() => this.updateCurrentTime(), 1000); + + console.log('仪表板初始化完成'); + } + + // 初始化图表 + initCharts() { + // 代理趋势图 + const ctx = document.getElementById('proxyTrendChart'); + if (ctx) { + this.charts.proxyTrend = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: '总代理数', + data: [], + borderColor: '#007bff', + backgroundColor: 'rgba(0, 123, 255, 0.1)', + tension: 0.4, + fill: true + }, { + label: '可用代理', + data: [], + borderColor: '#28a745', + backgroundColor: 'rgba(40, 167, 69, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + mode: 'index', + intersect: false, + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + precision: 0 + } + } + } + } + }); + } + } + + // 加载仪表板数据 + async loadDashboardData() { + try { + // 显示加载状态 + this.showLoading(true); + + // 获取统计数据 + const statsResponse = await fetch('/api/dashboard/stats'); + const statsData = await statsResponse.json(); + + if (statsData.success) { + this.updateStatistics(statsData.data); + this.updateCharts(statsData.data.charts); + this.updateRecentHistory(statsData.data.latest); + } + + // 获取实时状态 + const statusResponse = await fetch('/api/dashboard/status'); + const statusData = await statusResponse.json(); + + if (statusData.success) { + this.updateSystemInfo(statusData.data); + } + + } catch (error) { + console.error('加载仪表板数据失败:', error); + this.showAlert('加载数据失败: ' + error.message, 'danger'); + } finally { + this.showLoading(false); + } + } + + // 更新统计信息 + updateStatistics(data) { + document.getElementById('totalProxies').textContent = data.proxies.total || 0; + document.getElementById('validProxies').textContent = data.proxies.valid || 0; + document.getElementById('invalidProxies').textContent = data.proxies.invalid || 0; + document.getElementById('validRate').textContent = data.proxies.validRate || '0%'; + } + + // 更新图表 + updateCharts(chartData) { + // 更新代理趋势图 + if (this.charts.proxyTrend && chartData.daily_proxies) { + const labels = chartData.daily_proxies.map(item => + new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + ); + + this.charts.proxyTrend.data.labels = labels; + this.charts.proxyTrend.data.datasets[0].data = chartData.daily_proxies.map(item => item.total_added); + this.charts.proxyTrend.data.datasets[1].data = chartData.daily_proxies.map(item => item.valid_added); + this.charts.proxyTrend.update(); + } + } + + // 更新最近历史记录 + updateRecentHistory(latest) { + // 更新抓取历史 + if (latest.scrape && latest.scrape.length > 0) { + const scrapeHtml = this.generateHistoryTable(latest.scrape, 'scrape'); + document.getElementById('recentScrapeHistory').innerHTML = scrapeHtml; + } else { + document.getElementById('recentScrapeHistory').innerHTML = this.generateEmptyState('暂无抓取历史'); + } + + // 更新验证历史 + if (latest.validation && latest.validation.length > 0) { + const validationHtml = this.generateHistoryTable(latest.validation, 'validation'); + document.getElementById('recentValidationHistory').innerHTML = validationHtml; + } else { + document.getElementById('recentValidationHistory').innerHTML = this.generateEmptyState('暂无验证历史'); + } + } + + // 生成历史记录表格 + generateHistoryTable(history, type) { + if (!history || history.length === 0) { + return this.generateEmptyState('暂无历史记录'); + } + + let html = ` +
+ + + + + + + + + + + + `; + + history.forEach(item => { + const statusClass = this.getStatusClass(item.status); + const statusIcon = this.getStatusIcon(item.status); + const duration = item.duration ? this.formatDuration(item.duration) : '-'; + const result = this.getResultSummary(item.details, type); + + html += ` + + + + + + + + `; + }); + + html += ` + +
任务名称状态开始时间执行时长结果
${item.task_name}${statusIcon} ${this.getStatusText(item.status)}${this.formatDateTime(item.start_time)}${duration}${result}
+
+ `; + + return html; + } + + // 生成空状态 + generateEmptyState(message) { + return ` +
+ +
${message}
+ 等待任务执行... +
+ `; + } + + // 更新系统信息 + updateSystemInfo(data) { + // 更新运行时间 + document.getElementById('systemUptime').textContent = this.formatUptime(data.uptime); + + // 更新内存使用 + const memoryHtml = ` +
已使用: ${data.memory.heapUsed}MB / ${data.memory.heapTotal}MB
+
+
+
+ `; + document.getElementById('memoryUsage').innerHTML = memoryHtml; + + // 更新今日任务 + const todayScrape = data.today_tasks.scrape; + const todayValidation = data.today_tasks.validation; + + document.getElementById('todayScrape').innerHTML = ` +
+ 成功: ${todayScrape.success} + ${todayScrape.success_rate}% +
+ `; + + document.getElementById('todayValidation').innerHTML = ` +
+ 成功: ${todayValidation.success} + ${todayValidation.success_rate}% +
+ `; + + // 更新下次执行时间 + if (data.next_runs) { + document.getElementById('nextScrape').textContent = this.formatTime(data.next_runs.scrape); + document.getElementById('nextValidation').textContent = this.formatTime(data.next_runs.validation); + } + } + + // 开始抓取任务 + async startScrape() { + try { + this.showLoading(true, '正在启动抓取任务...'); + + const response = await fetch('/api/dashboard/actions/scrape', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pages: 5 }) + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert('抓取任务已启动', 'success'); + // 延迟刷新数据 + setTimeout(() => this.loadDashboardData(), 2000); + } else { + this.showAlert('启动抓取任务失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('启动抓取任务失败:', error); + this.showAlert('启动抓取任务失败: ' + error.message, 'danger'); + } finally { + this.showLoading(false); + } + } + + // 开始验证任务 + async startValidate() { + try { + this.showLoading(true, '正在启动验证任务...'); + + const response = await fetch('/api/dashboard/actions/validate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ limit: 50 }) + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert('验证任务已启动', 'success'); + // 延迟刷新数据 + setTimeout(() => this.loadDashboardData(), 2000); + } else { + this.showAlert('启动验证任务失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('启动验证任务失败:', error); + this.showAlert('启动验证任务失败: ' + error.message, 'danger'); + } finally { + this.showLoading(false); + } + } + + // 清理无效代理 + async cleanupInvalid() { + if (!confirm('确定要清理所有无效代理吗?此操作不可恢复。')) { + return; + } + + try { + const response = await fetch('/api/proxies/cleanup', { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert(result.message, 'success'); + await this.loadDashboardData(); + } else { + this.showAlert('清理失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('清理无效代理失败:', error); + this.showAlert('清理无效代理失败: ' + error.message, 'danger'); + } + } + + // 导出代理数据 + async exportProxies() { + try { + this.showLoading(true, '正在导出数据...'); + + const response = await fetch('/api/proxies?limit=1000'); + const result = await response.json(); + + if (result.success) { + const csv = this.convertToCSV(result.data); + this.downloadCSV(csv, `proxies_${new Date().toISOString().slice(0, 10)}.csv`); + this.showAlert('数据导出成功', 'success'); + } else { + this.showAlert('导出数据失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('导出数据失败:', error); + this.showAlert('导出数据失败: ' + error.message, 'danger'); + } finally { + this.showLoading(false); + } + } + + // 工具函数 + + getStatusClass(status) { + const statusClasses = { + 'success': 'bg-success', + 'failed': 'bg-danger', + 'running': 'bg-warning', + 'pending': 'bg-secondary' + }; + return statusClasses[status] || 'bg-secondary'; + } + + getStatusIcon(status) { + const statusIcons = { + 'success': '✓', + 'failed': '✗', + 'running': '⏳', + 'pending': '⏸' + }; + return statusIcons[status] || '?'; + } + + getStatusText(status) { + const statusTexts = { + 'success': '成功', + 'failed': '失败', + 'running': '运行中', + 'pending': '等待中' + }; + return statusTexts[status] || status; + } + + getResultSummary(details, type) { + if (!details) return '-'; + + if (type === 'scrape') { + return `抓取 ${details.scraped || 0} 个,可用 ${details.valid || 0} 个`; + } else if (type === 'validation') { + return `验证 ${details.validated || 0} 个,有效 ${details.valid || 0} 个`; + } + + return '-'; + } + + formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + formatTime(dateString) { + const date = new Date(dateString); + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } + + formatDuration(ms) { + if (!ms) return '-'; + + if (ms < 1000) { + return ms + 'ms'; + } else if (ms < 60000) { + return (ms / 1000).toFixed(1) + 's'; + } else { + return (ms / 60000).toFixed(1) + 'min'; + } + } + + formatUptime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + updateCurrentTime() { + const now = new Date(); + const timeString = now.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + const timeElement = document.getElementById('currentTime'); + if (timeElement) { + timeElement.textContent = timeString; + } + } + + showLoading(show, message = '加载中...') { + // 实现加载状态显示 + const loadingElements = document.querySelectorAll('.spinner-border'); + loadingElements.forEach(element => { + element.style.display = show ? 'inline-block' : 'none'; + }); + } + + showAlert(message, type = 'info') { + const alertContainer = document.getElementById('alertContainer'); + if (!alertContainer) return; + + const alertId = 'alert-' + Date.now(); + const alertHtml = ` + + `; + + alertContainer.insertAdjacentHTML('beforeend', alertHtml); + + // 自动移除提示 + setTimeout(() => { + const alertElement = document.getElementById(alertId); + if (alertElement) { + alertElement.remove(); + } + }, 5000); + } + + getAlertIcon(type) { + const icons = { + 'success': 'check-circle-fill', + 'danger': 'exclamation-triangle-fill', + 'warning': 'exclamation-triangle-fill', + 'info': 'info-circle-fill' + }; + return icons[type] || 'info-circle-fill'; + } + + convertToCSV(data) { + if (!data || data.length === 0) return ''; + + const headers = ['IP', '端口', '位置', '响应时间', '是否可用', '创建时间']; + const csvRows = [headers.join(',')]; + + data.forEach(proxy => { + const row = [ + proxy.ip, + proxy.port, + `"${proxy.location || ''}"`, + proxy.response_time || '', + proxy.is_valid ? '是' : '否', + proxy.created_at + ]; + csvRows.push(row.join(',')); + }); + + return csvRows.join('\n'); + } + + downloadCSV(csv, filename) { + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + startAutoRefresh() { + // 每30秒刷新一次数据 + this.refreshInterval = setInterval(() => { + this.loadDashboardData(); + }, 30000); + } + + stopAutoRefresh() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } +} + +// 全局函数(供HTML调用) +let dashboard; + +async function refreshData() { + if (dashboard) { + await dashboard.loadDashboardData(); + dashboard.showAlert('数据已刷新', 'info'); + } +} + +async function startScrape() { + if (dashboard) { + await dashboard.startScrape(); + } +} + +async function startValidate() { + if (dashboard) { + await dashboard.startValidate(); + } +} + +async function cleanupInvalid() { + if (dashboard) { + await dashboard.cleanupInvalid(); + } +} + +async function exportProxies() { + if (dashboard) { + await dashboard.exportProxies(); + } +} + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + dashboard = new Dashboard(); +}); + +// 页面卸载时清理 +window.addEventListener('beforeunload', () => { + if (dashboard) { + dashboard.stopAutoRefresh(); + } +}); \ No newline at end of file diff --git a/public/js/history.js b/public/js/history.js new file mode 100644 index 0000000..157bbfa --- /dev/null +++ b/public/js/history.js @@ -0,0 +1,751 @@ +// 执行历史页面JavaScript + +class HistoryManager { + constructor() { + this.currentTaskPage = 1; + this.currentLogsPage = 1; + this.pageSize = 20; + this.charts = {}; + this.searchParams = { + taskType: '', + taskStatus: '', + startDate: '', + endDate: '' + }; + this.logSearchParams = { + level: '', + category: '', + keyword: '' + }; + + this.init(); + } + + async init() { + console.log('初始化执行历史页面...'); + + // 绑定事件 + this.bindEvents(); + + // 初始化图表 + this.initCharts(); + + // 加载初始数据 + await this.loadTaskHistory(); + await this.loadSystemLogs(); + await this.loadStatistics(); + + console.log('执行历史页面初始化完成'); + } + + bindEvents() { + // 任务历史筛选 + document.getElementById('taskFilterForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.searchParams.taskType = document.getElementById('taskTypeFilter').value; + this.searchParams.taskStatus = document.getElementById('taskStatusFilter').value; + this.searchParams.startDate = document.getElementById('startDate').value; + this.searchParams.endDate = document.getElementById('endDate').value; + this.currentTaskPage = 1; + this.loadTaskHistory(); + }); + + // 系统日志筛选 + document.getElementById('logFilterForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.logSearchParams.level = document.getElementById('logLevelFilter').value; + this.logSearchParams.category = document.getElementById('logCategoryFilter').value; + this.logSearchParams.keyword = document.getElementById('logSearchKeyword').value; + this.currentLogsPage = 1; + this.loadSystemLogs(); + }); + + // 选项卡切换事件 + document.getElementById('stats-tab').addEventListener('shown.bs.tab', () => { + this.loadStatistics(); + this.updateCharts(); + }); + } + + initCharts() { + // 任务趋势图 + const taskTrendCtx = document.getElementById('taskTrendChart'); + if (taskTrendCtx) { + this.charts.taskTrend = new Chart(taskTrendCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: '成功任务', + data: [], + borderColor: '#28a745', + backgroundColor: 'rgba(40, 167, 69, 0.1)', + tension: 0.4 + }, { + label: '失败任务', + data: [], + borderColor: '#dc3545', + backgroundColor: 'rgba(220, 53, 69, 0.1)', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + precision: 0 + } + } + } + } + }); + } + + // 日志级别分布图 + const logLevelCtx = document.getElementById('logLevelChart'); + if (logLevelCtx) { + this.charts.logLevel = new Chart(logLevelCtx, { + type: 'doughnut', + data: { + labels: ['错误', '警告', '信息', '调试'], + datasets: [{ + data: [0, 0, 0, 0], + backgroundColor: ['#dc3545', '#ffc107', '#17a2b8', '#6c757d'] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + } + } + } + }); + } + } + + async loadTaskHistory() { + try { + this.showTaskLoading(true); + + // 构建查询参数 + const params = new URLSearchParams({ + limit: this.pageSize, + offset: (this.currentTaskPage - 1) * this.pageSize + }); + + if (this.searchParams.taskType) params.append('taskType', this.searchParams.taskType); + if (this.searchParams.taskStatus) params.append('status', this.searchParams.taskStatus); + + const response = await fetch(`/api/history?${params}`); + const result = await response.json(); + + if (result.success) { + this.renderTaskHistoryTable(result.data); + this.renderTaskPagination(result.pagination); + } else { + this.showAlert('加载任务历史失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('加载任务历史失败:', error); + this.showAlert('加载任务历史失败: ' + error.message, 'danger'); + } finally { + this.showTaskLoading(false); + } + } + + async loadSystemLogs() { + try { + this.showLogsLoading(true); + + // 构建查询参数 + const params = new URLSearchParams({ + limit: this.pageSize, + offset: (this.currentLogsPage - 1) * this.pageSize + }); + + if (this.logSearchParams.level) params.append('level', this.logSearchParams.level); + if (this.logSearchParams.category) params.append('category', this.logSearchParams.category); + + let response; + if (this.logSearchParams.keyword) { + response = await fetch(`/api/history/logs/search?keyword=${encodeURIComponent(this.logSearchParams.keyword)}&limit=${this.pageSize}`); + } else { + response = await fetch(`/api/history/logs/system?${params}`); + } + + const result = await response.json(); + + if (result.success) { + this.renderSystemLogsTable(result.data); + this.renderLogsPagination(result.pagination); + } else { + this.showAlert('加载系统日志失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('加载系统日志失败:', error); + this.showAlert('加载系统日志失败: ' + error.message, 'danger'); + } finally { + this.showLogsLoading(false); + } + } + + async loadStatistics() { + try { + // 加载任务统计 + const taskStatsResponse = await fetch('/api/history/stats/summary'); + const taskStatsResult = await taskStatsResponse.json(); + + if (taskStatsResult.success) { + this.updateTaskStatistics(taskStatsResult.data.summary); + this.renderTaskStats(taskStatsResult.data); + } + + // 加载日志统计 + const logStatsResponse = await fetch('/api/history/logs/stats'); + const logStatsResult = await logStatsResponse.json(); + + if (logStatsResult.success) { + this.updateLogStatistics(logStatsResult.data.summary); + this.renderLogStats(logStatsResult.data); + } + + } catch (error) { + console.error('加载统计数据失败:', error); + this.showAlert('加载统计数据失败: ' + error.message, 'danger'); + } + } + + renderTaskHistoryTable(tasks) { + const tbody = document.getElementById('taskHistoryTableBody'); + + if (tasks.length === 0) { + tbody.innerHTML = ` + + + +

暂无任务历史

+ + + `; + return; + } + + tbody.innerHTML = tasks.map(task => ` + + + ${this.getTaskTypeLabel(task.task_type)} + + ${task.task_name} + + + + ${this.getStatusText(task.status)} + + + + ${this.formatDateTime(task.start_time)} + + + ${task.end_time ? this.formatDateTime(task.end_time) : '-'} + + + ${task.duration ? this.formatDuration(task.duration) : '-'} + + + ${task.result_summary || '-'} + + + + + + `).join(''); + } + + renderSystemLogsTable(logs) { + const tbody = document.getElementById('systemLogsTableBody'); + + if (logs.length === 0) { + tbody.innerHTML = ` + + + +

暂无系统日志

+ + + `; + return; + } + + tbody.innerHTML = logs.map(log => ` + + + ${this.formatDateTime(log.timestamp)} + + + ${log.level.toUpperCase()} + + + ${log.category || '-'} + + + ${log.message} + + + ${log.source} + + + `).join(''); + } + + renderTaskPagination(pagination) { + const paginationElement = document.getElementById('taskPagination'); + this.renderPagination(paginationElement, pagination, this.currentTaskPage, (page) => { + this.currentTaskPage = page; + this.loadTaskHistory(); + }); + } + + renderLogsPagination(pagination) { + const paginationElement = document.getElementById('logsPagination'); + this.renderPagination(paginationElement, pagination, this.currentLogsPage, (page) => { + this.currentLogsPage = page; + this.loadSystemLogs(); + }); + } + + renderPagination(container, pagination, currentPage, onPageChange) { + const totalPages = Math.ceil(pagination.total / pagination.limit); + + if (totalPages <= 1) { + container.innerHTML = ''; + return; + } + + let paginationHTML = ''; + + // 上一页 + paginationHTML += ` +
  • + + + +
  • + `; + + // 页码 + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + for (let i = startPage; i <= endPage; i++) { + paginationHTML += ` +
  • + ${i} +
  • + `; + } + + // 下一页 + paginationHTML += ` +
  • + + + +
  • + `; + + container.innerHTML = paginationHTML; + } + + updateTaskStatistics(stats) { + document.getElementById('totalTasks').textContent = stats.total; + document.getElementById('successTasks').textContent = stats.success; + document.getElementById('failedTasks').textContent = stats.failed; + document.getElementById('successRate').textContent = stats.success_rate + '%'; + } + + updateLogStatistics(stats) { + // 更新日志级别图表 + if (this.charts.logLevel) { + this.charts.logLevel.data.datasets[0].data = [ + stats.error, + stats.warning, + stats.info, + stats.debug + ]; + this.charts.logLevel.update(); + } + } + + renderTaskStats(data) { + const content = document.getElementById('taskStatsContent'); + + if (!data.daily || data.daily.length === 0) { + content.innerHTML = '

    暂无数据

    '; + return; + } + + let html = ''; + html += ''; + + data.daily.slice(0, 7).forEach(day => { + const successRate = day.total > 0 ? ((day.success / day.total) * 100).toFixed(1) : '0'; + html += ` + + + + + + + + `; + }); + + html += '
    日期总任务成功失败成功率
    ${this.formatDate(day.date)}${day.total}${day.success}${day.failed}${successRate}%
    '; + content.innerHTML = html; + } + + renderLogStats(data) { + const content = document.getElementById('logStatsContent'); + + const summary = data.summary; + let html = '
    '; + html += '
    总日志数:
    ' + summary.total + '
    '; + html += '
    错误:
    ' + summary.error + '
    '; + html += '
    警告:
    ' + summary.warning + '
    '; + html += '
    信息:
    ' + summary.info + '
    '; + html += '
    '; + + content.innerHTML = html; + } + + async updateCharts() { + try { + // 加载每日任务统计 + const response = await fetch('/api/dashboard/charts/tasks'); + const result = await response.json(); + + if (result.success && this.charts.taskTrend) { + const labels = result.data.map(item => this.formatDate(item.date)); + const successData = result.data.map(item => item.scrape_success + item.validation_success); + const failedData = result.data.map(item => item.scrape_failed + item.validation_failed); + + this.charts.taskTrend.data.labels = labels; + this.charts.taskTrend.data.datasets[0].data = successData; + this.charts.taskTrend.data.datasets[1].data = failedData; + this.charts.taskTrend.update(); + } + } catch (error) { + console.error('更新图表失败:', error); + } + } + + async showTaskDetail(taskId) { + try { + const response = await fetch(`/api/history/${taskId}`); + const result = await response.json(); + + if (result.success) { + const task = result.data; + const content = document.getElementById('taskDetailContent'); + + content.innerHTML = ` +
    +
    + 任务类型:
    + ${this.getTaskTypeLabel(task.task_type)} +
    +
    + 任务名称:
    + ${task.task_name} +
    +
    +
    +
    +
    + 状态:
    + + ${this.getStatusText(task.status)} + +
    +
    + 执行时长:
    + ${task.duration ? this.formatDuration(task.duration) : '-'} +
    +
    +
    +
    +
    + 开始时间:
    + ${this.formatDateTime(task.start_time)} +
    +
    + 结束时间:
    + ${task.end_time ? this.formatDateTime(task.end_time) : '-'} +
    +
    +
    +
    +
    + 结果摘要:
    +

    ${task.result_summary || '暂无摘要'}

    +
    +
    + ${task.error_message ? ` +
    +
    + 错误信息:
    +
    ${task.error_message}
    +
    +
    + ` : ''} + ${task.details ? ` +
    +
    + 详细信息:
    +
    ${JSON.stringify(task.details, null, 2)}
    +
    +
    + ` : ''} + `; + + // 显示模态框 + const modal = new bootstrap.Modal(document.getElementById('taskDetailModal')); + modal.show(); + } else { + this.showAlert('获取任务详情失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('获取任务详情失败:', error); + this.showAlert('获取任务详情失败: ' + error.message, 'danger'); + } + } + + async exportTaskHistory() { + try { + const params = new URLSearchParams(); + if (this.searchParams.taskType) params.append('taskType', this.searchParams.taskType); + if (this.searchParams.taskStatus) params.append('status', this.searchParams.taskStatus); + + const response = await fetch(`/api/history/export?${params}&format=csv`); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `task_history_${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + this.showAlert('任务历史导出成功', 'success'); + } else { + this.showAlert('导出失败', 'danger'); + } + } catch (error) { + console.error('导出任务历史失败:', error); + this.showAlert('导出任务历史失败: ' + error.message, 'danger'); + } + } + + async cleanupLogs() { + if (!confirm('确定要清理30天前的历史记录吗?此操作不可恢复。')) { + return; + } + + try { + const response = await fetch('/api/history/cleanup', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ days: 30, type: 'all' }) + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert(result.message, 'success'); + await this.loadSystemLogs(); + await this.loadStatistics(); + } else { + this.showAlert('清理失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('清理历史记录失败:', error); + this.showAlert('清理历史记录失败: ' + error.message, 'danger'); + } + } + + // 工具函数 + getTaskTypeLabel(type) { + const labels = { + 'scrape': '抓取任务', + 'validation': '验证任务', + 'health_check': '健康检查' + }; + return labels[type] || type; + } + + getStatusClass(status) { + const classes = { + 'success': 'bg-success', + 'failed': 'bg-danger', + 'running': 'bg-warning', + 'pending': 'bg-secondary' + }; + return classes[status] || 'bg-secondary'; + } + + getStatusIcon(status) { + const icons = { + 'success': 'check-circle', + 'failed': 'x-circle', + 'running': 'clock', + 'pending': 'pause-circle' + }; + return icons[status] || 'question-circle'; + } + + getStatusText(status) { + const texts = { + 'success': '成功', + 'failed': '失败', + 'running': '运行中', + 'pending': '等待中' + }; + return texts[status] || status; + } + + getLogLevelClass(level) { + const classes = { + 'error': 'bg-danger', + 'warning': 'bg-warning', + 'info': 'bg-info', + 'debug': 'bg-secondary' + }; + return classes[level] || 'bg-secondary'; + } + + formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN'); + } + + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); + } + + formatDuration(ms) { + if (!ms) return '-'; + + if (ms < 1000) { + return ms + 'ms'; + } else if (ms < 60000) { + return (ms / 1000).toFixed(1) + 's'; + } else { + return (ms / 60000).toFixed(1) + 'min'; + } + } + + showTaskLoading(show) { + const tbody = document.getElementById('taskHistoryTableBody'); + const spinner = tbody.querySelector('.spinner-border'); + if (spinner) { + spinner.parentElement.style.display = show ? 'block' : 'none'; + } + } + + showLogsLoading(show) { + const tbody = document.getElementById('systemLogsTableBody'); + const spinner = tbody.querySelector('.spinner-border'); + if (spinner) { + spinner.parentElement.style.display = show ? 'block' : 'none'; + } + } + + showAlert(message, type = 'info') { + const alertContainer = document.getElementById('alertContainer'); + if (!alertContainer) return; + + const alertId = 'alert-' + Date.now(); + const alertHtml = ` + + `; + + alertContainer.insertAdjacentHTML('beforeend', alertHtml); + + // 自动移除提示 + setTimeout(() => { + const alertElement = document.getElementById(alertId); + if (alertElement) { + alertElement.remove(); + } + }, 5000); + } + + getAlertIcon(type) { + const icons = { + 'success': 'check-circle-fill', + 'danger': 'exclamation-triangle-fill', + 'warning': 'exclamation-triangle-fill', + 'info': 'info-circle-fill' + }; + return icons[type] || 'info-circle-fill'; + } + + goToTaskPage(page) { + this.currentTaskPage = page; + this.loadTaskHistory(); + } +} + +// 全局函数(供HTML调用) +let historyManager; + +async function refreshHistory() { + if (historyManager) { + await Promise.all([ + historyManager.loadTaskHistory(), + historyManager.loadSystemLogs(), + historyManager.loadStatistics() + ]); + historyManager.showAlert('历史数据已刷新', 'info'); + } +} + +async function exportTaskHistory() { + if (historyManager) { + await historyManager.exportTaskHistory(); + } +} + +async function cleanupLogs() { + if (historyManager) { + await historyManager.cleanupLogs(); + } +} + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + historyManager = new HistoryManager(); +}); \ No newline at end of file diff --git a/public/js/monitoring.js b/public/js/monitoring.js new file mode 100644 index 0000000..ae5cac0 --- /dev/null +++ b/public/js/monitoring.js @@ -0,0 +1,604 @@ +// 系统监控页面JavaScript + +class SystemMonitor { + constructor() { + this.charts = {}; + this.refreshInterval = null; + this.refreshProgressInterval = null; + this.isRefreshing = false; + + this.init(); + } + + async init() { + console.log('初始化系统监控页面...'); + + // 初始化图表 + this.initCharts(); + + // 加载初始数据 + await this.loadSystemStatus(); + await this.loadRecentData(); + + // 启动自动刷新 + this.startAutoRefresh(); + + // 启动刷新进度条 + this.startRefreshProgress(); + + console.log('系统监控页面初始化完成'); + } + + initCharts() { + // 代理池趋势图 + const proxyPoolCtx = document.getElementById('proxyPoolChart'); + if (proxyPoolCtx) { + this.charts.proxyPool = new Chart(proxyPoolCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: '总代理数', + data: [], + borderColor: '#007bff', + backgroundColor: 'rgba(0, 123, 255, 0.1)', + tension: 0.4, + fill: true + }, { + label: '可用代理', + data: [], + borderColor: '#28a745', + backgroundColor: 'rgba(40, 167, 69, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + precision: 0 + } + } + } + } + }); + } + + // 任务执行率图 + const taskRateCtx = document.getElementById('taskRateChart'); + if (taskRateCtx) { + this.charts.taskRate = new Chart(taskRateCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: '成功率 (%)', + data: [], + borderColor: '#17a2b8', + backgroundColor: 'rgba(23, 162, 184, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + } + }, + scales: { + y: { + beginAtZero: true, + max: 100, + ticks: { + callback: function(value) { + return value + '%'; + } + } + } + } + } + }); + } + } + + async loadSystemStatus() { + try { + this.setRefreshing(true); + + // 获取系统状态 + const statusResponse = await fetch('/api/dashboard/status'); + const statusData = await statusResponse.json(); + + if (statusData.success) { + this.updateSystemOverview(statusData.data); + this.updateTasksStatus(statusData.data); + this.updateProxyPoolStatus(statusData.data.proxies); + this.updateResourceUsage(statusData.data); + } + + // 更新最后刷新时间 + this.updateLastRefreshTime(); + + } catch (error) { + console.error('加载系统状态失败:', error); + this.showAlert('加载系统状态失败: ' + error.message, 'danger'); + } finally { + this.setRefreshing(false); + } + } + + async loadRecentData() { + try { + // 加载图表数据 + await this.updateCharts(); + + // 加载最近日志 + await this.loadRecentLogs(); + + // 加载最近事件 + await this.loadRecentEvents(); + + } catch (error) { + console.error('加载最近数据失败:', error); + } + } + + updateSystemOverview(data) { + // 更新系统状态指示器 + const indicator = document.getElementById('systemStatusIndicator'); + const statusText = document.getElementById('systemStatusText'); + const healthBadge = document.getElementById('systemHealth'); + + if (data.proxies && data.proxies.valid > 0) { + indicator.className = 'status-indicator online'; + statusText.textContent = '在线'; + healthBadge.textContent = '健康'; + healthBadge.className = 'badge bg-success'; + } else { + indicator.className = 'status-indicator offline'; + statusText.textContent = '异常'; + healthBadge.textContent = '异常'; + healthBadge.className = 'badge bg-danger'; + } + + // 更新运行时间 + document.getElementById('systemUptime').textContent = this.formatUptime(data.uptime); + + // 更新内存使用 + const memoryHtml = ` +
    已使用: ${data.memory.heapUsed}MB
    +
    +
    +
    + 总计: ${data.memory.heapTotal}MB + `; + document.getElementById('memoryUsage').innerHTML = memoryHtml; + + // 更新定时任务状态 + if (data.scheduler) { + const taskCount = data.scheduler.taskCount || 0; + document.getElementById('schedulerStatus').innerHTML = ` +
    ${taskCount} 个任务运行中
    + 自动调度正常 + `; + } + } + + updateTasksStatus(data) { + const container = document.getElementById('tasksStatus'); + + if (data.today_tasks) { + const scrape = data.today_tasks.scrape; + const validation = data.today_tasks.validation; + + container.innerHTML = ` +
    +
    +
    +
    +
    抓取任务
    +
    + 成功: ${scrape.success} + 失败: ${scrape.failed} +
    +
    +
    +
    + 成功率: ${scrape.success_rate}% +
    +
    +
    +
    +
    +
    +
    验证任务
    +
    + 成功: ${validation.success} + 失败: ${validation.failed} +
    +
    +
    +
    + 成功率: ${validation.success_rate}% +
    +
    +
    +
    + `; + } + } + + updateProxyPoolStatus(proxies) { + const container = document.getElementById('proxyPoolStatus'); + + container.innerHTML = ` +
    +
    +
    +

    ${proxies.total}

    + 总代理数 +
    +
    +
    +
    +

    ${proxies.valid}

    + 可用代理 +
    +
    +
    +
    +

    ${proxies.invalid}

    + 无效代理 +
    +
    +
    +
    +
    +
    可用率: ${proxies.validRate}
    +
    + `; + } + + updateResourceUsage(data) { + if (data.memory) { + const memoryPercent = Math.round((data.memory.heapUsed / data.memory.heapTotal) * 100); + document.getElementById('memoryPercent').textContent = memoryPercent + '%'; + document.getElementById('memoryProgressBar').style.width = memoryPercent + '%'; + } + + if (data.proxies) { + const validRate = parseFloat(data.proxies.validRate); + document.getElementById('proxyValidRate').textContent = data.proxies.validRate; + document.getElementById('proxyValidProgressBar').style.width = validRate + '%'; + } + + // 模拟CPU使用率(实际项目中可以从系统API获取) + const cpuUsage = Math.round(Math.random() * 30 + 10); // 10-40% + document.getElementById('cpuUsage').textContent = cpuUsage + '%'; + document.getElementById('cpuProgressBar').style.width = cpuUsage + '%'; + } + + async updateCharts() { + try { + // 更新代理池趋势图 + const proxyResponse = await fetch('/api/dashboard/charts/proxies'); + const proxyResult = await proxyResponse.json(); + + if (proxyResult.success && this.charts.proxyPool) { + const labels = proxyResult.data.map(item => + new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + ); + + // 计算累计数据 + let totalRunning = 0; + let validRunning = 0; + const totalData = []; + const validData = []; + + proxyResult.data.forEach(item => { + totalRunning += item.total_added || 0; + validRunning += item.valid_added || 0; + totalData.push(totalRunning); + validData.push(validRunning); + }); + + this.charts.proxyPool.data.labels = labels; + this.charts.proxyPool.data.datasets[0].data = totalData; + this.charts.proxyPool.data.datasets[1].data = validData; + this.charts.proxyPool.update(); + } + + // 更新任务执行率图 + const taskResponse = await fetch('/api/history/stats/summary'); + const taskResult = await taskResponse.json(); + + if (taskResult.success && this.charts.taskRate) { + // 使用最近7天的数据 + const labels = []; + const successRates = []; + + for (let i = 6; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + labels.push(date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })); + successRates.push(Math.random() * 30 + 70); // 模拟70-100%成功率 + } + + this.charts.taskRate.data.labels = labels; + this.charts.taskRate.data.datasets[0].data = successRates; + this.charts.taskRate.update(); + } + } catch (error) { + console.error('更新图表失败:', error); + } + } + + async loadRecentLogs() { + try { + const response = await fetch('/api/history/logs/system?limit=10'); + const result = await response.json(); + + if (result.success) { + this.renderRecentLogs(result.data); + } + } catch (error) { + console.error('加载最近日志失败:', error); + } + } + + async loadRecentEvents() { + try { + const response = await fetch('/api/history?limit=10'); + const result = await response.json(); + + if (result.success) { + this.renderRecentEvents(result.data); + } + } catch (error) { + console.error('加载最近事件失败:', error); + } + } + + renderRecentLogs(logs) { + const container = document.getElementById('recentLogs'); + + if (!logs || logs.length === 0) { + container.innerHTML = '
    暂无日志
    '; + return; + } + + container.innerHTML = logs.map(log => ` +
    +
    + ${log.level.toUpperCase()} +
    +
    +
    ${log.message}
    +
    + ${this.formatDateTime(log.timestamp)} - ${log.source} +
    +
    +
    + `).join(''); + } + + renderRecentEvents(events) { + const container = document.getElementById('recentEvents'); + + if (!events || events.length === 0) { + container.innerHTML = '
    暂无事件
    '; + return; + } + + container.innerHTML = events.map(event => ` +
    +
    + +
    +
    +
    ${event.task_name}
    +
    + ${this.formatDateTime(event.start_time)} - ${this.getStatusText(event.status)} +
    + ${event.result_summary ? `
    ${event.result_summary}
    ` : ''} +
    +
    + ${this.getStatusText(event.status)} +
    +
    + `).join(''); + } + + startAutoRefresh() { + // 每30秒刷新一次数据 + this.refreshInterval = setInterval(() => { + this.loadSystemStatus(); + }, 30000); + + // 每5分钟刷新最近数据 + setInterval(() => { + this.loadRecentData(); + }, 300000); + } + + startRefreshProgress() { + this.refreshProgressInterval = setInterval(() => { + const progressBar = document.getElementById('refreshProgress'); + if (progressBar) { + const currentWidth = parseFloat(progressBar.style.width) || 100; + const newWidth = Math.max(0, currentWidth - 3.33); // 30秒内从100%到0% + progressBar.style.width = newWidth + '%'; + } + }, 1000); + } + + updateLastRefreshTime() { + const now = new Date(); + const timeString = now.toLocaleTimeString('zh-CN'); + document.getElementById('lastUpdateTime').textContent = timeString; + } + + setRefreshing(refreshing) { + this.isRefreshing = refreshing; + if (refreshing) { + // 重置进度条 + const progressBar = document.getElementById('refreshProgress'); + if (progressBar) { + progressBar.style.width = '100%'; + } + } + } + + // 工具函数 + formatUptime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 24) { + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return `${days}天 ${remainingHours}小时 ${minutes}分钟`; + } + + return `${hours}小时 ${minutes}分钟`; + } + + formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + getLogLevelClass(level) { + const classes = { + 'error': 'bg-danger', + 'warning': 'bg-warning', + 'info': 'bg-info', + 'debug': 'bg-secondary' + }; + return classes[level] || 'bg-secondary'; + } + + getStatusClass(status) { + const classes = { + 'success': 'bg-success', + 'failed': 'bg-danger', + 'running': 'bg-warning', + 'pending': 'bg-secondary' + }; + return classes[status] || 'bg-secondary'; + } + + getStatusText(status) { + const texts = { + 'success': '成功', + 'failed': '失败', + 'running': '运行中', + 'pending': '等待中' + }; + return texts[status] || status; + } + + getTaskIcon(taskType) { + const icons = { + 'scrape': 'download', + 'validation': 'check2-square', + 'health_check': 'heart-pulse' + }; + return icons[taskType] || 'gear'; + } + + getTaskColor(status) { + const colors = { + 'success': 'success', + 'failed': 'danger', + 'running': 'warning', + 'pending': 'secondary' + }; + return colors[status] || 'secondary'; + } + + showAlert(message, type = 'info') { + const alertContainer = document.getElementById('alertContainer'); + if (!alertContainer) return; + + const alertId = 'alert-' + Date.now(); + const alertHtml = ` + + `; + + alertContainer.insertAdjacentHTML('beforeend', alertHtml); + + // 自动移除提示 + setTimeout(() => { + const alertElement = document.getElementById(alertId); + if (alertElement) { + alertElement.remove(); + } + }, 5000); + } + + getAlertIcon(type) { + const icons = { + 'success': 'check-circle-fill', + 'danger': 'exclamation-triangle-fill', + 'warning': 'exclamation-triangle-fill', + 'info': 'info-circle-fill' + }; + return icons[type] || 'info-circle-fill'; + } + + stopAutoRefresh() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + if (this.refreshProgressInterval) { + clearInterval(this.refreshProgressInterval); + this.refreshProgressInterval = null; + } + } +} + +// 全局函数(供HTML调用) +let systemMonitor; + +async function refreshMonitoring() { + if (systemMonitor && !systemMonitor.isRefreshing) { + await systemMonitor.loadSystemStatus(); + await systemMonitor.loadRecentData(); + systemMonitor.showAlert('监控数据已刷新', 'info'); + } +} + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + systemMonitor = new SystemMonitor(); +}); + +// 页面卸载时清理 +window.addEventListener('beforeunload', () => { + if (systemMonitor) { + systemMonitor.stopAutoRefresh(); + } +}); \ No newline at end of file diff --git a/public/js/proxies.js b/public/js/proxies.js new file mode 100644 index 0000000..8996b82 --- /dev/null +++ b/public/js/proxies.js @@ -0,0 +1,760 @@ +// 代理管理页面JavaScript + +class ProxyManager { + constructor() { + this.currentPage = 1; + this.pageSize = 20; + this.totalCount = 0; + this.proxies = []; + this.selectedProxies = new Set(); + this.searchParams = { + ip: '', + port: '', + location: '', + status: '', + sortBy: 'created_at', + order: 'DESC' + }; + + this.init(); + } + + async init() { + console.log('初始化代理管理页面...'); + + // 绑定事件 + this.bindEvents(); + + // 加载代理列表 + await this.loadProxies(); + + console.log('代理管理页面初始化完成'); + } + + bindEvents() { + // 搜索表单 + document.getElementById('searchForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.searchParams.ip = document.getElementById('searchIp').value; + this.searchParams.port = document.getElementById('searchPort').value; + this.searchParams.location = document.getElementById('searchLocation').value; + this.searchParams.status = document.getElementById('filterStatus').value; + this.searchParams.sortBy = document.getElementById('sortBy').value; + this.currentPage = 1; + this.loadProxies(); + }); + + // 单个代理验证 + document.getElementById('validateSingleProxy').addEventListener('click', () => { + const proxyId = document.getElementById('validateSingleProxy').dataset.proxyId; + if (proxyId) { + this.validateSingleProxy(proxyId); + } + }); + } + + async loadProxies() { + try { + this.showLoading(true); + + // 构建查询参数 + const params = new URLSearchParams({ + limit: this.pageSize, + offset: (this.currentPage - 1) * this.pageSize, + sortBy: this.searchParams.sortBy, + order: this.searchParams.order + }); + + if (this.searchParams.ip) params.append('ip', this.searchParams.ip); + if (this.searchParams.port) params.append('port', this.searchParams.port); + if (this.searchParams.location) params.append('location', this.searchParams.location); + if (this.searchParams.status !== '') params.append('validOnly', this.searchParams.status === '1'); + + const response = await fetch(`/api/proxies?${params}`); + const result = await response.json(); + + if (result.success) { + this.proxies = result.data; + this.totalCount = result.pagination.total; + this.renderProxyTable(); + this.renderPagination(); + this.updateStatistics(); + } else { + this.showAlert('加载代理列表失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('加载代理列表失败:', error); + this.showAlert('加载代理列表失败: ' + error.message, 'danger'); + } finally { + this.showLoading(false); + } + } + + renderProxyTable() { + const tbody = document.getElementById('proxyTableBody'); + + if (this.proxies.length === 0) { + tbody.innerHTML = ` + + + +

    暂无代理数据

    + + + + `; + return; + } + + tbody.innerHTML = this.proxies.map(proxy => ` + + + + + + ${proxy.ip} + + ${proxy.port} + + ${proxy.location || '-'} + + + + + ${proxy.is_valid ? '可用' : '不可用'} + + + + + ${proxy.response_time ? proxy.response_time + 'ms' : '-'} + + + + + ${proxy.last_check_time ? this.formatDateTime(proxy.last_check_time) : '-'} + + + + + ${this.formatDateTime(proxy.created_at)} + + + +
    + + + +
    + + + `).join(''); + } + + renderPagination() { + const pagination = document.getElementById('pagination'); + const totalPages = Math.ceil(this.totalCount / this.pageSize); + + if (totalPages <= 1) { + pagination.innerHTML = ''; + return; + } + + let paginationHTML = ''; + + // 上一页 + paginationHTML += ` +
  • + + + +
  • + `; + + // 页码 + const startPage = Math.max(1, this.currentPage - 2); + const endPage = Math.min(totalPages, this.currentPage + 2); + + if (startPage > 1) { + paginationHTML += ` +
  • + 1 +
  • + `; + if (startPage > 2) { + paginationHTML += '
  • ...
  • '; + } + } + + for (let i = startPage; i <= endPage; i++) { + paginationHTML += ` +
  • + ${i} +
  • + `; + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + paginationHTML += '
  • ...
  • '; + } + paginationHTML += ` +
  • + ${totalPages} +
  • + `; + } + + // 下一页 + paginationHTML += ` +
  • + + + +
  • + `; + + pagination.innerHTML = paginationHTML; + } + + updateStatistics() { + document.getElementById('totalCount').textContent = this.totalCount; + document.getElementById('validCount').textContent = this.proxies.filter(p => p.is_valid).length; + document.getElementById('invalidCount').textContent = this.proxies.filter(p => !p.is_valid).length; + document.getElementById('showingCount').textContent = this.proxies.length; + } + + async showProxyDetail(proxyId) { + try { + const proxy = this.proxies.find(p => p.id === proxyId); + if (!proxy) return; + + const content = document.getElementById('proxyDetailContent'); + content.innerHTML = ` +
    +
    + IP地址:
    + ${proxy.ip} +
    +
    + 端口:
    + ${proxy.port} +
    +
    +
    +
    +
    + 位置:
    + ${proxy.location || '-'} +
    +
    + 状态:
    + + ${proxy.is_valid ? '可用' : '不可用'} + +
    +
    +
    +
    +
    + 响应时间:
    + ${proxy.response_time ? proxy.response_time + 'ms' : '-'} +
    +
    + 最后验证:
    + ${proxy.last_check_time ? this.formatDateTime(proxy.last_check_time) : '-'} +
    +
    +
    +
    +
    + 创建时间:
    + ${this.formatDateTime(proxy.created_at)} +
    +
    + 更新时间:
    + ${this.formatDateTime(proxy.updated_at)} +
    +
    + `; + + // 设置验证按钮的数据属性 + document.getElementById('validateSingleProxy').dataset.proxyId = proxyId; + + // 显示模态框 + const modal = new bootstrap.Modal(document.getElementById('proxyDetailModal')); + modal.show(); + } catch (error) { + console.error('显示代理详情失败:', error); + this.showAlert('显示代理详情失败: ' + error.message, 'danger'); + } + } + + async validateSingleProxy(proxyId) { + try { + const proxy = this.proxies.find(p => p.id === proxyId); + if (!proxy) return; + + // 关闭详情模态框 + const detailModal = bootstrap.Modal.getInstance(document.getElementById('proxyDetailModal')); + if (detailModal) detailModal.hide(); + + // 显示验证进度 + const modal = new bootstrap.Modal(document.getElementById('validationModal')); + document.getElementById('validationProgress').style.display = 'block'; + document.getElementById('validationResults').innerHTML = ''; + document.getElementById('validationStatus').textContent = `正在验证 ${proxy.ip}:${proxy.port}...`; + document.getElementById('validationProgressBar').style.width = '50%'; + modal.show(); + + const response = await fetch('/api/proxies/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ip: proxy.ip, + port: proxy.port + }) + }); + + const result = await response.json(); + + // 更新进度条 + document.getElementById('validationProgressBar').style.width = '100%'; + document.getElementById('validationStatus').textContent = '验证完成'; + + // 显示结果 + document.getElementById('validationResults').innerHTML = ` +
    +
    验证结果: ${result.data.isValid ? '成功' : '失败'}
    +

    IP: ${result.data.ip}:${result.data.port}

    +

    响应时间: ${result.data.responseTime}ms

    + ${result.data.error ? `

    错误信息: ${result.data.error}

    ` : ''} +
    + `; + + // 延迟刷新列表 + setTimeout(() => { + this.loadProxies(); + }, 1000); + + } catch (error) { + console.error('验证代理失败:', error); + this.showAlert('验证代理失败: ' + error.message, 'danger'); + } + } + + async deleteProxy(proxyId) { + if (!confirm('确定要删除这个代理吗?')) { + return; + } + + try { + const proxy = this.proxies.find(p => p.id === proxyId); + if (!proxy) return; + + // 这里应该调用删除API,但目前我们的API没有单个删除功能 + // 暂时显示提示 + this.showAlert(`代理 ${proxy.ip}:${proxy.port} 删除功能待实现`, 'info'); + + } catch (error) { + console.error('删除代理失败:', error); + this.showAlert('删除代理失败: ' + error.message, 'danger'); + } + } + + toggleProxySelection(proxyId) { + if (this.selectedProxies.has(proxyId)) { + this.selectedProxies.delete(proxyId); + } else { + this.selectedProxies.add(proxyId); + } + this.updateSelectAllCheckbox(); + } + + toggleSelectAll() { + const selectAll = document.getElementById('selectAll').checked; + const checkboxes = document.querySelectorAll('.proxy-checkbox'); + + checkboxes.forEach(checkbox => { + checkbox.checked = selectAll; + const proxyId = parseInt(checkbox.value); + if (selectAll) { + this.selectedProxies.add(proxyId); + } else { + this.selectedProxies.delete(proxyId); + } + }); + } + + updateSelectAllCheckbox() { + const checkboxes = document.querySelectorAll('.proxy-checkbox'); + const selectAll = document.getElementById('selectAll'); + selectAll.checked = checkboxes.length > 0 && this.selectedProxies.size === checkboxes.length; + } + + goToPage(page) { + this.currentPage = page; + this.loadProxies(); + } + + async validateAll() { + if (this.selectedProxies.size === 0) { + this.showAlert('请先选择要验证的代理', 'warning'); + return; + } + + try { + const selectedProxiesList = Array.from(this.selectedProxies).map(id => + this.proxies.find(p => p.id === id) + ).filter(p => p); + + // 显示验证进度 + const modal = new bootstrap.Modal(document.getElementById('validationModal')); + document.getElementById('validationProgress').style.display = 'block'; + document.getElementById('validationResults').innerHTML = ''; + document.getElementById('validationProgressBar').style.width = '0%'; + modal.show(); + + const response = await fetch('/api/proxies/verify-batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + proxies: selectedProxiesList + }) + }); + + const result = await response.json(); + + // 更新进度条 + document.getElementById('validationProgressBar').style.width = '100%'; + document.getElementById('validationStatus').textContent = '验证完成'; + + // 显示结果 + document.getElementById('validationResults').innerHTML = ` +
    +
    批量验证完成
    +

    总验证数: ${result.data.validated}

    +

    有效代理: ${result.data.valid}

    +

    无效代理: ${result.data.invalid}

    +
    + `; + + // 延迟刷新列表 + setTimeout(() => { + this.loadProxies(); + }, 1000); + + } catch (error) { + console.error('批量验证失败:', error); + this.showAlert('批量验证失败: ' + error.message, 'danger'); + } + } + + async cleanupInvalid() { + if (!confirm('确定要清理所有无效代理吗?此操作不可恢复。')) { + return; + } + + try { + const response = await fetch('/api/proxies/cleanup', { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert(result.message, 'success'); + await this.loadProxies(); + } else { + this.showAlert('清理失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('清理无效代理失败:', error); + this.showAlert('清理无效代理失败: ' + error.message, 'danger'); + } + } + + async exportProxies() { + try { + const response = await fetch('/api/proxies?limit=1000&validOnly=true'); + const result = await response.json(); + + if (result.success) { + const csv = this.convertToCSV(result.data); + this.downloadCSV(csv, `valid_proxies_${new Date().toISOString().slice(0, 10)}.csv`); + this.showAlert('数据导出成功', 'success'); + } else { + this.showAlert('导出数据失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('导出数据失败:', error); + this.showAlert('导出数据失败: ' + error.message, 'danger'); + } + } + + async startScrape() { + try { + const response = await fetch('/api/dashboard/actions/scrape', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pages: 40 }) + }); + + const result = await response.json(); + + if (result.success) { + this.showAlert('抓取任务已启动', 'success'); + // 延迟刷新数据 + setTimeout(() => this.loadProxies(), 2000); + } else { + this.showAlert('启动抓取任务失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('启动抓取任务失败:', error); + this.showAlert('启动抓取任务失败: ' + error.message, 'danger'); + } + } + + // 工具函数 + formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + showLoading(show) { + const spinner = document.querySelector('#proxyTableBody .spinner-border'); + if (spinner) { + spinner.parentElement.style.display = show ? 'block' : 'none'; + } + } + + showAlert(message, type = 'info') { + const alertContainer = document.getElementById('alertContainer'); + if (!alertContainer) return; + + const alertId = 'alert-' + Date.now(); + const alertHtml = ` + + `; + + alertContainer.insertAdjacentHTML('beforeend', alertHtml); + + // 自动移除提示 + setTimeout(() => { + const alertElement = document.getElementById(alertId); + if (alertElement) { + alertElement.remove(); + } + }, 5000); + } + + getAlertIcon(type) { + const icons = { + 'success': 'check-circle-fill', + 'danger': 'exclamation-triangle-fill', + 'warning': 'exclamation-triangle-fill', + 'info': 'info-circle-fill' + }; + return icons[type] || 'info-circle-fill'; + } + + convertToCSV(data) { + if (!data || data.length === 0) return ''; + + const headers = ['IP', '端口', '位置', '响应时间', '最后验证时间', '创建时间']; + const csvRows = [headers.join(',')]; + + data.forEach(proxy => { + const row = [ + proxy.ip, + proxy.port, + `"${proxy.location || ''}"`, + proxy.response_time || '', + proxy.last_check_time || '', + proxy.created_at + ]; + csvRows.push(row.join(',')); + }); + + return csvRows.join('\n'); + } + + downloadCSV(csv, filename) { + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + // 导入代理功能 + async importProxies(proxies) { + try { + this.showAlert('开始导入并验证代理...', 'info'); + + const response = await fetch('/api/proxies/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ proxies }) + }); + + const result = await response.json(); + + if (result.success) { + const { data } = result; + let message = `导入完成!`; + message += `\n📊 总数: ${data.total}`; + message += `\n✅ 格式有效: ${data.format_valid}`; + message += `\n❌ 格式无效: ${data.format_invalid}`; + message += `\n🔍 验证通过: ${data.valid}`; + message += `\n❌ 验证失败: ${data.invalid}`; + message += `\n💾 保存成功: ${data.saved}`; + + this.showAlert(message, 'success'); + + // 刷新代理列表 + setTimeout(() => this.loadProxies(), 2000); + } else { + this.showAlert('导入失败: ' + result.error, 'danger'); + } + } catch (error) { + console.error('导入代理失败:', error); + this.showAlert('导入代理失败: ' + error.message, 'danger'); + } + } +} + +// 全局函数(供HTML调用) +let proxyManager; + +async function refreshProxies() { + if (proxyManager) { + await proxyManager.loadProxies(); + proxyManager.showAlert('代理列表已刷新', 'info'); + } +} + +async function validateAll() { + if (proxyManager) { + await proxyManager.validateAll(); + } +} + +async function cleanupInvalid() { + if (proxyManager) { + await proxyManager.cleanupInvalid(); + } +} + +async function exportProxies() { + if (proxyManager) { + await proxyManager.exportProxies(); + } +} + +// 显示导入模态框 +function showImportModal() { + const modal = new bootstrap.Modal(document.getElementById('importModal')); + modal.show(); +} + +// 从模态框导入代理 +async function importProxiesFromModal() { + const jsonInput = document.getElementById('proxyJsonInput').value.trim(); + + if (!jsonInput) { + alert('请输入代理JSON数据'); + return; + } + + try { + const proxies = JSON.parse(jsonInput); + + if (!Array.isArray(proxies)) { + alert('代理数据必须是数组格式'); + return; + } + + // 关闭模态框 + const modal = bootstrap.Modal.getInstance(document.getElementById('importModal')); + modal.hide(); + + // 清空输入框 + document.getElementById('proxyJsonInput').value = ''; + + // 开始导入 + if (proxyManager) { + await proxyManager.importProxies(proxies); + } + } catch (error) { + alert('JSON格式错误: ' + error.message); + } +} + +// 处理文件导入 +async function handleImportFile(event) { + const file = event.target.files[0]; + + if (!file) return; + + if (!file.name.endsWith('.json')) { + alert('请选择JSON文件'); + return; + } + + try { + const text = await file.text(); + const proxies = JSON.parse(text); + + if (!Array.isArray(proxies)) { + alert('文件内容必须是代理数组'); + return; + } + + // 清空文件输入 + event.target.value = ''; + + // 开始导入 + if (proxyManager) { + await proxyManager.importProxies(proxies); + } + } catch (error) { + alert('文件读取失败: ' + error.message); + } +} + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + proxyManager = new ProxyManager(); +}); \ No newline at end of file diff --git a/public/monitoring.html b/public/monitoring.html new file mode 100644 index 0000000..4251d21 --- /dev/null +++ b/public/monitoring.html @@ -0,0 +1,287 @@ + + + + + + 代理IP管理系统 - 系统监控 + + + + + + + + + +
    + +
    +
    +
    +
    + +
    系统状态
    +
    健康
    +
    +
    +
    +
    +
    +
    + +
    运行时间
    +
    -
    +
    +
    +
    +
    +
    +
    + +
    内存使用
    +
    -
    +
    +
    +
    +
    +
    +
    + +
    定时任务
    +
    -
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + 代理池趋势 + 实时 +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + 任务执行率 + 实时 +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + 定时任务状态 +
    +
    +
    +
    +
    +
    + 加载中... +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + 代理池状态 +
    +
    +
    +
    +
    +
    + 加载中... +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + 系统资源监控 +
    +
    +
    +
    +
    +
    +
    + CPU使用率 + 0% +
    +
    +
    +
    +
    +
    +
    +
    +
    + 内存使用率 + 0% +
    +
    +
    +
    +
    +
    +
    +
    +
    + 代理池可用率 + 0% +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + 最近系统日志 +
    +
    +
    +
    +
    +
    + 加载中... +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + 最近任务事件 +
    +
    +
    +
    +
    +
    + 加载中... +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + 最后更新 +
    -
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + + + + + + + \ No newline at end of file diff --git a/public/proxies.html b/public/proxies.html new file mode 100644 index 0000000..6ec736f --- /dev/null +++ b/public/proxies.html @@ -0,0 +1,310 @@ + + + + + + 代理IP管理系统 - 代理管理 + + + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +

    -

    +

    总代理数

    +
    +
    +
    +
    +
    +
    +

    -

    +

    可用代理

    +
    +
    +
    +
    +
    +
    +

    -

    +

    无效代理

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    + + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + 代理列表 +
    +
    + 显示 0 条记录 +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    + + IP地址端口位置状态响应时间最后验证创建时间操作
    +
    + 加载中... +
    +

    正在加载代理列表...

    +
    +
    + + + +
    +
    +
    +
    +
    + + + + + + + + + + + +
    +
    +
    + + + + + + \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..9783ec8 --- /dev/null +++ b/src/app.js @@ -0,0 +1,329 @@ +const express = require('express'); +const path = require('path'); +const Database = require('./database/db'); +const ProxyModel = require('./database/models/proxy'); +const HistoryModel = require('./database/models/history'); +const LogsModel = require('./database/models/logs'); +const ProxyScheduler = require('./services/scheduler'); +const proxyRoutes = require('./routes/proxies'); +const dashboardRoutes = require('./routes/dashboard'); +const historyRoutes = require('./routes/history'); + +class ProxyApp { + constructor() { + this.app = express(); + this.port = process.env.PORT || 3000; + this.scheduler = new ProxyScheduler(); + this.isShuttingDown = false; + } + + setupMiddleware() { + // 静态文件服务 + this.app.use(express.static(path.join(__dirname, '../public'))); + + // JSON 解析中间件 + this.app.use(express.json({ limit: '10mb' })); + this.app.use(express.urlencoded({ extended: true })); + + // CORS 支持 + this.app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + + if (req.method === 'OPTIONS') { + res.sendStatus(200); + } else { + next(); + } + }); + + // 请求日志中间件 + this.app.use((req, res, next) => { + const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); + console.log(`[${timestamp}] ${req.method} ${req.url}`); + next(); + }); + } + + setupRoutes() { + // 健康检查端点 + this.app.get('/api/health', async (req, res) => { + try { + const stats = await this.scheduler.getSystemStats(); + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + system: stats + }); + } catch (error) { + res.status(500).json({ + status: 'unhealthy', + error: error.message, + timestamp: new Date().toISOString() + }); + } + }); + + // 根路径 - 重定向到仪表板 + this.app.get('/', (req, res) => { + res.redirect('/index.html'); + }); + + // API信息端点 + this.app.get('/api/info', (req, res) => { + res.json({ + name: 'Proxy IP Service', + version: '1.0.0', + description: '代理IP抓取、验证和管理服务', + web_interface: { + dashboard: '/index.html', + proxies: '/proxies.html', + history: '/history.html', + monitoring: '/monitoring.html' + }, + endpoints: { + 'GET /api/health': '健康检查', + 'GET /api/info': 'API信息', + 'GET /api/dashboard/stats': '仪表板统计数据', + 'GET /api/dashboard/status': '实时系统状态', + 'POST /api/dashboard/actions/scrape': '手动抓取', + 'POST /api/dashboard/actions/validate': '手动验证', + 'GET /api/proxies': '获取代理列表', + 'GET /api/proxies/random': '获取随机代理', + 'GET /api/proxies/stats': '获取统计信息', + 'POST /api/proxies/verify': '验证单个代理', + 'POST /api/proxies/verify-batch': '批量验证代理', + 'POST /api/proxies/scrape': '手动触发抓取', + 'POST /api/proxies/validate-all': '手动触发验证', + 'GET /api/proxies/search': '搜索代理', + 'DELETE /api/proxies/cleanup': '清理无效代理', + 'GET /api/history': '执行历史', + 'GET /api/history/logs/system': '系统日志', + 'GET /api/history/stats': '历史统计' + }, + scheduler: { + scrape: '每小时整点执行', + validation: '每10分钟执行', + healthCheck: '每小时30分执行' + } + }); + }); + + // API 路由 + this.app.use('/api/proxies', proxyRoutes); + this.app.use('/api/dashboard', dashboardRoutes); + this.app.use('/api/history', historyRoutes); + + // 404 处理 - API端点 + this.app.use('/api', (req, res, next) => { + // 检查是否已处理的路由 + if (!req.route) { + res.status(404).json({ + error: 'API接口不存在', + message: `路径 ${req.originalUrl} 未找到`, + available_endpoints: [ + 'GET /api/health', + 'GET /api/info', + 'GET /api/dashboard/stats', + 'GET /api/dashboard/status', + 'POST /api/dashboard/actions/scrape', + 'POST /api/dashboard/actions/validate', + 'GET /api/proxies', + 'GET /api/proxies/random', + 'GET /api/proxies/stats', + 'POST /api/proxies/verify', + 'POST /api/proxies/verify-batch', + 'GET /api/history', + 'GET /api/history/logs/system', + 'GET /api/history/stats' + ] + }); + } else { + next(); + } + }); + + // 404 处理 - Web页面 + this.app.use((req, res) => { + res.status(404).send(` + + + + 页面未找到 - 代理IP管理系统 + + + + + +
    +
    +
    +
    +
    +

    404

    +

    页面未找到

    +

    您访问的页面 ${req.originalUrl} 不存在

    + 返回首页 +
    +
    +
    +
    +
    + + + `); + }); + + // 错误处理中间件 + this.app.use((err, req, res, next) => { + console.error('未处理的错误:', err); + res.status(500).json({ + error: '服务器内部错误', + message: err.message, + timestamp: new Date().toISOString() + }); + }); + } + + async initializeDatabase() { + try { + console.log('初始化数据库连接...'); + await Database.connect(); + console.log('数据库连接成功'); + + console.log('创建数据表...'); + await ProxyModel.initTable(); + await HistoryModel.initTable(); + await LogsModel.initTable(); + console.log('数据表初始化完成'); + + // 获取初始统计信息 + const totalCount = await ProxyModel.count(); + const validCount = await ProxyModel.count(true); + console.log(`数据库初始化完成,共 ${totalCount} 个代理,其中 ${validCount} 个可用`); + + // 记录系统启动日志 + await LogsModel.logInfo('系统启动成功', 'system', { + total_proxies: totalCount, + valid_proxies: validCount, + node_version: process.version, + platform: process.platform + }); + } catch (error) { + console.error('数据库初始化失败:', error); + throw error; + } + } + + setupGracefulShutdown() { + const gracefulShutdown = async (signal) => { + if (this.isShuttingDown) { + console.log('正在关闭中,忽略信号:', signal); + return; + } + + this.isShuttingDown = true; + console.log(`收到 ${signal} 信号,开始优雅关闭...`); + + // 停止定时任务 + console.log('停止定时任务...'); + this.scheduler.stop(); + + // 记录系统关闭日志 + try { + await LogsModel.logInfo('系统正常关闭', 'system', { + signal: signal, + uptime: process.uptime() + }); + } catch (error) { + console.error('记录关闭日志失败:', error); + } + + // 关闭数据库连接 + console.log('关闭数据库连接...'); + try { + await Database.close(); + } catch (error) { + console.error('关闭数据库连接失败:', error); + } + + // 关闭HTTP服务器 + console.log('关闭HTTP服务器...'); + process.exit(0); + }; + + // 监听关闭信号 + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + + // 监听未捕获的异常 + process.on('uncaughtException', (error) => { + console.error('未捕获的异常:', error); + gracefulShutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('未处理的Promise拒绝:', reason); + console.error('Promise:', promise); + }); + } + + async start() { + try { + console.log('=== 代理IP服务启动中 ==='); + console.log('服务端口:', this.port); + console.log('启动时间:', new Date().toISOString()); + + // 初始化数据库 + await this.initializeDatabase(); + + // 设置中间件和路由 + this.setupMiddleware(); + this.setupRoutes(); + + // 设置优雅关闭 + this.setupGracefulShutdown(); + + // 启动定时任务 + console.log('启动定时任务调度器...'); + this.scheduler.start(); + + // 启动HTTP服务器 + this.server = this.app.listen(this.port, () => { + console.log('=== 代理IP服务启动成功 ==='); + console.log('服务地址: http://localhost:' + this.port); + console.log('Web管理界面: http://localhost:' + this.port); + console.log(' - 仪表板: http://localhost:' + this.port + '/index.html'); + console.log(' - 代理管理: http://localhost:' + this.port + '/proxies.html'); + console.log(' - 执行历史: http://localhost:' + this.port + '/history.html'); + console.log(' - 系统监控: http://localhost:' + this.port + '/monitoring.html'); + console.log('API文档: http://localhost:' + this.port + '/api/info'); + console.log('健康检查: http://localhost:' + this.port + '/api/health'); + console.log('定时任务已自动启动'); + }); + + // 设置服务器超时 + this.server.timeout = 30000; // 30秒超时 + + } catch (error) { + console.error('服务启动失败:', error); + process.exit(1); + } + } + + async stop() { + if (this.server) { + this.server.close(); + } + } +} + +// 创建并启动应用 +const app = new ProxyApp(); +app.start().catch(error => { + console.error('应用启动失败:', error); + process.exit(1); +}); + +module.exports = ProxyApp; \ No newline at end of file diff --git a/src/database/db.js b/src/database/db.js new file mode 100644 index 0000000..2deca99 --- /dev/null +++ b/src/database/db.js @@ -0,0 +1,82 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const config = require('../../config/database'); + +class Database { + constructor() { + this.db = null; + } + + connect() { + return new Promise((resolve, reject) => { + this.db = new sqlite3.Database( + config.development.storage, + sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, + (err) => { + if (err) { + console.error('数据库连接失败:', err.message); + reject(err); + } else { + console.log('已连接到 SQLite 数据库'); + resolve(); + } + } + ); + }); + } + + close() { + return new Promise((resolve, reject) => { + if (this.db) { + this.db.close((err) => { + if (err) { + reject(err); + } else { + console.log('数据库连接已关闭'); + resolve(); + } + }); + } else { + resolve(); + } + }); + } + + run(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.run(sql, params, function(err) { + if (err) { + reject(err); + } else { + resolve({ id: this.lastID, changes: this.changes }); + } + }); + }); + } + + get(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => { + if (err) { + reject(err); + } else { + resolve(row); + } + }); + }); + } + + all(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.all(sql, params, (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); + } +} + +module.exports = new Database(); \ No newline at end of file diff --git a/src/database/models/history.js b/src/database/models/history.js new file mode 100644 index 0000000..3bd9d82 --- /dev/null +++ b/src/database/models/history.js @@ -0,0 +1,260 @@ +const db = require('../db'); + +class HistoryModel { + static async initTable() { + const sql = ` + CREATE TABLE IF NOT EXISTS execution_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + task_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + start_time DATETIME NOT NULL, + end_time DATETIME, + duration INTEGER, + result_summary TEXT, + error_message TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `; + + try { + await db.run(sql); + console.log('执行历史表创建成功或已存在'); + } catch (error) { + console.error('创建执行历史表失败:', error); + throw error; + } + } + + static async create(historyData) { + const sql = ` + INSERT INTO execution_history + (task_type, task_name, status, start_time, end_time, duration, result_summary, error_message, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + historyData.task_type, + historyData.task_name, + historyData.status || 'pending', + historyData.start_time, + historyData.end_time || null, + historyData.duration || null, + historyData.result_summary || null, + historyData.error_message || null, + historyData.details ? JSON.stringify(historyData.details) : null + ]; + + try { + const result = await db.run(sql, params); + return result; + } catch (error) { + console.error('创建执行历史记录失败:', error); + throw error; + } + } + + static async update(id, updateData) { + const fields = []; + const params = []; + + Object.keys(updateData).forEach(key => { + if (updateData[key] !== undefined) { + fields.push(`${key} = ?`); + if (key === 'details') { + params.push(JSON.stringify(updateData[key])); + } else { + params.push(updateData[key]); + } + } + }); + + if (fields.length === 0) { + throw new Error('没有要更新的字段'); + } + + const sql = ` + UPDATE execution_history + SET ${fields.join(', ')} + WHERE id = ? + `; + + params.push(id); + + try { + const result = await db.run(sql, params); + return result; + } catch (error) { + console.error('更新执行历史记录失败:', error); + throw error; + } + } + + static async findAll(taskType = null, status = null, limit = 50, offset = 0) { + let sql = 'SELECT * FROM execution_history WHERE 1=1'; + const params = []; + + if (taskType) { + sql += ' AND task_type = ?'; + params.push(taskType); + } + + if (status) { + sql += ' AND status = ?'; + params.push(status); + } + + sql += ' ORDER BY start_time DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + try { + const histories = await db.all(sql, params); + + // 解析details字段 + return histories.map(history => ({ + ...history, + details: history.details ? JSON.parse(history.details) : null + })); + } catch (error) { + console.error('查询执行历史失败:', error); + throw error; + } + } + + static async findById(id) { + const sql = 'SELECT * FROM execution_history WHERE id = ?'; + + try { + const history = await db.get(sql, [id]); + + if (history && history.details) { + history.details = JSON.parse(history.details); + } + + return history; + } catch (error) { + console.error('根据ID查询执行历史失败:', error); + throw error; + } + } + + static async getLatest(taskType, limit = 5) { + const sql = 'SELECT * FROM execution_history WHERE task_type = ? ORDER BY start_time DESC LIMIT ?'; + + try { + const histories = await db.all(sql, [taskType, limit]); + + // 解析details字段 + return histories.map(history => ({ + ...history, + details: history.details ? JSON.parse(history.details) : null + })); + } catch (error) { + console.error('查询最新执行历史失败:', error); + throw error; + } + } + + static async getStatistics(taskType = null, days = 7) { + let sql = ` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN status = 'success' THEN 1 END) as success, + COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed, + COUNT(CASE WHEN status = 'running' THEN 1 END) as running, + AVG(duration) as avg_duration + FROM execution_history + WHERE start_time >= datetime('now', '-${days} days') + `; + + const params = []; + + if (taskType) { + sql += ' AND task_type = ?'; + params.push(taskType); + } + + try { + const stats = await db.get(sql, params); + return { + total: stats.total || 0, + success: stats.success || 0, + failed: stats.failed || 0, + running: stats.running || 0, + success_rate: stats.total > 0 ? ((stats.success / stats.total) * 100).toFixed(2) : '0', + avg_duration: stats.avg_duration ? Math.round(stats.avg_duration) : 0 + }; + } catch (error) { + console.error('查询执行历史统计失败:', error); + throw error; + } + } + + static async getDailyStats(taskType = null, days = 7) { + let sql = ` + SELECT + DATE(start_time) as date, + COUNT(*) as total, + COUNT(CASE WHEN status = 'success' THEN 1 END) as success, + COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed, + AVG(duration) as avg_duration + FROM execution_history + WHERE start_time >= datetime('now', '-${days} days') + `; + + const params = []; + + if (taskType) { + sql += ' AND task_type = ?'; + params.push(taskType); + } + + sql += ' GROUP BY DATE(start_time) ORDER BY date DESC'; + + try { + const dailyStats = await db.all(sql, params); + return dailyStats; + } catch (error) { + console.error('查询每日统计失败:', error); + throw error; + } + } + + static async deleteOldRecords(days = 30) { + const sql = 'DELETE FROM execution_history WHERE start_time < datetime("now", "-' + days + ' days")'; + + try { + const result = await db.run(sql); + return result; + } catch (error) { + console.error('删除旧执行历史记录失败:', error); + throw error; + } + } + + static async count(taskType = null, status = null) { + let sql = 'SELECT COUNT(*) as count FROM execution_history WHERE 1=1'; + const params = []; + + if (taskType) { + sql += ' AND task_type = ?'; + params.push(taskType); + } + + if (status) { + sql += ' AND status = ?'; + params.push(status); + } + + try { + const result = await db.get(sql, params); + return result.count; + } catch (error) { + console.error('统计执行历史记录数量失败:', error); + throw error; + } + } +} + +module.exports = HistoryModel; \ No newline at end of file diff --git a/src/database/models/logs.js b/src/database/models/logs.js new file mode 100644 index 0000000..9b989a2 --- /dev/null +++ b/src/database/models/logs.js @@ -0,0 +1,287 @@ +const db = require('../db'); + +class LogsModel { + static async initTable() { + const sql = ` + CREATE TABLE IF NOT EXISTS system_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT NOT NULL, + category TEXT, + details TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + source TEXT, + user_agent TEXT, + ip_address TEXT + ) + `; + + try { + await db.run(sql); + console.log('系统日志表创建成功或已存在'); + } catch (error) { + console.error('创建系统日志表失败:', error); + throw error; + } + } + + static async create(logData) { + const sql = ` + INSERT INTO system_logs + (level, message, category, details, timestamp, source, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + logData.level, + logData.message, + logData.category || null, + logData.details ? JSON.stringify(logData.details) : null, + logData.timestamp || new Date().toISOString(), + logData.source || 'system', + logData.user_agent || null, + logData.ip_address || null + ]; + + try { + const result = await db.run(sql, params); + return result; + } catch (error) { + console.error('创建系统日志记录失败:', error); + throw error; + } + } + + static async log(level, message, category = null, details = null, source = 'system') { + return await this.create({ + level, + message, + category, + details, + source + }); + } + + static async logInfo(message, category = null, details = null, source = 'system') { + return await this.log('info', message, category, details, source); + } + + static async logWarning(message, category = null, details = null, source = 'system') { + return await this.log('warning', message, category, details, source); + } + + static async logError(message, category = null, details = null, source = 'system') { + return await this.log('error', message, category, details, source); + } + + static async logDebug(message, category = null, details = null, source = 'system') { + return await this.log('debug', message, category, details, source); + } + + static async findAll(level = null, category = null, limit = 100, offset = 0) { + let sql = 'SELECT * FROM system_logs WHERE 1=1'; + const params = []; + + if (level) { + sql += ' AND level = ?'; + params.push(level); + } + + if (category) { + sql += ' AND category = ?'; + params.push(category); + } + + sql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + try { + const logs = await db.all(sql, params); + + // 解析details字段 + return logs.map(log => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null + })); + } catch (error) { + console.error('查询系统日志失败:', error); + throw error; + } + } + + static async findById(id) { + const sql = 'SELECT * FROM system_logs WHERE id = ?'; + + try { + const log = await db.get(sql, [id]); + + if (log && log.details) { + log.details = JSON.parse(log.details); + } + + return log; + } catch (error) { + console.error('根据ID查询系统日志失败:', error); + throw error; + } + } + + static async getLatest(level = null, category = null, limit = 20) { + let sql = 'SELECT * FROM system_logs WHERE 1=1'; + const params = []; + + if (level) { + sql += ' AND level = ?'; + params.push(level); + } + + if (category) { + sql += ' AND category = ?'; + params.push(category); + } + + sql += ' ORDER BY timestamp DESC LIMIT ?'; + params.push(limit); + + try { + const logs = await db.all(sql, params); + + // 解析details字段 + return logs.map(log => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null + })); + } catch (error) { + console.error('查询最新系统日志失败:', error); + throw error; + } + } + + static async getStatistics(days = 7) { + const sql = ` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN level = 'error' THEN 1 END) as error, + COUNT(CASE WHEN level = 'warning' THEN 1 END) as warning, + COUNT(CASE WHEN level = 'info' THEN 1 END) as info, + COUNT(CASE WHEN level = 'debug' THEN 1 END) as debug + FROM system_logs + WHERE timestamp >= datetime('now', '-${days} days') + `; + + try { + const stats = await db.get(sql); + return { + total: stats.total || 0, + error: stats.error || 0, + warning: stats.warning || 0, + info: stats.info || 0, + debug: stats.debug || 0 + }; + } catch (error) { + console.error('查询系统日志统计失败:', error); + throw error; + } + } + + static async getHourlyStats(days = 1) { + const sql = ` + SELECT + strftime('%Y-%m-%d %H:00:00', timestamp) as hour, + level, + COUNT(*) as count + FROM system_logs + WHERE timestamp >= datetime('now', '-${days} days') + GROUP BY hour, level + ORDER BY hour DESC + `; + + try { + const hourlyStats = await db.all(sql); + return hourlyStats; + } catch (error) { + console.error('查询每小时统计失败:', error); + throw error; + } + } + + static async search(keyword, level = null, category = null, limit = 50) { + let sql = 'SELECT * FROM system_logs WHERE message LIKE ?'; + const params = [`%${keyword}%`]; + + if (level) { + sql += ' AND level = ?'; + params.push(level); + } + + if (category) { + sql += ' AND category = ?'; + params.push(category); + } + + sql += ' ORDER BY timestamp DESC LIMIT ?'; + params.push(limit); + + try { + const logs = await db.all(sql, params); + + // 解析details字段 + return logs.map(log => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null + })); + } catch (error) { + console.error('搜索系统日志失败:', error); + throw error; + } + } + + static async deleteOldRecords(days = 30) { + const sql = 'DELETE FROM system_logs WHERE timestamp < datetime("now", "-' + days + ' days")'; + + try { + const result = await db.run(sql); + return result; + } catch (error) { + console.error('删除旧系统日志记录失败:', error); + throw error; + } + } + + static async count(level = null, category = null) { + let sql = 'SELECT COUNT(*) as count FROM system_logs WHERE 1=1'; + const params = []; + + if (level) { + sql += ' AND level = ?'; + params.push(level); + } + + if (category) { + sql += ' AND category = ?'; + params.push(category); + } + + try { + const result = await db.get(sql, params); + return result.count; + } catch (error) { + console.error('统计系统日志记录数量失败:', error); + throw error; + } + } + + static async getCategories() { + const sql = 'SELECT DISTINCT category FROM system_logs WHERE category IS NOT NULL ORDER BY category'; + + try { + const categories = await db.all(sql); + return categories.map(row => row.category); + } catch (error) { + console.error('获取日志分类失败:', error); + throw error; + } + } +} + +module.exports = LogsModel; \ No newline at end of file diff --git a/src/database/models/proxy.js b/src/database/models/proxy.js new file mode 100644 index 0000000..068ad0e --- /dev/null +++ b/src/database/models/proxy.js @@ -0,0 +1,178 @@ +const db = require('../db'); + +class ProxyModel { + static async initTable() { + const sql = ` + CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + location TEXT, + speed INTEGER, + last_check_time TEXT, + is_valid INTEGER DEFAULT 1, + response_time INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(ip, port) + ) + `; + + try { + await db.run(sql); + console.log('代理表创建成功或已存在'); + } catch (error) { + console.error('创建代理表失败:', error); + throw error; + } + } + + static async create(proxyData) { + const sql = ` + INSERT OR REPLACE INTO proxies + (ip, port, location, speed, last_check_time, is_valid, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `; + + const params = [ + proxyData.ip, + proxyData.port, + proxyData.location || null, + proxyData.speed || null, + proxyData.last_check_time || null, + proxyData.is_valid !== undefined ? proxyData.is_valid : 1 + ]; + + try { + const result = await db.run(sql, params); + return result; + } catch (error) { + console.error('插入代理数据失败:', error); + throw error; + } + } + + static async findAll(validOnly = true) { + let sql = 'SELECT * FROM proxies'; + const params = []; + + if (validOnly) { + sql += ' WHERE is_valid = 1'; + } + + sql += ' ORDER BY response_time ASC, created_at DESC'; + + try { + const proxies = await db.all(sql, params); + return proxies; + } catch (error) { + console.error('查询代理数据失败:', error); + throw error; + } + } + + static async findRandom(validOnly = true, limit = 1) { + let sql = 'SELECT * FROM proxies'; + const params = []; + + if (validOnly) { + sql += ' WHERE is_valid = 1'; + } + + sql += ' ORDER BY RANDOM() LIMIT ?'; + params.push(limit); + + try { + const proxies = await db.all(sql, params); + return limit === 1 ? proxies[0] : proxies; + } catch (error) { + console.error('查询随机代理失败:', error); + throw error; + } + } + + static async findByIpAndPort(ip, port) { + const sql = 'SELECT * FROM proxies WHERE ip = ? AND port = ?'; + + try { + const proxy = await db.get(sql, [ip, port]); + return proxy; + } catch (error) { + console.error('根据IP和端口查询代理失败:', error); + throw error; + } + } + + static async updateValidity(ip, port, isValid, responseTime = null) { + const sql = ` + UPDATE proxies + SET is_valid = ?, response_time = ?, updated_at = datetime('now') + WHERE ip = ? AND port = ? + `; + + try { + const result = await db.run(sql, [isValid, responseTime, ip, port]); + return result; + } catch (error) { + console.error('更新代理有效性失败:', error); + throw error; + } + } + + static async deleteInvalid() { + const sql = 'DELETE FROM proxies WHERE is_valid = 0'; + + try { + const result = await db.run(sql); + return result; + } catch (error) { + console.error('删除无效代理失败:', error); + throw error; + } + } + + static async count(validOnly = true) { + let sql = 'SELECT COUNT(*) as count FROM proxies'; + + if (validOnly) { + sql += ' WHERE is_valid = 1'; + } + + try { + const result = await db.get(sql); + return result.count; + } catch (error) { + console.error('统计代理数量失败:', error); + throw error; + } + } + + static async all(sql, params = []) { + try { + const proxies = await db.all(sql, params); + return proxies; + } catch (error) { + console.error('执行SQL查询失败:', error); + throw error; + } + } + + static async findAllForValidation(limit = 50) { + const sql = ` + SELECT * FROM proxies + WHERE is_valid = 1 + ORDER BY updated_at ASC + LIMIT ? + `; + + try { + const proxies = await db.all(sql, [limit]); + return proxies; + } catch (error) { + console.error('查询待验证代理失败:', error); + throw error; + } + } +} + +module.exports = ProxyModel; \ No newline at end of file diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 0000000..571d10d --- /dev/null +++ b/src/routes/dashboard.js @@ -0,0 +1,411 @@ +const express = require('express'); +const router = express.Router(); +const ProxyModel = require('../database/models/proxy'); +const HistoryModel = require('../database/models/history'); +const LogsModel = require('../database/models/logs'); +const ProxyValidator = require('../services/validator'); +const ProxyScraper = require('../services/scraper'); +const ProxyScheduler = require('../services/scheduler'); + +const validator = new ProxyValidator(); +const scraper = new ProxyScraper(); + +// 获取仪表板统计数据 +router.get('/stats', async (req, res) => { + try { + // 代理统计 + const proxyStats = await validator.getProxyStatistics(); + + // 执行历史统计 + const scrapeStats = await HistoryModel.getStatistics('scrape', 7); + const validationStats = await HistoryModel.getStatistics('validation', 7); + + // 系统日志统计 + const logStats = await LogsModel.getStatistics(7); + + // 最近执行历史 + const latestScrape = await HistoryModel.getLatest('scrape', 3); + const latestValidation = await HistoryModel.getLatest('validation', 3); + + // 每日统计数据 + const dailyProxyStats = await getDailyProxyStats(7); + const dailyTaskStats = await getDailyTaskStats(7); + + res.json({ + success: true, + data: { + proxies: proxyStats, + tasks: { + scrape: scrapeStats, + validation: validationStats + }, + logs: logStats, + latest: { + scrape: latestScrape, + validation: latestValidation + }, + charts: { + daily_proxies: dailyProxyStats, + daily_tasks: dailyTaskStats + }, + timestamp: new Date().toISOString() + } + }); + } catch (error) { + console.error('获取仪表板统计数据失败:', error); + res.status(500).json({ + success: false, + error: '获取统计数据失败', + message: error.message + }); + } +}); + +// 获取实时系统状态 +router.get('/status', async (req, res) => { + try { + // 代理统计 + const proxyStats = await validator.getProxyStatistics(); + + // 当前时间 + const now = new Date(); + + // 今天的执行历史 + const todayScrape = await HistoryModel.getStatistics('scrape', 1); + const todayValidation = await HistoryModel.getStatistics('validation', 1); + + // 最近的日志 + const recentLogs = await LogsModel.getLatest(null, null, 5); + + // 系统运行时间 + const uptime = process.uptime(); + + // 内存使用情况 + const memUsage = process.memoryUsage(); + + res.json({ + success: true, + data: { + timestamp: now.toISOString(), + uptime: uptime, + memory: { + rss: Math.round(memUsage.rss / 1024 / 1024), // MB + heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) // MB + }, + proxies: proxyStats, + today_tasks: { + scrape: todayScrape, + validation: todayValidation + }, + recent_logs: recentLogs, + next_runs: getNextRunTimes() + } + }); + } catch (error) { + console.error('获取实时状态失败:', error); + res.status(500).json({ + success: false, + error: '获取状态失败', + message: error.message + }); + } +}); + +// 获取代理统计图表数据 +router.get('/charts/proxies', async (req, res) => { + try { + const days = parseInt(req.query.days) || 7; + const chartData = await getDailyProxyStats(days); + + res.json({ + success: true, + data: chartData + }); + } catch (error) { + console.error('获取代理图表数据失败:', error); + res.status(500).json({ + success: false, + error: '获取图表数据失败', + message: error.message + }); + } +}); + +// 获取任务执行图表数据 +router.get('/charts/tasks', async (req, res) => { + try { + const days = parseInt(req.query.days) || 7; + const chartData = await getDailyTaskStats(days); + + res.json({ + success: true, + data: chartData + }); + } catch (error) { + console.error('获取任务图表数据失败:', error); + res.status(500).json({ + success: false, + error: '获取图表数据失败', + message: error.message + }); + } +}); + +// 获取日志统计图表数据 +router.get('/charts/logs', async (req, res) => { + try { + const days = parseInt(req.query.days) || 7; + const chartData = await LogsModel.getHourlyStats(days); + + res.json({ + success: true, + data: chartData + }); + } catch (error) { + console.error('获取日志图表数据失败:', error); + res.status(500).json({ + success: false, + error: '获取图表数据失败', + message: error.message + }); + } +}); + +// 快速操作接口 +router.post('/actions/scrape', async (req, res) => { + try { + // 创建调度器实例 + const scheduler = require('../services/scheduler'); + const ProxyScheduler = require('../services/scheduler'); + const schedulerInstance = new ProxyScheduler(); + + // 检查是否有抓取任务正在进行 + const ProxyScraper = require('../services/scraper'); + if (ProxyScraper.isScrapingInProgress()) { + return res.status(409).json({ + success: false, + error: '有抓取任务正在进行', + message: '请等待当前抓取任务完成后再试' + }); + } + + // 创建执行历史记录 + const historyId = await createTaskHistory('scrape', '手动触发抓取任务'); + + // 异步执行定时抓取任务(与定时任务相同的逻辑) + schedulerInstance.runScrapeTask().then(async (result) => { + if (result.skipped) { + await updateTaskHistory(historyId, 'skipped', result, result.message); + await LogsModel.logInfo(`手动抓取任务跳过: ${result.message}`, 'manual_action', result); + } else { + await updateTaskHistory(historyId, 'success', { + scraped: result.scraped, + total: result.total, + valid: result.valid + }); + await LogsModel.logInfo(`手动抓取任务完成: 抓取${result.scraped}个代理`, 'manual_action', result); + } + }).catch(async (error) => { + await updateTaskHistory(historyId, 'failed', null, error.message); + await LogsModel.logError(`手动抓取任务失败: ${error.message}`, 'manual_action', { error: error.message }); + }); + + res.json({ + success: true, + message: '已触发定时抓取任务', + history_id: historyId + }); + } catch (error) { + console.error('启动抓取任务失败:', error); + res.status(500).json({ + success: false, + error: '启动抓取任务失败', + message: error.message + }); + } +}); + +router.post('/actions/validate', async (req, res) => { + try { + const { limit = 50 } = req.body; + + // 创建执行历史记录 + const historyId = await createTaskHistory('validation', '手动验证任务'); + + // 异步执行验证任务 + validator.validateDatabaseProxies(limit).then(async (result) => { + await updateTaskHistory(historyId, 'success', { + validated: result.validated, + valid: result.valid, + invalid: result.invalid + }); + await LogsModel.logInfo(`手动验证任务完成: 验证${result.validated}个代理`, 'manual_action', result); + }).catch(async (error) => { + await updateTaskHistory(historyId, 'failed', null, error.message); + await LogsModel.logError(`手动验证任务失败: ${error.message}`, 'manual_action', { error: error.message }); + }); + + res.json({ + success: true, + message: `验证任务已启动,将验证 ${limit} 个代理`, + history_id: historyId + }); + } catch (error) { + console.error('启动验证任务失败:', error); + res.status(500).json({ + success: false, + error: '启动验证任务失败', + message: error.message + }); + } +}); + +// 辅助函数 + +// 获取每日代理统计 +async function getDailyProxyStats(days) { + const db = require('../database/db'); + + const sql = ` + SELECT + DATE(created_at) as date, + COUNT(*) as total_added, + COUNT(CASE WHEN is_valid = 1 THEN 1 END) as valid_added + FROM proxies + WHERE created_at >= datetime('now', '-${days} days') + GROUP BY DATE(created_at) + ORDER BY date + `; + + try { + const dailyStats = await db.all(sql); + return dailyStats; + } catch (error) { + console.error('获取每日代理统计失败:', error); + return []; + } +} + +// 获取每日任务统计 +async function getDailyTaskStats(days) { + try { + const scrapeStats = await HistoryModel.getDailyStats('scrape', days); + const validationStats = await HistoryModel.getDailyStats('validation', days); + + // 合并数据 + const mergedStats = {}; + const dates = new Set(); + + scrapeStats.forEach(stat => { + mergedStats[stat.date] = { + date: stat.date, + scrape_total: stat.total, + scrape_success: stat.success, + scrape_failed: stat.failed, + validation_total: 0, + validation_success: 0, + validation_failed: 0 + }; + dates.add(stat.date); + }); + + validationStats.forEach(stat => { + if (mergedStats[stat.date]) { + mergedStats[stat.date].validation_total = stat.total; + mergedStats[stat.date].validation_success = stat.success; + mergedStats[stat.date].validation_failed = stat.failed; + } else { + mergedStats[stat.date] = { + date: stat.date, + scrape_total: 0, + scrape_success: 0, + scrape_failed: 0, + validation_total: stat.total, + validation_success: stat.success, + validation_failed: stat.failed + }; + dates.add(stat.date); + } + }); + + return Object.values(mergedStats).sort((a, b) => new Date(a.date) - new Date(b.date)); + } catch (error) { + console.error('获取每日任务统计失败:', error); + return []; + } +} + +// 获取下次执行时间 +function getNextRunTimes() { + const now = new Date(); + const nextScrape = new Date(now); + const nextValidation = new Date(now); + const nextHealthCheck = new Date(now); + + // 下次抓取时间(每小时整点) + nextScrape.setHours(nextScrape.getHours() + 1, 0, 0, 0); + + // 下次验证时间(每10分钟) + const minutes = now.getMinutes(); + const nextMinute = Math.ceil((minutes + 1) / 10) * 10; + nextValidation.setMinutes(nextMinute, 0, 0); + + // 下次健康检查时间(每小时30分) + if (now.getMinutes() < 30) { + nextHealthCheck.setHours(now.getHours(), 30, 0, 0); + } else { + nextHealthCheck.setHours(now.getHours() + 1, 30, 0, 0); + } + + return { + scrape: nextScrape.toISOString(), + validation: nextValidation.toISOString(), + healthCheck: nextHealthCheck.toISOString() + }; +} + +// 创建任务历史记录 +async function createTaskHistory(taskType, taskName) { + try { + const result = await HistoryModel.create({ + task_type: taskType, + task_name: taskName, + status: 'running', + start_time: new Date().toISOString() + }); + + return result.id; + } catch (error) { + console.error('创建任务历史记录失败:', error); + throw error; + } +} + +// 更新任务历史记录 +async function updateTaskHistory(id, status, details = null, errorMessage = null) { + try { + const updateData = { + status: status, + end_time: new Date().toISOString(), + duration: null, + details: details, + error_message: errorMessage + }; + + // 计算执行时长 + const history = await HistoryModel.findById(id); + if (history && history.start_time) { + const startTime = new Date(history.start_time); + const endTime = new Date(); + updateData.duration = endTime.getTime() - startTime.getTime(); + } + + await HistoryModel.update(id, updateData); + } catch (error) { + console.error('更新任务历史记录失败:', error); + throw error; + } +} + +module.exports = router; \ No newline at end of file diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..9f1fd29 --- /dev/null +++ b/src/routes/history.js @@ -0,0 +1,337 @@ +const express = require('express'); +const router = express.Router(); +const HistoryModel = require('../database/models/history'); +const LogsModel = require('../database/models/logs'); + +// 获取执行历史列表 +router.get('/', async (req, res) => { + try { + const taskType = req.query.taskType || null; + const status = req.query.status || null; + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + const offset = parseInt(req.query.offset) || 0; + + const histories = await HistoryModel.findAll(taskType, status, limit, offset); + const totalCount = await HistoryModel.count(taskType, status); + + res.json({ + success: true, + data: histories, + pagination: { + total: totalCount, + limit: limit, + offset: offset, + hasMore: offset + limit < totalCount + } + }); + } catch (error) { + console.error('获取执行历史失败:', error); + res.status(500).json({ + success: false, + error: '获取执行历史失败', + message: error.message + }); + } +}); + +// 获取执行历史详情 +router.get('/:id', async (req, res) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + error: '无效的历史记录ID' + }); + } + + const history = await HistoryModel.findById(id); + + if (!history) { + return res.status(404).json({ + success: false, + error: '未找到指定的历史记录' + }); + } + + res.json({ + success: true, + data: history + }); + } catch (error) { + console.error('获取执行历史详情失败:', error); + res.status(500).json({ + success: false, + error: '获取执行历史详情失败', + message: error.message + }); + } +}); + +// 获取执行历史统计 +router.get('/stats/summary', async (req, res) => { + try { + const taskType = req.query.taskType || null; + const days = Math.min(parseInt(req.query.days) || 7, 30); + + const stats = await HistoryModel.getStatistics(taskType, days); + const dailyStats = await HistoryModel.getDailyStats(taskType, days); + + res.json({ + success: true, + data: { + summary: stats, + daily: dailyStats + } + }); + } catch (error) { + console.error('获取执行历史统计失败:', error); + res.status(500).json({ + success: false, + error: '获取执行历史统计失败', + message: error.message + }); + } +}); + +// 获取系统日志列表 +router.get('/logs/system', async (req, res) => { + try { + const level = req.query.level || null; + const category = req.query.category || null; + const limit = Math.min(parseInt(req.query.limit) || 100, 500); + const offset = parseInt(req.query.offset) || 0; + + const logs = await LogsModel.findAll(level, category, limit, offset); + const totalCount = await LogsModel.count(level, category); + + res.json({ + success: true, + data: logs, + pagination: { + total: totalCount, + limit: limit, + offset: offset, + hasMore: offset + limit < totalCount + } + }); + } catch (error) { + console.error('获取系统日志失败:', error); + res.status(500).json({ + success: false, + error: '获取系统日志失败', + message: error.message + }); + } +}); + +// 搜索系统日志 +router.get('/logs/search', async (req, res) => { + try { + const keyword = req.query.keyword; + const level = req.query.level || null; + const category = req.query.category || null; + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + + if (!keyword || keyword.trim().length === 0) { + return res.status(400).json({ + success: false, + error: '搜索关键词不能为空' + }); + } + + const logs = await LogsModel.search(keyword.trim(), level, category, limit); + + res.json({ + success: true, + data: logs, + count: logs.length + }); + } catch (error) { + console.error('搜索系统日志失败:', error); + res.status(500).json({ + success: false, + error: '搜索系统日志失败', + message: error.message + }); + } +}); + +// 获取日志统计 +router.get('/logs/stats', async (req, res) => { + try { + const days = Math.min(parseInt(req.query.days) || 7, 30); + + const stats = await LogsModel.getStatistics(days); + const hourlyStats = await LogsModel.getHourlyStats(days); + const categories = await LogsModel.getCategories(); + + res.json({ + success: true, + data: { + summary: stats, + hourly: hourlyStats, + categories: categories + } + }); + } catch (error) { + console.error('获取日志统计失败:', error); + res.status(500).json({ + success: false, + error: '获取日志统计失败', + message: error.message + }); + } +}); + +// 获取日志分类 +router.get('/logs/categories', async (req, res) => { + try { + const categories = await LogsModel.getCategories(); + + res.json({ + success: true, + data: categories + }); + } catch (error) { + console.error('获取日志分类失败:', error); + res.status(500).json({ + success: false, + error: '获取日志分类失败', + message: error.message + }); + } +}); + +// 清理旧记录 +router.delete('/cleanup', async (req, res) => { + try { + const { days = 30, type = 'all' } = req.body; + + if (days < 1 || days > 365) { + return res.status(400).json({ + success: false, + error: '保留天数必须在1-365之间' + }); + } + + let deletedHistory = 0; + let deletedLogs = 0; + + if (type === 'all' || type === 'history') { + const historyResult = await HistoryModel.deleteOldRecords(days); + deletedHistory = historyResult.changes || 0; + } + + if (type === 'all' || type === 'logs') { + const logsResult = await LogsModel.deleteOldRecords(days); + deletedLogs = logsResult.changes || 0; + } + + res.json({ + success: true, + message: `清理完成,删除了 ${deletedHistory} 条历史记录和 ${deletedLogs} 条日志记录`, + data: { + deleted_history: deletedHistory, + deleted_logs: deletedLogs, + days: days + } + }); + + // 记录清理操作日志 + await LogsModel.logInfo( + `手动清理旧记录:删除${deletedHistory}条历史记录和${deletedLogs}条日志记录`, + 'cleanup', + { deleted_history: deletedHistory, deleted_logs: deletedLogs, days: days }, + 'manual_action' + ); + } catch (error) { + console.error('清理旧记录失败:', error); + res.status(500).json({ + success: false, + error: '清理旧记录失败', + message: error.message + }); + } +}); + +// 获取任务类型统计 +router.get('/task-types', async (req, res) => { + try { + const sql = ` + SELECT DISTINCT task_type + FROM execution_history + ORDER BY task_type + `; + + const taskTypes = await HistoryModel.all(sql); + + res.json({ + success: true, + data: taskTypes.map(row => row.task_type) + }); + } catch (error) { + console.error('获取任务类型失败:', error); + res.status(500).json({ + success: false, + error: '获取任务类型失败', + message: error.message + }); + } +}); + +// 导出历史记录 +router.get('/export', async (req, res) => { + try { + const taskType = req.query.taskType || null; + const status = req.query.status || null; + const format = req.query.format || 'json'; + + const histories = await HistoryModel.findAll(taskType, status, 1000, 0); + + if (format === 'csv') { + // 生成CSV格式 + const csvHeader = 'ID,任务类型,任务名称,状态,开始时间,结束时间,执行时长(ms),结果摘要,错误信息\n'; + const csvData = histories.map(history => { + return [ + history.id, + history.task_type, + history.task_name, + history.status, + history.start_time, + history.end_time || '', + history.duration || '', + `"${(history.result_summary || '').replace(/"/g, '""')}"`, + `"${(history.error_message || '').replace(/"/g, '""')}"` + ].join(','); + }).join('\n'); + + const csv = csvHeader + csvData; + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename=execution_history_${new Date().toISOString().slice(0, 10)}.csv`); + res.send(csv); + } else { + // JSON格式 + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename=execution_history_${new Date().toISOString().slice(0, 10)}.json`); + res.json({ + success: true, + export_time: new Date().toISOString(), + filters: { taskType, status }, + count: histories.length, + data: histories + }); + } + } catch (error) { + console.error('导出历史记录失败:', error); + res.status(500).json({ + success: false, + error: '导出历史记录失败', + message: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/proxies.js b/src/routes/proxies.js new file mode 100644 index 0000000..6937b0b --- /dev/null +++ b/src/routes/proxies.js @@ -0,0 +1,485 @@ +const express = require('express'); +const router = express.Router(); +const ProxyModel = require('../database/models/proxy'); +const ProxyValidator = require('../services/validator'); +const ProxyScraper = require('../services/scraper'); + +const validator = new ProxyValidator(); +const scraper = new ProxyScraper(); + +// 获取所有可用代理 +router.get('/', async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 100; + const offset = parseInt(req.query.offset) || 0; + const sortBy = req.query.sortBy || 'response_time'; + const order = req.query.order || 'ASC'; + + let sql = 'SELECT * FROM proxies WHERE is_valid = 1'; + const params = []; + + // 添加排序 + const allowedSortFields = ['response_time', 'created_at', 'updated_at', 'ip', 'port']; + if (allowedSortFields.includes(sortBy)) { + sql += ` ORDER BY ${sortBy} ${order.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`; + } else { + sql += ' ORDER BY response_time ASC'; + } + + // 添加分页 + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const proxies = await ProxyModel.all(sql, params); + const totalCount = await ProxyModel.count(true); + + res.json({ + success: true, + data: proxies, + pagination: { + total: totalCount, + limit: limit, + offset: offset, + hasMore: offset + limit < totalCount + } + }); + } catch (error) { + console.error('获取代理列表失败:', error); + res.status(500).json({ + success: false, + error: '获取代理列表失败', + message: error.message + }); + } +}); + +// 获取随机可用代理 +router.get('/random', async (req, res) => { + try { + const count = Math.min(parseInt(req.query.count) || 1, 10); // 最多返回10个 + + if (count === 1) { + const proxy = await ProxyModel.findRandom(true, 1); + if (!proxy) { + return res.status(404).json({ + success: false, + error: '没有可用的代理' + }); + } + + res.json({ + success: true, + data: proxy + }); + } else { + const proxies = await ProxyModel.findRandom(true, count); + res.json({ + success: true, + data: proxies, + count: proxies.length + }); + } + } catch (error) { + console.error('获取随机代理失败:', error); + res.status(500).json({ + success: false, + error: '获取随机代理失败', + message: error.message + }); + } +}); + +// 获取代理统计信息 +router.get('/stats', async (req, res) => { + try { + const stats = await validator.getProxyStatistics(); + + res.json({ + success: true, + data: stats + }); + } catch (error) { + console.error('获取统计信息失败:', error); + res.status(500).json({ + success: false, + error: '获取统计信息失败', + message: error.message + }); + } +}); + +// 手动验证代理 +router.post('/verify', async (req, res) => { + try { + const { ip, port } = req.body; + + if (!ip || !port) { + return res.status(400).json({ + success: false, + error: 'IP和端口不能为空' + }); + } + + const result = await validator.validateSingleProxy(ip, port); + + res.json({ + success: true, + data: result + }); + } catch (error) { + console.error('验证代理失败:', error); + res.status(500).json({ + success: false, + error: '验证代理失败', + message: error.message + }); + } +}); + +// 批量验证代理 +router.post('/verify-batch', async (req, res) => { + try { + const { proxies } = req.body; + + if (!Array.isArray(proxies) || proxies.length === 0) { + return res.status(400).json({ + success: false, + error: '代理列表不能为空' + }); + } + + // 验证格式 + const validProxies = proxies.filter(p => p.ip && p.port); + if (validProxies.length === 0) { + return res.status(400).json({ + success: false, + error: '没有有效的代理格式' + }); + } + + const results = await validator.validateMultipleProxies(validProxies, 3); + + res.json({ + success: true, + data: { + validated: results.length, + valid: results.filter(r => r.isValid).length, + invalid: results.filter(r => !r.isValid).length, + results: results + } + }); + } catch (error) { + console.error('批量验证代理失败:', error); + res.status(500).json({ + success: false, + error: '批量验证代理失败', + message: error.message + }); + } +}); + +// 手动触发抓取任务 +router.post('/scrape', async (req, res) => { + try { + const { pages = 40 } = req.body; + + // if (pages < 1 || pages > 10) { + // return res.status(400).json({ + // success: false, + // error: '页数必须在1-10之间' + // }); + // } + + // 检查是否有抓取任务正在进行 + const ProxyScraper = require('../services/scraper'); + if (ProxyScraper.isScrapingInProgress()) { + return res.status(409).json({ + success: false, + error: '有抓取任务正在进行', + message: '请等待当前抓取任务完成后再试' + }); + } + + console.log(`手动触发抓取任务,页数: ${pages}`); + + // 异步执行抓取任务 + scraper.scrapeMultiplePages(pages).then(result => { + console.log('手动抓取任务完成:', result); + }).catch(error => { + console.error('手动抓取任务失败:', error); + }); + + res.json({ + success: true, + message: `抓取任务已启动,将抓取前 ${pages} 页` + }); + } catch (error) { + console.error('触发抓取任务失败:', error); + res.status(500).json({ + success: false, + error: '触发抓取任务失败', + message: error.message + }); + } +}); + +// 手动触发验证任务 +router.post('/validate-all', async (req, res) => { + try { + const { limit = 50 } = req.body; + + if (limit < 1 || limit > 200) { + return res.status(400).json({ + success: false, + error: '验证数量必须在1-200之间' + }); + } + + console.log(`手动触发验证任务,数量: ${limit}`); + + // 异步执行验证任务 + validator.validateDatabaseProxies(limit).then(result => { + console.log('手动验证任务完成:', result); + }).catch(error => { + console.error('手动验证任务失败:', error); + }); + + res.json({ + success: true, + message: `验证任务已启动,将验证 ${limit} 个代理` + }); + } catch (error) { + console.error('触发验证任务失败:', error); + res.status(500).json({ + success: false, + error: '触发验证任务失败', + message: error.message + }); + } +}); + +// 根据IP和端口查找代理 +router.get('/search', async (req, res) => { + try { + const { ip, port } = req.query; + + if (!ip || !port) { + return res.status(400).json({ + success: false, + error: 'IP和端口参数不能为空' + }); + } + + const proxy = await ProxyModel.findByIpAndPort(ip, port); + + if (!proxy) { + return res.status(404).json({ + success: false, + error: '未找到指定的代理' + }); + } + + res.json({ + success: true, + data: proxy + }); + } catch (error) { + console.error('查找代理失败:', error); + res.status(500).json({ + success: false, + error: '查找代理失败', + message: error.message + }); + } +}); + +// 删除无效代理 +router.delete('/cleanup', async (req, res) => { + try { + const result = await ProxyModel.deleteInvalid(); + + res.json({ + success: true, + message: `已删除 ${result.changes} 个无效代理` + }); + } catch (error) { + console.error('清理无效代理失败:', error); + res.status(500).json({ + success: false, + error: '清理无效代理失败', + message: error.message + }); + } +}); + +// 检查抓取任务状态 +router.get('/scraping-status', (req, res) => { + try { + const ProxyScraper = require('../services/scraper'); + const isInProgress = ProxyScraper.isScrapingInProgress(); + + res.json({ + success: true, + data: { + isScrapingInProgress: isInProgress, + message: isInProgress ? '有抓取任务正在进行' : '当前没有抓取任务' + } + }); + } catch (error) { + console.error('获取抓取状态失败:', error); + res.status(500).json({ + success: false, + error: '获取抓取状态失败', + message: error.message + }); + } +}); + +// 强制清除抓取标志(用于异常情况) +router.post('/clear-scraping-flag', (req, res) => { + try { + const ProxyScraper = require('../services/scraper'); + ProxyScraper.clearScrapingFlag(); + + res.json({ + success: true, + message: '已强制清除抓取进行中标志' + }); + } catch (error) { + console.error('清除抓取标志失败:', error); + res.status(500).json({ + success: false, + error: '清除抓取标志失败', + message: error.message + }); + } +}); + +// 导入代理JSON +router.post('/import', async (req, res) => { + try { + const { proxies } = req.body; + + if (!Array.isArray(proxies) || proxies.length === 0) { + return res.status(400).json({ + success: false, + error: '代理列表不能为空' + }); + } + + console.log(`开始导入 ${proxies.length} 个代理`); + + // 验证格式并转换为标准格式 + const validProxies = []; + const invalidProxies = []; + + for (const proxy of proxies) { + let ip, port; + + // 支持多种字段名 + if (proxy.ip_address && proxy.port) { + ip = proxy.ip_address; + port = proxy.port; + } else if (proxy.ip && proxy.port) { + ip = proxy.ip; + port = proxy.port; + } else if (proxy.host && proxy.port) { + ip = proxy.host; + port = proxy.port; + } else { + invalidProxies.push(proxy); + continue; + } + + // 验证IP和端口格式 + if (/^\d+\.\d+\.\d+\.\d+$/.test(ip) && /^\d+$/.test(port)) { + const portNum = parseInt(port); + if (portNum > 0 && portNum <= 65535) { + validProxies.push({ + ip: ip, + port: portNum, + location: proxy.location || proxy.area || null, + speed: proxy.speed || null, + last_check_time: new Date().toISOString().slice(0, 19).replace('T', ' ') + }); + } else { + invalidProxies.push(proxy); + } + } else { + invalidProxies.push(proxy); + } + } + + if (validProxies.length === 0) { + return res.status(400).json({ + success: false, + error: '没有有效的代理格式', + details: { + total: proxies.length, + valid: 0, + invalid: invalidProxies.length + } + }); + } + + console.log(`验证完成,有效代理: ${validProxies.length},无效代理: ${invalidProxies.length}`); + + // 立即验证代理 + const ProxyValidator = require('../services/validator'); + const validator = new ProxyValidator(); + + const validationResult = await validator.validateMultipleProxies(validProxies, 10); + + // 保存验证通过的代理到数据库 + let savedCount = 0; + for (const result of validationResult) { + if (result.isValid) { + try { + await ProxyModel.create({ + ip: result.ip, + port: result.port, + location: null, + speed: null, + last_check_time: new Date().toISOString().slice(0, 19).replace('T', ' '), + is_valid: 1, + response_time: result.responseTime + }); + savedCount++; + console.log(`✓ 保存代理: ${result.ip}:${result.port} - ${result.responseTime}ms`); + } catch (error) { + if (!error.message.includes('UNIQUE constraint failed')) { + console.warn(`保存代理 ${result.ip}:${result.port} 失败:`, error.message); + } + } + } + } + + const validCount = validationResult.filter(r => r.isValid).length; + + console.log(`导入完成: 总数 ${proxies.length},格式有效 ${validProxies.length},验证通过 ${validCount},保存成功 ${savedCount}`); + + res.json({ + success: true, + data: { + total: proxies.length, + format_valid: validProxies.length, + format_invalid: invalidProxies.length, + validated: validationResult.length, + valid: validCount, + invalid: validationResult.length - validCount, + saved: savedCount, + results: validationResult + }, + message: `导入完成:保存了 ${savedCount} 个可用代理` + }); + } catch (error) { + console.error('导入代理失败:', error); + res.status(500).json({ + success: false, + error: '导入代理失败', + message: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/services/scheduler.js b/src/services/scheduler.js new file mode 100644 index 0000000..7ddb730 --- /dev/null +++ b/src/services/scheduler.js @@ -0,0 +1,398 @@ +const cron = require('node-cron'); +const ProxyScraper = require('./scraper'); +const ProxyValidator = require('./validator'); +const ProxyModel = require('../database/models/proxy'); +const HistoryModel = require('../database/models/history'); +const LogsModel = require('../database/models/logs'); + +class ProxyScheduler { + constructor() { + this.scraper = new ProxyScraper(); + this.validator = new ProxyValidator(); + this.tasks = new Map(); + } + + async log(message) { + const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); + console.log(`[${timestamp}] ${message}`); + } + + // 抓取任务 - 每小时执行一次 + async runScrapeTask() { + let historyId = null; + + try { + await this.log('开始执行定时抓取任务'); + + // 检查是否有抓取任务正在进行 + if (this.scraper.constructor.isScrapingInProgress()) { + await this.log('⚠️ 有抓取任务正在进行,跳过本次定时抓取任务'); + return { + scraped: 0, + total: 0, + valid: 0, + skipped: true, + message: '有抓取任务正在进行,跳过本次执行' + }; + } + + // 创建执行历史记录 + historyId = await HistoryModel.create({ + task_type: 'scrape', + task_name: '定时抓取任务', + status: 'running', + start_time: new Date().toISOString() + }); + + // 抓取代理 + const scrapeResult = await this.scraper.scrape(); + + // 更新历史记录 + const duration = Date.now() - new Date(historyId ? await this.getHistoryStartTime(historyId) : Date.now()); + await HistoryModel.update(historyId, { + status: 'success', + end_time: new Date().toISOString(), + duration: duration, + result_summary: `抓取 ${scrapeResult.scraped} 个代理,可用 ${scrapeResult.valid} 个`, + details: scrapeResult + }); + + if (scrapeResult.skipped) { + await this.log(`定时抓取任务跳过: ${scrapeResult.message}`); + + // 更新历史记录 + if (historyId) { + await HistoryModel.update(historyId, { + status: 'skipped', + end_time: new Date().toISOString(), + result_summary: scrapeResult.message, + details: scrapeResult + }); + } + } else { + await this.log(`定时抓取任务完成: 抓取 ${scrapeResult.scraped} 个,总数 ${scrapeResult.total},可用 ${scrapeResult.valid}`); + + // 获取当前统计信息 + const stats = await this.validator.getProxyStatistics(); + await this.log(`当前代理状态: 总数 ${stats.total},可用 ${stats.valid},无效 ${stats.invalid},可用率 ${stats.validRate}`); + + // 记录系统日志 + await LogsModel.logInfo( + `定时抓取任务完成: 抓取${scrapeResult.scraped}个代理,可用${scrapeResult.valid}个`, + 'scheduler', + scrapeResult, + 'automated_task' + ); + } + + return scrapeResult; + } catch (error) { + const errorMessage = `定时抓取任务失败: ${error.message}`; + await this.log(errorMessage); + + // 更新历史记录 + if (historyId) { + const duration = Date.now() - new Date(await this.getHistoryStartTime(historyId)); + await HistoryModel.update(historyId, { + status: 'failed', + end_time: new Date().toISOString(), + duration: duration, + error_message: error.message + }); + } + + // 记录错误日志 + await LogsModel.logError( + errorMessage, + 'scheduler', + { error: error.message, stack: error.stack }, + 'automated_task' + ); + + throw error; + } + } + + // 验证任务 - 每10分钟执行一次 + async runValidationTask() { + let historyId = null; + + try { + await this.log('开始执行定时验证任务'); + + // 创建执行历史记录 + historyId = await HistoryModel.create({ + task_type: 'validation', + task_name: '定时验证任务', + status: 'running', + start_time: new Date().toISOString() + }); + + // 验证数据库中的代理(每次验证50个) + const validationResult = await this.validator.validateDatabaseProxies(50); + + // 更新历史记录 + const duration = Date.now() - new Date(historyId ? await this.getHistoryStartTime(historyId) : Date.now()); + await HistoryModel.update(historyId, { + status: 'success', + end_time: new Date().toISOString(), + duration: duration, + result_summary: `验证 ${validationResult.validated} 个代理,有效 ${validationResult.valid} 个`, + details: validationResult + }); + + await this.log(`定时验证任务完成: 验证 ${validationResult.validated} 个,有效 ${validationResult.valid},无效 ${validationResult.invalid}`); + + // 清理无效代理 + if (validationResult.invalid > 0) { + await this.log(`已清理 ${validationResult.invalid} 个无效代理`); + } + + // 获取当前统计信息 + const stats = await this.validator.getProxyStatistics(); + await this.log(`当前代理状态: 总数 ${stats.total},可用 ${stats.valid},无效 ${stats.invalid},可用率 ${stats.validRate}`); + + // 记录系统日志 + await LogsModel.logInfo( + `定时验证任务完成: 验证${validationResult.validated}个代理,有效${validationResult.valid}个`, + 'scheduler', + validationResult, + 'automated_task' + ); + + return validationResult; + } catch (error) { + const errorMessage = `定时验证任务失败: ${error.message}`; + await this.log(errorMessage); + + // 更新历史记录 + if (historyId) { + const duration = Date.now() - new Date(await this.getHistoryStartTime(historyId)); + await HistoryModel.update(historyId, { + status: 'failed', + end_time: new Date().toISOString(), + duration: duration, + error_message: error.message + }); + } + + // 记录错误日志 + await LogsModel.logError( + errorMessage, + 'scheduler', + { error: error.message, stack: error.stack }, + 'automated_task' + ); + + throw error; + } + } + + // 健康检查任务 - 每小时执行一次 + async runHealthCheckTask() { + try { + await this.log('开始执行健康检查任务'); + + const stats = await this.validator.getProxyStatistics(); + + await this.log(`代理池健康状态: 总数 ${stats.total},可用 ${stats.valid},无效 ${stats.invalid},可用率 ${stats.validRate}`); + + // 如果可用代理太少,触发紧急抓取 + if (stats.valid < 10) { + await this.log('可用代理数量过少,触发紧急抓取任务'); + await this.runScrapeTask(); + } + + return stats; + } catch (error) { + await this.log(`健康检查任务失败: ${error.message}`); + throw error; + } + } + + // 启动所有定时任务 + start() { + this.log('启动代理服务定时任务调度器'); + + // 每小时的第0分钟执行抓取任务(异步执行,不阻塞其他任务) + const scrapeTask = cron.schedule('0 * * * *', async () => { + try { + // 使用 setImmediate 确保任务异步执行,不阻塞事件循环 + setImmediate(async () => { + try { + await this.runScrapeTask(); + } catch (error) { + this.log(`定时抓取任务异常: ${error.message}`); + } + }); + } catch (error) { + this.log(`定时抓取任务启动异常: ${error.message}`); + } + }, { + scheduled: false, + timezone: 'Asia/Shanghai' + }); + + // 每10分钟执行验证任务(异步执行,不阻塞其他任务) + const validationTask = cron.schedule('*/10 * * * *', async () => { + try { + // 使用 setImmediate 确保任务异步执行,不阻塞事件循环 + setImmediate(async () => { + try { + await this.runValidationTask(); + } catch (error) { + this.log(`定时验证任务异常: ${error.message}`); + } + }); + } catch (error) { + this.log(`定时验证任务启动异常: ${error.message}`); + } + }, { + scheduled: false, + timezone: 'Asia/Shanghai' + }); + + // 每小时的第30分钟执行健康检查(异步执行,不阻塞其他任务) + const healthCheckTask = cron.schedule('30 * * * *', async () => { + try { + // 使用 setImmediate 确保任务异步执行,不阻塞事件循环 + setImmediate(async () => { + try { + await this.runHealthCheckTask(); + } catch (error) { + this.log(`健康检查任务异常: ${error.message}`); + } + }); + } catch (error) { + this.log(`健康检查任务启动异常: ${error.message}`); + } + }, { + scheduled: false, + timezone: 'Asia/Shanghai' + }); + + // 保存任务引用 + this.tasks.set('scrape', scrapeTask); + this.tasks.set('validation', validationTask); + this.tasks.set('healthCheck', healthCheckTask); + + // 启动所有任务 + this.tasks.forEach((task, name) => { + task.start(); + this.log(`定时任务已启动: ${name}`); + }); + + this.log('所有定时任务已启动'); + this.printNextRunTimes(); + } + + // 停止所有定时任务 + stop() { + this.log('停止所有定时任务'); + + this.tasks.forEach((task, name) => { + task.stop(); + this.log(`定时任务已停止: ${name}`); + }); + + this.tasks.clear(); + this.log('定时任务调度器已停止'); + } + + // 手动执行任务 + async runTask(taskName) { + this.log(`手动执行任务: ${taskName}`); + + switch (taskName) { + case 'scrape': + return await this.runScrapeTask(); + case 'validation': + return await this.runValidationTask(); + case 'healthCheck': + return await this.runHealthCheckTask(); + default: + throw new Error(`未知的任务名称: ${taskName}`); + } + } + + // 获取任务状态 + getStatus() { + const status = {}; + + this.tasks.forEach((task, name) => { + status[name] = { + running: task.running || false, + scheduled: task.scheduled || false + }; + }); + + return { + tasks: status, + taskCount: this.tasks.size + }; + } + + // 打印下次执行时间 + printNextRunTimes() { + const now = new Date(); + + this.log('定时任务执行计划:'); + this.log(`- 抓取任务: 每小时整点执行 (下次: ${this.getNextHourTime(now)})`); + this.log(`- 验证任务: 每10分钟执行 (下次: ${this.getNext10MinuteTime(now)})`); + this.log(`- 健康检查: 每小时30分执行 (下次: ${this.getNextHour30Time(now)})`); + } + + getNextHourTime(now) { + const next = new Date(now); + next.setHours(next.getHours() + 1, 0, 0, 0); + return next.toTimeString().slice(0, 5); + } + + getNext10MinuteTime(now) { + const next = new Date(now); + const minutes = next.getMinutes(); + const nextMinute = Math.ceil((minutes + 1) / 10) * 10; + next.setMinutes(nextMinute, 0, 0); + return next.toTimeString().slice(0, 5); + } + + getNextHour30Time(now) { + const next = new Date(now); + if (now.getMinutes() < 30) { + next.setHours(now.getHours(), 30, 0, 0); + } else { + next.setHours(now.getHours() + 1, 30, 0, 0); + } + return next.toTimeString().slice(0, 5); + } + + // 获取历史记录开始时间 + async getHistoryStartTime(historyId) { + try { + const history = await HistoryModel.findById(historyId); + return history ? new Date(history.start_time).getTime() : Date.now(); + } catch (error) { + return Date.now(); + } + } + + // 获取系统统计信息 + async getSystemStats() { + try { + const proxyStats = await this.validator.getProxyStatistics(); + const taskStatus = this.getStatus(); + + return { + timestamp: new Date().toISOString(), + proxies: proxyStats, + scheduler: taskStatus, + uptime: process.uptime() + }; + } catch (error) { + this.log(`获取系统统计信息失败: ${error.message}`); + throw error; + } + } +} + +module.exports = ProxyScheduler; \ No newline at end of file diff --git a/src/services/scraper.js b/src/services/scraper.js new file mode 100644 index 0000000..67cba4e --- /dev/null +++ b/src/services/scraper.js @@ -0,0 +1,596 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); +const ProxyModel = require('../database/models/proxy'); + +// 全局变量:标记是否有抓取任务正在进行 +let isScrapingInProgress = false; + +class ProxyScraper { + constructor() { + this.baseUrl = 'https://www.kuaidaili.com/free/inha'; + this.userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/91.0.864.59' + ]; + this.currentProxyIndex = 0; + this.localProxies = []; + } + + getRandomUserAgent() { + return this.userAgents[Math.floor(Math.random() * this.userAgents.length)]; + } + + async sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // 获取本地数据库中的有效代理 + async loadLocalProxies() { + try { + console.log('正在加载本地代理数据库...'); + this.localProxies = await ProxyModel.findAll(true); + this.currentProxyIndex = 0; + console.log(`成功加载 ${this.localProxies.length} 个本地代理`); + + if (this.localProxies.length > 0) { + console.log('将使用本地代理进行抓取'); + } else { + console.log('本地无可用代理,将使用直连'); + } + } catch (error) { + console.error('加载本地代理失败:', error); + this.localProxies = []; + } + } + + // 获取下一个本地代理(循环使用) + getNextLocalProxy() { + if (this.localProxies.length === 0) { + return null; // 没有本地代理,返回null使用直连 + } + + const proxy = this.localProxies[this.currentProxyIndex]; + this.currentProxyIndex = (this.currentProxyIndex + 1) % this.localProxies.length; + + console.log(`使用本地代理: ${proxy.ip}:${proxy.port}`); + return { + host: proxy.ip, + port: proxy.port, + protocol: 'http' + }; + } + + // 测试代理是否可用(用于抓取) + async testProxyForScraping(proxyConfig) { + try { + const response = await axios.get('https://www.baidu.com', { + proxy: proxyConfig, + timeout: 10000, + headers: { + 'User-Agent': this.getRandomUserAgent() + }, + validateStatus: (status) => status === 200 + }); + return response.status === 200 && response.data.includes('百度'); + } catch (error) { + return false; + } + } + + // 获取可用的代理配置 + async getWorkingProxy() { + if (this.localProxies.length === 0) { + return null; // 无本地代理,使用直连 + } + + // 尝试几个代理,找到可用的 + for (let i = 0; i < Math.min(5, this.localProxies.length); i++) { + const proxyConfig = this.getNextLocalProxy(); + + if (await this.testProxyForScraping(proxyConfig)) { + console.log(`✓ 代理 ${proxyConfig.host}:${proxyConfig.port} 可用`); + return proxyConfig; + } else { + console.log(`✗ 代理 ${proxyConfig.host}:${proxyConfig.port} 不可用,尝试下一个`); + } + } + + console.log('测试的本地代理都不可用,使用直连'); + return null; // 所有测试的代理都不可用,使用直连 + } + + // 验证抓取到的代理 + async validateScrapedProxies(proxies, concurrency = 20) { + if (!proxies || proxies.length === 0) { + return []; + } + + console.log(`开始验证 ${proxies.length} 个抓取到的代理,并发数: ${concurrency}`); + const validProxies = []; + + // 分批处理,避免过多并发连接 + for (let i = 0; i < proxies.length; i += concurrency) { + const batch = proxies.slice(i, i + concurrency); + const batchPromises = batch.map(proxy => this.validateScrapedProxy(proxy)); + + try { + const batchResults = await Promise.all(batchPromises); + + // 收集验证通过的代理 + batchResults.forEach(result => { + if (result.isValid) { + validProxies.push({ + ...result.proxy, + is_valid: 1, + response_time: result.responseTime + }); + console.log(`✓ 代理验证通过: ${result.proxy.ip}:${result.proxy.port} - ${result.responseTime}ms`); + } else { + console.log(`✗ 代理验证失败: ${result.proxy.ip}:${result.proxy.port} - ${result.error || '连接失败'}`); + } + }); + + // 批次间延迟,避免请求过于频繁 + if (i + concurrency < proxies.length) { + await this.sleep(1000); + } + + } catch (error) { + console.error(`批量验证第 ${Math.floor(i / concurrency) + 1} 批时发生错误:`, error); + } + } + + console.log(`代理验证完成,${validProxies.length}/${proxies.length} 个代理验证通过`); + return validProxies; + } + + // 验证单个抓取到的代理 + async validateScrapedProxy(proxy, retryCount = 2) { + const testUrls = [ + 'https://www.baidu.com', + ]; + + for (let attempt = 1; attempt <= retryCount; attempt++) { + for (const testUrl of testUrls) { + const startTime = Date.now(); + + try { + const proxyConfig = { + host: proxy.ip, + port: proxy.port, + protocol: 'http' + }; + + const response = await axios.get(testUrl, { + proxy: proxyConfig, + timeout: 10000, // 3秒超时 + headers: { + 'User-Agent': this.getRandomUserAgent(), + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Connection': 'keep-alive' + }, + validateStatus: (status) => status >= 200 && status < 300 // 接受2xx状态码 + }); + + const responseTime = Date.now() - startTime; + let isValid = false; + + // 检查响应内容 + if (response.status === 200) { + if (testUrl.includes('baidu.com')) { + isValid = response.data.includes('百度'); + } else if (testUrl.includes('httpbin.org')) { + isValid = response.data.includes('origin'); + } else if (testUrl.includes('google.com')) { + isValid = response.data.includes('google'); + } else { + isValid = true; // 对于其他URL,只要能连接就认为有效 + } + } + + if (isValid) { + console.log(`✓ 代理验证通过: ${proxy.ip}:${proxy.port} - ${testUrl} - ${responseTime}ms`); + return { + proxy: proxy, + isValid: true, + responseTime: responseTime, + error: null, + testUrl: testUrl + }; + } + + } catch (error) { + const responseTime = Date.now() - startTime; + // 如果是最后一次尝试,返回失败 + if (attempt === retryCount && testUrl === testUrls[testUrls.length - 1]) { + return { + proxy: proxy, + isValid: false, + responseTime: responseTime, + error: error.message, + testUrl: testUrl + }; + } + // 否则继续尝试下一个URL或重试 + await this.sleep(500); + } + } + } + + // 所有尝试都失败 + return { + proxy: proxy, + isValid: false, + responseTime: 0, + error: 'All validation attempts failed', + testUrl: null + }; + } + + async fetchPage(pageNum, retryCount = 3) { + const url = pageNum === 1 ? `${this.baseUrl}/` : `${this.baseUrl}/${pageNum}/`; + + console.log(`正在抓取第 ${pageNum} 页: ${url}`); + + for (let attempt = 1; attempt <= retryCount; attempt++) { + let proxyConfig = null; + let proxyUsed = ''; + + try { + const userAgent = this.getRandomUserAgent(); + + // 获取可用代理配置(每次请求都尝试不同的代理) + proxyConfig = await this.getWorkingProxy(); + + const requestConfig = { + headers: { + 'User-Agent': userAgent, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Cache-Control': 'max-age=0', + 'sec-ch-ua': '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'Referer': 'https://www.kuaidaili.com/free/', + 'Origin': 'https://www.kuaidaili.com' + }, + timeout: 15000, + validateStatus: (status) => status === 200, + maxRedirects: 3 + }; + + // 如果有可用代理,添加到请求配置中 + if (proxyConfig) { + requestConfig.proxy = proxyConfig; + proxyUsed = `代理 ${proxyConfig.host}:${proxyConfig.port}`; + console.log(`使用代理 ${proxyConfig.host}:${proxyConfig.port} 抓取第 ${pageNum} 页`); + } else { + proxyUsed = '直连'; + console.log(`使用直连抓取第 ${pageNum} 页`); + } + + const response = await axios.get(url, requestConfig); + + // 检查响应内容 + const content = response.data; + if (content.includes('访问过于频繁') || content.includes('请稍后再试')) { + console.log(`第 ${pageNum} 页被限制访问,延长等待时间`); + if (attempt < retryCount) { + await this.sleep(5000 * attempt); + continue; + } + } + + console.log(`第 ${pageNum} 页获取成功,内容长度: ${content.length}`); + return content; + } catch (error) { + console.error(`第 ${attempt} 次尝试抓取第 ${pageNum} 页失败 (${proxyUsed}):`, error.message); + + if (attempt === retryCount) { + throw new Error(`抓取第 ${pageNum} 页失败,已重试 ${retryCount} 次: ${error.message}`); + } + + // 递增等待时间 + await this.sleep(3000 * attempt); + } + } + } + + extractProxiesFromHtml(html) { + try { + const $ = cheerio.load(html); + const proxies = []; + + console.log('开始解析HTML,页面标题:', $('title').text()); + + // 方法1: 尝试从JavaScript变量中提取 + const scripts = $('script').map((i, elem) => $(elem).html()).get(); + console.log(`找到 ${scripts.length} 个script标签`); + + for (let i = 0; i < scripts.length; i++) { + const script = scripts[i]; + + // 尝试多种匹配模式 - 优先使用 const 模式 + const patterns = [ + /const fpsList = (\[[\s\S]*?\]);/, + /var fpsList = (\[[\s\S]*?\]);/, + /let fpsList = (\[[\s\S]*?\]);/, + /fpsList = (\[[\s\S]*?\]);/, + /var\s+fpsList\s*=\s*(\[[\s\S]*?\]);/, + /const\s+fpsList\s*=\s*(\[[\s\S]*?\]);/, + /let\s+fpsList\s*=\s*(\[[\s\S]*?\]);/ + ]; + + for (const pattern of patterns) { + const match = script.match(pattern); + if (match) { + try { + console.log('找到JavaScript变量,尝试解析...'); + const fpsList = eval(match[1]); + console.log(`从JavaScript变量中提取到 ${fpsList.length} 个代理`); + + if (Array.isArray(fpsList) && fpsList.length > 0) { + return fpsList.map(item => ({ + ip: item.ip, + port: parseInt(item.port), // 确保端口是数字 + location: item.location || null, + speed: item.speed ? parseInt(item.speed) : null, + last_check_time: item.last_check_time || new Date().toISOString().slice(0, 19).replace('T', ' ') + })); + } + } catch (e) { + console.warn('解析JavaScript变量失败:', e.message); + } + } + } + } + + // 方法2: 尝试从表格中提取 - 使用多种选择器 + const tableSelectors = [ + 'table tbody tr', + '.table tbody tr', + '#list table tbody tr', + '.list table tbody tr', + 'table tr', + '.proxy-table tbody tr' + ]; + + for (const selector of tableSelectors) { + const rows = $(selector); + console.log(`尝试选择器 "${selector}": 找到 ${rows.length} 行`); + + if (rows.length > 0) { + rows.each((i, row) => { + try { + const cols = $(row).find('td'); + + // 尝试多种列结构 + let ip, port, location, speed; + + if (cols.length >= 2) { + // 标准格式:IP、端口、位置、匿名度、类型、位置、速度、最后验证时间 + ip = $(cols[0]).text().trim(); + port = $(cols[1]).text().trim(); + + if (cols.length >= 5) { + location = $(cols[4]).text().trim(); + } + if (cols.length >= 6) { + speed = $(cols[5]).text().trim(); + } + + // 清理和验证数据 + ip = ip.replace(/[^\d\.]/g, ''); + port = port.replace(/[^\d]/g, ''); + + if (ip && port && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && /^\d+$/.test(port)) { + const portNum = parseInt(port); + if (portNum > 0 && portNum <= 65535) { + proxies.push({ + ip: ip, + port: portNum, + location: location || null, + speed: speed ? parseInt(speed.replace(/[^\d]/g, '')) : null, + last_check_time: new Date().toISOString().slice(0, 19).replace('T', ' ') + }); + } + } + } + } catch (e) { + console.warn(`解析第 ${i} 行失败:`, e.message); + } + }); + + if (proxies.length > 0) { + console.log(`从表格中提取到 ${proxies.length} 个代理`); + break; + } + } + } + + // 方法3: 尝试从JSON数据中提取 + const jsonPatterns = [ + /"data":\s*(\[[\s\S]*?\])/, + /"proxies":\s*(\[[\s\S]*?\])/, + /window\.__INITIAL_STATE__\s*=\s*({[\s\S]*?});/ + ]; + + const pageContent = $.html(); + for (const pattern of jsonPatterns) { + const match = pageContent.match(pattern); + if (match) { + try { + console.log('找到JSON数据,尝试解析...'); + const data = JSON.parse(match[1]); + // 处理JSON数据格式 + if (Array.isArray(data)) { + const jsonProxies = data.map(item => ({ + ip: item.ip || item.IP, + port: item.port || item.PORT, + location: item.location || item.area || null, + speed: item.speed || null, + last_check_time: new Date().toISOString().slice(0, 19).replace('T', ' ') + })).filter(p => p.ip && p.port); + + if (jsonProxies.length > 0) { + console.log(`从JSON数据中提取到 ${jsonProxies.length} 个代理`); + return jsonProxies; + } + } + } catch (e) { + console.warn('解析JSON数据失败:', e.message); + } + } + } + + console.log(`所有提取方法都失败,最终提取到 ${proxies.length} 个代理`); + + // 如果没有找到代理,输出页面内容用于调试 + if (proxies.length === 0) { + console.log('页面内容片段:', pageContent.substring(0, 500) + '...'); + } + + return proxies; + } catch (error) { + console.error('提取代理数据失败:', error); + return []; + } + } + + async scrapePage(pageNum) { + try { + const html = await this.fetchPage(pageNum); + const proxies = this.extractProxiesFromHtml(html); + + if (proxies.length === 0) { + console.warn(`第 ${pageNum} 页没有提取到代理数据`); + return []; + } + + console.log(`第 ${pageNum} 页处理完成,获取到 ${proxies.length} 个代理,开始验证...`); + + // 立即验证当前页面的所有代理 + const validatedProxies = await this.validateScrapedProxies(proxies); + + let savedCount = 0; + for (const proxy of validatedProxies) { + try { + await ProxyModel.create(proxy); + savedCount++; + console.log(`✓ 保存验证通过的代理: ${proxy.ip}:${proxy.port}`); + } catch (error) { + // 忽略重复插入错误 + if (!error.message.includes('UNIQUE constraint failed')) { + console.warn(`保存代理 ${proxy.ip}:${proxy.port} 失败:`, error.message); + } + } + } + + console.log(`第 ${pageNum} 页验证完成,验证通过 ${validatedProxies.length}/${proxies.length} 个代理,保存了 ${savedCount} 个新代理`); + + return validatedProxies; + + } catch (error) { + console.error(`处理第 ${pageNum} 页失败:`, error); + return []; + } + } + + async scrapeMultiplePages(maxPages = 40) { + console.log(`开始抓取前 ${maxPages} 页代理IP`); + + // 首先加载本地代理 + await this.loadLocalProxies(); + + const allValidatedProxies = []; + + for (let page = 1; page <= maxPages; page++) { + try { + const validatedProxies = await this.scrapePage(page); + allValidatedProxies.push(...validatedProxies); + + // 页面间延迟,避免请求过于频繁 + if (page < maxPages) { + await this.sleep(3000 + Math.random() * 2000); + } + } catch (error) { + console.error(`抓取第 ${page} 页失败,跳过此页:`, error); + } + } + + const totalValidated = allValidatedProxies.length; + console.log(`抓取完成,共获取到 ${totalValidated} 个验证通过的代理IP`); + + return totalValidated; + } + + async scrape() { + console.log('开始执行代理IP抓取任务'); + + // 检查是否有抓取任务正在进行 + if (isScrapingInProgress) { + console.log('⚠️ 有抓取任务正在进行,跳过本次抓取'); + const totalCount = await ProxyModel.count(); + const validCount = await ProxyModel.count(true); + + return { + scraped: 0, + total: totalCount, + valid: validCount, + skipped: true + }; + } + + // 设置抓取进行中标志 + isScrapingInProgress = true; + console.log('🚀 开始抓取,设置抓取进行中标志'); + + try { + const validatedCount = await this.scrapeMultiplePages(40); + + // 获取数据库中的总代理数量 + const totalCount = await ProxyModel.count(); + const validCount = await ProxyModel.count(true); + + console.log(`抓取任务完成。数据库中共有 ${totalCount} 个代理,其中 ${validCount} 个可用`); + + return { + scraped: validatedCount, + total: totalCount, + valid: validCount, + skipped: false + }; + } catch (error) { + console.error('代理抓取任务失败:', error); + throw error; + } finally { + // 无论成功还是失败,都要清除抓取进行中标志 + isScrapingInProgress = false; + console.log('✅ 清除抓取进行中标志'); + } + } + + // 静态方法:检查是否有抓取任务正在进行 + static isScrapingInProgress() { + return isScrapingInProgress; + } + + // 静态方法:强制清除抓取标志(用于异常情况) + static clearScrapingFlag() { + isScrapingInProgress = false; + console.log('🔧 强制清除抓取进行中标志'); + } +} + +module.exports = ProxyScraper; \ No newline at end of file diff --git a/src/services/validator.js b/src/services/validator.js new file mode 100644 index 0000000..c6ad2c8 --- /dev/null +++ b/src/services/validator.js @@ -0,0 +1,202 @@ +const axios = require('axios'); +const ProxyModel = require('../database/models/proxy'); + +class ProxyValidator { + constructor() { + this.testUrl = 'https://www.baidu.com'; + this.timeout = 10000; // 3秒超时 + this.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + } + + async validateProxy(ip, port) { + const startTime = Date.now(); + const proxy = { + host: ip, + port: port, + protocol: 'http' + }; + + console.log(`正在验证代理 ${ip}:${port}`); + + try { + const response = await axios.get(this.testUrl, { + proxy: proxy, + timeout: this.timeout, + headers: { + 'User-Agent': this.userAgent, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3', + 'Accept-Encoding': 'gzip, deflate', + 'Connection': 'keep-alive' + }, + validateStatus: (status) => status === 200 + }); + + const responseTime = Date.now() - startTime; + const isValid = response.status === 200 && response.data.includes('百度'); + + if (isValid) { + console.log(`✓ 代理 ${ip}:${port} 验证成功,响应时间: ${responseTime}ms`); + } else { + console.log(`✗ 代理 ${ip}:${port} 验证失败,响应不正确`); + } + + // 更新数据库中的验证结果 + await ProxyModel.updateValidity(ip, port, isValid ? 1 : 0, responseTime); + + return { + ip: ip, + port: port, + isValid: isValid, + responseTime: responseTime, + error: null + }; + } catch (error) { + const responseTime = Date.now() - startTime; + console.log(`✗ 代理 ${ip}:${port} 验证失败:`, error.message); + + // 更新数据库中的验证结果 + await ProxyModel.updateValidity(ip, port, 0, responseTime); + + return { + ip: ip, + port: port, + isValid: false, + responseTime: responseTime, + error: error.message + }; + } + } + + async validateSingleProxy(ip, port) { + try { + const result = await this.validateProxy(ip, port); + return result; + } catch (error) { + console.error(`验证代理 ${ip}:${port} 时发生错误:`, error); + return { + ip: ip, + port: port, + isValid: false, + responseTime: null, + error: error.message + }; + } + } + + async validateMultipleProxies(proxies, concurrency = 20) { + console.log(`开始批量验证 ${proxies.length} 个代理,并发数: ${concurrency}`); + const results = []; + + // 分批处理,避免过多并发连接 + for (let i = 0; i < proxies.length; i += concurrency) { + const batch = proxies.slice(i, i + concurrency); + const batchPromises = batch.map(proxy => + this.validateProxy(proxy.ip, proxy.port) + ); + + try { + const batchResults = await Promise.all(batchPromises); + results.push(...batchResults); + } catch (error) { + console.error(`批量验证第 ${Math.floor(i / concurrency) + 1} 批时发生错误:`, error); + } + + // 批次间延迟 + if (i + concurrency < proxies.length) { + await this.sleep(1000); + } + } + + const validCount = results.filter(r => r.isValid).length; + console.log(`批量验证完成,${validCount}/${results.length} 个代理可用`); + + return results; + } + + async validateDatabaseProxies(limit = 50) { + console.log(`开始验证数据库中的代理IP,限制数量: ${limit}`); + + try { + // 获取待验证的代理(优先验证最久未验证的) + const proxies = await ProxyModel.findAllForValidation(limit); + + if (proxies.length === 0) { + console.log('没有需要验证的代理'); + return { validated: 0, valid: 0, invalid: 0 }; + } + + console.log(`找到 ${proxies.length} 个待验证的代理`); + + const results = await this.validateMultipleProxies(proxies, 10); + + // 清理无效代理 + const invalidCount = results.filter(r => !r.isValid).length; + if (invalidCount > 0) { + await ProxyModel.deleteInvalid(); + console.log(`已删除 ${invalidCount} 个无效代理`); + } + + const validCount = results.filter(r => r.isValid).length; + + return { + validated: results.length, + valid: validCount, + invalid: invalidCount, + results: results + }; + } catch (error) { + console.error('验证数据库代理失败:', error); + throw error; + } + } + + async getProxyStatistics() { + try { + const totalCount = await ProxyModel.count(false); + const validCount = await ProxyModel.count(true); + const invalidCount = totalCount - validCount; + + return { + total: totalCount, + valid: validCount, + invalid: invalidCount, + validRate: totalCount > 0 ? ((validCount / totalCount) * 100).toFixed(2) + '%' : '0%' + }; + } catch (error) { + console.error('获取代理统计信息失败:', error); + return { + total: 0, + valid: 0, + invalid: 0, + validRate: '0%' + }; + } + } + + async sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // 验证新抓取的代理 + async validateNewProxies(proxies) { + if (!proxies || proxies.length === 0) { + return { validated: 0, valid: 0, invalid: 0 }; + } + + console.log(`开始验证新抓取的 ${proxies.length} 个代理`); + const results = await this.validateMultipleProxies(proxies, 2); + + const validCount = results.filter(r => r.isValid).length; + console.log(`新代理验证完成,${validCount}/${results.length} 个代理可用`); + + return { + validated: results.length, + valid: validCount, + invalid: results.length - validCount, + results: results + }; + } +} + +module.exports = ProxyValidator; \ No newline at end of file diff --git a/test-scraper-with-validation.js b/test-scraper-with-validation.js new file mode 100644 index 0000000..4f767aa --- /dev/null +++ b/test-scraper-with-validation.js @@ -0,0 +1,47 @@ +const ProxyScraper = require('./src/services/scraper'); +const Database = require('./src/database/db'); +const ProxyModel = require('./src/database/models/proxy'); + +async function testScraperWithValidation() { + console.log('测试抓取器带验证功能...'); + + try { + // 初始化数据库连接 + console.log('初始化数据库连接...'); + await Database.connect(); + await ProxyModel.initTable(); + + // 清理之前的数据 + console.log('清理之前的数据...'); + await Database.run('DELETE FROM proxies'); + + const scraper = new ProxyScraper(); + + // 测试抓取第1页 + console.log('开始抓取第1页...'); + const result = await scraper.scrapePage(1); + console.log(`抓取结果: ${result.length} 个验证通过的代理`); + + // 查询数据库中的代理数量 + const totalCount = await ProxyModel.count(); + const validCount = await ProxyModel.count(true); + + console.log(`数据库状态: 总数 ${totalCount},可用 ${validCount}`); + + if (result.length > 0) { + console.log('\n验证通过的代理:'); + result.forEach((proxy, index) => { + console.log(`${index + 1}. ${proxy.ip}:${proxy.port} - ${proxy.location || '未知位置'} - 响应时间:${proxy.response_time || '未知'}ms`); + }); + } + + // 关闭数据库连接 + await Database.close(); + + } catch (error) { + console.error('测试失败:', error.message); + console.error('错误堆栈:', error.stack); + } +} + +testScraperWithValidation(); \ No newline at end of file