diff --git a/core/douyu/login.py b/core/douyu/login.py index 130731b..fe3493a 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -292,12 +292,18 @@ class DouyuLogin: # 3️⃣ 第二次登录(带极验) logger.info("步骤3: 第二次登录(带极验验证)...") - remote_code = self._second_login(gt, challenge, validate, seccode, code_token) + next_step, value = self._second_login( + gt, challenge, validate, seccode, code_token, + ) + + if next_step == 'mobile_bind_skip': + logger.info("步骤4: 跳过手机号绑定,完成登录...") + return self._complete_login(value) # 4️⃣ 发送邮箱验证 logger.info("步骤4: 发送邮箱验证...") email_sent_at = time.time() - self._send_email_verify(remote_code) + self._send_email_verify(value) # 5️⃣ IMAP获取验证码 logger.info("步骤5: 获取邮箱验证码...") @@ -305,7 +311,7 @@ class DouyuLogin: # 6️⃣ 提交验证码 logger.info("步骤6: 提交验证码...") - login_url = self._submit_verify_code(remote_code, verify_code) + login_url = self._submit_verify_code(value, verify_code) # 7️⃣ 完成登录获取Cookie logger.info("步骤7: 完成登录,获取Cookie...") @@ -643,7 +649,7 @@ class DouyuLogin: 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) -> str: + seccode: str, code_token: str) -> tuple[str, str]: """ 第二次登录(带极验验证) @@ -655,7 +661,9 @@ class DouyuLogin: code_token: 第一次登录返回的code_token Returns: - remote_code: 用于邮箱验证的code + (next_step, value): + - ('remote_email', remote_code): 继续邮箱验证流程 + - ('mobile_bind_skip', login_url): 已跳过手机号绑定,可完成登录 """ data = self.api.build_second_login_data( self.account.username, @@ -675,6 +683,14 @@ class DouyuLogin: logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}") + # 新版登录在账号未绑定手机号时会返回一次性 uniqueKey。浏览器点击 + # “跳过”后会向同一接口提交 type=3 + uniqueKey,服务端才会发放回调 URL。 + unique_key = self.api.extract_mobile_bind_unique_key(payload) + if unique_key: + logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定") + login_url = self._skip_mobile_bind(unique_key) + return 'mobile_bind_skip', login_url + if payload.get('error') != 130014: error_msg = payload.get('msg', '未知错误') credential_error = self._credential_error_from_payload("第二次登录", payload) @@ -695,7 +711,27 @@ class DouyuLogin: logger.info(f"获取remote_code成功: {remote_code[:20]}...") - return remote_code + return 'remote_email', remote_code + + def _skip_mobile_bind(self, unique_key: str) -> str: + """跳过手机号绑定并获取后续登录回调地址。""" + payload = self._request_json( + 'post', + self.api.login_url, + '跳过手机号绑定接口', + data=self.api.build_skip_mobile_bind_data(unique_key), + ) + + if payload.get('error') != 0: + raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}") + + login_url = self.api.extract_login_url(payload) + if not login_url: + raise ValueError("跳过手机号绑定后未获取登录URL") + login_url = self._normalize_login_url(login_url) + + logger.info("手机号绑定已跳过,获取登录URL成功") + return login_url def _send_email_verify(self, remote_code: str) -> None: """发送邮箱验证""" @@ -753,14 +789,19 @@ class DouyuLogin: if not login_url: raise ValueError("获取登录URL失败") - # 补全URL - if login_url.startswith('//'): - login_url = 'https:' + login_url + login_url = self._normalize_login_url(login_url) logger.info(f"获取登录URL成功: {login_url[:50]}...") return login_url + @staticmethod + def _normalize_login_url(login_url: str) -> str: + """补全斗鱼接口返回的协议相对登录回调地址。""" + if login_url.startswith('//'): + return 'https:' + login_url + return login_url + def _complete_login(self, login_url: str) -> str: """ 完成登录,获取Cookie diff --git a/core/douyu/login_api.py b/core/douyu/login_api.py index 7e14957..8a64259 100644 --- a/core/douyu/login_api.py +++ b/core/douyu/login_api.py @@ -112,4 +112,46 @@ class LoginAPIStrategy(ABC): Returns: 登录回调 URL """ - return payload.get('data', {}).get('url', '') + data = payload.get('data', {}) + if not isinstance(data, dict): + return '' + + # `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址, + # 但不同版本接口的字段命名不完全一致。 + for key in ('url', 'loginUrl', 'login_url', 'redirectUrl', 'redirect_url'): + value = data.get(key) + if isinstance(value, str) and value: + return value + return '' + + def extract_mobile_bind_unique_key(self, payload: dict) -> str: + """提取服务端要求绑定手机号时返回的一次性继续登录标识。""" + data = payload.get('data', {}) + if not isinstance(data, dict): + return '' + + def find_unique_key(value: object) -> str: + if isinstance(value, dict): + 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(): + result = find_unique_key(child) + if result: + return result + elif isinstance(value, (list, tuple)): + for child in value: + result = find_unique_key(child) + if result: + return result + 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', + } diff --git a/tests/test_douyu_mobile_bind_skip.py b/tests/test_douyu_mobile_bind_skip.py new file mode 100644 index 0000000..02681dd --- /dev/null +++ b/tests/test_douyu_mobile_bind_skip.py @@ -0,0 +1,66 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import Mock + +from core.douyu.login import DouyuLogin +from core.douyu.login_api_wgapi import WgapiLoginAPI + + +class MobileBindSkipTests(unittest.TestCase): + def setUp(self): + self.login = DouyuLogin.__new__(DouyuLogin) + self.login.api = WgapiLoginAPI() + self.login.account = SimpleNamespace(username='test-user', password='test-password') + self.login._request_json = Mock() + + def test_second_login_skips_mobile_bind_with_unique_key(self): + self.login._request_json.side_effect = [ + { + 'error': 130014, + 'msg': '需要进行手机号绑定', + 'data': {'mobileBind': {'uniqueKey': 'one-time-key'}}, + }, + { + 'error': 0, + 'data': {'url': '//www.douyu.com/api/passport/login?code=callback-code'}, + }, + ] + + next_step, login_url = self.login._second_login( + 'gt', 'challenge', 'validate', 'validate|jordan', 'code-token', + ) + + self.assertEqual(next_step, 'mobile_bind_skip') + self.assertEqual( + login_url, + 'https://www.douyu.com/api/passport/login?code=callback-code', + ) + self.assertEqual(self.login._request_json.call_count, 2) + skip_call = self.login._request_json.call_args_list[1] + self.assertEqual(skip_call.args[:3], ( + 'post', + 'https://passport.douyu.com/wgapi/member/passport/login', + '跳过手机号绑定接口', + )) + self.assertEqual(skip_call.kwargs['data'], { + 'type': '3', + 'uniqueKey': 'one-time-key', + 'biz_type': '1', + }) + + def test_second_login_preserves_remote_email_flow_without_unique_key(self): + self.login._request_json.return_value = { + 'error': 130014, + 'data': {'remoteLogin': {'code': 'remote-code'}}, + } + + next_step, remote_code = self.login._second_login( + 'gt', 'challenge', 'validate', 'validate|jordan', 'code-token', + ) + + self.assertEqual((next_step, remote_code), ('remote_email', 'remote-code')) + self.assertEqual(self.login._request_json.call_count, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/frontend/src/pages/AssignmentsPage.tsx b/web/frontend/src/pages/AssignmentsPage.tsx index cc213dd..03d7e8d 100644 --- a/web/frontend/src/pages/AssignmentsPage.tsx +++ b/web/frontend/src/pages/AssignmentsPage.tsx @@ -313,9 +313,13 @@ export default function AssignmentsPage() { />