style: 统一 Ruff 代码格式
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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|jordan(requests 会自动 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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
"""查询商户账户信息。"""
|
||||
|
||||
Reference in New Issue
Block a user