This commit is contained in:
yml2213
2026-06-21 21:56:56 +08:00
commit 1ce867f3a8
25 changed files with 3111 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
"""斗鱼登录模块"""
from .login import DouyuLogin
from .email_verifier import EmailVerifier
from .config import Config
__all__ = ["DouyuLogin", "EmailVerifier", "Config"]
+72
View File
@@ -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'})
+63
View File
@@ -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
+231
View File
@@ -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
View File
@@ -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}")