代理池模块
代理资源池:抓取公开代理、验证可用性、按质量评分管理,并按需提供给 ProxyCat 的出口代理服务。
这个模块不是独立项目。 它作为 ProxyCat 的一部分运行在同一个进程里,没有自己的端口, 也不再有独立的 Web 界面 —— 它的管理页面是 ProxyCat 面板「代理池」页签下的「代理管理」、 「池设置」与「数据库维护」三个子视图。
- 如何使用:见仓库根目录的 README.md
- 接口文档:见仓库根目录的 API.md
- 配置项:
config/config.ini的[Pool]段(首次启动会自动从旧的config.yaml迁移)
目录结构
proxypool/
├── core/ 核心实现(分层)
│ ├── config.py 配置数据模型([Pool] 段的数据结构)
│ ├── paths.py 子项目根目录锚点,避免依赖进程工作目录
│ ├── database.py 建表与列迁移
│ ├── backup.py 数据库定时备份与恢复
│ ├── data_cleaner.py 数据库优化与统计查询
│ ├── logger.py 池日志的轮转文件 handler
│ ├── domain/ 领域模型与异常
│ ├── interfaces/ 抽象契约(仓库、服务、基础设施)
│ ├── infrastructure/ 连接池、去重缓存、HTTP 客户端、DI 容器、配置管理器、离线归属地库
│ ├── data/ 仓库实现与异步写队列
│ ├── services/ 验证器、插件管理器、自动重验证、健康评分、GeoIP
│ └── application/ 服务编排(Application)、后台任务、业务操作层
├── plugins/ 抓取插件(新增插件放在这里)
├── data/proxies.db SQLite 数据库
├── data/backups/ 数据库自动备份(超期自动淘汰)
├── data/geodb/ 离线归属地库(约 104MB,不入库,由更新命令拉取,见「归属地」)
└── logs/proxy_pool.log 池日志(同时会汇入面板的「运行日志」)
抓取插件开发
插件是 plugins/ 目录下一个 .py 文件,文件名即插件名(也是代理的 source_plugin)。
插件在池服务启动时扫描加载,新增文件需要重启池服务才会出现;已在列表中的插件改了代码后,
在面板的「代理池 → 插件管理」里点「重载」即可热加载,无需重启。
方式一:实现 IProxyPlugin(推荐)
"""示例:从某个 API 抓取代理。"""
import json
from typing import List
from core.domain.exceptions import PluginExecutionException
from core.interfaces.services import IProxyPlugin, PluginContext
class ExamplePlugin(IProxyPlugin):
"""插件说明。会被记录到日志,也会显示在插件列表里。"""
API_URL = "https://example.com/api/proxies"
async def fetch_proxies(self, context: PluginContext) -> List[str]:
"""返回代理 URL 列表,格式为 protocol://ip:port 或 protocol://user:pass@ip:port。"""
response = await context.http_client.get(self.API_URL)
if response.status != 200:
# 抓不到就抛:返回空列表会被调度器当成「这个源暂时没有代理」,
# 既不重试也不在插件列表报错。
raise PluginExecutionException(
f"请求 {self.API_URL} 失败,HTTP 状态码: {response.status}"
)
items = json.loads(response.text).get("data", [])
return [f"http://{item['ip']}:{item['port']}" for item in items]
@property
def name(self) -> str:
return "example"
@property
def version(self) -> str:
return "1.0.0"
PluginContext 提供三样东西:
| 成员 | 说明 |
|---|---|
http_client |
异步 HTTP 客户端。单次请求默认超时是 plugins.request_timeout_seconds,整个抓取另受 plugins.execution_timeout_seconds 上限约束;无需自己管理连接池 |
logger |
已按插件名命名好的 logger |
config |
当前生效的配置对象,可读取 [Pool] 段的参数 |
方式二:只提供 fetch_proxies 函数
旧式写法仍被支持,模块里定义 fetch_proxies 函数即可(同步或异步都可以,同步版本会被放到线程池里执行):
def fetch_proxies():
return ["http://1.2.3.4:8080", "socks5://5.6.7.8:1080"]
约定与注意事项
- 返回
list[str],元素必须是protocol://[user:pass@]ip:port;解析失败的条目会被逐条跳过并记日志。 - 协议只支持
http、https、socks5。 - 插件不需要自己入库,也不需要自己去重或验证 —— 抓取结果由插件管理器按
ip:port去重后交给入库流水线(验证通过才入库)。 - 插件抛出的异常不会影响其他插件;失败会按
plugins.max_retries_on_failure次做指数退避重试(总尝试次数是这个值 +1)。 - 同时执行的插件数受
plugins.max_parallel_plugins限制,改完即时生效。 - 抓取周期与测试地址都按插件单独设置,存在数据库里,不需要写在配置文件里。
代理进入池子的流程
抓取(插件) ─┐
手动运行插件 ─┼─► 去重 ─► 端口初筛 ─► 验证 ─► 只把「确认可用」的入库 ─► 异步查归属地
批量导入 ─┘
- 只入可用的(
core/application/ingest.py):这是代理进入池子的唯一入口。明确不可用(连不上代理、代理拒绝转发)与本轮不可结论(我方检测端点故障、本机断网)都不写库; 通过存活探测、却归因于代理拿不到出口 IP 的同样不写(失败可归因于我方的仍入库待补测)。代价是检测链路抖动期间抓到的代理会被丢弃;收益是池里每一条记录都真的验证通过过 —— 否则「池里有多少能用」这个问题根本答不上来。(注意这与下一条「不可结论不惩罚」并不冲突:那条约束的是不要删除已有记录。) - 端口初筛:完整验证一个「连得上但不转发」的黑洞代理要跑满超时(约 6.5 秒),而公共代理列表动辄十几万条,其中绝大多数根本没人监听。初筛只做一次 TCP 建连:连不上的直接出局,不进入验证。它不碰代理协议、不请求任何外部站点,因此可以用数倍于验证的并发跑(默认 4 倍,下限 64、上限 2000)。
- 实测:3000 个不可路由地址从 64 秒降到 17 秒,且一次验证请求都没发出。
- 已知限制:初筛拦不住「连得上但不转发」的黑洞代理,那类只能靠验证的超时兜住;因此十几万条的大批量抓取仍是以分钟计的等待 —— 进度条会给出实时速率与预计剩余时间,随时可以取消。
- 测试地址按插件区分:默认用
[Server] test_url,每个插件可以在「代理池 → 插件管理」里单独指定自己的测试地址(PUT /api/pool/plugins/<name>/test-url,留空即恢复继承全局)。 - 批量验证分两档(
POST /api/pool/proxies/batch/validate的mode):liveness只判「还通不通」(不碰出口 IP、匿名度,也不推进完整评估时间;存活探测本身会把测试地址走通的协议记为支持),full把身份与能力一并刷新,auto保持原有的「按记录状态自己选」。面板的「验证有效性 / 验证全部内容」两个按钮分别对应前两者。 - 按插件的验证策略(
PUT /api/pool/plugins/<name>/validation):重验证开关、重验证间隔、以及跳过入库验证三项一次提交。最后一项面向按请求计费、有效性由供应商保证的代理源 —— 打开后该插件抓来的代理不经过初筛与验证直接入库,不连代理也不发探测请求。代价是这些记录没有出口 IP、延迟与方案能力,匿名度停在unverified,健康分按缺数据默认值算出(total_checks为 0),要等自动重验证真的测过一次才补上;因此它与重验证开关相互独立,只关入库验证并不会阻止后续的定时检测。
验证
-
三档强度(
ValidationMode,由validation_policy统一选择):强度 探测项 何时使用 LIVENESS存活 默认轮次 IDENTITY存活 + https 身份 + 明文身份 已有完整评估、但身份信息不全(没有出口 IP,或匿名度未知——后者只可能是旧数据) FULL上列三项 + 明文转发能力 全面检测、首次入库、距上次完整评估超过 auto_revalidation.full_recheck_interval_minutes- 「探测项」不等于请求数:每项内部会按直连延迟逐个补发多个候选端点(见下面的竞速),实际请求数取决于有几个端点能通。表里的项数是下限。
IDENTITY有两项身份探测,不是一项:https 那条给出口 IP,明文那条只给匿名度(原因见下文「明文路径与 https 路径都是证据」)。匿名度优先用明文证据,因此补身份信息时顺带也会更新匿名度。FULL的第四项只在测试地址不是明文 http 时才发:测试地址本身是 http 时,存活探测已经证明了明文转发可用,不必再探一次。默认测试地址是 https,所以按四项算。
-
存活探测是闸门:先经代理请求测试地址(回答"能不能用、多快"),不通就到此为止;通过之后后续各项才并发发出(墙钟是最慢的那一项,而不是各项之和)。
- 为什么以它为准:它是唯一能回答「这个代理能不能把你要的站点打开」的探测。若允许"别的探测成功"来印证可用,就会收下一串「判为可用、但延迟/出口 IP/匿名度/地区全空」的记录 —— 这四项全都来自后续探测。实测库里 9 条这样的记录全是这么来的。
- 顺带省下大量工作:失败候选在大批量抓取里是绝大多数,而它们现在只花 1 个请求(此前会连身份与明文一起打出去)。实测单个黑洞代理从 7.6 秒降到 5.4 秒,吞吐 57 → 67 个/秒,对外部站点的请求量降到 1/3。
- 闸门之后的各项彼此独立,因此并发发出(
IDENTITY是 https 身份与明文身份两项,FULL再加明文能力探测)。其中 https 身份与明文能力两项构成「协议 × 目标协议」的能力矩阵:http://目标走明文转发、https://目标走 CONNECT 隧道,实测存在只通其中一条的代理,因此二者分别记录。
-
出口 IP 检测只在需要时做:它走 https 检测端点(
validator.identity_echo_apis/ip_check_apis,只接受 https —— 明文回显可被代理伪造,而身份数据要喂给归属地与匿名度判定)。取 origin 链的最后一跳,并优先取 IPv4;整条链没有 IPv4 时才接受 IPv6。- 不做条数截断:候选按我们的直连延迟排序,而「这个代理能不能访问某个端点」取决于它的网络 —— 排在前面的被它那侧挡掉是常态。截断会让可用的代理永远拿不到出口 IP,也就永远查不出地区。
- 全量参与不会拖慢失败路径:补发窗口总长有上限(1.5 秒),十几个候选也在 1.5 秒内发完,失败代价仍是「窗口 + 超时」。成功时第一条就返回。
- 端点要跨服务商:同族镜像往往一起被挡。默认清单里既有 httpbin 系(能给 origin 链 → 同时得出匿名度),也有纯 IP 回显(icanhazip / ipinfo / ipwho.is / Cloudflare trace),响应形态支持纯文本、
{"ip": ...}与key=value逐行三类。 - 一个可达代理却够不到任何回显端点时,会记一条 WARNING —— 那正是「出口 IP 与地区为空」的根因,不该是静默的。
- 匿名度按「目标实际收到了什么」判(
_judge_anonymity):transparent(目标能看出我们是谁)/ anonymous(能看出中间有代理)/ elite(两者都看不出)。判据不是代理的协议类型,而是它转发时目标究竟收到了什么。-
证据有两个载体,缺一不可。四个默认端点实测下来的行为完全不同:
端点(前置) originX-Forwarded-For在回显的 headers 里能靠什么判 httpbin.org / eu.httpbin.org(AWS ALB) 有,把 XFF 拼了进去 被剥掉(Via 也是) 只能靠 origin httpbingo.org(fly.io) 有,但只有对端、不含 XFF 保留 只能靠 headers postman-echo(Cloudflare) 没有 被剥掉 只能靠它回显的 Via只认头部会在 httpbin 上一个都判不出来,只认 origin 会在 httpbingo 上一个都判不出来 —— 两者是同一份证据的两种呈现,必须合起来用。换端点或加端点前先看这张表。
-
判据:① 本机出口 IP 出现在
origin链里、或出现在任何一个头部值里 → transparent(不做基线减法:问的就是「经这个代理之后目标收到的东西里有没有我们」);② 出现代理痕迹 → anonymous ——origin链比直连基线更长、或某个代理新增的头里带着地址、或某个头命中代理特征头清单却不在基线里;③ 其余 → elite。 -
判「代理夹带」看的是「基线那一份里有没有 IP」,不是「值变了没有」。带 CDN 的端点自己就往 XFF 里写对端地址:直连时是我们的 IP、经代理时变成代理的 IP,名字没变值却变了。按「值变了」判会让每一个干净代理都被判成匿名(实测 httpbingo 就是这样)。所以端点自己在写的头(基线含 IP)整条不参与,而它不写 IP 的头(如自己的
Via)被代理塞进地址就能抓到。 -
明文路径与 https 路径都是证据:明文优先(它同时反映明文转发行为),拿不到就用 https 那条。隧道里代理按字节转发,可它要夹带地址,隧道里一样夹带得了。
-
socks5 没有捷径:协议本身不注入头部,但跑 SOCKS5 的节点可能是 HTTP 感知的网关,或链路上还串着别的代理 —— 与别的协议用同一套判据。
-
不下结论的几种情形(都保留库里的旧值,而不是猜一个):开关
validator.check_anonymity关着;拿不到本机出口 IP(没有对比基准);本轮根本没做身份探测(LIVENESS 轻量轮次);失败可归因于我方的回显端点不可达。只有「探测做了、却一个可用的回显都没拿到」才兜底 transparent 并打anonymity_fallback标记,界面据此把它与实测所得区分开。 -
明文端点由 https 回显端点清单同址派生(
httpbingo的明文变体是其中唯一能拿到 XFF 证据的那个),另可由validator.plain_identity_echo_apis补充。
-
- 本机公网出口 IP(
ValidationEndpoints.ensure_host_ip):判定 transparent 的对比基准,不经过代理直接访问 IP 查询站点(validator.ip_check_apis那批)获取,第一个成功即停、按 30 分钟 TTL 缓存,失败也按同一 TTL 退避。用户可以在validator.host_public_ip里直接填自己的出口 IP,那样一个请求都不发 —— 界面上会显示当前生效的值与来源(手动指定 / 自动查询 / 未获取到)。 - 匿名度是可选开销:
validator.check_anonymity关掉后不再发明文身份探测、不再判匿名度,已有等级原样保留、新记录停在unverified;端点预筛的候选也少掉明文那一批(默认配置下每轮 11 个请求降到 4 个)。
-
失败归因:每次失败都带结论分类。只有「连不上代理」与「代理不转发」才算代理的问题;我方检测端点不可达、测试地址不可达、本机断网都记为不可结论,此时不写任何字段 —— 既不判代理失效,也不累计失败次数,因此检测基础设施抖动不会触发自动清理。
-
超时是固定值:所有请求一律用
validator.timeout_seconds,连接阶段另用validator.connect_timeout_seconds卡 TCP 握手(对应 aiohttp 的sock_connect,不是connect;后者会把 CONNECT 隧道建立也算进去,误杀隧道偏慢的可用代理)——黑洞代理因此能在连接超时内快速失败,这是「高速」的主要来源。早先还有一套「按本机直连耗时动态推导单次超时」加「整波墙钟预算」的机制,已移除:两者叠在一起会让身份探测在存活探测稍慢时拿不到预算,于是「没测到」与「测失败」在库里无法区分。 -
端点预筛与竞速:验证前先对本机直连探测各端点,不可达的自动剔除(被墙、被重置的端点不必逐个代理去撞)。每个端点的直连响应会被记录为基线,既用于扣除端点自身注入的响应头,也用于给候选排序 —— 竞速时按直连延迟逐个补发(对冲)而不是全部并发,慢代理不再被七八条并发隧道同时打爆。补发的总时长有上限(1.5 秒),因此失败路径的代价是「窗口 + 超时」而不是「候选数 × 间隔 + 超时」:身份探测有 5 个候选时,这一条把单个黑洞代理的验证从 12 秒压到 6.5 秒。
-
健康评分:延迟 25% + 成功率 25% + 稳定性 15% + 可用时长 15% + 匿名度 10% + 存活时长 10%。成功率维度用成功率的 Wilson 置信下界,样本少时向低位收缩,避免「只验过一两次」的代理拿到虚高分;样本取探测项级计数(每个得出明确结论的探测项各一票,一轮最多 3 票:存活、https 身份、明文转发能力,竞速补发出去的候选端点请求不各自计票),比"每轮一票"至多密 3 倍,估计收敛更快。延迟均值与方差对离群样本做钳制,一次拥塞不会把评分整段拖走。
-
身份类字段是粘性的:出口 IP、方案能力只在本次确实测到时才写库;没测到就保留上一次的已知值。匿名度同理 —— 唯一会「写一个推算值」的情形是探测确实做了却拿不到任何回显证据,那时会写兜底档并带上
anonymity_fallback标记,界面能把它与实测所得区分开。否则一次失败就会把已知的出口 IP 抹掉。延迟同理,且"上次完整评估时间"只在各个探测项都拿到明确结论时才推进 —— 一次没问全的轮次不会让这个代理在重做间隔内都不再被补测。 -
重新抓取不清空历史:同一个
ip:port再次入库时按 upsert 合并,delay_variance、可用时长、收藏标记与首次入库时间都保留,只有本次抓取真正拿到的字段会被刷新。
归属地
归属地只走本地离线库:一次查询同时给出中英两套名字与 ISO 规范代码,不发出任何外部请求。 这一节说的是名字从哪来、以及名字不好看时该改哪里。
(早先它只走在线接口 ip-api、中英各发一次请求,且该接口对一级行政区的中文覆盖不完整 ——
有译名给中文、没译名给当地原文,于是库里一格中文一格英文。那条路径已整体移除,连带
geoip.* 四项配置也一并作废。)
两个离线库的分工
| 数据文件 | 覆盖 | 给出什么 | 用在哪 |
|---|---|---|---|
GeoLite2-City.mmdb(约 65MB) |
全球 | ISO 3166-1 国码、ISO 3166-2 行政区码,以及 8 种语言的名字 | 国家判定与国外行政区 |
qqwry.ipdb(纯真,约 39MB) |
中国 | 省 / 市 / 区县名(中文) | 只补中国大陆的省市级 |
- 为什么要 GeoLite2:一次查询就同时给出中英两套名字与规范代码。这既省掉了「一个 IP 发两次请求」(在线接口时代中英各要一次),也彻底不受任何接口额度限制 —— 后一条是引入 离线库的主要动机之一。
- 为什么还要纯真:中国的城市级覆盖明显更强 —— 实测同一批 8 个中国 IP,纯真 7 个给出 城市,GeoLite2 只有 3 个。
- 纯真只在
country_code == "CN"时参与:它在国外的国家判定不可信,实测把香港判成 美国、荷兰判成乌克兰。所以非中国记录一律按 GeoLite2 的结论走,纯真连省市级都不采用。 - GeoLite2 的中文名是有缺口的:抽样 4000 个公网 IP,能查到一级行政区的占 46.8%,其中 70.5% 带中文名;能查到城市的占 46.1%,其中 63.0% 带中文名。缺译名时回退成原文并标记 为「原文」(见下),而不是让两种语言静默混在同一格 —— 那正是上一版的样子:中英两套 分别向接口要,有译名给中文、没译名给当地原文,于是库里一格中文一格英文,还看不出原因。
- 两个库对同一 IP 的城市判定可能不一致(实测 183.62.176.46 纯真=深圳、GeoLite2=广州),
此时以 GeoLite2 已有的
zh-CN名字为准,纯真只补 GeoLite2 缺zh-CN名字的级别, 结论来源记在geo_source里备查。
名字是「代码 + 对照表」渲染出来的
离线库返回的不是字符串,而是规范代码(ISO 3166-1 的国码 + ISO 3166-2 的细分代码)与一份
「语言 → 名称」映射。写进库的 country/province/city(中文那套)与
country_en/province_en/city_en(英文那套)是拿代码渲染出来的派生数据:
- 中文那套:库里的
zh-CN→ 一级行政区对照表 → 英文原文 - 英文那套:库里的
en→ 中国省市对照表 → 中文原文
为什么存代码:名字会随数据源和对照表变,代码不会;显示名在每次查询时按当时的对照表渲染,
代码则用于挑选仍可重算的记录(只挑缺代码的,见下)。结论来源(geolite2 / geolite2+qqwry)
一并记下,便于排障与事后重判。
对照表是尽力而为的有限集:中国 34 个省级 + 156 个主要城市(代理出口集中的那些), 以及 13 个国家/地区的一级行政区。没有收录的地区回退成原文 —— 宁可显示准确的原文, 也不要编一个错的译名。
「原文」在界面上是标出来的:地区名后面点一个浅色星号,tooltip 说明「该名称暂无当前语言的 译名,显示的是原文」。判据是确定的:中英两列逐字段比较,某一级取值相同就是没译出来 (接口把两套分列都透出,正是为了让它可判定,而不是靠猜串里有没有英文字母)。缺译名是 可预期、可累积改善的,标出来才能被发现。
已知限制:中英两列本来就不同的第三种语言原文判不出来 —— 例如日文「千葉県」对应的英文是 Chiba,两列不等;中日共用汉字,仅凭字符串无法区分。这类值只可能来自合并保留的旧数据, 而日韩的一级行政区离线库都有中文名,会走「新值覆盖」而不是保留,因此实际很少遇到。
数据文件
- 位置:
modules/proxypool/data/geodb/,两个文件合计约 104MB,不入库(.gitignore整个目录排除)。这两个文件是归属地的唯一来源:不下载就没有归属地(见下)。 - 更新命令:在
modules/proxypool/目录下执行python -m core.infrastructure.geodb(可选--dir指定数据目录)。两个库都走 jsDelivr 的@latest—— 纯真每周、GeoLite2 每周 两更,写死版本号会永远拉回同一个旧库。任一个失败不影响另一个,结束后逐项汇报,有失败时 退出码为 1。 - 面板入口:代理池 → 数据库维护 → 「归属地库」小节的**「更新归属地库」**(
POST /api/pool/geo/update)。 它建的是后台任务,下载几分钟、进度显示在按钮下方;命令行适合首次部署,面板适合日常更新。 - 文件缺失时:整体判定为「离线库不可用」,所有查询返回「未知」——没有兜底了。池照常 启动(抓取、验证、出口转发都不依赖它),但地区会是空的;启动日志会记一条 WARNING 指出 补救方式,面板的归属地库区块也会显示「未下载」。 这里刻意不做「缺谁用谁」:部分可用会让「这个 IP 查不到」既可能是库里没有、也可能只是少了 一个文件,排查成本远高于直接判定不可用。
- 面板上能看到两个文件各自的存在、大小与修改时间,以及待重算的记录数。
缓存
- 按 IP 缓存(
core/services/geoip.py):中英两套都拿到的结果缓存 24 小时;只拿到一套 或查询失败缓存 1 小时(避免「缺一半」的结果被当成完整结果锁上一整天),条目上限 10000。 缓存是进程内内存态,重启后重查。 - 查不到就不写:只写「查到的语种」。查询失败时一个字段都不写 —— 此处曾经会把已查好的 归属地覆盖成「未知」,与其它身份字段「测到才写」的策略正好相反。
- 重算只处理没代码的记录(
country_code IS NULL):它们要么从没查过,要么是在「只走在线 接口」的版本里查过(那时拿不到规范代码,名字也就无法按语言重新呈现)。面板的「重算归属地」 (POST /api/pool/geo/recompute)就是干这个的;启动补漏一次最多入队 5000 条(队列只有 2000 的 容量,灌满会挤掉验证链路顺带触发的查询),超出部分留给这个按钮。已经用离线库解析过的记录 不会被重算 —— 数据文件更新后想刷新它们,需要先把country_code清空(当前没有提供该操作)。
解析是异步的
- 异步解析(
core/services/geo_resolver.py):验证阶段只负责问出「出口 IP 是多少」,拿到之后就把 IP 丢进队列返回 —— 查询、重试、写库都在后台做,不占验证的墙钟。此前它在五个调用点被逐条 await 在验证协程里,直接拖长每轮墙钟。 - 几个并发 worker 一起查:队列与 worker 是为了把「查归属地」与「验证代理」解耦 —— 验证只管把出口 IP 丢进队列就返回,写库由后台按自己的节奏做。查询本身现在是本地查表(微秒级),不再是当初「在线接口超时 + 重试要十几秒」的形态,因此并发数(4)如今主要是把写库节奏摊平,而不是为了压查询延迟。
- 启动补漏:队列是内存态,重启会丢掉没查完的。因此启动时会把库里当前有效、有出口 IP、却没有地区(离线库可用时,缺规范代码的也算)的记录重新入队 —— 否则这些代理要等到下一次完整评估(默认 24 小时)才会再查一次,期间地区一直是空的。
- 尝试上限是时间窗内的:同一个出口 IP 在 30 分钟内最多尝试 3 次。早先是一次性计数,失败 3 次就再也不查了 —— 一次接口抖动就把这个 IP 的归属地永久判了死刑。
- 中英两套怎么来:
country/province/city/region是中文那一套,country_en/province_en/city_en/region_en是英文那一套。走离线库时同一次查询同时给出两套(不是互译,也不是两次请求)。- 地区搜索两边都能命中(
region LIKE ? OR region_en LIKE ?),搜「美国」与搜United States命中同一批记录。 - 界面展示跟随界面语言:英文界面显示
region_en,另一套进 tooltip;按地区排序时也换成对应的列,否则列表顺序和看到的地名对不上。缺一套时回退到另一套(「未知」视为缺失,不当成地名显示)。
- 地区搜索两边都能命中(
- 按出口 IP 去重:同一个出口 IP 上的一批代理共享一次查询并一次写完(按
real_ip更新),因此刚插入的记录没有 id 也不影响。 - 已知限制:两个库只保证「结论可追溯」,不保证与物理位置一致 —— 池里的 IP 多是机房/云主机,这类 IP 的地理位置本质是注册地而非物理位置,实测同一个 IP 在四个源上分别被判成日本、美国、新加坡、美国。这是数据源之间的固有分歧,不是解析问题。
自动清理
命中以下任一条件的代理会被删除 —— 健康分低于阈值且验证次数达标、连续失败次数超限、失效后超过保留天数(auto_cleanup.max_age_days_if_invalid,判据取 validated_at 距今天数,缺失时用入库时间;每次结论性验证无论成败都会刷新它,所以衡量的是「多久没被验证过」而非「多久没验证成功过」);收藏的代理不删。删除不可逆,因此上面那条「不可结论不惩罚」是它的前置防线。
删除只有这一处。曾经还有一套并行的删除逻辑(performance.* 那组键,按 validated_at 算保留期),两套判据不同、同一行记录被哪套删掉取决于时序,已合并到 auto_cleanup。