增加了代理平台和日志

This commit is contained in:
yml2213
2026-06-24 14:22:29 +08:00
parent 0d03b2c242
commit 83345dfdc5
21 changed files with 1134 additions and 444 deletions
+58 -8
View File
@@ -23,6 +23,9 @@ from core.geetest.common.network import (
req_fullpage_validate, req_fullpage_validate,
) )
# ── 全局极验并发限制:同一时刻最多2个线程做极验验证 ──
_geetest_semaphore = threading.Semaphore(2)
class AccountLike(Protocol): class AccountLike(Protocol):
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。""" """DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
@@ -75,6 +78,8 @@ class DouyuLogin:
max_total_time: float = 0, max_total_time: float = 0,
whitelist_uid: str = "", whitelist_uid: str = "",
whitelist_ukey: str = "", whitelist_ukey: str = "",
whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None,
proxy_manager: Optional[ProxyManager] = None, proxy_manager: Optional[ProxyManager] = None,
stop_event: Optional[threading.Event] = None, stop_event: Optional[threading.Event] = None,
): ):
@@ -95,6 +100,8 @@ class DouyuLogin:
proxy_api_url, proxy_api_url,
whitelist_uid=whitelist_uid, whitelist_uid=whitelist_uid,
whitelist_ukey=whitelist_ukey, whitelist_ukey=whitelist_ukey,
whitelist_platform=whitelist_platform,
whitelist_credentials=whitelist_credentials,
) )
else: else:
self.proxy_manager = None self.proxy_manager = None
@@ -220,6 +227,22 @@ class DouyuLogin:
return True return True
return False return False
@staticmethod
def _is_geetest_network_error(err_str: str) -> bool:
"""
判断异常是否为极验接口的网络类错误(代理到极验不可达/被限流)。
这类错误说明当前代理已被极验识别或限流,应立即换代理而非软重试。
"""
err_lower = err_str.lower()
network_error_keywords = [
'网络不给力', # 极验 get.php 返回的网络错误
'connection aborted', # 远程断开连接
'remotedisconnected', # requests RemoteDisconnected
'read timed out', # 读取超时(极验接口)
'api.geetest.com', # 极验接口相关错误
]
return any(kw in err_lower for kw in network_error_keywords)
@staticmethod @staticmethod
def _truncate_error(err_str: str, max_len: int = 80) -> str: def _truncate_error(err_str: str, max_len: int = 80) -> str:
"""截断错误信息,避免日志刷屏。""" """截断错误信息,避免日志刷屏。"""
@@ -461,28 +484,41 @@ class DouyuLogin:
""" """
解决极验 fullpage 验证(带重试机制) 解决极验 fullpage 验证(带重试机制)
区分类错误: 区分类错误:
- 临时波动(slide、网络不给力、KeyError):先用原代理重试,连续失败才换
- 代理死亡(ProxyError、连接超时):立即换代理 - 代理死亡(ProxyError、连接超时):立即换代理
- 网络类错误(网络不给力、Connection aborted):立即换代理
- 极验逻辑失败(slide等):原代理重试,连续2次才换
Args: Args:
gt: 极验gt参数 gt: 极验gt参数
challenge: 极验challenge参数(第一次登录返回的) challenge: 极验challenge参数(第一次登录返回的)
deadline: 登录整体超时截止时间(monotonic),0=不限 deadline: 登录整体超时截止时间(monotonic),0=不限
challenge: 极验challenge参数(第一次登录返回的)
Returns: Returns:
(validate, seccode) (validate, seccode)
""" """
logger.info("开始极验 fullpage 验证...") logger.info("开始极验 fullpage 验证...")
# max_proxy_retries=0 表示不限代理切换次数。 # ── 全局并发限制:同一时刻最多2个线程做极验,避免被极验限流 ──
acquired = _geetest_semaphore.acquire(timeout=120)
if not acquired:
raise ValueError("极验验证等待超时(并发排队120秒未获得信号量)")
try:
return self._solve_geetest_inner(gt, challenge, deadline)
finally:
_geetest_semaphore.release()
def _solve_geetest_inner(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
"""极验验证内部实现(已获取并发信号量)。"""
max_proxy_switches = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999 max_proxy_switches = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999
proxy_switches = 0 proxy_switches = 0
# ── 极验验证最大尝试次数(超出后回到 login 整体重试)──
_MAX_GEETEST_ATTEMPTS = 15
# 连续临时失败计数(同一代理下),超过阈值才换代理 # 连续临时失败计数(同一代理下),超过阈值才换代理
_soft_fail_streak = 0 _soft_fail_streak = 0
_SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换 _SOFT_FAIL_THRESHOLD = 2
def refresh_proxy(mark_bad: bool) -> None: def refresh_proxy(mark_bad: bool) -> None:
"""按代理切换上限刷新代理。""" """按代理切换上限刷新代理。"""
@@ -499,12 +535,20 @@ class DouyuLogin:
while True: while True:
attempt += 1 attempt += 1
self._ensure_not_stopped() self._ensure_not_stopped()
# ── 极验验证尝试上限 ──
if attempt > _MAX_GEETEST_ATTEMPTS:
raise ValueError(
f"极验验证已尝试 {attempt} 次,超过上限 {_MAX_GEETEST_ATTEMPTS}"
f"回到 login 整体重试换新代理"
)
# 超时兜底:极验验证不应超过登录整体时间上限 # 超时兜底:极验验证不应超过登录整体时间上限
if deadline and time.monotonic() > deadline: if deadline and time.monotonic() > deadline:
raise ValueError(f"极验验证超时(登录整体时间耗尽)") raise ValueError(f"极验验证超时(登录整体时间耗尽)")
try: try:
logger.info(f"极验验证尝试 {attempt} (无限重试)") logger.info(f"极验验证尝试 {attempt}/{_MAX_GEETEST_ATTEMPTS}")
# 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。 # 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。
str_16 = _generate_seed() str_16 = _generate_seed()
@@ -556,17 +600,23 @@ class DouyuLogin:
except Exception as e: except Exception as e:
err_str = str(e) err_str = str(e)
if "代理切换次数已达上限" in err_str: if "代理切换次数已达上限" in err_str or "超过上限" in err_str:
raise raise
is_proxy_dead = self._is_proxy_connection_error(err_str) is_proxy_dead = self._is_proxy_connection_error(err_str)
is_network_error = self._is_geetest_network_error(err_str)
if is_proxy_dead: if is_proxy_dead:
# 代理确实不可用:立即换,标记坏 # 代理确实不可用:立即换,标记坏
logger.warning(f"极验验证代理连接失败: {self._truncate_error(err_str)},换代理") logger.warning(f"极验验证代理连接失败: {self._truncate_error(err_str)},换代理")
refresh_proxy(mark_bad=True) refresh_proxy(mark_bad=True)
_soft_fail_streak = 0 _soft_fail_streak = 0
elif is_network_error:
# 网络类错误(极验限流/网络不给力/Connection aborted):立即换代理
logger.warning(f"极验网络错误: {self._truncate_error(err_str)},立即换代理")
refresh_proxy(mark_bad=False)
_soft_fail_streak = 0
else: else:
# 临时异常(KeyError、网络不给力等):先原代理重试 # 其他临时异常(KeyError 等):先原代理重试
_soft_fail_streak += 1 _soft_fail_streak += 1
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD: if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},连续 {_soft_fail_streak} 次,换代理") logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},连续 {_soft_fail_streak} 次,换代理")
+77 -30
View File
@@ -19,10 +19,19 @@ class ProxyManager:
_COOLDOWN_MAX = 120 # 最大冷却秒数 _COOLDOWN_MAX = 120 # 最大冷却秒数
_MAX_FAIL_COUNT = 3 # 连续失败此次数后永久移出池 _MAX_FAIL_COUNT = 3 # 连续失败此次数后永久移出池
def __init__(self, api_url: str = "", whitelist_uid: str = "", whitelist_ukey: str = ""): def __init__(
self,
api_url: str = "",
whitelist_uid: str = "",
whitelist_ukey: str = "",
whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None,
):
self.api_url = api_url self.api_url = api_url
self.whitelist_uid = whitelist_uid self.whitelist_uid = whitelist_uid
self.whitelist_ukey = whitelist_ukey self.whitelist_ukey = whitelist_ukey
self.whitelist_platform = whitelist_platform
self.whitelist_credentials = whitelist_credentials
self.current_proxy: Optional[str] = None self.current_proxy: Optional[str] = None
self._cond = threading.Condition() self._cond = threading.Condition()
# 已验证可用的代理池: {proxy_url: validated_timestamp} # 已验证可用的代理池: {proxy_url: validated_timestamp}
@@ -31,11 +40,19 @@ class ProxyManager:
self._in_use: set[str] = set() self._in_use: set[str] = set()
# 代理失败记录: {proxy_url: {"count": 失败次数, "cooldown_until": 冷却到期时间戳}} # 代理失败记录: {proxy_url: {"count": 失败次数, "cooldown_until": 冷却到期时间戳}}
self._fail_records: dict[str, dict] = {} self._fail_records: dict[str, dict] = {}
self._pool_ttl = 90 self._pool_ttl = 180
self._fetching = False self._fetching = False
# 构建白名单同步器(优先使用新参数,向后兼容旧参数)
_wl_platform = whitelist_platform or "xiequ"
_wl_credentials = whitelist_credentials
if not _wl_credentials and whitelist_uid and whitelist_ukey:
_wl_platform = "xiequ"
_wl_credentials = {"uid": whitelist_uid, "ukey": whitelist_ukey}
self._whitelist_syncer = ( self._whitelist_syncer = (
DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey) DouyuWhitelistSyncer(platform=_wl_platform, credentials=_wl_credentials)
if whitelist_uid and whitelist_ukey if _wl_credentials
else None else None
) )
@@ -70,49 +87,71 @@ class ProxyManager:
return proxy return proxy
return None return None
def get_proxy(self, max_attempts: int = 5) -> Optional[str]: def get_proxy(self, max_attempts: int = 5, max_retries: int = 3) -> Optional[str]:
""" """
获取可用代理 IP,优先从已验证代理池复用。 获取可用代理 IP,优先从已验证代理池复用。
线程安全:池空时只有一个线程调 API 获取并验证所有代理入池, 线程安全:池空时只有一个线程调 API 获取并验证所有代理入池,
其他线程等待后复用。 其他线程等待后复用。等待线程被唤醒后会循环尝试取代理,
避免多个线程竞争拿到同一个代理。
Args:
max_attempts: 代理 API 获取尝试次数
max_retries: 等待线程获取代理的重试次数(防止唤醒后池仍为空直接放弃)
""" """
# ── 快速路径:池中有可用代理 ──
with self._cond: with self._cond:
proxy = self._pick_from_pool_locked() proxy = self._pick_from_pool_locked()
if proxy: if proxy:
logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
return proxy return proxy
if self._fetching: # ── 池空:需要获取新代理 ──
logger.debug("代理池空,等待其他线程获取...") for retry in range(max_retries):
self._cond.wait(timeout=60) with self._cond:
# 再次检查(可能其他线程刚充盈了池)
proxy = self._pick_from_pool_locked() proxy = self._pick_from_pool_locked()
if proxy: if proxy:
logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})") logger.debug(f"从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
return proxy return proxy
return None
self._fetching = True if self._fetching:
logger.debug("代理池空,等待其他线程获取...")
self._cond.wait(timeout=60)
# 等待后循环重试取代理(不再只尝试一次)
proxy = self._pick_from_pool_locked()
if proxy:
logger.debug(f"等待后从代理池复用: {proxy} (池剩余 {len(self._verified_pool)})")
return proxy
# 池仍为空,但可能有其他等待线程会去获取,继续重试
continue
try: # 当前线程负责获取
proxies_all, msg = self._fetch_and_verify_all(max_attempts) self._fetching = True
finally:
with self._cond:
self._fetching = False
self._cond.notify_all()
if proxies_all: try:
with self._cond: proxies_all, msg = self._fetch_and_verify_all(max_attempts)
now = time.time() finally:
for proxy in proxies_all: with self._cond:
self._verified_pool[proxy] = now self._fetching = False
first = proxies_all[0] self._cond.notify_all()
self._in_use.add(first)
self.current_proxy = first
logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...")
return first
logger.warning(f"获取代理失败: {msg}") if proxies_all:
with self._cond:
now = time.time()
for proxy in proxies_all:
self._verified_pool[proxy] = now
first = proxies_all[0]
self._in_use.add(first)
self.current_proxy = first
logger.info(f"代理池充盈 {len(proxies_all)} 个可用代理: {first} ...")
return first
logger.warning(f"获取代理失败: {msg}")
# 获取失败,等一小段时间后重试
time.sleep(2)
logger.warning(f"获取代理失败(已重试 {max_retries} 次)")
return None return None
def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]: def _fetch_and_verify_all(self, max_attempts: int) -> tuple[Optional[list[str]], str]:
@@ -206,6 +245,14 @@ def get_proxy_manager(
api_url: str = "", api_url: str = "",
whitelist_uid: str = "", whitelist_uid: str = "",
whitelist_ukey: str = "", whitelist_ukey: str = "",
whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None,
) -> ProxyManager: ) -> ProxyManager:
"""获取代理管理器实例。""" """获取代理管理器实例。"""
return ProxyManager(api_url, whitelist_uid=whitelist_uid, whitelist_ukey=whitelist_ukey) return ProxyManager(
api_url,
whitelist_uid=whitelist_uid,
whitelist_ukey=whitelist_ukey,
whitelist_platform=whitelist_platform,
whitelist_credentials=whitelist_credentials,
)
+61
View File
@@ -0,0 +1,61 @@
"""代理平台适配器注册表与工厂函数。
新增平台适配器:
1. 在 proxy_platforms/ 下创建 <platform>.py,继承 BaseWhitelistAdapter
2. 在本文件导入并注册到 _PLATFORM_REGISTRY
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
"""
from typing import Optional, Type
from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy
from .xiequ import XiequAdapter
from .xkdaili import XkdailiAdapter
# ── 平台注册表 ──
_PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = {
"xiequ": XiequAdapter,
"xkdaili": XkdailiAdapter,
}
# ── 平台凭据字段定义(前端动态渲染用)──
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
"xiequ": [
{"key": "uid", "label": "UID", "placeholder": "如: 99769"},
{"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"},
],
"xkdaili": [
{"key": "apikey", "label": "API Key", "placeholder": "如: XK86872CC0C85A415461"},
{"key": "wl_sign", "label": "白名单签名(Sign)", "placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027"},
{"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"},
],
}
# ── 平台显示名 ──
_PLATFORM_LABELS: dict[str, str] = {
name: cls(dict()).platform_label
for name, cls in _PLATFORM_REGISTRY.items()
}
def create_adapter(platform: str, credentials: dict) -> Optional[BaseWhitelistAdapter]:
"""根据平台标识符和凭据创建适配器实例。"""
cls = _PLATFORM_REGISTRY.get(platform)
if not cls:
return None
return cls(credentials)
def get_platform_names() -> list[str]:
"""获取所有已注册平台标识符。"""
return list(_PLATFORM_REGISTRY.keys())
def get_platform_labels() -> dict[str, str]:
"""获取所有平台标识符到中文显示名的映射。"""
return dict(_PLATFORM_LABELS)
def get_credential_fields(platform: str) -> list[dict]:
"""获取指定平台的凭据字段定义。"""
return _PLATFORM_CREDENTIAL_FIELDS.get(platform, [])
+193
View File
@@ -0,0 +1,193 @@
"""代理平台白名单适配器基类。
所有平台适配器继承 BaseWhitelistAdapter,实现平台特定的 API 调用。
基类提供通用的 sync_ip 逻辑,子类只需实现增删查等原子操作。
"""
import re
import threading
import time
from abc import ABC, abstractmethod
from typing import Optional
import requests
from loguru import logger
# 全局锁:防止并发白名单同步触发代理服务商限流
_whitelist_sync_lock = threading.Lock()
class BaseWhitelistAdapter(ABC):
"""白名单适配器基类。
子类必须实现:
- platform_name / platform_label 属性
- add_ip / delete_ip / get_whitelist / test_connection 方法
基类提供:
- sync_ip 通用同步逻辑
- get_local_exit_ip 静态方法
"""
MEMO_PREFIX = "douyu_auto"
@property
def memo(self) -> str:
return self.MEMO_PREFIX
@property
@abstractmethod
def platform_name(self) -> str:
"""平台标识符,如 'xiequ', 'xkdaili'"""
...
@property
@abstractmethod
def platform_label(self) -> str:
"""平台中文显示名,如 '协固', '星客'"""
...
@abstractmethod
def add_ip(self, ip: str) -> tuple[bool, str]:
"""添加IP到白名单。返回 (是否成功, 消息)"""
...
@abstractmethod
def delete_ip(self, ip: str) -> tuple[bool, str]:
"""删除白名单IP。返回 (是否成功, 消息)"""
...
@abstractmethod
def get_whitelist(self) -> list[dict]:
"""获取白名单列表。返回 [{"ip": "x.x.x.x", "memo": "备注"}, ...]
如果平台不支持查询接口,返回空列表。
"""
...
@abstractmethod
def test_connection(self) -> tuple[bool, str]:
"""测试API连接。返回 (是否成功, 消息)"""
...
def sync_ip(self, current_ip: str, keep_recent: int = 3) -> tuple[bool, str]:
"""
通用白名单同步逻辑(线程安全)。
策略:
- 当前IP已在白名单(同备注),无需操作
- 当前IP不在白名单,添加
- 超过 keep_recent 个同备注IP时,删除最老的
- 当前IP已存在但备注不同,不处理
Args:
current_ip: 当前出口IP
keep_recent: 保留的同备注IP数量上限
Returns:
(是否成功, 消息)
"""
with _whitelist_sync_lock:
try:
records = self.get_whitelist()
# 不支持查询的平台,直接尝试添加
if records is None:
ok, msg = self.add_ip(current_ip)
if ok:
return True, f"白名单IP已添加: {current_ip}"
# 添加失败但提示已存在,视为成功
if "已存在" in msg or "exist" in msg.lower():
return True, f"白名单IP已存在: {current_ip}"
return False, f"白名单添加失败: {msg}"
memo_records = [r for r in records if r.get("memo") == self.memo]
memo_ips = [r.get("ip") for r in memo_records]
# 当前IP已在白名单(同备注),无需操作
if current_ip in memo_ips:
msg = f"白名单IP已是最新的: {current_ip}"
logger.info(msg)
return True, msg
# 当前IP已存在但备注不同,不处理
if any(r.get('ip') == current_ip for r in records):
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加")
return True, f"白名单IP已存在: {current_ip}"
# 超过上限,删除最老的
if len(memo_ips) >= keep_recent:
to_delete = memo_ips[:len(memo_ips) - keep_recent + 1]
for old_ip in to_delete:
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
self.delete_ip(old_ip)
time.sleep(1)
# 添加新IP
logger.info(f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)")
ok, resp = self.add_ip(current_ip)
if ok:
msg = f"白名单IP已添加: {current_ip}"
logger.info(msg)
return True, msg
return False, f"白名单添加失败,API响应: {resp}"
except Exception as e:
msg = f"白名单同步失败: {e}"
logger.error(msg)
return False, msg
@staticmethod
def get_local_exit_ip() -> Optional[str]:
"""获取本机当前公网出口IP(不走代理)。"""
return _get_local_exit_ip()
def _get_local_exit_ip() -> Optional[str]:
"""获取本机当前公网出口IP(不走代理)。"""
targets = [
"https://myip.ipip.net",
"https://4.ipw.cn",
"https://api.ipify.org",
]
for url in targets:
try:
response = requests.get(url, timeout=8, headers={"User-Agent": "Mozilla/5.0"})
response.raise_for_status()
text = response.text.strip()
match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text)
if match:
return match.group(1)
except Exception:
continue
return None
def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
"""通过代理获取出口IP。"""
targets = [
"https://qifu-api.baidubce.com/ip/local/geo/v1/district",
"https://myip.ipip.net",
"https://4.ipw.cn",
]
proxies = {"http": proxy, "https": proxy}
for url in targets:
try:
response = requests.get(
url, proxies=proxies, timeout=6,
headers={"User-Agent": "Mozilla/5.0"},
)
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
if "json" in content_type:
data = response.json()
ip = data.get("ip") or data.get("origin")
if ip:
return str(ip).split(",")[0].strip()
text = response.text.strip()
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match:
return match.group(1)
except Exception:
continue
return None
+159
View File
@@ -0,0 +1,159 @@
"""携趣代理(xiequ)白名单适配器。
从 core.douyu.whitelist.WhitelistManager 迁移而来。
"""
import json
import re
import time
from urllib.parse import urlencode
import requests
from loguru import logger
from .base import BaseWhitelistAdapter
class XiequAdapter(BaseWhitelistAdapter):
"""携趣代理白名单适配器。
API格式:
基础URL: http://op.xiequ.cn/IpWhiteList.aspx
认证参数: uid + ukey
操作区分: act=add / del / getjson
"""
BASE_URL = "http://op.xiequ.cn/IpWhiteList.aspx"
def __init__(self, credentials: dict):
self.uid = credentials.get("uid", "")
self.ukey = credentials.get("ukey", "")
@property
def platform_name(self) -> str:
return "xiequ"
@property
def platform_label(self) -> str:
return "携趣"
def _build_url(self, **params) -> str:
"""构建请求URL"""
base_params = {"uid": self.uid, "ukey": self.ukey}
base_params.update(params)
return f"{self.BASE_URL}?{urlencode(base_params)}"
def get_whitelist(self) -> list[dict]:
"""
获取白名单列表。
协固原始格式: [{"IP": "x.x.x.x", "MEMO": "备注"}, ...]
统一格式: [{"ip": "x.x.x.x", "memo": "备注"}, ...]
"""
try:
url = self._build_url(act="getjson")
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
if not text or text == "[]":
return []
data = json.loads(text)
if isinstance(data, dict):
data = data.get("data", [])
if not isinstance(data, list):
return []
# 映射到统一格式
return [
{"ip": r.get("IP", r.get("ip", "")), "memo": r.get("MEMO", r.get("memo", ""))}
for r in data
if r.get("IP") or r.get("ip")
]
except Exception as e:
logger.error(f"获取白名单失败: {e}")
return []
def add_ip(self, ip: str, retry: bool = True) -> tuple[bool, str]:
"""添加IP到白名单。"""
try:
url = self._build_url(act="add", ip=ip, meno=self.memo)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"添加白名单响应: {text}")
if "ok" in text.lower() or "success" in text.lower() or "添加成功" in text:
logger.info(f"白名单添加成功: {ip} (备注: {self.memo})")
return True, text
if "已存在" in text or "exist" in text.lower() or "IpRep" in text:
logger.info(f"白名单已存在: {ip}")
return True, text
# 频率限制,等待后重试一次
if retry and ("频率过快" in text or "稍后" in text):
wait = 5
match = re.search(r'(\d+)\s*秒', text)
if match:
wait = int(match.group(1))
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
time.sleep(wait)
return self.add_ip(ip, retry=False)
# UKEY 错误
if "Err:Key" in text:
logger.error("白名单UKEY错误,请检查配置")
return False, "UKEY错误,请检查白名单配置"
# 超出白名单数量限制
if "Err:Max" in text or "超过" in text or "上限" in text:
logger.error(f"白名单数量超限: {text}")
return False, f"白名单数量超限: {text}"
logger.warning(f"白名单添加结果: {text}")
return False, text
except Exception as e:
logger.error(f"添加白名单失败: {e}")
return False, str(e)
def delete_ip(self, ip: str) -> tuple[bool, str]:
"""删除白名单IP。"""
try:
url = self._build_url(act="del", ip=ip)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"删除白名单响应: {text}")
if "ok" in text.lower() or "success" in text.lower() or "删除成功" in text:
logger.info(f"白名单删除成功: {ip}")
return True, text
logger.warning(f"白名单删除结果: {text}")
return False, text
except Exception as e:
logger.error(f"删除白名单失败: {e}")
return False, str(e)
def test_connection(self) -> tuple[bool, str]:
"""测试白名单API连接。"""
try:
records = self.get_whitelist()
count = len(records)
my_records = [r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)]
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
logger.info(msg)
return True, msg
except Exception as e:
msg = f"连接失败: {e}"
logger.error(msg)
return False, msg
+162
View File
@@ -0,0 +1,162 @@
"""星空代理(xkdaili)白名单适配器。
API格式:
基础URL: http://api2.xkdaili.com/tools/XApi.ashx
白名单认证参数: apikey + sign(白名单专用) + flag
获取代理认证参数: apikey + sign(提取专用) — 由用户在代理API地址中填写完整URL
白名单接口的 sign 和获取代理的 sign 不同!
响应格式:
status=100: 操作成功
status=508: 白名单已存在(视为成功)
status=202: 秘钥验证失败
status=808: 其它错误
注意:没有查询白名单列表的接口
"""
import json
import requests
from loguru import logger
from .base import BaseWhitelistAdapter
class XkdailiAdapter(BaseWhitelistAdapter):
"""星空代理(xkdaili)白名单适配器。"""
BASE_URL = "http://api2.xkdaili.com/tools/XApi.ashx"
def __init__(self, credentials: dict):
self.apikey = credentials.get("apikey", "")
self.wl_sign = credentials.get("wl_sign", "")
self.flag = credentials.get("flag", "")
@property
def platform_name(self) -> str:
return "xkdaili"
@property
def platform_label(self) -> str:
return "星空"
def _build_url(self, **params) -> str:
"""构建白名单请求URL"""
base_params = {
"apikey": self.apikey,
"sign": self.wl_sign,
"flag": self.flag,
}
base_params.update(params)
from urllib.parse import urlencode
return f"{self.BASE_URL}?{urlencode(base_params)}"
def _parse_response(self, text: str) -> tuple[bool, str]:
"""解析星空API响应。
status=100: 成功
status=508: 白名单已存在(视为成功)
status=202: 秘钥验证失败
status=808: 其它错误
"""
try:
data = json.loads(text)
status = data.get("status", -1)
info = data.get("info", "")
# 100=成功
if status == 100:
return True, info
# 508=已存在,视为成功
if status == 508:
return True, info
# 202=秘钥验证失败
if status == 202:
return False, f"认证失败: {info}"
# 其他错误
return False, f"失败(status={status}): {info}"
except (json.JSONDecodeError, ValueError):
# 非JSON响应,按文本判断
if "成功" in text or "ok" in text.lower():
return True, text
if "已存在" in text:
return True, text
return False, text
def get_whitelist(self) -> list[dict]:
"""
星空不支持查询白名单列表,返回空列表。
sync_ip 会检测到空列表后直接走添加逻辑。
"""
return []
def add_ip(self, ip: str) -> tuple[bool, str]:
"""添加IP到白名单。多个IP用英文逗号隔开。"""
try:
url = self._build_url(type="addwhiteip", ip=ip)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"星空添加白名单响应: {text}")
ok, msg = self._parse_response(text)
if ok:
logger.info(f"星空白名单添加成功: {ip} - {msg}")
else:
logger.warning(f"星空白名单添加失败: {msg}")
return ok, msg
except Exception as e:
logger.error(f"星空添加白名单失败: {e}")
return False, str(e)
def delete_ip(self, ip: str) -> tuple[bool, str]:
"""删除白名单IP。多个IP用英文逗号隔开。"""
try:
url = self._build_url(type="delwhiteip", ip=ip)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"星空删除白名单响应: {text}")
ok, msg = self._parse_response(text)
if ok:
logger.info(f"星空白名单删除成功: {ip} - {msg}")
else:
logger.warning(f"星空白名单删除失败: {msg}")
return ok, msg
except Exception as e:
logger.error(f"星空删除白名单失败: {e}")
return False, str(e)
def test_connection(self) -> tuple[bool, str]:
"""测试白名单API连接。"""
try:
# 尝试添加无效IP来验证认证
url = self._build_url(type="addwhiteip", ip="0.0.0.0")
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
ok, msg = self._parse_response(text)
if ok:
return True, "连接成功,API认证正常"
# 认证失败
if "认证失败" in msg:
return False, msg
# 其他错误(如无效IP),说明认证通过了
return True, f"连接成功,API可访问: {msg}"
except Exception as e:
return False, f"连接失败: {e}"
+14 -3
View File
@@ -149,17 +149,28 @@ def resolve_working_proxy(
api_url: str, api_url: str,
whitelist_uid: str = "", whitelist_uid: str = "",
whitelist_ukey: str = "", whitelist_ukey: str = "",
whitelist_platform: str = "xiequ",
whitelist_credentials: dict = None,
max_attempts: int = 4, max_attempts: int = 4,
log_func: Optional[LogFunc] = None, log_func: Optional[LogFunc] = None,
) -> tuple[Optional[str], str]: ) -> tuple[Optional[str], str]:
""" """
从代理 API 获取可用代理,自动处理白名单同步。 从代理 API 获取可用代理,自动处理白名单同步。
保留旧函数签名,供 Web 层代理测试继续使用。 支持:
- 新参数:whitelist_platform + whitelist_credentials
- 旧参数:whitelist_uid + whitelist_ukey(向后兼容)
""" """
# 构建白名单同步器
_wl_platform = whitelist_platform or "xiequ"
_wl_credentials = whitelist_credentials
if not _wl_credentials and whitelist_uid and whitelist_ukey:
_wl_platform = "xiequ"
_wl_credentials = {"uid": whitelist_uid, "ukey": whitelist_ukey}
syncer = ( syncer = (
DouyuWhitelistSyncer(whitelist_uid, whitelist_ukey) DouyuWhitelistSyncer(platform=_wl_platform, credentials=_wl_credentials)
if whitelist_uid and whitelist_ukey if _wl_credentials
else None else None
) )
resolver = ProxyResolver( resolver = ProxyResolver(
+7 -25
View File
@@ -9,18 +9,19 @@ from loguru import logger
def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]: def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str]:
""" """
验证代理是否可用,依次验证: 验证代理是否可用,只验证斗鱼主站可达。
1. 斗鱼主站 www.douyu.com(登录目标)
2. 极验接口 api.geetest.com(极验验证瓶颈)
两关都过才算可用。 不再验证极验接口,原因:
1. 极验接口验证耗时 5-8秒,对短效代理是巨大浪费
2. 极验限流导致大量代理被误判为不可用
3. 登录流程中极验验证本身会检测代理到极验的连通性
Returns: Returns:
(是否可用, 消息) (是否可用, 消息)
""" """
proxies = {'http': proxy_url, 'https': proxy_url} proxies = {'http': proxy_url, 'https': proxy_url}
# ── 第1关:斗鱼主站 ── # ── 斗鱼主站可达性验证 ──
try: try:
response = requests.get( response = requests.get(
'https://www.douyu.com', 'https://www.douyu.com',
@@ -29,6 +30,7 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
headers={'User-Agent': 'Mozilla/5.0'}, headers={'User-Agent': 'Mozilla/5.0'},
) )
response.raise_for_status() response.raise_for_status()
return True, '代理可用 → 斗鱼可达'
except Exception as exc: except Exception as exc:
err_msg = str(exc) err_msg = str(exc)
if 'Tunnel connection failed' in err_msg or '503' in err_msg: if 'Tunnel connection failed' in err_msg or '503' in err_msg:
@@ -40,26 +42,6 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}") logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
return False, detail return False, detail
# ── 第2关:极验接口 ──
try:
response = requests.get(
'https://api.geetest.com',
proxies=proxies,
timeout=timeout,
headers={'User-Agent': 'Mozilla/5.0'},
# geetest 首页可能返回 4xx,只要能连上就算通
allow_redirects=True,
)
return True, '代理可用 → 斗鱼+极验'
except Exception as exc:
err_msg = str(exc)
if 'timed out' in err_msg.lower():
detail = '极验接口超时'
else:
detail = f'极验不可达: {type(exc).__name__}'
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼可达但{detail}")
return False, detail
def verify_proxies_concurrent( def verify_proxies_concurrent(
proxy_urls: list[str], proxy_urls: list[str],
+30 -8
View File
@@ -2,19 +2,41 @@
from typing import Optional from typing import Optional
from .whitelist import WhitelistManager, get_local_exit_ip from .proxy_platforms import create_adapter
from .proxy_platforms.base import BaseWhitelistAdapter, _get_local_exit_ip
class DouyuWhitelistSyncer: class DouyuWhitelistSyncer:
"""把白名单 API 隔离成代理解析流程可调用的适配器。""" """把白名单 API 隔离成代理解析流程可调用的适配器。
def __init__(self, uid: str, ukey: str): 支持:
self.uid = uid - 新接口:platform + credentials(推荐)
self.ukey = ukey - 旧接口:uid + ukey(向后兼容,自动映射为协固平台)
"""
def __init__(
self,
platform: str = "xiequ",
credentials: dict = None,
uid: str = "",
ukey: str = "",
):
# 向后兼容:如果传入 uid/ukey,转为 credentials
if uid and ukey and not credentials:
platform = "xiequ"
credentials = {"uid": uid, "ukey": ukey}
self.platform = platform
self.credentials = credentials or {}
self._adapter: Optional[BaseWhitelistAdapter] = None
if self.credentials:
self._adapter = create_adapter(platform, self.credentials)
def sync_ip(self, ip: str) -> tuple[bool, str]: def sync_ip(self, ip: str) -> tuple[bool, str]:
manager = WhitelistManager(self.uid, self.ukey) if not self._adapter:
return manager.sync_ip(ip) return False, "未配置白名单凭据"
return self._adapter.sync_ip(ip)
def get_local_exit_ip(self) -> Optional[str]: def get_local_exit_ip(self) -> Optional[str]:
return get_local_exit_ip() return _get_local_exit_ip()
+47 -285
View File
@@ -1,327 +1,89 @@
"""代理IP白名单管理模块""" """代理IP白名单管理模块(兼容层)。
核心逻辑已迁移至 proxy_platforms/xiequ.py,本模块仅做向后兼容包装。
旧代码仍可 from .whitelist import WhitelistManager 正常使用。
"""
import json
import re
import threading
import time
from typing import Optional from typing import Optional
from urllib.parse import urlencode
import requests
from loguru import logger from loguru import logger
# 全局锁:防止多个并发登录任务同时同步白名单,触发代理服务商的30秒限流 from .proxy_platforms.xiequ import XiequAdapter
_whitelist_sync_lock = threading.Lock() from .proxy_platforms.base import (
BaseWhitelistAdapter,
_get_local_exit_ip,
get_exit_ip_via_proxy,
)
# 全局锁保留(基类内部使用,此处导出供旧代码引用)
from .proxy_platforms.base import _whitelist_sync_lock
class WhitelistManager: class WhitelistManager:
"""协固代理IP白名单管理器""" """携趣代理IP白名单管理器(兼容层,委托给 XiequAdapter)。"""
MEMO_PREFIX = "douyu_auto" MEMO_PREFIX = XiequAdapter.MEMO_PREFIX
BASE_URL = "http://op.xiequ.cn/IpWhiteList.aspx" BASE_URL = XiequAdapter.BASE_URL
def __init__(self, uid: str, ukey: str): def __init__(self, uid: str, ukey: str):
self._adapter = XiequAdapter({"uid": uid, "ukey": ukey})
self.uid = uid self.uid = uid
self.ukey = ukey self.ukey = ukey
self._memo = self.MEMO_PREFIX
@property @property
def memo(self) -> str: def memo(self) -> str:
"""当前机器的固定备注""" return self._adapter.memo
return self._memo
def _build_url(self, **params) -> str: def _build_url(self, **params) -> str:
"""构建请求URL""" """构建请求URL(兼容旧代码直接调用)。"""
base_params = { return self._adapter._build_url(**params)
"uid": self.uid,
"ukey": self.ukey,
}
base_params.update(params)
query = urlencode(base_params)
return f"{self.BASE_URL}?{query}"
def get_whitelist_json(self) -> list[dict]: def get_whitelist_json(self) -> list[dict]:
""" """
获取白名单列表(JSON格式 获取白名单列表(JSON格式,协固原始格式)。
Returns: Returns:
[{"IP": "x.x.x.x", "MEMO": "备注"}, ...] [{"IP": "x.x.x.x", "MEMO": "备注"}, ...]
""" """
try: records = self._adapter.get_whitelist()
url = self._build_url(act="getjson") # 将统一格式映射回调固原始格式
response = requests.get(url, timeout=10) return [{"IP": r["ip"], "MEMO": r["memo"]} for r in records]
response.raise_for_status()
text = response.text.strip()
if not text or text == "[]":
return []
data = json.loads(text)
if isinstance(data, list):
return data
# 处理 {"data": [...]} 格式
if isinstance(data, dict):
items = data.get("data", [])
if isinstance(items, list):
return items
return []
except Exception as e:
logger.error(f"获取白名单失败: {e}")
return []
def add_ip(self, ip: str, retry: bool = True) -> tuple[bool, str]: def add_ip(self, ip: str, retry: bool = True) -> tuple[bool, str]:
""" """添加IP到白名单。"""
添加IP到白名单 return self._adapter.add_ip(ip, retry=retry)
Returns:
(是否成功, API原始响应)
"""
try:
url = self._build_url(act="add", ip=ip, meno=self._memo)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"添加白名单响应: {text}")
if "ok" in text.lower() or "success" in text.lower() or "添加成功" in text:
logger.info(f"白名单添加成功: {ip} (备注: {self._memo})")
return True, text
if "已存在" in text or "exist" in text.lower() or "IpRep" in text:
logger.info(f"白名单已存在: {ip}")
return True, text
# 频率限制,等待后重试一次
if retry and ("频率过快" in text or "稍后" in text):
wait = 5
match = re.search(r'(\d+)\s*秒', text)
if match:
wait = int(match.group(1))
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
time.sleep(wait)
return self.add_ip(ip, retry=False)
# UKEY 错误
if "Err:Key" in text:
logger.error("白名单UKEY错误,请检查配置")
return False, "UKEY错误,请检查白名单配置"
# 超出白名单数量限制
if "Err:Max" in text or "超过" in text or "上限" in text:
logger.error(f"白名单数量超限: {text}")
return False, f"白名单数量超限: {text}"
logger.warning(f"白名单添加结果: {text}")
return False, text
except Exception as e:
logger.error(f"添加白名单失败: {e}")
return False, str(e)
def delete_ip(self, ip: str) -> bool: def delete_ip(self, ip: str) -> bool:
""" """删除指定IP。"""
删除指定IP ok, _ = self._adapter.delete_ip(ip)
return ok
Args:
ip: 要删除的IP地址
"""
try:
url = self._build_url(act="del", ip=ip)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"删除白名单响应: {text}")
if "ok" in text.lower() or "success" in text.lower() or "删除成功" in text:
logger.info(f"白名单删除成功: {ip}")
return True
logger.warning(f"白名单删除结果: {text}")
return False
except Exception as e:
logger.error(f"删除白名单失败: {e}")
return False
def get_memo_ip(self) -> Optional[str]: def get_memo_ip(self) -> Optional[str]:
""" """获取当前备注对应的IP。"""
获取当前备注对应的IP records = self._adapter.get_whitelist()
Returns:
IP地址,如果不存在返回None
"""
records = self.get_whitelist_json()
for record in records: for record in records:
if record.get("MEMO") == self._memo: if record.get("memo") == self.memo:
return record.get("IP") return record.get("ip")
return None return None
def get_memo_records(self) -> list[dict]: def get_memo_records(self) -> list[dict]:
""" """获取当前备注的所有记录(协固原始格式)。"""
获取当前备注的所有记录 records = self._adapter.get_whitelist()
return [
Returns: {"IP": r["ip"], "MEMO": r["memo"]}
匹配备注的记录列表 for r in records
""" if r.get("memo") == self.memo
records = self.get_whitelist_json() ]
return [r for r in records if r.get("MEMO") == self._memo]
def sync_ip(self, current_ip: str, keep_recent: int = 3) -> tuple[bool, str]: def sync_ip(self, current_ip: str, keep_recent: int = 3) -> tuple[bool, str]:
""" """同步白名单IP。"""
同步白名单IP(保留多个近期出口IP,应对移动网络IP漂移) return self._adapter.sync_ip(current_ip, keep_recent)
策略:
- 当前IP已在白名单(同备注),无需操作
- 当前IP不在白名单,添加(不删旧的,保留多个出口IP)
- 超过 keep_recent 个同备注IP时,删除最老的(按列表顺序)
- 当前IP已存在但备注不同,不处理(避免误删他人配置)
线程安全:使用全局锁串行化同步操作,避免并发调用触发代理服务商限流。
Args:
current_ip: 当前出口IP
keep_recent: 保留的同备注IP数量上限(默认3个,应对IP漂移)
Returns:
(是否成功, 消息)
"""
with _whitelist_sync_lock:
try:
records = self.get_whitelist_json()
memo_records = [r for r in records if r.get("MEMO") == self._memo]
memo_ips = [r.get("IP") for r in memo_records]
# 当前IP已在白名单(同备注),无需操作
if current_ip in memo_ips:
msg = f"白名单IP已是最新的: {current_ip}"
logger.info(msg)
return True, msg
# 当前IP已存在但备注不同,不处理(避免误删他人配置)
if any(r.get('IP') == current_ip for r in records):
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加")
return True, f"白名单IP已存在: {current_ip}"
# 超过上限,删除最老的(列表前面的)
if len(memo_ips) >= keep_recent:
# 删除最早添加的(列表顺序)
to_delete = memo_ips[:len(memo_ips) - keep_recent + 1]
for old_ip in to_delete:
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
self.delete_ip(old_ip)
time.sleep(1)
# 添加新IP
logger.info(f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)")
ok, resp = self.add_ip(current_ip)
if ok:
msg = f"白名单IP已添加: {current_ip}"
logger.info(msg)
return True, msg
return False, f"白名单添加失败,API响应: {resp}"
except Exception as e:
msg = f"白名单同步失败: {e}"
logger.error(msg)
return False, msg
def test_connection(self) -> tuple[bool, str]: def test_connection(self) -> tuple[bool, str]:
""" """测试白名单API连接。"""
测试白名单API连接 return self._adapter.test_connection()
Returns:
(是否成功, 消息)
"""
try:
records = self.get_whitelist_json()
count = len(records)
my_records = [r for r in records if r.get("MEMO", "").startswith(self.MEMO_PREFIX)]
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
logger.info(msg)
return True, msg
except Exception as e:
msg = f"连接失败: {e}"
logger.error(msg)
return False, msg
def get_local_exit_ip() -> Optional[str]: def get_local_exit_ip() -> Optional[str]:
""" """获取本机当前公网出口IP(不走代理)。"""
获取本机当前公网出口IP(不走代理)。 return _get_local_exit_ip()
用于在获取代理前主动同步白名单,避免出口IP漂移导致代理拒绝连接。
Returns:
出口IP地址,获取失败返回 None
"""
targets = [
"https://myip.ipip.net",
"https://4.ipw.cn",
"https://api.ipify.org",
]
for url in targets:
try:
response = requests.get(url, timeout=8, headers={"User-Agent": "Mozilla/5.0"})
response.raise_for_status()
text = response.text.strip()
# 从文本中提取IP
match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text)
if match:
return match.group(1)
except Exception:
continue
return None
def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
"""
通过代理获取出口IP
Args:
proxy: 代理URL,格式 http://ip:port
Returns:
出口IP地址
"""
targets = [
"https://qifu-api.baidubce.com/ip/local/geo/v1/district",
"https://myip.ipip.net",
"https://4.ipw.cn",
]
proxies = {"http": proxy, "https": proxy}
for url in targets:
try:
response = requests.get(
url,
proxies=proxies,
timeout=6,
headers={"User-Agent": "Mozilla/5.0"},
)
response.raise_for_status()
# 尝试解析IP
content_type = response.headers.get("content-type", "").lower()
if "json" in content_type:
data = response.json()
ip = data.get("ip") or data.get("origin")
if ip:
return str(ip).split(",")[0].strip()
# 从文本中提取IP
text = response.text.strip()
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match:
return match.group(1)
except Exception:
continue
return None
+45 -14
View File
@@ -1,17 +1,37 @@
"""日志配置模块""" """日志配置模块"""
import os
import sys import sys
from datetime import date
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
def setup_logger(level: str = "INFO", log_file: str = None) -> None: def _should_rotate(message, file):
"""自定义轮转条件:每天0点轮转,或单文件超过10M。"""
if not file:
return False
try:
# 单文件超过 10M 则轮转
if os.path.getsize(file.name) > 10 * 1024 * 1024:
return True
# 跨天则轮转:日志日期 ≠ 文件最后修改日期
file_date = date.fromtimestamp(os.path.getmtime(file.name))
log_date = message.record["time"].date()
return file_date != log_date
except OSError:
return False
def setup_logger(level: str = "INFO", log_dir: str = None, log_file: str = None) -> None:
""" """
配置日志 配置日志
Args: Args:
level: 日志级别 level: 日志级别
log_file: 日志文件路径 log_dir: 日志目录路径(文件名按日期自动生成,如 app-2026-06-24.log
log_file: 日志文件路径(兼容旧接口,优先级低于 log_dir)
""" """
# 移除默认handler # 移除默认handler
logger.remove() logger.remove()
@@ -27,16 +47,27 @@ def setup_logger(level: str = "INFO", log_file: str = None) -> None:
colorize=True, colorize=True,
) )
# 文件输出 # 确定日志文件路径
if log_file: if log_dir:
log_path = Path(log_file) # 新接口:按目录 + 日期命名
log_path.parent.mkdir(parents=True, exist_ok=True) dir_path = Path(log_dir)
dir_path.mkdir(parents=True, exist_ok=True)
file_path = str(dir_path / "app-{time:YYYY-MM-DD}.log")
elif log_file:
# 兼容旧接口
file_path = Path(log_file)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path = str(file_path)
else:
return
logger.add( # 文件输出:按天命名 + 单文件最大10M轮转 + 保留7天
log_file, logger.add(
level=level, file_path,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}", level=level,
rotation="10 MB", format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}",
retention="7 days", rotation=_should_rotate, # 超过10M轮转
encoding="utf-8", retention="7 days", # 保留7天
) compression="zip", # 旧日志自动压缩节省空间
encoding="utf-8",
)
+2 -2
View File
@@ -17,10 +17,10 @@ from utils import setup_logger
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 初始化日志(控制台 + 文件) # 初始化日志(控制台 + 按天命名文件)
_log_level = os.getenv("LOG_LEVEL", "DEBUG") _log_level = os.getenv("LOG_LEVEL", "DEBUG")
_log_dir = Path(__file__).resolve().parents[2] / "logs" _log_dir = Path(__file__).resolve().parents[2] / "logs"
setup_logger(level=_log_level, log_file=str(_log_dir / "app.log")) setup_logger(level=_log_level, log_dir=str(_log_dir))
init_db() init_db()
yield yield
@@ -0,0 +1,33 @@
"""代理白名单平台抽象:新增 whitelist_platform / whitelist_credentials 字段
Revision ID: 20260624_0003
Revises: 20260624_0002
Create Date: 2026-06-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260624_0003"
down_revision: Union[str, None] = "20260624_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("proxy_config") as batch:
batch.add_column(
sa.Column("whitelist_platform", sa.String(32), server_default="xiequ")
)
batch.add_column(
sa.Column("whitelist_credentials", sa.JSON(), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("proxy_config") as batch:
batch.drop_column("whitelist_credentials")
batch.drop_column("whitelist_platform")
+3
View File
@@ -81,6 +81,9 @@ class ProxyConfig(Base):
https = Column(EncryptedText(), default="") https = Column(EncryptedText(), default="")
# 白名单 # 白名单
whitelist_enabled = Column(Boolean, default=False) whitelist_enabled = Column(Boolean, default=False)
whitelist_platform = Column(String(32), default="xiequ")
whitelist_credentials = Column(JSON, nullable=True)
# 旧字段保留(向后兼容双写)
whitelist_uid = Column(EncryptedText(), default="") whitelist_uid = Column(EncryptedText(), default="")
whitelist_ukey = Column(EncryptedText(), default="") whitelist_ukey = Column(EncryptedText(), default="")
+26 -6
View File
@@ -7,9 +7,12 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User from ..models import User
from ..schemas import ProxyConfigOut, ProxyConfigUpdate from ..schemas import ProxyConfigOut, ProxyConfigUpdate, PlatformInfo, PlatformFieldDef
from ..deps import require_permission, authenticate_websocket from ..deps import require_permission, authenticate_websocket
from ..services.proxy_service import proxy_service from ..services.proxy_service import proxy_service
from core.douyu.proxy_platforms import (
get_platform_names, get_platform_labels, get_credential_fields,
)
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"]) router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
@@ -30,17 +33,34 @@ def update_proxy_config(
): ):
return proxy_service.update_config( return proxy_service.update_config(
db, db,
enabled=req.enabled, enabled=req.enabled if req.enabled is not None else False,
api_url=req.api_url, api_url=req.api_url if req.api_url is not None else "",
http=req.http, http=req.http if req.http is not None else "",
https=req.https, https=req.https if req.https is not None else "",
whitelist_enabled=req.whitelist_enabled, whitelist_enabled=req.whitelist_enabled if req.whitelist_enabled is not None else False,
whitelist_platform=req.whitelist_platform or "xiequ",
whitelist_credentials=req.whitelist_credentials,
whitelist_uid=req.whitelist_uid, whitelist_uid=req.whitelist_uid,
whitelist_ukey=req.whitelist_ukey, whitelist_ukey=req.whitelist_ukey,
current_user=current, current_user=current,
) )
@router.get("/platforms", response_model=list[PlatformInfo])
def list_platforms():
"""获取所有可用的代理白名单平台及其凭据字段定义。"""
labels = get_platform_labels()
result = []
for name in get_platform_names():
fields = get_credential_fields(name)
result.append(PlatformInfo(
name=name,
label=labels.get(name, name),
credential_fields=[PlatformFieldDef(**f) for f in fields],
))
return result
# ---- WebSocket 日志推送 ---- # ---- WebSocket 日志推送 ----
@router.websocket("/ws/test/{test_id}") @router.websocket("/ws/test/{test_id}")
+29 -2
View File
@@ -167,12 +167,39 @@ class ProxyConfigOut(BaseModel):
http: str = "" http: str = ""
https: str = "" https: str = ""
whitelist_enabled: bool = False whitelist_enabled: bool = False
whitelist_platform: str = "xiequ"
whitelist_credentials: Optional[dict] = None
# 旧字段保留(向后兼容)
whitelist_uid: str = "" whitelist_uid: str = ""
whitelist_ukey: str = "" whitelist_ukey: str = ""
class ProxyConfigUpdate(ProxyConfigOut): class ProxyConfigUpdate(BaseModel):
pass enabled: Optional[bool] = None
api_url: Optional[str] = None
http: Optional[str] = None
https: Optional[str] = None
whitelist_enabled: Optional[bool] = None
whitelist_platform: Optional[str] = "xiequ"
whitelist_credentials: Optional[dict] = None
# 旧字段保留(向后兼容)
whitelist_uid: Optional[str] = None
whitelist_ukey: Optional[str] = None
# ---- 代理平台元信息 ----
class PlatformFieldDef(BaseModel):
"""平台凭据字段定义。"""
key: str
label: str
placeholder: str = ""
class PlatformInfo(BaseModel):
"""平台元信息。"""
name: str
label: str
credential_fields: list[PlatformFieldDef]
# ---- 通用 ---- # ---- 通用 ----
+12 -4
View File
@@ -55,12 +55,20 @@ class LoginBatchRunner:
# 共享代理管理器(带锁,避免并发白名单限流;极验失败时可刷新代理) # 共享代理管理器(带锁,避免并发白名单限流;极验失败时可刷新代理)
self._shared_proxy_manager = None self._shared_proxy_manager = None
if proxy_config and proxy_config.enabled and proxy_config.api_url: if proxy_config and proxy_config.enabled and proxy_config.api_url:
wl_uid = proxy_config.whitelist_uid or "" if proxy_config.whitelist_enabled else "" wl_platform = "xiequ"
wl_ukey = proxy_config.whitelist_ukey or "" if proxy_config.whitelist_enabled else "" wl_credentials = None
if proxy_config.whitelist_enabled:
wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ"
wl_credentials = getattr(proxy_config, 'whitelist_credentials', None)
# 向后兼容:旧字段有值但新字段为空
if not wl_credentials and proxy_config.whitelist_uid and proxy_config.whitelist_ukey:
wl_platform = "xiequ"
wl_credentials = {"uid": proxy_config.whitelist_uid, "ukey": proxy_config.whitelist_ukey}
self._shared_proxy_manager = get_proxy_manager( self._shared_proxy_manager = get_proxy_manager(
proxy_config.api_url, proxy_config.api_url,
whitelist_uid=wl_uid, whitelist_platform=wl_platform,
whitelist_ukey=wl_ukey, whitelist_credentials=wl_credentials,
) )
def stop(self): def stop(self):
+89 -42
View File
@@ -9,9 +9,31 @@ from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
from core.douyu.proxy_platforms import create_adapter, get_platform_labels
from core.douyu.proxy_platforms.base import _get_local_exit_ip
from ..models import ProxyConfig as ProxyConfigModel, AuditLog from ..models import ProxyConfig as ProxyConfigModel, AuditLog
def _build_whitelist_params(cfg: ProxyConfigModel) -> dict:
"""从 ProxyConfig 构建白名单参数,自动处理新旧字段兼容。
Returns:
{"whitelist_platform": str, "whitelist_credentials": dict|None}
"""
platform = getattr(cfg, 'whitelist_platform', None) or "xiequ"
credentials = getattr(cfg, 'whitelist_credentials', None)
# 向后兼容:旧字段有值但新字段为空时,自动迁移
if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey:
platform = "xiequ"
credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
return {
"whitelist_platform": platform,
"whitelist_credentials": credentials if cfg.whitelist_enabled else None,
}
class ProxyService: class ProxyService:
"""代理 & 白名单服务:管理代理配置、执行测试。""" """代理 & 白名单服务:管理代理配置、执行测试。"""
@@ -31,12 +53,26 @@ class ProxyService:
db.add(cfg) db.add(cfg)
db.commit() db.commit()
db.refresh(cfg) db.refresh(cfg)
# 应用层自动迁移:旧字段有值但新字段为空时,填充新字段
if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials:
cfg.whitelist_platform = "xiequ"
cfg.whitelist_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
db.commit()
return cfg return cfg
@staticmethod @staticmethod
def update_config(db: Session, enabled, api_url, http, https, def update_config(
whitelist_enabled, whitelist_uid, whitelist_ukey, db: Session,
current_user) -> ProxyConfigModel: enabled, api_url, http, https,
whitelist_enabled,
whitelist_platform="xiequ",
whitelist_credentials=None,
whitelist_uid=None,
whitelist_ukey=None,
current_user=None,
) -> ProxyConfigModel:
"""更新代理配置并记录审计日志。""" """更新代理配置并记录审计日志。"""
cfg = ProxyService.get_or_create(db) cfg = ProxyService.get_or_create(db)
cfg.enabled = enabled cfg.enabled = enabled
@@ -44,18 +80,29 @@ class ProxyService:
cfg.http = http cfg.http = http
cfg.https = https cfg.https = https
cfg.whitelist_enabled = whitelist_enabled cfg.whitelist_enabled = whitelist_enabled
cfg.whitelist_uid = whitelist_uid cfg.whitelist_platform = whitelist_platform
cfg.whitelist_ukey = whitelist_ukey cfg.whitelist_credentials = whitelist_credentials
# 双写:协固平台同步到旧字段,其他平台清空旧字段
if whitelist_platform == "xiequ" and whitelist_credentials:
cfg.whitelist_uid = whitelist_credentials.get("uid", "")
cfg.whitelist_ukey = whitelist_credentials.get("ukey", "")
else:
# 非协固平台,旧字段使用传入值或清空
cfg.whitelist_uid = whitelist_uid or ""
cfg.whitelist_ukey = whitelist_ukey or ""
db.commit() db.commit()
db.refresh(cfg) db.refresh(cfg)
db.add(AuditLog( if current_user:
user_id=current_user.id, db.add(AuditLog(
username=current_user.username, user_id=current_user.id,
action="proxy:update", username=current_user.username,
target="proxy_config", action="proxy:update",
)) target="proxy_config",
db.commit() ))
db.commit()
return cfg return cfg
# ---- 测试任务管理 ---- # ---- 测试任务管理 ----
@@ -112,13 +159,12 @@ class ProxyService:
# API代理 # API代理
if cfg.api_url: if cfg.api_url:
whitelist_uid = cfg.whitelist_uid if cfg.whitelist_enabled else "" wl_params = _build_whitelist_params(cfg)
whitelist_ukey = cfg.whitelist_ukey if cfg.whitelist_enabled else ""
proxy_url, msg = resolve_working_proxy( proxy_url, msg = resolve_working_proxy(
api_url=cfg.api_url, api_url=cfg.api_url,
whitelist_uid=whitelist_uid, whitelist_platform=wl_params["whitelist_platform"],
whitelist_ukey=whitelist_ukey, whitelist_credentials=wl_params["whitelist_credentials"],
max_attempts=3, max_attempts=3,
log_func=push, log_func=push,
) )
@@ -145,7 +191,6 @@ class ProxyService:
): ):
"""在线程中执行白名单测试。""" """在线程中执行白名单测试。"""
import requests as req_lib import requests as req_lib
from core.douyu.whitelist import WhitelistManager
def push(level, message): def push(level, message):
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
@@ -158,16 +203,28 @@ class ProxyService:
push("error", "白名单未启用") push("error", "白名单未启用")
push("result", "") push("result", "")
return return
if not cfg.whitelist_uid or not cfg.whitelist_ukey:
push("error", "未配置白名单UID/UKEY") # 构建适配器
wl_params = _build_whitelist_params(cfg)
credentials = wl_params["whitelist_credentials"]
platform = wl_params["whitelist_platform"]
if not credentials:
push("error", "未配置白名单凭据")
push("result", "") push("result", "")
return return
manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey) adapter = create_adapter(platform, credentials)
if not adapter:
push("error", f"不支持的白名单平台: {platform}")
push("result", "")
return
push("info", f"当前白名单平台: {adapter.platform_label}")
# 1. 测试API连接 # 1. 测试API连接
push("info", "测试白名单API连接...") push("info", "测试白名单API连接...")
ok, msg = manager.test_connection() ok, msg = adapter.test_connection()
push("info" if ok else "error", f"白名单API: {msg}") push("info" if ok else "error", f"白名单API: {msg}")
if not ok: if not ok:
push("result", "") push("result", "")
@@ -190,19 +247,7 @@ class ProxyService:
if not local_ip: if not local_ip:
push("info", "通过IP检测服务获取本机公网IP...") push("info", "通过IP检测服务获取本机公网IP...")
for url in [ local_ip = _get_local_exit_ip()
'https://qifu-api.baidubce.com/ip/local/geo/v1/district',
'https://myip.ipip.net',
'https://4.ipw.cn',
]:
try:
resp = req_lib.get(url, timeout=6, headers={"User-Agent": "Mozilla/5.0"})
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text)
if match:
local_ip = match.group(1)
break
except Exception:
continue
if not local_ip: if not local_ip:
push("error", "无法获取本机公网IP") push("error", "无法获取本机公网IP")
@@ -212,22 +257,24 @@ class ProxyService:
push("info", f"本机公网IP: {local_ip}") push("info", f"本机公网IP: {local_ip}")
# 3. 检查并同步白名单 # 3. 检查并同步白名单
records = manager.get_whitelist_json() records = adapter.get_whitelist()
in_list = any(r.get('IP') == local_ip for r in records) in_list = any(r.get('ip') == local_ip for r in records)
push("info", f"白名单共 {len(records)} 条记录")
if records:
push("info", f"白名单共 {len(records)} 条记录")
if in_list: if in_list:
record = next((r for r in records if r.get('IP') == local_ip), {}) record = next((r for r in records if r.get('ip') == local_ip), {})
memo = record.get('MEMO', '') memo = record.get('memo', '')
if memo == manager.memo: if memo == adapter.memo:
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)") push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
else: else:
push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...') push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...')
sync_ok, sync_msg = manager.sync_ip(local_ip) sync_ok, sync_msg = adapter.sync_ip(local_ip)
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}") push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
else: else:
push("info", f"正在将 {local_ip} 添加到白名单...") push("info", f"正在将 {local_ip} 添加到白名单...")
sync_ok, sync_msg = manager.sync_ip(local_ip) sync_ok, sync_msg = adapter.sync_ip(local_ip)
push("success" if sync_ok else "error", f"白名单同步: {sync_msg}") push("success" if sync_ok else "error", f"白名单同步: {sync_msg}")
push("result", "") push("result", "")
+2 -1
View File
@@ -1,9 +1,10 @@
import api from './client'; import api from './client';
import type { ProxyConfig, ProxyTestResult } from './types'; import type { ProxyConfig, ProxyTestResult, PlatformInfo } from './types';
export const proxyApi = { export const proxyApi = {
get: () => api.get<ProxyConfig, ProxyConfig>('/proxy'), get: () => api.get<ProxyConfig, ProxyConfig>('/proxy'),
update: (data: ProxyConfig) => api.put<ProxyConfig, ProxyConfig>('/proxy', data), update: (data: ProxyConfig) => api.put<ProxyConfig, ProxyConfig>('/proxy', data),
test: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/test'), test: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/test'),
testWhitelist: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/whitelist/test'), testWhitelist: () => api.post<ProxyTestResult, ProxyTestResult>('/proxy/whitelist/test'),
getPlatforms: () => api.get<PlatformInfo[], PlatformInfo[]>('/proxy/platforms'),
}; };
+15
View File
@@ -112,6 +112,9 @@ export interface ProxyConfig {
http: string; http: string;
https: string; https: string;
whitelist_enabled: boolean; whitelist_enabled: boolean;
whitelist_platform: string;
whitelist_credentials: Record<string, string> | null;
// 旧字段保留(向后兼容)
whitelist_uid: string; whitelist_uid: string;
whitelist_ukey: string; whitelist_ukey: string;
} }
@@ -121,6 +124,18 @@ export interface ProxyTestResult {
success: boolean; success: boolean;
} }
export interface PlatformFieldDef {
key: string;
label: string;
placeholder: string;
}
export interface PlatformInfo {
name: string;
label: string;
credential_fields: PlatformFieldDef[];
}
// ==================== Permissions ==================== // ==================== Permissions ====================
export interface PermissionsListResult { export interface PermissionsListResult {
+70 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Form, Input, Switch, Button, Card, message, Row, Col } from 'antd'; import { Form, Input, Switch, Button, Card, Select, message, Row, Col } from 'antd';
import { proxyApi, type ProxyConfig } from '../api/modules'; import { proxyApi, type ProxyConfig, type PlatformInfo } from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel'; import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
@@ -13,6 +13,19 @@ export default function ProxyPage() {
const [configLoaded, setConfigLoaded] = useState(false); const [configLoaded, setConfigLoaded] = useState(false);
const { logs, connect: connectLogs, close: closeLogs } = useWebSocketLogs(); const { logs, connect: connectLogs, close: closeLogs } = useWebSocketLogs();
// 平台相关状态
const [platforms, setPlatforms] = useState<PlatformInfo[]>([]);
const [selectedPlatform, setSelectedPlatform] = useState<string>('xiequ');
const [credentials, setCredentials] = useState<Record<string, string>>({});
// 加载平台列表
useEffect(() => {
proxyApi.getPlatforms().then(setPlatforms).catch(() => {});
}, []);
// 当前平台的凭据字段定义
const currentPlatformFields = platforms.find(p => p.name === selectedPlatform)?.credential_fields ?? [];
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
try { try {
const data = await proxyApi.get(); const data = await proxyApi.get();
@@ -22,9 +35,19 @@ export default function ProxyPage() {
http: data.http ?? '', http: data.http ?? '',
https: data.https ?? '', https: data.https ?? '',
whitelist_enabled: data.whitelist_enabled ?? false, whitelist_enabled: data.whitelist_enabled ?? false,
whitelist_uid: data.whitelist_uid ?? '',
whitelist_ukey: data.whitelist_ukey ?? '',
}); });
// 恢复平台和凭据
const platform = data.whitelist_platform || 'xiequ';
setSelectedPlatform(platform);
if (data.whitelist_credentials && Object.keys(data.whitelist_credentials).length > 0) {
setCredentials(data.whitelist_credentials);
} else if (data.whitelist_uid || data.whitelist_ukey) {
// 向后兼容:旧字段迁移到凭据
setCredentials({ uid: data.whitelist_uid || '', ukey: data.whitelist_ukey || '' });
} else {
setCredentials({});
}
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e)); message.error(getErrorMessage(e));
} finally { } finally {
@@ -56,7 +79,15 @@ export default function ProxyPage() {
setLoading(true); setLoading(true);
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
await proxyApi.update(values as ProxyConfig); const submitData: ProxyConfig = {
...values,
whitelist_platform: selectedPlatform,
whitelist_credentials: credentials,
// 旧字段:协固平台双写,其他平台清空
whitelist_uid: selectedPlatform === 'xiequ' ? (credentials.uid || '') : '',
whitelist_ukey: selectedPlatform === 'xiequ' ? (credentials.ukey || '') : '',
};
await proxyApi.update(submitData);
message.success('已保存'); message.success('已保存');
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e)); message.error(getErrorMessage(e));
@@ -87,6 +118,15 @@ export default function ProxyPage() {
} }
}; };
const handlePlatformChange = (value: string) => {
setSelectedPlatform(value);
setCredentials({});
};
const handleCredentialChange = (key: string, value: string) => {
setCredentials(prev => ({ ...prev, [key]: value }));
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}>
@@ -106,8 +146,6 @@ export default function ProxyPage() {
api_url: '', api_url: '',
http: '', http: '',
https: '', https: '',
whitelist_uid: '',
whitelist_ukey: '',
}} }}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
@@ -118,7 +156,7 @@ export default function ProxyPage() {
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item name="api_url" label="代理API地址" style={{ marginBottom: 8 }}> <Form.Item name="api_url" label="代理API地址" style={{ marginBottom: 8 }}>
<Input placeholder="http://op.xiequ.cn/...?act=get" /> <Input placeholder="代理提取API地址" />
</Form.Item> </Form.Item>
<Form.Item name="http" label="静态HTTP代理" style={{ marginBottom: 8 }}> <Form.Item name="http" label="静态HTTP代理" style={{ marginBottom: 8 }}>
<Input placeholder="http://ip:port" /> <Input placeholder="http://ip:port" />
@@ -134,12 +172,30 @@ export default function ProxyPage() {
<Form.Item name="whitelist_enabled" label="启用白名单自动管理" valuePropName="checked" style={{ marginBottom: 8 }}> <Form.Item name="whitelist_enabled" label="启用白名单自动管理" valuePropName="checked" style={{ marginBottom: 8 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item name="whitelist_uid" label="协固UID" style={{ marginBottom: 8 }}>
<Input placeholder="如: 99769" /> {/* 平台选择 */}
</Form.Item> <div style={{ marginBottom: 8 }}>
<Form.Item name="whitelist_ukey" label="协固UKEY" style={{ marginBottom: 8 }}> <div style={{ marginBottom: 4, fontSize: 13, color: 'rgba(0,0,0,0.88)' }}></div>
<Input placeholder="如: C99371082B965B70F46DCAA87A04618B" /> <Select
</Form.Item> value={selectedPlatform}
onChange={handlePlatformChange}
style={{ width: '100%' }}
options={platforms.map(p => ({ value: p.name, label: p.label }))}
/>
</div>
{/* 动态凭据字段 */}
{currentPlatformFields.map(field => (
<div key={field.key} style={{ marginBottom: 8 }}>
<div style={{ marginBottom: 4, fontSize: 13, color: 'rgba(0,0,0,0.88)' }}>{field.label}</div>
<Input
placeholder={field.placeholder}
value={credentials[field.key] || ''}
onChange={(e) => handleCredentialChange(field.key, e.target.value)}
/>
</div>
))}
<Button size="small" onClick={handleTestWhitelist} loading={testingWl}></Button> <Button size="small" onClick={handleTestWhitelist} loading={testingWl}></Button>
</Card> </Card>
</Col> </Col>