diff --git a/.env.example b/.env.example deleted file mode 100644 index 43869b5..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# 本项目的运行参数已改为由 JSON 配置文件提供,不再使用环境变量。 -# -# 后端:编辑项目根目录下的 config/app.json -# 前端 dev/build:编辑项目根目录下的 config/webui.json(与 WebUI 同级的 config 目录) -# -# 测试专用配置:config/app.test.json(pytest 会自动选用,勿与生产库共用 db_path) diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 84b08f6..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,480 +0,0 @@ -# ProxyPool 架构重构设计文档 - -> 目标:建立一个高度可扩展、分层清晰、易于维护的代理池系统。最关键的目标是**让添加新爬虫变得极其简单**。 - ---- - -## 1. 架构总览 - -采用经典的分层架构: - -``` -┌─────────────────────────────────────────┐ -│ Frontend (Vue3 + Vite + Element Plus) │ -└─────────────┬───────────────────────────┘ - │ HTTP/REST -┌─────────────▼───────────────────────────┐ -│ API Layer (FastAPI Routers) │ ← 只负责:校验输入、调用 Service、格式化输出 -├─────────────────────────────────────────┤ -│ Service Layer │ ← 业务逻辑编排:爬取策略、验证调度、导出逻辑 -├─────────────────────────────────────────┤ -│ Plugin System (Crawlers) │ ← 爬虫插件:实现统一接口,返回原始代理数据 -├─────────────────────────────────────────┤ -│ Task Queue & Workers │ ← 验证队列:背压控制、Worker 池、削峰填谷 -├─────────────────────────────────────────┤ -│ Repository Layer │ ← 数据访问:所有 SQL 收敛于此 -├─────────────────────────────────────────┤ -│ Infrastructure (DB / Config / Log) │ ← 基础设施:连接池、配置、日志 -└─────────────────────────────────────────┘ -``` - ---- - -## 2. 后端核心设计原则 - -### 2.1 消灭全局单例,全面使用依赖注入 (DI) -当前 `scheduler = ValidationScheduler()` 是模块级全局变量,导致测试困难、隐式依赖。 - -重构后: -- 所有核心组件(DB、Scheduler、PluginManager)都通过 FastAPI `Depends` 注入 -- 使用 `contextlib.asynccontextmanager` 在 lifespan 中初始化并挂载到 `app.state` -- 单元测试可以轻易 mock 任何一层 - -### 2.2 Repository 模式收敛所有 SQL -所有数据库操作从 `api_server.py`、`scheduler.py` 中彻底抽离到 `repositories/proxy_repo.py`。 - -好处: -- 换数据库时只改 Repository -- 写单元测试直接 mock Repository -- SQL 语句集中管理,防止散落在各处 - -### 2.3 任务队列解耦爬取与验证 -当前插件爬取后直接 `asyncio.gather(*10000_tasks)` 验证,存在内存和并发风险。 - -重构后引入轻量级内存队列: -- `ValidationQueue`:基于 `asyncio.Queue` -- `ValidationWorkerPool`:固定数量的 Worker 从队列消费 -- 爬取结果 `put` 进队列即返回,验证在后台进行 -- 天然支持背压(backpressure),防止内存爆炸 - ---- - -## 3. 插件系统设计(核心) - -### 3.1 设计目标 -**让添加一个新爬虫只需要做两件事:** -1. 创建一个类,继承 `BaseCrawlerPlugin` -2. 实现 `crawl()` 方法,返回 `list[ProxyRaw]` - -### 3.2 插件接口 - -```python -from dataclasses import dataclass -from typing import List, AsyncIterator - -@dataclass -class ProxyRaw: - ip: str - port: int - protocol: str # http | https | socks4 | socks5 - -class BaseCrawlerPlugin: - """所有爬虫插件必须继承的基类""" - - name: str = "" # 插件唯一标识 - display_name: str = "" # 展示名称 - description: str = "" # 描述 - enabled: bool = True # 是否默认启用 - - async def crawl(self) -> List[ProxyRaw]: - """ - 爬取代理的核心方法。 - 可以是纯同步逻辑,也可以包含异步 HTTP 请求。 - 返回原始代理列表,不要在这里做验证。 - """ - raise NotImplementedError - - async def health_check(self) -> bool: - """可选:检查当前插件是否可用(如目标网站是否可访问)""" - return True -``` - -### 3.3 插件注册机制 -采用**显式注册 + 装饰器**模式,抛弃运行时目录扫描。 - -```python -from core.plugin_system import registry - -@registry.register -class MyNewPlugin(BaseCrawlerPlugin): - name = "my_new_plugin" - display_name = "我的新代理源" - - async def crawl(self): - return [ProxyRaw("1.2.3.4", 8080, "http")] -``` - -优点: -- 类型安全:IDE 可以自动补全、静态检查 -- 可控:不会出现意外加载未预期模块的问题 -- 测试友好:测试时只注册 mock 插件 - -同时提供一个兼容入口 `registry.auto_discover("plugins")`,用于兼容现有习惯。 - -### 3.4 插件元数据持久化 -插件的 `enabled` 状态应该持久化到数据库(或 settings JSON),而不是仅存在于内存。 - -新增 `plugin_settings` 表: -```sql -CREATE TABLE plugin_settings ( - plugin_id TEXT PRIMARY KEY, - enabled INTEGER DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -启动时: -1. 加载所有已注册插件 -2. 从 `plugin_settings` 读取持久化状态 -3. 合并到插件实例中 - ---- - -## 4. 任务调度与验证队列 - -### 4.1 验证队列设计 - -```python -class ValidationQueue: - def __init__(self, worker_count: int = 50): - self.queue: asyncio.Queue[ProxyRaw] = asyncio.Queue() - self.worker_count = worker_count - self.workers: list[asyncio.Task] = [] - self._running = False - - async def start(self): - self._running = True - for _ in range(self.worker_count): - self.workers.append(asyncio.create_task(self._worker_loop())) - - async def stop(self): - self._running = False - for _ in self.workers: - self.queue.put_nowait(None) # sentinel - await asyncio.gather(*self.workers, return_exceptions=True) - - async def submit(self, proxies: list[ProxyRaw]): - for p in proxies: - await self.queue.put(p) - - async def _worker_loop(self): - while True: - item = await self.queue.get() - if item is None: - break - await self._validate_and_save(item) - self.queue.task_done() -``` - -### 4.2 调度器设计 -`SchedulerService` 负责: -- 启动/停止验证队列 -- 定时从数据库拉取存量代理,重新投入验证队列 -- 协调插件爬取后的验证流程 - -```python -class SchedulerService: - def __init__(self, queue: ValidationQueue, proxy_repo: ProxyRepository): - self.queue = queue - self.proxy_repo = proxy_repo - self.interval_minutes = 30 - self._task: asyncio.Task | None = None -``` - ---- - -## 5. 数据库设计 - -保留 SQLite + aiosqlite,但优化连接管理。 - -### 5.1 表结构 - -```sql --- 代理表 -CREATE TABLE proxies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - protocol TEXT DEFAULT 'http', - score INTEGER DEFAULT 10, - response_time_ms REAL, - last_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(ip, port) -); - --- 插件设置表 -CREATE TABLE plugin_settings ( - plugin_id TEXT PRIMARY KEY, - enabled INTEGER DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 系统设置表(JSON 存储) -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -### 5.2 连接管理 -- 使用 `asynccontextmanager` 管理连接生命周期 -- 每个 HTTP 请求独立获取连接,请求结束后关闭 -- 调度器/队列等长生命周期组件也定期重建连接(如每 1000 次操作) - ---- - -## 6. API 设计调整 - -保持现有 API 路径基本不变,但路由按资源拆分。 - -### 6.1 路由拆分 -``` -apiv1/ -├── __init__.py -├── proxies.py # /api/proxies/* -├── plugins.py # /api/plugins/* -├── scheduler.py # /api/scheduler/* -└── settings.py # /api/settings -``` - -### 6.2 新增/调整的 API - -#### 插件相关 -- `GET /api/plugins` — 获取插件列表(含持久化状态) -- `PUT /api/plugins/{plugin_id}/toggle` — 切换启用状态(持久化到 DB) -- `POST /api/plugins/{plugin_id}/crawl` — 触发爬取(异步,返回任务 ID) -- `POST /api/plugins/crawl-all` — 批量爬取 - -**关键变更**:爬取接口改为**异步触发**而不是同步等待。因为新爬虫可能爬取数万个代理,同步 HTTP 请求会超时。 - -返回示例: -```json -{ - "code": 200, - "message": "爬取任务已启动", - "data": { - "task_id": "crawl-20250402-001", - "queued": 150 - } -} -``` - -为了简化前端,第一阶段可以保留同步 API,但内部通过 `asyncio.create_task` 包装,并设置合理的超时(30 秒)。在真正大规模使用时,再迁移到 WebSocket/SSE 推送进度。 - ---- - -## 7. 前端架构调整 - -### 7.1 新增 Service 层 -从 Store 中剥离 API 调用逻辑: - -``` -frontend/src/ -├── services/ -│ ├── proxyService.js # 代理相关 API 调用 -│ ├── pluginService.js # 插件相关 API 调用 -│ ├── schedulerService.js -│ └── settingService.js -├── stores/ -│ ├── proxy.js # 纯状态管理 -│ └── plugin.js -``` - -### 7.2 Store 职责收敛 -Store 只负责: -- 持有状态(`ref/reactive`) -- 提供计算属性 -- 调用 Service,然后更新状态 - -### 7.3 API 适配 -由于后端 API 路径保持不变,前端改动主要是代码组织上的调整,URL 和返回结构尽量兼容。 - ---- - -## 8. 目录结构(重构后) - -``` -ProxyPool/ -├── main.py # 项目入口 -├── requirements.txt # Python 依赖 -├── .env.example # 环境变量示例 -│ -├── app/ # 后端代码 -│ ├── api/ # FastAPI 入口和路由 -│ │ ├── __init__.py -│ │ ├── main.py # 应用工厂 -│ │ ├── lifespan.py # 生命周期管理 -│ │ ├── deps.py # 依赖注入 -│ │ ├── errors.py # 统一异常 -│ │ └── routes/ -│ │ ├── __init__.py -│ │ ├── proxies.py -│ │ ├── plugins.py -│ │ ├── scheduler.py -│ │ └── settings.py -│ │ -│ ├── core/ # 基础设施 -│ │ ├── __init__.py -│ │ ├── config.py # Pydantic Settings -│ │ ├── log.py # 日志 -│ │ ├── db.py # 数据库连接池/上下文 -│ │ ├── exceptions.py # 业务异常 -│ │ ├── plugin_system/ # 插件系统 -│ │ │ ├── __init__.py -│ │ │ ├── base.py # BaseCrawlerPlugin -│ │ │ └── registry.py # 插件注册中心 -│ │ └── tasks/ # 任务队列 -│ │ ├── __init__.py -│ │ └── queue.py # ValidationQueue -│ │ -│ ├── models/ # 数据模型 -│ │ ├── __init__.py -│ │ ├── schemas.py # Pydantic 模型 -│ │ └── domain.py # 领域模型 -│ │ -│ ├── repositories/ # 数据访问层 -│ │ ├── __init__.py -│ │ ├── proxy_repo.py -│ │ ├── settings_repo.py -│ │ └── task_repo.py -│ │ -│ ├── services/ # 业务逻辑层 -│ │ ├── __init__.py -│ │ ├── proxy_service.py -│ │ ├── plugin_service.py -│ │ ├── scheduler_service.py -│ │ └── validator_service.py -│ │ -│ └── plugins/ # 爬虫插件 -│ ├── __init__.py -│ ├── base.py # 通用抓取基类 -│ ├── fate0.py -│ ├── kuaidaili.py -│ ├── ip3366.py -│ ├── ip89.py -│ ├── speedx.py -│ ├── yundaili.py -│ ├── proxylist_download.py -│ └── proxyscrape.py -│ -├── WebUI/ # Vue3 前端 -│ ├── src/ -│ │ ├── api/ # API 封装 -│ │ ├── stores/ # Pinia 状态管理 -│ │ ├── views/ # 页面组件 -│ │ ├── router/ # 路由配置 -│ │ ├── components/ # 通用组件 -│ │ └── style.css # 全局样式 -│ ├── index.html -│ └── package.json -│ -├── tests/ # 测试目录 -│ ├── conftest.py -│ ├── unit/ -│ └── integration/ -│ -├── script/ # 启动脚本 -├── db/ # 数据存储 -├── logs/ # 日志文件 -└── DESIGN.md # 本文档 -``` - ---- - -## 9. 迁移计划 - -### Phase 1: 基础设施(今天完成) -1. 重写 `core/config.py` → Pydantic Settings -2. 重写 `core/db.py` → 带上下文管理的连接池 -3. 创建 `models/` 层 - -### Phase 2: Repository + Service(今天完成) -1. 创建 `repositories/proxy_repo.py` -2. 创建 `services/` 下的业务类 -3. 迁移现有逻辑 - -### Phase 3: 插件系统(今天完成,核心) -1. 创建 `core/plugin_system/base.py` 和 `registry.py` -2. 设计显式注册机制 -3. 将所有现有插件迁移到新基类 - -### Phase 4: 任务队列(今天完成) -1. 创建 `ValidationQueue` 和 `WorkerPool` -2. 重写 `SchedulerService` - -### Phase 5: API 路由(今天完成) -1. 拆分 `api_server.py` 到 `api/routes/` -2. 组装新的 `api/main.py` - -### Phase 6: 前端调整(今天完成) -1. 拆分 Service 层 -2. 适配 Store -3. 保留现有页面,只改代码组织 - -### Phase 7: 清理与验证 -1. 删除旧的 `api_server.py`, `core/scheduler.py`, `core/sqlite.py` 等 -2. 运行测试,确保所有功能正常 -3. 提交代码 - ---- - -## 10. 添加新爬虫的标准流程(目标体验) - -假设要添加一个名为 `mynewsource` 的爬虫: - -**Step 1**: 创建文件 `app/plugins/mynewsource.py` - -```python -from app.core.plugin_system import BaseCrawlerPlugin, ProxyRaw -from app.plugins.base import BaseHTTPPlugin # 可选:如果基于 HTTP 爬取 - -class MyNewSourcePlugin(BaseHTTPPlugin): - name = "mynewsource" - display_name = "我的新代理源" - description = "从 example.com 爬取免费代理" - - def __init__(self): - super().__init__() - self.urls = ["https://example.com/proxies"] - - async def crawl(self) -> list[ProxyRaw]: - results = [] - for url in self.urls: - html = await self.fetch(url) - # ... 解析 html ... - results.append(ProxyRaw(ip="1.2.3.4", port=8080, protocol="http")) - return results -``` - -**Step 2**: 在 `app/plugins/__init__.py` 中注册 - -```python -from .mynewsource import MyNewSourcePlugin -from app.core.plugin_system import registry - -registry.register(MyNewSourcePlugin) -``` - -**Step 3**: 重启后端服务,前端自动显示新插件。 - -无需修改任何路由、服务、数据库表。 - ---- - -*文档版本: 1.0* -*作者: Kimi Code* -*日期: 2026-04-02* diff --git a/WebUI/src/components/LatencyHistogram.vue b/WebUI/src/components/LatencyHistogram.vue new file mode 100644 index 0000000..e8fa957 --- /dev/null +++ b/WebUI/src/components/LatencyHistogram.vue @@ -0,0 +1,270 @@ + + + + + + + diff --git a/WebUI/src/components/ProtocolChart.vue b/WebUI/src/components/ProtocolChart.vue index e8c4f17..eb428d0 100644 --- a/WebUI/src/components/ProtocolChart.vue +++ b/WebUI/src/components/ProtocolChart.vue @@ -124,6 +124,7 @@ function getChartOption() { return { tooltip: { trigger: 'item', + confine: true, formatter: (params) => { const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0 return `${params.name}: ${params.value} (${percent}%)` @@ -150,7 +151,7 @@ function getChartOption() { { type: 'pie', radius: ['40%', '65%'], - center: ['38%', '50%'], + center: ['38%', '52%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 6, @@ -252,17 +253,18 @@ onUnmounted(() => { + + diff --git a/WebUI/src/components/QuickActions.vue b/WebUI/src/components/QuickActions.vue deleted file mode 100644 index 8eb3e8a..0000000 --- a/WebUI/src/components/QuickActions.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - - - diff --git a/WebUI/src/components/ScoreDistribution.vue b/WebUI/src/components/ScoreDistribution.vue new file mode 100644 index 0000000..37b477e --- /dev/null +++ b/WebUI/src/components/ScoreDistribution.vue @@ -0,0 +1,268 @@ + + + + + + + diff --git a/WebUI/src/components/ValidationGauge.vue b/WebUI/src/components/ValidationGauge.vue new file mode 100644 index 0000000..6ff521b --- /dev/null +++ b/WebUI/src/components/ValidationGauge.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/WebUI/src/views/Dashboard.vue b/WebUI/src/views/Dashboard.vue index a3f8361..96507d3 100644 --- a/WebUI/src/views/Dashboard.vue +++ b/WebUI/src/views/Dashboard.vue @@ -47,25 +47,6 @@ /> - - - - - - - - - - - - - - - - @@ -76,6 +57,16 @@ 系统状态 +
+ + + 导出代理 + + + + 清理无效 + +
@@ -105,6 +96,25 @@ + + + + + + + + + + + + + + + + + + +
@@ -120,13 +130,16 @@ import { InfoFilled, Clock, Odometer, - WarningFilled + WarningFilled, + Download, + Delete } from '@element-plus/icons-vue' import { useProxyStore } from '../stores/proxy' import { formatNumber } from '../utils/format' import StatCard from '../components/StatCard.vue' import ProtocolChart from '../components/ProtocolChart.vue' -import QuickActions from '../components/QuickActions.vue' +import LatencyHistogram from '../components/LatencyHistogram.vue' +import ScoreDistribution from '../components/ScoreDistribution.vue' import PageHeader from '../components/PageHeader.vue' import { useStatsWebSocket } from '../composables/useStatsWebSocket' @@ -143,7 +156,8 @@ const latencyLabel = computed(() => { if (ms == null || ms === '' || Number(ms) <= 0) { return '—' } - return `${formatNumber(Number(ms), 1)} ms` + const seconds = Number(ms) / 1000 + return `${formatNumber(seconds, 2)} s` }) async function refreshData() { @@ -220,6 +234,9 @@ onMounted(async () => { .card-header { font-size: 16px; font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; } .card-title { @@ -228,6 +245,11 @@ onMounted(async () => { gap: 8px; } +.header-actions { + display: flex; + gap: 8px; +} + .status-list { display: flex; flex-wrap: wrap; @@ -262,3 +284,9 @@ onMounted(async () => { } } + + diff --git a/app/api/routes/proxies.py b/app/api/routes/proxies.py index b653e4c..72d7ee1 100644 --- a/app/api/routes/proxies.py +++ b/app/api/routes/proxies.py @@ -108,3 +108,21 @@ async def batch_delete( async def clean_invalid(service: ProxyService = Depends(get_proxy_service)): count = await service.clean_invalid() return success_response(f"清理了 {count} 个无效代理", {"deleted_count": count}) + + +@router.get("/latency-distribution") +async def get_latency_distribution( + service: ProxyService = Depends(get_proxy_service), +): + """获取延迟分布数据,用于直方图展示""" + distribution = await service.get_latency_distribution() + return success_response("获取延迟分布成功", distribution) + + +@router.get("/score-distribution") +async def get_score_distribution( + service: ProxyService = Depends(get_proxy_service), +): + """获取评分分布数据,用于柱状图展示""" + distribution = await service.get_score_distribution() + return success_response("获取评分分布成功", distribution) diff --git a/app/repositories/proxy_repo.py b/app/repositories/proxy_repo.py index c4dc3a7..e726079 100644 --- a/app/repositories/proxy_repo.py +++ b/app/repositories/proxy_repo.py @@ -526,3 +526,73 @@ class ProxyRepository: except Exception as e: logger.error(f"clean_expired failed: {e}", exc_info=True) return 0 + + @staticmethod + async def get_latency_distribution(db: aiosqlite.Connection) -> dict: + """获取延迟分布数据(仅已验证可用的代理)""" + try: + async with db.execute( + """ + SELECT response_time_ms FROM proxies + WHERE validated = 1 AND score > 0 AND response_time_ms IS NOT NULL AND response_time_ms > 0 + """ + ) as cursor: + rows = await cursor.fetchall() + if not rows: + return {"ranges": [], "counts": []} + + latencies = [row[0] for row in rows] + ranges = ["<500ms", "500-1s", "1-2s", "2-3s", ">3s"] + counts = [0, 0, 0, 0, 0] + + for lat in latencies: + if lat < 500: + counts[0] += 1 + elif lat < 1000: + counts[1] += 1 + elif lat < 2000: + counts[2] += 1 + elif lat < 3000: + counts[3] += 1 + else: + counts[4] += 1 + + return {"ranges": ranges, "counts": counts} + except Exception as e: + logger.error(f"get_latency_distribution failed: {e}", exc_info=True) + return {"ranges": [], "counts": []} + + @staticmethod + async def get_score_distribution(db: aiosqlite.Connection) -> dict: + """获取评分分布数据(仅已验证可用的代理)""" + try: + async with db.execute( + """ + SELECT score FROM proxies + WHERE validated = 1 AND score > 0 + """ + ) as cursor: + rows = await cursor.fetchall() + if not rows: + return {"ranges": [], "counts": []} + + scores = [row[0] for row in rows] + ranges = ["80-100", "60-80", "40-60", "20-40", "0-20"] + counts = [0, 0, 0, 0, 0] + + for score in scores: + if score >= 80: + counts[0] += 1 + elif score >= 60: + counts[1] += 1 + elif score >= 40: + counts[2] += 1 + elif score >= 20: + counts[3] += 1 + else: + counts[4] += 1 + + return {"ranges": ranges, "counts": counts} + except Exception as e: + logger.error(f"get_score_distribution failed: {e}", exc_info=True) + return {"ranges": [], "counts": []} diff --git a/app/services/proxy_service.py b/app/services/proxy_service.py index 569eec9..17756ac 100644 --- a/app/services/proxy_service.py +++ b/app/services/proxy_service.py @@ -147,3 +147,11 @@ class ProxyService: if isinstance(dt, str): return dt return dt.isoformat() + + async def get_latency_distribution(self) -> dict: + async with get_db() as db: + return await self.proxy_repo.get_latency_distribution(db) + + async def get_score_distribution(self) -> dict: + async with get_db() as db: + return await self.proxy_repo.get_score_distribution(db)