彻底优化项目结构,使用 web
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""核心业务逻辑"""
|
||||
|
||||
from .models import Account, ProxyConfig
|
||||
|
||||
__all__ = ["Account", "ProxyConfig"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Geetest 验证码求解器"""
|
||||
|
||||
from .v3_slide.solver import run_solver
|
||||
|
||||
__all__ = ["run_solver"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Geetest 公共工具模块"""
|
||||
@@ -0,0 +1,246 @@
|
||||
import random
|
||||
import hashlib
|
||||
from typing import Any
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5, AES
|
||||
|
||||
# 随机产生4个字符组成的字符串
|
||||
def four_random_chart() -> str:
|
||||
return hex(int(65536 * (1 + random.random())))[2:][1:]
|
||||
|
||||
# PKCS#1 v1.5 填充 + RSA 加密
|
||||
def parse_jsbn_bigint(n_obj:dict[Any, int]) -> int:
|
||||
DB = 28
|
||||
DV = 1 << DB
|
||||
t = n_obj['t']
|
||||
|
||||
result = 0
|
||||
for i in range(t):
|
||||
result += n_obj[i] * (DV ** i)
|
||||
|
||||
return result
|
||||
|
||||
def encrypt_data(plaintext:str) -> str:
|
||||
cipher = PKCS1_v1_5.new(public_key)
|
||||
encrypted = cipher.encrypt(plaintext.encode('utf-8'))
|
||||
# 转换为十六进制,确保偶数长度
|
||||
hex_result = encrypted.hex()
|
||||
if len(hex_result) % 2 == 1:
|
||||
hex_result = '0' + hex_result
|
||||
return hex_result
|
||||
|
||||
def RSA_jiami_r(str_16:str) -> str:
|
||||
global public_key
|
||||
# 你的数据
|
||||
n_data = {
|
||||
0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349,
|
||||
4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857,
|
||||
8: 44638621, 9: 93783641, 10: 212253739, 11: 62792472,
|
||||
12: 186688352, 13: 109500232, 14: 182488077, 15: 261196188,
|
||||
16: 26354094, 17: 103248217, 18: 106891695, 19: 165771045,
|
||||
20: 41530993, 21: 263704736, 22: 111785174, 23: 12753611,
|
||||
24: 232116673, 25: 155524985, 26: 218291229, 27: 122452343,
|
||||
28: 248250238, 29: 118739550, 30: 251169095, 31: 129059733,
|
||||
32: 149835464, 33: 5498868, 34: 71719731, 35: 154456417,
|
||||
36: 49635,
|
||||
't': 37, 's': 0
|
||||
}
|
||||
|
||||
e = 65537
|
||||
n = parse_jsbn_bigint(n_data)
|
||||
|
||||
# 构造公钥
|
||||
public_key = RSA.construct((n, e))
|
||||
encrypted = encrypt_data(str_16)
|
||||
return encrypted
|
||||
|
||||
# AES加密
|
||||
# 加密模式: AES-CBC
|
||||
# 密钥长度: 128位
|
||||
# IV: 固定为 "0000000000000000"
|
||||
def parse_string_to_wordarray(text:str) -> list[int]:
|
||||
"""将字符串转换为 WordArray 格式"""
|
||||
length = len(text)
|
||||
words = []
|
||||
|
||||
for i in range(length):
|
||||
# 计算在 words 数组中的索引
|
||||
word_index = i >> 2 # 相当于 i // 4
|
||||
|
||||
# 确保 words 数组足够长
|
||||
while len(words) <= word_index:
|
||||
words.append(0)
|
||||
|
||||
# 获取字符的 ASCII 码
|
||||
char_code = ord(text[i]) & 0xFF
|
||||
|
||||
# 计算位移量
|
||||
shift = 24 - (i % 4) * 8
|
||||
|
||||
# 将字符添加到对应的 word 中
|
||||
words[word_index] |= char_code << shift
|
||||
|
||||
return words
|
||||
def AES_O(plaintext:str, str_16:str) -> list[int]:
|
||||
# 密钥
|
||||
key_words = parse_string_to_wordarray(str_16)
|
||||
key = b''.join(w.to_bytes(4, 'big') for w in key_words)
|
||||
|
||||
# IV
|
||||
iv = b'0000' * 4 # "0000000000000000"
|
||||
|
||||
# 填充(PKCS7)
|
||||
pad_len = 16 - len(plaintext) % 16
|
||||
plaintext_padded = plaintext.encode() + bytes([pad_len] * pad_len)
|
||||
|
||||
# 加密
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
ciphertext = cipher.encrypt(plaintext_padded)
|
||||
|
||||
# 结果是字节数组
|
||||
return list(ciphertext)
|
||||
|
||||
# 自定义base64编码
|
||||
def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
|
||||
"""极验自定义Base64编码"""
|
||||
|
||||
# 配置
|
||||
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()'
|
||||
pad_char = '.'
|
||||
|
||||
# 位掩码 (这些是打乱的)
|
||||
masks = [7274496, 9483264, 19220, 235]
|
||||
mask_bits = 24 # 总位数
|
||||
|
||||
def extract_bits(value, mask):
|
||||
"""根据掩码提取位"""
|
||||
result = 0
|
||||
# 从高位到低位遍历
|
||||
for i in range(mask_bits - 1, -1, -1):
|
||||
# 如果掩码的第i位是1
|
||||
if (mask >> i) & 1:
|
||||
# 提取value的第i位,添加到结果
|
||||
result = (result << 1) | ((value >> i) & 1)
|
||||
return result
|
||||
|
||||
encoded = ""
|
||||
padding = ""
|
||||
length = len(data)
|
||||
|
||||
# 每3字节一组
|
||||
i = 0
|
||||
while i < length:
|
||||
if i + 2 < length:
|
||||
# 完整3字节
|
||||
block = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
|
||||
|
||||
# 使用4个掩码提取
|
||||
encoded += charset[extract_bits(block, masks[0])]
|
||||
encoded += charset[extract_bits(block, masks[1])]
|
||||
encoded += charset[extract_bits(block, masks[2])]
|
||||
encoded += charset[extract_bits(block, masks[3])]
|
||||
|
||||
i += 3
|
||||
else:
|
||||
# 处理剩余
|
||||
remainder = length - i
|
||||
|
||||
if remainder == 2:
|
||||
block = (data[i] << 16) | (data[i + 1] << 8)
|
||||
encoded += charset[extract_bits(block, masks[0])]
|
||||
encoded += charset[extract_bits(block, masks[1])]
|
||||
encoded += charset[extract_bits(block, masks[2])]
|
||||
padding = pad_char
|
||||
elif remainder == 1:
|
||||
block = data[i] << 16
|
||||
encoded += charset[extract_bits(block, masks[0])]
|
||||
encoded += charset[extract_bits(block, masks[1])]
|
||||
padding = pad_char + pad_char
|
||||
|
||||
break
|
||||
|
||||
return {
|
||||
"res": encoded,
|
||||
"end": padding
|
||||
}
|
||||
|
||||
|
||||
def encrypt_string(e:str, t:list[int], n:str) -> str:
|
||||
"""
|
||||
JS加密函数的Python实现
|
||||
|
||||
参数:
|
||||
e: 原始字符串
|
||||
t: 加密参数数组
|
||||
n: 十六进制字符串
|
||||
"""
|
||||
if not t or not n:
|
||||
return e
|
||||
|
||||
o = 0 # 偏移量
|
||||
i = e # 结果字符串
|
||||
s = t[0] # 12
|
||||
a = t[2] # 98
|
||||
_ = t[4] # 43
|
||||
|
||||
# 每次读取2个字符(十六进制)
|
||||
while o < len(n):
|
||||
r = n[o:o + 2] # 取2个字符
|
||||
if len(r) < 2:
|
||||
break
|
||||
o += 2
|
||||
|
||||
# 解析十六进制
|
||||
c = int(r, 16)
|
||||
|
||||
# 转换为字符
|
||||
l = chr(c)
|
||||
|
||||
# 计算插入位置: (s * c^2 + a * c + _) % len(e)
|
||||
u = (s * c * c + a * c + _) % len(e)
|
||||
|
||||
# 在位置u插入字符
|
||||
i = i[:u] + l + i[u:]
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def simple_md5(message:str) -> str:
|
||||
"""
|
||||
简化版MD5实现,结构更清晰
|
||||
"""
|
||||
|
||||
# 使用内置hashlib验证结果
|
||||
def verify_result(msg):
|
||||
return hashlib.md5(msg.encode()).hexdigest()
|
||||
|
||||
# 轮移位常量
|
||||
shifts = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
|
||||
]
|
||||
|
||||
# K常数(与JavaScript版本中的常数对应)
|
||||
K = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
|
||||
]
|
||||
|
||||
# 实际实现...
|
||||
return verify_result(message)
|
||||
@@ -0,0 +1,89 @@
|
||||
import requests
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
REQUEST_TIMEOUT = (3.05, 12)
|
||||
|
||||
|
||||
# 将 Image 转换为 Mat,通过 flag 可以控制颜色
|
||||
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
|
||||
return cv2.cvtColor(np.asarray(img), flag)
|
||||
|
||||
# 识别图片缺口返回滑块距离
|
||||
def shibie(img: Image.Image, slice: Image.Image):
|
||||
# 通过 pilImgToCv2 将图片置灰
|
||||
# 背景图和滑块图都需要做相同处理
|
||||
grayImg = pilImgToCv2(img, cv2.COLOR_BGR2GRAY)
|
||||
# showImg(grayImg) # 可以通过它来看处理后的图片效果
|
||||
graySlice = pilImgToCv2(slice, cv2.COLOR_BGR2GRAY)
|
||||
# 做边缘检测进一步降低干扰,阈值可以自行调整
|
||||
grayImg = cv2.Canny(grayImg, 255, 255)
|
||||
# showImg(grayImg) # 可以通过它来看处理后的图片效果
|
||||
graySlice = cv2.Canny(graySlice, 255, 255)
|
||||
# 通过模板匹配两张图片,找出缺口的位置
|
||||
result = cv2.matchTemplate(grayImg, graySlice, cv2.TM_CCOEFF_NORMED)
|
||||
maxLoc = cv2.minMaxLoc(result)[3]
|
||||
# 匹配出来的滑动距离
|
||||
distance = maxLoc[0]
|
||||
# 下面的逻辑是在图片画出一个矩形框来标记匹配到的位置,可以直观的看到匹配结果,去掉也可以的
|
||||
sliceHeight, sliceWidth = graySlice.shape[:2]
|
||||
# 左上角
|
||||
x, y = maxLoc
|
||||
# 右下角
|
||||
x2, y2 = x + sliceWidth, y + sliceHeight
|
||||
resultBg = pilImgToCv2(img, cv2.COLOR_RGB2BGR)
|
||||
cv2.rectangle(resultBg, (x, y), (x2, y2), (0, 0, 255), 2)
|
||||
# showImg(resultBg) # 可以通过它来看处理后的图片效果
|
||||
return distance
|
||||
|
||||
# 还原图片
|
||||
def restore_geetest_image(input_path:str, output_path:str) -> None:
|
||||
"""
|
||||
还原极验打乱的验证码图像
|
||||
"""
|
||||
Ut = [
|
||||
39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29,
|
||||
27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15,
|
||||
21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17
|
||||
]
|
||||
|
||||
# 打开混淆图像
|
||||
img = Image.open(input_path)
|
||||
new_img = Image.new("RGB", (260,160))
|
||||
r = 160
|
||||
for _ in range(len(Ut)):
|
||||
a = r / 2
|
||||
c = Ut[_] % 26 * 12 + 1
|
||||
u = 80 if Ut[_] > 25 else 0
|
||||
l = img.crop(box=(c, u, c + 10, u + 80))
|
||||
new_img.paste(l, box=(_ % 26 * 10, 80 if _ > 25 else 0))
|
||||
|
||||
new_img.save(output_path)
|
||||
print(f"图像已还原并保存到: {output_path}")
|
||||
|
||||
# 下载图片
|
||||
def download_picture(bg:str, fullbg:str, slice:str) -> int:
|
||||
for i in range(3):
|
||||
if i == 0:
|
||||
url = "https://static.geetest.com/"+bg
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
with open("bg.jpg", "wb") as f:
|
||||
f.write(response.content)
|
||||
restore_geetest_image("bg.jpg", "bg.jpg")
|
||||
else:
|
||||
if i == 1:
|
||||
url = "https://static.geetest.com/" + fullbg
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
with open("fullbg.jpg", "wb") as f:
|
||||
f.write(response.content)
|
||||
restore_geetest_image("fullbg.jpg", "fullbg.jpg")
|
||||
else:
|
||||
url = "https://static.geetest.com/" + slice
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
with open("slice.jpg", "wb") as f:
|
||||
f.write(response.content)
|
||||
return shibie(Image.open('fullbg.jpg'), Image.open('slice.jpg'))
|
||||
@@ -0,0 +1,338 @@
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from typing import Mapping, Optional, Tuple
|
||||
|
||||
REQUEST_TIMEOUT = (3.05, 12)
|
||||
PASSPORT_REFERER = "https://passport.douyu.com/"
|
||||
|
||||
|
||||
def _get(
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
proxies: Optional[Mapping[str, str]] = None,
|
||||
) -> requests.Response:
|
||||
"""发送极验 GET 请求,确保使用同一个代理出口。"""
|
||||
return requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
proxies=dict(proxies or {}),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _parse_jsonp_response(response: requests.Response, source: str) -> dict:
|
||||
"""解析极验 JSONP 响应。"""
|
||||
response.raise_for_status()
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
if not match:
|
||||
raise ValueError(f"{source} 无法解析 JSONP 响应")
|
||||
|
||||
data = json.loads(match.group(1))
|
||||
if data.get('status') == 'error':
|
||||
raise ValueError(f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}")
|
||||
return data
|
||||
|
||||
|
||||
def _parse_json_response(response: requests.Response, source: str) -> dict:
|
||||
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 get_challenge_gt_bak() -> Tuple[str, str]:
|
||||
headers = {
|
||||
'accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'priority': 'u=1, i',
|
||||
'referer': 'https://demos.geetest.com/slide-float.html',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
}
|
||||
|
||||
params = {
|
||||
't': str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://demos.geetest.com/gt/register-slide',
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
data = _parse_json_response(response, "极验 demo register-slide")
|
||||
return data["gt"], data["challenge"]
|
||||
|
||||
|
||||
def get_challenge_gt() -> Tuple[str, str]:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'referer': 'https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status',
|
||||
}
|
||||
|
||||
data = {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==',
|
||||
'password': '57219dddec71c31b7647683fa5306103',
|
||||
'biz_type': '1',
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
'https://passport.douyu.com/wgapi/member/passport/login',
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=10,
|
||||
)
|
||||
payload = _parse_json_response(response, "斗鱼登录接口")
|
||||
|
||||
try:
|
||||
code_data = payload["data"]["geetest"]["code_data"]
|
||||
return code_data["gt"], code_data["challenge"]
|
||||
except KeyError as exc:
|
||||
preview = json.dumps(payload, ensure_ascii=False)[:300]
|
||||
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
|
||||
|
||||
|
||||
|
||||
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict:
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'referer': PASSPORT_REFERER,
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'script',
|
||||
'sec-fetch-mode': 'no-cors',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
'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',
|
||||
}
|
||||
|
||||
params = {
|
||||
'gt': gt,
|
||||
'callback': 'geetest_' + str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = _get(
|
||||
'https://api.geetest.com/gettype.php',
|
||||
params=params,
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
)
|
||||
|
||||
return _parse_jsonp_response(response, "极验 gettype")
|
||||
|
||||
def get_c_s(
|
||||
gt: str,
|
||||
challenge: str,
|
||||
w: str,
|
||||
proxies: Optional[Mapping[str, str]] = None,
|
||||
) -> Tuple[list[int], str]:
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'referer': PASSPORT_REFERER,
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'script',
|
||||
'sec-fetch-mode': 'no-cors',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
'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',
|
||||
}
|
||||
|
||||
response = _get(
|
||||
'https://api.geetest.com/get.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str(
|
||||
int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
)
|
||||
data = _parse_jsonp_response(response, "极验 get.php")
|
||||
return data['data']['c'], data['data']['s']
|
||||
|
||||
|
||||
def req_fullpage_validate(
|
||||
gt: str,
|
||||
challenge: str,
|
||||
w: str,
|
||||
proxies: Optional[Mapping[str, str]] = None,
|
||||
) -> dict:
|
||||
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': PASSPORT_REFERER,
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'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',
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
}
|
||||
|
||||
response = _get(
|
||||
'https://api.geetest.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str(
|
||||
int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
)
|
||||
return _parse_jsonp_response(response, "极验 fullpage ajax.php")
|
||||
|
||||
def req_slide(gt:str, challenge:str, w2:str) -> None:
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Sec-Fetch-Storage-Access': 'active',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://api.geevisit.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w2 + '&callback=geetest_' + str(
|
||||
int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def get_picture(gt:str, challenge:str) -> tuple[str, str, list[int], str, str, str]:
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Sec-Fetch-Storage-Access': 'active',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
}
|
||||
params = {
|
||||
'is_next': 'true',
|
||||
'type': 'slide3',
|
||||
'gt': gt,
|
||||
'challenge': challenge,
|
||||
'lang': 'zh-cn',
|
||||
'https': 'true',
|
||||
'protocol': 'https://',
|
||||
'offline': 'false',
|
||||
'product': 'embed',
|
||||
'api_server': 'api.geevisit.com',
|
||||
'isPC': 'true',
|
||||
'autoReset': 'true',
|
||||
'width': '100%',
|
||||
'callback': 'geetest_'+str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://api.geevisit.com/get.php',
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(response.text)
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
data = json.loads(json_str)
|
||||
|
||||
# 检查验证码类型
|
||||
if 'data' in data and isinstance(data['data'], dict):
|
||||
# 新版极验格式
|
||||
inner_data = data['data']
|
||||
return (
|
||||
inner_data.get('bg', ''),
|
||||
inner_data.get('fullbg', ''),
|
||||
inner_data.get('c', []),
|
||||
inner_data.get('s', ''),
|
||||
inner_data.get('slice', ''),
|
||||
inner_data.get('challenge', '')
|
||||
)
|
||||
else:
|
||||
# 旧版极验格式
|
||||
return (
|
||||
data.get('bg', ''),
|
||||
data.get('fullbg', ''),
|
||||
data.get('c', []),
|
||||
data.get('s', ''),
|
||||
data.get('slice', ''),
|
||||
data.get('challenge', '')
|
||||
)
|
||||
else:
|
||||
raise ValueError("无法解析 JSONP 响应")
|
||||
|
||||
def req_end(gt:str, challenge:str, w:str) -> dict:
|
||||
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Sec-Fetch-Storage-Access': 'active',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://api.geevisit.com/ajax.php?gt='+gt+'&challenge='+challenge+'&lang=zh-cn&%24_BCm=0&client_type=web&w='+w+'&callback=geetest_'+str(int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(response.text)
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
data = json.loads(json_str)
|
||||
print(f"极验验证响应: {data}")
|
||||
|
||||
# 检查验证是否成功
|
||||
if data.get('success') == 1:
|
||||
return data
|
||||
else:
|
||||
# 如果验证失败,返回错误信息
|
||||
return {
|
||||
'success': 0,
|
||||
'message': data.get('message', '验证失败'),
|
||||
'validate': ''
|
||||
}
|
||||
else:
|
||||
raise ValueError("无法解析 JSONP 响应")
|
||||
@@ -0,0 +1,113 @@
|
||||
import time
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[str, int]:
|
||||
"""
|
||||
生成伪造的浏览器性能时间戳数据
|
||||
|
||||
Args:
|
||||
base_time: 基准时间戳(毫秒),默认使用当前时间
|
||||
|
||||
Returns:
|
||||
dict: 包含所有性能时间戳的字典
|
||||
"""
|
||||
if base_time is None:
|
||||
base_time = int(time.time() * 1000)
|
||||
|
||||
# 定义合理的时间间隔范围(毫秒)
|
||||
intervals = {
|
||||
'fetch': random.randint(1, 2),
|
||||
'domain_lookup_start': random.randint(3, 5),
|
||||
'domain_lookup': random.randint(5, 15),
|
||||
'connect': random.randint(50, 150),
|
||||
'ssl_offset': random.randint(30, 50),
|
||||
'request': random.randint(1, 5),
|
||||
'response': random.randint(20, 100),
|
||||
'response_end': random.randint(1, 3),
|
||||
'unload_start': random.randint(1, 3),
|
||||
'unload': random.randint(1, 5),
|
||||
'dom_loading': random.randint(1, 3),
|
||||
'dom_interactive': random.randint(50, 200),
|
||||
'dom_content_loaded': random.randint(1, 3),
|
||||
'load_event': random.randint(0, 5)
|
||||
}
|
||||
|
||||
timing = {}
|
||||
|
||||
# 按照时间顺序构建
|
||||
timing['navigationStart'] = base_time
|
||||
timing['fetchStart'] = timing['navigationStart'] + intervals['fetch']
|
||||
timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start']
|
||||
timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup']
|
||||
|
||||
timing['connectStart'] = timing['domainLookupEnd']
|
||||
timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset']
|
||||
timing['connectEnd'] = timing['connectStart'] + intervals['connect']
|
||||
|
||||
timing['requestStart'] = timing['connectEnd'] + intervals['request']
|
||||
timing['responseStart'] = timing['requestStart'] + intervals['response']
|
||||
timing['responseEnd'] = timing['responseStart'] + intervals['response_end']
|
||||
|
||||
timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start']
|
||||
timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload']
|
||||
|
||||
timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading']
|
||||
timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive']
|
||||
timing['domContentLoadedEventStart'] = timing['domInteractive']
|
||||
timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded']
|
||||
timing['domComplete'] = timing['domContentLoadedEventEnd']
|
||||
timing['loadEventStart'] = timing['domComplete']
|
||||
timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event']
|
||||
|
||||
# 无重定向的情况
|
||||
timing['redirectStart'] = 0
|
||||
timing['redirectEnd'] = 0
|
||||
|
||||
return timing
|
||||
|
||||
def __ease_out_expo(sep):
|
||||
'''
|
||||
轨迹相关操作
|
||||
'''
|
||||
if sep == 1:
|
||||
return 1
|
||||
else:
|
||||
return 1 - pow(2, -10 * sep)
|
||||
def get_slide_track(distance):
|
||||
"""
|
||||
根据滑动距离生成滑动轨迹
|
||||
:param distance: 需要滑动的距离
|
||||
:return: 滑动轨迹<type 'list'>: [[x,y,t], ...]
|
||||
x: 已滑动的横向距离
|
||||
y: 已滑动的纵向距离, 除起点外, 均为0
|
||||
t: 滑动过程消耗的时间, 单位: 毫秒
|
||||
"""
|
||||
|
||||
if not isinstance(distance, int) or distance < 0:
|
||||
raise ValueError(f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}")
|
||||
# 初始化轨迹列表
|
||||
slide_track = [
|
||||
[random.randint(-50, -10), random.randint(-50, -10), 0],
|
||||
[0, 0, 0],
|
||||
]
|
||||
# 共记录count次滑块位置信息
|
||||
count = 10 + int(distance / 2)
|
||||
# 初始化滑动时间
|
||||
t = random.randint(50, 100)
|
||||
# 记录上一次滑动的距离
|
||||
_x = 0
|
||||
_y = 0
|
||||
for i in range(count):
|
||||
# 已滑动的横向距离
|
||||
x = round(__ease_out_expo(i / count) * distance)
|
||||
# y = round(__ease_out_expo(i / count) * 14)
|
||||
# 滑动过程消耗的时间
|
||||
t += random.randint(10, 50)
|
||||
if x == _x:
|
||||
continue
|
||||
slide_track.append([x, _y, t])
|
||||
_x = x
|
||||
slide_track.append(slide_track[-1])
|
||||
return slide_track, slide_track[-1][2]
|
||||
@@ -0,0 +1,628 @@
|
||||
import random
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# 生成类人的鼠标轨迹
|
||||
def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int, start_time:int) -> list[Any]:
|
||||
"""生成类人的鼠标轨迹"""
|
||||
trajectory = []
|
||||
|
||||
# 计算距离和步数
|
||||
distance = ((end_x - start_x) ** 2 + (end_y - start_y) ** 2) ** 0.5
|
||||
steps = int(distance / 2) + random.randint(5, 15) # 根据距离动态调整步数
|
||||
|
||||
current_time = start_time
|
||||
current_x, current_y = start_x, start_y
|
||||
|
||||
for i in range(steps):
|
||||
# 使用贝塞尔曲线模拟自然移动
|
||||
progress = i / steps
|
||||
|
||||
# 添加缓动函数(开始快,中间慢,结束快)
|
||||
if progress < 0.3:
|
||||
eased = progress / 0.3 * 0.2
|
||||
elif progress < 0.7:
|
||||
eased = 0.2 + (progress - 0.3) / 0.4 * 0.5
|
||||
else:
|
||||
eased = 0.7 + (progress - 0.7) / 0.3 * 0.3
|
||||
|
||||
# 计算目标位置(添加随机抖动)
|
||||
target_x = start_x + (end_x - start_x) * eased
|
||||
target_y = start_y + (end_y - start_y) * eased
|
||||
|
||||
# 添加微小的随机偏移(模拟手抖)
|
||||
jitter_x = random.uniform(-0.5, 0.5)
|
||||
jitter_y = random.uniform(-0.5, 0.5)
|
||||
|
||||
current_x = int(target_x + jitter_x)
|
||||
current_y = int(target_y + jitter_y)
|
||||
|
||||
# 随机时间间隔(3-25ms,符合人类反应)
|
||||
time_delta = random.choices(
|
||||
[3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 24],
|
||||
weights=[5, 8, 10, 12, 10, 8, 5, 3, 2, 1, 1]
|
||||
)[0]
|
||||
current_time += time_delta
|
||||
|
||||
trajectory.append([
|
||||
"move",
|
||||
current_x,
|
||||
current_y,
|
||||
current_time,
|
||||
"pointermove"
|
||||
])
|
||||
|
||||
# 偶尔在同一位置停留(模拟视觉确认)
|
||||
if random.random() < 0.15:
|
||||
trajectory.append([
|
||||
"move",
|
||||
current_x,
|
||||
current_y,
|
||||
current_time + random.randint(5, 15),
|
||||
"pointermove"
|
||||
])
|
||||
current_time += random.randint(5, 15)
|
||||
|
||||
# 到达目标后的悬停
|
||||
hover_time = random.randint(50, 150)
|
||||
for _ in range(random.randint(2, 5)):
|
||||
current_time += random.randint(8, 25)
|
||||
trajectory.append([
|
||||
"move",
|
||||
end_x + random.randint(-1, 1),
|
||||
end_y + random.randint(-1, 1),
|
||||
current_time,
|
||||
"pointermove"
|
||||
])
|
||||
|
||||
current_time += hover_time
|
||||
|
||||
# 点击事件
|
||||
trajectory.append([
|
||||
"down",
|
||||
end_x,
|
||||
end_y,
|
||||
current_time,
|
||||
"pointerdown"
|
||||
])
|
||||
|
||||
trajectory.append([
|
||||
"focus",
|
||||
current_time + 1
|
||||
])
|
||||
|
||||
click_duration = random.randint(80, 130)
|
||||
trajectory.append([
|
||||
"up",
|
||||
end_x,
|
||||
end_y,
|
||||
current_time + click_duration,
|
||||
"pointerup"
|
||||
])
|
||||
|
||||
return trajectory
|
||||
|
||||
# 处理原始轨迹数组
|
||||
def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None) -> dict[str,Any]:
|
||||
"""
|
||||
处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差
|
||||
|
||||
参数:
|
||||
events: 原始事件数据列表
|
||||
max_records: 最大保留记录数(None表示保留全部)
|
||||
|
||||
返回:
|
||||
处理后的事件列表
|
||||
"""
|
||||
if not events or len(events) == 0:
|
||||
return []
|
||||
|
||||
# 初始化变量
|
||||
prev_x = 0 # 上一个X坐标
|
||||
prev_y = 0 # 上一个Y坐标
|
||||
prev_time = 0 # 上一个时间戳
|
||||
result = [] # 结果数组
|
||||
first_event = None # 第一个事件
|
||||
last_event = None # 最后一个事件
|
||||
|
||||
# 移动类事件(包含坐标信息)
|
||||
MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"]
|
||||
|
||||
# 点击类事件(仅时间信息)
|
||||
CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup",
|
||||
"touchstart", "touchend", "pointerdown", "pointerup"]
|
||||
|
||||
# 特殊事件(仅时间信息)
|
||||
TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"]
|
||||
|
||||
# 如果设置了最大记录数,只处理最后N条
|
||||
start_index = 0
|
||||
if max_records and len(events) > max_records:
|
||||
start_index = len(events) - max_records
|
||||
|
||||
# 遍历事件
|
||||
for i in range(start_index, len(events)):
|
||||
event = events[i]
|
||||
event_type = event[0]
|
||||
|
||||
# 处理移动类事件(包含X, Y坐标)
|
||||
if event_type in MOVE_EVENTS:
|
||||
x = event[1]
|
||||
y = event[2]
|
||||
timestamp = event[3]
|
||||
|
||||
# 记录第一个和最后一个事件
|
||||
if first_event is None:
|
||||
first_event = event
|
||||
last_event = event
|
||||
|
||||
# 计算相对坐标差值
|
||||
delta_x = x - prev_x
|
||||
delta_y = y - prev_y
|
||||
|
||||
# 计算时间差
|
||||
if prev_time == 0:
|
||||
time_diff = 0 # 第一个事件时间差为0
|
||||
else:
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组
|
||||
result.append([
|
||||
event_type,
|
||||
[delta_x, delta_y],
|
||||
time_diff
|
||||
])
|
||||
|
||||
# 更新上一次的值
|
||||
prev_x = x
|
||||
prev_y = y
|
||||
prev_time = timestamp
|
||||
|
||||
# 处理点击类事件(包含坐标但只记录时间差)
|
||||
elif event_type in CLICK_EVENTS:
|
||||
timestamp = event[3] if len(event) > 3 else event[1]
|
||||
|
||||
# 计算时间差
|
||||
if prev_time == 0:
|
||||
time_diff = 0
|
||||
else:
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组(坐标差为[0,0])
|
||||
result.append([
|
||||
event_type,
|
||||
[0, 0],
|
||||
time_diff
|
||||
])
|
||||
|
||||
prev_time = timestamp
|
||||
|
||||
# 处理仅时间类事件(如focus)
|
||||
elif event_type in TIME_ONLY_EVENTS:
|
||||
timestamp = event[1]
|
||||
|
||||
# 计算时间差
|
||||
if prev_time == 0:
|
||||
time_diff = 0
|
||||
else:
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组(仅包含时间差)
|
||||
result.append([
|
||||
event_type,
|
||||
time_diff
|
||||
])
|
||||
|
||||
prev_time = timestamp
|
||||
|
||||
return {
|
||||
"data": result,
|
||||
"first_event": first_event,
|
||||
"last_event": last_event,
|
||||
"total_events": len(result)
|
||||
}
|
||||
|
||||
def compress_trajectory(e:list[Any]) -> str:
|
||||
"""
|
||||
压缩轨迹数据的完整实现
|
||||
|
||||
Args:
|
||||
e: 轨迹数据列表
|
||||
|
||||
Returns:
|
||||
压缩后的 Base64 编码字符串
|
||||
"""
|
||||
# 事件类型映射
|
||||
p = {
|
||||
"move": 0,
|
||||
"down": 1,
|
||||
"up": 2,
|
||||
"scroll": 3,
|
||||
"focus": 4,
|
||||
"blur": 5,
|
||||
"unload": 6,
|
||||
"unknown": 7
|
||||
}
|
||||
|
||||
def h(e, t):
|
||||
"""
|
||||
填充二进制字符串
|
||||
|
||||
Args:
|
||||
e: 数值
|
||||
t: 目标长度
|
||||
|
||||
Returns:
|
||||
填充后的二进制字符串
|
||||
"""
|
||||
n = bin(e)[2:] # 转为二进制并去掉 '0b' 前缀
|
||||
r = ""
|
||||
o = len(n) + 1
|
||||
while o <= t:
|
||||
r += "0"
|
||||
o += 1
|
||||
return r + n
|
||||
|
||||
def f(e):
|
||||
"""
|
||||
压缩事件类型数组
|
||||
|
||||
Args:
|
||||
e: 事件类型列表
|
||||
|
||||
Returns:
|
||||
压缩后的二进制字符串
|
||||
"""
|
||||
t = []
|
||||
n = len(e)
|
||||
r = 0
|
||||
|
||||
# 游程编码(Run-Length Encoding)
|
||||
while r < n:
|
||||
o = e[r]
|
||||
i = 0
|
||||
while True:
|
||||
if 16 <= i:
|
||||
break
|
||||
s = r + i + 1
|
||||
if n <= s:
|
||||
break
|
||||
if e[s] != o:
|
||||
break
|
||||
i += 1
|
||||
|
||||
r = r + 1 + i
|
||||
a = p[o]
|
||||
if i != 0:
|
||||
t.append(8 | a) # 设置重复标志位
|
||||
t.append(i - 1)
|
||||
else:
|
||||
t.append(a)
|
||||
|
||||
# 编码长度信息
|
||||
_ = h(32768 | n, 16)
|
||||
c = ""
|
||||
for l in range(len(t)):
|
||||
c += h(t[l], 4)
|
||||
|
||||
return _ + c
|
||||
|
||||
def c(e, t):
|
||||
"""
|
||||
对数组每个元素应用函数
|
||||
|
||||
Args:
|
||||
e: 输入数组
|
||||
t: 转换函数
|
||||
|
||||
Returns:
|
||||
转换后的数组
|
||||
"""
|
||||
n = []
|
||||
for r in range(len(e)):
|
||||
n.append(t(e[r]))
|
||||
return n
|
||||
|
||||
def d(e, t):
|
||||
"""
|
||||
压缩数值数组(游程编码 + 变长编码)
|
||||
|
||||
Args:
|
||||
e: 数值数组
|
||||
t: 是否为坐标数据(需要过滤符号位)
|
||||
|
||||
Returns:
|
||||
压缩后的二进制字符串
|
||||
"""
|
||||
|
||||
# 第一步:限制数值范围到 [-32767, 32767]
|
||||
def limit_value(val):
|
||||
limit = 32767
|
||||
return max(-limit, min(limit, val))
|
||||
|
||||
e = c(e, limit_value)
|
||||
|
||||
# 第二步:游程编码(Run-Length Encoding)
|
||||
n = len(e)
|
||||
r = 0
|
||||
o = []
|
||||
|
||||
while r < n:
|
||||
i = 1
|
||||
s = e[r]
|
||||
a = abs(s)
|
||||
|
||||
# 统计连续相同的值
|
||||
while r + i < n and e[r + i] == s and a < 127 and i < 127:
|
||||
i += 1
|
||||
|
||||
if i > 1:
|
||||
# 重复值编码格式:
|
||||
# 位15: 符号标志 (1=负数49152, 0=正数32768)
|
||||
# 位14-7: 重复次数 (i)
|
||||
# 位6-0: 绝对值 (a)
|
||||
o.append((49152 if s < 0 else 32768) | (i << 7) | a)
|
||||
else:
|
||||
o.append(s)
|
||||
|
||||
r += i
|
||||
|
||||
e = o
|
||||
|
||||
# 第三步:变长编码
|
||||
r = [] # 存储每个数字的十六进制位数
|
||||
o = [] # 存储实际数值
|
||||
|
||||
for val in e:
|
||||
# 计算需要多少个十六进制位(每位4 bit)
|
||||
if val == 0:
|
||||
bits = 1
|
||||
else:
|
||||
# 方法1:使用对数(与原JS一致)
|
||||
bits = math.ceil(math.log(abs(val) + 1) / math.log(16))
|
||||
# 方法2:直接计算(更快)
|
||||
# bits = len(format(abs(val), 'x'))
|
||||
|
||||
bits = max(1, bits)
|
||||
|
||||
r.append(h(bits - 1, 2)) # 2位十六进制存储位数信息
|
||||
o.append(h(abs(val), 4 * bits)) # 实际值
|
||||
|
||||
i = "".join(r) # 元数据
|
||||
s = "".join(o) # 数据
|
||||
|
||||
# 第四步:符号位编码(仅用于坐标数据)
|
||||
if t:
|
||||
# 过滤掉:0值 和 已编码符号的压缩值(位15=1)
|
||||
filtered = [x for x in e if x != 0 and (x >> 15) != 1]
|
||||
n = "".join(["1" if x < 0 else "0" for x in filtered])
|
||||
else:
|
||||
n = ""
|
||||
|
||||
# 最终格式:[头部16位][元数据][数据][符号位]
|
||||
# 头部:最高位置1 + 数组长度
|
||||
return h(32768 | len(e), 16) + i + s + n
|
||||
|
||||
# 主函数:数据分离
|
||||
t = [] # 事件类型
|
||||
n = [] # 时间差
|
||||
r = [] # X坐标
|
||||
o = [] # Y坐标
|
||||
|
||||
for i in range(len(e)):
|
||||
a = e[i]
|
||||
length = len(a)
|
||||
|
||||
t.append(a[0])
|
||||
n.append(a[1] if length == 2 else a[2])
|
||||
|
||||
if length == 3:
|
||||
r.append(a[1][0])
|
||||
o.append(a[1][1])
|
||||
|
||||
# 压缩各部分
|
||||
c_str = f(t) + d(n, False) + d(r, True) + d(o, True)
|
||||
|
||||
# 填充到6的倍数
|
||||
l = len(c_str)
|
||||
if l % 6 != 0:
|
||||
c_str += h(0, 6 - l % 6)
|
||||
|
||||
# Base64编码
|
||||
def u(e):
|
||||
"""Base64编码"""
|
||||
t = ""
|
||||
n = len(e) // 6
|
||||
base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
||||
|
||||
for r in range(n):
|
||||
# 每次取6位二进制
|
||||
binary_str = e[6 * r: 6 * (r + 1)]
|
||||
index = int(binary_str, 2)
|
||||
t += base64_chars[index]
|
||||
|
||||
return t
|
||||
|
||||
return u(c_str)
|
||||
|
||||
class TrajectoryEncoder:
|
||||
def __init__(self) -> None:
|
||||
self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
|
||||
self.BASE = len(self.CHARSET) # 64
|
||||
self.DIRECTION_CHARS = "stuvwxyz~"
|
||||
self.DIRECTION_PATTERNS = [
|
||||
[1, 0], # s
|
||||
[2, 0], # t
|
||||
[1, -1], # u
|
||||
[1, 1], # v
|
||||
[0, 1], # w
|
||||
[0, -1], # x
|
||||
[3, 0], # y
|
||||
[2, -1], # z
|
||||
[2, 1] # ~
|
||||
]
|
||||
|
||||
def encode_number(self, num):
|
||||
"""编码单个数值为64进制"""
|
||||
abs_num = abs(num)
|
||||
high_index = abs_num // self.BASE
|
||||
low_index = abs_num % self.BASE
|
||||
|
||||
result = ""
|
||||
|
||||
# 负数标记
|
||||
if num < 0:
|
||||
result += "!"
|
||||
|
||||
# 高位(当值>=64时)
|
||||
if high_index > 0 and high_index < self.BASE:
|
||||
result += "$"
|
||||
result += self.CHARSET[high_index]
|
||||
|
||||
# 低位
|
||||
result += self.CHARSET[low_index]
|
||||
|
||||
return result
|
||||
|
||||
def compress_trajectory(self, points):
|
||||
"""压缩轨迹:计算相邻点差值"""
|
||||
compressed = []
|
||||
time_accumulator = 0
|
||||
|
||||
for i in range(len(points) - 1):
|
||||
dx = points[i + 1][0] - points[i][0] # 不要用abs
|
||||
dy = points[i + 1][1] - points[i][1] # 不要用abs
|
||||
dt = abs(points[i + 1][2] - points[i][2]) # 时间可以用abs
|
||||
|
||||
# 跳过完全相同的点
|
||||
if dx == 0 and dy == 0 and dt == 0:
|
||||
continue
|
||||
|
||||
# 位置不变只累积时间
|
||||
if dx == 0 and dy == 0:
|
||||
time_accumulator += dt
|
||||
else:
|
||||
compressed.append([dx, dy, dt + time_accumulator])
|
||||
time_accumulator = 0
|
||||
|
||||
# 处理剩余时间
|
||||
if time_accumulator != 0:
|
||||
compressed.append([0, 0, time_accumulator])
|
||||
|
||||
return compressed
|
||||
|
||||
def get_direction_code(self, dx, dy):
|
||||
"""识别是否匹配方向模式"""
|
||||
for i, pattern in enumerate(self.DIRECTION_PATTERNS):
|
||||
if dx == pattern[0] and dy == pattern[1]:
|
||||
return self.DIRECTION_CHARS[i]
|
||||
return None
|
||||
|
||||
def encode(self, trajectory):
|
||||
"""
|
||||
编码轨迹
|
||||
trajectory: [[x, y, timestamp], ...]
|
||||
返回: 编码后的字符串
|
||||
"""
|
||||
compressed = self.compress_trajectory(trajectory)
|
||||
|
||||
x_encoded = []
|
||||
y_encoded = []
|
||||
t_encoded = []
|
||||
|
||||
for dx, dy, dt in compressed:
|
||||
direction_code = self.get_direction_code(dx, dy)
|
||||
|
||||
if direction_code:
|
||||
# 匹配到方向模式,只记录y
|
||||
y_encoded.append(direction_code)
|
||||
else:
|
||||
# 不匹配,完整编码x和y
|
||||
x_encoded.append(self.encode_number(dx))
|
||||
y_encoded.append(self.encode_number(dy))
|
||||
|
||||
# 时间总是编码
|
||||
t_encoded.append(self.encode_number(dt))
|
||||
|
||||
# 拼接:x坐标 !! y坐标 !! 时间戳
|
||||
return "".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded)
|
||||
|
||||
def encrypt_string(self,e, t, n):
|
||||
"""
|
||||
JS加密函数的Python实现
|
||||
|
||||
参数:
|
||||
e: 原始字符串
|
||||
t: 加密参数数组
|
||||
n: 十六进制字符串
|
||||
"""
|
||||
if not t or not n:
|
||||
return e
|
||||
|
||||
o = 0 # 偏移量
|
||||
i = e # 结果字符串
|
||||
s = t[0] # 12
|
||||
a = t[2] # 98
|
||||
_ = t[4] # 43
|
||||
|
||||
# 每次读取2个字符(十六进制)
|
||||
while o < len(n):
|
||||
r = n[o:o + 2] # 取2个字符
|
||||
if len(r) < 2:
|
||||
break
|
||||
o += 2
|
||||
|
||||
# 解析十六进制
|
||||
c = int(r, 16)
|
||||
|
||||
# 转换为字符
|
||||
l = chr(c)
|
||||
|
||||
# 计算插入位置: (s * c^2 + a * c + _) % len(e)
|
||||
u = (s * c * c + a * c + _) % len(e)
|
||||
|
||||
# 在位置u插入字符
|
||||
i = i[:u] + l + i[u:]
|
||||
|
||||
return i
|
||||
|
||||
def H(t:int, e:str) -> str:
|
||||
# 解析后缀
|
||||
n = e[-2:]
|
||||
r = []
|
||||
for char in n:
|
||||
o = ord(char)
|
||||
r.append(o - 87 if o > 57 else o - 48)
|
||||
n = 36 * r[0] + r[1]
|
||||
|
||||
# 计算目标值
|
||||
a = round(t) + n
|
||||
|
||||
# 构建字符池
|
||||
_ = [[], [], [], [], []]
|
||||
c = {}
|
||||
u = 0
|
||||
for char in e[:-2]:
|
||||
if char not in c:
|
||||
c[char] = 1
|
||||
_[u].append(char)
|
||||
u = (u + 1) % 5
|
||||
|
||||
# 生成结果
|
||||
f = a
|
||||
d = 4
|
||||
p = ""
|
||||
g = [1, 2, 5, 10, 50]
|
||||
|
||||
while f > 0:
|
||||
if f >= g[d]:
|
||||
h = int(random.random() * len(_[d]))
|
||||
p += _[d][h]
|
||||
f -= g[d]
|
||||
else:
|
||||
_.pop(d)
|
||||
g.pop(d)
|
||||
d -= 1
|
||||
|
||||
return p
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Geetest v3 滑块验证码求解器"""
|
||||
|
||||
from .solver import run_solver
|
||||
|
||||
__all__ = ["run_solver"]
|
||||
@@ -0,0 +1,159 @@
|
||||
import time
|
||||
import random
|
||||
import json
|
||||
from core.geetest.common.trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \
|
||||
H
|
||||
from core.geetest.common.crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5
|
||||
from core.geetest.common.imaging import download_picture
|
||||
from core.geetest.common.network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end
|
||||
from core.geetest.common.performance import generate_fake_performance_timing, get_slide_track
|
||||
|
||||
|
||||
def _generate_seed() -> str:
|
||||
return four_random_chart() + four_random_chart() + four_random_chart() + four_random_chart()
|
||||
|
||||
def get_w1(gt:str, challenge:str, str_16:str) -> str:
|
||||
r = RSA_jiami_r(str_16)
|
||||
plaintext = '{"gt":"' + gt + '","challenge":"' + challenge + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}'
|
||||
o = AES_O(plaintext, str_16)
|
||||
i = geetest_base64_encode(o)
|
||||
return i['res'] + i['end'] + r
|
||||
|
||||
def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
||||
# 伪造浏览器性能数据
|
||||
fake_timing = generate_fake_performance_timing()
|
||||
# 映射
|
||||
web_load_time = {
|
||||
"a": fake_timing["navigationStart"],
|
||||
"b": fake_timing["unloadEventStart"],
|
||||
"c": fake_timing["unloadEventEnd"],
|
||||
"d": fake_timing["redirectStart"],
|
||||
"e": fake_timing["redirectEnd"],
|
||||
"f": fake_timing["fetchStart"],
|
||||
"g": fake_timing["domainLookupStart"],
|
||||
"h": fake_timing["domainLookupEnd"],
|
||||
"i": fake_timing["connectStart"],
|
||||
"j": fake_timing["connectEnd"],
|
||||
"k": fake_timing["secureConnectionStart"],
|
||||
"l": fake_timing["requestStart"],
|
||||
"m": fake_timing["responseStart"],
|
||||
"n": fake_timing["responseEnd"],
|
||||
"o": fake_timing["domLoading"],
|
||||
"p": fake_timing["domInteractive"],
|
||||
"q": fake_timing["domContentLoadedEventStart"],
|
||||
"r": fake_timing["domContentLoadedEventEnd"],
|
||||
"s": fake_timing["domComplete"],
|
||||
"t": fake_timing["loadEventStart"],
|
||||
"u": fake_timing["loadEventEnd"]
|
||||
}
|
||||
|
||||
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间
|
||||
|
||||
guiji_yuanshu_shuzu = generate_realistic_trajectory(
|
||||
start_x=random.randint(400, 600), # 起始位置随机
|
||||
start_y=random.randint(400, 500),
|
||||
end_x=853,
|
||||
end_y=288,
|
||||
start_time=first_time
|
||||
)
|
||||
|
||||
|
||||
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
|
||||
compressed = compress_trajectory(trajectory)
|
||||
tt = encrypt_string(compressed, c, s)
|
||||
|
||||
passtime = str(int(round(time.time() * 1000)) - first_time)
|
||||
|
||||
rp = simple_md5(gt + challenge + passtime)
|
||||
|
||||
plaintext = '{"lang":"zh-cn","type":"fullpage","tt":"'+tt+'","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'+json.dumps(guiji_yuanshu_shuzu[0], separators=(',', ':'))+',"lp":'+json.dumps(guiji_yuanshu_shuzu[-1], separators=(',', ':'))+',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"dnf":"dnf","by":0},"passtime":'+passtime+',"rp":"'+rp+'","captcha_token":"112439067","tsfq":"xovrayel"}'
|
||||
|
||||
|
||||
result = geetest_base64_encode(AES_O(plaintext, str_16))
|
||||
|
||||
return result['res']+result['end']+result['end']
|
||||
|
||||
def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> str:
|
||||
encoder = TrajectoryEncoder()
|
||||
u = RSA_jiami_r(str_16)
|
||||
# 伪造浏览器性能数据
|
||||
fake_timing = generate_fake_performance_timing()
|
||||
# 映射
|
||||
web_load_time = {
|
||||
"a": fake_timing["navigationStart"],
|
||||
"b": fake_timing["unloadEventStart"],
|
||||
"c": fake_timing["unloadEventEnd"],
|
||||
"d": fake_timing["redirectStart"],
|
||||
"e": fake_timing["redirectEnd"],
|
||||
"f": fake_timing["fetchStart"],
|
||||
"g": fake_timing["domainLookupStart"],
|
||||
"h": fake_timing["domainLookupEnd"],
|
||||
"i": fake_timing["connectStart"],
|
||||
"j": fake_timing["connectEnd"],
|
||||
"k": fake_timing["secureConnectionStart"],
|
||||
"l": fake_timing["requestStart"],
|
||||
"m": fake_timing["responseStart"],
|
||||
"n": fake_timing["responseEnd"],
|
||||
"o": fake_timing["domLoading"],
|
||||
"p": fake_timing["domInteractive"],
|
||||
"q": fake_timing["domContentLoadedEventStart"],
|
||||
"r": fake_timing["domContentLoadedEventEnd"],
|
||||
"s": fake_timing["domComplete"],
|
||||
"t": fake_timing["loadEventStart"],
|
||||
"u": fake_timing["loadEventEnd"]
|
||||
}
|
||||
|
||||
|
||||
|
||||
trajectory = get_slide_track(hkjl)[0]
|
||||
print(trajectory)
|
||||
|
||||
# trajectory = generator.generate(target_x)
|
||||
userresponse = H(trajectory[-1][0], challenge)
|
||||
|
||||
result1 = encoder.encode(trajectory)
|
||||
aa = encoder.encrypt_string(result1, c, s)
|
||||
|
||||
passtime = str(trajectory[-1][2])
|
||||
|
||||
|
||||
rp = simple_md5(gt + challenge[:32] + passtime)
|
||||
|
||||
plaintext = '{"lang":"zh-cn","userresponse":"'+userresponse+'","passtime":'+passtime+',"imgload":50,"aa":"'+aa+'","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"td":-1},"h9s9":"1816378497","rp":"'+rp+'"}'
|
||||
|
||||
h = geetest_base64_encode(AES_O(plaintext, str_16))['res']
|
||||
return h+u
|
||||
|
||||
def run_solver() -> None:
|
||||
# 16位字符串
|
||||
str_16 = _generate_seed()
|
||||
|
||||
# 访问第一个链接获取gt challeneg
|
||||
gt, challenge = get_challenge_gt()
|
||||
|
||||
# 访问第二个链接获取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)
|
||||
|
||||
# 访问第五个链接获取最后一个w值所需要的加密参数
|
||||
bg, fullbg, c, s, slice, challenge = get_picture(gt, challenge)
|
||||
|
||||
# 识别缺口位置得到滑块距离
|
||||
hkjl = download_picture(bg, fullbg, slice)
|
||||
|
||||
# 获取第三个w值
|
||||
w3 = get_w3(str_16,challenge,hkjl,c,s,gt)
|
||||
|
||||
# 最后的验证
|
||||
message = req_end(gt, challenge, w3)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""核心数据模型"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@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
|
||||
api_url: str = ""
|
||||
http: str = ""
|
||||
https: str = ""
|
||||
# 白名单配置
|
||||
whitelist_enabled: bool = False
|
||||
whitelist_uid: str = ""
|
||||
whitelist_ukey: str = ""
|
||||
Reference in New Issue
Block a user