type: 收敛测试 schemas 与协议层类型

This commit is contained in:
yml2213
2026-08-30 20:35:08 +08:00
parent 92dc461e52
commit c891ac982e
26 changed files with 1846 additions and 882 deletions
+194 -116
View File
@@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI
from .proxy_fetcher import ProxyFetcher
from core.geetest.v3_slide.solver import (
_generate_seed, get_w1, get_w2,
_generate_seed,
get_w1,
get_w2,
)
from core.geetest.common.network import (
get_js_address,
@@ -44,6 +46,7 @@ class CredentialError(ValueError):
class AccountLike(Protocol):
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
username: str
password: str
email: str
@@ -56,7 +59,9 @@ class AccountLike(Protocol):
class LoginResult:
"""登录结果"""
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""):
def __init__(
self, success: bool, cookie: str = "", message: str = "", code: str = ""
):
self.success = success
self.cookie = cookie
self.message = message
@@ -68,7 +73,9 @@ class DouyuLogin:
# 斗鱼API地址
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
SEND_EMAIL_API = (
"https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
)
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
@@ -141,50 +148,52 @@ class DouyuLogin:
"""配置Session"""
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
self.session.trust_env = False
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': self.LOGIN_REFERER,
'Origin': 'https://passport.douyu.com',
'X-Requested-With': 'XMLHttpRequest',
})
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": self.LOGIN_REFERER,
"Origin": "https://passport.douyu.com",
"X-Requested-With": "XMLHttpRequest",
}
)
# 设置代理
self._apply_proxy()
def _apply_proxy(self, proxy: str = None) -> None:
def _apply_proxy(self, proxy: str | None = None) -> None:
"""应用代理到Session"""
if proxy:
# 使用指定的代理
self.session.proxies = {
'http': proxy,
'https': proxy,
"http": proxy,
"https": proxy,
}
self._current_proxy_url = proxy
elif self.proxy:
# 使用配置的静态代理
if isinstance(self.proxy, str):
self.session.proxies = {
'http': self.proxy,
'https': self.proxy,
"http": self.proxy,
"https": self.proxy,
}
self._current_proxy_url = self.proxy
else:
self.session.proxies = {
scheme: url
for scheme, url in self.proxy.items()
if url
scheme: url for scheme, url in self.proxy.items() if url
}
self._current_proxy_url = self.proxy.get('http') or self.proxy.get('https')
self._current_proxy_url = self.proxy.get("http") or self.proxy.get(
"https"
)
elif self.proxy_fetcher:
# 从代理获取器取新代理
new_proxy = self.proxy_fetcher.fetch_new_proxy()
if new_proxy:
self.session.proxies = {
'http': new_proxy,
'https': new_proxy,
"http": new_proxy,
"https": new_proxy,
}
self._current_proxy_url = new_proxy
else:
@@ -207,7 +216,7 @@ class DouyuLogin:
统一发送请求,附带分段超时和更明确的错误信息。
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
"""
timeout = kwargs.pop('timeout', self.timeout)
timeout = kwargs.pop("timeout", self.timeout)
safe_url = self._safe_url(url)
self._ensure_not_stopped()
@@ -256,19 +265,21 @@ class DouyuLogin:
@staticmethod
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
"""识别登录接口返回的账号类终态。"""
error_code = payload.get('error')
error_msg = str(payload.get('msg') or '')
error_code = payload.get("error")
error_msg = str(payload.get("msg") or "")
if error_code == 110022 or '账号不存在' in error_msg:
if error_code == 110022 or "账号不存在" in error_msg:
return "account_cancelled", "账号已注销"
password_keywords = ['密码错误', '账号或密码', '账号或者密码']
password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
return "password_wrong", "账号密码错误"
return None
def _credential_error_from_payload(self, stage: str, payload: dict) -> CredentialError | None:
def _credential_error_from_payload(
self, stage: str, payload: dict
) -> CredentialError | None:
"""把账号类错误转换为不重试的 CredentialError。"""
classified = self._classify_credential_payload(payload)
if not classified:
@@ -293,10 +304,14 @@ class DouyuLogin:
# 3️⃣ 第二次登录(带极验)
logger.info("步骤3: 第二次登录(带极验验证)...")
next_step, value = self._second_login(
gt, challenge, validate, seccode, code_token,
gt,
challenge,
validate,
seccode,
code_token,
)
if next_step == 'mobile_bind_skip':
if next_step == "mobile_bind_skip":
logger.info("步骤4: 跳过手机号绑定,完成登录...")
return self._complete_login(value)
@@ -339,12 +354,19 @@ class DouyuLogin:
return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time
if self.max_total_time > 0 and elapsed > self.max_total_time:
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s")
logger.warning(
f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
)
return LoginResult(
success=False,
message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s",
)
if attempt > 1:
if self.max_login_retries > 0:
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
logger.info(
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
)
else:
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
# 重试前:取新代理 + 重置 session
@@ -371,15 +393,23 @@ class DouyuLogin:
return LoginResult(success=False, message=str(e), code=e.code)
except EmailLoginError as e:
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="email_login_failed")
return LoginResult(
success=False, message=str(e), code="email_login_failed"
)
except Exception as e:
elapsed = time.monotonic() - start_time
if self.max_login_retries > 0:
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}")
logger.error(
f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}"
)
else:
logger.error(f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}")
logger.error(
f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}"
)
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
has_retry = (
self.max_login_retries <= 0 or attempt < self.max_login_retries
)
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
if has_retry and has_time:
self._sleep_interruptible(1)
@@ -405,14 +435,23 @@ class DouyuLogin:
return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time
if self.max_total_time > 0 and elapsed > self.max_total_time:
logger.warning(f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s")
logger.warning(
f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
)
return LoginResult(
success=False,
message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s",
)
if attempt > 1:
if self.max_login_retries > 0:
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
logger.info(
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
)
else:
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始")
logger.info(
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
)
if not self._prepare_retry():
return LoginResult(
success=False,
@@ -431,15 +470,23 @@ class DouyuLogin:
return LoginResult(success=True, message=e.status_message, code=e.code)
except EmailLoginError as e:
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="email_login_failed")
return LoginResult(
success=False, message=str(e), code="email_login_failed"
)
except Exception as e:
elapsed = time.monotonic() - start_time
if self.max_login_retries > 0:
logger.error(f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}")
logger.error(
f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}"
)
else:
logger.error(f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}")
logger.error(
f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}"
)
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
has_retry = (
self.max_login_retries <= 0 or attempt < self.max_login_retries
)
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
if has_retry and has_time:
self._sleep_interruptible(1)
@@ -449,40 +496,44 @@ class DouyuLogin:
def _check_certification_status(self) -> LoginResult:
"""请求个人中心接口并判断实名状态。"""
headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Priority': 'u=1, i',
'Referer': self.CP_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',
'X-Requested-With': 'XMLHttpRequest',
'Origin': None,
'Content-Type': None,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Priority": "u=1, i",
"Referer": self.CP_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",
"X-Requested-With": "XMLHttpRequest",
"Origin": None,
"Content-Type": None,
}
payload = self._request_json(
'get',
"get",
self.CP_RPC_API,
'账号认证状态接口',
"账号认证状态接口",
headers=headers,
timeout=(5, 10),
)
info = payload.get('info') or {}
ident_status = str(info.get('ident_status', ''))
ident_type = str(info.get('ident_type', ''))
info = payload.get("info") or {}
ident_status = str(info.get("ident_status", ""))
ident_type = str(info.get("ident_type", ""))
if ident_status == '0' and ident_type == '0':
if ident_status == "0" and ident_type == "0":
logger.success("账号检测完成: 账号未认证")
return LoginResult(success=True, message="账号未认证", code="account_unverified")
return LoginResult(
success=True, message="账号未认证", code="account_unverified"
)
if ident_status == '2' and ident_type == '2':
if ident_status == "2" and ident_type == "2":
logger.success("账号检测完成: 账号已认证")
return LoginResult(success=True, message="账号已认证", code="account_verified")
return LoginResult(
success=True, message="账号已认证", code="account_verified"
)
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
logger.warning(message)
@@ -528,17 +579,19 @@ class DouyuLogin:
)
payload = self._request_json(
'post',
"post",
self.api.first_login_url,
'第一次登录接口',
"第一次登录接口",
data=data,
)
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
if payload.get('error') != 81:
error_msg = payload.get('msg', '未知错误')
credential_error = self._credential_error_from_payload("第一次登录", payload)
if payload.get("error") != 81:
error_msg = payload.get("msg", "未知错误")
credential_error = self._credential_error_from_payload(
"第一次登录", payload
)
if credential_error:
raise credential_error
raise ValueError(f"第一次登录失败: {error_msg}")
@@ -553,7 +606,9 @@ class DouyuLogin:
return gt, challenge, code_token, self.session.cookies.get_dict()
def _solve_geetest(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
def _solve_geetest(
self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]:
"""
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
@@ -578,7 +633,9 @@ class DouyuLogin:
finally:
_geetest_semaphore.release()
def _solve_geetest_inner(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
def _solve_geetest_inner(
self, gt: str, challenge: str, deadline: float = 0
) -> Tuple[str, str]:
"""极验验证内部实现:最多3次尝试,失败直接抛异常。"""
_MAX_ATTEMPTS = 3
@@ -620,14 +677,18 @@ class DouyuLogin:
# 从 fullpage ajax.php 响应中提取 validate
if isinstance(result, dict):
data = result.get('data', {})
validate = data.get('validate', '') or result.get('validate', '')
success = data.get('result') == 'success' or result.get('success') == 1
message = data.get('result') or result.get('message', '')
data = result.get("data", {})
validate = data.get("validate", "") or result.get("validate", "")
success = (
data.get("result") == "success" or result.get("success") == 1
)
message = data.get("result") or result.get("message", "")
if success and validate:
seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
logger.success(
f"极验 fullpage 验证成功! validate={validate[:20]}..."
)
return validate, seccode
else:
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
@@ -636,7 +697,9 @@ class DouyuLogin:
validate = str(result)
if validate:
seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
logger.success(
f"极验 fullpage 验证成功! validate={validate[:20]}..."
)
return validate, seccode
except ValueError:
@@ -645,11 +708,16 @@ class DouyuLogin:
except Exception as e:
err_str = str(e)
# 所有网络/代理异常都直接上抛,由 login 换新代理
logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理")
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
logger.warning(
f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理"
)
raise ValueError(
f"极验验证异常: {self._truncate_error(err_str)}"
) from e
def _second_login(self, gt: str, challenge: str, validate: str,
seccode: str, code_token: str) -> tuple[str, str]:
def _second_login(
self, gt: str, challenge: str, validate: str, seccode: str, code_token: str
) -> tuple[str, str]:
"""
第二次登录(带极验验证)
@@ -668,16 +736,22 @@ class DouyuLogin:
data = self.api.build_second_login_data(
self.account.username,
self.account.password,
gt, challenge, validate, seccode, code_token,
gt,
challenge,
validate,
seccode,
code_token,
self.LOGIN_REFERER,
)
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...")
logger.debug(
f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..."
)
payload = self._request_json(
'post',
"post",
self.api.login_url,
'第二次登录接口',
"第二次登录接口",
data=data,
)
@@ -689,11 +763,13 @@ class DouyuLogin:
if unique_key:
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
login_url = self._skip_mobile_bind(unique_key)
return 'mobile_bind_skip', login_url
return "mobile_bind_skip", login_url
if payload.get('error') != 130014:
error_msg = payload.get('msg', '未知错误')
credential_error = self._credential_error_from_payload("第二次登录", payload)
if payload.get("error") != 130014:
error_msg = payload.get("msg", "未知错误")
credential_error = self._credential_error_from_payload(
"第二次登录", payload
)
if credential_error:
raise credential_error
raise ValueError(f"第二次登录失败: {error_msg}")
@@ -703,26 +779,26 @@ class DouyuLogin:
if not remote_code:
# 提取可能的风控提示
data = payload.get('data', {})
security_quiz = data.get('securityQuiz', '')
msg = payload.get('msg', '')
detail = security_quiz or msg or '未知原因'
data = payload.get("data", {})
security_quiz = data.get("securityQuiz", "")
msg = payload.get("msg", "")
detail = security_quiz or msg or "未知原因"
raise ValueError(f"获取remote_code失败: {detail}")
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
return 'remote_email', remote_code
return "remote_email", remote_code
def _skip_mobile_bind(self, unique_key: str) -> str:
"""跳过手机号绑定并获取后续登录回调地址。"""
payload = self._request_json(
'post',
"post",
self.api.login_url,
'跳过手机号绑定接口',
"跳过手机号绑定接口",
data=self.api.build_skip_mobile_bind_data(unique_key),
)
if payload.get('error') != 0:
if payload.get("error") != 0:
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
login_url = self.api.extract_login_url(payload)
@@ -738,13 +814,13 @@ class DouyuLogin:
data = self.api.build_send_email_data(remote_code)
payload = self._request_json(
'post',
"post",
self.api.send_email_url,
'发送验证邮件接口',
"发送验证邮件接口",
data=data,
)
if payload.get('error') != 0:
if payload.get("error") != 0:
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
logger.info("验证邮件已发送")
@@ -775,13 +851,13 @@ class DouyuLogin:
data = self.api.build_verify_data(remote_code, verify_code)
payload = self._request_json(
'post',
"post",
self.api.verify_url,
'提交验证码接口',
"提交验证码接口",
data=data,
)
if payload.get('error') != 0:
if payload.get("error") != 0:
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
login_url = self.api.extract_login_url(payload)
@@ -798,8 +874,8 @@ class DouyuLogin:
@staticmethod
def _normalize_login_url(login_url: str) -> str:
"""补全斗鱼接口返回的协议相对登录回调地址。"""
if login_url.startswith('//'):
return 'https:' + login_url
if login_url.startswith("//"):
return "https:" + login_url
return login_url
def _complete_login(self, login_url: str) -> str:
@@ -810,16 +886,16 @@ class DouyuLogin:
cookie: 完整的Cookie字符串
"""
# 访问登录URL
response = self._request('get', login_url)
response = self._request("get", login_url)
response.raise_for_status()
# 尝试访问webLogin获取用户信息
try:
code_match = re.search(r'code=([^&]+)', login_url)
code_match = re.search(r"code=([^&]+)", login_url)
if code_match:
code = code_match.group(1)
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
response2 = self._request('get', weblogin_url)
response2 = self._request("get", weblogin_url)
if response2.status_code == 200:
logger.info("WebLogin成功")
@@ -839,7 +915,9 @@ class DouyuLogin:
raise
except Exception as e:
self._cookie_enrich_error = self._truncate_error(str(e), 160)
logger.warning(f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}")
logger.warning(
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
)
return self._format_cookie_string()
@@ -848,6 +926,6 @@ class DouyuLogin:
cookies = self.session.cookies.get_dict()
# 格式化Cookie字符串
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
return cookie_str