style: 统一 Ruff 代码格式

This commit is contained in:
yml2213
2026-08-30 21:04:52 +08:00
parent c891ac982e
commit 47e19ed7b2
90 changed files with 5574 additions and 2350 deletions
+6 -1
View File
@@ -6,7 +6,12 @@ from .login_api_wgapi import WgapiLoginAPI
from .login_api_iframe import IframeLoginAPI
from .email_verifier import EmailVerifier
from .activity_client import DouyuActivityClient, DouyuActivityError
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError
from .recharge_api import (
FishFinRechargeClient,
FishFinRechargeConfig,
FishFinRechargeConfigError,
FishFinRechargeError,
)
__all__ = [
"DouyuLogin",
+37 -33
View File
@@ -15,7 +15,9 @@ class CookieEnricher:
CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
ACF_CCN_REFERER = (
"https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
)
TIMEOUT = (5, 10)
RETRIES = 3
@@ -47,25 +49,27 @@ class CookieEnricher:
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
if attempt < max_attempts:
self.sleep_interruptible(1)
raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error
raise ValueError(
f"已重试 {max_attempts} 次仍未补齐CK: {last_error}"
) from last_error
def generate_csrf_cookie(self) -> str:
"""访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。"""
logger.info("补齐CSRF Cookie...")
headers = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5',
'Origin': 'https://www.douyu.com',
'Referer': self.CSRF_REFERER,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5",
"Origin": "https://www.douyu.com",
"Referer": self.CSRF_REFERER,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
# 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。
'Content-Type': None,
'X-Requested-With': None,
"Content-Type": None,
"X-Requested-With": None,
}
response = self.request(
'post',
"post",
self.CSRF_API,
headers=headers,
timeout=self.TIMEOUT,
@@ -82,11 +86,11 @@ class CookieEnricher:
preview = body[:200].replace("\n", "\\n")
raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc
if payload.get('error') != 0:
if payload.get("error") != 0:
raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}")
cookies = self.session.cookies.get_dict()
csrf_token = cookies.get('cvl_csrf_token', '')
csrf_token = cookies.get("cvl_csrf_token", "")
if not csrf_token:
raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token")
@@ -97,25 +101,25 @@ class CookieEnricher:
"""访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。"""
logger.info("补齐 acf_ccn Cookie...")
headers = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Priority': 'u=1, i',
'Referer': self.ACF_CCN_REFERER,
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'Sec-CH-UA-Mobile': '?0',
'Sec-CH-UA-Platform': '"macOS"',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Priority": "u=1, i",
"Referer": self.ACF_CCN_REFERER,
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
# 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。
'Origin': None,
'Content-Type': None,
'X-Requested-With': None,
"Origin": None,
"Content-Type": None,
"X-Requested-With": None,
}
response = self.request(
'get',
"get",
self.ACF_CCN_API,
headers=headers,
timeout=self.TIMEOUT,
@@ -123,10 +127,10 @@ class CookieEnricher:
response.raise_for_status()
cookies = self.session.cookies.get_dict()
acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '')
if acf_ccn and not cookies.get('acf_ccn'):
acf_ccn = cookies.get("acf_ccn", "") or response.cookies.get("acf_ccn", "")
if acf_ccn and not cookies.get("acf_ccn"):
# 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。
self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/')
self.session.cookies.set("acf_ccn", acf_ccn, domain=".douyu.com", path="/")
if not acf_ccn:
raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn")
+9 -9
View File
@@ -25,21 +25,21 @@ DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr"
def md5(text: str) -> str:
"""MD5加密"""
return hashlib.md5(text.encode('utf-8')).hexdigest()
return hashlib.md5(text.encode("utf-8")).hexdigest()
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
"""RSA加密"""
key = RSA.import_key(public_key)
cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(text.encode('utf-8'))
return base64.b64encode(encrypted).decode('utf-8')
encrypted = cipher.encrypt(text.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
"""AES加密"""
key_bytes = key.encode('utf-8')
text_bytes = text.encode('utf-8')
key_bytes = key.encode("utf-8")
text_bytes = text.encode("utf-8")
# 填充到16的倍数
padding_len = 16 - (len(text_bytes) % 16)
@@ -47,7 +47,7 @@ def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
cipher = AES.new(key_bytes, AES.MODE_ECB)
encrypted = cipher.encrypt(text_bytes)
return base64.b64encode(encrypted).decode('utf-8')
return base64.b64encode(encrypted).decode("utf-8")
def encrypt_username(username: str) -> str:
@@ -62,7 +62,7 @@ def encrypt_password(password: str) -> str:
def encrypt_nickname_or_phone(text: str) -> str:
"""加密昵称或手机号"""
key = DOUYU_RC4_KEY.encode('utf-8')
key = DOUYU_RC4_KEY.encode("utf-8")
cipher = ARC4.new(key)
encrypted = cipher.encrypt(text.encode('utf-8'))
return base64.b64encode(encrypted).decode('utf-8')
encrypted = cipher.encrypt(text.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
+20 -18
View File
@@ -47,7 +47,9 @@ class LoginAPIStrategy(ABC):
...
@abstractmethod
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录请求参数(获取极验参数)"""
...
@@ -85,11 +87,11 @@ class LoginAPIStrategy(ABC):
Returns:
(gt, challenge, code_token)
"""
geetest_data = payload.get('data', {}).get('geetest', {})
code_data = geetest_data.get('code_data', {})
gt = code_data.get('gt', '')
challenge = code_data.get('challenge', '')
code_token = geetest_data.get('code_token', '')
geetest_data = payload.get("data", {}).get("geetest", {})
code_data = geetest_data.get("code_data", {})
gt = code_data.get("gt", "")
challenge = code_data.get("challenge", "")
code_token = geetest_data.get("code_token", "")
return gt, challenge, code_token
def extract_remote_code(self, payload: dict) -> str:
@@ -101,7 +103,7 @@ class LoginAPIStrategy(ABC):
Returns:
remote_code 字符串
"""
return payload.get('data', {}).get('remoteLogin', {}).get('code', '')
return payload.get("data", {}).get("remoteLogin", {}).get("code", "")
def extract_login_url(self, payload: dict) -> str:
"""从验证码响应提取登录 URL
@@ -112,27 +114,27 @@ class LoginAPIStrategy(ABC):
Returns:
登录回调 URL
"""
data = payload.get('data', {})
data = payload.get("data", {})
if not isinstance(data, dict):
return ''
return ""
# `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址,
# 但不同版本接口的字段命名不完全一致。
for key in ('url', 'loginUrl', 'login_url', 'redirectUrl', 'redirect_url'):
for key in ("url", "loginUrl", "login_url", "redirectUrl", "redirect_url"):
value = data.get(key)
if isinstance(value, str) and value:
return value
return ''
return ""
def extract_mobile_bind_unique_key(self, payload: dict) -> str:
"""提取服务端要求绑定手机号时返回的一次性继续登录标识。"""
data = payload.get('data', {})
data = payload.get("data", {})
if not isinstance(data, dict):
return ''
return ""
def find_unique_key(value: object) -> str:
if isinstance(value, dict):
unique_key = value.get('uniqueKey') or value.get('unique_key')
unique_key = value.get("uniqueKey") or value.get("unique_key")
if isinstance(unique_key, str) and unique_key:
return unique_key
for child in value.values():
@@ -144,14 +146,14 @@ class LoginAPIStrategy(ABC):
result = find_unique_key(child)
if result:
return result
return ''
return ""
return find_unique_key(data)
def build_skip_mobile_bind_data(self, unique_key: str) -> dict:
"""构建跳过手机号绑定、继续完成网页登录的请求参数。"""
return {
'type': '3',
'uniqueKey': unique_key,
'biz_type': '1',
"type": "3",
"uniqueKey": unique_key,
"biz_type": "1",
}
+44 -42
View File
@@ -42,9 +42,11 @@ class IframeLoginAPI(LoginAPIStrategy):
def encrypt_username(self, username: str) -> str:
"""RC4 加密后 URL 编码"""
encrypted = encrypt_nickname_or_phone(username)
return urllib.parse.quote(encrypted, safe='')
return urllib.parse.quote(encrypted, safe="")
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录参数(获取极验参数)
注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。
@@ -52,17 +54,17 @@ class IframeLoginAPI(LoginAPIStrategy):
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名
encrypted_username = encrypt_nickname_or_phone(username)
return {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypt_password(password),
'biz_type': '1',
'room_id': '0',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
"type": "1",
"nicknameOrPhoneEncrypt": encrypted_username,
"password": encrypt_password(password),
"biz_type": "1",
"room_id": "0",
"redirect_url": referer,
"t": str(int(time.time() * 1000)),
"client_id": "1",
"did": "",
"lang": "",
"isMultiAccount": "0",
}
def build_second_login_data(
@@ -81,46 +83,46 @@ class IframeLoginAPI(LoginAPIStrategy):
登录使用 wgapi 接口,参数格式与 wgapi 策略一致。
"""
# seccode 格式: validate|jordanrequests 会自动 URL 编码)
seccode_value = validate + '|jordan'
seccode_value = validate + "|jordan"
# 使用 base64 编码(wgapi 接口要求),而不是 URL 编码
encrypted_username = encrypt_nickname_or_phone(username)
return {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypt_password(password),
'room_id': '0',
'code_type': '1',
'code_token': code_token,
'gt_version': 'v3',
'geetest_challenge': challenge,
'geetest_validate': validate,
'geetest_seccode': seccode_value,
'code_data[geetest_challenge]': challenge,
'code_data[geetest_validate]': validate,
'code_data[geetest_seccode]': seccode_value,
'code_data[gt_version]': 'v3',
'code_data[code]': '',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
'biz_type': '1',
"type": "1",
"nicknameOrPhoneEncrypt": encrypted_username,
"password": encrypt_password(password),
"room_id": "0",
"code_type": "1",
"code_token": code_token,
"gt_version": "v3",
"geetest_challenge": challenge,
"geetest_validate": validate,
"geetest_seccode": seccode_value,
"code_data[geetest_challenge]": challenge,
"code_data[geetest_validate]": validate,
"code_data[geetest_seccode]": seccode_value,
"code_data[gt_version]": "v3",
"code_data[code]": "",
"redirect_url": referer,
"t": str(int(time.time() * 1000)),
"client_id": "1",
"did": "",
"lang": "",
"isMultiAccount": "0",
"biz_type": "1",
}
def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数"""
return {'cacheId': code}
return {"cacheId": code}
def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数"""
return {
'veify_type': '2', # e 语言原文如此,斗鱼接口拼写
'phoneCaptcha': captcha,
'client_id': '1',
'isMultiAccount': '0',
'cacheId': code,
"veify_type": "2", # e 语言原文如此,斗鱼接口拼写
"phoneCaptcha": captcha,
"client_id": "1",
"isMultiAccount": "0",
"cacheId": code,
}
+44 -42
View File
@@ -36,20 +36,22 @@ class WgapiLoginAPI(LoginAPIStrategy):
"""RC4 加密后 base64 编码"""
return encrypt_nickname_or_phone(username)
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
def build_first_login_data(
self, username: str, password: str, referer: str
) -> dict:
"""构建第一次登录参数"""
return {
'type': '1',
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
'password': encrypt_password(password),
'biz_type': '1',
'room_id': '0',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
"type": "1",
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
"password": encrypt_password(password),
"biz_type": "1",
"room_id": "0",
"redirect_url": referer,
"t": str(int(time.time() * 1000)),
"client_id": "1",
"did": "",
"lang": "",
"isMultiAccount": "0",
}
def build_second_login_data(
@@ -65,44 +67,44 @@ class WgapiLoginAPI(LoginAPIStrategy):
) -> dict:
"""构建第二次登录参数(带极验验证)"""
return {
'type': '1',
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
'password': encrypt_password(password),
'room_id': '0',
'code_type': '1',
'code_token': code_token,
'gt_version': 'v3',
'geetest_challenge': challenge,
'geetest_validate': validate,
'geetest_seccode': seccode,
'code_data[geetest_challenge]': challenge,
'code_data[geetest_validate]': validate,
'code_data[geetest_seccode]': seccode,
'code_data[gt_version]': 'v3',
'code_data[code]': '',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
'biz_type': '1',
"type": "1",
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
"password": encrypt_password(password),
"room_id": "0",
"code_type": "1",
"code_token": code_token,
"gt_version": "v3",
"geetest_challenge": challenge,
"geetest_validate": validate,
"geetest_seccode": seccode,
"code_data[geetest_challenge]": challenge,
"code_data[geetest_validate]": validate,
"code_data[geetest_seccode]": seccode,
"code_data[gt_version]": "v3",
"code_data[code]": "",
"redirect_url": referer,
"t": str(int(time.time() * 1000)),
"client_id": "1",
"did": "",
"lang": "",
"isMultiAccount": "0",
"biz_type": "1",
}
def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数"""
return {
'code': code,
'client_id': '1',
"code": code,
"client_id": "1",
}
def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数"""
return {
'verify_type': '2',
'captcha': captcha,
'isMultiAccount': '0',
'code': code,
'client_id': '1',
'redirect_url': '//www.douyu.com/api/passport/login',
"verify_type": "2",
"captcha": captcha,
"isMultiAccount": "0",
"code": code,
"client_id": "1",
"redirect_url": "//www.douyu.com/api/passport/login",
}
+4 -4
View File
@@ -24,7 +24,7 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
code = data.get("code")
msg = data.get("msg", "") or ""
if code != 0 and ("白名单" in msg or "添加白名单" in msg):
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg)
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", msg)
if ip_match:
return [], ip_match.group(1)
@@ -46,12 +46,12 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
except (json.JSONDecodeError, ValueError):
pass
if '添加白名单' in text or '白名单' in text:
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
if "添加白名单" in text or "白名单" in text:
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", text)
if ip_match:
return [], ip_match.group(1)
matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
matches = re.findall(r"(\d+\.\d+\.\d+\.\d+):(\d+)", text)
proxies = [f"http://{ip}:{port}" for ip, port in matches]
if proxies:
return proxies, None
+11 -4
View File
@@ -67,7 +67,10 @@ class XiequAdapter(BaseWhitelistAdapter):
# 映射到统一格式
return [
{"ip": r.get("IP", r.get("ip", "")), "memo": r.get("MEMO", r.get("memo", ""))}
{
"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")
]
@@ -97,7 +100,7 @@ class XiequAdapter(BaseWhitelistAdapter):
# 频率限制,等待后重试一次
if retry and ("频率过快" in text or "稍后" in text):
wait = 5
match = re.search(r'(\d+)\s*秒', text)
match = re.search(r"(\d+)\s*秒", text)
if match:
wait = int(match.group(1))
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
@@ -147,9 +150,13 @@ class XiequAdapter(BaseWhitelistAdapter):
try:
records = self.get_whitelist()
count = len(records)
my_records = [r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)]
my_records = [
r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)
]
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
msg = (
f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
)
logger.info(msg)
return True, msg
+1
View File
@@ -51,6 +51,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
}
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]:
+41 -30
View File
@@ -17,11 +17,9 @@ LogFunc = Callable[[str, str], None]
class WhitelistSyncer(Protocol):
"""代理解析流程需要的白名单能力。"""
def sync_ip(self, ip: str) -> tuple[bool, str]:
...
def sync_ip(self, ip: str) -> tuple[bool, str]: ...
def get_local_exit_ip(self) -> Optional[str]:
...
def get_local_exit_ip(self) -> Optional[str]: ...
class ProxyResolver:
@@ -62,14 +60,16 @@ class ProxyResolver:
return
log_method = getattr(
logger,
level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info',
level
if level in ("debug", "info", "warning", "error", "success")
else "info",
logger.info,
)
log_method(message)
def _sync_ip(self, ip: str) -> tuple[bool, str]:
if not self.whitelist_syncer:
return False, '未配置白名单 UID/UKEY'
return False, "未配置白名单 UID/UKEY"
ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
if ok:
self._last_synced_ip = ip
@@ -82,14 +82,14 @@ class ProxyResolver:
local_ip = self.whitelist_syncer.get_local_exit_ip()
if local_ip and local_ip != self._last_synced_ip:
self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
self._log("info", f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
ok, sync_msg = self._sync_ip(local_ip)
if ok:
self._log('info', f"白名单同步成功: {sync_msg}")
self._log("info", f"白名单同步成功: {sync_msg}")
else:
self._log('warning', f"白名单同步失败: {sync_msg}")
self._log("warning", f"白名单同步失败: {sync_msg}")
elif local_ip == self._last_synced_ip:
self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}")
self._log("debug", f"[尝试 {attempt}] 出口IP未变: {local_ip}")
def fetch_verified(
self,
@@ -107,18 +107,18 @@ class ProxyResolver:
for attempt in range(1, max_attempts + 1):
if self._is_stopped():
return None, '任务已停止'
return None, "任务已停止"
if attempt > 1:
delay = min(attempt - 1, 2)
if self.log_func:
self._log('info', f'等待 {delay}s 后重试...')
self._log("info", f"等待 {delay}s 后重试...")
if self._wait_or_stopped(delay):
return None, '任务已停止'
return None, "任务已停止"
self._sync_local_exit_ip_if_needed(attempt)
if self._is_stopped():
return None, '任务已停止'
self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理')
return None, "任务已停止"
self._log("info", f"代理预检 {attempt}/{max_attempts}: 正在获取代理")
try:
response = requests.get(self.api_url, timeout=10)
@@ -128,38 +128,49 @@ class ProxyResolver:
proxy_urls, whitelist_ip = parse_proxy_response(text)
if proxy_urls:
self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证')
available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all)
self._log("info", f"获取到 {len(proxy_urls)} 个代理,并发验证")
available, msg = verify_proxies_concurrent(
proxy_urls, return_all=return_all
)
if available:
if not return_all:
self._log('success', f'代理预检成功: {available}')
self._log("success", f"代理预检成功: {available}")
return available, msg
last_error = msg
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
self._log(
"warning", f"代理预检 {attempt}/{max_attempts}: {last_error}"
)
continue
if whitelist_ip and self.whitelist_syncer:
if self.sync_whitelist_once and self._has_synced_whitelist:
last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}'
self._log('warning', last_error)
last_error = (
f"白名单已同步但代理API仍返回白名单错误: {whitelist_ip}"
)
self._log("warning", last_error)
continue
self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')
self._log(
"warning", f"代理需要白名单IP: {whitelist_ip},自动同步..."
)
ok, sync_msg = self._sync_ip(whitelist_ip)
self._log('success' if ok else 'error', f'白名单同步: {sync_msg}')
self._log("success" if ok else "error", f"白名单同步: {sync_msg}")
if ok:
self._log('info', '白名单已更新,立即重试...')
self._log("info", "白名单已更新,立即重试...")
continue
return None, f'白名单同步失败: {sync_msg}'
return None, f"白名单同步失败: {sync_msg}"
last_error = '代理API响应无法解析'
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}")
last_error = "代理API响应无法解析"
self._log(
"warning",
f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}",
)
except Exception as exc:
last_error = f'代理API请求失败: {exc}'
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
last_error = f"代理API请求失败: {exc}"
self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}")
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}'
return None, f"代理预检失败({max_attempts}次尝试均失败): {last_error}"
def resolve_working_proxy(
+18 -14
View File
@@ -16,24 +16,24 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str
Returns:
(是否可用, 消息)
"""
proxies = {'http': proxy_url, 'https': proxy_url}
proxies = {"http": proxy_url, "https": proxy_url}
# ── 斗鱼主站可达性验证 ──
try:
response = requests.get(
'https://www.douyu.com',
"https://www.douyu.com",
proxies=proxies,
timeout=timeout,
headers={'User-Agent': 'Mozilla/5.0'},
headers={"User-Agent": "Mozilla/5.0"},
)
response.raise_for_status()
return True, '代理可用 → 斗鱼可达'
return True, "代理可用 → 斗鱼可达"
except Exception as exc:
err_msg = str(exc)
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
detail = '代理拒绝连接(白名单可能未生效)'
elif 'timed out' in err_msg.lower():
detail = '连接超时'
if "Tunnel connection failed" in err_msg or "503" in err_msg:
detail = "代理拒绝连接(白名单可能未生效)"
elif "timed out" in err_msg.lower():
detail = "连接超时"
else:
detail = type(exc).__name__
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
@@ -54,7 +54,7 @@ def verify_proxies_concurrent(
return_all=True: (可用代理 URL 列表或 None, 消息)
"""
if not proxy_urls:
return None, '无代理可验证'
return None, "无代理可验证"
if len(proxy_urls) == 1:
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
@@ -64,7 +64,9 @@ def verify_proxies_concurrent(
if return_all:
available: list[str] = []
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
with ThreadPoolExecutor(
max_workers=min(max_workers, len(proxy_urls))
) as executor:
future_map = {
executor.submit(verify_proxy_url, proxy, timeout): proxy
for proxy in proxy_urls
@@ -77,9 +79,11 @@ def verify_proxies_concurrent(
except Exception:
continue
if available:
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理")
return available, f'找到 {len(available)} 个可用代理'
return None, f'{len(proxy_urls)} 个代理均不可用'
logger.success(
f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理"
)
return available, f"找到 {len(available)} 个可用代理"
return None, f"{len(proxy_urls)} 个代理均不可用"
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
future_map = {
@@ -99,4 +103,4 @@ def verify_proxies_concurrent(
except Exception:
continue
return None, f'{len(proxy_urls)} 个代理均不可用'
return None, f"{len(proxy_urls)} 个代理均不可用"
+53 -28
View File
@@ -44,7 +44,8 @@ class FishFinRechargeConfig:
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
timeout=(8, timeout),
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower()
in {"1", "true", "yes"},
)
def validate(self) -> None:
@@ -86,7 +87,9 @@ class FishFinRechargeClient:
def _sign_value(value: Any) -> str:
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
return json.dumps(
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
)
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
@@ -143,7 +146,9 @@ class FishFinRechargeClient:
received = str(payload.get("sign") or "").strip().lower()
return bool(received) and received == self.sign(payload, method)
def _request(self, method: str, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
def _request(
self, method: str, path: str, params: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""补齐公共参数、签名并执行一次 JSON 请求。"""
request_params: dict[str, Any] = {
"app_id": self.config.app_id,
@@ -167,27 +172,37 @@ class FishFinRechargeClient:
if key != "sign"
},
# 只记录摘要,便于关联排查而不暴露可重放的签名。
"sign_digest": hashlib.sha256(request_params["sign"].encode("utf-8")).hexdigest()[:12],
"sign_digest": hashlib.sha256(
request_params["sign"].encode("utf-8")
).hexdigest()[:12],
}
if self.config.debug:
sign_params = self.normalized_params(request_params)
sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params))
trace_event.update({
"url": url,
"content_type": "application/json",
"json_body": self._debug_payload(request_params),
"sign_params": sign_params,
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
"sign_source_digest": hashlib.sha256(
f"{sign_query}{method.upper()}".encode("utf-8")
).hexdigest()[:12],
})
sign_query = "&".join(
f"{key}={sign_params[key]}" for key in sorted(sign_params)
)
trace_event.update(
{
"url": url,
"content_type": "application/json",
"json_body": self._debug_payload(request_params),
"sign_params": sign_params,
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
"sign_source_digest": hashlib.sha256(
f"{sign_query}{method.upper()}".encode("utf-8")
).hexdigest()[:12],
}
)
self.trace(trace_event)
try:
if method.upper() == "GET":
response = self.session.get(url, params=request_params, timeout=self.config.timeout)
response = self.session.get(
url, params=request_params, timeout=self.config.timeout
)
else:
response = self.session.post(url, json=request_params, timeout=self.config.timeout)
response = self.session.post(
url, json=request_params, timeout=self.config.timeout
)
response.raise_for_status()
payload = response.json()
except requests.RequestException as exc:
@@ -205,18 +220,22 @@ class FishFinRechargeClient:
"message": payload.get("msg") or payload.get("message") or "",
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
"order_id": self._response_value(payload, "order_id", "orderId"),
"order_status": self._response_value(payload, "order_status", "orderStatus"),
"order_status": self._response_value(
payload, "order_status", "orderStatus"
),
"fail_reason": self._response_value(payload, "fail_reason", "failReason"),
}
if self.config.debug:
trace_event.update({
"response_headers": {
key: value
for key, value in response.headers.items()
if key.lower() in {"content-type", "x-request-id", "request-id"}
},
"response_body": self._debug_payload(payload),
})
trace_event.update(
{
"response_headers": {
key: value
for key, value in response.headers.items()
if key.lower() in {"content-type", "x-request-id", "request-id"}
},
"response_body": self._debug_payload(payload),
}
)
self.trace(trace_event)
return payload
@@ -263,7 +282,11 @@ class FishFinRechargeClient:
raise ValueError("product_id 不能为空")
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
raise ValueError("buy_num 必须是不小于 1 的整数")
if not isinstance(order_type, int) or isinstance(order_type, bool) or order_type not in {0, 1, 2, 3}:
if (
not isinstance(order_type, int)
or isinstance(order_type, bool)
or order_type not in {0, 1, 2, 3}
):
raise ValueError("order_type 必须是 0 至 3 的整数")
if not isinstance(recharge_arg, list) or not recharge_arg:
raise ValueError("recharge_arg 必须是非空数组")
@@ -287,7 +310,9 @@ class FishFinRechargeClient:
out_order_id = str(out_order_id).strip()
if not out_order_id:
raise ValueError("out_order_id 不能为空")
return self._request("GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id})
return self._request(
"GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}
)
def account_info(self) -> dict[str, Any]:
"""查询商户账户信息。"""