Files
2026-10-05 17:30:47 +08:00
..
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00
2026-10-05 17:30:47 +08:00

代理池模块

代理资源池:抓取公开代理、验证可用性、按质量评分管理,并按需提供给 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(两者都看不出)。判据不是代理的协议类型,而是它转发时目标究竟收到了什么。
      • 证据有两个载体,缺一不可。四个默认端点实测下来的行为完全不同:

        端点(前置) origin X-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。