支持跳过斗鱼手机号绑定并优化标签筛选

This commit is contained in:
yml2213
2026-08-07 22:07:39 +08:00
parent 535fcbb257
commit a67d144b78
4 changed files with 163 additions and 10 deletions
+50 -9
View File
@@ -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
+43 -1
View File
@@ -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',
}
+66
View File
@@ -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()
@@ -313,9 +313,13 @@ export default function AssignmentsPage() {
/>
<Select
allowClear
showSearch
placeholder="按标签筛选"
value={filterTag}
onChange={(v) => setFilterTag(v || undefined)}
filterOption={(input, option) =>
String(option?.label || '').toLowerCase().includes(input.trim().toLowerCase())
}
style={{ width: 150 }}
size="small"
options={allTags.map((t) => ({ value: t, label: t }))}