支持跳过斗鱼手机号绑定并优化标签筛选
This commit is contained in:
+50
-9
@@ -292,12 +292,18 @@ class DouyuLogin:
|
|||||||
|
|
||||||
# 3️⃣ 第二次登录(带极验)
|
# 3️⃣ 第二次登录(带极验)
|
||||||
logger.info("步骤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️⃣ 发送邮箱验证
|
# 4️⃣ 发送邮箱验证
|
||||||
logger.info("步骤4: 发送邮箱验证...")
|
logger.info("步骤4: 发送邮箱验证...")
|
||||||
email_sent_at = time.time()
|
email_sent_at = time.time()
|
||||||
self._send_email_verify(remote_code)
|
self._send_email_verify(value)
|
||||||
|
|
||||||
# 5️⃣ IMAP获取验证码
|
# 5️⃣ IMAP获取验证码
|
||||||
logger.info("步骤5: 获取邮箱验证码...")
|
logger.info("步骤5: 获取邮箱验证码...")
|
||||||
@@ -305,7 +311,7 @@ class DouyuLogin:
|
|||||||
|
|
||||||
# 6️⃣ 提交验证码
|
# 6️⃣ 提交验证码
|
||||||
logger.info("步骤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
|
# 7️⃣ 完成登录获取Cookie
|
||||||
logger.info("步骤7: 完成登录,获取Cookie...")
|
logger.info("步骤7: 完成登录,获取Cookie...")
|
||||||
@@ -643,7 +649,7 @@ class DouyuLogin:
|
|||||||
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
|
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
|
||||||
|
|
||||||
def _second_login(self, gt: str, challenge: str, validate: str,
|
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
|
code_token: 第一次登录返回的code_token
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
remote_code: 用于邮箱验证的code
|
(next_step, value):
|
||||||
|
- ('remote_email', remote_code): 继续邮箱验证流程
|
||||||
|
- ('mobile_bind_skip', login_url): 已跳过手机号绑定,可完成登录
|
||||||
"""
|
"""
|
||||||
data = self.api.build_second_login_data(
|
data = self.api.build_second_login_data(
|
||||||
self.account.username,
|
self.account.username,
|
||||||
@@ -675,6 +683,14 @@ class DouyuLogin:
|
|||||||
|
|
||||||
logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
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:
|
if payload.get('error') != 130014:
|
||||||
error_msg = payload.get('msg', '未知错误')
|
error_msg = payload.get('msg', '未知错误')
|
||||||
credential_error = self._credential_error_from_payload("第二次登录", payload)
|
credential_error = self._credential_error_from_payload("第二次登录", payload)
|
||||||
@@ -695,7 +711,27 @@ class DouyuLogin:
|
|||||||
|
|
||||||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
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:
|
def _send_email_verify(self, remote_code: str) -> None:
|
||||||
"""发送邮箱验证"""
|
"""发送邮箱验证"""
|
||||||
@@ -753,14 +789,19 @@ class DouyuLogin:
|
|||||||
if not login_url:
|
if not login_url:
|
||||||
raise ValueError("获取登录URL失败")
|
raise ValueError("获取登录URL失败")
|
||||||
|
|
||||||
# 补全URL
|
login_url = self._normalize_login_url(login_url)
|
||||||
if login_url.startswith('//'):
|
|
||||||
login_url = 'https:' + login_url
|
|
||||||
|
|
||||||
logger.info(f"获取登录URL成功: {login_url[:50]}...")
|
logger.info(f"获取登录URL成功: {login_url[:50]}...")
|
||||||
|
|
||||||
return login_url
|
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:
|
def _complete_login(self, login_url: str) -> str:
|
||||||
"""
|
"""
|
||||||
完成登录,获取Cookie
|
完成登录,获取Cookie
|
||||||
|
|||||||
+43
-1
@@ -112,4 +112,46 @@ class LoginAPIStrategy(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
登录回调 URL
|
登录回调 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
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
|
showSearch
|
||||||
placeholder="按标签筛选"
|
placeholder="按标签筛选"
|
||||||
value={filterTag}
|
value={filterTag}
|
||||||
onChange={(v) => setFilterTag(v || undefined)}
|
onChange={(v) => setFilterTag(v || undefined)}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
String(option?.label || '').toLowerCase().includes(input.trim().toLowerCase())
|
||||||
|
}
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
size="small"
|
size="small"
|
||||||
options={allTags.map((t) => ({ value: t, label: t }))}
|
options={allTags.map((t) => ({ value: t, label: t }))}
|
||||||
|
|||||||
Reference in New Issue
Block a user