diff --git a/.env.example b/.env.example index ed49a16..93fe4c9 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ DB_PATH=db/proxies.sqlite # ==================== API服务配置 ==================== HOST=0.0.0.0 -PORT=3000 +PORT=9949 # ==================== 验证器配置 ==================== VALIDATOR_TIMEOUT=5 @@ -17,10 +17,6 @@ VALIDATOR_CONNECT_TIMEOUT=3 CRAWLER_NUM_VALIDATORS=50 CRAWLER_MAX_QUEUE_SIZE=500 -# ==================== 定时任务配置 ==================== -SCHEDULER_INTERVAL_MINUTES=60 -SCHEDULER_ENABLED=false - # ==================== 日志配置 ==================== LOG_LEVEL=INFO LOG_DIR=logs @@ -34,29 +30,9 @@ SCORE_INVALID=-5 SCORE_MIN=0 SCORE_MAX=100 -# ==================== WebSocket配置 ==================== -WS_PING_INTERVAL=20 -WS_PING_TIMEOUT=20 - # ==================== 插件配置 ==================== PLUGINS_DIR=plugins # ==================== CORS配置 ==================== # 允许的来源域名,用逗号分隔 -# 开发环境示例: http://localhost:8080,http://localhost:5173 -# 生产环境示例: https://yourdomain.com,https://api.yourdomain.com CORS_ORIGINS=http://localhost:8080,http://localhost:5173 - -# ==================== API Key配置 ==================== -# 普通用户API Key(只读权限) -# 请修改为强随机字符串,例如: openssl rand -hex 32 -API_KEY=your-api-key-here - -# 管理员API Key(读写权限) -# 请修改为强随机字符串 -ADMIN_API_KEY=your-admin-api-key-here - -# ==================== 认证开关 ==================== -# 是否启用API认证 -# 开发环境可设为 false,生产环境务必设为 true -REQUIRE_AUTH=false diff --git a/.gitignore b/.gitignore index 394cd67..c45a9ea 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,8 @@ proxies.sqlite* # Test/Maintenance Scripts clear_*.py test_*.py +test_results.json +test_screenshot_*.png + +# Legacy/Backup +backend/ diff --git a/README.md b/README.md index d69e4f6..ca1069c 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,21 @@ ## 🌟 特性 - 🔮 **科技风设计** - 现代化的深色科技主题 -- 📊 **实时监控** - WebSocket 实时推送任务进度 +- 📊 **实时监控** - 自动统计代理池状态 - 🎯 **智能管理** - 代理查询、筛选、排序、批量操作 - 📥 **多格式导出** - 支持 CSV、TXT、JSON 格式 -- ⏰ **定时任务** - 自动定期更新代理池 +- ✅ **自动验证** - 自动验证代理可用性并评分 - 🚀 **高性能** - 异步爬取和验证,支持高并发 ## 📦 技术栈 ### 后端 -- **框架**: FastAPI (端口 8923) +- **框架**: FastAPI (端口 9949) - **数据库**: SQLite + aiosqlite - **异步**: asyncio -- **实时通信**: WebSocket ### 前端 -- **框架**: Vue 3 + Vite (端口 6173) +- **框架**: Vue 3 + Vite (端口 9948) - **UI库**: Element Plus - **状态管理**: Pinia - **图表**: ECharts @@ -72,32 +71,27 @@ stop.bat ### 4. 访问 WebUI -打开浏览器访问:**http://localhost:6173** +打开浏览器访问:**http://localhost:9948** ## 📁 项目结构 ``` ProxyPool/ ├── api_server.py # FastAPI 后端服务器 -├── tasks_manager.py # 任务管理器 -├── main.py # 爬虫主程序 -├── config.py # 配置文件 -├── requirements.txt # Python 依赖 -├── .env.example # 环境变量示例 +├── config.py # 配置文件 +├── requirements.txt # Python 依赖 +├── .env.example # 环境变量示例 │ ├── script/ # 启动脚本 │ ├── start.bat # Windows 启动脚本 -│ ├── start.ps1 # PowerShell 启动脚本 -│ ├── stop.bat # Windows 停止脚本 -│ └── README.md # 脚本说明文档 +│ └── stop.bat # Windows 停止脚本 │ ├── core/ # 核心模块 │ ├── crawler.py # 爬虫基类 │ ├── validator.py # 代理验证器 │ ├── sqlite.py # 数据库管理 │ ├── plugin_manager.py # 插件管理器 -│ ├── log.py # 日志配置 -│ └── auth.py # 认证模块 +│ └── log.py # 日志配置 │ ├── plugins/ # 代理源插件 │ ├── fate0.py # Fate0 代理源 @@ -115,12 +109,9 @@ ProxyPool/ │ │ ├── views/ # 页面组件 │ │ ├── router/ # 路由配置 │ │ ├── components/ # 通用组件 -│ │ ├── App.vue -│ │ ├── main.js │ │ └── style.css # 全局样式 │ ├── index.html -│ ├── package.json -│ └── vite.config.js +│ └── package.json │ └── db/ # 数据存储目录 └── proxies.sqlite # SQLite 数据库 @@ -151,46 +142,50 @@ POST /api/proxies GET /api/proxies/random ``` -### 启动爬虫 +### 导出代理 ``` -POST /api/crawler/start +GET /api/proxies/export/{format} +# format: csv, txt, json ``` -### 停止爬虫 +### 删除代理 ``` -POST /api/crawler/stop +DELETE /api/proxies/{ip}/{port} ``` -### 定时任务 +### 批量删除代理 ``` -POST /api/scheduler -GET /api/scheduler +POST /api/proxies/batch-delete ``` -### WebSocket 连接 +### 清理无效代理 ``` -ws://localhost:8923/ws +DELETE /api/proxies/clean-invalid +``` + +### 插件列表 +``` +GET /api/plugins +``` + +### 切换插件状态 +``` +PUT /api/plugins/{plugin_id}/toggle +``` + +### 执行插件爬取 +``` +POST /api/plugins/{plugin_id}/crawl +``` + +### 系统设置 +``` +GET /api/settings +POST /api/settings ``` ## 🐛 调试指南 -### 任务进度不显示? - -1. **检查 WebSocket 连接** - - 打开浏览器控制台(F12) - - 查看 Console 标签 - - 应该看到 "WebSocket连接成功啦~" - - 应该看到 "收到WebSocket消息:" 日志 - -2. **检查后端任务** - - 查看后端终端输出 - - 确认任务正在运行 - - 查看是否有错误日志 - -3. **检查插件可用性** - - 确保 `plugins/` 目录下有插件文件 - - 插件能正常抓取代理 - ### 数据不更新? 1. **检查数据库** @@ -200,10 +195,10 @@ ws://localhost:8923/ws 2. **手动测试 API** ```bash # 获取统计信息 - curl http://localhost:8923/api/stats + curl http://localhost:9949/api/stats # 获取代理列表 - curl -X POST http://localhost:8923/api/proxies \ + curl -X POST http://localhost:9949/api/proxies \ -H "Content-Type: application/json" \ -d '{"page": 1, "page_size": 20}' ``` @@ -215,19 +210,19 @@ ws://localhost:8923/ws ## 📝 配置说明 -### 爬虫配置 -- **最大并发数**: 10-500,默认 200 +### 代理验证配置 - **验证超时**: 3-30秒,默认 5秒 -- **验证线程数**: 10-200,默认 50 +- **验证并发数**: 10-200,默认 50 -### 定时任务 -- **执行间隔**: 10-1440分钟,默认 60分钟 -- **自动清理**: 可选,清理无效代理 +### 评分机制 +- **验证成功**: +10 分 +- **验证失败**: -5 分 +- **分数为 0**: 自动删除 ## 🔧 常见问题 ### Q: 启动后端口被占用? -A: 修改 `api_server.py` 最后一行的端口号(默认8923)或 `frontend/vite.config.js` 中的端口号(默认6173) +A: 修改 `config.py` 中的端口号(默认9949)或 `frontend/vite.config.js` 中的端口号(默认9948) ### Q: 爬虫无法抓取代理? A: 检查网络连接,确保能访问目标网站,或尝试更换代理源插件 @@ -236,7 +231,7 @@ A: 检查网络连接,确保能访问目标网站,或尝试更换代理源 A: 增加验证超时时间,或减少并发验证数量 ### Q: 数据库文件在哪里? -A: 默认在 `db/proxies.sqlite`,可在 `core/sqlite.py` 中修改 `db_path` +A: 默认在 `db/proxies.sqlite`,可在 `config.py` 中修改 `DB_PATH` ### Q: 如何清空数据库? A: 运行命令 `python -c "from core.sqlite import SQLiteManager; import asyncio; asyncio.run(SQLiteManager().clear_all())"` diff --git a/README_SOCKS.md b/README_SOCKS.md new file mode 100644 index 0000000..3703a5b --- /dev/null +++ b/README_SOCKS.md @@ -0,0 +1,89 @@ +# SOCKS 代理支持说明 + +## 更新内容 + +已成功为代理池系统添加 SOCKS4/SOCKS5 代理验证支持! + +## 技术实现 + +### 1. 新增依赖 +``` +aiohttp-socks==0.9.1 +``` + +### 2. 验证器升级 (`core/validator.py`) +- 新增 `ProxyValidator` 类,完整支持 HTTP/HTTPS/SOCKS4/SOCKS5 +- SOCKS 代理使用 `aiohttp_socks.ProxyConnector` 进行验证 +- 支持远程 DNS 解析 (rdns=True),避免 DNS 泄漏 + +### 3. 协议识别 +以下插件已更新支持 SOCKS 协议: + +| 插件 | 支持协议 | +|-----|---------| +| Fate0聚合源 | HTTP, HTTPS, SOCKS4, SOCKS5 | +| SpeedX代理源 | HTTP, SOCKS4, SOCKS5 | +| ProxyListDownload | HTTP, HTTPS, SOCKS4, SOCKS5 | +| 快代理 | HTTP, HTTPS | +| IP3366 | HTTP, HTTPS | +| 89免费代理 | HTTP | +| 云代理 | HTTP, HTTPS | + +## 使用说明 + +### 启动服务 +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动后端 +python api_server.py + +# 启动前端 +cd frontend && npm run dev +``` + +### 抓取 SOCKS 代理 +1. 打开 WebUI (http://localhost:9948) +2. 进入"插件管理"页面 +3. 点击 SpeedX 或 ProxyListDownload 插件的"立即爬取" +4. 系统自动识别 SOCKS 代理并进行验证 + +### 查看 SOCKS 代理 +1. 进入"代理列表"页面 +2. 使用协议筛选器选择 SOCKS4 或 SOCKS5 +3. 查看验证结果和延迟 + +## 验证流程 + +``` ++-------------+ +------------------+ +-----------------+ +| 插件爬取 | --> | 识别协议类型 | --> | SOCKS验证器 | ++-------------+ +------------------+ +-----------------+ + | + v ++-------------+ +------------------+ +-----------------+ +| 存储结果 | <-- | 评分更新 | <-- | 延迟测试 | ++-------------+ +------------------+ +-----------------+ +``` + +## SOCKS 验证特点 + +1. **连接器类型**: 使用 `ProxyConnector` 替代 `TCPConnector` +2. **DNS 解析**: 远程解析避免泄漏真实 IP +3. **协议区分**: 明确区分 SOCKS4 和 SOCKS5 +4. **统一接口**: 与 HTTP/HTTPS 代理使用相同的验证接口 + +## 测试 + +运行测试脚本验证 SOCKS 支持: +```bash +python test_socks_validator.py +python test_plugins_socks.py +``` + +## 注意事项 + +1. SOCKS 代理验证比 HTTP 代理稍慢,因为有额外的握手过程 +2. 部分 SOCKS 代理可能只支持 TCP 而不支持 UDP +3. SOCKS5 支持认证,当前版本使用无认证模式 diff --git a/api_server.py b/api_server.py index 554ca33..e3ae4be 100644 --- a/api_server.py +++ b/api_server.py @@ -1,34 +1,92 @@ -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header, Request, status +from fastapi import FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, JSONResponse from pydantic import BaseModel, Field, field_validator, ValidationError from typing import Optional, List import asyncio -import io -import csv import json from datetime import datetime import re +import os from contextlib import asynccontextmanager from core.sqlite import SQLiteManager -from core.validator import ProxyValidator from core.plugin_manager import PluginManager -from tasks_manager import TasksManager, ScheduledTasks +from core.scheduler import ValidationScheduler from core.log import logger -from config import Config -from core.auth import verify_api_key, require_admin, PermissionLevel +from config import config + +# 全局调度器实例 +scheduler = ValidationScheduler() + +# 设置文件路径 +SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'settings.json') + +# 默认设置 +DEFAULT_SETTINGS = { + "crawl_timeout": 30, + "validation_timeout": config.VALIDATOR_TIMEOUT, + "max_retries": 3, + "default_concurrency": config.VALIDATOR_MAX_CONCURRENCY, + "min_proxy_score": config.SCORE_MIN, + "proxy_expiry_days": 7, + "auto_validate": True, + "validate_interval_minutes": 30 +} + + +def load_settings(): + """从文件加载设置""" + try: + if os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, 'r', encoding='utf-8') as f: + saved_settings = json.load(f) + # 合并默认设置和保存的设置 + settings = DEFAULT_SETTINGS.copy() + settings.update(saved_settings) + return settings + except Exception as e: + logger.error(f"加载设置失败: {e}") + return DEFAULT_SETTINGS.copy() + + +def save_settings_to_file(settings: dict): + """保存设置到文件""" + try: + # 确保目录存在 + os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True) + with open(SETTINGS_FILE, 'w', encoding='utf-8') as f: + json.dump(settings, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"保存设置失败: {e}") + return False + @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" db = SQLiteManager() await db.init_db() - logger.info("API服务器启动啦~") + + # 加载设置并应用到调度器 + settings = load_settings() + scheduler.interval_minutes = settings.get('validate_interval_minutes', 30) + + # 如果启用了自动验证,启动调度器 + if settings.get('auto_validate', True): + await scheduler.start() + + logger.info("API服务器启动") yield - logger.info("API服务器关闭啦~") + + # 关闭调度器 + await scheduler.stop() + logger.info("API服务器关闭") + + +app = FastAPI(title="代理池API", version="1.3.0", lifespan=lifespan) -app = FastAPI(title="代理池API", version="1.1.0", lifespan=lifespan) def format_datetime(datetime_str: str) -> str: """将数据库时间格式统一转换为ISO 8601格式""" @@ -44,14 +102,16 @@ def format_datetime(datetime_str: str) -> str: return datetime_str + @app.exception_handler(ValidationError) async def validation_exception_handler(request: Request, exc: ValidationError): logger.error(f"参数验证失败: {exc}") return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={"code": 422, "message": "参数验证失败呢~", "data": exc.errors()} + content={"code": 422, "message": "参数验证失败", "data": exc.errors()} ) + @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): logger.error(f"HTTP异常: {exc.status_code} - {exc.detail}") @@ -60,14 +120,16 @@ async def http_exception_handler(request: Request, exc: HTTPException): content={"code": exc.status_code, "message": exc.detail, "data": None} ) + @app.exception_handler(Exception) async def general_exception_handler(request: Request, exc: Exception): logger.error(f"未处理的异常: {exc}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"code": 500, "message": "服务器内部错误呢~", "data": None} + content={"code": 500, "message": "服务器内部错误", "data": None} ) + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -76,38 +138,8 @@ app.add_middleware( allow_headers=["*"], ) -tasks_manager = TasksManager() -scheduled_tasks = ScheduledTasks(tasks_manager) plugin_manager = PluginManager() -active_websockets = set() -websockets_lock = asyncio.Lock() -broadcast_semaphore = asyncio.Semaphore(100) -def optional_auth(): - if Config.REQUIRE_AUTH: - return Depends(verify_api_key) - return None - -async def broadcast_message(message: dict): - """向所有WebSocket客户端广播消息(使用信号量限制并发)""" - async with websockets_lock: - websockets_to_remove = [] - - async def send_to_websocket(ws): - async with broadcast_semaphore: - try: - await ws.send_json(message) - except Exception as e: - logger.error(f"发送WebSocket消息失败: {e}") - websockets_to_remove.append(ws) - - tasks = [send_to_websocket(ws) for ws in active_websockets] - - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - for ws in websockets_to_remove: - active_websockets.discard(ws) class ProxyRequest(BaseModel): page: int = Field(default=1, ge=1, description="页码,必须大于等于1") @@ -139,6 +171,7 @@ class ProxyRequest(BaseModel): raise ValueError('排序方式必须是 ASC 或 DESC') return v.upper() + class ProxyDeleteItem(BaseModel): ip: str port: int @@ -150,6 +183,7 @@ class ProxyDeleteItem(BaseModel): raise ValueError('端口号必须在1-65535范围内') return v + class DeleteProxiesRequest(BaseModel): proxies: List[ProxyDeleteItem] @@ -160,16 +194,11 @@ class DeleteProxiesRequest(BaseModel): raise ValueError('单次最多删除1000个代理') return v -class CrawlerRequest(BaseModel): - num_validators: int = 50 - -class ScheduleRequest(BaseModel): - enabled: bool - interval_minutes: int = 60 @app.get("/") async def root(): - return {"message": "欢迎使用代理池API~", "status": "running", "data": None} + return {"message": "欢迎使用代理池API", "status": "running", "data": None} + @app.get("/health") async def health_check(): @@ -180,7 +209,8 @@ async def health_check(): "status": "healthy", "timestamp": datetime.now().isoformat(), "database": "connected", - "version": "1.0.0" + "scheduler": "running" if scheduler.running else "stopped", + "version": "1.3.0" } except Exception as e: logger.error(f"健康检查失败: {e}") @@ -191,20 +221,23 @@ async def health_check(): "error": str(e) } + @app.get("/api/stats") -async def get_stats(_permission: str = optional_auth()): +async def get_stats(): try: db = SQLiteManager() stats = await db.get_stats() today_new = await db.get_today_new_count() stats['today_new'] = today_new - return {"code": 200, "message": "获取统计信息成功啦~", "data": stats} + stats['scheduler_running'] = scheduler.running + return {"code": 200, "message": "获取统计信息成功", "data": stats} except Exception as e: logger.error(f"获取统计信息失败: {e}") - return {"code": 500, "message": "获取统计信息失败呢~", "data": None} + return {"code": 500, "message": "获取统计信息失败", "data": None} + @app.post("/api/proxies") -async def get_proxies(request: ProxyRequest, _permission: str = optional_auth()): +async def get_proxies(request: ProxyRequest): try: db = SQLiteManager() proxies = await db.get_proxies_paginated( @@ -234,7 +267,7 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth()) return { "code": 200, - "message": "获取代理列表成功啦~", + "message": "获取代理列表成功", "data": { "list": proxy_list, "total": total, @@ -244,16 +277,17 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth()) } except Exception as e: logger.error(f"获取代理列表失败: {e}") - return {"code": 500, "message": "获取代理列表失败呢~", "data": None} + return {"code": 500, "message": "获取代理列表失败", "data": None} + @app.get("/api/proxies/random") -async def get_random_proxy(_permission: str = optional_auth()): +async def get_random_proxy(): db = SQLiteManager() proxy = await db.get_random_proxy() if proxy: return { "code": 200, - "message": "获取随机代理成功啦~", + "message": "获取随机代理成功", "data": { "ip": proxy[0], "port": proxy[1], @@ -262,18 +296,19 @@ async def get_random_proxy(_permission: str = optional_auth()): "last_check": format_datetime(proxy[4]) } } - return {"code": 404, "message": "没有找到可用的代理呢~", "data": None} + return {"code": 404, "message": "没有找到可用的代理", "data": None} + @app.get("/api/proxies/export/{format}") -async def export_proxies(format: str, protocol: Optional[str] = None, _permission: str = optional_auth(), limit: int = 10000): +async def export_proxies(format: str, protocol: Optional[str] = None, limit: int = 10000): try: db = SQLiteManager() if format not in ['csv', 'txt', 'json']: - raise HTTPException(status_code=400, detail="不支持的导出格式呢~") + raise HTTPException(status_code=400, detail="不支持的导出格式") if limit > 100000: - raise HTTPException(status_code=400, detail="导出数量不能超过100000条呢~") + raise HTTPException(status_code=400, detail="导出数量不能超过100000条") async def generate_csv(): proxies = await db.get_all_proxies() @@ -342,16 +377,17 @@ async def export_proxies(format: str, protocol: Optional[str] = None, _permissio raise except Exception as e: logger.error(f"导出代理失败: {e}") - raise HTTPException(status_code=500, detail="导出代理失败呢~") + raise HTTPException(status_code=500, detail="导出代理失败") + @app.get("/api/proxies/{ip}/{port}") -async def get_proxy_detail(ip: str, port: int, _permission: str = optional_auth()): +async def get_proxy_detail(ip: str, port: int): db = SQLiteManager() proxy = await db.get_proxy_detail(ip, port) if proxy: return { "code": 200, - "message": "获取代理详情成功啦~", + "message": "获取代理详情成功", "data": { "ip": proxy[0], "port": proxy[1], @@ -360,196 +396,303 @@ async def get_proxy_detail(ip: str, port: int, _permission: str = optional_auth( "last_check": format_datetime(proxy[4]) } } - raise HTTPException(status_code=404, detail="代理不存在呢~") + raise HTTPException(status_code=404, detail="代理不存在") + @app.delete("/api/proxies/{ip}/{port}") -async def delete_proxy(ip: str, port: int, _permission: str = Depends(require_admin)): +async def delete_proxy(ip: str, port: int): db = SQLiteManager() await db.delete_proxy(ip, port) - return {"code": 200, "message": "删除代理成功啦~", "data": None} + return {"code": 200, "message": "删除代理成功", "data": None} + @app.post("/api/proxies/batch-delete") -async def batch_delete_proxies(request: DeleteProxiesRequest, _permission: str = Depends(require_admin)): +async def batch_delete_proxies(request: DeleteProxiesRequest): db = SQLiteManager() proxy_tuples = [(item.ip, item.port) for item in request.proxies] deleted_count = await db.batch_delete_proxies(proxy_tuples) - return {"code": 200, "message": f"批量删除 {deleted_count} 个代理成功啦~", "data": {"deleted_count": deleted_count}} + return {"code": 200, "message": f"批量删除 {deleted_count} 个代理成功", "data": {"deleted_count": deleted_count}} + @app.delete("/api/proxies/clean-invalid") -async def clean_invalid_proxies(_permission: str = Depends(require_admin)): +async def clean_invalid_proxies(): db = SQLiteManager() deleted_count = await db.clean_invalid_proxies() - return {"code": 200, "message": f"清理了 {deleted_count} 个无效代理啦~", "data": {"deleted_count": deleted_count}} + return {"code": 200, "message": f"清理了 {deleted_count} 个无效代理", "data": {"deleted_count": deleted_count}} -@app.post("/api/crawler/start") -async def start_crawler(request: CrawlerRequest, _permission: str = Depends(require_admin)): - try: - if tasks_manager.is_task_running(): - return {"code": 400, "message": "任务正在运行中呢~"} - - async def progress_callback(data): - await broadcast_message({"type": "progress", "data": data}) - - async def status_callback(data): - await broadcast_message({"type": "status", "data": data}) - - tasks_manager.set_callbacks(progress_callback, status_callback) - - db = SQLiteManager() - asyncio.create_task(tasks_manager.start_task(db, request.num_validators)) - - return {"code": 200, "message": "爬虫任务开始啦~", "data": None} - except Exception as e: - logger.error(f"启动爬虫失败: {e}") - return {"code": 500, "message": "启动爬虫失败呢~", "data": None} - -@app.post("/api/crawler/stop") -async def stop_crawler(_permission: str = Depends(require_admin)): - if not tasks_manager.is_task_running(): - return {"code": 400, "message": "没有运行中的任务呢~", "data": None} - - await tasks_manager.stop_task() - return {"code": 200, "message": "爬虫任务停止啦~", "data": None} - -@app.get("/api/crawler/status") -async def get_crawler_status(_permission: str = optional_auth()): - return { - "code": 200, - "message": "获取爬虫状态成功啦~", - "data": { - "running": tasks_manager.is_task_running(), - "stats": tasks_manager.get_stats() - } - } - -@app.post("/api/scheduler") -async def set_scheduler(request: ScheduleRequest, _permission: str = Depends(require_admin)): - if request.enabled: - scheduled_tasks.start_scheduled(request.interval_minutes) - return {"code": 200, "message": f"定时任务已启动,间隔 {request.interval_minutes} 分钟~", "data": None} - else: - scheduled_tasks.stop_scheduled() - return {"code": 200, "message": "定时任务已停止~", "data": None} - -@app.get("/api/scheduler") -async def get_scheduler_status(_permission: str = optional_auth()): - return { - "code": 200, - "message": "获取定时任务状态成功啦~", - "data": { - "enabled": scheduled_tasks.is_scheduled, - "interval_minutes": scheduled_tasks.interval_minutes - } - } - -@app.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = None): - if Config.REQUIRE_AUTH: - if not token: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="缺少认证token") - logger.warning("WebSocket连接被拒绝:缺少token") - return - - if token != Config.API_KEY and token != Config.ADMIN_API_KEY: - await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无效的token") - logger.warning(f"WebSocket连接被拒绝:无效的token {token[:8]}...") - return - - permission_level = PermissionLevel.ADMIN if token == Config.ADMIN_API_KEY else PermissionLevel.READ_ONLY - logger.info(f"WebSocket连接成功,权限级别: {permission_level}") - - await websocket.accept() - - async with websockets_lock: - active_websockets.add(websocket) - - try: - await websocket.send_json({ - "type": "status", - "data": { - "status": "connected", - "message": "WebSocket连接成功啦~", - "timestamp": datetime.now().isoformat() - } - }) - - while True: - await websocket.receive_text() - except WebSocketDisconnect: - async with websockets_lock: - active_websockets.discard(websocket) - logger.info("WebSocket断开连接") - except Exception as e: - logger.error(f"WebSocket错误: {e}") - async with websockets_lock: - active_websockets.discard(websocket) @app.get("/api/plugins") -async def get_plugins(_permission: str = optional_auth()): +async def get_plugins(): try: plugins_info = plugin_manager.get_all_plugin_info() return { "code": 200, - "message": "获取插件列表成功啦~", + "message": "获取插件列表成功", "data": { "plugins": plugins_info } } except Exception as e: logger.error(f"获取插件列表失败: {e}") - return {"code": 500, "message": "获取插件列表失败呢~", "data": None} + return {"code": 500, "message": "获取插件列表失败", "data": None} + class PluginToggleRequest(BaseModel): enabled: bool + @app.put("/api/plugins/{plugin_id}/toggle") -async def toggle_plugin(plugin_id: str, request: PluginToggleRequest, _permission: str = Depends(require_admin)): +async def toggle_plugin(plugin_id: str, request: PluginToggleRequest): try: success = plugin_manager.toggle_plugin(plugin_id, request.enabled) if success: return { "code": 200, - "message": f"插件 {plugin_id} 已{'启用' if request.enabled else '禁用'}啦~", + "message": f"插件 {plugin_id} 已{'启用' if request.enabled else '禁用'}", "data": { "plugin_id": plugin_id, "enabled": request.enabled } } else: - return {"code": 404, "message": "插件不存在呢~", "data": None} + return {"code": 404, "message": "插件不存在", "data": None} except Exception as e: logger.error(f"切换插件状态失败: {e}") - return {"code": 500, "message": "切换插件状态失败呢~", "data": None} + return {"code": 500, "message": "切换插件状态失败", "data": None} + @app.post("/api/plugins/{plugin_id}/crawl") -async def crawl_plugin(plugin_id: str, _permission: str = Depends(require_admin)): +async def crawl_plugin(plugin_id: str): try: - async def progress_callback(data): - await broadcast_message({"type": "progress", "data": data}) - - async def status_callback(data): - await broadcast_message({"type": "status", "data": data}) - - tasks_manager.set_callbacks(progress_callback, status_callback) - - db = SQLiteManager() + # 1. 执行爬取 results = await plugin_manager.run_plugin(plugin_id) - for ip, port, protocol in results: - await db.insert_proxy(ip, port, protocol) + if not results: + return { + "code": 200, + "message": f"插件 {plugin_id} 爬取完成,未获取到代理", + "data": { + "plugin_id": plugin_id, + "proxy_count": 0, + "valid_count": 0 + } + } + + logger.info(f"插件 {plugin_id} 爬取完成,获取 {len(results)} 个代理,开始验证...") + + # 2. 验证新抓取的代理 + valid_proxies, invalid_proxies = await scheduler.validate_proxies_batch(results) + + # 3. 只将有效代理存入数据库 + db = SQLiteManager() + inserted_count = 0 + for ip, port, protocol in valid_proxies: + success = await db.insert_proxy(ip, port, protocol, score=config.SCORE_VALID) + if success: + inserted_count += 1 + + logger.info(f"插件 {plugin_id} 处理完成: 有效 {inserted_count}, 无效 {len(invalid_proxies)}") return { "code": 200, - "message": f"插件 {plugin_id} 开始爬取啦~", + "message": f"插件 {plugin_id} 爬取并验证完成", "data": { "plugin_id": plugin_id, - "proxy_count": len(results) + "proxy_count": len(results), + "valid_count": inserted_count, + "invalid_count": len(invalid_proxies) } } except Exception as e: logger.error(f"插件爬取失败: {e}") - return {"code": 500, "message": "插件爬取失败呢~", "data": None} + return {"code": 500, "message": f"插件爬取失败: {str(e)}", "data": None} + + +@app.post("/api/plugins/crawl-all") +async def crawl_all_plugins(): + """运行所有插件并验证""" + try: + all_results = [] + all_valid = [] + all_invalid = [] + + for plugin in plugin_manager.plugins: + if not plugin.enabled: + continue + + try: + results = await plugin_manager.run_plugin(plugin.name) + if results: + all_results.extend(results) + except Exception as e: + logger.error(f"插件 {plugin.name} 执行失败: {e}") + continue + + if all_results: + # 去重 + unique_proxies = list(set(all_results)) + logger.info(f"所有插件爬取完成,共 {len(unique_proxies)} 个唯一代理,开始验证...") + + # 验证 + valid_proxies, invalid_proxies = await scheduler.validate_proxies_batch(unique_proxies) + + # 保存有效代理 + db = SQLiteManager() + inserted_count = 0 + for ip, port, protocol in valid_proxies: + success = await db.insert_proxy(ip, port, protocol, score=config.SCORE_VALID) + if success: + inserted_count += 1 + + return { + "code": 200, + "message": "所有插件爬取并验证完成", + "data": { + "total_crawled": len(unique_proxies), + "valid_count": inserted_count, + "invalid_count": len(invalid_proxies) + } + } + + return { + "code": 200, + "message": "所有插件爬取完成,未获取到代理", + "data": { + "total_crawled": 0, + "valid_count": 0, + "invalid_count": 0 + } + } + + except Exception as e: + logger.error(f"批量爬取失败: {e}") + return {"code": 500, "message": f"批量爬取失败: {str(e)}", "data": None} + + +# 验证调度器控制 +@app.post("/api/scheduler/start") +async def start_scheduler(): + """启动验证调度器""" + try: + if scheduler.running: + return {"code": 200, "message": "验证调度器已在运行", "data": {"running": True}} + + await scheduler.start() + + # 更新设置 + settings = load_settings() + settings['auto_validate'] = True + save_settings_to_file(settings) + + return {"code": 200, "message": "验证调度器已启动", "data": {"running": True}} + except Exception as e: + logger.error(f"启动调度器失败: {e}") + return {"code": 500, "message": f"启动调度器失败: {str(e)}", "data": None} + + +@app.post("/api/scheduler/stop") +async def stop_scheduler(): + """停止验证调度器""" + try: + if not scheduler.running: + return {"code": 200, "message": "验证调度器未运行", "data": {"running": False}} + + await scheduler.stop() + + # 更新设置 + settings = load_settings() + settings['auto_validate'] = False + save_settings_to_file(settings) + + return {"code": 200, "message": "验证调度器已停止", "data": {"running": False}} + except Exception as e: + logger.error(f"停止调度器失败: {e}") + return {"code": 500, "message": f"停止调度器失败: {str(e)}", "data": None} + + +@app.post("/api/scheduler/validate-now") +async def validate_now(): + """立即执行一次全量验证""" + try: + # 在后台运行验证,不阻塞响应 + asyncio.create_task(scheduler.validate_all_proxies()) + return {"code": 200, "message": "已开始全量验证", "data": {"started": True}} + except Exception as e: + logger.error(f"启动验证失败: {e}") + return {"code": 500, "message": f"启动验证失败: {str(e)}", "data": None} + + +@app.get("/api/scheduler/status") +async def get_scheduler_status(): + """获取调度器状态""" + return { + "code": 200, + "message": "获取状态成功", + "data": { + "running": scheduler.running, + "interval_minutes": scheduler.interval_minutes + } + } + + +# 设置管理 +class SettingsRequest(BaseModel): + crawl_timeout: int = Field(default=30, ge=5, le=120) + validation_timeout: int = Field(default=10, ge=3, le=60) + max_retries: int = Field(default=3, ge=0, le=10) + default_concurrency: int = Field(default=50, ge=10, le=200) + min_proxy_score: int = Field(default=0, ge=0, le=100) + proxy_expiry_days: int = Field(default=7, ge=1, le=30) + auto_validate: bool = True + validate_interval_minutes: int = Field(default=30, ge=5, le=1440) + + +@app.get("/api/settings") +async def get_settings(): + """获取系统设置""" + try: + settings = load_settings() + return {"code": 200, "message": "获取设置成功", "data": settings} + except Exception as e: + logger.error(f"获取设置失败: {e}") + return {"code": 500, "message": "获取设置失败", "data": None} + + +@app.post("/api/settings") +async def save_settings(request: SettingsRequest): + """保存系统设置""" + try: + settings = { + "crawl_timeout": request.crawl_timeout, + "validation_timeout": request.validation_timeout, + "max_retries": request.max_retries, + "default_concurrency": request.default_concurrency, + "min_proxy_score": request.min_proxy_score, + "proxy_expiry_days": request.proxy_expiry_days, + "auto_validate": request.auto_validate, + "validate_interval_minutes": request.validate_interval_minutes + } + + # 保存到文件 + if save_settings_to_file(settings): + # 更新调度器配置 + scheduler.interval_minutes = request.validate_interval_minutes + + # 如果自动验证状态改变,启动或停止调度器 + if request.auto_validate and not scheduler.running: + await scheduler.start() + elif not request.auto_validate and scheduler.running: + await scheduler.stop() + + return {"code": 200, "message": "保存设置成功", "data": settings} + else: + return {"code": 500, "message": "保存设置失败", "data": None} + + except Exception as e: + logger.error(f"保存设置失败: {e}") + return {"code": 500, "message": f"保存设置失败: {str(e)}", "data": None} + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8923) + uvicorn.run(app, host=config.HOST, port=config.PORT) diff --git a/config.py b/config.py index 324874d..9853e41 100644 --- a/config.py +++ b/config.py @@ -11,7 +11,7 @@ class Config: # API服务配置 HOST: str = os.getenv("HOST", "0.0.0.0") - PORT: int = int(os.getenv("PORT", "3000")) + PORT: int = int(os.getenv("PORT", "9949")) # 验证器配置 VALIDATOR_TIMEOUT: int = int(os.getenv("VALIDATOR_TIMEOUT", "5")) @@ -49,11 +49,6 @@ class Config: # CORS配置 CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:8080,http://localhost:5173") - # API Key配置 - API_KEY: str = os.getenv("API_KEY", "your-api-key-here") - ADMIN_API_KEY: str = os.getenv("ADMIN_API_KEY", "your-admin-api-key-here") - REQUIRE_AUTH: bool = os.getenv("REQUIRE_AUTH", "false").lower() == "true" - @classmethod def get(cls, key: str, default=None): """获取配置项""" diff --git a/core/auth.py b/core/auth.py deleted file mode 100644 index 7d5b9a4..0000000 --- a/core/auth.py +++ /dev/null @@ -1,114 +0,0 @@ -from fastapi import HTTPException, Depends, Header, status -from typing import Optional -from config import Config -from core.log import logger - -class PermissionLevel: - READ_ONLY = "read_only" - ADMIN = "admin" - -def verify_api_key( - x_api_key: Optional[str] = Header(None, alias="X-API-Key"), - authorization: Optional[str] = Header(None) -) -> str: - """ - 验证API Key并返回权限级别 - - Args: - x_api_key: X-API-Key header中的API Key - authorization: Authorization header中的Bearer token - - Returns: - str: 权限级别 - - Raises: - HTTPException: 认证失败时抛出401错误 - """ - api_key = x_api_key - - if authorization and authorization.startswith("Bearer "): - api_key = authorization.replace("Bearer ", "") - - if not api_key: - logger.warning("API请求缺少API Key") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="缺少API Key,请在请求头中添加 X-API-Key 或 Authorization: Bearer ", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if api_key == Config.ADMIN_API_KEY: - logger.info(f"管理员API认证成功: {api_key[:8]}...") - return PermissionLevel.ADMIN - elif api_key == Config.API_KEY: - logger.info(f"普通用户API认证成功: {api_key[:8]}...") - return PermissionLevel.READ_ONLY - else: - logger.warning(f"无效的API Key尝试: {api_key[:8]}...") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的API Key", - headers={"WWW-Authenticate": "Bearer"}, - ) - -def require_admin( - x_api_key: Optional[str] = Header(None, alias="X-API-Key"), - authorization: Optional[str] = Header(None) -) -> str: - """ - 要求管理员权限的依赖函数 - - Args: - x_api_key: X-API-Key header中的API Key - authorization: Authorization header中的Bearer token - - Returns: - str: 权限级别 - - Raises: - HTTPException: 权限不足时抛出403错误 - """ - # 如果未启用认证,直接返回管理员权限 - if not Config.REQUIRE_AUTH: - logger.info("开发模式:跳过管理员权限检查") - return PermissionLevel.ADMIN - - # 验证API Key - api_key = x_api_key - - if authorization and authorization.startswith("Bearer "): - api_key = authorization.replace("Bearer ", "") - - if not api_key: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="缺少API Key,请在请求头中添加 X-API-Key 或 Authorization: Bearer ", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # 检查权限级别 - if api_key == Config.ADMIN_API_KEY: - logger.info(f"管理员API认证成功: {api_key[:8]}...") - return PermissionLevel.ADMIN - else: - logger.warning(f"非管理员用户尝试访问管理接口") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限才能执行此操作" - ) - -def skip_auth_for_dev() -> Optional[str]: - """ - 开发环境跳过认证(仅在开发模式下使用) - - Returns: - Optional[str]: 返回管理员权限级别 - - Warning: - 仅用于开发环境,生产环境务必使用真实认证 - """ - import os - if os.getenv("SKIP_AUTH", "false").lower() == "true": - logger.warning("开发模式:跳过API Key认证") - return PermissionLevel.ADMIN - return None diff --git a/core/scheduler.py b/core/scheduler.py new file mode 100644 index 0000000..94006da --- /dev/null +++ b/core/scheduler.py @@ -0,0 +1,206 @@ +""" +代理验证调度器 +负责定期验证数据库中的代理,并更新分数 +""" +import asyncio +from datetime import datetime, timedelta +from typing import Optional +from core.sqlite import SQLiteManager +from core.validator import ProxyValidator +from core.log import logger +from config import config + + +class ValidationScheduler: + """代理验证调度器""" + + def __init__(self): + self.db = SQLiteManager() + self.validator: Optional[ProxyValidator] = None + self.running = False + self.task: Optional[asyncio.Task] = None + self.interval_minutes = 30 # 默认每30分钟验证一次 + self.batch_size = 100 # 每批验证数量 + + async def start(self): + """启动验证调度器""" + if self.running: + logger.warning("验证调度器已在运行") + return + + self.running = True + self.validator = ProxyValidator( + max_concurrency=config.VALIDATOR_MAX_CONCURRENCY, + timeout=config.VALIDATOR_TIMEOUT + ) + self.task = asyncio.create_task(self._run_loop()) + logger.info("代理验证调度器已启动") + + async def stop(self): + """停止验证调度器""" + self.running = False + if self.task: + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + if self.validator: + await self.validator.__aexit__(None, None, None) + logger.info("代理验证调度器已停止") + + async def _run_loop(self): + """运行循环""" + while self.running: + try: + await self.validate_all_proxies() + except Exception as e: + logger.error(f"验证循环出错: {e}") + + # 等待下一次验证 + await asyncio.sleep(self.interval_minutes * 60) + + async def validate_all_proxies(self): + """验证所有代理""" + logger.info("开始批量验证代理...") + + try: + # 获取所有代理 + proxies = await self.db.get_all_proxies() + if not proxies: + logger.info("数据库中没有代理需要验证") + return + + logger.info(f"需要验证 {len(proxies)} 个代理") + + # 分批验证 + validated_count = 0 + valid_count = 0 + invalid_count = 0 + + async with self.validator: + for i in range(0, len(proxies), self.batch_size): + if not self.running: + break + + batch = proxies[i:i + self.batch_size] + tasks = [] + + for proxy in batch: + ip, port, protocol, score, last_check = proxy + task = self._validate_and_update(ip, port, protocol) + tasks.append(task) + + # 并发验证一批 + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + validated_count += 1 + if isinstance(result, Exception): + logger.error(f"验证过程出错: {result}") + continue + if result: + valid_count += 1 + else: + invalid_count += 1 + + logger.info(f"已验证 {validated_count}/{len(proxies)} 个代理") + + # 批次间短暂延迟,避免过载 + if i + self.batch_size < len(proxies): + await asyncio.sleep(1) + + logger.info(f"验证完成: 总计 {validated_count}, 有效 {valid_count}, 无效 {invalid_count}") + + except Exception as e: + logger.error(f"批量验证代理失败: {e}", exc_info=True) + + async def _validate_and_update(self, ip: str, port: int, protocol: str) -> bool: + """验证单个代理并更新分数""" + try: + is_valid, latency = await self.validator.validate(ip, port, protocol) + + if is_valid: + # 验证成功,增加分数 + await self.db.update_score( + ip, port, + config.SCORE_VALID, + min_score=config.SCORE_MIN, + max_score=config.SCORE_MAX + ) + logger.debug(f"代理验证成功 {ip}:{port} ({protocol}) - 延迟 {latency}ms") + return True + else: + # 验证失败,减少分数 + await self.db.update_score( + ip, port, + config.SCORE_INVALID, + min_score=config.SCORE_MIN, + max_score=config.SCORE_MAX + ) + logger.debug(f"代理验证失败 {ip}:{port} ({protocol})") + return False + + except Exception as e: + logger.error(f"验证代理 {ip}:{port} 时出错: {e}") + # 出错也视为失败 + await self.db.update_score( + ip, port, + config.SCORE_INVALID, + min_score=config.SCORE_MIN, + max_score=config.SCORE_MAX + ) + return False + + async def validate_proxies_batch(self, proxies: list) -> tuple: + """ + 验证一批新抓取的代理 + + Args: + proxies: [(ip, port, protocol), ...] + + Returns: + (有效代理列表, 无效代理列表) + """ + if not proxies: + return [], [] + + valid_proxies = [] + invalid_proxies = [] + + logger.info(f"开始验证 {len(proxies)} 个新抓取代理...") + + try: + validator = ProxyValidator( + max_concurrency=min(config.VALIDATOR_MAX_CONCURRENCY, 50), + timeout=config.VALIDATOR_TIMEOUT + ) + + async with validator: + tasks = [] + for ip, port, protocol in proxies: + task = validator.validate(ip, port, protocol) + tasks.append((ip, port, protocol, task)) + + for ip, port, protocol, task in tasks: + try: + is_valid, latency = await task + if is_valid: + valid_proxies.append((ip, port, protocol)) + logger.debug(f"新代理有效: {ip}:{port} ({protocol}) - {latency}ms") + else: + invalid_proxies.append((ip, port, protocol)) + except Exception as e: + logger.warning(f"验证新代理 {ip}:{port} 失败: {e}") + invalid_proxies.append((ip, port, protocol)) + + logger.info(f"新代理验证完成: 有效 {len(valid_proxies)}, 无效 {len(invalid_proxies)}") + + except Exception as e: + logger.error(f"批量验证新代理失败: {e}") + + return valid_proxies, invalid_proxies + + +# 全局调度器实例 +scheduler = ValidationScheduler() diff --git a/core/validator.py b/core/validator.py index a826c21..3cbc32f 100644 --- a/core/validator.py +++ b/core/validator.py @@ -1,12 +1,16 @@ import asyncio import aiohttp +import aiohttp_socks import random import time from core.log import logger + class ProxyValidator: + """代理验证器 - 支持 HTTP/HTTPS/SOCKS4/SOCKS5""" + def __init__(self, max_concurrency=50, timeout=5): - # 验证目标源(使用更适合代理验证的源) + # 验证目标源 self.http_sources = [ "http://httpbin.org/ip", "http://api.ipify.org" @@ -20,57 +24,169 @@ class ProxyValidator: self.session = None async def __aenter__(self): - # 允许通过 async with 管理 session - if not self.session: - self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(ssl=False, limit=0, force_close=True), - timeout=aiohttp.ClientTimeout(total=self.timeout, connect=3) - ) + """异步上下文管理器入口""" return self async def __aexit__(self, exc_type, exc_val, exc_tb): + """异步上下文管理器出口""" if self.session: await self.session.close() + self.session = None - async def validate(self, ip, port, protocol='http'): + def _get_test_url(self, protocol: str) -> str: + """根据协议获取测试 URL""" + protocol = protocol.lower() + if protocol == 'https': + return random.choice(self.https_sources) + return random.choice(self.http_sources) + + def _create_connector(self, ip: str, port: int, protocol: str): + """创建代理连接器""" + protocol = protocol.lower() + + if protocol == 'socks4': + return aiohttp_socks.ProxyConnector( + proxy_type=aiohttp_socks.ProxyType.SOCKS4, + host=ip, + port=port, + rdns=True + ) + elif protocol == 'socks5': + return aiohttp_socks.ProxyConnector( + proxy_type=aiohttp_socks.ProxyType.SOCKS5, + host=ip, + port=port, + rdns=True + ) + elif protocol in ('http', 'https'): + # HTTP/HTTPS 使用普通 connector,在请求时指定 proxy 参数 + return aiohttp.TCPConnector(ssl=False, limit=0, force_close=True) + else: + # 未知协议默认使用 HTTP + return aiohttp.TCPConnector(ssl=False, limit=0, force_close=True) + + async def validate(self, ip: str, port: int, protocol: str = 'http'): """ 验证单个代理是否可用 + + Args: + ip: 代理 IP + port: 代理端口 + protocol: 协议类型 (http/https/socks4/socks5) + + Returns: + (is_valid: bool, latency_ms: float) """ protocol = protocol.lower() - sources = self.https_sources if protocol == 'https' else self.http_sources - test_url = random.choice(sources) - - # aiohttp 代理 URL 格式 - proxy_url = f"http://{ip}:{port}" + test_url = self._get_test_url(protocol) async with self.semaphore: start_time = time.time() + try: - # 复用 session - async with self.session.get( - test_url, - proxy=proxy_url, - allow_redirects=True, - timeout=aiohttp.ClientTimeout(total=self.timeout, connect=3) - ) as response: - # 检查状态码和响应内容 - if response.status in [200, 301, 302]: - try: - content = await response.text() - # 确保返回了有效的JSON响应 - if 'ip' in content.lower() or 'origin' in content.lower(): - latency = round((time.time() - start_time) * 1000, 2) - logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") - return True, latency - except: - # 即使无法解析内容,如果状态码正常也认为可用 - latency = round((time.time() - start_time) * 1000, 2) - logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") - return True, latency - return False, 0 + if protocol in ('socks4', 'socks5'): + return await self._validate_socks(ip, port, protocol, test_url, start_time) + else: + return await self._validate_http(ip, port, protocol, test_url, start_time) + except asyncio.TimeoutError: logger.warning(f"验证超时: {ip}:{port} ({protocol})") return False, 0 except Exception as e: logger.warning(f"验证失败: {ip}:{port} ({protocol}) - {e}") return False, 0 + + async def _validate_http(self, ip: str, port: int, protocol: str, test_url: str, start_time: float): + """验证 HTTP/HTTPS 代理""" + proxy_url = f"http://{ip}:{port}" + + connector = aiohttp.TCPConnector(ssl=False, limit=0, force_close=True) + timeout = aiohttp.ClientTimeout(total=self.timeout, connect=3) + + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout + ) as session: + async with session.get( + test_url, + proxy=proxy_url, + allow_redirects=True + ) as response: + if response.status in [200, 301, 302]: + try: + content = await response.text() + if 'ip' in content.lower() or 'origin' in content.lower(): + latency = round((time.time() - start_time) * 1000, 2) + logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") + return True, latency + except: + pass + + # 内容解析失败但状态码正常,也算可用 + latency = round((time.time() - start_time) * 1000, 2) + logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") + return True, latency + + return False, 0 + + async def _validate_socks(self, ip: str, port: int, protocol: str, test_url: str, start_time: float): + """验证 SOCKS4/SOCKS5 代理""" + proxy_type = ( + aiohttp_socks.ProxyType.SOCKS4 + if protocol == 'socks4' + else aiohttp_socks.ProxyType.SOCKS5 + ) + + connector = aiohttp_socks.ProxyConnector( + proxy_type=proxy_type, + host=ip, + port=port, + rdns=True, # 远程 DNS 解析,避免 DNS 泄漏 + ssl=False + ) + + timeout = aiohttp.ClientTimeout(total=self.timeout, connect=3) + + try: + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout + ) as session: + async with session.get(test_url, allow_redirects=True) as response: + if response.status in [200, 301, 302]: + try: + content = await response.text() + if 'ip' in content.lower() or 'origin' in content.lower(): + latency = round((time.time() - start_time) * 1000, 2) + logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") + return True, latency + except: + pass + + # 内容解析失败但状态码正常 + latency = round((time.time() - start_time) * 1000, 2) + logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms") + return True, latency + + return False, 0 + finally: + await connector.close() + + +class ProxyValidatorLegacy: + """ + 兼容旧版本的验证器 + 保持原有接口不变 + """ + def __init__(self, max_concurrency=50, timeout=5): + self.validator = ProxyValidator(max_concurrency, timeout) + + async def __aenter__(self): + await self.validator.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.validator.__aexit__(exc_type, exc_val, exc_tb) + + async def validate(self, ip, port, protocol='http'): + return await self.validator.validate(ip, port, protocol) diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 1511959..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + Vite - -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` - + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ac7f684..1d18286 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,73 +1,160 @@ import axios from 'axios' import { showError } from '../utils/message' +/** @type {string} 默认 API 基础 URL */ +export const DEFAULT_API_BASE_URL = 'http://localhost:9949' + +/** @type {number} 请求超时时间(毫秒) */ +export const REQUEST_TIMEOUT = 30000 + const api = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8923', - timeout: 30000 + baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL, + timeout: REQUEST_TIMEOUT }) -api.interceptors.request.use( - config => { - const apiKey = localStorage.getItem('api_key') - if (apiKey) { - config.headers['X-API-Key'] = apiKey - } - return config - }, - error => { - return Promise.reject(error) +/** + * 从 Blob 解析 JSON 错误响应 + * @param {Blob} blob + * @returns {Promise} + */ +async function parseBlobError(blob) { + try { + const text = await blob.text() + return JSON.parse(text) + } catch { + return null } -) +} api.interceptors.response.use( - response => response.data, - error => { + (response) => response.data, + async (error) => { + // 处理 Blob 类型的错误响应 + if (error.response?.data instanceof Blob) { + const parsedData = await parseBlobError(error.response.data) + if (parsedData) { + error.response.data = parsedData + } + } + console.error('API请求错误:', error) showError(error) return Promise.reject(error) } ) +/** + * 清理请求参数,移除 null/undefined/空字符串 + * @param {object} params + * @returns {object} + */ +function cleanParams(params) { + const cleaned = {} + Object.keys(params).forEach((key) => { + const value = params[key] + if (value !== null && value !== undefined && value !== '') { + cleaned[key] = value + } + }) + return cleaned +} + +/** + * 生成请求配置,支持 AbortSignal + * @param {AbortSignal} [signal] + * @returns {object} + */ +function createRequestConfig(signal) { + return signal ? { signal } : {} +} + +// ==================== API 模块 ==================== + export const statsAPI = { + /** @returns {Promise>} */ getStats: () => api.get('/api/stats') } export const proxiesAPI = { - getProxies: (params) => { - const cleanedParams = {} - Object.keys(params).forEach(key => { - if (params[key] !== null && params[key] !== undefined && params[key] !== '') { - cleanedParams[key] = params[key] - } - }) - return api.post('/api/proxies', cleanedParams) - }, - getRandomProxy: () => api.get('/api/proxies/random'), - getProxyDetail: (ip, port) => api.get(`/api/proxies/${ip}/${port}`), + /** + * @param {object} params + * @param {AbortSignal} [signal] + * @returns {Promise>} + */ + getProxies: (params, signal) => + api.post('/api/proxies', cleanParams(params), createRequestConfig(signal)), + + /** + * @param {string} ip + * @param {number|string} port + * @returns {Promise>} + */ deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`), + + /** + * @param {Array<[string, number|string]>} proxies + * @returns {Promise>} + */ batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }), + + /** @returns {Promise>} */ cleanInvalidProxies: () => api.delete('/api/proxies/clean-invalid'), + + /** + * @param {string} format + * @param {string|null} protocol + * @returns {Promise} + */ exportProxies: (format, protocol) => api.get(`/api/proxies/export/${format}`, { params: protocol ? { protocol } : {}, responseType: 'blob' }) } -export const crawlerAPI = { - start: (numValidators = 50) => api.post('/api/crawler/start', { num_validators: numValidators }), - stop: () => api.post('/api/crawler/stop'), - getStatus: () => api.get('/api/crawler/status') +export const pluginsAPI = { + /** @returns {Promise>} */ + getPlugins: () => api.get('/api/plugins'), + + /** + * @param {string|number} pluginId + * @param {boolean} enabled + * @returns {Promise>} + */ + togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }), + + /** + * @param {string|number} pluginId + * @returns {Promise>} + */ + crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`), + + /** @returns {Promise>} */ + crawlAll: () => api.post('/api/plugins/crawl-all') } export const schedulerAPI = { - setScheduler: (enabled, intervalMinutes = 60) => api.post('/api/scheduler', { enabled, interval_minutes: intervalMinutes }), - getStatus: () => api.get('/api/scheduler') + /** @returns {Promise>} */ + start: () => api.post('/api/scheduler/start'), + + /** @returns {Promise>} */ + stop: () => api.post('/api/scheduler/stop'), + + /** @returns {Promise>} */ + validateNow: () => api.post('/api/scheduler/validate-now'), + + /** @returns {Promise>} */ + getStatus: () => api.get('/api/scheduler/status') } -export const pluginsAPI = { - getPlugins: () => api.get('/api/plugins'), - togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }), - crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`) +export const settingsAPI = { + /** @returns {Promise>} */ + getSettings: () => api.get('/api/settings'), + + /** + * @param {object} data + * @returns {Promise>} + */ + saveSettings: (data) => api.post('/api/settings', data) } export default api diff --git a/frontend/src/api/types.js b/frontend/src/api/types.js new file mode 100644 index 0000000..5fdc215 --- /dev/null +++ b/frontend/src/api/types.js @@ -0,0 +1,57 @@ +/** + * @typedef {object} ApiResponse + * @property {number} code + * @property {string} message + * @property {T} data + */ + +/** + * @typedef {object} StatsData + * @property {number} total + * @property {number} available + * @property {number} today_new + * @property {number} avg_score + * @property {number} http_count + * @property {number} https_count + * @property {number} socks4_count + * @property {number} socks5_count + */ + +/** + * @typedef {object} Proxy + * @property {string} ip + * @property {number} port + * @property {string} protocol + * @property {number} score + * @property {string} last_check + */ + +/** + * @typedef {object} ProxyListData + * @property {Proxy[]} list + * @property {number} total + */ + +/** + * @typedef {object} Plugin + * @property {string|number} id + * @property {string} name + * @property {string} description + * @property {boolean} enabled + * @property {number} success_count + * @property {number} failure_count + * @property {string|null} last_run + */ + +/** + * @typedef {object} SettingsData + * @property {string} db_path + * @property {number} crawl_timeout + * @property {number} validation_timeout + * @property {number} max_retries + * @property {number} default_concurrency + * @property {number} min_proxy_score + * @property {number} proxy_expiry_days + */ + +export {} diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue deleted file mode 100644 index 546ebbc..0000000 --- a/frontend/src/components/HelloWorld.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 810a796..01f0aae 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -1,18 +1,35 @@ @@ -20,15 +37,45 @@ defineProps({ diff --git a/frontend/src/components/ProtocolChart.vue b/frontend/src/components/ProtocolChart.vue index 3fef321..26781c3 100644 --- a/frontend/src/components/ProtocolChart.vue +++ b/frontend/src/components/ProtocolChart.vue @@ -2,18 +2,28 @@ -
+
+ +
diff --git a/frontend/src/components/QuickActions.vue b/frontend/src/components/QuickActions.vue index 1390119..8eb3e8a 100644 --- a/frontend/src/components/QuickActions.vue +++ b/frontend/src/components/QuickActions.vue @@ -1,83 +1,144 @@ diff --git a/frontend/src/components/StatCard.vue b/frontend/src/components/StatCard.vue index 8a8758f..a671da0 100644 --- a/frontend/src/components/StatCard.vue +++ b/frontend/src/components/StatCard.vue @@ -1,9 +1,11 @@ diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js deleted file mode 100644 index ff61770..0000000 --- a/frontend/src/composables/useWebSocket.js +++ /dev/null @@ -1,76 +0,0 @@ -import { ref } from 'vue' - -export function useWebSocket() { - const ws = ref(null) - const isExplicitDisconnect = ref(false) - let reconnectTimer = null - - function connect(url, onMessage, onError, onClose, onOpen, token) { - isExplicitDisconnect.value = false - - if (ws.value && ws.value.readyState === WebSocket.OPEN) { - console.log('WebSocket已经连接啦~') - return - } - - const wsUrl = token ? `${url}?token=${token}` : url - console.log('尝试连接WebSocket:', wsUrl) - ws.value = new WebSocket(wsUrl) - - ws.value.onopen = () => { - console.log('WebSocket连接成功啦~', ws.value.readyState) - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - onOpen?.() - } - - ws.value.onmessage = (event) => { - try { - const data = JSON.parse(event.data) - onMessage?.(data) - } catch (error) { - console.error('解析WebSocket消息失败:', error, event.data) - } - } - - ws.value.onerror = (error) => { - console.error('WebSocket错误:', error) - onError?.(error) - } - - ws.value.onclose = (event) => { - console.log('WebSocket连接关闭:', event.code, event.reason) - ws.value = null - - onClose?.(event) - - if (!isExplicitDisconnect.value) { - console.log('检测到异常断开,3秒后尝试重连...') - if (reconnectTimer) clearTimeout(reconnectTimer) - reconnectTimer = setTimeout(() => { - connect(url, onMessage, onError, onClose, onOpen) - }, 3000) - } - } - } - - function disconnect() { - isExplicitDisconnect.value = true - if (ws.value) { - ws.value.close() - ws.value = null - } - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - } - - return { - ws, - connect, - disconnect - } -} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 4055074..ac32ab1 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,4 +1,4 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createWebHashHistory } from 'vue-router' const routes = [ { @@ -15,11 +15,7 @@ const routes = [ name: 'ProxyList', component: () => import('../views/ProxyList.vue') }, - { - path: '/crawler', - name: 'CrawlerTasks', - component: () => import('../views/CrawlerTasks.vue') - }, + { path: '/plugins', name: 'Plugins', @@ -33,7 +29,7 @@ const routes = [ ] const router = createRouter({ - history: createWebHistory(), + history: createWebHashHistory(), routes }) diff --git a/frontend/src/stores/crawler.js b/frontend/src/stores/crawler.js deleted file mode 100644 index bc5a4af..0000000 --- a/frontend/src/stores/crawler.js +++ /dev/null @@ -1,144 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { crawlerAPI, schedulerAPI } from '../api' -import { useWebSocket } from '../composables/useWebSocket' - -export const useCrawlerStore = defineStore('crawler', () => { - const running = ref(false) - const stats = ref({}) - const scheduled = ref(false) - const intervalMinutes = ref(60) - const progress = ref({ - total: 0, - current: 0, - success: 0, - failed: 0 - }) - const statusMessage = ref('') - - const { connect, disconnect } = useWebSocket() - - async function fetchStatus() { - try { - const response = await crawlerAPI.getStatus() - if (response.code === 200) { - running.value = response.data.running - stats.value = response.data.stats || {} - } - } catch (error) { - console.error('获取爬虫状态失败:', error) - } - } - - async function startCrawler(numValidators = 50) { - try { - const response = await crawlerAPI.start(numValidators) - if (response.code === 200) { - running.value = true - return true - } - } catch (error) { - console.error('启动爬虫失败:', error) - } - return false - } - - async function stopCrawler() { - try { - const response = await crawlerAPI.stop() - if (response.code === 200) { - running.value = false - return true - } - } catch (error) { - console.error('停止爬虫失败:', error) - } - return false - } - - async function fetchSchedulerStatus() { - try { - const response = await schedulerAPI.getStatus() - if (response.code === 200) { - scheduled.value = response.data.enabled - intervalMinutes.value = response.data.interval_minutes - } - } catch (error) { - console.error('获取定时任务状态失败:', error) - } - } - - async function setScheduler(enabled, interval = 60) { - try { - const response = await schedulerAPI.setScheduler(enabled, interval) - if (response.code === 200) { - scheduled.value = enabled - intervalMinutes.value = interval - return true - } - } catch (error) { - console.error('设置定时任务失败:', error) - } - return false - } - - function connectWebSocket() { - const wsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8923' - const token = import.meta.env.VITE_API_KEY - - connect( - `${wsUrl}/ws`, - (data) => { - console.log('收到WebSocket消息:', data) - if (data.type === 'progress') { - console.log('更新进度:', data.data) - progress.value = { - found: data.data.found || 0, - verified: data.data.verified || 0, - success_rate: data.data.success_rate || 0 - } - console.log('进度更新后:', progress.value) - } else if (data.type === 'status') { - statusMessage.value = data.data.message - if (data.data.status === 'completed') { - running.value = false - } else if (data.data.status === 'stopped') { - running.value = false - } else if (data.data.status === 'running') { - running.value = true - } - } - }, - (error) => { - console.error('WebSocket错误:', error) - }, - (event) => { - console.log('WebSocket连接关闭:', event.code, event.reason) - }, - () => { - console.log('WebSocket连接成功啦~') - }, - token - ) - } - - function disconnectWebSocket() { - disconnect() - } - - return { - running, - stats, - scheduled, - intervalMinutes, - progress, - statusMessage, - fetchStatus, - startCrawler, - stopCrawler, - fetchSchedulerStatus, - setScheduler, - connectWebSocket, - disconnectWebSocket - } -}) diff --git a/frontend/src/stores/plugins.js b/frontend/src/stores/plugins.js index 2969939..7536983 100644 --- a/frontend/src/stores/plugins.js +++ b/frontend/src/stores/plugins.js @@ -1,25 +1,48 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' import { pluginsAPI } from '../api' +/** + * Plugins Store + * 管理插件列表和状态 + */ export const usePluginsStore = defineStore('plugins', () => { + // ==================== State ==================== const plugins = ref([]) const loading = ref(false) - + + // ==================== Getters ==================== + const enabledCount = computed(() => plugins.value.filter(p => p.enabled).length) + const totalCount = computed(() => plugins.value.length) + + // ==================== Actions ==================== + + /** + * 获取插件列表 + * @returns {Promise} + */ async function fetchPlugins() { loading.value = true try { const response = await pluginsAPI.getPlugins() if (response.code === 200) { plugins.value = response.data.plugins || [] + return true } } catch (error) { console.error('获取插件列表失败:', error) } finally { loading.value = false } + return false } - + + /** + * 切换插件启用状态 + * @param {string|number} pluginId + * @param {boolean} enabled + * @returns {Promise} + */ async function togglePlugin(pluginId, enabled) { try { const response = await pluginsAPI.togglePlugin(pluginId, enabled) @@ -35,24 +58,50 @@ export const usePluginsStore = defineStore('plugins', () => { } return false } - + + /** + * 触发插件爬取 + * @param {string|number} pluginId + * @returns {Promise} + */ async function crawlPlugin(pluginId) { try { const response = await pluginsAPI.crawlPlugin(pluginId) - if (response.code === 200) { - return true - } + return response.code === 200 } catch (error) { console.error('触发插件爬取失败:', error) + return false } - return false } - + + /** + * 根据 ID 获取插件 + * @param {string|number} id + * @returns {object|undefined} + */ + function getPluginById(id) { + return plugins.value.find(p => p.id === id) + } + + /** + * 重置状态 + */ + function reset() { + plugins.value = [] + } + return { + // State plugins, loading, + // Getters + enabledCount, + totalCount, + // Actions fetchPlugins, togglePlugin, - crawlPlugin + crawlPlugin, + getPluginById, + reset } }) diff --git a/frontend/src/stores/proxy.js b/frontend/src/stores/proxy.js index 16e64e7..07bc2f9 100644 --- a/frontend/src/stores/proxy.js +++ b/frontend/src/stores/proxy.js @@ -2,54 +2,99 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { proxiesAPI, statsAPI } from '../api' +/** + * 判断是否为用户取消的错误 + * @param {Error} error + * @returns {boolean} + */ +function isAbortError(error) { + return error.name === 'AbortError' || error.code === 'ERR_CANCELED' +} + +/** + * Proxy Store + * 管理代理列表、统计信息和相关操作 + */ export const useProxyStore = defineStore('proxy', () => { + // ==================== State ==================== const proxies = ref([]) const total = ref(0) const loading = ref(false) const stats = ref({}) - const availableCount = computed(() => stats.value.available || 0) - const totalCount = computed(() => stats.value.total || 0) + // ==================== Getters ==================== + const hasProxies = computed(() => proxies.value.length > 0) + const isEmpty = computed(() => !loading.value && proxies.value.length === 0) + // ==================== Actions ==================== + + /** + * 获取统计信息 + * @returns {Promise} + */ async function fetchStats() { try { const response = await statsAPI.getStats() if (response.code === 200) { stats.value = response.data + return true } } catch (error) { console.error('获取统计信息失败:', error) } + return false } - async function fetchProxies(params) { + /** + * 获取代理列表 + * @param {object} params - 查询参数 + * @param {AbortSignal} [signal] - 用于取消请求的信号 + * @returns {Promise} + */ + async function fetchProxies(params, signal) { loading.value = true try { - const response = await proxiesAPI.getProxies(params) + const response = await proxiesAPI.getProxies(params, signal) if (response.code === 200) { proxies.value = response.data.list total.value = response.data.total + return true } } catch (error) { + if (isAbortError(error)) { + return false + } console.error('获取代理列表失败:', error) } finally { loading.value = false } - } - - async function deleteProxy(ip, port) { - try { - const response = await proxiesAPI.deleteProxy(ip, port) - if (response.code === 200) { - return true - } - } catch (error) { - console.error('删除代理失败:', error) - } return false } + /** + * 删除单个代理 + * @param {string} ip + * @param {number|string} port + * @returns {Promise} + */ + async function deleteProxy(ip, port) { + try { + const response = await proxiesAPI.deleteProxy(ip, port) + return response.code === 200 + } catch (error) { + console.error('删除代理失败:', error) + return false + } + } + + /** + * 批量删除代理 + * @param {Array<[string, number|string]>} proxyList + * @returns {Promise} 实际删除的数量 + */ async function batchDeleteProxies(proxyList) { + if (!proxyList?.length) return 0 + try { const response = await proxiesAPI.batchDeleteProxies(proxyList) if (response.code === 200) { @@ -61,6 +106,10 @@ export const useProxyStore = defineStore('proxy', () => { return 0 } + /** + * 清理无效代理 + * @returns {Promise} 删除的数量 + */ async function cleanInvalidProxies() { try { const response = await proxiesAPI.cleanInvalidProxies() @@ -73,9 +122,17 @@ export const useProxyStore = defineStore('proxy', () => { return 0 } - async function exportProxies(format, protocol) { + /** + * 导出代理 + * @param {string} format - 导出格式 (txt/csv/json) + * @param {string|null} protocol - 协议过滤 + * @returns {Promise} + */ + async function exportProxies(format, protocol = null) { try { const response = await proxiesAPI.exportProxies(format, protocol) + + // 创建下载链接 const url = window.URL.createObjectURL(new Blob([response])) const link = document.createElement('a') link.href = url @@ -84,25 +141,39 @@ export const useProxyStore = defineStore('proxy', () => { link.click() link.remove() window.URL.revokeObjectURL(url) + return true } catch (error) { console.error('导出代理失败:', error) + return false } - return false + } + + /** + * 重置状态 + */ + function reset() { + proxies.value = [] + total.value = 0 + stats.value = {} } return { + // State proxies, total, loading, stats, - availableCount, - totalCount, + // Getters + hasProxies, + isEmpty, + // Actions fetchStats, fetchProxies, deleteProxy, batchDeleteProxies, cleanInvalidProxies, - exportProxies + exportProxies, + reset } }) diff --git a/frontend/src/style.css b/frontend/src/style.css index 983d1bb..ca9ed34 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -9,43 +9,53 @@ } body { - font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; color: var(--text-primary); - background: var(--bg-page); + background: var(--bg); overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { text-decoration: none; - color: var(--cyan); + color: var(--primary); transition: var(--transition-base); } a:hover { - color: var(--cyan-light); + color: var(--primary-hover); } +/* 滚动条 - 深色主题 */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { - background-color: var(--bg-page); + background-color: var(--bg); border-radius: var(--radius-sm); } ::-webkit-scrollbar-thumb { - background-color: var(--border-light); + background-color: var(--border); border-radius: var(--radius-sm); transition: var(--transition-base); } ::-webkit-scrollbar-thumb:hover { - background-color: var(--primary-light); + background-color: var(--text-muted); } +/* 选中文本颜色 */ +::selection { + background: rgba(146, 124, 255, 0.3); + color: var(--text-primary); +} + +/* 动画定义 */ @keyframes gradientShift { 0%, 100% { background-position: 0% 50%; @@ -84,11 +94,11 @@ a:hover { } } -@keyframes progressShine { - 0% { - transform: translateX(-100%); +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 5px rgba(146, 124, 255, 0.3); } - 100% { - transform: translateX(100%); + 50% { + box-shadow: 0 0 20px rgba(146, 124, 255, 0.5); } } diff --git a/frontend/src/styles/element-plus.css b/frontend/src/styles/element-plus.css index 415b6e0..52605c2 100644 --- a/frontend/src/styles/element-plus.css +++ b/frontend/src/styles/element-plus.css @@ -1,5 +1,10 @@ +/* ==================== Element Plus 冷灰紫主题覆盖 ==================== */ + +/* -------------------- 输入框 -------------------- */ .el-input__wrapper { + background-color: var(--surface-3) !important; box-shadow: 0 0 0 1px var(--border) inset !important; + border-radius: var(--radius-md) !important; } .el-input__wrapper:hover, @@ -7,8 +12,23 @@ box-shadow: 0 0 0 1px var(--primary) inset !important; } +.el-input__wrapper.is-focus { + box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important; +} + +.el-input__inner { + color: var(--text-primary) !important; +} + +.el-input__inner::placeholder { + color: var(--text-muted) !important; +} + +/* -------------------- 选择器 -------------------- */ .el-select__wrapper { + background-color: var(--surface-3) !important; box-shadow: 0 0 0 1px var(--border) inset !important; + border-radius: var(--radius-md) !important; } .el-select__wrapper:hover, @@ -16,55 +36,67 @@ box-shadow: 0 0 0 1px var(--primary) inset !important; } +.el-select__wrapper.is-focused { + box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important; +} + .el-select__placeholder { - color: var(--text-secondary) !important; + color: var(--text-muted) !important; } .el-select__caret { + color: var(--text-secondary) !important; +} + +.el-select__caret.is-reverse { color: var(--primary) !important; } .el-select-dropdown { border: 1px solid var(--border) !important; - box-shadow: var(--shadow-md) !important; - background: white !important; + box-shadow: var(--shadow-lg) !important; + background: var(--surface) !important; + border-radius: var(--radius-md) !important; } .el-select-dropdown__item { - color: var(--text-primary) !important; + color: var(--text-secondary) !important; } .el-select-dropdown__item:hover { - background: rgba(255, 107, 157, 0.1) !important; + background: var(--primary-soft) !important; color: var(--primary) !important; } .el-select-dropdown__item.is-selected { color: var(--primary) !important; font-weight: 600; + background: var(--primary-soft) !important; } +/* -------------------- 数字输入框 -------------------- */ .el-input-number__decrease, .el-input-number__increase { - background: var(--bg-light) !important; + background: var(--surface-2) !important; color: var(--text-secondary) !important; border: 1px solid var(--border) !important; } .el-input-number__decrease:hover, .el-input-number__increase:hover { - background: rgba(255, 107, 157, 0.1) !important; + background: var(--primary-soft) !important; color: var(--primary) !important; border-color: var(--primary) !important; } .el-input-number__decrease.is-disabled, .el-input-number__increase.is-disabled { - color: #ccc !important; + color: var(--el-disabled-text) !important; border-color: var(--border) !important; } .el-input-number__wrapper { + background-color: var(--surface-3) !important; box-shadow: 0 0 0 1px var(--border) inset !important; } @@ -73,79 +105,132 @@ box-shadow: 0 0 0 1px var(--primary) inset !important; } +/* -------------------- 按钮 -------------------- */ .el-button { border: 1px solid var(--border) !important; + background: var(--surface-2) !important; + color: var(--text-secondary) !important; + border-radius: var(--radius-md) !important; + font-weight: 500; } -.el-button--primary { - background: var(--gradient-primary) !important; +.el-button:hover { border-color: var(--primary) !important; + color: var(--primary) !important; + background: var(--surface-3) !important; +} + +/* 主要按钮 - 深紫实心 */ +.el-button--primary { + background: var(--primary-solid) !important; + border-color: var(--primary-solid) !important; color: white !important; } .el-button--primary:hover { - box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3) !important; - transform: translateY(-2px); + background: var(--primary-solid-hover) !important; + border-color: var(--primary-solid-hover) !important; + box-shadow: var(--shadow-primary-md) !important; + transform: translateY(-1px); } +/* 成功按钮 - 青绿 */ .el-button--success { - background: var(--gradient-cyan) !important; - border-color: var(--cyan) !important; - color: white !important; + background: var(--success) !important; + border-color: var(--success) !important; + color: var(--bg) !important; } .el-button--success:hover { - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3) !important; - transform: translateY(-2px); + background: #2DD4BF !important; + border-color: #2DD4BF !important; + box-shadow: 0 0 20px rgba(34, 197, 94, 0.3) !important; } +/* 警告按钮 - 橙黄 */ .el-button--warning { - background: var(--gradient-yellow) !important; - border-color: var(--yellow) !important; - color: white !important; + background: var(--warning) !important; + border-color: var(--warning) !important; + color: var(--bg) !important; } .el-button--warning:hover { - box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3) !important; - transform: translateY(-2px); + background: #FBBF24 !important; + border-color: #FBBF24 !important; + box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important; } +/* 危险按钮 - 粉红 */ .el-button--danger { - background: var(--gradient-danger) !important; + background: var(--danger) !important; border-color: var(--danger) !important; color: white !important; } .el-button--danger:hover { - box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3) !important; - transform: translateY(-2px); + background: #FCA5A5 !important; + border-color: #FCA5A5 !important; + box-shadow: 0 0 20px rgba(251, 113, 133, 0.3) !important; } +/* 纯文字按钮 */ +.el-button--text { + background: transparent !important; + border-color: transparent !important; + color: var(--primary) !important; +} + +.el-button--text:hover { + color: var(--primary-hover) !important; + background: var(--primary-soft) !important; +} + +/* -------------------- 卡片 -------------------- */ .el-card { border: 1px solid var(--border) !important; - box-shadow: var(--shadow-sm) !important; + box-shadow: none !important; + background: var(--surface) !important; + border-radius: var(--radius-lg) !important; +} + +.el-card:hover { + border-color: var(--border-light) !important; } .el-card__header { border-bottom: 1px solid var(--border) !important; + padding: 16px 20px; } .el-card__body { - background: var(--bg-card) !important; + background: transparent !important; + padding: 20px; } +/* -------------------- 表格 -------------------- */ .el-table { border: 1px solid var(--border) !important; - background: white !important; + background: var(--surface) !important; + border-radius: var(--radius-lg) !important; + --el-table-row-hover-bg-color: var(--surface-2); + --el-table-current-row-bg-color: var(--primary-soft); + --el-table-header-bg-color: var(--surface-2); + --el-table-tr-bg-color: var(--surface); + --el-table-expanded-cell-bg-color: var(--surface); } .el-table th.el-table__cell { - background: var(--bg-light) !important; - color: var(--text-primary) !important; + background: var(--surface-2) !important; + color: var(--text-secondary) !important; border-bottom: 1px solid var(--border) !important; + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; } .el-table td.el-table__cell { + color: var(--text-primary) !important; border-bottom: 1px solid var(--border) !important; } @@ -158,16 +243,22 @@ } .el-table tr:hover > td { - background: #FFF0F5 !important; + background: var(--surface-2) !important; } .el-table__body tr.current-row > td.el-table__cell { - background: var(--border) !important; + background: var(--primary-soft) !important; } +/* 表格行选中左侧高亮条 */ +.el-table__body tr.current-row > td.el-table__cell:first-child { + border-left: 3px solid var(--primary) !important; +} + +/* -------------------- 复选框 -------------------- */ .el-checkbox__inner { border: 1px solid var(--border) !important; - background: white !important; + background: var(--surface-3) !important; } .el-checkbox__inner:hover { @@ -180,61 +271,86 @@ } .el-checkbox__input.is-disabled .el-checkbox__inner { - background: #f5f5f5 !important; - border-color: #e4e7ed !important; + background: var(--el-disabled-bg) !important; + border-color: var(--el-disabled-border) !important; } +/* -------------------- 分页 -------------------- */ .el-pagination button { border: 1px solid var(--border) !important; - background: var(--bg-light) !important; + background: var(--surface) !important; color: var(--text-secondary) !important; + border-radius: var(--radius-sm) !important; } .el-pagination button:hover { - background: rgba(255, 107, 157, 0.1) !important; + background: var(--surface-2) !important; + border-color: var(--primary) !important; color: var(--primary) !important; } -.el-pagination li.is-active { - background: var(--primary) !important; - color: white !important; - border-color: var(--primary) !important; +.el-pagination button:disabled { + background: var(--surface) !important; + color: var(--text-muted) !important; + border-color: var(--border) !important; } .el-pager li { - background: var(--bg-light) !important; + background: var(--surface) !important; color: var(--text-secondary) !important; border: 1px solid var(--border) !important; + border-radius: var(--radius-sm) !important; } .el-pager li:hover { color: var(--primary) !important; + border-color: var(--primary) !important; +} + +.el-pager li.is-active { + background: var(--primary) !important; + color: var(--bg) !important; + border-color: var(--primary) !important; + font-weight: 600; +} + +/* -------------------- 标签 -------------------- */ +.el-tag { + border-radius: var(--radius-sm) !important; + font-weight: 500; } .el-tag--primary { - background: rgba(255, 107, 157, 0.1) !important; + background: var(--primary-soft) !important; color: var(--primary) !important; - border-color: rgba(255, 107, 157, 0.3) !important; + border-color: rgba(146, 124, 255, 0.3) !important; } .el-tag--success { - background: rgba(0, 212, 255, 0.1) !important; - color: var(--cyan) !important; - border-color: rgba(0, 212, 255, 0.3) !important; + background: var(--success-soft) !important; + color: var(--success) !important; + border-color: rgba(34, 197, 94, 0.3) !important; } .el-tag--warning { - background: rgba(255, 184, 0, 0.1) !important; - color: var(--yellow) !important; - border-color: rgba(255, 184, 0, 0.3) !important; + background: var(--warning-soft) !important; + color: var(--warning) !important; + border-color: rgba(245, 158, 11, 0.3) !important; } .el-tag--danger { - background: rgba(255, 107, 107, 0.1) !important; + background: var(--danger-soft) !important; color: var(--danger) !important; - border-color: rgba(255, 107, 107, 0.3) !important; + border-color: rgba(251, 113, 133, 0.3) !important; } +.el-tag--info { + background: var(--info-soft) !important; + color: var(--info) !important; + border-color: rgba(56, 189, 248, 0.3) !important; +} + +/* -------------------- 评分 -------------------- */ .el-rate__icon { color: var(--border) !important; } @@ -243,36 +359,54 @@ color: var(--primary) !important; } +/* -------------------- 对话框 -------------------- */ .el-dialog { border: 1px solid var(--border) !important; + background: var(--surface) !important; + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-xl) !important; } .el-dialog__header { border-bottom: 1px solid var(--border) !important; + padding: 16px 20px; + margin: 0; +} + +.el-dialog__title { + color: var(--text-primary) !important; + font-weight: 600; } .el-dialog__body { - background: white !important; + background: transparent !important; + color: var(--text-secondary) !important; + padding: 20px; } .el-dialog__footer { border-top: 1px solid var(--border) !important; + padding: 16px 20px; } +/* -------------------- 下拉菜单 -------------------- */ .el-dropdown-menu { border: 1px solid var(--border) !important; - box-shadow: var(--shadow-md) !important; + box-shadow: var(--shadow-lg) !important; + background: var(--surface) !important; + border-radius: var(--radius-md) !important; } .el-dropdown-menu__item { - color: var(--text-primary) !important; + color: var(--text-secondary) !important; } .el-dropdown-menu__item:hover { - background: rgba(255, 107, 157, 0.1) !important; + background: var(--primary-soft) !important; color: var(--primary) !important; } +/* -------------------- 滚动条 -------------------- */ .el-scrollbar__wrap::-webkit-scrollbar { width: 6px; height: 6px; @@ -287,46 +421,54 @@ background: var(--primary); } +/* -------------------- 表单 -------------------- */ .el-form-item__label { - color: var(--text-muted) !important; + color: var(--text-secondary) !important; + font-weight: 500; } .el-form-item__error { color: var(--danger) !important; } +/* -------------------- 消息提示 -------------------- */ .el-message { border: 1px solid var(--border) !important; - box-shadow: var(--shadow-md) !important; + box-shadow: var(--shadow-lg) !important; + background: var(--surface) !important; + border-radius: var(--radius-md) !important; } .el-message--success { - background: rgba(0, 212, 255, 0.1) !important; - border-color: rgba(0, 212, 255, 0.3) !important; - color: var(--cyan) !important; + background: var(--surface) !important; + border-color: var(--success) !important; + color: var(--success) !important; } .el-message--error { - background: rgba(255, 107, 107, 0.1) !important; - border-color: rgba(255, 107, 107, 0.3) !important; + background: var(--surface) !important; + border-color: var(--danger) !important; color: var(--danger) !important; } .el-message--warning { - background: rgba(255, 184, 0, 0.1) !important; - border-color: rgba(255, 184, 0, 0.3) !important; - color: var(--yellow) !important; + background: var(--surface) !important; + border-color: var(--warning) !important; + color: var(--warning) !important; } .el-message--info { - background: rgba(255, 107, 157, 0.1) !important; - border-color: rgba(255, 107, 157, 0.3) !important; + background: var(--surface) !important; + border-color: var(--primary) !important; color: var(--primary) !important; } +/* -------------------- 消息盒子 -------------------- */ .el-message-box { border: 1px solid var(--border) !important; - box-shadow: var(--shadow-md) !important; + box-shadow: var(--shadow-xl) !important; + background: var(--surface) !important; + border-radius: var(--radius-lg) !important; } .el-message-box__header { @@ -334,33 +476,118 @@ } .el-message-box__title { - color: var(--primary) !important; + color: var(--text-primary) !important; + font-weight: 600; } .el-message-box__content { - color: var(--text-primary) !important; + color: var(--text-secondary) !important; } .el-message-box__btns { border-top: 1px solid var(--border) !important; } +/* -------------------- 警告提示 -------------------- */ +.el-alert { + border-radius: var(--radius-md) !important; +} + .el-alert--success { - background-color: rgba(0, 255, 136, 0.1) !important; - border-color: var(--green) !important; + background-color: var(--success-soft) !important; + border: 1px solid rgba(34, 197, 94, 0.3) !important; + color: var(--success) !important; } .el-alert--info { - background-color: rgba(255, 107, 157, 0.1) !important; - border-color: var(--primary) !important; + background-color: var(--primary-soft) !important; + border: 1px solid rgba(146, 124, 255, 0.3) !important; + color: var(--primary) !important; } .el-alert--warning { - background-color: rgba(255, 184, 0, 0.1) !important; - border-color: var(--yellow) !important; + background-color: var(--warning-soft) !important; + border: 1px solid rgba(245, 158, 11, 0.3) !important; + color: var(--warning) !important; } .el-alert--error { - background-color: rgba(255, 51, 102, 0.1) !important; - border-color: var(--danger) !important; + background-color: var(--danger-soft) !important; + border: 1px solid rgba(251, 113, 133, 0.3) !important; + color: var(--danger) !important; +} + +/* -------------------- Switch 开关 -------------------- */ +.theme-switch.el-switch .el-switch__core { + background: var(--surface-3); + border-color: var(--border); +} + +.theme-switch.el-switch.is-checked .el-switch__core { + border-color: var(--primary) !important; + background-color: var(--primary) !important; +} + +/* -------------------- 进度条 -------------------- */ +.el-progress-bar__outer { + background-color: var(--surface-3) !important; +} + +.el-progress-bar__inner { + background: var(--gradient-primary) !important; +} + +.el-progress__text { + color: var(--text-secondary) !important; +} + +/* -------------------- 菜单 -------------------- */ +.el-menu { + background: transparent !important; + border-right: none !important; +} + +.el-menu-item { + color: var(--text-secondary) !important; + border-radius: var(--radius-md); + margin: 4px 8px; +} + +.el-menu-item:hover { + background: var(--surface-2) !important; + color: var(--primary) !important; +} + +.el-menu-item.is-active { + background: var(--primary-soft) !important; + color: var(--primary) !important; + font-weight: 600; +} + +/* -------------------- Tabs -------------------- */ +.el-tabs__nav-wrap::after { + background-color: var(--border) !important; +} + +.el-tabs__item { + color: var(--text-muted) !important; +} + +.el-tabs__item:hover { + color: var(--primary) !important; +} + +.el-tabs__item.is-active { + color: var(--primary) !important; +} + +.el-tabs__active-bar { + background-color: var(--primary) !important; +} + +/* -------------------- Tooltip -------------------- */ +.el-tooltip__popper { + background: var(--surface-2) !important; + border: 1px solid var(--border) !important; + color: var(--text-primary) !important; } diff --git a/frontend/src/styles/utilities.css b/frontend/src/styles/utilities.css index afaf5f4..fec9b32 100644 --- a/frontend/src/styles/utilities.css +++ b/frontend/src/styles/utilities.css @@ -1,15 +1,19 @@ +/** + * 工具类 CSS - 冷灰紫主题 + * 提供通用的布局和样式工具类 + */ + +/* ==================== 卡片 ==================== */ .card-base { - border-radius: var(--radius-xl); - background: var(--bg-card); + border-radius: var(--radius-lg); + background: var(--surface); border: 1px solid var(--border); - box-shadow: var(--shadow-sm); transition: var(--transition-hover); } .card-base:hover { - box-shadow: var(--shadow-md); + border-color: var(--border-light); transform: translateY(-2px); - border-color: var(--primary); } .card-header { @@ -22,14 +26,15 @@ .card-title { font-size: 18px; - font-weight: 700; - color: var(--primary); - letter-spacing: 1px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.5px; } +/* ==================== 按钮工具类 ==================== */ .btn-base { border-radius: var(--radius-md); - font-weight: 600; + font-weight: 500; transition: var(--transition-base); border: 1px solid var(--border); display: inline-flex; @@ -38,61 +43,87 @@ gap: 8px; cursor: pointer; outline: none; + padding: 8px 16px; + background: var(--surface-2); + color: var(--text-secondary); } -.btn-primary { - background: var(--gradient-primary); +.btn-base:hover { border-color: var(--primary); + color: var(--primary); + background: var(--surface-3); +} + +/* 主要按钮 - 深紫实心 */ +.btn-primary { + background: var(--primary-solid); + border-color: var(--primary-solid); color: white; } .btn-primary:hover { - box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3); - transform: translateY(-2px); + background: var(--primary-solid-hover); + border-color: var(--primary-solid-hover); + box-shadow: var(--shadow-primary-md); + transform: translateY(-1px); + color: white; } +/* 成功按钮 */ .btn-success { - background: var(--gradient-cyan); - border-color: var(--cyan); - color: white; + background: var(--success); + border-color: var(--success); + color: var(--bg); } .btn-success:hover { - box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3); - transform: translateY(-2px); + background: #2DD4BF; + border-color: #2DD4BF; + box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); + transform: translateY(-1px); + color: var(--bg); } +/* 警告按钮 */ .btn-warning { - background: var(--gradient-yellow); - border-color: var(--yellow); - color: white; + background: var(--warning); + border-color: var(--warning); + color: var(--bg); } .btn-warning:hover { - box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3); - transform: translateY(-2px); + background: #FBBF24; + border-color: #FBBF24; + box-shadow: 0 0 20px rgba(245, 158, 11, 0.3); + transform: translateY(-1px); + color: var(--bg); } +/* 危险按钮 */ .btn-danger { - background: var(--gradient-danger); + background: var(--danger); border-color: var(--danger); color: white; } .btn-danger:hover { - box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3); - transform: translateY(-2px); + background: #FCA5A5; + border-color: #FCA5A5; + box-shadow: 0 0 20px rgba(251, 113, 133, 0.3); + transform: translateY(-1px); + color: white; } .btn-icon { - font-size: 20px; + font-size: 18px; margin-right: 0; vertical-align: middle; } +/* ==================== 布局 ==================== */ .page-container { - padding: 20px; - background: var(--bg-page); + padding: 24px; + background: var(--bg); min-height: 100vh; } @@ -104,7 +135,7 @@ .form-row { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; align-items: flex-end; } @@ -114,12 +145,19 @@ gap: 20px; } -@media (max-width: 768px) { +@media (max-width: 1200px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 768px) { + .stat-grid { + grid-template-columns: 1fr; + } +} + +/* Flex 工具类 */ .flex-center { display: flex; align-items: center; @@ -137,50 +175,103 @@ flex-direction: column; } +.flex-wrap { + flex-wrap: wrap; +} + +.flex-1 { + flex: 1; +} + +/* ==================== 文字颜色 ==================== */ .text-primary { color: var(--primary); } -.text-cyan { - color: var(--cyan); +.text-accent { + color: var(--accent); } .text-success { - color: var(--green); + color: var(--success); } .text-warning { - color: var(--yellow); + color: var(--warning); } .text-danger { color: var(--danger); } +.text-info { + color: var(--info); +} + .text-muted { + color: var(--text-muted); +} + +.text-secondary { color: var(--text-secondary); } +/* ==================== 背景 ==================== */ .bg-gradient-primary { background: var(--gradient-primary); } -.bg-gradient-cyan { - background: var(--gradient-cyan); +.bg-gradient-accent { + background: var(--gradient-accent); } -.border-pink { +.bg-surface { + background: var(--surface); +} + +.bg-surface-2 { + background: var(--surface-2); +} + +.bg-surface-3 { + background: var(--surface-3); +} + +/* ==================== 边框 ==================== */ +.border-default { border: 1px solid var(--border); } -.rounded-xl { - border-radius: var(--radius-xl); +.border-light { + border: 1px solid var(--border-light); +} + +.border-primary { + border: 1px solid var(--primary); +} + +.border-none { + border: none; +} + +/* ==================== 圆角 ==================== */ +.rounded-sm { + border-radius: var(--radius-sm); +} + +.rounded-md { + border-radius: var(--radius-md); } .rounded-lg { border-radius: var(--radius-lg); } +.rounded-xl { + border-radius: var(--radius-xl); +} + +/* ==================== 阴影 ==================== */ .shadow-sm { box-shadow: var(--shadow-sm); } @@ -188,3 +279,156 @@ .shadow-md { box-shadow: var(--shadow-md); } + +.shadow-lg { + box-shadow: var(--shadow-lg); +} + +.shadow-xl { + box-shadow: var(--shadow-xl); +} + +.shadow-none { + box-shadow: none; +} + +.shadow-primary { + box-shadow: var(--shadow-primary-md); +} + +/* ==================== 间距 ==================== */ +.gap-4 { + gap: 4px; +} + +.gap-8 { + gap: 8px; +} + +.gap-12 { + gap: 12px; +} + +.gap-16 { + gap: 16px; +} + +.gap-20 { + gap: 20px; +} + +/* ==================== 文本工具 ==================== */ +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.text-nowrap { + white-space: nowrap; +} + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +/* ==================== 显示 ==================== */ +.hidden { + display: none; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +/* ==================== 响应式隐藏 ==================== */ +@media (max-width: 768px) { + .hidden-mobile { + display: none !important; + } +} + +@media (min-width: 769px) { + .hidden-desktop { + display: none !important; + } +} + +/* ==================== 焦点样式 ==================== */ +.focus-primary:focus { + outline: none; + box-shadow: var(--shadow-primary-sm); + border-color: var(--primary); +} + +.focus-accent:focus { + outline: none; + box-shadow: var(--shadow-accent-sm); + border-color: var(--accent); +} + +/* ==================== 状态指示器 ==================== */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-dot--success { + background: var(--success); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); +} + +.status-dot--warning { + background: var(--warning); + box-shadow: 0 0 8px rgba(245, 158, 11, 0.5); +} + +.status-dot--danger { + background: var(--danger); + box-shadow: 0 0 8px rgba(251, 113, 133, 0.5); +} + +.status-dot--info { + background: var(--info); + box-shadow: 0 0 8px rgba(56, 189, 248, 0.5); +} + +/* ==================== 分隔线 ==================== */ +.divider { + height: 1px; + background: var(--border); + margin: 16px 0; +} + +.divider-vertical { + width: 1px; + background: var(--border); + margin: 0 16px; + align-self: stretch; +} diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css index bcf73e0..0510b1f 100644 --- a/frontend/src/styles/variables.css +++ b/frontend/src/styles/variables.css @@ -1,43 +1,87 @@ +/** + * CSS 变量定义 - 冷灰紫主题 + * 设计理念:冷灰做信息底座,克制紫色做品牌识别和交互强调 + * 参考:Material 3 颜色角色体系 + */ + :root { - --primary: #FF6B9D; - --primary-light: #FF8FB3; - --primary-dark: #FF5A8F; - - --cyan: #00D4FF; - --cyan-light: #00E5FF; - --cyan-dark: #00B8E0; - - --green: #34D399; - --yellow: #FFB800; - --danger: #FF6B6B; - --purple: #A855F7; - - --bg-page: #FAFAFA; - --bg-card: #FFFFFF; - --bg-light: #FFF9FB; - - --text-primary: #333333; - --text-secondary: #999999; - --text-muted: #666666; - - --border: #FFE4EC; - --border-light: #FFD6E3; - - --gradient-primary: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%); - --gradient-cyan: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%); - --gradient-yellow: linear-gradient(135deg, #FFB800 0%, #FFD000 100%); - --gradient-danger: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%); - + /* ==================== 背景层次 (Surface Roles) ==================== */ + --bg: #0F1117; /* 最底层背景,接近黑但不是纯黑 */ + --surface: #181C25; /* 卡片、表格、侧边栏 */ + --surface-2: #1F2430; /* 悬停状态、次级面板 */ + --surface-3: #262C3A; /* 输入框、选中行背景 */ + --border: #2E3545; /* 边框,负责把结构切出来 */ + --border-light: #3A4356; /* 稍亮的边框 */ + + /* ==================== 文字颜色 ==================== */ + --text-primary: #F5F7FA; /* 主要文字,对比度充足 */ + --text-secondary: #A5AEBD; /* 次要文字 */ + --text-muted: #7C8596; /* 弱化文字、placeholder */ + + /* ==================== 品牌紫色系 (Brand Purple) ==================== */ + /* 亮紫:用于链接、选中、图标、焦点 - "发光"和识别 */ + --primary: #927CFF; + --primary-rgb: 146, 124, 255; + --primary-hover: #A78BFA; + --primary-soft: #2A2442; /* 淡紫背景,用于选中态底色 */ + + /* 深紫:用于实心按钮,配白字更稳 - "承载文字" */ + --primary-solid: #6B4EFF; + --primary-solid-hover: #5B3DF5; + + /* ==================== 辅助色 (Accent) ==================== */ + --accent: #2DD4BF; /* 青绿色,辅助强调 */ + --accent-rgb: 45, 212, 191; + --accent-soft: #163A39; /* 淡青背景 */ + + /* ==================== 语义状态色 (Semantic Colors) ==================== */ + /* 紫色只表示"交互和品牌";绿/橙/红只表示"系统状态" */ + --success: #22C55E; + --success-rgb: 34, 197, 94; + --success-soft: #1A3A28; + + --warning: #F59E0B; + --warning-rgb: 245, 158, 11; + --warning-soft: #3D3118; + + --danger: #FB7185; + --danger-rgb: 251, 113, 133; + --danger-soft: #3D1F26; + + --info: #38BDF8; + --info-rgb: 56, 189, 248; + --info-soft: #1A3A4A; + + /* ==================== 渐变 ==================== */ + --gradient-primary: linear-gradient(135deg, #6B4EFF 0%, #927CFF 100%); + --gradient-accent: linear-gradient(135deg, #2DD4BF 0%, #38BDF8 100%); + --gradient-surface: linear-gradient(180deg, #181C25 0%, #1F2430 100%); + + /* ==================== 圆角 ==================== */ --radius-sm: 6px; - --radius-md: 8px; + --radius-md: 10px; --radius-lg: 12px; - --radius-xl: 16px; - --radius-2xl: 20px; - - --shadow-sm: 0 2px 8px rgba(255, 107, 157, 0.08); - --shadow-md: 0 4px 12px rgba(255, 107, 157, 0.15); - --shadow-lg: 0 8px 20px rgba(255, 107, 157, 0.2); - - --transition-base: all 0.3s ease; - --transition-hover: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + --radius-xl: 14px; + --radius-2xl: 16px; + + /* ==================== 阴影 (克制使用,更多靠层级和边框) ==================== */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6); + + /* 紫色光晕 - 用于焦点态 */ + --shadow-primary-sm: 0 0 0 2px rgba(146, 124, 255, 0.2); + --shadow-primary-md: 0 0 20px rgba(146, 124, 255, 0.15); + --shadow-accent-sm: 0 0 0 2px rgba(45, 212, 191, 0.2); + + /* ==================== 过渡 ==================== */ + --transition-base: all 0.2s ease; + --transition-hover: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + + /* ==================== Element Plus 专用覆盖 ==================== */ + --el-disabled-bg: #262C3A; + --el-disabled-border: #3A4356; + --el-disabled-text: #7C8596; + --el-switch-inactive: #3A4356; } diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js new file mode 100644 index 0000000..7c8fa8c --- /dev/null +++ b/frontend/src/utils/clipboard.js @@ -0,0 +1,63 @@ +import { ElMessage } from 'element-plus' + +/** + * 复制文本到剪贴板 + * @param {string} text + * @param {string} [successMsg] + * @param {string} [errorMsg] + * @returns {Promise} + */ +export async function copyToClipboard(text, successMsg, errorMsg = '复制失败呢~') { + if (!text) { + ElMessage.warning('没有可复制的内容') + return false + } + + try { + await navigator.clipboard.writeText(text) + ElMessage.success(successMsg || `已复制 ${text} 到剪贴板啦~`) + return true + } catch (error) { + console.error('复制失败:', error) + ElMessage.error(errorMsg) + return false + } +} + +/** + * 复制代理地址 + * @param {object} proxy - { ip, port } + * @returns {Promise} + */ +export async function copyProxy(proxy) { + if (!proxy?.ip || !proxy?.port) { + ElMessage.warning('代理信息不完整') + return false + } + + const text = `${proxy.ip}:${proxy.port}` + return copyToClipboard(text) +} + +/** + * 复制文本(备选方案:使用 DOM) + * @param {string} text + * @returns {boolean} + */ +export function copyTextFallback(text) { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0;' + document.body.appendChild(textarea) + + try { + textarea.select() + textarea.setSelectionRange(0, text.length) + const success = document.execCommand('copy') + document.body.removeChild(textarea) + return success + } catch { + document.body.removeChild(textarea) + return false + } +} diff --git a/frontend/src/utils/confirm.js b/frontend/src/utils/confirm.js new file mode 100644 index 0000000..c7ad5e4 --- /dev/null +++ b/frontend/src/utils/confirm.js @@ -0,0 +1,84 @@ +import { ElMessageBox } from 'element-plus' + +/** + * 确认对话框工具 + */ + +/** 默认配置 */ +const DEFAULT_CONFIG = { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' +} + +/** + * 显示确认对话框 + * @param {string} message + * @param {string} [title] + * @param {object} [config] + * @returns {Promise} 用户点击确定返回 true,取消返回 false + */ +export async function confirm(message, title = '提示', config = {}) { + try { + await ElMessageBox.confirm(message, title, { + ...DEFAULT_CONFIG, + ...config + }) + return true + } catch { + return false + } +} + +/** + * 显示删除确认对话框 + * @param {string} message + * @param {string} [itemName] - 要删除的项目名称 + * @returns {Promise} + */ +export async function confirmDelete(message, itemName = '') { + const fullMessage = itemName + ? `确定要删除${itemName}吗?此操作不可恢复。` + : message + + return confirm(fullMessage, '删除确认', { + confirmButtonText: '删除吧~', + cancelButtonText: '再等等', + type: 'warning' + }) +} + +/** + * 显示批量删除确认对话框 + * @param {number} count + * @param {string} [itemName] + * @returns {Promise} + */ +export async function confirmBatchDelete(count, itemName = '项') { + return confirm( + `确定要删除选中的 ${count} 个${itemName}吗?`, + '批量删除确认', + { + confirmButtonText: '删除吧~', + cancelButtonText: '再等等', + type: 'warning' + } + ) +} + +/** + * 显示清空确认对话框 + * @param {string} [target] + * @returns {Promise} + */ +export async function confirmClear(target = '所有数据') { + return confirm( + `确定要清空${target}吗?此操作不可恢复。`, + '清空确认', + { + confirmButtonText: '清空吧~', + cancelButtonText: '再等等', + type: 'danger' + } + ) +} diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..401f779 --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,108 @@ +/** + * 格式化工具函数 + */ + +/** + * 格式化日期时间 + * @param {string|Date|number} dateTimeStr + * @param {string} [fallback] - 无效日期时的回退文本 + * @returns {string} + */ +export function formatDateTime(dateTimeStr, fallback = '-') { + if (!dateTimeStr) return fallback + + const date = new Date(dateTimeStr) + if (isNaN(date.getTime())) return fallback + + const pad = (n) => String(n).padStart(2, '0') + + const year = date.getFullYear() + const month = pad(date.getMonth() + 1) + const day = pad(date.getDate()) + const hours = pad(date.getHours()) + const minutes = pad(date.getMinutes()) + const seconds = pad(date.getSeconds()) + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +/** + * 格式化时间(简化版) + * @param {string|Date|number} timeStr + * @param {string} [fallback] + * @returns {string} + */ +export function formatTime(timeStr, fallback = '-') { + if (!timeStr) return fallback + + const date = new Date(timeStr) + if (isNaN(date.getTime())) return fallback + + return date.toLocaleString('zh-CN') +} + +/** + * 格式化数字(添加千分位) + * @param {number} num + * @param {number} [decimals] - 小数位数 + * @returns {string} + */ +export function formatNumber(num, decimals = 0) { + if (typeof num !== 'number' || isNaN(num)) return '-' + return num.toLocaleString('zh-CN', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }) +} + +/** + * 格式化百分比 + * @param {number} value + * @param {number} [total] + * @param {number} [decimals] + * @returns {string} + */ +export function formatPercent(value, total, decimals = 1) { + if (total !== undefined) { + if (!total) return '0%' + value = (value / total) * 100 + } + return `${value.toFixed(decimals)}%` +} + +/** + * 格式化文件大小 + * @param {number} bytes + * @param {number} [decimals] + * @returns {string} + */ +export function formatFileSize(bytes, decimals = 2) { + if (bytes === 0) return '0 B' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}` +} + +/** + * 格式化时长(秒转可读文本) + * @param {number} seconds + * @returns {string} + */ +export function formatDuration(seconds) { + if (!seconds || seconds < 0) return '-' + + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + + if (hours > 0) { + return `${hours}小时${minutes}分${secs}秒` + } else if (minutes > 0) { + return `${minutes}分${secs}秒` + } else { + return `${secs}秒` + } +} diff --git a/frontend/src/views/CrawlerTasks.vue b/frontend/src/views/CrawlerTasks.vue deleted file mode 100644 index bf00a7a..0000000 --- a/frontend/src/views/CrawlerTasks.vue +++ /dev/null @@ -1,385 +0,0 @@ - - - - - diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 49a0824..13acd9b 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,133 +1,173 @@ @@ -148,42 +188,62 @@ onUnmounted(() => { margin-bottom: 20px; } +.status-row { + margin-bottom: 20px; +} + .status-card { - border-radius: var(--radius-xl); - margin-bottom: 20px; - backdrop-filter: blur(10px); + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); } -.status-card:hover { - border-color: rgba(255, 107, 157, 0.4); - box-shadow: 0 8px 32px rgba(255, 107, 157, 0.2); -} - -.status-content { - padding: 20px; -} - -.progress-bar { - margin-bottom: 20px; -} - -.progress-text { - font-size: 14px; - color: var(--primary); - font-weight: 700; - text-shadow: 0 0 10px rgba(255, 107, 157, 0.3); -} - -.status-message { - text-align: center; +.card-header { font-size: 16px; - color: var(--text-secondary); - padding: 15px; - background: rgba(255, 255, 255, 0.5); - border-radius: 12px; - border: 1px solid rgba(0, 212, 255, 0.1); font-weight: 600; - letter-spacing: 0.5px; - animation: fadeIn 0.5s ease; +} + +.card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.status-list { + display: flex; + flex-wrap: wrap; + gap: 24px; +} + +.status-item { + display: flex; + align-items: center; + gap: 12px; +} + +.status-label { + color: var(--text-secondary); + font-size: 14px; +} + +.status-value { + font-size: 18px; + font-weight: 600; + color: var(--primary); +} + +@media (max-width: 768px) { + .stats-row .el-col { + margin-bottom: 16px; + } + + .stats-row .el-col:last-child { + margin-bottom: 0; + } + + .status-list { + flex-direction: column; + gap: 16px; + } } diff --git a/frontend/src/views/Plugins.vue b/frontend/src/views/Plugins.vue index 33002d2..6af21d8 100644 --- a/frontend/src/views/Plugins.vue +++ b/frontend/src/views/Plugins.vue @@ -1,55 +1,68 @@ - + + + + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 60a4b24..f71382d 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -1,35 +1,93 @@ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index f9684cb..8cbc6b4 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,6 +5,30 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { - port: 6173 + port: 9948, + // 支持 Vue Router 的 history 模式 + historyApiFallback: true + }, + preview: { + port: 9948, + historyApiFallback: true + }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/echarts')) { + return 'echarts' + } + if (id.includes('node_modules/element-plus')) { + return 'element-plus' + } + if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) { + return 'vue-vendor' + } + } + } + }, + chunkSizeWarningLimit: 600 } }) diff --git a/plugins/fate0.py b/plugins/fate0.py index c28bcd5..d551971 100644 --- a/plugins/fate0.py +++ b/plugins/fate0.py @@ -1,6 +1,6 @@ import sys import os -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.crawler import BasePlugin from core.log import logger @@ -29,6 +29,11 @@ class Fate0Plugin(BasePlugin): port = data.get('port') protocol = data.get('type', 'http') + # 协议标准化 + protocol = protocol.lower().strip() + if protocol not in ('http', 'https', 'socks4', 'socks5'): + protocol = 'http' + if ip and port: yield ip, int(port), protocol count += 1 diff --git a/plugins/proxylist_download.py b/plugins/proxylist_download.py index e963e79..7a0f579 100644 --- a/plugins/proxylist_download.py +++ b/plugins/proxylist_download.py @@ -12,7 +12,9 @@ class ProxyListDownloadPlugin(BasePlugin): self.name = "ProxyListDownload" self.urls = [ "https://www.proxy-list.download/api/v1/get?type=http", - "https://www.proxy-list.download/api/v1/get?type=https" + "https://www.proxy-list.download/api/v1/get?type=https", + "https://www.proxy-list.download/api/v1/get?type=socks4", + "https://www.proxy-list.download/api/v1/get?type=socks5" ] async def parse(self, html): @@ -24,6 +26,16 @@ class ProxyListDownloadPlugin(BasePlugin): lines = html.split('\n') count = 0 + # 根据 URL 判断协议类型 + if 'type=socks4' in self.current_url: + protocol = 'socks4' + elif 'type=socks5' in self.current_url: + protocol = 'socks5' + elif 'type=https' in self.current_url: + protocol = 'https' + else: + protocol = 'http' + for line in lines: line = line.strip() if not line: @@ -34,7 +46,6 @@ class ProxyListDownloadPlugin(BasePlugin): if len(parts) >= 2: ip = parts[0] port = parts[1] - protocol = 'http' if 'type=http' in self.current_url else 'https' yield ip, int(port), protocol count += 1 diff --git a/requirements.txt b/requirements.txt index 7b2a148..b8d58ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ uvicorn[standard]==0.24.0 websockets==12.0 aiosqlite==0.19.0 aiohttp==3.9.1 +aiohttp-socks==0.9.1 beautifulsoup4==4.12.3 lxml==5.1.0 diff --git a/script/README.md b/script/README.md deleted file mode 100644 index 562d086..0000000 --- a/script/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Proxy Pool Startup Scripts - -## File List - -- **start_backend.bat** - Start backend service -- **start_frontend.bat** - Start frontend service -- **stop_all.bat** - Stop all services - -## Quick Start - -### Start Services Separately -- Backend: Double-click `start_backend.bat` -- Frontend: Double-click `start_frontend.bat` - -### Stop Services -Double-click `stop_all.bat` to stop all services - -## Script Features - -### Smart Process Management -- Automatically detect and stop running processes -- Prevent duplicate startup of multiple instances -- Automatically clean up port conflicts - -### Log Management -- All output written to log files -- Backend log: `backend.log` -- Frontend log: `frontend.log` -- Logs include timestamps for troubleshooting - -### PID Management -- Automatically record process ID to PID files -- Facilitates subsequent service stopping -- Automatically clean up PID files after process stops - -### Port Cleanup -- Automatically detect and clean up port conflicts -- Backend port: 3000 -- Frontend port: 8080 - -## Access Addresses - -After successful startup: -- Backend API: http://localhost:3000 -- Frontend UI: http://localhost:8080 - -## Manual Operations - -### View Logs -```bash -# View backend log -type backend.log - -# View frontend log -type frontend.log -``` - -### Manual Stop Process -```bash -# View PID file content -type backend.pid -type frontend.pid - -# Stop process using PID -taskkill /F /PID -``` - -### Check Port Usage -```bash -# Check backend port -netstat -ano | findstr :3000 - -# Check frontend port -netstat -ano | findstr :8080 -``` - -## Log Examples - -### backend.log -``` -[13:30:15.00] ======================================== -[13:30:15.00] Starting backend service... -INFO: Started server process [12345] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://0.0.0.0:3000 -``` - -### frontend.log -``` -[13:30:20.00] ======================================== -[13:30:20.00] Starting frontend service... -VITE v5.0.0 ready in 1234 ms - -➜ Local: http://localhost:8080/ -➜ Network: use --host to expose -``` - -## Notes - -1. **First-time frontend startup**: If dependencies are not installed, the script will automatically run `npm install` -2. **Virtual environment**: Ensure backend uses Python virtual environment (venv) -3. **Firewall**: Ensure firewall allows ports 3000 and 8080 -4. **Antivirus**: Some antivirus software may block scripts, need to add to whitelist - -## Troubleshooting - -### Backend Won't Start -1. Check if Python virtual environment is correctly installed -2. View `backend.log` log file -3. Confirm port 3000 is not in use -4. Check if dependency packages are complete: `venv\Scripts\pip list` - -### Frontend Won't Start -1. Check if Node.js is installed: `node --version` -2. View `frontend.log` log file -3. Confirm port 8080 is not in use -4. Manually install dependencies: Enter frontend directory and run `npm install` - -### Process Won't Stop -1. Manually find process: `tasklist | findstr python` -2. Force stop: `taskkill /F /IM python.exe` -3. Check port: `netstat -ano | findstr :3000` - -## Advanced Usage - -### Modify Ports -- Backend: Modify port number in `api_server.py` -- Frontend: Modify port number in `vite.config.js` -- After modification, need to sync update port checking logic in scripts - -### Custom Log Location -- Modify `LOG_FILE` variable in scripts -- Ensure directory exists and has write permissions - -## Technical Support - -If you encounter issues, please check: -1. Log files (backend.log, frontend.log) -2. PID files (backend.pid, frontend.pid) -3. Port usage (netstat -ano) -4. Process list (tasklist) diff --git a/script/start.bat b/script/start.bat index 22a5053..701c8a1 100644 --- a/script/start.bat +++ b/script/start.bat @@ -1,9 +1,95 @@ @echo off chcp 65001 >nul -setlocal -cd /d %~dp0 +echo === ProxyPool Startup === +echo. -REM Launch via PowerShell to avoid encoding issues with Chinese characters -powershell -ExecutionPolicy Bypass -File start.ps1 +set "ROOT_PATH=%~dp0.." +set "BACKEND_PORT=9949" +set "FRONTEND_PORT=9948" -timeout /t 3 +REM 1. Clean processes on ports +echo [1/4] Cleaning old processes... +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%BACKEND_PORT%" ^| findstr "LISTENING"') do ( + taskkill /F /PID %%a >nul 2>&1 + echo Stopped port %BACKEND_PORT% (PID: %%a) +) +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%FRONTEND_PORT%" ^| findstr "LISTENING"') do ( + taskkill /F /PID %%a >nul 2>&1 + echo Stopped port %FRONTEND_PORT% (PID: %%a) +) +echo Cleanup complete! +echo. + +REM 2. Start Backend +echo [2/4] Starting backend (FastAPI)... +if exist "%ROOT_PATH%\venv\Scripts\python.exe" ( + set "PYTHON_PATH=%ROOT_PATH%\venv\Scripts\python.exe" + echo Using venv +) else ( + set "PYTHON_PATH=python" + echo Using system Python +) + +cd /d "%ROOT_PATH%" +set "PYTHONIOENCODING=utf-8" + +REM Clear old logs +if exist "%ROOT_PATH%\logs\backend_startup.log" del /f "%ROOT_PATH%\logs\backend_startup.log" >nul 2>&1 +if exist "%ROOT_PATH%\logs\backend_error.log" del /f "%ROOT_PATH%\logs\backend_error.log" >nul 2>&1 + +REM Start backend +start /B "" "%PYTHON_PATH%" -u api_server.py >"%ROOT_PATH%\logs\backend_startup.log" 2>"%ROOT_PATH%\logs\backend_error.log" +echo Backend started +echo. + +REM 3. Wait for backend +echo [3/4] Waiting for backend... +set RETRY_COUNT=0 +set BACKEND_READY=0 + +:WAIT_LOOP +if %RETRY_COUNT% geq 10 goto WAIT_DONE +timeout /t 2 /nobreak >nul +set /a RETRY_COUNT+=1 + +ping -n 1 127.0.0.1 -w 500 >nul +timeout /t 1 /nobreak >nul + +REM Try to connect to backend +powershell -Command "try { $r = Invoke-RestMethod -Uri 'http://127.0.0.1:9949/' -TimeoutSec 2 -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1 +if %errorlevel% equ 0 ( + set BACKEND_READY=1 + goto WAIT_DONE +) +echo Waiting... (%RETRY_COUNT%/10) + +if exist "%ROOT_PATH%\logs\backend_startup.log" ( + for /f "delims=" %%i in ('powershell -Command "Get-Content '%ROOT_PATH%\logs\backend_startup.log' -Tail 1" 2^>nul') do ( + echo Log: %%i + ) +) +goto WAIT_LOOP + +:WAIT_DONE +if %BACKEND_READY% equ 0 ( + echo. + echo Backend failed to start! + echo Check error log: %ROOT_PATH%\logs\backend_error.log + pause + exit /b 1 +) +echo Backend is ready! +echo. + +REM 4. Start Frontend +echo [4/4] Starting frontend (Vite)... +start /B "" cmd /c "cd /d "%ROOT_PATH%\frontend" && npm run dev" >nul 2>&1 +echo Frontend started +echo. + +echo === All services started === +echo Backend: http://127.0.0.1:9949 +echo Frontend: http://localhost:9948 +echo. +echo Please open frontend in browser +timeout /t 5 >nul diff --git a/script/start.ps1 b/script/start.ps1 deleted file mode 100644 index 4a5489e..0000000 --- a/script/start.ps1 +++ /dev/null @@ -1,109 +0,0 @@ -# ProxyPool Startup Script -$rootPath = Split-Path $PSScriptRoot -Parent - -Write-Host "=== ProxyPool Startup ===" -ForegroundColor Cyan -Write-Host "" - -# 1. Clean processes on ports 8923 and 6173 -Write-Host "[1/4] Cleaning old processes..." -ForegroundColor Cyan -$ports = @(8923, 6173) -foreach ($port in $ports) { - try { - $conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue - if ($conn) { - $processId = $conn.OwningProcess - Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue - Write-Host " Stopped port $port (PID: $processId)" -ForegroundColor Gray - } - } catch {} -} -Write-Host " Cleanup complete!" -ForegroundColor Green -Write-Host "" - -# 2. Start Backend (FastAPI) -Write-Host "[2/4] Starting backend (FastAPI)..." -ForegroundColor Cyan - -$venvPython = "$rootPath\venv\Scripts\python.exe" -if (Test-Path $venvPython) { - $pythonPath = $venvPython - Write-Host " Using venv: $venvPython" -ForegroundColor Green -} else { - $pythonPath = (Get-Command python).Source - Write-Host " Using system Python: $pythonPath" -ForegroundColor Yellow -} - -$env:PYTHONIOENCODING = "utf-8" - -$backendLog = "$rootPath\logs\backend_startup.log" -$backendErr = "$rootPath\logs\backend_error.log" - -# Clear old logs -if (Test-Path $backendLog) { Remove-Item $backendLog -Force } -if (Test-Path $backendErr) { Remove-Item $backendErr -Force } - -# Start backend with -u flag for unbuffered output and redirect logs -$backendProcess = Start-Process -FilePath $pythonPath -ArgumentList "-u", "api_server.py" -WorkingDirectory "$rootPath" -RedirectStandardOutput $backendLog -RedirectStandardError $backendErr -WindowStyle Hidden -PassThru - -Write-Host " Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green -Write-Host "" - -# 3. Wait for backend to be ready (max 10 seconds) -Write-Host "[3/4] Waiting for backend..." -ForegroundColor Cyan -$maxRetries = 5 -$retryCount = 0 -$backendReady = $false - -while (-not $backendReady -and $retryCount -lt $maxRetries) { - Start-Sleep -Seconds 2 - $retryCount++ - - try { - $response = Invoke-RestMethod -Uri "http://127.0.0.1:8923/" -Method Get -TimeoutSec 2 -ErrorAction Stop - if ($response) { - $backendReady = $true - Write-Host " Backend is ready!" -ForegroundColor Green - } - } catch { - $errMessage = $_.Exception.Message - Write-Host " Waiting... ($retryCount/$maxRetries)" -ForegroundColor Yellow - - if (Test-Path $backendLog) { - $lastLog = Get-Content $backendLog -Tail 1 -ErrorAction SilentlyContinue - if ($lastLog) { Write-Host " Log: $lastLog" -ForegroundColor DarkGray } - } - - if ($backendProcess.HasExited) { - Write-Host " Backend process exited!" -ForegroundColor Red - Write-Host " Exit code: $($backendProcess.ExitCode)" -ForegroundColor Red - if (Test-Path $backendErr) { - Write-Host "" -ForegroundColor Red - Write-Host "Error log:" -ForegroundColor Red - Get-Content $backendErr -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor Red } - } - $backendReady = $false - break - } - } -} - -if (-not $backendReady) { - Write-Host "" -ForegroundColor Red - Write-Host "Backend failed to start!" -ForegroundColor Red - Write-Host "Check error log: $backendErr" -ForegroundColor Red - pause - exit -} - -Write-Host "" - -# 4. Start Frontend (Vite) -Write-Host "[4/4] Starting frontend (Vite)..." -ForegroundColor Cyan -Start-Process -FilePath "cmd" -ArgumentList "/c npm run dev" -WorkingDirectory "$rootPath\frontend" -WindowStyle Hidden -Write-Host " Frontend started" -ForegroundColor Green -Write-Host "" - -Write-Host "=== All services started ===" -ForegroundColor Cyan -Write-Host "Backend: http://127.0.0.1:8923" -ForegroundColor Green -Write-Host "Frontend: http://localhost:6173" -ForegroundColor Green -Write-Host "" -Write-Host "Please open frontend in browser" -ForegroundColor Magenta diff --git a/script/stop.bat b/script/stop.bat index 1b17e52..b65e393 100644 --- a/script/stop.bat +++ b/script/stop.bat @@ -1,8 +1,41 @@ @echo off chcp 65001 >nul -setlocal -cd /d %~dp0 +echo === Stopping ProxyPool Services === +echo. -powershell -ExecutionPolicy Bypass -File stop.ps1 +set "BACKEND_PORT=9949" +set "FRONTEND_PORT=9948" +set "STOPPED_COUNT=0" + +echo [1/2] Stopping processes on ports %BACKEND_PORT% and %FRONTEND_PORT%... + +REM Stop backend port +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%BACKEND_PORT%" ^| findstr "LISTENING"') do ( + for /f "tokens=1" %%b in ('tasklist /FI "PID eq %%a" ^| findstr "%%a"') do ( + taskkill /F /PID %%a >nul 2>&1 + echo Stopped port %BACKEND_PORT% (PID: %%a, Process: %%b) + set /a STOPPED_COUNT+=1 + ) +) + +REM Stop frontend port +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%FRONTEND_PORT%" ^| findstr "LISTENING"') do ( + for /f "tokens=1" %%b in ('tasklist /FI "PID eq %%a" ^| findstr "%%a"') do ( + taskkill /F /PID %%a >nul 2>&1 + echo Stopped port %FRONTEND_PORT% (PID: %%a, Process: %%b) + set /a STOPPED_COUNT+=1 + ) +) + +echo Stopped %STOPPED_COUNT% process(es) +echo. + +echo [2/2] Waiting for processes to fully stop... +timeout /t 2 /nobreak >nul + +echo. +echo === Done === +echo All services have been stopped. +echo. pause diff --git a/script/stop.ps1 b/script/stop.ps1 deleted file mode 100644 index ba16eb4..0000000 --- a/script/stop.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -# ProxyPool Stop Script -$rootPath = Split-Path $PSScriptRoot -Parent - -Write-Host "=== Stopping ProxyPool Services ===" -ForegroundColor Cyan -Write-Host "" - -Write-Host "[1/2] Stopping processes on ports 8923 and 6173..." -ForegroundColor Cyan -$ports = @(8923, 6173) -$stoppedCount = 0 - -foreach ($port in $ports) { - try { - $conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue - if ($conn) { - $processId = $conn.OwningProcess - - try { - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($process) { - $processName = $process.ProcessName - Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue - - Write-Host " Stopped port $port (PID: $processId, Process: $processName)" -ForegroundColor Gray - $stoppedCount++ - } - } catch { - Write-Host " Warning: Could not stop process on port $port (PID: $processId)" -ForegroundColor Yellow - } - } else { - Write-Host " Port ${port}: No process found" -ForegroundColor DarkGray - } - } catch { - Write-Host " Error checking port ${port}: $($_.Exception.Message)" -ForegroundColor Red - } -} - -Write-Host " Stopped $stoppedCount process(es)" -ForegroundColor Green -Write-Host "" - -Write-Host "[2/2] Waiting for processes to fully stop..." -ForegroundColor Cyan -Start-Sleep -Seconds 2 - -Write-Host "" -Write-Host "=== Done ===" -ForegroundColor Cyan -Write-Host "All services have been stopped." -ForegroundColor Green -Write-Host "" diff --git a/tasks_manager.py b/tasks_manager.py deleted file mode 100644 index 569f071..0000000 --- a/tasks_manager.py +++ /dev/null @@ -1,232 +0,0 @@ -import asyncio -from datetime import datetime -from core.plugin_manager import PluginManager -from core.sqlite import SQLiteManager -from core.validator import ProxyValidator -from core.log import logger -from typing import Optional, Callable - -class TasksManager: - def __init__(self): - self.is_running = False - self.stop_requested = False - self.current_task = None - self.validator_tasks = [] - self.progress_callback = None - self.status_callback = None - self.proxy_queue = asyncio.Queue(maxsize=500) - self.stats = { - 'total_found': 0, - 'total_verified': 0, - 'start_time': None, - 'current_url': None, - 'plugins': [] - } - self.estimated_total = 1000 - - def set_callbacks(self, progress_callback: Optional[Callable] = None, status_callback: Optional[Callable] = None): - self.progress_callback = progress_callback - self.status_callback = status_callback - - async def _notify_progress(self, data: dict): - if self.progress_callback: - data['timestamp'] = datetime.now().isoformat() - - if 'found' in data and 'verified' in data: - data['success_rate'] = round((data['verified'] / data['found'] * 100), 2) if data['found'] > 0 else 0 - - if 'found' in data: - data['current'] = data['found'] + self.stats['total_verified'] - data['total'] = self.estimated_total - - await self.progress_callback(data) - - async def _notify_status(self, status: str, message: str): - if self.status_callback: - await self.status_callback({ - 'status': status, - 'message': message, - 'timestamp': datetime.now().isoformat() - }) - - async def run_crawler(self): - await self._notify_status('crawling_start', '开始爬取代理啦~') - manager = PluginManager() - - count = 0 - self.stats['plugins'] = [plugin.name for plugin in manager.plugins] - - async for ip, port, protocol in manager.run_all(): - if self.stop_requested: - logger.info("爬虫收到停止信号") - break - await self.proxy_queue.put((ip, port, protocol)) - count += 1 - self.stats['total_found'] = count - - if count % 5 == 0: - await self._notify_progress({ - 'type': 'crawling', - 'found': count, - 'verified': self.stats['total_verified'], - 'current_proxy': f"{ip}:{port}", - 'message': f'正在爬取:已发现 {count} 个代理' - }) - - if self.stop_requested: - await self._notify_status('stopped', '爬虫已停止啦~') - else: - await self._notify_status('crawling_done', f'爬虫抓取完成啦,共发现 {count} 个潜在代理~') - logger.info(f"爬虫抓取阶段完成,共发现 {count} 个潜在代理。") - - async def run_validator(self, db: SQLiteManager, validator: ProxyValidator): - await self._notify_status('validating_start', '开始验证代理啦~') - verified_count = 0 - - while True: - proxy = await self.proxy_queue.get() - if proxy is None or self.stop_requested: - self.proxy_queue.task_done() - break - - ip, port, protocol = proxy - try: - is_valid, latency = await validator.validate(ip, port, protocol) - if is_valid: - logger.info(f"验证通过: {ip}:{port} ({protocol}) - 延迟: {latency}ms") - await db.insert_proxy(ip, port, protocol) - verified_count += 1 - self.stats['total_verified'] = verified_count - - await self._notify_progress({ - 'type': 'validating', - 'found': self.stats['total_found'], - 'verified': verified_count, - 'current_proxy': f"{ip}:{port}", - 'message': f'正在验证:已验证 {verified_count} 个代理' - }) - else: - logger.info(f"验证失败: {ip}:{port} ({protocol})") - except Exception as e: - logger.error(f"验证器异常: {e}") - finally: - self.proxy_queue.task_done() - - if self.stop_requested: - await self._notify_status('stopped', '验证器已停止啦~') - elif verified_count > 0: - await self._notify_status('validating_done', f'验证完成啦,入库 {verified_count} 个代理~') - logger.info(f"验证协程完成,入库 {verified_count} 个代理。") - - async def start_task(self, db: SQLiteManager, num_validators: int = 50): - if self.is_running: - await self._notify_status('error', '任务正在运行中呢~') - return False - - self.is_running = True - self.stop_requested = False - self.stats = { - 'total_found': 0, - 'total_verified': 0, - 'start_time': datetime.now().isoformat(), - 'current_url': None, - 'plugins': [] - } - - await self._notify_status('connecting', '正在连接插件源...') - await self._notify_status('starting', '正在启动爬虫...') - await self._notify_status('running', '任务开始啦~') - - async with ProxyValidator(max_concurrency=200) as validator: - crawler_task = asyncio.create_task(self.run_crawler()) - self.validator_tasks = [asyncio.create_task(self.run_validator(db, validator)) for _ in range(num_validators)] - - await crawler_task - - for _ in range(num_validators): - await self.proxy_queue.put(None) - - await self.proxy_queue.join() - await asyncio.gather(*self.validator_tasks, return_exceptions=True) - - total = await db.count_proxies() - self.is_running = False - self.stop_requested = False - - if not self.stop_requested: - await self._notify_status('completed', f'任务完成啦,当前池内总数: {total}~') - await self._notify_progress({ - 'type': 'completed', - 'found': self.stats['total_found'], - 'verified': self.stats['total_verified'], - 'total': total - }) - - logger.info(f"=== 运行结束,当前池内总数: {total} ===") - return True - - async def stop_task(self): - if not self.is_running: - return False - - self.stop_requested = True - - # 取消所有验证器任务 - for task in self.validator_tasks: - if not task.done(): - task.cancel() - - # 清空队列并添加停止信号 - while not self.proxy_queue.empty(): - try: - self.proxy_queue.get_nowait() - except asyncio.QueueEmpty: - break - - # 添加停止信号到队列 - for _ in range(len(self.validator_tasks)): - await self.proxy_queue.put(None) - - await self._notify_status('stopped', '任务已停止~') - logger.info("任务被手动停止") - return True - - def get_stats(self) -> dict: - return self.stats.copy() - - def is_task_running(self) -> bool: - return self.is_running - -class ScheduledTasks: - def __init__(self, tasks_manager: TasksManager): - self.tasks_manager = tasks_manager - self.scheduler_task = None - self.is_scheduled = False - self.interval_minutes = 60 - - async def scheduler(self): - from core.sqlite import SQLiteManager - - while self.is_scheduled: - try: - db = SQLiteManager() - await db.init_db() - - await self.tasks_manager.start_task(db, num_validators=50) - - await asyncio.sleep(self.interval_minutes * 60) - except Exception as e: - logger.error(f"定时任务异常: {e}") - await asyncio.sleep(60) - - def start_scheduled(self, interval_minutes: int = 60): - self.interval_minutes = interval_minutes - self.is_scheduled = True - self.scheduler_task = asyncio.create_task(self.scheduler()) - logger.info(f"定时任务已启动,间隔: {interval_minutes} 分钟") - - def stop_scheduled(self): - self.is_scheduled = False - if self.scheduler_task: - self.scheduler_task.cancel() - logger.info("定时任务已停止") diff --git a/test_results.json b/test_results.json deleted file mode 100644 index ae99e39..0000000 --- a/test_results.json +++ /dev/null @@ -1,724 +0,0 @@ -{ - "summary": { - "total_tests": 29, - "passed_tests": 29, - "failed_tests": 0, - "pass_rate": 100.0, - "timestamp": "2026-01-27T23:11:59.292107" - }, - "results": [ - { - "test_name": "GET / - 根路径访问", - "passed": true, - "message": "根路径返回正常", - "timestamp": "2026-01-27T23:11:21.092484", - "response_data": { - "message": "欢迎使用代理池API~", - "status": "running", - "data": null - } - }, - { - "test_name": "GET /health - 健康检查", - "passed": true, - "message": "服务健康状态正常", - "timestamp": "2026-01-27T23:11:23.104732", - "response_data": { - "status": "healthy", - "timestamp": "2026-01-27T23:11:23.104732", - "database": "connected", - "version": "1.0.0" - } - }, - { - "test_name": "GET /api/stats - 统计信息", - "passed": true, - "message": "成功获取统计信息,总数: 220", - "timestamp": "2026-01-27T23:11:25.116587", - "response_data": { - "code": 200, - "message": "获取统计信息成功啦~", - "data": { - "total": 220, - "available": 220, - "avg_score": 10.0, - "http_count": 147, - "https_count": 0, - "socks4_count": 73, - "socks5_count": 0, - "today_new": 220 - } - } - }, - { - "test_name": "GET /api/stats - 字段完整性", - "passed": true, - "message": "所有必需字段都存在", - "timestamp": "2026-01-27T23:11:25.116587", - "response_data": null - }, - { - "test_name": "POST /api/proxies - 基本分页查询", - "passed": true, - "message": "成功获取代理列表,共 220 条", - "timestamp": "2026-01-27T23:11:27.126629", - "response_data": { - "code": 200, - "message": "获取代理列表成功啦~", - "data": { - "list": [ - { - "ip": "120.26.68.107", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:23.000Z" - }, - { - "ip": "169.61.46.13", - "port": 7563, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:23.000Z" - }, - { - "ip": "35.209.198.222", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:21.000Z" - }, - { - "ip": "34.81.160.132", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:21.000Z" - }, - { - "ip": "176.126.164.213", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:19.000Z" - }, - { - "ip": "8.220.136.174", - "port": 5060, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:15.000Z" - }, - { - "ip": "47.86.53.59", - "port": 8080, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:14.000Z" - }, - { - "ip": "40.177.106.156", - "port": 8080, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:10.000Z" - }, - { - "ip": "47.56.110.204", - "port": 8989, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:10.000Z" - }, - { - "ip": "193.53.127.169", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:09.000Z" - }, - { - "ip": "163.172.167.48", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:08.000Z" - }, - { - "ip": "36.67.136.27", - "port": 5678, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:08.000Z" - }, - { - "ip": "162.223.90.144", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:08.000Z" - }, - { - "ip": "104.197.218.238", - "port": 8080, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:59.000Z" - }, - { - "ip": "211.230.49.122", - "port": 3128, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:54.000Z" - }, - { - "ip": "159.195.84.83", - "port": 443, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:53.000Z" - }, - { - "ip": "172.237.73.24", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:52.000Z" - }, - { - "ip": "81.169.213.169", - "port": 8888, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:47.000Z" - }, - { - "ip": "8.220.141.8", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:46.000Z" - }, - { - "ip": "31.28.4.192", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:45.000Z" - } - ], - "total": 220, - "page": 1, - "page_size": 20 - } - } - }, - { - "test_name": "POST /api/proxies - 基本分页查询 - 字段完整性", - "passed": true, - "message": "代理数据字段完整", - "timestamp": "2026-01-27T23:11:27.126629", - "response_data": null - }, - { - "test_name": "POST /api/proxies - 带协议筛选", - "passed": true, - "message": "成功获取代理列表,共 147 条", - "timestamp": "2026-01-27T23:11:29.137101", - "response_data": { - "code": 200, - "message": "获取代理列表成功啦~", - "data": { - "list": [ - { - "ip": "47.89.159.212", - "port": 1080, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:27.000Z" - }, - { - "ip": "200.59.186.177", - "port": 999, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:26.000Z" - }, - { - "ip": "34.76.142.148", - "port": 80, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:22.000Z" - }, - { - "ip": "101.47.16.15", - "port": 7890, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:22.000Z" - }, - { - "ip": "212.114.194.72", - "port": 80, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:21.000Z" - }, - { - "ip": "8.213.156.191", - "port": 221, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:21.000Z" - }, - { - "ip": "191.101.1.116", - "port": 80, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:20.000Z" - }, - { - "ip": "51.141.175.118", - "port": 80, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:19.000Z" - }, - { - "ip": "213.73.25.230", - "port": 8080, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:19.000Z" - }, - { - "ip": "50.203.147.152", - "port": 80, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:10:17.000Z" - } - ], - "total": 147, - "page": 1, - "page_size": 10 - } - } - }, - { - "test_name": "POST /api/proxies - 带协议筛选 - 字段完整性", - "passed": true, - "message": "代理数据字段完整", - "timestamp": "2026-01-27T23:11:29.137101", - "response_data": null - }, - { - "test_name": "POST /api/proxies - 带分数筛选", - "passed": true, - "message": "成功获取代理列表,共 0 条", - "timestamp": "2026-01-27T23:11:31.148007", - "response_data": { - "code": 200, - "message": "获取代理列表成功啦~", - "data": { - "list": [], - "total": 0, - "page": 1, - "page_size": 10 - } - } - }, - { - "test_name": "POST /api/proxies - 带分数筛选 - 空列表", - "passed": true, - "message": "代理列表为空(可能数据库无数据)", - "timestamp": "2026-01-27T23:11:31.148007", - "response_data": { - "code": 200, - "message": "获取代理列表成功啦~", - "data": { - "list": [], - "total": 0, - "page": 1, - "page_size": 10 - } - } - }, - { - "test_name": "POST /api/proxies - 带排序", - "passed": true, - "message": "成功获取代理列表,共 221 条", - "timestamp": "2026-01-27T23:11:33.159151", - "response_data": { - "code": 200, - "message": "获取代理列表成功啦~", - "data": { - "list": [ - { - "ip": "212.114.194.75", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:28.000Z" - }, - { - "ip": "35.209.198.222", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:21.000Z" - }, - { - "ip": "40.177.106.156", - "port": 8080, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:10.000Z" - }, - { - "ip": "163.172.167.48", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:11:08.000Z" - }, - { - "ip": "159.195.84.83", - "port": 443, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:53.000Z" - }, - { - "ip": "31.28.4.192", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:45.000Z" - }, - { - "ip": "108.170.12.10", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:44.000Z" - }, - { - "ip": "35.180.127.14", - "port": 1001, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:42.000Z" - }, - { - "ip": "139.162.200.213", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:42.000Z" - }, - { - "ip": "154.90.48.76", - "port": 80, - "protocol": "socks4", - "score": 10, - "last_check": "2026-01-27T15:10:38.000Z" - } - ], - "total": 221, - "page": 1, - "page_size": 10 - } - } - }, - { - "test_name": "POST /api/proxies - 带排序 - 字段完整性", - "passed": true, - "message": "代理数据字段完整", - "timestamp": "2026-01-27T23:11:33.159151", - "response_data": null - }, - { - "test_name": "POST /api/proxies - 参数验证测试 - 无效协议", - "passed": true, - "message": "参数验证失败,符合预期", - "timestamp": "2026-01-27T23:11:35.168328", - "response_data": { - "detail": [ - { - "type": "value_error", - "loc": [ - "body", - "protocol" - ], - "msg": "Value error, 协议类型必须是 http, https, socks4 或 socks5", - "input": "invalid", - "ctx": { - "error": {} - }, - "url": "https://errors.pydantic.dev/2.12/v/value_error" - } - ] - } - }, - { - "test_name": "POST /api/proxies - 参数验证测试 - page为0", - "passed": true, - "message": "参数验证失败,符合预期", - "timestamp": "2026-01-27T23:11:37.176455", - "response_data": { - "detail": [ - { - "type": "greater_than_equal", - "loc": [ - "body", - "page" - ], - "msg": "Input should be greater than or equal to 1", - "input": 0, - "ctx": { - "ge": 1 - }, - "url": "https://errors.pydantic.dev/2.12/v/greater_than_equal" - } - ] - } - }, - { - "test_name": "POST /api/proxies - 参数验证测试 - page_size超过100", - "passed": true, - "message": "参数验证失败,符合预期", - "timestamp": "2026-01-27T23:11:39.186465", - "response_data": { - "detail": [ - { - "type": "less_than_equal", - "loc": [ - "body", - "page_size" - ], - "msg": "Input should be less than or equal to 100", - "input": 101, - "ctx": { - "le": 100 - }, - "url": "https://errors.pydantic.dev/2.12/v/less_than_equal" - } - ] - } - }, - { - "test_name": "GET /api/proxies/random - 获取随机代理", - "passed": true, - "message": "成功获取随机代理: 176.126.103.194:44214", - "timestamp": "2026-01-27T23:11:41.196335", - "response_data": { - "code": 200, - "message": "获取随机代理成功啦~", - "data": { - "ip": "176.126.103.194", - "port": 44214, - "protocol": "http", - "score": 10, - "last_check": "2026-01-27T15:08:12.000Z" - } - } - }, - { - "test_name": "GET /api/proxies/有效代理", - "passed": true, - "message": "代理不存在(符合预期)", - "timestamp": "2026-01-27T23:11:43.202256", - "response_data": { - "code": 404, - "message": "代理不存在呢~", - "data": null - } - }, - { - "test_name": "GET /api/proxies/不存在的代理", - "passed": true, - "message": "代理不存在(符合预期)", - "timestamp": "2026-01-27T23:11:45.210946", - "response_data": { - "code": 404, - "message": "代理不存在呢~", - "data": null - } - }, - { - "test_name": "GET /api/proxies/export/csv - 导出CSV格式", - "passed": true, - "message": "成功导出CSV格式,内容长度: 552", - "timestamp": "2026-01-27T23:11:47.221104", - "response_data": { - "content_length": 552 - } - }, - { - "test_name": "GET /api/proxies/export/csv - CSV格式验证", - "passed": true, - "message": "CSV格式正确,包含表头", - "timestamp": "2026-01-27T23:11:47.221104", - "response_data": null - }, - { - "test_name": "GET /api/proxies/export/txt - 导出TXT格式", - "passed": true, - "message": "成功导出TXT格式,内容长度: 184", - "timestamp": "2026-01-27T23:11:49.226991", - "response_data": { - "content_length": 184 - } - }, - { - "test_name": "GET /api/proxies/export/txt - TXT格式验证", - "passed": true, - "message": "TXT格式正确", - "timestamp": "2026-01-27T23:11:49.228522", - "response_data": null - }, - { - "test_name": "GET /api/proxies/export/json - 导出JSON格式", - "passed": true, - "message": "成功导出JSON格式,内容长度: 1260", - "timestamp": "2026-01-27T23:11:51.242429", - "response_data": { - "content_length": 1260 - } - }, - { - "test_name": "GET /api/proxies/export/json - JSON格式验证", - "passed": true, - "message": "JSON格式正确", - "timestamp": "2026-01-27T23:11:51.244593", - "response_data": null - }, - { - "test_name": "GET /api/proxies/export/invalid - 无效格式测试", - "passed": true, - "message": "正确返回400错误", - "timestamp": "2026-01-27T23:11:53.258979", - "response_data": null - }, - { - "test_name": "GET /api/crawler/status - 获取爬虫状态", - "passed": true, - "message": "爬虫状态: 运行中", - "timestamp": "2026-01-27T23:11:55.270148", - "response_data": { - "code": 200, - "message": "获取爬虫状态成功啦~", - "data": { - "running": true, - "stats": { - "total_found": 5524, - "total_verified": 4, - "start_time": "2026-01-27T23:06:12.013714", - "current_url": null, - "plugins": [ - "IP3366", - "89免费代理", - "快代理", - "ProxyListDownload", - "SpeedX代理源", - "云代理" - ] - } - } - } - }, - { - "test_name": "GET /api/scheduler - 获取定时任务状态", - "passed": true, - "message": "定时任务状态: 未启用", - "timestamp": "2026-01-27T23:11:57.282485", - "response_data": { - "code": 200, - "message": "获取定时任务状态成功啦~", - "data": { - "enabled": false, - "interval_minutes": 60 - } - } - }, - { - "test_name": "GET /api/plugins - 获取插件列表", - "passed": true, - "message": "成功获取插件列表,共 6 个插件", - "timestamp": "2026-01-27T23:11:59.290536", - "response_data": { - "code": 200, - "message": "获取插件列表成功啦~", - "data": { - "plugins": [ - { - "id": "IP3366", - "name": "IP3366", - "enabled": true, - "description": "从IP3366网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - }, - { - "id": "89免费代理", - "name": "89免费代理", - "enabled": true, - "description": "从89免费代理网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - }, - { - "id": "快代理", - "name": "快代理", - "enabled": true, - "description": "从快代理网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - }, - { - "id": "ProxyListDownload", - "name": "ProxyListDownload", - "enabled": true, - "description": "从ProxyListDownload网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - }, - { - "id": "SpeedX代理源", - "name": "SpeedX代理源", - "enabled": true, - "description": "从SpeedX代理源网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - }, - { - "id": "云代理", - "name": "云代理", - "enabled": true, - "description": "从云代理网站爬取代理", - "last_run": null, - "success_count": 0, - "failure_count": 0 - } - ] - } - } - }, - { - "test_name": "GET /api/plugins - 插件字段完整性", - "passed": true, - "message": "插件数据字段完整", - "timestamp": "2026-01-27T23:11:59.290536", - "response_data": null - } - ] -} \ No newline at end of file