彻底优化项目结构,使用 web

This commit is contained in:
yml2213
2026-06-22 13:39:03 +08:00
parent 347edb8103
commit 63c1ffa046
46 changed files with 127 additions and 3077 deletions
+8
View File
@@ -0,0 +1,8 @@
"""斗鱼登录模块"""
from .login import DouyuLogin
from .email_verifier import EmailVerifier
from .proxy import ProxyManager
from .whitelist import WhitelistManager
__all__ = ["DouyuLogin", "EmailVerifier", "ProxyManager", "WhitelistManager"]
+68
View File
@@ -0,0 +1,68 @@
"""斗鱼专用加密模块 - 用户名密码加密"""
import base64
import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
from Crypto.Cipher import AES
from Crypto.Cipher import ARC4
# 斗鱼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"
# 斗鱼登录页 cryptoData 使用的 RC4 密钥
DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr"
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:
"""加密昵称或手机号"""
key = DOUYU_RC4_KEY.encode('utf-8')
cipher = ARC4.new(key)
encrypted = cipher.encrypt(text.encode('utf-8'))
return base64.b64encode(encrypted).decode('utf-8')
+349
View File
@@ -0,0 +1,349 @@
"""邮箱验证模块 - IMAP获取验证码"""
import imaplib
import email
import html
import re
import time
from datetime import datetime, timedelta
from email.header import decode_header
from email.utils import parsedate_to_datetime
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,
mailbox: str = "INBOX",
lookback_minutes: int = 10,
max_messages: int = 20,
):
self.imap_server = imap_server
self.imap_port = imap_port
self.username = username
self.password = password
self.timeout = timeout
self.mailbox = mailbox
self.lookback_minutes = lookback_minutes
self.max_messages = max_messages
self._connection: Optional[imaplib.IMAP4_SSL] = None
def connect(self) -> None:
"""连接IMAP服务器"""
try:
logger.info(f"连接IMAP服务器: {self.imap_server}:{self.imap_port}")
self._validate_login_text()
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 _validate_login_text(self) -> None:
"""提前检查IMAP登录字段,给出比ascii编码异常更明确的提示。"""
for label, value in (("邮箱账号", self.username), ("邮箱密码/授权码", self.password)):
try:
value.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError(
f"{label}包含中文或其他非ASCII字符,IMAP无法登录;"
"请检查导入格式是否为:用户名|密码|邮箱|邮箱密码"
) from exc
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,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> str:
"""
获取斗鱼验证码
Args:
max_wait: 最大等待时间(秒)
interval: 轮询间隔(秒)
after_timestamp: 发起发送验证码请求的时间戳,用于过滤旧邮件
allow_old_seconds: 邮件服务器时间允许向前偏移的秒数
Returns:
6位验证码
"""
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
self.connect()
try:
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
code = self._fetch_latest_code(
after_timestamp=after_timestamp,
allow_old_seconds=allow_old_seconds,
)
if code:
logger.success(f"获取到验证码: {code}")
return code
logger.debug("未找到验证码,等待中...")
time.sleep(interval)
raise TimeoutError("等待验证码超时")
finally:
self.disconnect()
def _fetch_latest_code(
self,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> Optional[str]:
"""从IMAP获取最新验证码"""
try:
if not self._connection:
raise RuntimeError("IMAP未连接")
status, _ = self._connection.select(self.mailbox, readonly=True)
if status != 'OK':
logger.warning(f"选择邮箱目录失败: {self.mailbox}")
return None
since = self._build_since_date(after_timestamp, allow_old_seconds)
# IMAP命令只能稳定发送ASCII条件,中文主题/发件人改到本地解析过滤。
status, messages = self._connection.search(None, 'SINCE', since)
if status != 'OK' or not messages[0]:
return None
message_ids = messages[0].split()
recent_ids = list(reversed(message_ids[-self.max_messages:]))
logger.debug(f"扫描最近 {len(recent_ids)} 封邮件,SINCE {since}")
for message_id in recent_ids:
msg = self._fetch_message(message_id)
if not msg:
continue
if not self._is_recent_enough(msg, after_timestamp, allow_old_seconds):
continue
subject = self._decode_subject(msg.get('Subject', ''))
from_addr = msg.get('From', '')
body = self._get_email_body(msg)
if not self._is_douyu_email(subject, from_addr, body):
continue
code = self._extract_verification_code(body)
if code:
logger.info(f"从邮件中提取到验证码,主题: {subject}")
return code
return None
except imaplib.IMAP4.abort as e:
logger.warning(f"IMAP连接中断,准备下轮重连: {e}")
self.disconnect()
self.connect()
return None
except Exception as e:
logger.error(f"获取邮件失败: {e}")
return None
def _build_since_date(
self,
after_timestamp: Optional[float],
allow_old_seconds: int,
) -> str:
"""构造IMAP SINCE日期,月份固定用英文缩写。"""
if after_timestamp:
since_dt = datetime.fromtimestamp(
max(0, after_timestamp - allow_old_seconds)
)
else:
since_dt = datetime.now() - timedelta(minutes=self.lookback_minutes)
return self._format_imap_date(since_dt)
def _format_imap_date(self, value: datetime) -> str:
"""格式化IMAP日期,避免系统locale影响月份名称。"""
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
return f"{value.day:02d}-{months[value.month - 1]}-{value.year}"
def _fetch_message(self, message_id: bytes) -> Optional[email.message.Message]:
"""获取邮件完整内容,使用PEEK避免标记已读。"""
status, msg_data = self._connection.fetch(message_id, '(BODY.PEEK[])')
if status != 'OK':
return None
raw_message = self._join_fetch_payload(msg_data)
if not raw_message:
return None
return email.message_from_bytes(raw_message)
def _join_fetch_payload(self, msg_data) -> bytes:
"""合并IMAP fetch返回中的邮件字节内容。"""
chunks = []
for item in msg_data:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes):
chunks.append(item[1])
elif isinstance(item, bytes) and item.startswith(b'From '):
chunks.append(item)
return b''.join(chunks)
def _is_recent_enough(
self,
msg: email.message.Message,
after_timestamp: Optional[float],
allow_old_seconds: int,
) -> bool:
"""检查邮件时间是否晚于本次发送验证码请求。"""
if not after_timestamp:
return True
message_time = self._parse_message_time(msg)
if not message_time:
logger.debug("邮件缺少Date头,保守纳入候选")
return True
if message_time.timestamp() < after_timestamp - allow_old_seconds:
subject = self._decode_subject(msg.get('Subject', ''))
logger.debug(f"跳过旧邮件: {subject}")
return False
return True
def _parse_message_time(self, msg: email.message.Message) -> Optional[datetime]:
"""解析邮件Date头。"""
date_header = msg.get('Date')
if not date_header:
return None
try:
return parsedate_to_datetime(date_header)
except (TypeError, ValueError):
return None
def _is_douyu_email(self, subject: str, from_addr: str, body: str = "") -> bool:
"""判断是否是斗鱼的邮件"""
douyu_keywords = ['斗鱼', 'douyu', 'douyutv']
verify_keywords = ['验证码', '安全验证', '登录验证', '动态码', '校验码']
combined = f"{subject}\n{from_addr}\n{body[:500]}".lower()
return (
any(keyword in combined for keyword in douyu_keywords)
or any(keyword in subject for keyword in verify_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:
"""获取邮件正文"""
parts = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if part.get_content_disposition() == 'attachment':
continue
if content_type == 'text/plain' or content_type == 'text/html':
decoded = self._decode_part_payload(part)
if decoded:
parts.append(decoded)
else:
decoded = self._decode_part_payload(msg)
if decoded:
parts.append(decoded)
return "\n".join(parts)
def _decode_part_payload(self, part: email.message.Message) -> str:
"""解码邮件片段内容。"""
try:
payload = part.get_payload(decode=True)
if payload is None:
raw_payload = part.get_payload()
return raw_payload if isinstance(raw_payload, str) else ""
charset = part.get_content_charset() or 'utf-8'
return payload.decode(charset, errors='ignore')
except Exception:
return ""
def _extract_verification_code(self, text: str) -> Optional[str]:
"""从文本中提取验证码"""
# 清理HTML标签和实体,方便匹配中文邮件模板。
text = html.unescape(text)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text)
# 查找6位数字验证码
# 注意:中文冒号和英文冒号需要分别处理
patterns = [
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|)?\s*(\d{6})',
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)',
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|)?\s*(\d{6})',
r'\b(\d{6})\b', # 候选邮件已经过滤为斗鱼验证码邮件,最后再兜底匹配6位数字。
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
code = match.group(1)
return code
return None
def get_email_config_for_account(email_address: str) -> dict:
"""根据邮箱地址自动配置IMAP服务器"""
configs = {
'bdhg.xyz': {'server': 'mail.bdhg.xyz', 'port': 993},
'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})
+551
View File
@@ -0,0 +1,551 @@
"""斗鱼登录核心模块"""
import re
import json
import time
import requests
from typing import Mapping, Optional, Tuple
from urllib.parse import urlsplit, urlunsplit
from loguru import logger
from core.models import Account
from .crypto import encrypt_password, encrypt_nickname_or_phone
from .email_verifier import EmailVerifier
from .proxy import ProxyManager, get_proxy_manager
from core.geetest import run_solver
from core.geetest.v3_slide.solver import (
_generate_seed, get_w1, get_w2,
)
from core.geetest.common.network import (
get_js_address,
get_c_s,
req_fullpage_validate,
)
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"
LOGIN_REFERER = (
"https://passport.douyu.com/index/login?"
"passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&"
"passport_login_callback=PASSPORT_LOGIN_SUCCESS_CALLBACK&"
"passport_close_callback=PASSPORT_CLOSE_CALLBACK&"
"passport_dp_callback=PASSPORT_DP_CALLBACK&"
"type=login&client_id=1&"
"state=https%3A%2F%2Fwww.douyu.com%2Fdirectory"
)
REQUEST_TIMEOUT = (10, 30)
def __init__(
self,
account: Account,
proxy: Optional[str | Mapping[str, str]] = None,
proxy_api_url: Optional[str] = None,
timeout: tuple[float, float] = REQUEST_TIMEOUT,
max_geetest_retries: int = 5,
):
self.account = account
self.proxy = proxy
self.timeout = timeout
self.max_geetest_retries = max_geetest_retries
self.session = requests.Session()
# 初始化代理管理器
self.proxy_manager = get_proxy_manager(proxy_api_url) if proxy_api_url else None
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': self.LOGIN_REFERER,
'Origin': 'https://passport.douyu.com',
'X-Requested-With': 'XMLHttpRequest',
})
# 设置代理
self._apply_proxy()
def _apply_proxy(self, proxy: str = None) -> None:
"""应用代理到Session"""
if proxy:
# 使用指定的代理
self.session.proxies = {
'http': proxy,
'https': proxy,
}
elif 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
}
else:
# 从代理管理器获取代理
if self.proxy_manager:
new_proxy = self.proxy_manager.get_proxy()
if new_proxy:
self.session.proxies = {
'http': new_proxy,
'https': new_proxy,
}
def _refresh_proxy(self) -> Optional[str]:
"""刷新代理IP"""
if not self.proxy_manager:
return None
new_proxy = self.proxy_manager.get_proxy()
if new_proxy:
self._apply_proxy(new_proxy)
logger.info(f"已切换代理: {new_proxy}")
return new_proxy
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, max_retries: int = 3, **kwargs) -> requests.Response:
"""
统一发送请求,附带分段超时和更明确的错误信息。
代理连接失败时自动重试获取新的代理IP。
"""
timeout = kwargs.pop('timeout', self.timeout)
safe_url = self._safe_url(url)
for attempt in range(max_retries):
started = time.monotonic()
try:
response = self.session.request(method, url, timeout=timeout, **kwargs)
elapsed = time.monotonic() - started
logger.debug(
f"{method.upper()} {safe_url} -> {response.status_code} "
f"({elapsed:.2f}s)"
)
return response
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.ProxyError as exc:
elapsed = time.monotonic() - started
logger.warning(f"代理连接失败,尝试 {attempt + 1}/{max_retries}: {exc}")
if attempt < max_retries - 1:
# 刷新代理
self._refresh_proxy()
time.sleep(1)
continue
raise ConnectionError(
f"{method.upper()} {safe_url} 代理连接失败,已重试 {max_retries}"
) from exc
except requests.RequestException as exc:
elapsed = time.monotonic() - started
raise ConnectionError(
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
) from exc
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️⃣ 极验 fullpage 验证
logger.info("步骤2: 极验 fullpage 验证...")
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: 发送邮箱验证...")
email_sent_at = time.time()
self._send_email_verify(remote_code)
# 5️⃣ IMAP获取验证码
logger.info("步骤5: 获取邮箱验证码...")
verify_code = self._get_email_code(after_timestamp=email_sent_at)
# 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': self.LOGIN_REFERER,
't': str(int(time.time() * 1000)),
'client_id': '1',
'did': '',
'lang': '',
'isMultiAccount': '0',
}
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]:
"""
解决极验 fullpage 验证(带重试机制)
Args:
gt: 极验gt参数
challenge: 极验challenge参数(第一次登录返回的)
Returns:
(validate, seccode)
"""
logger.info("开始极验 fullpage 验证...")
for attempt in range(self.max_geetest_retries):
try:
logger.info(f"极验验证尝试 {attempt + 1}/{self.max_geetest_retries}")
# 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。
str_16 = _generate_seed()
proxies = dict(self.session.proxies)
# 获取JS地址
get_js_address(gt, proxies=proxies)
# 获取第一个w值
w1 = get_w1(gt, challenge, str_16)
# 获取c和s
c, s = get_c_s(gt, challenge, w1, proxies=proxies)
# 获取第二个w值
w2 = get_w2(gt, challenge, c, s, str_16)
# HAR 中 ajax.php 直接返回 validate。
result = req_fullpage_validate(gt, challenge, w2, proxies=proxies)
# 从 fullpage ajax.php 响应中提取 validate
if isinstance(result, dict):
data = result.get('data', {})
validate = data.get('validate', '') or result.get('validate', '')
success = data.get('result') == 'success' or result.get('success') == 1
message = data.get('result') or result.get('message', '')
if success and validate:
seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
return validate, seccode
else:
logger.warning(f"极验验证失败: {message},重试中...")
# 刷新代理
self._refresh_proxy()
time.sleep(1)
continue
else:
validate = str(result)
if validate:
seccode = f"{validate}|jordan"
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
return validate, seccode
except Exception as e:
logger.warning(f"极验验证异常: {e},重试中...")
# 刷新代理
self._refresh_proxy()
# 增加等待时间,避免请求过于频繁
time.sleep(2)
continue
raise ValueError(f"极验验证失败,已重试 {self.max_geetest_retries}")
def _second_login(self, gt: str, challenge: str, validate: str,
seccode: str, code_token: str) -> str:
"""
第二次登录(带极验验证)
Args:
gt: 极验gt参数
challenge: 第一次登录返回的challenge
validate: 极验验证返回的validate
seccode: 极验验证返回的seccode
code_token: 第一次登录返回的code_token
Returns:
remote_code: 用于邮箱验证的code
"""
encrypted_username = encrypt_nickname_or_phone(self.account.username)
encrypted_password = encrypt_password(self.account.password)
# 参考HAR文件中的完整参数
# 注意:geetest_challenge应该使用第一次登录返回的challenge
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',
}
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...")
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, after_timestamp: Optional[float] = None) -> 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,
after_timestamp=after_timestamp,
)
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}")
+115
View File
@@ -0,0 +1,115 @@
"""代理管理模块"""
import re
import requests
from typing import Optional, List
from loguru import logger
class ProxyManager:
"""代理管理器"""
def __init__(self, api_url: str = None):
self.api_url = api_url or "http://api.xiequ.cn/VAD/GetIp.aspx?act=get&uid=106015&vkey=97111DB5379E38E3BC2FF09A1B00A0C7&num=1&time=30&plat=1&re=0&type=0&so=1&ow=1&spl=1&addr=&db=1"
self.current_proxy: Optional[str] = None
def get_proxy(self) -> Optional[str]:
"""
从代理API获取代理IP
Returns:
代理URL,格式: http://ip:port
"""
try:
logger.info("获取代理IP...")
response = requests.get(self.api_url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"代理API响应: {text}")
# 解析IP:Port格式
match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
if match:
ip = match.group(1)
port = match.group(2)
proxy = f"http://{ip}:{port}"
self.current_proxy = proxy
logger.info(f"获取到代理: {proxy}")
return proxy
else:
logger.warning(f"无法解析代理地址: {text}")
return None
except Exception as e:
logger.error(f"获取代理失败: {e}")
return None
def get_proxies_dict(self, proxy: str = None) -> dict:
"""
获取requests使用的proxies字典
Args:
proxy: 代理URL,如果不提供则使用当前代理
Returns:
proxies字典
"""
proxy = proxy or self.current_proxy
if proxy:
return {
'http': proxy,
'https': proxy,
}
return {}
def verify_proxy(self, proxy: str = None) -> bool:
"""
验证代理是否可用
Args:
proxy: 代理URL
Returns:
是否可用
"""
proxy = proxy or self.current_proxy
if not proxy:
return False
try:
response = requests.get(
'https://httpbin.org/ip',
proxies={'http': proxy, 'https': proxy},
timeout=10
)
if response.status_code == 200:
data = response.json()
logger.info(f"代理验证成功,当前IP: {data.get('origin')}")
return True
return False
except Exception as e:
logger.error(f"代理验证失败: {e}")
return False
# 全局代理管理器实例
_proxy_manager: Optional[ProxyManager] = None
def get_proxy_manager(api_url: str = None) -> ProxyManager:
"""获取全局代理管理器实例"""
global _proxy_manager
if _proxy_manager is None:
_proxy_manager = ProxyManager(api_url)
return _proxy_manager
def get_proxy() -> Optional[str]:
"""获取代理URL的便捷函数"""
return get_proxy_manager().get_proxy()
def get_proxies_dict() -> dict:
"""获取proxies字典的便捷函数"""
return get_proxy_manager().get_proxies_dict()
+268
View File
@@ -0,0 +1,268 @@
"""代理IP白名单管理模块"""
import json
import re
from typing import Optional
from urllib.parse import urlencode
import requests
from loguru import logger
class WhitelistManager:
"""协固代理IP白名单管理器"""
MEMO_PREFIX = "douyu_auto"
BASE_URL = "http://op.xiequ.cn/IpWhiteList.aspx"
def __init__(self, uid: str, ukey: str):
self.uid = uid
self.ukey = ukey
self._memo = self.MEMO_PREFIX
@property
def memo(self) -> str:
"""当前机器的固定备注"""
return self._memo
def _build_url(self, **params) -> str:
"""构建请求URL"""
base_params = {
"uid": self.uid,
"ukey": self.ukey,
}
base_params.update(params)
query = urlencode(base_params)
return f"{self.BASE_URL}?{query}"
def get_whitelist_json(self) -> list[dict]:
"""
获取白名单列表(JSON格式)
Returns:
[{"IP": "x.x.x.x", "MEMO": "备注"}, ...]
"""
try:
url = self._build_url(act="getjson")
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
if not text or text == "[]":
return []
data = json.loads(text)
if isinstance(data, list):
return data
# 处理 {"data": [...]} 格式
if isinstance(data, dict):
items = data.get("data", [])
if isinstance(items, list):
return items
return []
except Exception as e:
logger.error(f"获取白名单失败: {e}")
return []
def add_ip(self, ip: str) -> bool:
"""
添加IP到白名单
Args:
ip: 要添加的IP地址
"""
try:
url = self._build_url(act="add", ip=ip, meno=self._memo)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"添加白名单响应: {text}")
# 成功通常返回 "ok" 或类似信息
if "ok" in text.lower() or "success" in text.lower() or "添加成功" in text:
logger.info(f"白名单添加成功: {ip} (备注: {self._memo})")
return True
# 检查是否已存在
if "已存在" in text or "exist" in text.lower():
logger.info(f"白名单已存在: {ip}")
return True
logger.warning(f"白名单添加结果: {text}")
return False
except Exception as e:
logger.error(f"添加白名单失败: {e}")
return False
def delete_ip(self, ip: str) -> bool:
"""
删除指定IP
Args:
ip: 要删除的IP地址
"""
try:
url = self._build_url(act="del", ip=ip)
response = requests.get(url, timeout=10)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"删除白名单响应: {text}")
if "ok" in text.lower() or "success" in text.lower() or "删除成功" in text:
logger.info(f"白名单删除成功: {ip}")
return True
logger.warning(f"白名单删除结果: {text}")
return False
except Exception as e:
logger.error(f"删除白名单失败: {e}")
return False
def get_memo_ip(self) -> Optional[str]:
"""
获取当前备注对应的IP
Returns:
IP地址,如果不存在返回None
"""
records = self.get_whitelist_json()
for record in records:
if record.get("MEMO") == self._memo:
return record.get("IP")
return None
def get_memo_records(self) -> list[dict]:
"""
获取当前备注的所有记录
Returns:
匹配备注的记录列表
"""
records = self.get_whitelist_json()
return [r for r in records if r.get("MEMO") == self._memo]
def sync_ip(self, current_ip: str) -> tuple[bool, str]:
"""
同步白名单IP
检查当前备注是否有记录:
- 如果IP相同,无需操作
- 如果IP不同,删除旧的并添加新的
- 如果IP已存在但备注不同(如手动添加无备注),删除后重新添加
- 如果无记录,添加新的
Args:
current_ip: 当前出口IP
Returns:
(是否成功, 消息)
"""
try:
existing_ip = self.get_memo_ip()
# IP相同,无需更新
if existing_ip == current_ip:
msg = f"白名单IP已是最新的: {current_ip}"
logger.info(msg)
return True, msg
# 有旧记录,先删除
if existing_ip:
logger.info(f"白名单IP变化: {existing_ip} -> {current_ip}")
self.delete_ip(existing_ip)
# IP已存在但备注不同(或备注为空),先删除后重新添加
records = self.get_whitelist_json()
if any(r.get('IP') == current_ip for r in records):
logger.info(f"白名单IP {current_ip} 已存在但备注不同,删除后重新添加")
self.delete_ip(current_ip)
# 添加新IP
if self.add_ip(current_ip):
if existing_ip:
msg = f"白名单IP已更新: {existing_ip} -> {current_ip}"
else:
msg = f"白名单IP已添加: {current_ip}"
logger.info(msg)
return True, msg
return False, "白名单添加失败"
except Exception as e:
msg = f"白名单同步失败: {e}"
logger.error(msg)
return False, msg
def test_connection(self) -> tuple[bool, str]:
"""
测试白名单API连接
Returns:
(是否成功, 消息)
"""
try:
records = self.get_whitelist_json()
count = len(records)
my_records = [r for r in records if r.get("MEMO", "").startswith(self.MEMO_PREFIX)]
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)}"
logger.info(msg)
return True, msg
except Exception as e:
msg = f"连接失败: {e}"
logger.error(msg)
return False, msg
def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
"""
通过代理获取出口IP
Args:
proxy: 代理URL,格式 http://ip:port
Returns:
出口IP地址
"""
targets = [
"https://qifu-api.baidubce.com/ip/local/geo/v1/district",
"https://myip.ipip.net",
"https://4.ipw.cn",
]
proxies = {"http": proxy, "https": proxy}
for url in targets:
try:
response = requests.get(
url,
proxies=proxies,
timeout=6,
headers={"User-Agent": "Mozilla/5.0"},
)
response.raise_for_status()
# 尝试解析IP
content_type = response.headers.get("content-type", "").lower()
if "json" in content_type:
data = response.json()
ip = data.get("ip") or data.get("origin")
if ip:
return str(ip).split(",")[0].strip()
# 从文本中提取IP
text = response.text.strip()
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
if match:
return match.group(1)
except Exception:
continue
return None