彻底优化项目结构,使用 web
This commit is contained in:
@@ -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"]
|
||||
@@ -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')
|
||||
@@ -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})
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user