增加另一个登录接口
This commit is contained in:
+12
-2
@@ -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
@@ -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失败")
|
||||
|
||||
@@ -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', '')
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user