Files
ProxyCat/modules/modules.py
T
2026-10-05 17:30:47 +08:00

1538 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
模块名称:modules.modules
功能描述:宿主侧公共基础层,集中提供跨模块复用的基础能力:配置读写与键迁移、国际化文案、
启动与运行期终端展示、代理可用性检测,以及 IP 名单与绕过名单的加载、匹配和变更监视。
职责边界:负责:配置读写与迁移、国际化文案、终端展示、代理检测、IP 名单和变更监视等公共能力;
不负责:出口转发与轮换调度(modules.proxyserver)、代理池内部业务(modules.proxypool_service
与 modules/proxypool/)、日志装配(modules.logging_setup)、Web 路由(app 与 modules.proxypool_api)。
关键依赖:标准库 configparser / socket / fnmatch / threading / asyncio / locale、colorama(终端着色)、
httpx(HTTP 代理检测)。
已知限制:
1. ini 写助手整文件读入、改写后原子替换,不做并发合并,两个写者同时保存时后写者胜。
2. 临时文件由 mkstemp 创建(固定 0600),换入前用 _copy_file_mode 复制原文件权限位,源文件不存在时保持默认。
3. read_config_text 先按 utf-8-sig 再按系统本地代码页解码,两级都失败才抛 UnicodeDecodeError,命中本地代码页回退会 warning。
4. 代理检测结果缓存是进程内的,键为 (代理 URL, 目标地址),TTL 10 秒、上限约 1000 条;检测抛意外异常时不写缓存,本次仍返回 False。
5. load_config 出错只记日志并回退默认配置副本;IP 名单读取失败返回空集合,均不向上抛出。
6. 文案表 cn / en 的键必须一一对应,缺键或语言未知时按「目标语言 → 中文 → 键名」回退,键名会被原样显示。
7. HTTP 出口探测客户端固定 trust_env=False(与转发侧客户端同口径),不继承系统代理环境变量;否则探测会绕经本机系统代理,测得的不是该出口的真实连通性。
8. SOCKS5 应答长度随 atyp 变(IPv4 10、IPv6 22、域名 7+len 字节),解析须按 atyp 变长读取,固定长度会把域名与 IPv6 代理误判为坏。
"""
import asyncio, logging, httpx, os, re, sys, time, fnmatch, threading, socket, locale
from configparser import ConfigParser
from typing import Any
from colorama import Fore, Style
CURRENT_VERSION = "ProxyCat-V3.0.0"
logger = logging.getLogger(__name__)
class ColoredFormatter(logging.Formatter):
COLORS = {
logging.INFO: Fore.GREEN,
logging.WARNING: Fore.YELLOW,
logging.ERROR: Fore.RED,
logging.CRITICAL: Fore.RED + Style.BRIGHT,
}
def format(self, record):
log_color = self.COLORS.get(record.levelno, Fore.WHITE)
colored = logging.makeLogRecord(record.__dict__)
colored.msg = f"{log_color}{record.getMessage()}{Style.RESET_ALL}"
colored.args = None
return super().format(colored)
MESSAGES = {
'cn': {
'proxy_check_disabled': '代理检测已禁用',
'valid_proxies': '检测完成,有效出口: {} 个',
'no_valid_proxies': '没有有效的代理地址',
'proxy_check_failed': '代理 {} 检测失败: {}',
'proxy_switch': '出口批次已更换: {} 个(原 {} 个)',
'proxy_invalid': '代理 {} 不可达,已从本批出口中剔除',
'proxy_check_error': '代理检查时发生错误: {}',
'server_closing': '服务器正在关闭...',
'current_proxy': '最近分配出口',
'next_switch': '最近到期',
'seconds': '秒',
'proxy_file_not_found': '代理文件不存在: {}',
'auth_not_set': '未设置 (无需认证)',
'public_account': '公众号',
'blog': '博客',
'proxy_mode': '运行模式',
'cycle': '按顺序使用',
'loadbalance': '负载均衡',
'continuous': '持续更换',
'request': '触发更换',
'single_round': '单轮',
'proxy_interval': '出口刷新间隔',
'default_auth': '默认账号密码',
'local_http': '本地监听地址 (HTTP)',
'local_socks5': '本地监听地址 (SOCKS5)',
'star_project': '开源项目求 Star',
'client_handle_error': '客户端处理错误: {}',
'user_interrupt': '用户中断程序',
'latest_version': '当前已是最新版本',
'new_version_available': '发现新版本:{}(当前 {})',
'version_not_checked': '尚未完成版本检查',
'version_info_not_found': '未找到版本信息',
'update_check_error': '检查更新失败: {}',
'unauthorized_ip': '未授权的IP尝试访问: {}',
'socks5_connection_error': 'SOCKS5连接错误: {}',
'connect_timeout': '连接超时',
'client_request_error': '客户端请求错误: {}',
'request_retry': '请求失败,重试中 (剩余{}次)',
'log_suppressed': '(窗口内另有 {} 条同类消息已折叠)',
'whitelist_error': '添加白名单失败: {}',
'api_mode_notice': '当前为API模式,收到请求将自动批量获取上游出口',
'server_running': '代理服务器运行在 {}:{}',
'server_start_error': '服务器启动错误: {}',
'server_shutting_down': '正在关闭服务器...',
'status_update_error': '状态更新出错',
'display_level_notice': '当前显示级别: {}',
'display_level_desc': '''显示级别说明:
0: 仅显示出口名册变化和错误信息
1: 显示出口名册、各出口状态、倒计时和错误信息
2: 显示所有详细信息''',
'new_client_connect': '新客户端连接 - IP: {}, 用户: {}',
'port_changed': '端口已更改: {} -> {},需要重启服务器生效',
'load_proxy_file_error': '加载代理文件失败: {}',
'proxy_check_result': '代理检查完成,有效代理:{}个',
'no_proxy': '无代理',
'proxy_check_start': '开始检查代理...',
'proxy_save_success': '代理保存成功',
'proxy_save_failed': '代理保存失败: {}',
'ip_list_save_success': 'IP名单保存成功',
'ip_list_save_failed': 'IP名单保存失败: {}',
'switch_success': '出口批次已刷新',
'switch_failed': '刷新出口批次失败: {}',
'switch_cooldown_msg': '刷新冷却中,请等待 {} 秒后再试',
'switch_in_progress': '出口批次刷新正在进行中,请稍后再试',
'service_start_success': '服务启动成功',
'service_already_running': '服务已在运行',
'service_stop_success': '服务停止成功',
'service_not_running': '服务未在运行',
'service_restart_success': '服务重启成功',
'invalid_action': '无效的操作',
'operation_failed': '操作失败: {}',
'logs_cleared': '日志已清除',
'clear_logs_failed': '清除日志失败: {}',
'unsupported_language': '不支持的语言',
'service_start_failed': '服务启动失败',
'service_restart_failed': '服务重启失败:{}',
'invalid_token': '无效的访问令牌',
'config_file_changed': '检测到配置文件更改,正在重新加载...',
'proxy_file_changed': '代理文件已更改,正在重新加载...',
'ip_lists_reloaded': '访问名单已重新加载:白名单 {} 条、黑名单 {} 条、绕过名单 {} 条',
'users_save_success': '用户保存成功',
'users_save_failed': '用户保存失败:{}',
'users_load_failed': '用户列表读取失败:{}',
'web_panel_url': '网页控制面板地址: {}',
'web_panel_notice': '请使用浏览器访问上述地址来管理代理服务器',
'all_retries_failed': '所有重试均已失败,最后错误: {}',
'proxy_get_failed': '获取代理失败',
'proxy_get_error': '获取代理错误: {}',
'proxy_config_error': '代理配置有误: {}',
'proxy_source_error_request': '取代理接口请求失败(网络不通、接口地址不可达或对方拒绝)',
'proxy_source_error_config': '取代理接口的配置有误(接口地址缺失,或接口返回了错误码)',
'proxy_source_error_unknown': '取代理接口失败',
'proxy_switch_error': '刷新出口批次出错: {}',
'proxy_allocate_error': '获取上游出口出错: {}',
'proxy_get_timeout': '获取上游出口超时,等待下次补货',
'pool_mode_notice': '当前为维护池模式,收到请求将自动批量获取上游出口',
'pool_no_proxy': '维护池中无可用代理',
'pool_unavailable': '维护池接口不可用: {}',
'pool_fetch_failed': '从维护池获取代理失败: {}',
'proxy_workers_ready': '已就绪 {} 个上游出口',
'elastic_expanded': '出口池已扩容至 {} 个出口(高峰后自动收回)',
'elastic_capacity_boosted': '来源暂时拿不出新出口,单出口并发额度已临时放宽至 {} 倍',
'elastic_released': '出口池已停止扩容,多出的出口将在寿命到期后逐个撤下(当前 {} 个出口)',
'exit_pool_drained': '没有任务在跑,已到寿命的出口直接移除(当前 {} 个出口,下次请求会重新取货)',
'exit_pool_trimmed': '出口池已按容量收回多余的出口,当前 {} 个出口',
'proxy_exit_added': '新增出口 {}',
'proxy_exit_rotated': '轮换出口 {} -> {}',
'proxy_worker_retired': '上游出口 {} 近期失败占比过高,已停用并等待补货',
'proxy_batch_fetch_failed': '批量获取代理失败: {}',
'proxy_batch_partial': '批量获取到 {} 个代理,期望 {} 个',
'exits_summary': '上游出口: {}/{}',
'exits_summary_expanded': '上游出口: {}/{}(高峰期扩容中)',
'exit_line': ' {} 在用 {} 剩余 {}',
'exit_load_no_cap': '{}(不限)',
'exit_left_requests': '{} 次',
'exit_left_none': '不换',
'pool_start_success': '维护池启动成功',
'pool_start_failed': '维护池启动失败: {}',
'pool_stop_success': '维护池停止成功',
'pool_stop_failed': '维护池停止失败: {}',
'pool_restart_success': '维护池重启成功',
'pool_restart_failed': '维护池重启失败: {}',
'pool_status_error': '获取维护池状态失败: {}',
'pool_starting': '正在启动代理池...',
'pool_stopping': '正在停止代理池...',
'pool_unknown_error': '未知原因',
'pool_start_timeout': '维护池初始化超时({} 秒)',
'pool_stop_timeout': '维护池未能在 {} 秒内停止',
'pool_previous_stop_incomplete': (
'上一次停止未完成,旧线程仍在运行;为避免两个池实例共用同一数据库,'
'本次启动已拒绝。请重启进程。'
),
'pool_loop_crashed': '维护池事件循环异常退出: {}',
'pool_config_unreadable': '配置文件暂时读不出来,保持当前池配置,下一轮重试',
'pool_invalid_parameters': '维护池请求参数错误: {}',
'pool_request_failed': '维护池请求处理失败: {}',
'pool_internal_error': '维护池内部错误',
'pool_restore_requires_stop': '恢复备份前请先停止代理池(正在关闭中也需等它完全退出)',
'pool_backup_filename_required': '缺少备份文件名',
'pool_db_backup_disabled': '数据库备份功能未启用',
'pool_db_maintenance_disabled': '数据库维护功能不可用',
'pool_db_optimize_done': '数据库优化完成',
'pool_db_optimize_failed': '数据库优化失败,请查看日志',
'pool_backup_restore_success': '备份恢复成功,请重启代理池以加载恢复后的数据',
'pool_backup_restore_bad_name': '备份恢复失败:文件不存在或名称非法',
'pool_backup_restore_invalid_backup': '备份恢复失败:该文件不是有效的 SQLite 数据库',
'pool_backup_restore_wal_locked': '备份恢复失败:无法删除原数据库的 WAL 边车文件,可能仍有进程持有数据库',
'pool_backup_restore_verify_failed': '备份恢复失败:恢复后的数据库校验未通过,已回滚到原数据库',
'pool_backup_restore_error': '备份恢复失败:{}',
'pool_autostart_source_switched': '代理来源已切换为维护池,自动启动代理池',
'pool_autostart_required': '配置要求使用内置代理池,自动启动代理池',
'pool_autostart_skipped': '当前代理来源不使用内置代理池,跳过启动池',
'pool_error_http_status': '维护池返回异常状态码: {}',
'pool_error_connect': '无法连接到维护池: {}',
'pool_error_timeout': '维护池请求超时',
'pool_error_invalid_format': '维护池返回的代理格式无效: {}',
'port_in_use': '端口 {} 已被其他程序占用,请更换其他端口后重试',
'web_port_in_use': 'Web 面板端口 {} 已被其他程序占用,请修改 config.ini 的 web_port 后重新启动',
'access_ok': '[成功] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {}',
'access_failed': '[失败] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {} 原因 {}',
'access_aborted': '[中断] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {} 原因 {}',
'access_direct': '直连',
'access_unknown': '未知',
'access_incomplete': '请求未完成(客户端断开或请求被取消)',
'access_tunnel_no_data': '隧道已建立但上游未返回任何数据(客户端等不到响应)',
'access_tunnel_idle': '隧道已建立,客户端未发送数据即断开',
'access_tunnel_client_left': '隧道已建立,客户端在收到上游数据前主动断开',
'access_tunnel_closed_by_upstream': '隧道已建立,上游未发送任何数据即关闭连接',
'proxy_silent_upstream': '代理 {} 收下 CONNECT 后 {} 秒内未回传任何数据,判定为不可用并触发健康检查',
'domain_stats_cleared': '已清除 {} 个域名的访问统计',
'access_outcome_success': '成功',
'access_outcome_failure': '失败',
'access_outcome_aborted': '中断',
'access_records_cleared': '已清除 {} 条访问记录',
'access_records_bad_outcome': '结果筛选取值非法(只能是 success、failure、aborted 之一):{}',
'access_records_bad_format': '导出格式非法(只能是 csv 或 json):{}',
'pool_geo_update_task_created': '离线归属地库更新任务已创建',
'pool_geo_recompute_disabled': '归属地解析服务未启用,无法重算',
'pool_geo_recompute_task_created': '归属地重算任务已创建',
'pool_plugin_enabled': '插件 {} 已启用',
'pool_plugin_disabled': '插件 {} 已禁用',
'pool_plugin_enable_failed': '启用插件失败: {}',
'pool_plugin_disable_failed': '禁用插件失败: {}',
'pool_plugin_not_loaded': '插件 {} 未加载',
'pool_plugin_interval_set': '插件 {} 运行周期已设置为 {} 分钟',
'pool_plugin_interval_failed': '设置插件运行周期失败: {}',
'pool_plugin_test_url_set': '插件 {} 的测试地址已设置为 {}',
'pool_plugin_test_url_inherited': '插件 {} 的测试地址已恢复为继承全局地址',
'pool_plugin_test_url_failed': '设置插件测试地址失败: {}',
'pool_plugin_validation_set': '插件 {} 的验证策略已更新',
'pool_plugin_validation_failed': '设置插件验证策略失败: {}',
'pool_plugin_run_task_created': '插件 {} 抓取任务已创建',
'pool_plugin_reload_success': '插件 {} 热重载成功',
'pool_plugin_reload_rejected': '插件 {} 热重载失败(可能正在执行中或文件不存在)',
'pool_plugin_reload_failed': '热重载失败: {}',
'pool_no_matching_proxies': '没有符合条件的代理',
'pool_proxy_not_found': '代理不存在',
'config_option_invalid': '配置项 {} 非法(当前值: {}):{}',
'config_reason_expected_int': '期望整数',
'config_reason_expected_float': '期望小数',
'config_reason_expected_bool': '期望布尔值(true/false)',
'config_reason_expected_comma_list': '期望至少一个逗号分隔的取值',
'config_reason_expected_value': '期望至少一个取值',
'config_reason_min': '不能小于 {}',
'config_reason_max': '不能大于 {}',
'config_reason_expected_choice': '只能是 {} 之一',
'config_reason_expected_ip': '必须是一到两个合法的 IP 地址(IPv4 与 IPv6 各至多一个,逗号分隔)',
'config_reason_derived_key': '该配置项由 [Server] 段派生,不能单独设置',
'config_reason_unknown_key': '不是可修改的配置项',
'config_reason_users_via_api': '用户账号请通过用户管理接口修改',
'task_stage_prescreen_text': '正在初筛端口连通性...',
'task_stage_validate_text': '正在验证代理...',
'task_stage_ingest_text': '正在直接入库(该插件已关闭入库验证)...',
'task_cancelled_with_counts': '任务已取消,{}',
'task_failed': '任务失败: {}',
'task_counts_saved_unusable': '已入库 {} 个,不可用 {} 个',
'task_plugin_run_done': '插件 {} 执行成功,{}',
'task_validating': '正在验证代理...',
'task_validate_selected_done': '验证完成,有效: {}/{}',
'task_inconclusive_suffix': ',{} 个因检测链路问题未得出结论',
'task_validate_all_done': '验证完成,共验证 {} 个代理',
'task_inconclusive_among_suffix': ',其中 {} 个因检测链路问题未得出结论',
'task_validate_failed': '验证失败: {}',
'task_no_proxies_to_validate': '没有需要验证的代理',
'task_checking': '正在检测代理...',
'task_pool_empty': '池里还没有代理',
'task_check_done': '检测完成,共处理 {} 个代理',
'task_check_failed': '检测失败: {}',
'task_plugin_fetching': '正在抓取插件 {}...(外部源较多时最长约 {} 分钟)',
'task_plugin_no_new_proxies': '插件 {} 执行完成,未获取到新代理',
'task_plugin_fetch_cancelled': '插件 {} 抓取任务已取消,{}',
'task_plugin_failed': '插件 {} 执行失败: {}',
'task_geo_downloading': '正在下载离线归属地库...',
'task_geo_downloading_file': '正在下载 {}({}%)',
'task_geo_update_cancelled': '离线归属地库更新已取消',
'task_geo_update_failed': '更新失败: {}',
'task_geo_update_done': '离线归属地库更新完成',
'task_geo_update_partial': '部分文件更新失败({}),其余已更新',
'task_geo_recompute_disabled': '归属地解析服务未启用,无法重算',
'task_geo_picking': '正在挑选待重算的记录...',
'task_geo_nothing_to_recompute': '没有需要重算的记录',
'task_geo_queued': '已排入 {} 个出口 IP,正在解析...',
'task_geo_resolving': '正在解析归属地 {}/{}',
'task_geo_recompute_cancelled': '归属地重算已取消(已解析的部分保留)',
'task_geo_recompute_failed': '重算失败: {}',
'task_geo_recompute_done': '归属地重算完成,共处理 {} 个出口 IP',
'ingest_summary_candidates': '候选 {} 个',
'ingest_summary_prescreened_out': '初筛淘汰 {} 个',
'ingest_summary_validated': '验证 {} 个',
'ingest_summary_saved': '入库 {} 个',
'ingest_summary_invalid': '不可用 {} 个',
'ingest_summary_incomplete': '信息不全 {} 个',
'ingest_summary_inconclusive': '未能判定 {} 个',
'ingest_summary_failed': '失败 {} 个',
'list_separator': ',',
'config_body_must_be_json': '请求体必须是 JSON 对象',
'proxies_must_be_list': '代理列表必须是数组',
'config_sections_must_be_json': 'server 与 pool 都必须是 JSON 对象',
'config_nothing_to_save': '没有需要保存的配置项',
'ip_list_type_invalid': '类型必须为 whitelist 或 blacklist',
'log_category_unknown': '未知的日志类别',
'log_file_missing': '日志文件不存在',
'log_file_name_invalid': '非法的日志文件名: {}',
'log_file_read_failed': '读取日志文件失败: {}',
'credential_name_required': '凭据名称不能为空',
'credential_saved': '凭据已保存',
'credential_deleted': '凭据已删除',
'credential_switched': '已切换凭据',
'credential_not_found': '凭据不存在: {}',
'unknown_action': '未知操作',
'no_current_proxy': '无',
'service_thread_still_running': '旧服务线程未能在超时时间内退出,请稍后重试',
'service_stop_timeout': '服务未能在超时时间内停止',
'param_invalid_protocol': '不支持的协议: {}',
'param_invalid_status': '不支持的状态: {}',
'param_negative_min_delay': '最小延迟不能为负数: {}',
'param_negative_max_delay': '最大延迟不能为负数: {}',
'param_delay_range_reversed': '最小延迟不能大于最大延迟: {} > {}',
'param_negative_min_health': '最低健康分不能为负数: {}',
'param_invalid_anonymity': '不支持的匿名等级: {}',
'param_invalid_export_format': '不支持的格式: {},仅支持 {}',
'param_invalid_validation_mode': '不支持的验证强度: {}(只能是 {} 之一)',
'param_page_must_be_positive': '页码必须大于 0: {}',
'param_page_size_must_be_positive': '每页数量必须大于 0: {}',
'param_page_size_too_large': '每页数量不能大于 {}: {}',
'param_limit_must_be_positive': 'limit 必须大于 0: {}',
'param_limit_too_large': 'limit 不能大于 {}: {}',
'param_proxy_list_empty': '代理列表不能为空',
'param_proxy_ids_empty': '代理ID列表不能为空',
'param_body_must_be_json': '请求体必须是 JSON 对象',
'param_missing_fields': '缺少必需的参数: {}',
'param_interval_must_be_positive': '运行周期必须大于 0 分钟: {}',
'param_reval_interval_not_int': '重验证间隔必须是整数: {}',
'pool_not_running': '代理池未运行',
'pool_stopped': '代理池已停止',
'access_reason_bad_target': '非法的目标地址: {}',
'access_reason_direct_failed': '直连目标失败: {}',
'pool_call_timeout': '代理池调用超时({} 秒)',
'pool_not_enabled': '代理池未启用',
'getip_api_error_code': 'API返回error000x-13',
'csv_col_protocol': '协议', 'csv_col_ip': 'IP', 'csv_col_port': '端口',
'csv_col_username': '用户名', 'csv_col_password': '密码',
'csv_col_proxy_url': '代理URL', 'csv_col_region': '地区',
'csv_col_delay_ms': '延迟(ms)', 'csv_col_status': '状态',
'csv_col_real_ip': '真实IP', 'csv_col_source': '来源',
'csv_col_validated_at': '验证时间',
'csv_status_valid': '有效', 'csv_status_invalid': '失效',
'csv_col_time': '时间', 'csv_col_kind': '类型', 'csv_col_method': '方法',
'csv_col_client_ip': '客户端IP', 'csv_col_client_user': '客户端用户',
'csv_col_host': '目标域名', 'csv_col_upstream': '上游代理',
'csv_col_outcome': '结果', 'csv_col_status_code': '状态码',
'csv_col_elapsed': '耗时(ms)', 'csv_col_reason': '原因',
'plugin_error_bad_return_type': '返回值类型错误',
'plugin_error_timeout': '执行超时',
'plugin_error_bad_spec': '无效的模块规范',
'plugin_error_missing_interface': '未实现必需的接口',
'pool_field_protocol_required': '协议不能为空',
'pool_field_protocol_unsupported': '不支持的协议: {}',
'pool_field_ip_required': 'IP 地址不能为空',
'pool_field_port_invalid': '无效的端口: {}',
'pool_field_source_required': '来源插件不能为空',
'pool_field_update_not_allowed': '不允许更新的字段: {}',
'pool_field_where_not_allowed': '不允许作为更新条件的字段: {}',
'pool_field_is_valid_invalid': '无效的 is_valid 值: {}',
'pool_field_anonymity_invalid': '无效的匿名等级: {}',
'pool_field_plugin_name_required': '插件名称不能为空',
'pool_field_plugin_interval_invalid': '运行间隔必须大于 0: {}',
'pool_field_reval_interval_invalid': '重验证间隔不能为负数: {}',
'pool_repo_integrity_error': '代理数据完整性错误: {}',
'pool_repo_plugin_integrity_error': '插件配置数据完整性错误: {}',
'pool_repo_query_build_failed': '查询构建失败: {}',
'pool_random_proxy_bad_format': 'format 参数必须是 json 或 text',
'pool_import_no_valid_proxy': '没有有效的代理可导入',
'pool_import_duplicated': '所有代理已存在,未导入新代理',
'pool_import_validating': '正在后台验证 {} 个代理,通过后才入库',
'pool_favorite_added': '代理已收藏',
'pool_favorite_removed': '代理已取消收藏',
'pool_validation_task_created': '验证任务已创建',
'pool_full_check_task_created': '代理全面检测任务已创建',
'pool_validation_no_valid_proxy': '未找到有效的代理',
'pool_validating_proxies': '正在验证 {} 个代理',
'pool_no_invalid_proxies': '没有失效代理需要删除',
'pool_invalid_proxies_deleted': '成功删除 {} 个失效代理',
'pool_proxies_deleted': '成功删除 {} 个代理',
'pool_task_not_found': '任务不存在',
'pool_task_already_finished': '任务已经结束,无需取消',
'pool_task_cancelled': '任务已取消',
},
'en': {
'proxy_check_disabled': 'Proxy check is disabled',
'valid_proxies': 'Check done, valid exits: {}',
'no_valid_proxies': 'No valid proxies found',
'proxy_check_failed': 'Proxy {} check failed: {}',
'proxy_switch': 'Upstream exits replaced: {} (was {})',
'proxy_invalid': 'Proxy {} unreachable, dropped from this batch',
'proxy_check_error': 'Error occurred during proxy check: {}',
'server_closing': 'Server is closing...',
'current_proxy': 'Last Assigned Exit',
'next_switch': 'Next expiry',
'seconds': 's',
'proxy_file_not_found': 'Proxy file not found: {}',
'auth_not_set': 'Not set (No authentication required)',
'public_account': 'WeChat Public Number',
'blog': 'Blog',
'proxy_mode': 'Run Mode',
'cycle': 'In order',
'loadbalance': 'Least loaded',
'continuous': 'Continuous rotation',
'request': 'On-request rotation',
'single_round': 'Single Round',
'proxy_interval': 'Exit Refresh Interval',
'default_auth': 'Default Username and Password',
'local_http': 'Local Listening Address (HTTP)',
'local_socks5': 'Local Listening Address (SOCKS5)',
'star_project': 'Star the Project',
'client_handle_error': 'Client handling error: {}',
'user_interrupt': 'User interrupted the program',
'latest_version': 'You are using the latest version',
'new_version_available': 'New version available: {} (current {})',
'version_not_checked': 'Version check has not completed yet',
'version_info_not_found': 'Version information not found',
'update_check_error': 'Failed to check for updates: {}',
'unauthorized_ip': 'Unauthorized IP attempt: {}',
'socks5_connection_error': 'SOCKS5 connection error: {}',
'connect_timeout': 'Connection timeout',
'client_request_error': 'Client request handling error: {}',
'request_retry': 'Request failed, retrying ({} left)',
'log_suppressed': ' ({} similar messages suppressed in this window)',
'whitelist_error': 'Failed to add whitelist: {}',
'api_mode_notice': 'Currently in API mode, a batch of upstream exits will be fetched on demand',
'all_retries_failed': 'All retries failed, last error: {}',
'proxy_get_failed': 'Failed to get proxy',
'proxy_get_error': 'Error getting proxy: {}',
'proxy_config_error': 'Invalid proxy configuration: {}',
'proxy_source_error_request': 'Fetching proxies failed (network unreachable, bad endpoint, or the provider refused)',
'proxy_source_error_config': 'Proxy-fetch configuration is wrong (endpoint missing, or the provider returned an error code)',
'proxy_source_error_unknown': 'Fetching proxies failed',
'proxy_switch_error': 'Error refreshing upstream exits: {}',
'proxy_allocate_error': 'Error obtaining an upstream exit: {}',
'proxy_get_timeout': 'Upstream exit fetch timed out; waiting for the next replenish',
'server_running': 'Proxy server running at {}:{}',
'server_start_error': 'Server startup error: {}',
'server_shutting_down': 'Server shutting down...',
'status_update_error': 'Status update error',
'display_level_notice': 'Current display level: {}',
'display_level_desc': '''Display level description:
0: Only show exit roster changes and error messages
1: Show the exit roster, per-exit state, countdown and error messages
2: Show all detailed information''',
'new_client_connect': 'New client connection - IP: {}, User: {}',
'port_changed': 'Port changed: {} -> {}, server restart required to take effect',
'load_proxy_file_error': 'Failed to load proxy file: {}',
'proxy_check_result': 'Proxy check completed, valid proxies: {}',
'no_proxy': 'No proxy',
'proxy_check_start': 'Starting proxy check...',
'proxy_save_success': 'Proxy saved successfully',
'proxy_save_failed': 'Failed to save proxy: {}',
'ip_list_save_success': 'IP list saved successfully',
'ip_list_save_failed': 'Failed to save IP list: {}',
'switch_success': 'Upstream exit batch refreshed',
'switch_failed': 'Failed to refresh upstream exits: {}',
'switch_cooldown_msg': 'Refresh cooldown, wait {} seconds',
'switch_in_progress': 'Exit batch refresh in progress, please try again later',
'service_start_success': 'Service started successfully',
'service_already_running': 'Service is already running',
'service_stop_success': 'Service stopped successfully',
'service_not_running': 'Service is not running',
'service_restart_success': 'Service restarted successfully',
'invalid_action': 'Invalid action',
'operation_failed': 'Operation failed: {}',
'logs_cleared': 'Logs cleared',
'clear_logs_failed': 'Failed to clear logs: {}',
'unsupported_language': 'Unsupported language',
'service_start_failed': 'Failed to start service',
'service_restart_failed': 'Failed to restart service: {}',
'invalid_token': 'Invalid access token',
'config_file_changed': 'Config file change detected, reloading...',
'proxy_file_changed': 'Proxy file changed, reloading...',
'ip_lists_reloaded': 'Access lists reloaded: {} whitelist, {} blacklist, {} bypass entries',
'users_save_success': 'Users saved successfully',
'users_save_failed': 'Failed to save users: {}',
'users_load_failed': 'Failed to load the user list: {}',
'web_panel_url': 'Web panel URL: {}',
'web_panel_notice': 'Please use a browser to access the above URL to manage the proxy server',
'pool_mode_notice': 'Currently in pool mode, a batch of upstream exits will be fetched on demand',
'pool_no_proxy': 'No proxy available in pool',
'pool_unavailable': 'Pool API unavailable: {}',
'pool_fetch_failed': 'Failed to fetch proxy from pool: {}',
'proxy_workers_ready': '{} upstream exits ready',
'elastic_expanded': 'Exit pool expanded to {} exits (released automatically after the peak)',
'elastic_capacity_boosted': 'Source has no new exits right now; per-exit concurrency temporarily raised to {}x',
'elastic_released': 'Elastic capacity released; surplus exits retire as their lifetime ends ({} exits now)',
'exit_pool_drained': 'No tasks are running, so expired exits were removed outright ({} exits now; the next request fetches fresh ones)',
'exit_pool_trimmed': 'Exit pool trimmed back to the configured size; {} exits now',
'proxy_exit_added': 'Exit added: {}',
'proxy_exit_rotated': 'Exit rotated: {} -> {}',
'proxy_worker_retired': 'Upstream exit {} is failing too often; retired and awaiting replacement',
'proxy_batch_fetch_failed': 'Batch proxy fetch failed: {}',
'proxy_batch_partial': 'Fetched {} proxies, expected {}',
'exits_summary': 'Upstream exits: {}/{}',
'exits_summary_expanded': 'Upstream exits: {}/{} (peak expansion active)',
'exit_line': ' {} in use {} left {}',
'exit_load_no_cap': '{} (no cap)',
'exit_left_requests': '{} reqs',
'exit_left_none': 'kept',
'pool_start_success': 'Pool started successfully',
'pool_start_failed': 'Failed to start pool: {}',
'pool_stop_success': 'Pool stopped successfully',
'pool_stop_failed': 'Failed to stop pool: {}',
'pool_restart_success': 'Pool restarted successfully',
'pool_restart_failed': 'Failed to restart pool: {}',
'pool_status_error': 'Failed to get pool status: {}',
'pool_starting': 'Starting proxy pool...',
'pool_stopping': 'Stopping proxy pool...',
'pool_unknown_error': 'unknown reason',
'pool_start_timeout': 'Pool initialization timed out ({}s)',
'pool_stop_timeout': 'Pool did not stop within {}s',
'pool_previous_stop_incomplete': (
'The previous stop did not finish and the old thread is still running; '
'refusing to start a second pool instance against the same database. '
'Restart the process.'
),
'pool_loop_crashed': 'Pool event loop exited with an error: {}',
'pool_config_unreadable': 'Config file is temporarily unreadable, keeping the current pool config and retrying next round',
'pool_invalid_parameters': 'Invalid pool request parameters: {}',
'pool_request_failed': 'Pool request failed: {}',
'pool_internal_error': 'Pool internal error',
'pool_restore_requires_stop': 'Stop the pool before restoring a backup (wait for a stopping pool to exit completely)',
'pool_backup_filename_required': 'Backup file name is required',
'pool_db_backup_disabled': 'Database backup is not enabled',
'pool_db_maintenance_disabled': 'Database maintenance is unavailable',
'pool_db_optimize_done': 'Database optimization completed',
'pool_db_optimize_failed': 'Database optimization failed, please check the logs',
'pool_backup_restore_success': 'Backup restored; restart the pool to load the restored data',
'pool_backup_restore_bad_name': 'Restore failed: the file does not exist or its name is invalid',
'pool_backup_restore_invalid_backup': 'Restore failed: the file is not a valid SQLite database',
'pool_backup_restore_wal_locked': 'Restore failed: cannot remove the original database WAL sidecar files (another process may still hold the database)',
'pool_backup_restore_verify_failed': 'Restore failed: the restored database failed verification and was rolled back',
'pool_backup_restore_error': 'Restore failed: {}',
'pool_autostart_source_switched': 'Proxy source switched to the pool, starting it automatically',
'pool_autostart_required': 'Config requires the built-in pool, starting it automatically',
'pool_autostart_skipped': 'The current proxy source does not use the built-in pool, skipping pool start',
'pool_error_http_status': 'Pool returned abnormal status code: {}',
'pool_error_connect': 'Cannot connect to pool: {}',
'pool_error_timeout': 'Pool request timeout',
'pool_error_invalid_format': 'Invalid proxy format from pool: {}',
'port_in_use': 'Port {} is already in use by another program, please choose a different port and retry',
'web_port_in_use': 'Web panel port {} is already in use by another program, please change web_port in config.ini and restart',
'access_ok': '[OK] client {} upstream {} target {} {} {}ms status {}',
'access_failed': '[FAIL] client {} upstream {} target {} {} {}ms status {} reason {}',
'access_aborted': '[ABORT] client {} upstream {} target {} {} {}ms status {} reason {}',
'access_direct': 'direct',
'access_unknown': 'unknown',
'access_incomplete': 'request not completed (client disconnected or cancelled)',
'access_tunnel_no_data': 'tunnel established but upstream returned no data (client got no response)',
'access_tunnel_idle': 'tunnel established, client disconnected without sending data',
'access_tunnel_client_left': 'tunnel established, client gave up before any upstream data',
'access_tunnel_closed_by_upstream': 'tunnel established, upstream closed without sending data',
'proxy_silent_upstream': 'Proxy {} accepted CONNECT but sent no data within {}s; marked unusable and health-checked',
'domain_stats_cleared': 'Cleared access statistics for {} domains',
'access_outcome_success': 'Success',
'access_outcome_failure': 'Failure',
'access_outcome_aborted': 'Aborted',
'access_records_cleared': 'Cleared {} access records',
'access_records_bad_outcome': 'Invalid outcome filter (must be one of success, failure, aborted): {}',
'access_records_bad_format': 'Invalid export format (must be csv or json): {}',
'pool_geo_update_task_created': 'Geo database update task created',
'pool_geo_recompute_disabled': 'Geo resolver is not enabled, cannot recompute',
'pool_geo_recompute_task_created': 'Geo recompute task created',
'pool_plugin_enabled': 'Plugin {} enabled',
'pool_plugin_disabled': 'Plugin {} disabled',
'pool_plugin_enable_failed': 'Failed to enable plugin: {}',
'pool_plugin_disable_failed': 'Failed to disable plugin: {}',
'pool_plugin_not_loaded': 'Plugin {} is not loaded',
'pool_plugin_interval_set': 'Plugin {} interval set to {} minutes',
'pool_plugin_interval_failed': 'Failed to set the plugin interval: {}',
'pool_plugin_test_url_set': 'Test URL of plugin {} set to {}',
'pool_plugin_test_url_inherited': 'Test URL of plugin {} restored to inherit the global URL',
'pool_plugin_test_url_failed': 'Failed to set the plugin test URL: {}',
'pool_plugin_validation_set': 'Validation policy of plugin {} updated',
'pool_plugin_validation_failed': 'Failed to set the plugin validation policy: {}',
'pool_plugin_run_task_created': 'Fetch task created for plugin {}',
'pool_plugin_reload_success': 'Plugin {} reloaded successfully',
'pool_plugin_reload_rejected': 'Failed to reload plugin {} (it may be running or the file does not exist)',
'pool_plugin_reload_failed': 'Hot reload failed: {}',
'pool_no_matching_proxies': 'No proxies match the filter',
'pool_proxy_not_found': 'Proxy not found',
'config_option_invalid': 'Invalid option {} (current value: {}): {}',
'config_reason_expected_int': 'expected an integer',
'config_reason_expected_float': 'expected a decimal number',
'config_reason_expected_bool': 'expected a boolean (true/false)',
'config_reason_expected_comma_list': 'expected at least one comma-separated value',
'config_reason_expected_value': 'expected at least one value',
'config_reason_min': 'must not be less than {}',
'config_reason_max': 'must not be greater than {}',
'config_reason_expected_choice': 'must be one of {}',
'config_reason_expected_ip': 'must be one or two valid IP addresses (at most one IPv4 and one IPv6, comma-separated)',
'config_reason_derived_key': 'this option is derived from the [Server] section and cannot be set separately',
'config_reason_unknown_key': 'not an editable option',
'config_reason_users_via_api': 'manage user accounts through the user management API',
'task_stage_prescreen_text': 'Checking port reachability...',
'task_stage_validate_text': 'Validating proxies...',
'task_stage_ingest_text': 'Storing directly (ingest validation is off for this plugin)...',
'task_cancelled_with_counts': 'Task cancelled, {}',
'task_failed': 'Task failed: {}',
'task_counts_saved_unusable': '{} stored, {} unusable',
'task_plugin_run_done': 'Plugin {} finished, {}',
'task_validating': 'Validating proxies...',
'task_validate_selected_done': 'Done, valid: {}/{}',
'task_inconclusive_suffix': ', {} inconclusive (detection chain issue)',
'task_validate_all_done': 'Done, {} proxies checked',
'task_inconclusive_among_suffix': ', {} of them inconclusive (detection chain issue)',
'task_validate_failed': 'Validation failed: {}',
'task_no_proxies_to_validate': 'No proxies need validating',
'task_checking': 'Running a full check...',
'task_pool_empty': 'The pool has no proxies yet',
'task_check_done': 'Check finished, {} proxies processed',
'task_check_failed': 'Check failed: {}',
'task_plugin_fetching': 'Fetching plugin {}... (up to about {} minutes with many sources)',
'task_plugin_no_new_proxies': 'Plugin {} finished without new proxies',
'task_plugin_fetch_cancelled': 'Fetch task for plugin {} cancelled, {}',
'task_plugin_failed': 'Plugin {} failed: {}',
'task_geo_downloading': 'Downloading the offline geo databases...',
'task_geo_downloading_file': 'Downloading {} ({}%)',
'task_geo_update_cancelled': 'Geo database update cancelled',
'task_geo_update_failed': 'Update failed: {}',
'task_geo_update_done': 'Geo databases updated',
'task_geo_update_partial': 'Some files failed to update ({}), the rest are up to date',
'task_geo_recompute_disabled': 'The geo resolver is not enabled, cannot recompute',
'task_geo_picking': 'Picking records to recompute...',
'task_geo_nothing_to_recompute': 'No records need recomputing',
'task_geo_queued': '{} exit IPs queued for resolution...',
'task_geo_resolving': 'Resolving geo {}/{}',
'task_geo_recompute_cancelled': 'Geo recompute cancelled (already resolved parts are kept)',
'task_geo_recompute_failed': 'Recompute failed: {}',
'task_geo_recompute_done': 'Geo recompute finished, {} exit IPs processed',
'ingest_summary_candidates': '{} candidates',
'ingest_summary_prescreened_out': '{} unreachable',
'ingest_summary_validated': '{} validated',
'ingest_summary_saved': '{} stored',
'ingest_summary_invalid': '{} unusable',
'ingest_summary_incomplete': '{} incomplete',
'ingest_summary_inconclusive': '{} inconclusive',
'ingest_summary_failed': '{} failed',
'list_separator': ', ',
'config_body_must_be_json': 'The request body must be a JSON object',
'proxies_must_be_list': 'The proxy list must be an array',
'config_sections_must_be_json': 'Both server and pool must be JSON objects',
'config_nothing_to_save': 'Nothing to save',
'ip_list_type_invalid': 'Type must be whitelist or blacklist',
'log_category_unknown': 'Unknown log category',
'log_file_missing': 'Log file does not exist',
'log_file_name_invalid': 'Invalid log file name: {}',
'log_file_read_failed': 'Failed to read the log file: {}',
'credential_name_required': 'Credential name is required',
'credential_saved': 'Credential saved',
'credential_deleted': 'Credential deleted',
'credential_switched': 'Credential switched',
'credential_not_found': 'Credential not found: {}',
'unknown_action': 'Unknown action',
'no_current_proxy': 'none',
'service_thread_still_running': 'the previous service thread did not exit within the timeout, please retry later',
'service_stop_timeout': 'the service did not stop within the timeout',
'param_invalid_protocol': 'Unsupported protocol: {}',
'param_invalid_status': 'Unsupported status: {}',
'param_negative_min_delay': 'Minimum delay cannot be negative: {}',
'param_negative_max_delay': 'Maximum delay cannot be negative: {}',
'param_delay_range_reversed': 'Minimum delay cannot exceed maximum delay: {} > {}',
'param_negative_min_health': 'Minimum health score cannot be negative: {}',
'param_invalid_anonymity': 'Unsupported anonymity level: {}',
'param_invalid_export_format': 'Unsupported format: {}; only {} are supported',
'param_invalid_validation_mode': 'Unsupported validation mode: {} (must be one of {})',
'param_page_must_be_positive': 'Page number must be greater than 0: {}',
'param_page_size_must_be_positive': 'Page size must be greater than 0: {}',
'param_page_size_too_large': 'Page size cannot exceed {}: {}',
'param_limit_must_be_positive': 'limit must be greater than 0: {}',
'param_limit_too_large': 'limit cannot exceed {}: {}',
'param_proxy_list_empty': 'The proxy list cannot be empty',
'param_proxy_ids_empty': 'The proxy ID list cannot be empty',
'param_body_must_be_json': 'The request body must be a JSON object',
'param_missing_fields': 'Missing required parameters: {}',
'param_interval_must_be_positive': 'The run interval must be greater than 0 minutes: {}',
'param_reval_interval_not_int': 'Re-validation interval must be an integer: {}',
'pool_not_running': 'The proxy pool is not running',
'pool_stopped': 'The proxy pool has stopped',
'access_reason_bad_target': 'Invalid target address: {}',
'access_reason_direct_failed': 'Could not connect directly to the target: {}',
'pool_call_timeout': 'Pool call timed out ({} s)',
'pool_not_enabled': 'The proxy pool is not enabled',
'getip_api_error_code': 'the API returned error000x-13',
'csv_col_protocol': 'Protocol', 'csv_col_ip': 'IP', 'csv_col_port': 'Port',
'csv_col_username': 'Username', 'csv_col_password': 'Password',
'csv_col_proxy_url': 'Proxy URL', 'csv_col_region': 'Region',
'csv_col_delay_ms': 'Delay (ms)', 'csv_col_status': 'Status',
'csv_col_real_ip': 'Exit IP', 'csv_col_source': 'Source',
'csv_col_validated_at': 'Validated at',
'csv_status_valid': 'valid', 'csv_status_invalid': 'invalid',
'csv_col_time': 'Time', 'csv_col_kind': 'Kind', 'csv_col_method': 'Method',
'csv_col_client_ip': 'Client IP', 'csv_col_client_user': 'Client user',
'csv_col_host': 'Host', 'csv_col_upstream': 'Upstream proxy',
'csv_col_outcome': 'Outcome', 'csv_col_status_code': 'Status code',
'csv_col_elapsed': 'Elapsed (ms)', 'csv_col_reason': 'Reason',
'plugin_error_bad_return_type': 'unexpected return type',
'plugin_error_timeout': 'execution timed out',
'plugin_error_bad_spec': 'invalid module spec',
'plugin_error_missing_interface': 'required interface not implemented',
'pool_field_protocol_required': 'Protocol is required',
'pool_field_protocol_unsupported': 'Unsupported protocol: {}',
'pool_field_ip_required': 'IP address is required',
'pool_field_port_invalid': 'Invalid port: {}',
'pool_field_source_required': 'Source plugin is required',
'pool_field_update_not_allowed': 'Fields that may not be updated: {}',
'pool_field_where_not_allowed': 'Fields that may not be used as update conditions: {}',
'pool_field_is_valid_invalid': 'Invalid is_valid value: {}',
'pool_field_anonymity_invalid': 'Invalid anonymity level: {}',
'pool_field_plugin_name_required': 'Plugin name is required',
'pool_field_plugin_interval_invalid': 'Run interval must be greater than 0: {}',
'pool_field_reval_interval_invalid': 'Re-validation interval cannot be negative: {}',
'pool_repo_integrity_error': 'Proxy data integrity error: {}',
'pool_repo_plugin_integrity_error': 'Plugin config data integrity error: {}',
'pool_repo_query_build_failed': 'Query build failed: {}',
'pool_random_proxy_bad_format': 'format must be json or text',
'pool_import_no_valid_proxy': 'No valid proxies to import',
'pool_import_duplicated': 'All proxies already exist, none imported',
'pool_import_validating': 'Validating {} proxies in the background; only working ones are saved',
'pool_favorite_added': 'Proxy added to favorites',
'pool_favorite_removed': 'Proxy removed from favorites',
'pool_validation_task_created': 'Validation task created',
'pool_full_check_task_created': 'Full proxy check task created',
'pool_validation_no_valid_proxy': 'No valid proxies to validate',
'pool_validating_proxies': 'Validating {} proxies',
'pool_no_invalid_proxies': 'No invalid proxies to delete',
'pool_invalid_proxies_deleted': 'Deleted {} invalid proxies',
'pool_proxies_deleted': 'Deleted {} proxies',
'pool_task_not_found': 'Task not found',
'pool_task_already_finished': 'The task has already finished, nothing to cancel',
'pool_task_cancelled': 'Task cancelled',
}
}
class MessageManager:
def __init__(self, messages=MESSAGES):
self.messages = messages
self.default_lang = 'cn'
def get(self, key, lang='cn', *args):
try:
return self.messages[lang][key].format(*args) if args else self.messages[lang][key]
except KeyError:
return self.messages[self.default_lang][key] if key in self.messages[self.default_lang] else key
message_manager = MessageManager(MESSAGES)
get_message = message_manager.get
def enable_safe_console_output() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors='replace')
except (AttributeError, ValueError, OSError):
continue
MODE_BY_SOURCE = {
'local': ('cycle', 'loadbalance'),
'api': ('request', 'continuous'),
'pool': ('request', 'continuous'),
}
def normalize_rotation_mode(mode, source_mode) -> str:
allowed = MODE_BY_SOURCE.get(str(source_mode or '').strip().lower(),
MODE_BY_SOURCE['api'])
text = str(mode or '').strip().lower()
return text if text in allowed else allowed[0]
def mode_display_text(config, language) -> str:
source = str(config.get('proxy_source_mode', 'local')).strip().lower()
return get_message(normalize_rotation_mode(config.get('mode'), source), language)
def print_banner(config):
language = config.get('language', 'cn').lower()
users = config.get('Users') or {}
has_auth = bool(users)
auth_info = "、".join(users.keys()) if has_auth else get_message('auth_not_set', language)
http_addr = f"http://<用户>:<密码>@127.0.0.1:{config.get('port')}" if has_auth else f"http://127.0.0.1:{config.get('port')}"
socks5_addr = f"socks5://<用户>:<密码>@127.0.0.1:{config.get('port')}" if has_auth else f"socks5://127.0.0.1:{config.get('port')}"
banner_info = [
(get_message('public_account', language), '樱花庄的本间白猫'),
(get_message('blog', language), 'https://y.shironekosan.cn'),
(get_message('proxy_mode', language), mode_display_text(config, language)),
(get_message('proxy_interval', language), f"{config.get('interval')}{get_message('seconds', language)}"),
(get_message('default_auth', language), auth_info),
(get_message('local_http', language), http_addr),
(get_message('local_socks5', language), socks5_addr),
(get_message('star_project', language), 'https://github.com/honmashironeko/ProxyCat'),
]
print(f"{Fore.MAGENTA}{'=' * 55}")
for key, value in banner_info:
print(f"{Fore.YELLOW}{key}: {Fore.GREEN}{value}{Style.RESET_ALL}")
print(f"{Fore.MAGENTA}{'=' * 55}\n")
display_level = config.get('display_level', '1')
if int(display_level) >= 2:
print(f"\n{Fore.CYAN}{get_message('display_level_desc', language)}{Style.RESET_ALL}")
else:
print(f"\n{Fore.CYAN}{get_message('display_level_notice', language).format(display_level)}{Style.RESET_ALL}")
DEFAULT_CONFIG = {
'port': '1080',
'mode': 'cycle',
'interval': '300',
'proxy_source_mode': 'local',
'api_proxy_url': 'http://example.com/getip',
'pool_remote_url': '',
'proxy_file': 'ip.txt',
'check_proxies_on_startup': 'True',
'check_proxies_on_use': 'True',
'check_concurrency': '50',
'whitelist_file': 'whitelist.txt',
'blacklist_file': 'blacklist.txt',
'ip_auth_priority': 'whitelist',
'language': 'cn',
'display_level': '1',
'log_level': 'INFO',
'log_max_bytes': '10485760',
'log_access_enabled': 'true',
'log_backup_count': '3',
'domain_stats_enabled': 'true',
'domain_stats_flush_interval': '15',
'domain_stats_retention_days': '30',
'access_records_enabled': 'true',
'access_records_flush_interval': '5',
'access_records_retention_days': '7',
'access_records_max_rows': '200000',
'access_records_buffer_size': '20000',
'access_records_real_ip_probe': 'false',
'web_port': '5001',
'token': '',
'proxy_username': '',
'proxy_password': '',
'test_url': 'https://www.baidu.com',
'request_interval': '0',
'bypass_whitelist_file': 'bypass_whitelist.txt',
'switch_cooldown': '2',
'proxy_check_ttl': '60',
'check_cooldown': '10',
'proxy_failure_cooldown': '3',
'tunnel_idle_timeout': '10',
'buffer_size': '8192',
'max_concurrent_requests': '1000',
'max_pool_size': '500',
'max_concurrent_per_proxy': '0',
'auto_expand_enabled': 'true',
'exit_count': '5',
'exit_wait_timeout': '15',
'client_max_connections': '1000',
'client_max_keepalive': '8',
'client_keepalive_expiry': '30',
'client_idle_timeout': '300'
}
DEPRECATED_SERVER_KEYS = frozenset({
'check_proxies',
'getip_url',
'pool_autostart',
'pool_port',
'pool_proxy_url',
'pool_web_url',
'pool_proxy_count',
})
def migrate_pool_proxy_count(config_file: str) -> bool:
try:
config = ini_parser()
config.read_string(read_config_text(os.path.abspath(str(config_file))))
if not config.has_section('Server'):
return False
old_value = config.get('Server', 'pool_proxy_count', fallback='').strip()
new_value = config.get('Server', 'exit_count', fallback='').strip()
if not old_value or new_value:
return False
except Exception as e:
logging.warning(f"迁移 pool_proxy_count 时读取配置失败(已跳过): {e}")
return False
if not update_ini_keys(config_file, 'Server', {'exit_count': old_value}):
return False
logging.info(f"已把 pool_proxy_count = {old_value} 迁移为 exit_count")
return True
def migrate_getip_url(config_file: str) -> bool:
try:
config = ini_parser()
config.read_string(read_config_text(os.path.abspath(str(config_file))))
if not config.has_section('Server'):
return False
legacy_value = config.get('Server', 'getip_url', fallback='').strip()
if not legacy_value:
return False
except Exception as e:
logging.warning(f"迁移 getip_url 时读取配置失败(已跳过): {e}")
return False
if not update_ini_keys(config_file, 'Server',
{'api_proxy_url': legacy_value, 'getip_url': ''}):
return False
logging.info("已把 getip_url 合并进 api_proxy_url 并清空"
"(两者语义相同,合并后不再有「接口与凭据错配」)")
return True
def strip_deprecated_server_keys(config_file: str) -> bool:
try:
removed = remove_ini_keys(config_file, 'Server', DEPRECATED_SERVER_KEYS)
except OSError as e:
logging.warning(f"清理废弃配置项时读写配置失败(已跳过): {e}")
return False
except Exception as e:
logging.warning(f"清理废弃配置项时读取配置失败(已跳过): {e}")
return False
if not removed:
return False
logging.info(f"已从 [Server] 段清除 {len(removed)} 个废弃配置项: {', '.join(sorted(removed))}")
return True
def remove_ini_keys(path, section: str, keys) -> list:
target = os.path.abspath(str(path))
original = read_config_text(target)
lines = original.splitlines(keepends=True)
span = _ini_section_span(lines, section)
if span is None:
return []
start, end = span
wanted = {str(key) for key in keys}
removed = []
kept = []
for i, line in enumerate(lines):
if start < i < end:
stripped = line.lstrip()
if stripped and not stripped.startswith(('#', ';')) and '=' in line:
key = line.split('=', 1)[0].strip()
if key in wanted:
removed.append(key)
continue
kept.append(line)
if not removed:
return []
write_text_atomic(target, ''.join(kept))
return removed
TRUE_WORDS = frozenset({"1", "true", "yes", "on"})
FALSE_WORDS = frozenset({"0", "false", "no", "off"})
def render_config_error(key: str, value: Any, reason_key: str, reason_args: tuple,
language: str) -> str:
reason = get_message(reason_key, language, *reason_args)
return get_message('config_option_invalid', language, key, str(value), reason)
def parse_bool_lenient(value, default: bool = False) -> bool:
text = str(value).strip().lower()
if text in TRUE_WORDS:
return True
if text in FALSE_WORDS:
return False
return default
def parse_int_lenient(value, default: int, *, low: int) -> int:
try:
parsed = int(str(value).strip())
except (TypeError, ValueError):
return default
return parsed if parsed >= low else default
def write_text_atomic(path, text: str) -> None:
import tempfile
directory = os.path.dirname(os.path.abspath(str(path)))
fd, temp_path = tempfile.mkstemp(dir=directory, prefix='.config-', suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
_copy_file_mode(str(path), temp_path)
os.replace(temp_path, str(path))
except BaseException:
try:
os.unlink(temp_path)
except OSError:
pass
raise
def _copy_file_mode(source, target) -> None:
try:
os.chmod(target, os.stat(source).st_mode & 0o7777)
except OSError:
pass
def ini_parser() -> ConfigParser:
parser = ConfigParser(interpolation=None)
parser.optionxform = str
return parser
def read_config_text(path) -> str:
with open(os.path.abspath(str(path)), 'rb') as handle:
raw = handle.read()
try:
return _universal_newlines(raw.decode('utf-8-sig'))
except UnicodeDecodeError as utf8_error:
local = locale.getpreferredencoding(False) or ''
if local.replace('_', '-').lower() in ('utf-8', 'utf8', 'cp65001', 'ascii'):
raise
try:
text = raw.decode(local)
except (UnicodeDecodeError, LookupError):
raise utf8_error
logging.warning(
"%s 不是 UTF-8 编码,已按系统代码页 %s 读取;"
"请用编辑器另存为 UTF-8,避免后续内容被误读",
path, local,
)
return _universal_newlines(text)
def _universal_newlines(text: str) -> str:
return text.replace('\r\n', '\n').replace('\r', '\n')
_INI_SECTION_HEADER = re.compile(r'^\s*\[[^\]]+\]\s*$')
def _ini_section_span(lines: list, section: str):
header = re.compile(rf'^\s*\[{re.escape(section)}\]\s*$')
start = next((i for i, line in enumerate(lines) if header.match(line)), None)
if start is None:
return None
end = len(lines)
for i in range(start + 1, len(lines)):
if _INI_SECTION_HEADER.match(lines[i]):
end = i
break
return start, end
def _line_ending(line: str) -> str:
if line.endswith('\r\n'):
return '\r\n'
if line.endswith('\n'):
return '\n'
return '\n'
def update_ini_keys(path, section: str, updates) -> bool:
target = os.path.abspath(str(path))
original = read_config_text(target)
lines = original.splitlines(keepends=True)
span = _ini_section_span(lines, section)
if span is None:
block = f'[{section}]\n' + ''.join(
f'{key} = {value}\n' for key, value in updates.items()
)
head = original.rstrip('\n')
updated = f'{head}\n\n{block}' if head else block
else:
start, end = span
remaining = dict(updates)
for i in range(start + 1, end):
line = lines[i]
if line.lstrip().startswith(('#', ';')) or '=' not in line:
continue
key = line.split('=', 1)[0].strip()
if key in remaining:
lines[i] = f'{key} = {remaining.pop(key)}{_line_ending(line)}'
for key, value in remaining.items():
insert_at = end
while insert_at > start + 1 and not lines[insert_at - 1].strip():
insert_at -= 1
lines.insert(insert_at, f'{key} = {value}\n')
end += 1
updated = ''.join(lines)
if updated == original:
return False
write_text_atomic(target, updated)
return True
def _trim_section_tail(lines: list, start: int, end: int) -> int:
content_end = end
while content_end > start + 1:
stripped = lines[content_end - 1].strip()
if stripped and not stripped.startswith(('#', ';')):
break
content_end -= 1
return content_end
def write_ini_section(path, section: str, entries) -> bool:
target = os.path.abspath(str(path))
original = read_config_text(target)
lines = original.splitlines(keepends=True)
span = _ini_section_span(lines, section)
if not entries:
if span is None:
return False
start, end = span
del lines[start:_trim_section_tail(lines, start, end)]
updated = ''.join(lines)
else:
block = f'[{section}]\n' + ''.join(
f'{key} = {value}\n' for key, value in entries.items()
) + '\n'
if span is None:
head = original.rstrip('\n')
updated = f'{head}\n\n{block}' if head else block
else:
start, end = span
content_end = _trim_section_tail(lines, start, end)
updated = ''.join(lines[:start]) + block + ''.join(lines[content_end:])
if updated == original:
return False
write_text_atomic(target, updated)
return True
class NoPoolConsoleFilter(logging.Filter):
POOL_LOGGER_PREFIXES = (
"core.",
"plugin.",
"modules.proxypool_service",
"modules.proxypool_api",
"modules.pool_config_ini",
)
def filter(self, record):
return not record.name.startswith(self.POOL_LOGGER_PREFIXES)
def port_in_use(port, host='127.0.0.1', timeout=0.5):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
return s.connect_ex((host, int(port))) == 0
except Exception:
return False
def load_config(config_file='config/config.ini'):
try:
config = ini_parser()
if os.path.exists(config_file):
config.read_string(read_config_text(config_file))
if not config.has_section('Server'):
config.add_section('Server')
for key, value in DEFAULT_CONFIG.items():
if not config.has_option('Server', key):
config.set('Server', key, str(value))
result = {key.lower(): value for key, value in config.items('Server')}
if config.has_section('Users'):
result['Users'] = dict(config.items('Users'))
return result
except Exception as e:
logging.error(f"Error loading config: {e}")
return DEFAULT_CONFIG.copy()
def load_ip_list(file_path):
try:
if os.path.exists(file_path):
return set(
line.strip() for line in read_config_text(file_path).splitlines()
if line.strip() and not line.strip().startswith('#')
)
except Exception as e:
logging.error(f"Error loading IP list: {e}")
return set()
def load_bypass_whitelist(file_path):
try:
if os.path.exists(file_path):
return set(line.strip() for line in read_config_text(file_path).splitlines()
if line.strip() and not line.strip().startswith('#'))
except Exception as e:
logging.error(f"加载代理绕过白名单失败: {e}")
return set()
def check_bypass_match(host, bypass_list):
if not bypass_list or not host:
return False
normalized_host = str(host).strip().lower()
for pattern in bypass_list:
pattern = pattern.strip()
if not pattern:
continue
if fnmatch.fnmatchcase(normalized_host, pattern.lower()):
return True
return False
def list_files_snapshot(server) -> dict:
snapshot = {}
for path in (server.whitelist_file, server.blacklist_file,
server.bypass_whitelist_file):
try:
snapshot[path] = os.path.getmtime(path)
except OSError:
snapshot[path] = 0
return snapshot
def reload_ip_lists_if_changed(server, snapshot: dict) -> dict:
current = list_files_snapshot(server)
if current != snapshot:
server.reload_ip_lists()
return current
_proxy_check_cache = {}
_proxy_check_ttl = 10
_proxy_check_max_size = 1000
_proxy_check_cache_lock = threading.Lock()
_proxy_cache_clean_interval = 10.0
_last_proxy_cache_clean = 0.0
def _clean_proxy_cache():
global _last_proxy_cache_clean
current_time = time.time()
if current_time - _last_proxy_cache_clean < _proxy_cache_clean_interval:
return
_last_proxy_cache_clean = current_time
expired_keys = [
k for k, (cache_time, _) in _proxy_check_cache.items()
if current_time - cache_time >= _proxy_check_ttl
]
for k in expired_keys:
del _proxy_check_cache[k]
if len(_proxy_check_cache) > _proxy_check_max_size:
excess = len(_proxy_check_cache) - int(_proxy_check_max_size * 0.8)
oldest_keys = sorted(
_proxy_check_cache.keys(),
key=lambda k: _proxy_check_cache[k][0]
)[:excess]
for k in oldest_keys:
del _proxy_check_cache[k]
def split_host_port(value: str, default_port=None):
text = value.strip()
port_text = ''
if text.startswith('['):
host, closed, rest = text[1:].partition(']')
if not closed:
raise ValueError(f'IPv6 字面量缺少闭合方括号: {value}')
if rest and not rest.startswith(':'):
raise ValueError(f'IPv6 字面量后只能是端口: {value}')
port_text = rest[1:]
else:
head, sep, tail = text.rpartition(':')
if not sep or ':' in head:
host = text
else:
host, port_text = head, tail
if not port_text:
if default_port is None:
raise ValueError(f'缺少端口: {value}')
return host, default_port
if not port_text.isdigit():
raise ValueError(f'端口不是数字: {value}')
return host, int(port_text)
def parse_proxy_url(proxy):
protocol, separator, remaining = proxy.partition('://')
if not separator:
raise ValueError(f'缺少协议: {proxy}')
auth = None
if '@' in remaining:
auth, remaining = remaining.rsplit('@', 1)
host, port = split_host_port(remaining)
return protocol, auth, host, port
def parse_proxy(proxy):
try:
return parse_proxy_url(proxy)
except Exception:
return None, None, None, None
def format_proxy_url(protocol, host, port, auth=None):
host_text = f'[{host}]' if ':' in host else host
if auth:
return f'{protocol}://{auth}@{host_text}:{port}'
return f'{protocol}://{host_text}:{port}'
def sanitize_proxy(proxy: str) -> str:
if not proxy:
return ''
text = str(proxy)
if '@' not in text:
return text
scheme, _, rest = text.rpartition('://')
credentials, _, host = rest.rpartition('@')
if not credentials:
return text
user = credentials.split(':', 1)[0]
masked = f"{user}:***" if ':' in credentials else credentials
return f"{scheme}://{masked}@{host}" if scheme else f"{masked}@{host}"
async def check_http_proxy(proxy, test_url=None):
if test_url is None:
test_url = 'https://www.baidu.com'
protocol, auth, host, port = parse_proxy(proxy)
url = format_proxy_url(protocol, host, port, auth)
proxies = {'http://': url, 'https://': url}
try:
async with httpx.AsyncClient(proxies=proxies, timeout=10, verify=False,
trust_env=False) as client:
try:
response = await client.get(test_url, follow_redirects=True)
return response.status_code < 400
except (httpx.HTTPError, OSError, asyncio.TimeoutError):
if test_url.startswith('https://'):
http_url = 'http://' + test_url[8:]
try:
response = await client.get(http_url, follow_redirects=True)
return response.status_code < 400
except (httpx.HTTPError, OSError, asyncio.TimeoutError):
pass
return False
except (httpx.HTTPError, OSError, asyncio.TimeoutError):
return False
def build_socks5_auth_packet(username: str, password: str) -> bytes:
username_bytes = username.encode()
password_bytes = password.encode()
for label, raw in (('用户名', username_bytes), ('密码', password_bytes)):
if not 1 <= len(raw) <= 255:
raise ValueError(f'SOCKS5 {label}长度须为 1-255 字节,实际 {len(raw)}')
return (b'\x01' + bytes([len(username_bytes)]) + username_bytes +
bytes([len(password_bytes)]) + password_bytes)
async def check_socks_proxy(proxy, test_url=None):
if test_url is None:
test_url = 'https://www.baidu.com'
protocol, auth, host, port = parse_proxy(proxy)
if not all([host, port]):
return False
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=5)
if auth:
writer.write(b'\x05\x02\x00\x02')
else:
writer.write(b'\x05\x01\x00')
await writer.drain()
auth_method = await asyncio.wait_for(reader.readexactly(2), timeout=5)
if auth_method[0] != 0x05:
return False
if auth_method[1] == 0x02 and auth:
username, password = auth.split(':', 1)
writer.write(build_socks5_auth_packet(username, password))
await writer.drain()
auth_response = await asyncio.wait_for(reader.readexactly(2), timeout=5)
if auth_response[1] != 0x00:
return False
from urllib.parse import urlparse
parsed = urlparse(test_url)
domain = (parsed.hostname or test_url).encode()
target_port = parsed.port or (443 if parsed.scheme == 'https' else 80)
writer.write(
b'\x05\x01\x00\x03'
+ bytes([len(domain)])
+ domain
+ target_port.to_bytes(2, 'big')
)
await writer.drain()
head = await asyncio.wait_for(reader.readexactly(4), timeout=5)
atyp = head[3]
if atyp == 0x01:
tail = await asyncio.wait_for(reader.readexactly(6), timeout=5)
elif atyp == 0x04:
tail = await asyncio.wait_for(reader.readexactly(18), timeout=5)
elif atyp == 0x03:
length = await asyncio.wait_for(reader.readexactly(1), timeout=5)
tail = length + await asyncio.wait_for(
reader.readexactly(length[0] + 2), timeout=5)
else:
return False
return head[1] == 0x00
except Exception:
return False
finally:
await _close_socks_writer(writer)
async def _close_socks_writer(writer) -> None:
if writer is None:
return
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def check_proxy(proxy, test_url=None):
test_url = test_url or 'https://www.baidu.com'
current_time = time.time()
cache_key = f"{proxy}:{test_url}"
with _proxy_check_cache_lock:
_clean_proxy_cache()
if cache_key in _proxy_check_cache:
cache_time, is_valid = _proxy_check_cache[cache_key]
if current_time - cache_time < _proxy_check_ttl:
return is_valid
proxy_type = proxy.split('://')[0]
check_funcs = {
'http': check_http_proxy,
'https': check_http_proxy,
'socks5': check_socks_proxy
}
if proxy_type not in check_funcs:
return False
try:
is_valid = await check_funcs[proxy_type](proxy, test_url)
with _proxy_check_cache_lock:
_proxy_check_cache[cache_key] = (current_time, is_valid)
return is_valid
except Exception as e:
logger.debug(f"代理检测异常(不写缓存,下次重试) {sanitize_proxy(proxy)}: {e}")
return False
DEFAULT_CHECK_CONCURRENCY = 50
async def check_proxies(proxies, test_url=None, concurrency=DEFAULT_CHECK_CONCURRENCY):
if not proxies:
return []
limit = max(1, int(concurrency))
semaphore = asyncio.Semaphore(limit)
async def check_one(proxy):
async with semaphore:
return proxy if await check_proxy(proxy, test_url) else None
chunk_size = max(1, limit * 4)
results = []
for start in range(0, len(proxies), chunk_size):
chunk = proxies[start:start + chunk_size]
results.extend(await asyncio.gather(*(check_one(p) for p in chunk)))
return [proxy for proxy in results if proxy is not None]