支持跳过斗鱼手机号绑定并优化标签筛选
This commit is contained in:
+50
-9
@@ -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
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
Reference in New Issue
Block a user