inti
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""斗鱼登录模块"""
|
||||
|
||||
from .login import DouyuLogin
|
||||
from .email_verifier import EmailVerifier
|
||||
from .config import Config
|
||||
|
||||
__all__ = ["DouyuLogin", "EmailVerifier", "Config"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""配置管理模块"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
"""账号配置"""
|
||||
username: str
|
||||
password: str
|
||||
email: str
|
||||
email_password: str
|
||||
email_imap_server: str
|
||||
email_imap_port: int = 993
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyConfig:
|
||||
"""代理配置"""
|
||||
enabled: bool = False
|
||||
http: str = ""
|
||||
https: str = ""
|
||||
|
||||
|
||||
class Config:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self, config_path: str = "config.yaml"):
|
||||
self.config_path = Path(config_path)
|
||||
self._config = self._load_config()
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
"""加载配置文件"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def get_accounts(self) -> List[Account]:
|
||||
"""获取账号列表"""
|
||||
accounts = []
|
||||
for acc in self._config.get('accounts', []):
|
||||
accounts.append(Account(
|
||||
username=acc['username'],
|
||||
password=acc['password'],
|
||||
email=acc['email'],
|
||||
email_password=acc['email_password'],
|
||||
email_imap_server=acc.get('email_imap_server', ''),
|
||||
email_imap_port=acc.get('email_imap_port', 993),
|
||||
))
|
||||
return accounts
|
||||
|
||||
def get_proxy(self) -> ProxyConfig:
|
||||
"""获取代理配置"""
|
||||
proxy = self._config.get('proxy', {})
|
||||
return ProxyConfig(
|
||||
enabled=proxy.get('enabled', False),
|
||||
http=proxy.get('http', ''),
|
||||
https=proxy.get('https', ''),
|
||||
)
|
||||
|
||||
def get_cookie_dir(self) -> str:
|
||||
"""获取Cookie存储目录"""
|
||||
return self._config.get('cookie_dir', 'data/cookies')
|
||||
|
||||
def get_log_config(self) -> dict:
|
||||
"""获取日志配置"""
|
||||
return self._config.get('log', {'level': 'INFO', 'file': 'logs/douyu_login.log'})
|
||||
@@ -0,0 +1,63 @@
|
||||
"""斗鱼专用加密模块 - 用户名密码加密"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
|
||||
# 斗鱼RSA公钥(从JS中提取)
|
||||
DOUYU_RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHfGXRkF+RiLA71KAHOFBaWGBy
|
||||
J7M6D3MDAsFHo2JMBDm2Kfj6V3GFMI7B2JQ3qGl0jCk6ILT1jQ+IFhLvLR3cXPaC
|
||||
HT5xYa0hzJpMNO3bLSuJhzY5jQNqRMWfbcV4FLB2JBaFfWcY7RWQ2pCE6jjDnMHM
|
||||
o2kz+dJoGnZM0b99VwIDAQAB
|
||||
-----END PUBLIC KEY-----"""
|
||||
|
||||
# 斗鱼AES密钥(16位)
|
||||
DOUYU_AES_KEY = "1234567890abcdef"
|
||||
|
||||
|
||||
def md5(text: str) -> str:
|
||||
"""MD5加密"""
|
||||
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
|
||||
"""RSA加密"""
|
||||
key = RSA.import_key(public_key)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
|
||||
|
||||
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
|
||||
"""AES加密"""
|
||||
key_bytes = key.encode('utf-8')
|
||||
text_bytes = text.encode('utf-8')
|
||||
|
||||
# 填充到16的倍数
|
||||
padding_len = 16 - (len(text_bytes) % 16)
|
||||
text_bytes += bytes([padding_len] * padding_len)
|
||||
|
||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||
encrypted = cipher.encrypt(text_bytes)
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
|
||||
|
||||
def encrypt_username(username: str) -> str:
|
||||
"""加密用户名(斗鱼使用RSA加密)"""
|
||||
return rsa_encrypt(username)
|
||||
|
||||
|
||||
def encrypt_password(password: str) -> str:
|
||||
"""加密密码(斗鱼使用MD5)"""
|
||||
return md5(password)
|
||||
|
||||
|
||||
def encrypt_nickname_or_phone(text: str) -> str:
|
||||
"""加密昵称或手机号"""
|
||||
# 斗鱼使用特殊的Base64编码
|
||||
encoded = base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
return encoded
|
||||
@@ -0,0 +1,231 @@
|
||||
"""邮箱验证模块 - IMAP获取验证码"""
|
||||
|
||||
import imaplib
|
||||
import email
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from email.header import decode_header
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class EmailVerifier:
|
||||
"""邮箱验证器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
imap_server: str,
|
||||
imap_port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: float = 12,
|
||||
):
|
||||
self.imap_server = imap_server
|
||||
self.imap_port = imap_port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self._connection: Optional[imaplib.IMAP4_SSL] = None
|
||||
|
||||
def connect(self) -> None:
|
||||
"""连接IMAP服务器"""
|
||||
try:
|
||||
logger.info(f"连接IMAP服务器: {self.imap_server}:{self.imap_port}")
|
||||
self._connection = imaplib.IMAP4_SSL(
|
||||
self.imap_server,
|
||||
self.imap_port,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self._connection.login(self.username, self.password)
|
||||
logger.info("IMAP连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""断开IMAP连接"""
|
||||
if self._connection:
|
||||
try:
|
||||
self._connection.logout()
|
||||
except:
|
||||
pass
|
||||
self._connection = None
|
||||
|
||||
def get_verification_code(self, max_wait: int = 60, interval: int = 2) -> str:
|
||||
"""
|
||||
获取斗鱼验证码
|
||||
|
||||
Args:
|
||||
max_wait: 最大等待时间(秒)
|
||||
interval: 轮询间隔(秒)
|
||||
|
||||
Returns:
|
||||
6位验证码
|
||||
"""
|
||||
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
|
||||
|
||||
self.connect()
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait:
|
||||
code = self._fetch_latest_code()
|
||||
if code:
|
||||
logger.success(f"获取到验证码: {code}")
|
||||
return code
|
||||
|
||||
logger.debug("未找到验证码,等待中...")
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError("等待验证码超时")
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def _fetch_latest_code(self) -> Optional[str]:
|
||||
"""从IMAP获取最新验证码"""
|
||||
try:
|
||||
self._connection.select('INBOX')
|
||||
|
||||
# 搜索最近5分钟的邮件
|
||||
since = (datetime.now() - timedelta(minutes=5)).strftime("%d-%b-%Y")
|
||||
status, messages = self._connection.search(
|
||||
None,
|
||||
f'(OR (FROM "douyu") (FROM "斗鱼") (SUBJECT "验证码")) SINCE {since}'
|
||||
)
|
||||
|
||||
if status != 'OK' or not messages[0]:
|
||||
return None
|
||||
|
||||
# 获取最新的一封邮件
|
||||
latest_id = messages[0].split()[-1]
|
||||
status, msg_data = self._connection.fetch(latest_id, '(RFC822)')
|
||||
|
||||
if status != 'OK':
|
||||
return None
|
||||
|
||||
msg = email.message_from_bytes(msg_data[0][1])
|
||||
|
||||
# 检查是否是斗鱼的邮件
|
||||
subject = self._decode_subject(msg.get('Subject', ''))
|
||||
if not self._is_douyu_email(subject, msg.get('From', '')):
|
||||
return None
|
||||
|
||||
# 提取验证码
|
||||
body = self._get_email_body(msg)
|
||||
code = self._extract_verification_code(body)
|
||||
|
||||
if code:
|
||||
logger.info(f"从邮件中提取到验证码: {code}")
|
||||
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件失败: {e}")
|
||||
return None
|
||||
|
||||
def _is_douyu_email(self, subject: str, from_addr: str) -> bool:
|
||||
"""判断是否是斗鱼的邮件"""
|
||||
douyu_keywords = ['斗鱼', 'douyu', '验证码', '安全验证']
|
||||
subject_lower = subject.lower()
|
||||
from_lower = from_addr.lower()
|
||||
|
||||
return any(keyword in subject_lower or keyword in from_lower
|
||||
for keyword in douyu_keywords)
|
||||
|
||||
def _decode_subject(self, subject: str) -> str:
|
||||
"""解码邮件主题"""
|
||||
if not subject:
|
||||
return ""
|
||||
|
||||
decoded_parts = decode_header(subject)
|
||||
result = []
|
||||
for part, charset in decoded_parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or 'utf-8', errors='ignore'))
|
||||
else:
|
||||
result.append(part)
|
||||
return ' '.join(result)
|
||||
|
||||
def _get_email_body(self, msg: email.message.Message) -> str:
|
||||
"""获取邮件正文"""
|
||||
body = ""
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
if content_type == 'text/plain' or content_type == 'text/html':
|
||||
try:
|
||||
payload = part.get_payload(decode=True)
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
body += payload.decode(charset, errors='ignore')
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
payload = msg.get_payload(decode=True)
|
||||
charset = msg.get_content_charset() or 'utf-8'
|
||||
body = payload.decode(charset, errors='ignore')
|
||||
except:
|
||||
pass
|
||||
|
||||
return body
|
||||
|
||||
def _extract_verification_code(self, text: str) -> Optional[str]:
|
||||
"""从文本中提取验证码"""
|
||||
# 清理HTML标签
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
|
||||
# 查找6位数字验证码
|
||||
# 注意:中文冒号和英文冒号需要分别处理
|
||||
patterns = [
|
||||
r'验证码[::]\s*(\d{6})', # 验证码:123456 或 验证码:123456
|
||||
r'验证码\s+(\d{6})', # 验证码 123456
|
||||
r'(\d{6})\s*是您的验证码', # 123456是您的验证码
|
||||
r'您的验证码[是为]\s*[::]\s*(\d{6})', # 您的验证码是:123456
|
||||
r'您的验证码[是为]\s*(\d{6})', # 您的验证码是123456
|
||||
r'verification code[::]\s*(\d{6})',
|
||||
r'code[::]\s*(\d{6})',
|
||||
r'(\d{6})', # 最后尝试匹配任何6位数字
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
# 验证码有效性检查(简单过滤明显不是验证码的数字)
|
||||
if not self._is_likely_code(code):
|
||||
continue
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
def _is_likely_code(self, code: str) -> bool:
|
||||
"""判断是否是有效的验证码"""
|
||||
# 过滤掉一些明显不是验证码的数字
|
||||
invalid_patterns = [
|
||||
r'^(\d)\1{5}$', # 全部相同:111111, 222222
|
||||
r'^123456$', # 顺序数字
|
||||
r'^654321$', # 逆序数字
|
||||
]
|
||||
|
||||
for pattern in invalid_patterns:
|
||||
if re.match(pattern, code):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_email_config_for_account(email_address: str) -> dict:
|
||||
"""根据邮箱地址自动配置IMAP服务器"""
|
||||
configs = {
|
||||
'qq.com': {'server': 'imap.qq.com', 'port': 993},
|
||||
'163.com': {'server': 'imap.163.com', 'port': 993},
|
||||
'126.com': {'server': 'imap.126.com', 'port': 993},
|
||||
'gmail.com': {'server': 'imap.gmail.com', 'port': 993},
|
||||
'outlook.com': {'server': 'outlook.office365.com', 'port': 993},
|
||||
'hotmail.com': {'server': 'outlook.office365.com', 'port': 993},
|
||||
}
|
||||
|
||||
domain = email_address.split('@')[-1].lower()
|
||||
return configs.get(domain, {'server': f'imap.{domain}', 'port': 993})
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
"""斗鱼登录核心模块"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from typing import Mapping, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from loguru import logger
|
||||
|
||||
from .config import Account
|
||||
from .crypto import encrypt_password, encrypt_nickname_or_phone
|
||||
from .email_verifier import EmailVerifier
|
||||
|
||||
# 导入geetest滑块模块
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from geetest import run_solver
|
||||
from geetest.solver import (
|
||||
_generate_seed, get_w1, get_w2, get_w3,
|
||||
)
|
||||
from geetest.network import get_js_address, get_c_s, req_slide, get_picture, req_end
|
||||
from geetest.imaging import download_picture
|
||||
|
||||
|
||||
class LoginResult:
|
||||
"""登录结果"""
|
||||
|
||||
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""):
|
||||
self.success = success
|
||||
self.cookie = cookie
|
||||
self.message = message
|
||||
self.code = code # 需要验证时的code
|
||||
|
||||
|
||||
class DouyuLogin:
|
||||
"""斗鱼登录器"""
|
||||
|
||||
# 斗鱼API地址
|
||||
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
||||
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||||
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||||
LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login"
|
||||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||||
REQUEST_TIMEOUT = (3.05, 12)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: Account,
|
||||
proxy: Optional[str | Mapping[str, str]] = None,
|
||||
timeout: tuple[float, float] = REQUEST_TIMEOUT,
|
||||
):
|
||||
self.account = account
|
||||
self.proxy = proxy
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self._setup_session()
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
"""配置Session"""
|
||||
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
|
||||
self.session.trust_env = False
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Referer': 'https://www.douyu.com/',
|
||||
'Origin': 'https://www.douyu.com',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
})
|
||||
|
||||
if self.proxy:
|
||||
if isinstance(self.proxy, str):
|
||||
self.session.proxies = {
|
||||
'http': self.proxy,
|
||||
'https': self.proxy,
|
||||
}
|
||||
else:
|
||||
self.session.proxies = {
|
||||
scheme: url
|
||||
for scheme, url in self.proxy.items()
|
||||
if url
|
||||
}
|
||||
|
||||
def _safe_url(self, url: str) -> str:
|
||||
"""隐藏查询参数,避免日志泄露登录回调 code。"""
|
||||
parsed = urlsplit(url)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
|
||||
|
||||
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
||||
"""统一发送请求,附带分段超时和更明确的错误信息。"""
|
||||
timeout = kwargs.pop('timeout', self.timeout)
|
||||
safe_url = self._safe_url(url)
|
||||
started = time.monotonic()
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, timeout=timeout, **kwargs)
|
||||
except requests.Timeout as exc:
|
||||
elapsed = time.monotonic() - started
|
||||
raise TimeoutError(
|
||||
f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s,"
|
||||
f"timeout={timeout}"
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
elapsed = time.monotonic() - started
|
||||
raise ConnectionError(
|
||||
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
|
||||
) from exc
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
logger.debug(
|
||||
f"{method.upper()} {safe_url} -> {response.status_code} "
|
||||
f"({elapsed:.2f}s)"
|
||||
)
|
||||
return response
|
||||
|
||||
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
|
||||
"""请求 JSON 接口,并在响应异常时输出可定位的信息。"""
|
||||
response = self._request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
|
||||
body = response.text.strip()
|
||||
if not body:
|
||||
raise ValueError(f"{source} 返回为空,无法解析 JSON")
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
preview = body[:200].replace("\n", "\\n")
|
||||
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
|
||||
|
||||
def login(self) -> LoginResult:
|
||||
"""
|
||||
完整登录流程
|
||||
|
||||
Returns:
|
||||
LoginResult: 登录结果,包含cookie
|
||||
"""
|
||||
logger.info(f"开始登录账号: {self.account.username}")
|
||||
|
||||
try:
|
||||
# 1️⃣ 第一次登录(获取极验参数)
|
||||
logger.info("步骤1: 第一次登录,获取极验参数...")
|
||||
gt, challenge, code_token, initial_cookies = self._first_login()
|
||||
|
||||
# 2️⃣ 极验滑块验证
|
||||
logger.info("步骤2: 极验滑块验证...")
|
||||
validate, seccode = self._solve_geetest(gt, challenge)
|
||||
|
||||
# 3️⃣ 第二次登录(带极验)
|
||||
logger.info("步骤3: 第二次登录(带极验验证)...")
|
||||
remote_code = self._second_login(gt, challenge, validate, seccode, code_token)
|
||||
|
||||
# 4️⃣ 发送邮箱验证
|
||||
logger.info("步骤4: 发送邮箱验证...")
|
||||
self._send_email_verify(remote_code)
|
||||
|
||||
# 5️⃣ IMAP获取验证码
|
||||
logger.info("步骤5: 获取邮箱验证码...")
|
||||
verify_code = self._get_email_code()
|
||||
|
||||
# 6️⃣ 提交验证码
|
||||
logger.info("步骤6: 提交验证码...")
|
||||
login_url = self._submit_verify_code(remote_code, verify_code)
|
||||
|
||||
# 7️⃣ 完成登录获取Cookie
|
||||
logger.info("步骤7: 完成登录,获取Cookie...")
|
||||
cookie = self._complete_login(login_url)
|
||||
|
||||
logger.success(f"登录成功! Cookie长度: {len(cookie)}")
|
||||
return LoginResult(success=True, cookie=cookie, message="登录成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"登录失败: {e}")
|
||||
return LoginResult(success=False, message=str(e))
|
||||
|
||||
def _first_login(self) -> Tuple[str, str, str, dict]:
|
||||
"""
|
||||
第一次登录,获取极验参数
|
||||
|
||||
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': 'https://www.douyu.com/',
|
||||
}
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
self.LOGIN_API,
|
||||
'第一次登录接口',
|
||||
data=data,
|
||||
)
|
||||
|
||||
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||||
|
||||
if payload.get('error') != 81:
|
||||
error_msg = payload.get('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', '')
|
||||
|
||||
if not gt or not challenge:
|
||||
raise ValueError("获取极验参数失败")
|
||||
|
||||
logger.info(f"获取极验参数成功: gt={gt[:10]}..., challenge={challenge[:10]}...")
|
||||
|
||||
return gt, challenge, code_token, self.session.cookies.get_dict()
|
||||
|
||||
def _solve_geetest(self, gt: str, challenge: str) -> Tuple[str, str]:
|
||||
"""
|
||||
解决极验滑块验证
|
||||
|
||||
Args:
|
||||
gt: 极验gt参数
|
||||
challenge: 极验challenge参数
|
||||
|
||||
Returns:
|
||||
(validate, seccode)
|
||||
"""
|
||||
logger.info("开始极验滑块验证...")
|
||||
|
||||
# 使用geetest-v3-silde-crack的完整流程
|
||||
str_16 = _generate_seed()
|
||||
|
||||
# 获取JS地址
|
||||
get_js_address(gt)
|
||||
|
||||
# 获取第一个w值
|
||||
w1 = get_w1(gt, challenge, str_16)
|
||||
|
||||
# 获取c和s
|
||||
c, s = get_c_s(gt, challenge, w1)
|
||||
|
||||
# 获取第二个w值
|
||||
w2 = get_w2(gt, challenge, c, s, str_16)
|
||||
|
||||
# 请求滑块
|
||||
req_slide(gt, challenge, w2)
|
||||
|
||||
# 获取图片
|
||||
bg, fullbg, c, s, slice, challenge_new = get_picture(gt, challenge)
|
||||
|
||||
# 识别缺口位置
|
||||
hkjl = download_picture(bg, fullbg, slice)
|
||||
|
||||
# 获取第三个w值
|
||||
w3 = get_w3(str_16, challenge_new, hkjl, c, s, gt)
|
||||
|
||||
# 最后验证
|
||||
result = req_end(gt, challenge_new, w3)
|
||||
|
||||
# 从req_end的响应中提取validate
|
||||
# req_end返回的是验证结果,成功时包含validate
|
||||
if isinstance(result, dict):
|
||||
validate = result.get('validate', '')
|
||||
else:
|
||||
# 如果返回的是字符串,可能是直接的validate值
|
||||
validate = str(result)
|
||||
|
||||
if not validate:
|
||||
raise ValueError("极验验证失败,未获取到validate")
|
||||
|
||||
seccode = f"{validate}|jordan"
|
||||
|
||||
logger.success(f"极验滑块验证成功! validate={validate[:20]}...")
|
||||
return validate, seccode
|
||||
|
||||
def _second_login(self, gt: str, challenge: str, validate: str,
|
||||
seccode: str, code_token: str) -> str:
|
||||
"""
|
||||
第二次登录(带极验验证)
|
||||
|
||||
Returns:
|
||||
remote_code: 用于邮箱验证的code
|
||||
"""
|
||||
encrypted_username = encrypt_nickname_or_phone(self.account.username)
|
||||
encrypted_password = encrypt_password(self.account.password)
|
||||
|
||||
# 参考HAR文件中的完整参数
|
||||
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': 'https://www.douyu.com/',
|
||||
't': str(int(time.time() * 1000)),
|
||||
'client_id': '1',
|
||||
'did': '',
|
||||
'lang': '',
|
||||
'isMultiAccount': '0',
|
||||
'biz_type': '1',
|
||||
}
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
self.LOGIN_API,
|
||||
'第二次登录接口',
|
||||
data=data,
|
||||
)
|
||||
|
||||
logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||||
|
||||
if payload.get('error') != 130014:
|
||||
error_msg = payload.get('msg', '未知错误')
|
||||
raise ValueError(f"第二次登录失败: {error_msg}")
|
||||
|
||||
# 提取remote_code
|
||||
remote_code = payload.get('data', {}).get('remoteLogin', {}).get('code', '')
|
||||
|
||||
if not remote_code:
|
||||
raise ValueError("获取remote_code失败")
|
||||
|
||||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
||||
|
||||
return remote_code
|
||||
|
||||
def _send_email_verify(self, remote_code: str) -> None:
|
||||
"""发送邮箱验证"""
|
||||
data = {
|
||||
'code': remote_code,
|
||||
'client_id': '1',
|
||||
}
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
self.SEND_EMAIL_API,
|
||||
'发送验证邮件接口',
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||||
|
||||
logger.info("验证邮件已发送")
|
||||
|
||||
def _get_email_code(self) -> str:
|
||||
"""获取邮箱验证码"""
|
||||
verifier = EmailVerifier(
|
||||
imap_server=self.account.email_imap_server,
|
||||
imap_port=self.account.email_imap_port,
|
||||
username=self.account.email,
|
||||
password=self.account.email_password,
|
||||
)
|
||||
|
||||
return verifier.get_verification_code(max_wait=60)
|
||||
|
||||
def _submit_verify_code(self, remote_code: str, verify_code: str) -> str:
|
||||
"""
|
||||
提交验证码
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
self.VERIFY_API,
|
||||
'提交验证码接口',
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
||||
|
||||
login_url = payload.get('data', {}).get('url', '')
|
||||
|
||||
if not login_url:
|
||||
raise ValueError("获取登录URL失败")
|
||||
|
||||
# 补全URL
|
||||
if login_url.startswith('//'):
|
||||
login_url = 'https:' + login_url
|
||||
|
||||
logger.info(f"获取登录URL成功: {login_url[:50]}...")
|
||||
|
||||
return login_url
|
||||
|
||||
def _complete_login(self, login_url: str) -> str:
|
||||
"""
|
||||
完成登录,获取Cookie
|
||||
|
||||
Returns:
|
||||
cookie: 完整的Cookie字符串
|
||||
"""
|
||||
# 访问登录URL
|
||||
response = self._request('get', login_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# 尝试访问webLogin获取用户信息
|
||||
try:
|
||||
code_match = re.search(r'code=([^&]+)', login_url)
|
||||
if code_match:
|
||||
code = code_match.group(1)
|
||||
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
|
||||
response2 = self._request('get', weblogin_url)
|
||||
|
||||
if response2.status_code == 200:
|
||||
logger.info("WebLogin成功")
|
||||
except Exception as e:
|
||||
logger.warning(f"WebLogin请求失败(不影响登录): {e}")
|
||||
|
||||
# 收集所有Cookie
|
||||
cookies = self.session.cookies.get_dict()
|
||||
|
||||
# 格式化Cookie字符串
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
return cookie_str
|
||||
|
||||
def save_cookie(self, cookie: str, filepath: str) -> None:
|
||||
"""保存Cookie到文件"""
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
'username': self.account.username,
|
||||
'cookie': cookie,
|
||||
'timestamp': int(time.time()),
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Cookie已保存到: {filepath}")
|
||||
Reference in New Issue
Block a user