增加斗鱼账号检测功能

This commit is contained in:
yml2213
2026-07-09 23:12:39 +08:00
parent 1f16193690
commit cd2d02c86e
16 changed files with 1206 additions and 53 deletions
+179 -36
View File
@@ -30,7 +30,16 @@ _geetest_semaphore = threading.Semaphore(2)
class CredentialError(ValueError):
"""账号或密码错误,不应重试。"""
pass
def __init__(
self,
message: str,
code: str = "credential_error",
status_message: str = "",
):
super().__init__(message)
self.code = code
self.status_message = status_message or message
class AccountLike(Protocol):
@@ -62,6 +71,8 @@ class DouyuLogin:
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"
CP_REFERER = "https://www.douyu.com/member/cp"
LOGIN_REFERER = (
"https://passport.douyu.com/index/login?"
"passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&"
@@ -242,6 +253,64 @@ class DouyuLogin:
preview = body[:200].replace("\n", "\\n")
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
@staticmethod
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
"""识别登录接口返回的账号类终态。"""
error_code = payload.get('error')
error_msg = str(payload.get('msg') or '')
if error_code == 110022 or '账号不存在' in error_msg:
return "account_cancelled", "账号已注销"
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:
"""把账号类错误转换为不重试的 CredentialError。"""
classified = self._classify_credential_payload(payload)
if not classified:
return None
code, status_message = classified
return CredentialError(
f"{stage}失败: {status_message}",
code=code,
status_message=status_message,
)
def _run_login_steps(self, deadline: float = 0) -> str:
"""执行一次完整登录链路,成功后返回 Cookie。"""
# 1️⃣ 第一次登录(获取极验参数)
logger.info("步骤1: 第一次登录,获取极验参数...")
gt, challenge, code_token, _ = self._first_login()
# 2️⃣ 极验 fullpage 验证
logger.info("步骤2: 极验 fullpage 验证...")
validate, seccode = self._solve_geetest(gt, challenge, deadline=deadline)
# 3️⃣ 第二次登录(带极验)
logger.info("步骤3: 第二次登录(带极验验证)...")
remote_code = self._second_login(gt, challenge, validate, seccode, code_token)
# 4️⃣ 发送邮箱验证
logger.info("步骤4: 发送邮箱验证...")
email_sent_at = time.time()
self._send_email_verify(remote_code)
# 5️⃣ IMAP获取验证码
logger.info("步骤5: 获取邮箱验证码...")
verify_code = self._get_email_code(after_timestamp=email_sent_at)
# 6️⃣ 提交验证码
logger.info("步骤6: 提交验证码...")
login_url = self._submit_verify_code(remote_code, verify_code)
# 7️⃣ 完成登录获取Cookie
logger.info("步骤7: 完成登录,获取Cookie...")
return self._complete_login(login_url)
def login(self) -> LoginResult:
"""
完整登录流程(带整体重试)。
@@ -280,34 +349,7 @@ class DouyuLogin:
)
try:
# 1️⃣ 第一次登录(获取极验参数)
logger.info("步骤1: 第一次登录,获取极验参数...")
gt, challenge, code_token, _ = self._first_login()
# 2️⃣ 极验 fullpage 验证
logger.info("步骤2: 极验 fullpage 验证...")
validate, seccode = self._solve_geetest(gt, challenge, deadline=deadline)
# 3️⃣ 第二次登录(带极验)
logger.info("步骤3: 第二次登录(带极验验证)...")
remote_code = self._second_login(gt, challenge, validate, seccode, code_token)
# 4️⃣ 发送邮箱验证
logger.info("步骤4: 发送邮箱验证...")
email_sent_at = time.time()
self._send_email_verify(remote_code)
# 5️⃣ IMAP获取验证码
logger.info("步骤5: 获取邮箱验证码...")
verify_code = self._get_email_code(after_timestamp=email_sent_at)
# 6️⃣ 提交验证码
logger.info("步骤6: 提交验证码...")
login_url = self._submit_verify_code(remote_code, verify_code)
# 7️⃣ 完成登录获取Cookie
logger.info("步骤7: 完成登录,获取Cookie...")
cookie = self._complete_login(login_url)
cookie = self._run_login_steps(deadline=deadline)
message = "登录成功"
if self._cookie_enrich_error:
message = f"登录成功,补CK失败: {self._cookie_enrich_error}"
@@ -320,7 +362,7 @@ class DouyuLogin:
return LoginResult(success=False, message=str(e))
except CredentialError as e:
logger.error(f"登录失败(凭据错误,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="credential_error")
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")
@@ -339,6 +381,107 @@ class DouyuLogin:
# 所有重试耗尽或超时
return LoginResult(success=False, message=str(e))
def check_account(self) -> LoginResult:
"""
检测账号状态。
可直接识别注销/密码错误;登录成功后请求个人中心接口判断实名状态。
"""
logger.info(f"开始检测账号: {self.account.username}")
start_time = time.monotonic()
deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0
attempt = 0
while True:
attempt += 1
if self._is_stopped():
logger.warning("账号检测任务已停止")
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")
if attempt > 1:
if self.max_login_retries > 0:
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
else:
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始")
if not self._prepare_retry():
return LoginResult(
success=False,
message=f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理",
)
try:
self._run_login_steps(deadline=deadline)
return self._check_certification_status()
except InterruptedError as e:
logger.warning(f"账号检测任务已停止: {e}")
return LoginResult(success=False, message=str(e))
except CredentialError as e:
logger.info(f"账号检测完成: {e.status_message}")
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")
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}")
else:
logger.error(f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s: {e}")
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)
continue
return LoginResult(success=False, message=str(e))
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,
}
payload = self._request_json(
'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', ''))
if ident_status == '0' and ident_type == '0':
logger.success("账号检测完成: 账号未认证")
return LoginResult(success=True, message="账号未认证", code="account_unverified")
if ident_status == '2' and ident_type == '2':
logger.success("账号检测完成: 账号已认证")
return LoginResult(success=True, message="账号已认证", code="account_verified")
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
logger.warning(message)
return LoginResult(success=True, message=message, code="account_auth_unknown")
def _prepare_retry(self) -> bool:
"""重试前准备:取新代理、重置 session cookies。
@@ -389,9 +532,9 @@ class DouyuLogin:
if payload.get('error') != 81:
error_msg = payload.get('msg', '未知错误')
# 密码错误等凭据问题不应重试
if any(kw in error_msg for kw in ['密码错误', '账号或密码', '账号或者密码', '账号不存在']):
raise CredentialError(f"第一次登录失败: {error_msg}")
credential_error = self._credential_error_from_payload("第一次登录", payload)
if credential_error:
raise credential_error
raise ValueError(f"第一次登录失败: {error_msg}")
# 提取极验参数
@@ -534,9 +677,9 @@ class DouyuLogin:
if payload.get('error') != 130014:
error_msg = payload.get('msg', '未知错误')
# 密码错误等凭据问题不应重试
if any(kw in error_msg for kw in ['密码错误', '账号或密码', '账号或者密码', '账号不存在']):
raise CredentialError(f"第二次登录失败: {error_msg}")
credential_error = self._credential_error_from_payload("第二次登录", payload)
if credential_error:
raise credential_error
raise ValueError(f"第二次登录失败: {error_msg}")
# 提取remote_code