增加另一个登录接口

This commit is contained in:
yml2213
2026-06-26 17:22:51 +08:00
parent f736ef4f4c
commit d7d7c0cbb0
12 changed files with 1739 additions and 73 deletions
+12 -2
View File
@@ -1,6 +1,16 @@
"""斗鱼登录模块"""
from .login import DouyuLogin
from .login import DouyuLogin, CredentialError
from .login_api import LoginAPIStrategy
from .login_api_wgapi import WgapiLoginAPI
from .login_api_iframe import IframeLoginAPI
from .email_verifier import EmailVerifier
__all__ = ["DouyuLogin", "EmailVerifier"]
__all__ = [
"DouyuLogin",
"CredentialError",
"LoginAPIStrategy",
"WgapiLoginAPI",
"IframeLoginAPI",
"EmailVerifier",
]
+38 -69
View File
@@ -10,8 +10,9 @@ from urllib.parse import urlsplit, urlunsplit
from loguru import logger
from .cookie_enricher import CookieEnricher
from .crypto import encrypt_password, encrypt_nickname_or_phone
from .email_verifier import EmailLoginError, EmailVerifier
from .login_api import LoginAPIStrategy
from .login_api_wgapi import WgapiLoginAPI
from .proxy_fetcher import ProxyFetcher
from core.geetest.v3_slide.solver import (
@@ -27,6 +28,11 @@ from core.geetest.common.network import (
_geetest_semaphore = threading.Semaphore(2)
class CredentialError(ValueError):
"""账号或密码错误,不应重试。"""
pass
class AccountLike(Protocol):
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
username: str
@@ -78,6 +84,7 @@ class DouyuLogin:
max_total_time: float = 0,
proxy_fetcher: Optional[ProxyFetcher] = None,
stop_event: Optional[threading.Event] = None,
api_strategy: Optional[LoginAPIStrategy] = None,
):
self.account = account
self.proxy = proxy
@@ -86,6 +93,7 @@ class DouyuLogin:
self.max_total_time = max_total_time # 0=不限制单账号登录总时长
self.stop_event = stop_event
self.session = requests.Session()
self.api = api_strategy or WgapiLoginAPI() # 默认使用 wgapi 接口
self.proxy_fetcher = proxy_fetcher or (
ProxyFetcher(api_url=proxy_api_url) if proxy_api_url else None
@@ -310,6 +318,9 @@ class DouyuLogin:
except InterruptedError as e:
logger.warning(f"登录任务已停止: {e}")
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")
except EmailLoginError as e:
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
return LoginResult(success=False, message=str(e), code="email_login_failed")
@@ -361,27 +372,15 @@ class DouyuLogin:
Returns:
(gt, challenge, code_token, cookies)
"""
# 加密用户名和密码
encrypted_username = encrypt_nickname_or_phone(self.account.username)
encrypted_password = encrypt_password(self.account.password)
data = {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypted_password,
'biz_type': '1',
'room_id': '0',
'redirect_url': self.LOGIN_REFERER,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
}
data = self.api.build_first_login_data(
self.account.username,
self.account.password,
self.LOGIN_REFERER,
)
payload = self._request_json(
'post',
self.LOGIN_API,
self.api.first_login_url,
'第一次登录接口',
data=data,
)
@@ -390,15 +389,13 @@ 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}")
raise ValueError(f"第一次登录失败: {error_msg}")
# 提取极验参数
geetest_data = payload.get('data', {}).get('geetest', {})
code_data = geetest_data.get('code_data', {})
code_token = geetest_data.get('code_token', '')
gt = code_data.get('gt', '')
challenge = code_data.get('challenge', '')
gt, challenge, code_token = self.api.extract_geetest_params(payload)
if not gt or not challenge:
raise ValueError("获取极验参数失败")
@@ -517,39 +514,18 @@ class DouyuLogin:
Returns:
remote_code: 用于邮箱验证的code
"""
encrypted_username = encrypt_nickname_or_phone(self.account.username)
encrypted_password = encrypt_password(self.account.password)
data = {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypted_password,
'room_id': '0',
'code_type': '1',
'code_token': code_token,
'gt_version': 'v3',
'geetest_challenge': challenge,
'geetest_validate': validate,
'geetest_seccode': seccode,
'code_data[geetest_challenge]': challenge,
'code_data[geetest_validate]': validate,
'code_data[geetest_seccode]': seccode,
'code_data[gt_version]': 'v3',
'code_data[code]': '',
'redirect_url': self.LOGIN_REFERER,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
'biz_type': '1',
}
data = self.api.build_second_login_data(
self.account.username,
self.account.password,
gt, challenge, validate, seccode, code_token,
self.LOGIN_REFERER,
)
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...")
payload = self._request_json(
'post',
self.LOGIN_API,
self.api.login_url,
'第二次登录接口',
data=data,
)
@@ -558,10 +534,13 @@ 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}")
raise ValueError(f"第二次登录失败: {error_msg}")
# 提取remote_code
remote_code = payload.get('data', {}).get('remoteLogin', {}).get('code', '')
remote_code = self.api.extract_remote_code(payload)
if not remote_code:
raise ValueError("获取remote_code失败")
@@ -572,14 +551,11 @@ class DouyuLogin:
def _send_email_verify(self, remote_code: str) -> None:
"""发送邮箱验证"""
data = {
'code': remote_code,
'client_id': '1',
}
data = self.api.build_send_email_data(remote_code)
payload = self._request_json(
'post',
self.SEND_EMAIL_API,
self.api.send_email_url,
'发送验证邮件接口',
data=data,
)
@@ -612,18 +588,11 @@ class DouyuLogin:
Returns:
login_url: 登录回调URL
"""
data = {
'verify_type': '2',
'captcha': verify_code,
'isMultiAccount': '0',
'code': remote_code,
'client_id': '1',
'redirect_url': '//www.douyu.com/api/passport/login',
}
data = self.api.build_verify_data(remote_code, verify_code)
payload = self._request_json(
'post',
self.VERIFY_API,
self.api.verify_url,
'提交验证码接口',
data=data,
)
@@ -631,7 +600,7 @@ class DouyuLogin:
if payload.get('error') != 0:
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
login_url = payload.get('data', {}).get('url', '')
login_url = self.api.extract_login_url(payload)
if not login_url:
raise ValueError("获取登录URL失败")
+115
View File
@@ -0,0 +1,115 @@
"""登录接口策略基类"""
from abc import ABC, abstractmethod
from typing import Tuple
class LoginAPIStrategy(ABC):
"""登录接口策略基类
定义斗鱼登录各步骤的接口地址和参数构建方式。
子类需实现 wgapi 和 iframe 两套接口的差异部分。
"""
@property
@abstractmethod
def name(self) -> str:
"""策略名称,如 'wgapi''iframe'"""
...
@property
@abstractmethod
def first_login_url(self) -> str:
"""第一次登录接口 URL(获取极验参数)"""
...
@property
@abstractmethod
def login_url(self) -> str:
"""第二次登录接口 URL(带极验验证)"""
...
@property
@abstractmethod
def send_email_url(self) -> str:
"""发送邮箱验证 URL"""
...
@property
@abstractmethod
def verify_url(self) -> str:
"""提交验证码 URL"""
...
@abstractmethod
def encrypt_username(self, username: str) -> str:
"""加密用户名(各接口加密方式不同)"""
...
@abstractmethod
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
"""构建第一次登录请求参数(获取极验参数)"""
...
@abstractmethod
def build_second_login_data(
self,
username: str,
password: str,
gt: str,
challenge: str,
validate: str,
seccode: str,
code_token: str,
referer: str,
) -> dict:
"""构建第二次登录请求参数(带极验验证)"""
...
@abstractmethod
def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数"""
...
@abstractmethod
def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数"""
...
def extract_geetest_params(self, payload: dict) -> Tuple[str, str, str]:
"""从第一次登录响应提取极验参数
Args:
payload: 登录接口响应 JSON
Returns:
(gt, challenge, code_token)
"""
geetest_data = payload.get('data', {}).get('geetest', {})
code_data = geetest_data.get('code_data', {})
gt = code_data.get('gt', '')
challenge = code_data.get('challenge', '')
code_token = geetest_data.get('code_token', '')
return gt, challenge, code_token
def extract_remote_code(self, payload: dict) -> str:
"""从第二次登录响应提取 remote_code
Args:
payload: 登录接口响应 JSON
Returns:
remote_code 字符串
"""
return payload.get('data', {}).get('remoteLogin', {}).get('code', '')
def extract_login_url(self, payload: dict) -> str:
"""从验证码响应提取登录 URL
Args:
payload: 提交验证码接口响应 JSON
Returns:
登录回调 URL
"""
return payload.get('data', {}).get('url', '')
+117
View File
@@ -0,0 +1,117 @@
"""iframe 版登录接口策略"""
import time
import urllib.parse
from .crypto import encrypt_nickname_or_phone, encrypt_password
from .login_api import LoginAPIStrategy
class IframeLoginAPI(LoginAPIStrategy):
"""iframe 版接口策略
使用旧版 iframe 接口,参数嵌套结构,用户名 RC4 加密后 URL 编码。
第一次登录使用 wgapi 接口获取极验参数,第二次登录使用 iframe 接口。
"""
# e 语言中硬编码的 did 值
DEFAULT_DID = "6ab33184203a7d50d5f9a8fa00021701"
@property
def name(self) -> str:
return "iframe"
@property
def first_login_url(self) -> str:
"""第一次登录用 wgapi 接口获取极验参数"""
return "https://passport.douyu.com/wgapi/member/passport/login"
@property
def login_url(self) -> str:
"""第二次登录用 iframe 接口"""
return "https://passport.douyu.com/iframe/loginNew"
@property
def send_email_url(self) -> str:
return "https://passport.douyu.com/iframe/sendEmail"
@property
def verify_url(self) -> str:
return "https://passport.douyu.com/iframe/remoteLogin"
def encrypt_username(self, username: str) -> str:
"""RC4 加密后 URL 编码"""
encrypted = encrypt_nickname_or_phone(username)
return urllib.parse.quote(encrypted, safe='')
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
"""构建第一次登录参数(获取极验参数)
注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。
"""
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名
encrypted_username = encrypt_nickname_or_phone(username)
return {
'type': '1',
'nicknameOrPhoneEncrypt': encrypted_username,
'password': encrypt_password(password),
'biz_type': '1',
'room_id': '0',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
}
def build_second_login_data(
self,
username: str,
password: str,
gt: str,
challenge: str,
validate: str,
seccode: str,
code_token: str,
referer: str,
) -> dict:
"""构建第二次登录参数(带极验验证)
使用 iframe 接口的嵌套参数结构。
注意:iframe 接口使用 username 参数(直接用户名URL编码),而非 nicknameOrPhoneEncrypt。
"""
# seccode 格式: validate|jordan
seccode_value = validate + '%7Cjordan'
return {
'username': username, # iframe 接口用 username,直接用户名
'password': encrypt_password(password),
'room_id': '0',
'code_verify_data[code_type]': '1',
'code_verify_data[code_token]': code_token,
'code_verify_data[code_data][geetest_challenge]': challenge,
'code_verify_data[code_data][geetest_validate]': validate,
'code_verify_data[code_data][geetest_seccode]': seccode_value,
'code_verify_data[code_data][gt_version]': 'v3',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
}
def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数"""
return {'cacheId': code}
def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数"""
return {
'veify_type': '2', # e 语言原文如此,斗鱼接口拼写
'phoneCaptcha': captcha,
'client_id': '1',
'isMultiAccount': '0',
'cacheId': code,
}
+108
View File
@@ -0,0 +1,108 @@
"""wgapi 版登录接口策略"""
import time
from .crypto import encrypt_nickname_or_phone, encrypt_password
from .login_api import LoginAPIStrategy
class WgapiLoginAPI(LoginAPIStrategy):
"""wgapi 版接口策略
使用新版 wgapi 接口,参数扁平化,用户名 RC4 加密后 base64 编码。
"""
@property
def name(self) -> str:
return "wgapi"
@property
def first_login_url(self) -> str:
return "https://passport.douyu.com/wgapi/member/passport/login"
@property
def login_url(self) -> str:
return "https://passport.douyu.com/wgapi/member/passport/login"
@property
def send_email_url(self) -> str:
return "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
@property
def verify_url(self) -> str:
return "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
def encrypt_username(self, username: str) -> str:
"""RC4 加密后 base64 编码"""
return encrypt_nickname_or_phone(username)
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
"""构建第一次登录参数"""
return {
'type': '1',
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
'password': encrypt_password(password),
'biz_type': '1',
'room_id': '0',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
}
def build_second_login_data(
self,
username: str,
password: str,
gt: str,
challenge: str,
validate: str,
seccode: str,
code_token: str,
referer: str,
) -> dict:
"""构建第二次登录参数(带极验验证)"""
return {
'type': '1',
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
'password': encrypt_password(password),
'room_id': '0',
'code_type': '1',
'code_token': code_token,
'gt_version': 'v3',
'geetest_challenge': challenge,
'geetest_validate': validate,
'geetest_seccode': seccode,
'code_data[geetest_challenge]': challenge,
'code_data[geetest_validate]': validate,
'code_data[geetest_seccode]': seccode,
'code_data[gt_version]': 'v3',
'code_data[code]': '',
'redirect_url': referer,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
'biz_type': '1',
}
def build_send_email_data(self, code: str) -> dict:
"""构建发送邮箱验证参数"""
return {
'code': code,
'client_id': '1',
}
def build_verify_data(self, code: str, captcha: str) -> dict:
"""构建提交验证码参数"""
return {
'verify_type': '2',
'captcha': captcha,
'isMultiAccount': '0',
'code': code,
'client_id': '1',
'redirect_url': '//www.douyu.com/api/passport/login',
}