feat: JSON 配置、质量分与仪表盘,及设置与爬取流程

- 后端改为 config/app.json;pytest 使用 config/app.test.json 与 set_config_file,不再依赖环境变量;移除 pydantic-settings。

- 前端 API/WebSocket 由 config/webui.json 经 Vite define 注入。

- 代理分数按延迟与随机取用次数计算,新增 use_count 与 proxy_scoring;保存设置时同步调度器启停。

- 仪表盘双饼图(可用/待验证协议);设置页去掉调度器启停按钮并移动立即验证;爬取全部结束后自动提交全量验证。

- 删除 script/settings_maintain.py(此前已标记删除)。

Made-with: Cursor
This commit is contained in:
祀梦
2026-04-05 16:08:32 +08:00
parent 07248ff4ee
commit 7bc6d4e4de
31 changed files with 643 additions and 280 deletions

View File

@@ -1,38 +1,6 @@
# 代理池系统配置文件示例 # 本项目的运行参数已改为由 JSON 配置文件提供,不再使用环境变量。
# 复制此文件为 .env 并根据实际情况修改配置 #
# 后端:编辑项目根目录下的 config/app.json
# ==================== 数据库配置 ==================== # 前端 dev/build编辑项目根目录下的 config/webui.json与 WebUI 同级的 config 目录)
DB_PATH=db/proxies.sqlite #
# 测试专用配置config/app.test.jsonpytest 会自动选用,勿与生产库共用 db_path
# ==================== API服务配置 ====================
HOST=0.0.0.0
PORT=9949
# ==================== 验证器配置 ====================
VALIDATOR_TIMEOUT=5
VALIDATOR_MAX_CONCURRENCY=200
VALIDATOR_CONNECT_TIMEOUT=3
# ==================== 爬虫配置 ====================
CRAWLER_NUM_VALIDATORS=50
CRAWLER_MAX_QUEUE_SIZE=500
# ==================== 日志配置 ====================
LOG_LEVEL=INFO
LOG_DIR=logs
# ==================== 导出配置 ====================
EXPORT_MAX_RECORDS=10000
# ==================== 代理评分配置 ====================
SCORE_VALID=10
SCORE_INVALID=-5
SCORE_MIN=0
SCORE_MAX=100
# ==================== 插件配置 ====================
PLUGINS_DIR=plugins
# ==================== CORS配置 ====================
# 允许的来源域名,用逗号分隔
CORS_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:9948

2
.gitignore vendored
View File

@@ -30,7 +30,7 @@ env/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
*.db *.db
# pytest 隔离库(PROXYPOOL_DB_PATH=db/proxies.test.sqlite),勿提交 # pytest 隔离库(见 config/app.test.json 的 db_path),勿提交
**/proxies.test.sqlite **/proxies.test.sqlite
proxies.test.sqlite proxies.test.sqlite
*.db-shm *.db-shm

View File

@@ -1,14 +1,15 @@
import axios from 'axios' import axios from 'axios'
import { showError } from '../utils/message' import { showError } from '../utils/message'
/** @type {string} 默认 API 基础 URL */ /** @type {string} 由项目根目录 config/webui.json 注入(见 vite.config.js */
export const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080' export const DEFAULT_API_BASE_URL =
typeof __WEBUI_API_BASE_URL__ !== 'undefined' ? __WEBUI_API_BASE_URL__ : 'http://127.0.0.1:18080'
/** @type {number} 请求超时时间(毫秒) */ /** @type {number} 请求超时时间(毫秒) */
export const REQUEST_TIMEOUT = 120000 export const REQUEST_TIMEOUT = 120000
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL, baseURL: DEFAULT_API_BASE_URL,
timeout: REQUEST_TIMEOUT timeout: REQUEST_TIMEOUT
}) })

View File

@@ -1,18 +1,18 @@
<template> <template>
<el-card class="chart-card" shadow="hover"> <el-card class="chart-card" :class="{ 'chart-card--compact': compact }" shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span class="card-title"> <span class="card-title">
<el-icon class="header-icon"><PieChart /></el-icon> <el-icon class="header-icon"><PieChart /></el-icon>
协议分布 {{ titleText }}
</span> </span>
<el-tooltip content="显示各协议类型的代理数量分布"> <el-tooltip :content="helpText">
<el-icon class="help-icon"><InfoFilled /></el-icon> <el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<div ref="chartRef" class="chart-container" v-loading="!hasData"> <div ref="chartRef" class="chart-container">
<el-empty v-if="!hasData" description="暂无数据" :image-size="80" /> <el-empty v-if="!hasData" :description="emptyText" :image-size="72" />
</div> </div>
</el-card> </el-card>
</template> </template>
@@ -27,29 +27,73 @@ const props = defineProps({
data: { data: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
/** available仅已验证可用pending仅待验证池 */
variant: {
type: String,
default: 'available',
validator: (v) => ['available', 'pending'].includes(v)
},
/** 并排展示时略压低高度 */
compact: {
type: Boolean,
default: false
} }
}) })
const titleText = computed(() =>
props.variant === 'pending' ? '待验证 · 协议分布' : '可用代理 · 协议分布'
)
const helpText = computed(() =>
props.variant === 'pending'
? '仅统计 validated=0 的待验证代理,与各协议在队列中的占比'
: '仅统计已验证且分数大于 0 的可用代理,不含待验证与低分条目'
)
const emptyText = computed(() =>
props.variant === 'pending' ? '暂无待验证代理' : '暂无可用代理'
)
const chartRef = ref(null) const chartRef = ref(null)
let chartInstance = null let chartInstance = null
let resizeTimer = null let resizeTimer = null
const cachedColors = ref(null) const cachedColors = ref(null)
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
const counts = computed(() => {
const d = props.data || {}
if (props.variant === 'pending') {
return {
http: d.pending_http_count || 0,
https: d.pending_https_count || 0,
socks4: d.pending_socks4_count || 0,
socks5: d.pending_socks5_count || 0
}
}
return {
http: d.http_count || 0,
https: d.https_count || 0,
socks4: d.socks4_count || 0,
socks5: d.socks5_count || 0
}
})
const hasData = computed(() => { const hasData = computed(() => {
const { http_count, https_count, socks4_count, socks5_count } = props.data const c = counts.value
return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0 return c.http + c.https + c.socks4 + c.socks5 > 0
}) })
const chartData = computed(() => { const chartData = computed(() => {
if (!cachedColors.value) return [] if (!cachedColors.value) return []
const colors = cachedColors.value const colors = cachedColors.value
const c = counts.value
return [ return [
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: colors.info } }, { value: c.http, name: 'HTTP', itemStyle: { color: colors.info } },
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: colors.success } }, { value: c.https, name: 'HTTPS', itemStyle: { color: colors.success } },
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: colors.primary } }, { value: c.socks4, name: 'SOCKS4', itemStyle: { color: colors.primary } },
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: colors.warning } } { value: c.socks5, name: 'SOCKS5', itemStyle: { color: colors.warning } }
].filter(item => item.value > 0) ].filter((item) => item.value > 0)
}) })
const total = computed(() => const total = computed(() =>
@@ -143,6 +187,11 @@ function initChart() {
if (!chartRef.value || !hasData.value) return if (!chartRef.value || !hasData.value) return
loadColors() loadColors()
if (chartInstance) {
updateChart()
return
}
chartInstance = echarts.init(chartRef.value) chartInstance = echarts.init(chartRef.value)
updateChart() updateChart()
@@ -172,13 +221,21 @@ function destroyChart() {
} }
// ==================== 监听 ==================== // ==================== 监听 ====================
watch(() => props.data, () => { watch(
if (!chartInstance && hasData.value) { () => [props.data, props.variant, props.compact],
initChart() () => {
} else { if (!hasData.value) {
updateChart() destroyChart()
} return
}, { deep: true }) }
if (!chartInstance) {
initChart()
} else {
updateChart()
}
},
{ deep: true }
)
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
onMounted(() => { onMounted(() => {
@@ -200,6 +257,14 @@ onUnmounted(() => {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.chart-card--compact {
min-height: 340px;
}
.chart-card--compact .chart-container {
height: 300px;
}
.chart-card:hover { .chart-card:hover {
border-color: var(--border-light); border-color: var(--border-light);
} }

View File

@@ -25,7 +25,16 @@ const props = defineProps({
type: String, type: String,
default: 'default', default: 'default',
validator: (value) => validator: (value) =>
['default', 'total', 'pending', 'available', 'new', 'score'].includes(value) [
'default',
'total',
'pending',
'available',
'new',
'score',
'invalid',
'latency'
].includes(value)
}, },
/** 图标组件 */ /** 图标组件 */
icon: { icon: {
@@ -45,6 +54,9 @@ const props = defineProps({
}) })
const displayValue = computed(() => { const displayValue = computed(() => {
if (props.value === '—' || props.value === '-') {
return props.value
}
const num = Number(props.value) const num = Number(props.value)
if (!isNaN(num) && num > 9999) { if (!isNaN(num) && num > 9999) {
return (num / 10000).toFixed(1) + 'w' return (num / 10000).toFixed(1) + 'w'
@@ -95,6 +107,16 @@ const displayValue = computed(() => {
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4)); filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
} }
.stat-card.invalid .stat-icon {
color: var(--danger, #f56c6c);
filter: drop-shadow(0 0 8px rgba(245, 108, 108, 0.35));
}
.stat-card.latency .stat-icon {
color: var(--info);
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.35));
}
.stat-content { .stat-content {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -9,12 +9,16 @@ const INITIAL_DELAY_MS = 1000
* @returns {string} * @returns {string}
*/ */
export function resolveWebSocketStatsUrl() { export function resolveWebSocketStatsUrl() {
const explicit = import.meta.env.VITE_WS_URL const explicit =
typeof __WEBUI_WS_URL__ !== 'undefined' ? String(__WEBUI_WS_URL__).trim() : ''
if (explicit) { if (explicit) {
const t = String(explicit).trim().replace(/\/$/, '') const t = explicit.replace(/\/$/, '')
return t.endsWith('/api/ws') ? t : `${t}/api/ws` return t.endsWith('/api/ws') ? t : `${t}/api/ws`
} }
const api = import.meta.env.VITE_API_BASE_URL || 'http://localhost:18080' const api =
typeof __WEBUI_API_BASE_URL__ !== 'undefined'
? __WEBUI_API_BASE_URL__
: 'http://127.0.0.1:18080'
const u = new URL(api) const u = new URL(api)
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:' u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
u.pathname = '/api/ws' u.pathname = '/api/ws'

View File

@@ -31,13 +31,32 @@
type="score" type="score"
:icon="StarFilled" :icon="StarFilled"
:value="avgScore" :value="avgScore"
label="平均分数" label="平均分数(可用)"
/>
<StatCard
type="latency"
:icon="Odometer"
:value="latencyLabel"
label="平均延迟(可用)"
/>
<StatCard
type="invalid"
:icon="WarningFilled"
:value="stats.invalid_count || 0"
label="低分待清理"
/> />
</div> </div>
<el-row :gutter="20" class="charts-row"> <el-row :gutter="20" class="charts-row">
<el-col :xs="24" :lg="16"> <el-col :xs="24" :lg="16">
<ProtocolChart :data="stats" /> <el-row :gutter="16" class="charts-inner">
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="available" compact />
</el-col>
<el-col :xs="24" :md="12">
<ProtocolChart :data="stats" variant="pending" compact />
</el-col>
</el-row>
</el-col> </el-col>
<el-col :xs="24" :lg="8"> <el-col :xs="24" :lg="8">
<QuickActions <QuickActions
@@ -67,17 +86,21 @@
</el-tag> </el-tag>
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">HTTP 代理</span> <span class="status-label">HTTP可用</span>
<span class="status-value">{{ stats.http_count || 0 }}</span> <span class="status-value">{{ stats.http_count || 0 }}</span>
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">HTTPS 代理</span> <span class="status-label">HTTPS可用</span>
<span class="status-value">{{ stats.https_count || 0 }}</span> <span class="status-value">{{ stats.https_count || 0 }}</span>
</div> </div>
<div class="status-item"> <div class="status-item">
<span class="status-label">SOCKS 代理</span> <span class="status-label">SOCKS可用</span>
<span class="status-value">{{ (stats.socks4_count || 0) + (stats.socks5_count || 0) }}</span> <span class="status-value">{{ (stats.socks4_count || 0) + (stats.socks5_count || 0) }}</span>
</div> </div>
<div class="status-item" v-if="(stats.invalid_count || 0) > 0">
<span class="status-label">低分可清理</span>
<span class="status-value warn">{{ stats.invalid_count }}</span>
</div>
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
@@ -95,7 +118,9 @@ import {
Timer, Timer,
StarFilled, StarFilled,
InfoFilled, InfoFilled,
Clock Clock,
Odometer,
WarningFilled
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useProxyStore } from '../stores/proxy' import { useProxyStore } from '../stores/proxy'
import { formatNumber } from '../utils/format' import { formatNumber } from '../utils/format'
@@ -113,6 +138,14 @@ const { start: startStatsWs } = useStatsWebSocket()
const stats = computed(() => proxyStore.stats) const stats = computed(() => proxyStore.stats)
const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1)) const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1))
const latencyLabel = computed(() => {
const ms = stats.value.avg_response_ms
if (ms == null || ms === '' || Number(ms) <= 0) {
return '—'
}
return `${formatNumber(Number(ms), 1)} ms`
})
async function refreshData() { async function refreshData() {
await proxyStore.fetchStats() await proxyStore.fetchStats()
} }
@@ -170,6 +203,10 @@ onMounted(async () => {
margin-bottom: 20px; margin-bottom: 20px;
} }
.charts-inner {
height: 100%;
}
.status-row { .status-row {
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -214,6 +251,10 @@ onMounted(async () => {
color: var(--primary); color: var(--primary);
} }
.status-value.warn {
color: var(--danger, #f56c6c);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.status-list { .status-list {
flex-direction: column; flex-direction: column;

View File

@@ -359,7 +359,7 @@ async function handleCrawlAll() {
} }
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?代理将先以「待验证」入库,需再执行「全部验证」后才会变为可用(除非已开启「爬取后立即验证」)。`, `确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?代理将先以「待验证」入库;全部插件爬取结束后会自动执行一次「全部验证」(若已开启「爬取后立即验证」,新入库条目也会在爬取时提前排队验证)。`,
'批量爬取确认', '批量爬取确认',
{ {
confirmButtonText: '开始爬取', confirmButtonText: '开始爬取',
@@ -405,7 +405,11 @@ async function handleCrawlAll() {
crawlResults.value = merged crawlResults.value = merged
} }
if (!data.cancelled) { if (!data.cancelled) {
ElMessage.success('批量爬取完成') ElMessage.success(
data.validate_all_task_id
? '批量爬取完成,已自动启动全部验证'
: '批量爬取完成'
)
} }
await pluginsStore.fetchPlugins() await pluginsStore.fetchPlugins()
} else { } else {

View File

@@ -113,9 +113,21 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="use_count" label="取用次数" width="100">
<template #default="{ row }">
{{ row.use_count ?? 0 }}
</template>
</el-table-column>
<el-table-column prop="score" label="分数" width="100"> <el-table-column prop="score" label="分数" width="100">
<template #default="{ row }"> <template #default="{ row }">
<span class="score-value" :class="{ 'score-high': row.score >= 8, 'score-medium': row.score >= 5 && row.score < 8, 'score-low': row.score < 5 }"> <span
class="score-value"
:class="{
'score-high': row.score >= 70,
'score-medium': row.score >= 40 && row.score < 70,
'score-low': row.score < 40
}"
>
{{ row.score || 0 }} {{ row.score || 0 }}
</span> </span>
</template> </template>

View File

@@ -2,7 +2,7 @@
<div class="page-container"> <div class="page-container">
<PageHeader title="系统设置" :icon="Setting" /> <PageHeader title="系统设置" :icon="Setting" />
<!-- 验证调度器控制 --> <!-- 验证调度器状态启停由下方启用自动验证+ 保存配置 -->
<el-card class="settings-card scheduler-card" shadow="hover"> <el-card class="settings-card scheduler-card" shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
@@ -17,37 +17,6 @@
</div> </div>
</template> </template>
<div class="scheduler-actions">
<el-button
type="success"
@click="handleStartScheduler"
:disabled="schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPlay /></el-icon>
启动自动验证
</el-button>
<el-button
type="danger"
@click="handleStopScheduler"
:disabled="!schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPause /></el-icon>
停止自动验证
</el-button>
<el-button
type="primary"
@click="handleValidateNow"
:loading="validating"
>
<el-icon class="btn-icon"><Refresh /></el-icon>
立即验证全部
</el-button>
</div>
<div class="scheduler-info"> <div class="scheduler-info">
<el-alert <el-alert
:title="schedulerInfo" :title="schedulerInfo"
@@ -66,15 +35,25 @@
<el-icon class="header-icon"><Tools /></el-icon> <el-icon class="header-icon"><Tools /></el-icon>
基础配置 基础配置
</span> </span>
<el-button <div class="header-actions">
type="primary" <el-button
@click="handleSave" size="large"
size="large" @click="handleValidateNow"
:loading="saving" :loading="validating"
> >
<el-icon class="btn-icon"><DocumentChecked /></el-icon> <el-icon class="btn-icon"><Refresh /></el-icon>
保存配置 立即验证全部
</el-button> </el-button>
<el-button
type="primary"
@click="handleSave"
size="large"
:loading="saving"
>
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
保存配置
</el-button>
</div>
</div> </div>
</template> </template>
@@ -199,8 +178,6 @@ import {
DocumentChecked, DocumentChecked,
Tools, Tools,
Timer, Timer,
VideoPlay,
VideoPause,
Refresh Refresh
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { settingService } from '../services/settingService' import { settingService } from '../services/settingService'
@@ -210,11 +187,8 @@ import PageHeader from '../components/PageHeader.vue'
// ==================== Composables ==================== // ==================== Composables ====================
const { const {
schedulerRunning, schedulerRunning,
schedulerLoading,
validating, validating,
fetchStatus, fetchStatus,
startScheduler,
stopScheduler,
validateNow validateNow
} = useScheduler() } = useScheduler()
@@ -248,7 +222,7 @@ const schedulerInfo = computed(() => {
if (schedulerRunning.value) { if (schedulerRunning.value) {
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟执行一次:优先验证待验证代理,再按检查时间复检已入库代理` return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟执行一次:优先验证待验证代理,再按检查时间复检已入库代理`
} }
return '验证调度器已停止,待验证代理不会自动检查;可在下方开启自动验证或点击「立即验证全部」' return '验证调度器当前未运行。请在下方打开「启用自动验证」并保存配置以恢复定时任务;需要时可使用「基础配置」标题栏中的「立即验证全部」手动执行一轮全量验证。'
}) })
// ==================== 表单验证规则 ==================== // ==================== 表单验证规则 ====================
@@ -276,21 +250,6 @@ async function fetchSettings() {
} }
} }
// ==================== 调度器控制 ====================
async function handleStartScheduler() {
await startScheduler(
(msg) => ElMessage.success(msg),
(msg) => ElMessage.error(msg)
)
}
async function handleStopScheduler() {
await stopScheduler(
(msg) => ElMessage.success(msg),
(msg) => ElMessage.error(msg)
)
}
async function handleValidateNow() { async function handleValidateNow() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -372,6 +331,13 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -403,15 +369,8 @@ onMounted(() => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.scheduler-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.scheduler-info { .scheduler-info {
margin-top: 8px; margin-top: 0;
} }
.settings-form { .settings-form {

3
WebUI/src/vite-globals.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/** 由 vite.config.js define 注入(值来自项目根目录 config/webui.json */
declare const __WEBUI_API_BASE_URL__: string
declare const __WEBUI_WS_URL__: string

View File

@@ -1,9 +1,25 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const webuiConfigPath = path.resolve(__dirname, '../config/webui.json')
let webui = { api_base_url: 'http://127.0.0.1:18080', ws_url: '' }
try {
webui = { ...webui, ...JSON.parse(fs.readFileSync(webuiConfigPath, 'utf-8')) }
} catch {
console.warn('[vite] 未读取 config/webui.json使用默认 API 地址')
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
define: {
__WEBUI_API_BASE_URL__: JSON.stringify(String(webui.api_base_url || '').trim() || 'http://127.0.0.1:18080'),
__WEBUI_WS_URL__: JSON.stringify(webui.ws_url != null ? String(webui.ws_url) : ''),
},
server: { server: {
port: 18081, port: 18081,
// 支持 Vue Router 的 history 模式 // 支持 Vue Router 的 history 模式

View File

@@ -26,6 +26,7 @@ def format_proxy(proxy) -> dict:
"response_time_ms": proxy.response_time_ms, "response_time_ms": proxy.response_time_ms,
"last_check": proxy.last_check.isoformat() if proxy.last_check else None, "last_check": proxy.last_check.isoformat() if proxy.last_check else None,
"validated": getattr(proxy, "validated", 0), "validated": getattr(proxy, "validated", 0),
"use_count": int(getattr(proxy, "use_count", 0) or 0),
} }

View File

@@ -11,6 +11,7 @@ from app.core.plugin_system.registry import registry
from app.repositories.proxy_repo import ProxyRepository from app.repositories.proxy_repo import ProxyRepository
from app.repositories.settings_repo import SettingsRepository, DEFAULT_SETTINGS from app.repositories.settings_repo import SettingsRepository, DEFAULT_SETTINGS
from app.services.validator_service import ValidatorService from app.services.validator_service import ValidatorService
from app.services.proxy_scoring import compute_proxy_quality_score
from app.services.plugin_runner import PluginRunner from app.services.plugin_runner import PluginRunner
from app.services.scheduler_service import SchedulerService from app.services.scheduler_service import SchedulerService
from app.api.ws_manager import ConnectionManager from app.api.ws_manager import ConnectionManager
@@ -63,12 +64,21 @@ async def lifespan(app: FastAPI):
return return
if existing.validated == 0: if existing.validated == 0:
if is_valid: if is_valid:
lat_ms = (
float(latency)
if latency is not None and float(latency) > 0
else None
)
uc = int(getattr(existing, "use_count", 0) or 0)
q_score = compute_proxy_quality_score(
lat_ms, uc, app_settings
)
await proxy_repo.insert_or_update( await proxy_repo.insert_or_update(
db, db,
proxy.ip, proxy.ip,
proxy.port, proxy.port,
proxy.protocol, proxy.protocol,
score=app_settings.score_valid, score=q_score,
) )
if latency: if latency:
await proxy_repo.update_response_time( await proxy_repo.update_response_time(
@@ -78,12 +88,21 @@ async def lifespan(app: FastAPI):
await proxy_repo.delete(db, proxy.ip, proxy.port) await proxy_repo.delete(db, proxy.ip, proxy.port)
else: else:
if is_valid: if is_valid:
lat_ms = (
float(latency)
if latency is not None and float(latency) > 0
else None
)
uc = int(getattr(existing, "use_count", 0) or 0)
q_score = compute_proxy_quality_score(
lat_ms, uc, app_settings
)
await proxy_repo.insert_or_update( await proxy_repo.insert_or_update(
db, db,
proxy.ip, proxy.ip,
proxy.port, proxy.port,
proxy.protocol, proxy.protocol,
score=app_settings.score_valid, score=q_score,
) )
if latency: if latency:
await proxy_repo.update_response_time( await proxy_repo.update_response_time(

View File

@@ -4,7 +4,8 @@ from pydantic import BaseModel
from app.services.plugin_service import PluginService from app.services.plugin_service import PluginService
from app.services.plugin_runner import PluginRunner from app.services.plugin_runner import PluginRunner
from app.core.execution import JobExecutor, CrawlJob from app.core.execution import JobExecutor, CrawlJob, ValidateAllJob
from app.core.log import logger
from app.core.exceptions import PluginNotFoundException from app.core.exceptions import PluginNotFoundException
from app.api.deps import get_plugin_service, get_plugin_runner, get_executor from app.api.deps import get_plugin_service, get_plugin_runner, get_executor
from app.api.common import success_response, format_plugin from app.api.common import success_response, format_plugin
@@ -106,7 +107,7 @@ async def crawl_all(
def _create_crawl_all_aggregator(job_ids, executor): def _create_crawl_all_aggregator(job_ids, executor):
"""创建一个简单的聚合 Job查询所有子 Job 的状态汇总""" """创建一个简单的聚合 Job查询所有子 Job 的状态汇总;正常结束时自动提交一次全量验证"""
from app.core.execution.job import Job from app.core.execution.job import Job
import asyncio import asyncio
@@ -177,6 +178,13 @@ def _create_crawl_all_aggregator(job_ids, executor):
} }
if self.is_cancelled: if self.is_cancelled:
result["cancelled"] = True result["cancelled"] = True
else:
v_job = ValidateAllJob(validator_pool=executor.worker_pool)
result["validate_all_task_id"] = executor.submit_job(v_job)
logger.info(
"Crawl-all finished; submitted ValidateAllJob %s",
result["validate_all_task_id"],
)
return result return result
return CrawlAllAggregator() return CrawlAllAggregator()

View File

@@ -43,6 +43,18 @@ async def save_settings(
scheduler.interval_minutes = new_interval scheduler.interval_minutes = new_interval
logger.info(f"Scheduler interval updated to {new_interval} minutes") logger.info(f"Scheduler interval updated to {new_interval} minutes")
want_run = bool(request.auto_validate)
if want_run and not scheduler.running:
try:
await scheduler.start()
except Exception as e:
logger.error(f"Failed to start scheduler after settings save: {e}")
elif not want_run and scheduler.running:
try:
await scheduler.stop()
except Exception as e:
logger.error(f"Failed to stop scheduler after settings save: {e}")
# 热更新 Worker 池大小 # 热更新 Worker 池大小
if worker_pool and worker_pool.worker_count != request.default_concurrency: if worker_pool and worker_pool.worker_count != request.default_concurrency:
await worker_pool.resize(request.default_concurrency) await worker_pool.resize(request.default_concurrency)

View File

@@ -1,11 +1,18 @@
"""核心基础设施包""" """核心基础设施包
from .config import settings
from .log import logger 注意:不在此模块导入 config / log以免测试在 conftest 中调用 set_config_file 之前
from .exceptions import ProxyPoolException, PluginNotFoundException, ProxyNotFoundException, ValidationException 就把配置定死。请使用:
from app.core.config import settings
from app.core.log import logger
"""
from app.core.exceptions import (
PluginNotFoundException,
ProxyNotFoundException,
ProxyPoolException,
ValidationException,
)
__all__ = [ __all__ = [
"settings",
"logger",
"ProxyPoolException", "ProxyPoolException",
"PluginNotFoundException", "PluginNotFoundException",
"ProxyNotFoundException", "ProxyNotFoundException",

View File

@@ -1,77 +1,111 @@
"""全局配置 - 使用 Pydantic Settings 支持环境变量和 .env 文件""" """全局配置:仅从 JSON 文件加载,不使用环境变量。"""
import os from __future__ import annotations
from typing import List
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
import json
import logging
from typing import Any, Dict, List
class Settings(BaseSettings): from pydantic import BaseModel, ConfigDict
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# 数据库配置(环境变量 PROXYPOOL_DB_PATH 优先,供 pytest 与生产隔离) from app.core.config_paths import project_root, resolved_config_path
db_path: str = Field(
default="db/proxies.sqlite",
validation_alias=AliasChoices("PROXYPOOL_DB_PATH", "DB_PATH", "db_path"),
)
# API 服务配置 logger = logging.getLogger("ProxyPool")
host: str = "127.0.0.1"
port: int = 18080
# 验证器配置 _DEFAULTS: Dict[str, Any] = {
validator_timeout: int = 5 "db_path": "db/proxies.sqlite",
validator_max_concurrency: int = 200 "host": "127.0.0.1",
validator_connect_timeout: int = 3 "port": 18080,
"validator_timeout": 5,
# 爬虫配置 "validator_max_concurrency": 200,
crawler_num_validators: int = 50 "validator_connect_timeout": 3,
crawler_max_queue_size: int = 500 "crawler_num_validators": 50,
"crawler_max_queue_size": 500,
# 日志配置 "log_level": "INFO",
log_level: str = "INFO" "log_dir": "logs",
log_dir: str = "logs" "ws_stats_interval_seconds": 1,
"export_max_records": 10000,
# WebSocket统计广播间隔无连接时不查库 "score_valid": 10,
ws_stats_interval_seconds: int = 1 "score_invalid": -5,
"score_min": 0,
# 导出配置 "score_max": 100,
export_max_records: int = 10000 "score_latency_ref_ms": 500.0,
"score_use_penalty_per_pick": 2.5,
# 代理评分配置 "score_max_use_penalty": 70.0,
score_valid: int = 10 "score_default_latency_ms": 1500.0,
score_invalid: int = -5 "validator_test_urls": [
score_min: int = 0
score_max: int = 100
# 验证目标配置
validator_test_urls: List[str] = [
"http://httpbin.org/ip", "http://httpbin.org/ip",
"https://httpbin.org/ip", "https://httpbin.org/ip",
"http://api.ipify.org", "http://api.ipify.org",
"https://api.ipify.org", "https://api.ipify.org",
"http://www.baidu.com", "http://www.baidu.com",
"http://www.qq.com", "http://www.qq.com",
] ],
"plugins_dir": "plugins",
# 插件配置 "cors_origins": [
plugins_dir: str = "plugins"
# CORS 配置 - Pydantic v2 会自动将逗号分隔的字符串解析为 List[str]
cors_origins: List[str] = [
"http://localhost:8080", "http://localhost:8080",
"http://localhost:5173", "http://localhost:5173",
"http://127.0.0.1:18081", "http://127.0.0.1:18081",
"http://localhost:18081", "http://localhost:18081",
] ],
"run_network_tests": False,
}
def _load_merged_dict() -> Dict[str, Any]:
data = dict(_DEFAULTS)
path = resolved_config_path()
if not path.is_file():
logger.warning("配置文件不存在,使用内置默认项: %s", path)
return data
try:
with path.open(encoding="utf-8") as f:
file_data = json.load(f)
if not isinstance(file_data, dict):
logger.error("配置文件须为 JSON 对象,已忽略: %s", path)
return data
data.update(file_data)
except (json.JSONDecodeError, OSError) as e:
logger.error("读取配置文件失败,使用内置默认项: %s (%s)", path, e)
return data
class AppSettings(BaseModel):
"""应用配置(与 config/app.json 字段一致)"""
model_config = ConfigDict(extra="ignore")
db_path: str
host: str
port: int
validator_timeout: int
validator_max_concurrency: int
validator_connect_timeout: int
crawler_num_validators: int
crawler_max_queue_size: int
log_level: str
log_dir: str
ws_stats_interval_seconds: int
export_max_records: int
score_valid: int
score_invalid: int
score_min: int
score_max: int
score_latency_ref_ms: float
score_use_penalty_per_pick: float
score_max_use_penalty: float
score_default_latency_ms: float
validator_test_urls: List[str]
plugins_dir: str
cors_origins: List[str]
run_network_tests: bool = False
@property @property
def base_dir(self) -> str: def base_dir(self) -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) return str(project_root())
# 全局配置实例(启动时加载一次 # 全局单例(进程内首次导入时按当前 resolved_config_path() 加载
settings = Settings() settings = AppSettings.model_validate(_load_merged_dict())
# 历史代码别名
Settings = AppSettings

24
app/core/config_paths.py Normal file
View File

@@ -0,0 +1,24 @@
"""配置文件路径解析(先于 config 加载,供测试在导入应用前切换配置文件)"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
_CONFIG_FILE: Optional[Path] = None
def project_root() -> Path:
"""项目根目录(含 config/、app/ 的目录)"""
return Path(__file__).resolve().parents[2]
def set_config_file(path: Path) -> None:
"""指定使用的应用配置文件(仅测试应在导入 app.core.config 之前调用)"""
global _CONFIG_FILE
_CONFIG_FILE = Path(path)
def resolved_config_path() -> Path:
if _CONFIG_FILE is not None:
return _CONFIG_FILE
return project_root() / "config" / "app.json"

View File

@@ -75,6 +75,14 @@ async def init_db():
) )
logger.info("Migrated: added validated column") logger.info("Migrated: added validated column")
try:
await db.execute("SELECT use_count FROM proxies LIMIT 1")
except Exception:
await db.execute(
"ALTER TABLE proxies ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0"
)
logger.info("Migrated: added use_count column")
await db.execute("CREATE INDEX IF NOT EXISTS idx_score ON proxies(score)") await db.execute("CREATE INDEX IF NOT EXISTS idx_score ON proxies(score)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_protocol ON proxies(protocol)") await db.execute("CREATE INDEX IF NOT EXISTS idx_protocol ON proxies(protocol)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_last_check ON proxies(last_check)") await db.execute("CREATE INDEX IF NOT EXISTS idx_last_check ON proxies(last_check)")

View File

@@ -31,6 +31,7 @@ class Proxy:
last_check: Optional[datetime] = None last_check: Optional[datetime] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
validated: int = 0 # 0 待验证 1 已验证(可参与分数与对外取用) validated: int = 0 # 0 待验证 1 已验证(可参与分数与对外取用)
use_count: int = 0 # 被随机 API 取用的累计次数(用于降权)
@dataclass @dataclass

View File

@@ -26,6 +26,7 @@ class ProxyResponse(BaseModel):
response_time_ms: Optional[float] = None response_time_ms: Optional[float] = None
last_check: Optional[str] = None last_check: Optional[str] = None
validated: int = 0 validated: int = 0
use_count: int = 0
class PluginResponse(BaseModel): class PluginResponse(BaseModel):

View File

@@ -25,6 +25,8 @@ def _to_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
def _row_to_proxy(row: Tuple) -> Proxy: def _row_to_proxy(row: Tuple) -> Proxy:
validated = int(row[7]) if len(row) > 7 and row[7] is not None else 0
use_count = int(row[8]) if len(row) > 8 and row[8] is not None else 0
return Proxy( return Proxy(
ip=row[0], ip=row[0],
port=row[1], port=row[1],
@@ -33,12 +35,13 @@ def _row_to_proxy(row: Tuple) -> Proxy:
response_time_ms=row[4], response_time_ms=row[4],
last_check=_to_datetime(row[5]), last_check=_to_datetime(row[5]),
created_at=_to_datetime(row[6]), created_at=_to_datetime(row[6]),
validated=int(row[7]) if len(row) > 7 and row[7] is not None else 0, validated=validated,
use_count=use_count,
) )
_SELECT_PROXY_COLS = ( _SELECT_PROXY_COLS = (
"ip, port, protocol, score, response_time_ms, last_check, created_at, validated" "ip, port, protocol, score, response_time_ms, last_check, created_at, validated, use_count"
) )
@@ -58,8 +61,8 @@ class ProxyRepository:
try: try:
await db.execute( await db.execute(
""" """
INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated) INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated, use_count)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 0)
ON CONFLICT(ip, port) DO UPDATE SET ON CONFLICT(ip, port) DO UPDATE SET
protocol = excluded.protocol, protocol = excluded.protocol,
score = excluded.score, score = excluded.score,
@@ -87,13 +90,14 @@ class ProxyRepository:
protocol = "http" protocol = "http"
await db.execute( await db.execute(
""" """
INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated) INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated, use_count)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 0)
ON CONFLICT(ip, port) DO UPDATE SET ON CONFLICT(ip, port) DO UPDATE SET
protocol = excluded.protocol, protocol = excluded.protocol,
score = excluded.score, score = excluded.score,
last_check = CURRENT_TIMESTAMP, last_check = CURRENT_TIMESTAMP,
validated = 0 validated = 0,
use_count = 0
""", """,
(ip, port, protocol, initial_score), (ip, port, protocol, initial_score),
) )
@@ -113,13 +117,14 @@ class ProxyRepository:
rows.append((p.ip, p.port, proto, initial_score)) rows.append((p.ip, p.port, proto, initial_score))
await db.executemany( await db.executemany(
""" """
INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated) INSERT INTO proxies (ip, port, protocol, score, last_check, created_at, validated, use_count)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 0)
ON CONFLICT(ip, port) DO UPDATE SET ON CONFLICT(ip, port) DO UPDATE SET
protocol = excluded.protocol, protocol = excluded.protocol,
score = excluded.score, score = excluded.score,
last_check = CURRENT_TIMESTAMP, last_check = CURRENT_TIMESTAMP,
validated = 0 validated = 0,
use_count = 0
""", """,
rows, rows,
) )
@@ -176,6 +181,29 @@ class ProxyRepository:
logger.error(f"update_response_time failed: {e}", exc_info=True) logger.error(f"update_response_time failed: {e}", exc_info=True)
return False return False
@staticmethod
async def set_use_count_and_score(
db: aiosqlite.Connection,
ip: str,
port: int,
use_count: int,
score: int,
) -> bool:
try:
await db.execute(
"""
UPDATE proxies
SET use_count = ?, score = ?, last_check = CURRENT_TIMESTAMP
WHERE ip = ? AND port = ? AND validated = 1
""",
(use_count, score, ip, port),
)
await db.commit()
return db.total_changes > 0
except Exception as e:
logger.error(f"set_use_count_and_score failed: {e}", exc_info=True)
return False
@staticmethod @staticmethod
async def delete(db: aiosqlite.Connection, ip: str, port: int) -> None: async def delete(db: aiosqlite.Connection, ip: str, port: int) -> None:
await db.execute("DELETE FROM proxies WHERE ip = ? AND port = ?", (ip, port)) await db.execute("DELETE FROM proxies WHERE ip = ? AND port = ?", (ip, port))
@@ -369,21 +397,34 @@ class ProxyRepository:
@staticmethod @staticmethod
async def get_stats(db: aiosqlite.Connection) -> dict: async def get_stats(db: aiosqlite.Connection) -> dict:
"""统计快照。
协议计数http/https/socks*)仅含已验证且 score>0 的可用代理,供首页图表与「可用」口径一致。
pending_* 为待验证池validated=0按协议分布。
"""
query = """ query = """
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
COUNT(CASE WHEN validated = 0 THEN 1 END) as pending, COUNT(CASE WHEN validated = 0 THEN 1 END) as pending,
COUNT(CASE WHEN validated = 1 AND score > 0 THEN 1 END) as available, COUNT(CASE WHEN validated = 1 AND score > 0 THEN 1 END) as available,
(SELECT AVG(score) FROM proxies WHERE validated = 1 AND score > 0) as avg_score, (SELECT AVG(score) FROM proxies WHERE validated = 1 AND score > 0) as avg_score,
COUNT(CASE WHEN protocol = 'http' THEN 1 END) as http_count, COUNT(CASE WHEN validated = 1 AND score > 0 AND protocol = 'http' THEN 1 END) as http_count,
COUNT(CASE WHEN protocol = 'https' THEN 1 END) as https_count, COUNT(CASE WHEN validated = 1 AND score > 0 AND protocol = 'https' THEN 1 END) as https_count,
COUNT(CASE WHEN protocol = 'socks4' THEN 1 END) as socks4_count, COUNT(CASE WHEN validated = 1 AND score > 0 AND protocol = 'socks4' THEN 1 END) as socks4_count,
COUNT(CASE WHEN protocol = 'socks5' THEN 1 END) as socks5_count COUNT(CASE WHEN validated = 1 AND score > 0 AND protocol = 'socks5' THEN 1 END) as socks5_count,
COUNT(CASE WHEN validated = 0 AND protocol = 'http' THEN 1 END) as pending_http_count,
COUNT(CASE WHEN validated = 0 AND protocol = 'https' THEN 1 END) as pending_https_count,
COUNT(CASE WHEN validated = 0 AND protocol = 'socks4' THEN 1 END) as pending_socks4_count,
COUNT(CASE WHEN validated = 0 AND protocol = 'socks5' THEN 1 END) as pending_socks5_count,
COUNT(CASE WHEN validated = 1 AND score <= 0 THEN 1 END) as invalid_count,
(SELECT AVG(response_time_ms) FROM proxies WHERE validated = 1 AND score > 0
AND response_time_ms IS NOT NULL AND response_time_ms > 0) as avg_response_ms
FROM proxies FROM proxies
""" """
async with db.execute(query) as cursor: async with db.execute(query) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
if row: if row:
avg_lat = row[13]
return { return {
"total": row[0] or 0, "total": row[0] or 0,
"pending": row[1] or 0, "pending": row[1] or 0,
@@ -393,6 +434,12 @@ class ProxyRepository:
"https_count": row[5] or 0, "https_count": row[5] or 0,
"socks4_count": row[6] or 0, "socks4_count": row[6] or 0,
"socks5_count": row[7] or 0, "socks5_count": row[7] or 0,
"pending_http_count": row[8] or 0,
"pending_https_count": row[9] or 0,
"pending_socks4_count": row[10] or 0,
"pending_socks5_count": row[11] or 0,
"invalid_count": row[12] or 0,
"avg_response_ms": round(avg_lat, 2) if avg_lat is not None else None,
} }
return { return {
"total": 0, "total": 0,
@@ -403,6 +450,12 @@ class ProxyRepository:
"https_count": 0, "https_count": 0,
"socks4_count": 0, "socks4_count": 0,
"socks5_count": 0, "socks5_count": 0,
"pending_http_count": 0,
"pending_https_count": 0,
"pending_socks4_count": 0,
"pending_socks5_count": 0,
"invalid_count": 0,
"avg_response_ms": None,
} }
@staticmethod @staticmethod

View File

@@ -0,0 +1,54 @@
"""代理质量分:延迟越低越高,被取用次数越多越低。
设计要点
--------
1. **延迟项**0100用平滑倒数把毫秒映射到质量避免线性过于极端。
``latency_quality = 100 / (1 + latency_ms / latency_ref_ms)``
在 ``latency_ref_ms`` 处约为 50 分;越快越接近 100。
2. **使用惩罚**:每次通过 API 随机取出代理视为一次「使用」,``use_count`` 递增;
惩罚 ``min(max_use_penalty, use_count * use_penalty_per_pick)`` 从延迟项上扣除。
3. **未知延迟**:尚无 ``response_time_ms`` 时用 ``default_latency_ms`` 代替,避免给满分。
验证失败仍走 ``update_score`` 扣分;验证成功则用本函数**覆盖**分数(与当前延迟、使用次数一致)。
"""
from __future__ import annotations
from typing import Optional
from app.core.config import Settings
def compute_proxy_quality_score(
latency_ms: Optional[float],
use_count: int,
settings: Settings,
) -> int:
"""根据延迟与累计使用次数计算 0100 的整数分。"""
ref = float(settings.score_latency_ref_ms)
penalty_per = float(settings.score_use_penalty_per_pick)
cap = float(settings.score_max_use_penalty)
default_lat = float(settings.score_default_latency_ms)
lo = int(settings.score_min)
hi = int(settings.score_max)
if ref <= 0:
ref = 500.0
if penalty_per < 0:
penalty_per = 0.0
if cap < 0:
cap = 0.0
if default_lat <= 0:
default_lat = 1500.0
ms = latency_ms
if ms is None or ms <= 0:
ms = default_lat
latency_quality = 100.0 / (1.0 + float(ms) / ref)
uses = max(0, int(use_count))
usage_penalty = min(cap, uses * penalty_per)
raw = latency_quality - usage_penalty
score = int(round(raw))
return max(lo, min(hi, score))

View File

@@ -9,6 +9,8 @@ from app.core.db import get_db
from app.repositories.proxy_repo import ProxyRepository from app.repositories.proxy_repo import ProxyRepository
from app.models.domain import Proxy from app.models.domain import Proxy
from app.core.log import logger from app.core.log import logger
from app.core.config import settings as app_settings
from app.services.proxy_scoring import compute_proxy_quality_score
class ProxyService: class ProxyService:
@@ -47,7 +49,19 @@ class ProxyService:
async def get_random_proxy(self) -> Optional[Proxy]: async def get_random_proxy(self) -> Optional[Proxy]:
async with get_db() as db: async with get_db() as db:
return await self.proxy_repo.get_random(db) p = await self.proxy_repo.get_random(db)
if not p:
return None
new_uc = int(getattr(p, "use_count", 0) or 0) + 1
q_score = compute_proxy_quality_score(
p.response_time_ms, new_uc, app_settings
)
await self.proxy_repo.set_use_count_and_score(
db, p.ip, p.port, new_uc, q_score
)
p.use_count = new_uc
p.score = q_score
return p
async def delete_proxy(self, ip: str, port: int) -> None: async def delete_proxy(self, ip: str, port: int) -> None:
async with get_db() as db: async with get_db() as db:

37
config/app.json Normal file
View File

@@ -0,0 +1,37 @@
{
"db_path": "db/proxies.sqlite",
"host": "127.0.0.1",
"port": 18080,
"validator_timeout": 5,
"validator_max_concurrency": 200,
"validator_connect_timeout": 3,
"crawler_num_validators": 50,
"crawler_max_queue_size": 500,
"log_level": "INFO",
"log_dir": "logs",
"ws_stats_interval_seconds": 1,
"export_max_records": 10000,
"score_valid": 10,
"score_invalid": -5,
"score_min": 0,
"score_max": 100,
"score_latency_ref_ms": 500.0,
"score_use_penalty_per_pick": 2.5,
"score_max_use_penalty": 70.0,
"score_default_latency_ms": 1500.0,
"validator_test_urls": [
"http://httpbin.org/ip",
"https://httpbin.org/ip",
"http://api.ipify.org",
"https://api.ipify.org",
"http://www.baidu.com",
"http://www.qq.com"
],
"plugins_dir": "plugins",
"cors_origins": [
"http://localhost:8080",
"http://localhost:5173",
"http://127.0.0.1:18081",
"http://localhost:18081"
]
}

33
config/app.test.json Normal file
View File

@@ -0,0 +1,33 @@
{
"db_path": "db/proxies.test.sqlite",
"host": "127.0.0.1",
"port": 18080,
"validator_timeout": 5,
"validator_max_concurrency": 200,
"validator_connect_timeout": 3,
"crawler_num_validators": 50,
"crawler_max_queue_size": 500,
"log_level": "INFO",
"log_dir": "logs",
"ws_stats_interval_seconds": 1,
"export_max_records": 10000,
"score_valid": 10,
"score_invalid": -5,
"score_min": 0,
"score_max": 100,
"score_latency_ref_ms": 500.0,
"score_use_penalty_per_pick": 2.5,
"score_max_use_penalty": 70.0,
"score_default_latency_ms": 1500.0,
"validator_test_urls": [
"http://httpbin.org/ip",
"https://httpbin.org/ip"
],
"plugins_dir": "plugins",
"cors_origins": [
"http://localhost:8080",
"http://127.0.0.1:18081",
"http://localhost:18081"
],
"run_network_tests": false
}

4
config/webui.json Normal file
View File

@@ -0,0 +1,4 @@
{
"api_base_url": "http://127.0.0.1:18080",
"ws_url": ""
}

View File

@@ -5,6 +5,5 @@ aiohttp==3.9.1
aiohttp-socks==0.9.1 aiohttp-socks==0.9.1
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
lxml==5.1.0 lxml==5.1.0
pydantic-settings==2.8.1
httpx[http2]==0.27.0 httpx[http2]==0.27.0
curl-cffi>=0.7.0 curl-cffi>=0.7.0

View File

@@ -1,44 +0,0 @@
"""维护 SQLite settings 表:删除废弃键并写入推荐验证参数。
请在项目根目录执行(与 start.bat 同级的上一级):
python script/settings_maintain.py
改库后需重启应用或在 WebUI 保存一次设置WorkerPool / Validator 才会重载并发与超时。
"""
import asyncio
import os
import sys
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
_SETTINGS_MAINTENANCE_SQL = """
DELETE FROM settings WHERE key = 'crawl_timeout';
DELETE FROM settings WHERE key = 'max_retries';
INSERT INTO settings (key, value, updated_at) VALUES ('validation_timeout', '6', CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP;
INSERT INTO settings (key, value, updated_at) VALUES ('default_concurrency', '120', CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP;
"""
async def _run() -> None:
import aiosqlite
from app.core.db import DB_PATH, ensure_db_dir
ensure_db_dir()
if not os.path.isfile(DB_PATH):
print(f"数据库不存在,跳过: {DB_PATH}")
return
async with aiosqlite.connect(DB_PATH) as db:
await db.executescript(_SETTINGS_MAINTENANCE_SQL)
await db.commit()
print(f"已执行设置维护: {DB_PATH}")
print("请重启应用或在 WebUI 保存一次设置以使并发/超时生效。")
if __name__ == "__main__":
asyncio.run(_run())

View File

@@ -1,8 +1,10 @@
"""pytest 配置文件和 fixtures""" """pytest 配置文件和 fixtures"""
# 必须在任何 app.* 导入之前:下方 app fixture 会清空表,不可与生产共用 db/proxies.sqlite # 必须在任何会加载 app.core.config 的导入之前(测试库与生产库隔离)
import os from pathlib import Path
os.environ["PROXYPOOL_DB_PATH"] = "db/proxies.test.sqlite" from app.core.config_paths import set_config_file
set_config_file(Path(__file__).resolve().parents[1] / "config" / "app.test.json")
import asyncio import asyncio
import sys import sys
@@ -14,8 +16,9 @@ import pytest
def _network_tests_enabled() -> bool: def _network_tests_enabled() -> bool:
v = os.environ.get("PROXYPOOL_RUN_NETWORK_TESTS", "").strip().lower() from app.core.config import settings
return v in ("1", "true", "yes", "on")
return bool(getattr(settings, "run_network_tests", False))
def pytest_collection_modifyitems(config, items) -> None: def pytest_collection_modifyitems(config, items) -> None:
@@ -24,8 +27,8 @@ def pytest_collection_modifyitems(config, items) -> None:
return return
skip = pytest.mark.skip( skip = pytest.mark.skip(
reason=( reason=(
"外网/真实爬取用例默认跳过。需要验收时设置环境变量 " "外网/真实爬取用例默认跳过。需要验收时在 config/app.test.json 中设置 "
"PROXYPOOL_RUN_NETWORK_TESTS=1 后再运行对应文件或 -m network。" "\"run_network_tests\": true 后再运行对应文件或 -m network。"
) )
) )
for item in items: for item in items: