type: 收敛测试 schemas 与协议层类型
This commit is contained in:
@@ -49,7 +49,9 @@ class EmailVerifier:
|
||||
self.password = password # 邮箱密码
|
||||
self.timeout = timeout
|
||||
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
|
||||
self.backup_passwords = backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
|
||||
self.backup_passwords = (
|
||||
backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
|
||||
)
|
||||
|
||||
# Roundcube 会话(懒初始化)
|
||||
self._rc_session: Optional[requests.Session] = None
|
||||
@@ -87,7 +89,9 @@ class EmailVerifier:
|
||||
|
||||
for index, password in enumerate(passwords):
|
||||
is_backup = index > 0
|
||||
ok, password_failed = self._try_roundcube_login(password, is_backup=is_backup)
|
||||
ok, password_failed = self._try_roundcube_login(
|
||||
password, is_backup=is_backup
|
||||
)
|
||||
if ok:
|
||||
return True
|
||||
credential_failed = credential_failed or password_failed
|
||||
@@ -112,13 +116,17 @@ class EmailVerifier:
|
||||
seen.add(password)
|
||||
return result
|
||||
|
||||
def _try_roundcube_login(self, password: str, is_backup: bool = False) -> tuple[bool, bool]:
|
||||
def _try_roundcube_login(
|
||||
self, password: str, is_backup: bool = False
|
||||
) -> tuple[bool, bool]:
|
||||
"""尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。"""
|
||||
try:
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
})
|
||||
session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
}
|
||||
)
|
||||
|
||||
# 1. 访问首页获取 token
|
||||
resp = session.get(self.roundcube_url, timeout=self.timeout)
|
||||
@@ -143,17 +151,20 @@ class EmailVerifier:
|
||||
)
|
||||
|
||||
# 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。
|
||||
new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text)
|
||||
new_token_match = re.search(
|
||||
r'request_token["\s:]+["\']([^"\']+)', resp.text
|
||||
)
|
||||
if not new_token_match:
|
||||
logger.warning("Roundcube: 登录失败(未找到 request_token)")
|
||||
return False, False
|
||||
|
||||
text_lower = resp.text.lower()
|
||||
login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower
|
||||
login_form_present = 'name="_user"' in resp.text and 'name="_pass"' in resp.text
|
||||
mail_page = (
|
||||
"_task=mail" in resp.url
|
||||
or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text)
|
||||
login_form_present = (
|
||||
'name="_user"' in resp.text and 'name="_pass"' in resp.text
|
||||
)
|
||||
mail_page = "_task=mail" in resp.url or re.search(
|
||||
r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text
|
||||
)
|
||||
|
||||
if login_failed or login_form_present or not mail_page:
|
||||
@@ -183,6 +194,8 @@ class EmailVerifier:
|
||||
if self._rc_login_error:
|
||||
raise EmailLoginError(self._rc_login_error)
|
||||
return []
|
||||
if self._rc_session is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = self._rc_session.get(
|
||||
@@ -201,7 +214,7 @@ class EmailVerifier:
|
||||
|
||||
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
|
||||
for m in re.finditer(
|
||||
r'add_message_row\((\d+),\s*(\{[^}]+\})',
|
||||
r"add_message_row\((\d+),\s*(\{[^}]+\})",
|
||||
exec_text,
|
||||
):
|
||||
uid = int(m.group(1))
|
||||
@@ -210,11 +223,13 @@ class EmailVerifier:
|
||||
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
|
||||
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
|
||||
|
||||
messages.append({
|
||||
"uid": uid,
|
||||
"subject": subject_m.group(1) if subject_m else "",
|
||||
"date": date_m.group(1) if date_m else "",
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"uid": uid,
|
||||
"subject": subject_m.group(1) if subject_m else "",
|
||||
"date": date_m.group(1) if date_m else "",
|
||||
}
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
@@ -230,11 +245,12 @@ class EmailVerifier:
|
||||
if self._rc_login_error:
|
||||
raise EmailLoginError(self._rc_login_error)
|
||||
return None
|
||||
if self._rc_session is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = self._rc_session.get(
|
||||
f"{self.roundcube_url}?_task=mail&_action=show"
|
||||
f"&_mbox=INBOX&_uid={uid}",
|
||||
f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}",
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return resp.text
|
||||
@@ -280,8 +296,14 @@ class EmailVerifier:
|
||||
|
||||
# 星期X
|
||||
weekday_map = {
|
||||
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
|
||||
"星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6,
|
||||
"星期一": 0,
|
||||
"星期二": 1,
|
||||
"星期三": 2,
|
||||
"星期四": 3,
|
||||
"星期五": 4,
|
||||
"星期六": 5,
|
||||
"星期日": 6,
|
||||
"星期天": 6,
|
||||
}
|
||||
for prefix, wd in weekday_map.items():
|
||||
if date_str.startswith(prefix):
|
||||
@@ -295,7 +317,9 @@ class EmailVerifier:
|
||||
target = now
|
||||
else:
|
||||
target = now - timedelta(days=days_ago)
|
||||
return target.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
|
||||
return target.replace(
|
||||
hour=int(h), minute=int(m), second=0, microsecond=0
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
@@ -366,7 +390,9 @@ class EmailVerifier:
|
||||
raise InterruptedError("任务已停止")
|
||||
|
||||
try:
|
||||
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
|
||||
code = self._fetch_code_via_roundcube(
|
||||
after_timestamp, allow_old_seconds
|
||||
)
|
||||
if code:
|
||||
logger.success(f"获取到验证码: {code}")
|
||||
return code
|
||||
@@ -383,7 +409,9 @@ class EmailVerifier:
|
||||
raise InterruptedError("任务已停止")
|
||||
time.sleep(min(0.2, sleep_deadline - time.monotonic()))
|
||||
|
||||
raise TimeoutError(f"等待验证码超时{'(' + last_error + ')' if last_error else ''}")
|
||||
raise TimeoutError(
|
||||
f"等待验证码超时{'(' + last_error + ')' if last_error else ''}"
|
||||
)
|
||||
|
||||
# ── Roundcube 方式获取验证码 ────────────────────────────
|
||||
|
||||
@@ -397,7 +425,8 @@ class EmailVerifier:
|
||||
|
||||
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
||||
douyu_msgs = [
|
||||
msg for msg in messages
|
||||
msg
|
||||
for msg in messages
|
||||
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
||||
]
|
||||
|
||||
@@ -407,7 +436,9 @@ class EmailVerifier:
|
||||
msg_dt = self._parse_rc_date(msg["date"])
|
||||
if msg_dt is None:
|
||||
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
|
||||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过")
|
||||
logger.debug(
|
||||
f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过"
|
||||
)
|
||||
continue
|
||||
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
|
||||
# 而 after_timestamp 是秒级精度(如14:20:49)。
|
||||
@@ -415,7 +446,9 @@ class EmailVerifier:
|
||||
# 解决:将 after_timestamp 也向下取整到分钟后再比较。
|
||||
after_minute_ts = after_timestamp - (after_timestamp % 60)
|
||||
if msg_dt.timestamp() < after_minute_ts - allow_old_seconds:
|
||||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过")
|
||||
logger.debug(
|
||||
f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过"
|
||||
)
|
||||
continue
|
||||
|
||||
# 读取邮件正文提取验证码
|
||||
@@ -436,16 +469,16 @@ class EmailVerifier:
|
||||
"""从文本中提取6位验证码"""
|
||||
# 清理HTML标签和实体
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
text = re.sub(r'\s+', ' ', 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',
|
||||
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",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
@@ -459,7 +492,7 @@ class EmailVerifier:
|
||||
def get_email_config_for_account(email_address: str) -> dict:
|
||||
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
||||
return {
|
||||
'server': '',
|
||||
'port': 143,
|
||||
'ssl': False,
|
||||
"server": "",
|
||||
"port": 143,
|
||||
"ssl": False,
|
||||
}
|
||||
|
||||
+194
-116
@@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI
|
||||
from .proxy_fetcher import ProxyFetcher
|
||||
|
||||
from core.geetest.v3_slide.solver import (
|
||||
_generate_seed, get_w1, get_w2,
|
||||
_generate_seed,
|
||||
get_w1,
|
||||
get_w2,
|
||||
)
|
||||
from core.geetest.common.network import (
|
||||
get_js_address,
|
||||
@@ -44,6 +46,7 @@ class CredentialError(ValueError):
|
||||
|
||||
class AccountLike(Protocol):
|
||||
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
email: str
|
||||
@@ -56,7 +59,9 @@ class AccountLike(Protocol):
|
||||
class LoginResult:
|
||||
"""登录结果"""
|
||||
|
||||
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""):
|
||||
def __init__(
|
||||
self, success: bool, cookie: str = "", message: str = "", code: str = ""
|
||||
):
|
||||
self.success = success
|
||||
self.cookie = cookie
|
||||
self.message = message
|
||||
@@ -68,7 +73,9 @@ 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"
|
||||
SEND_EMAIL_API = (
|
||||
"https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||||
)
|
||||
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||||
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
|
||||
@@ -141,50 +148,52 @@ class DouyuLogin:
|
||||
"""配置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.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:
|
||||
def _apply_proxy(self, proxy: str | None = None) -> None:
|
||||
"""应用代理到Session"""
|
||||
if proxy:
|
||||
# 使用指定的代理
|
||||
self.session.proxies = {
|
||||
'http': proxy,
|
||||
'https': proxy,
|
||||
"http": proxy,
|
||||
"https": proxy,
|
||||
}
|
||||
self._current_proxy_url = proxy
|
||||
elif self.proxy:
|
||||
# 使用配置的静态代理
|
||||
if isinstance(self.proxy, str):
|
||||
self.session.proxies = {
|
||||
'http': self.proxy,
|
||||
'https': self.proxy,
|
||||
"http": self.proxy,
|
||||
"https": self.proxy,
|
||||
}
|
||||
self._current_proxy_url = self.proxy
|
||||
else:
|
||||
self.session.proxies = {
|
||||
scheme: url
|
||||
for scheme, url in self.proxy.items()
|
||||
if url
|
||||
scheme: url for scheme, url in self.proxy.items() if url
|
||||
}
|
||||
self._current_proxy_url = self.proxy.get('http') or self.proxy.get('https')
|
||||
self._current_proxy_url = self.proxy.get("http") or self.proxy.get(
|
||||
"https"
|
||||
)
|
||||
elif self.proxy_fetcher:
|
||||
# 从代理获取器取新代理
|
||||
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
||||
if new_proxy:
|
||||
self.session.proxies = {
|
||||
'http': new_proxy,
|
||||
'https': new_proxy,
|
||||
"http": new_proxy,
|
||||
"https": new_proxy,
|
||||
}
|
||||
self._current_proxy_url = new_proxy
|
||||
else:
|
||||
@@ -207,7 +216,7 @@ class DouyuLogin:
|
||||
统一发送请求,附带分段超时和更明确的错误信息。
|
||||
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
|
||||
"""
|
||||
timeout = kwargs.pop('timeout', self.timeout)
|
||||
timeout = kwargs.pop("timeout", self.timeout)
|
||||
safe_url = self._safe_url(url)
|
||||
|
||||
self._ensure_not_stopped()
|
||||
@@ -256,19 +265,21 @@ class DouyuLogin:
|
||||
@staticmethod
|
||||
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
|
||||
"""识别登录接口返回的账号类终态。"""
|
||||
error_code = payload.get('error')
|
||||
error_msg = str(payload.get('msg') or '')
|
||||
error_code = payload.get("error")
|
||||
error_msg = str(payload.get("msg") or "")
|
||||
|
||||
if error_code == 110022 or '账号不存在' in error_msg:
|
||||
if error_code == 110022 or "账号不存在" in error_msg:
|
||||
return "account_cancelled", "账号已注销"
|
||||
|
||||
password_keywords = ['密码错误', '账号或密码', '账号或者密码']
|
||||
password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
|
||||
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
|
||||
return "password_wrong", "账号密码错误"
|
||||
|
||||
return None
|
||||
|
||||
def _credential_error_from_payload(self, stage: str, payload: dict) -> CredentialError | None:
|
||||
def _credential_error_from_payload(
|
||||
self, stage: str, payload: dict
|
||||
) -> CredentialError | None:
|
||||
"""把账号类错误转换为不重试的 CredentialError。"""
|
||||
classified = self._classify_credential_payload(payload)
|
||||
if not classified:
|
||||
@@ -293,10 +304,14 @@ class DouyuLogin:
|
||||
# 3️⃣ 第二次登录(带极验)
|
||||
logger.info("步骤3: 第二次登录(带极验验证)...")
|
||||
next_step, value = self._second_login(
|
||||
gt, challenge, validate, seccode, code_token,
|
||||
gt,
|
||||
challenge,
|
||||
validate,
|
||||
seccode,
|
||||
code_token,
|
||||
)
|
||||
|
||||
if next_step == 'mobile_bind_skip':
|
||||
if next_step == "mobile_bind_skip":
|
||||
logger.info("步骤4: 跳过手机号绑定,完成登录...")
|
||||
return self._complete_login(value)
|
||||
|
||||
@@ -339,12 +354,19 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message="任务已停止")
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
||||
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)")
|
||||
logger.warning(
|
||||
f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||||
)
|
||||
return LoginResult(
|
||||
success=False,
|
||||
message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||||
)
|
||||
|
||||
if attempt > 1:
|
||||
if self.max_login_retries > 0:
|
||||
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
||||
logger.info(
|
||||
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||
)
|
||||
else:
|
||||
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
|
||||
# 重试前:取新代理 + 重置 session
|
||||
@@ -371,15 +393,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message=str(e), code=e.code)
|
||||
except EmailLoginError as e:
|
||||
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
|
||||
return LoginResult(success=False, message=str(e), code="email_login_failed")
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_login_failed"
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_login_retries > 0:
|
||||
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
|
||||
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
has_retry = (
|
||||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
)
|
||||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||
if has_retry and has_time:
|
||||
self._sleep_interruptible(1)
|
||||
@@ -405,14 +435,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message="任务已停止")
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||
logger.warning(f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
||||
return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)")
|
||||
logger.warning(
|
||||
f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||||
)
|
||||
return LoginResult(
|
||||
success=False,
|
||||
message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||||
)
|
||||
|
||||
if attempt > 1:
|
||||
if self.max_login_retries > 0:
|
||||
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
||||
logger.info(
|
||||
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||
)
|
||||
else:
|
||||
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始")
|
||||
logger.info(
|
||||
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
|
||||
)
|
||||
if not self._prepare_retry():
|
||||
return LoginResult(
|
||||
success=False,
|
||||
@@ -431,15 +470,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=True, message=e.status_message, code=e.code)
|
||||
except EmailLoginError as e:
|
||||
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}")
|
||||
return LoginResult(success=False, message=str(e), code="email_login_failed")
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_login_failed"
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_login_retries > 0:
|
||||
logger.error(f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
|
||||
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
has_retry = (
|
||||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
)
|
||||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||
if has_retry and has_time:
|
||||
self._sleep_interruptible(1)
|
||||
@@ -449,40 +496,44 @@ class DouyuLogin:
|
||||
def _check_certification_status(self) -> LoginResult:
|
||||
"""请求个人中心接口并判断实名状态。"""
|
||||
headers = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Priority': 'u=1, i',
|
||||
'Referer': self.CP_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': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': None,
|
||||
'Content-Type': None,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": self.CP_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": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Origin": None,
|
||||
"Content-Type": None,
|
||||
}
|
||||
payload = self._request_json(
|
||||
'get',
|
||||
"get",
|
||||
self.CP_RPC_API,
|
||||
'账号认证状态接口',
|
||||
"账号认证状态接口",
|
||||
headers=headers,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
info = payload.get('info') or {}
|
||||
ident_status = str(info.get('ident_status', ''))
|
||||
ident_type = str(info.get('ident_type', ''))
|
||||
info = payload.get("info") or {}
|
||||
ident_status = str(info.get("ident_status", ""))
|
||||
ident_type = str(info.get("ident_type", ""))
|
||||
|
||||
if ident_status == '0' and ident_type == '0':
|
||||
if ident_status == "0" and ident_type == "0":
|
||||
logger.success("账号检测完成: 账号未认证")
|
||||
return LoginResult(success=True, message="账号未认证", code="account_unverified")
|
||||
return LoginResult(
|
||||
success=True, message="账号未认证", code="account_unverified"
|
||||
)
|
||||
|
||||
if ident_status == '2' and ident_type == '2':
|
||||
if ident_status == "2" and ident_type == "2":
|
||||
logger.success("账号检测完成: 账号已认证")
|
||||
return LoginResult(success=True, message="账号已认证", code="account_verified")
|
||||
return LoginResult(
|
||||
success=True, message="账号已认证", code="account_verified"
|
||||
)
|
||||
|
||||
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
|
||||
logger.warning(message)
|
||||
@@ -528,17 +579,19 @@ class DouyuLogin:
|
||||
)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.first_login_url,
|
||||
'第一次登录接口',
|
||||
"第一次登录接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||||
|
||||
if payload.get('error') != 81:
|
||||
error_msg = payload.get('msg', '未知错误')
|
||||
credential_error = self._credential_error_from_payload("第一次登录", payload)
|
||||
if payload.get("error") != 81:
|
||||
error_msg = payload.get("msg", "未知错误")
|
||||
credential_error = self._credential_error_from_payload(
|
||||
"第一次登录", payload
|
||||
)
|
||||
if credential_error:
|
||||
raise credential_error
|
||||
raise ValueError(f"第一次登录失败: {error_msg}")
|
||||
@@ -553,7 +606,9 @@ class DouyuLogin:
|
||||
|
||||
return gt, challenge, code_token, self.session.cookies.get_dict()
|
||||
|
||||
def _solve_geetest(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
|
||||
def _solve_geetest(
|
||||
self, gt: str, challenge: str, deadline: float = 0
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
|
||||
|
||||
@@ -578,7 +633,9 @@ class DouyuLogin:
|
||||
finally:
|
||||
_geetest_semaphore.release()
|
||||
|
||||
def _solve_geetest_inner(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
|
||||
def _solve_geetest_inner(
|
||||
self, gt: str, challenge: str, deadline: float = 0
|
||||
) -> Tuple[str, str]:
|
||||
"""极验验证内部实现:最多3次尝试,失败直接抛异常。"""
|
||||
_MAX_ATTEMPTS = 3
|
||||
|
||||
@@ -620,14 +677,18 @@ class DouyuLogin:
|
||||
|
||||
# 从 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', '')
|
||||
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]}...")
|
||||
logger.success(
|
||||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||
)
|
||||
return validate, seccode
|
||||
else:
|
||||
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
|
||||
@@ -636,7 +697,9 @@ class DouyuLogin:
|
||||
validate = str(result)
|
||||
if validate:
|
||||
seccode = f"{validate}|jordan"
|
||||
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
|
||||
logger.success(
|
||||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||
)
|
||||
return validate, seccode
|
||||
|
||||
except ValueError:
|
||||
@@ -645,11 +708,16 @@ class DouyuLogin:
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
# 所有网络/代理异常都直接上抛,由 login 换新代理
|
||||
logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理")
|
||||
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
|
||||
logger.warning(
|
||||
f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理"
|
||||
)
|
||||
raise ValueError(
|
||||
f"极验验证异常: {self._truncate_error(err_str)}"
|
||||
) from e
|
||||
|
||||
def _second_login(self, gt: str, challenge: str, validate: str,
|
||||
seccode: str, code_token: str) -> tuple[str, str]:
|
||||
def _second_login(
|
||||
self, gt: str, challenge: str, validate: str, seccode: str, code_token: str
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
第二次登录(带极验验证)
|
||||
|
||||
@@ -668,16 +736,22 @@ class DouyuLogin:
|
||||
data = self.api.build_second_login_data(
|
||||
self.account.username,
|
||||
self.account.password,
|
||||
gt, challenge, validate, seccode, code_token,
|
||||
gt,
|
||||
challenge,
|
||||
validate,
|
||||
seccode,
|
||||
code_token,
|
||||
self.LOGIN_REFERER,
|
||||
)
|
||||
|
||||
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...")
|
||||
logger.debug(
|
||||
f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..."
|
||||
)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.login_url,
|
||||
'第二次登录接口',
|
||||
"第二次登录接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
@@ -689,11 +763,13 @@ class DouyuLogin:
|
||||
if unique_key:
|
||||
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
|
||||
login_url = self._skip_mobile_bind(unique_key)
|
||||
return 'mobile_bind_skip', login_url
|
||||
return "mobile_bind_skip", login_url
|
||||
|
||||
if payload.get('error') != 130014:
|
||||
error_msg = payload.get('msg', '未知错误')
|
||||
credential_error = self._credential_error_from_payload("第二次登录", payload)
|
||||
if payload.get("error") != 130014:
|
||||
error_msg = payload.get("msg", "未知错误")
|
||||
credential_error = self._credential_error_from_payload(
|
||||
"第二次登录", payload
|
||||
)
|
||||
if credential_error:
|
||||
raise credential_error
|
||||
raise ValueError(f"第二次登录失败: {error_msg}")
|
||||
@@ -703,26 +779,26 @@ class DouyuLogin:
|
||||
|
||||
if not remote_code:
|
||||
# 提取可能的风控提示
|
||||
data = payload.get('data', {})
|
||||
security_quiz = data.get('securityQuiz', '')
|
||||
msg = payload.get('msg', '')
|
||||
detail = security_quiz or msg or '未知原因'
|
||||
data = payload.get("data", {})
|
||||
security_quiz = data.get("securityQuiz", "")
|
||||
msg = payload.get("msg", "")
|
||||
detail = security_quiz or msg or "未知原因"
|
||||
raise ValueError(f"获取remote_code失败: {detail}")
|
||||
|
||||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
||||
|
||||
return 'remote_email', remote_code
|
||||
return "remote_email", remote_code
|
||||
|
||||
def _skip_mobile_bind(self, unique_key: str) -> str:
|
||||
"""跳过手机号绑定并获取后续登录回调地址。"""
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.login_url,
|
||||
'跳过手机号绑定接口',
|
||||
"跳过手机号绑定接口",
|
||||
data=self.api.build_skip_mobile_bind_data(unique_key),
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
|
||||
|
||||
login_url = self.api.extract_login_url(payload)
|
||||
@@ -738,13 +814,13 @@ class DouyuLogin:
|
||||
data = self.api.build_send_email_data(remote_code)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.send_email_url,
|
||||
'发送验证邮件接口',
|
||||
"发送验证邮件接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||||
|
||||
logger.info("验证邮件已发送")
|
||||
@@ -775,13 +851,13 @@ class DouyuLogin:
|
||||
data = self.api.build_verify_data(remote_code, verify_code)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.verify_url,
|
||||
'提交验证码接口',
|
||||
"提交验证码接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
||||
|
||||
login_url = self.api.extract_login_url(payload)
|
||||
@@ -798,8 +874,8 @@ class DouyuLogin:
|
||||
@staticmethod
|
||||
def _normalize_login_url(login_url: str) -> str:
|
||||
"""补全斗鱼接口返回的协议相对登录回调地址。"""
|
||||
if login_url.startswith('//'):
|
||||
return 'https:' + login_url
|
||||
if login_url.startswith("//"):
|
||||
return "https:" + login_url
|
||||
return login_url
|
||||
|
||||
def _complete_login(self, login_url: str) -> str:
|
||||
@@ -810,16 +886,16 @@ class DouyuLogin:
|
||||
cookie: 完整的Cookie字符串
|
||||
"""
|
||||
# 访问登录URL
|
||||
response = self._request('get', login_url)
|
||||
response = self._request("get", login_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# 尝试访问webLogin获取用户信息
|
||||
try:
|
||||
code_match = re.search(r'code=([^&]+)', login_url)
|
||||
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)
|
||||
response2 = self._request("get", weblogin_url)
|
||||
|
||||
if response2.status_code == 200:
|
||||
logger.info("WebLogin成功")
|
||||
@@ -839,7 +915,9 @@ class DouyuLogin:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._cookie_enrich_error = self._truncate_error(str(e), 160)
|
||||
logger.warning(f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}")
|
||||
logger.warning(
|
||||
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
|
||||
)
|
||||
|
||||
return self._format_cookie_string()
|
||||
|
||||
@@ -848,6 +926,6 @@ class DouyuLogin:
|
||||
cookies = self.session.cookies.get_dict()
|
||||
|
||||
# 格式化Cookie字符串
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
|
||||
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
return cookie_str
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
|
||||
"""
|
||||
|
||||
from typing import Optional, Type
|
||||
from typing import Any, Optional, Type, cast
|
||||
|
||||
from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy
|
||||
from .xiequ import XiequAdapter
|
||||
@@ -22,19 +22,30 @@ _PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = {
|
||||
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
|
||||
"xiequ": [
|
||||
{"key": "uid", "label": "UID", "placeholder": "如: 99769"},
|
||||
{"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"},
|
||||
{
|
||||
"key": "ukey",
|
||||
"label": "UKEY",
|
||||
"placeholder": "如: C99371082B965B70F46DCAA87A04618B",
|
||||
},
|
||||
],
|
||||
"xkdaili": [
|
||||
{"key": "apikey", "label": "API Key", "placeholder": "如: XK86872CC0C85A415461"},
|
||||
{"key": "wl_sign", "label": "白名单签名(Sign)", "placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027"},
|
||||
{
|
||||
"key": "apikey",
|
||||
"label": "API Key",
|
||||
"placeholder": "如: XK86872CC0C85A415461",
|
||||
},
|
||||
{
|
||||
"key": "wl_sign",
|
||||
"label": "白名单签名(Sign)",
|
||||
"placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027",
|
||||
},
|
||||
{"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"},
|
||||
],
|
||||
}
|
||||
|
||||
# ── 平台显示名 ──
|
||||
_PLATFORM_LABELS: dict[str, str] = {
|
||||
name: cls(dict()).platform_label
|
||||
for name, cls in _PLATFORM_REGISTRY.items()
|
||||
name: cast(Any, cls)({}).platform_label for name, cls in _PLATFORM_REGISTRY.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +54,7 @@ def create_adapter(platform: str, credentials: dict) -> Optional[BaseWhitelistAd
|
||||
cls = _PLATFORM_REGISTRY.get(platform)
|
||||
if not cls:
|
||||
return None
|
||||
return cls(credentials)
|
||||
return cast(Any, cls)(credentials)
|
||||
|
||||
|
||||
def get_platform_names() -> list[str]:
|
||||
|
||||
@@ -110,20 +110,26 @@ class BaseWhitelistAdapter(ABC):
|
||||
return True, msg
|
||||
|
||||
# 当前IP已存在但备注不同,不处理
|
||||
if any(r.get('ip') == current_ip for r in records):
|
||||
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加")
|
||||
if any(r.get("ip") == current_ip for r in records):
|
||||
logger.info(
|
||||
f"白名单IP {current_ip} 已存在(备注不同),无需重复添加"
|
||||
)
|
||||
return True, f"白名单IP已存在: {current_ip}"
|
||||
|
||||
# 超过上限,删除最老的
|
||||
if len(memo_ips) >= keep_recent:
|
||||
to_delete = memo_ips[:len(memo_ips) - keep_recent + 1]
|
||||
to_delete = memo_ips[: len(memo_ips) - keep_recent + 1]
|
||||
for old_ip in to_delete:
|
||||
if not old_ip:
|
||||
continue
|
||||
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
|
||||
self.delete_ip(old_ip)
|
||||
time.sleep(1)
|
||||
|
||||
# 添加新IP
|
||||
logger.info(f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)")
|
||||
logger.info(
|
||||
f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)"
|
||||
)
|
||||
ok, resp = self.add_ip(current_ip)
|
||||
if ok:
|
||||
msg = f"白名单IP已添加: {current_ip}"
|
||||
@@ -152,10 +158,12 @@ def _get_local_exit_ip() -> Optional[str]:
|
||||
]
|
||||
for url in targets:
|
||||
try:
|
||||
response = requests.get(url, timeout=8, headers={"User-Agent": "Mozilla/5.0"})
|
||||
response = requests.get(
|
||||
url, timeout=8, headers={"User-Agent": "Mozilla/5.0"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip()
|
||||
match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text)
|
||||
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
@@ -174,7 +182,9 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
|
||||
for url in targets:
|
||||
try:
|
||||
response = requests.get(
|
||||
url, proxies=proxies, timeout=6,
|
||||
url,
|
||||
proxies=proxies,
|
||||
timeout=6,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -17,7 +17,7 @@ class DouyuWhitelistSyncer:
|
||||
def __init__(
|
||||
self,
|
||||
platform: str = "xiequ",
|
||||
credentials: dict = None,
|
||||
credentials: Optional[dict] = None,
|
||||
uid: str = "",
|
||||
ukey: str = "",
|
||||
):
|
||||
|
||||
@@ -4,7 +4,9 @@ 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]:
|
||||
def generate_realistic_trajectory(
|
||||
start_x: int, start_y: int, end_x: int, end_y: int, start_time: int
|
||||
) -> list[Any]:
|
||||
"""生成类人的鼠标轨迹"""
|
||||
trajectory = []
|
||||
|
||||
@@ -41,70 +43,56 @@ def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int
|
||||
# 随机时间间隔(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]
|
||||
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"
|
||||
])
|
||||
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"
|
||||
])
|
||||
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"
|
||||
])
|
||||
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(["down", end_x, end_y, current_time, "pointerdown"])
|
||||
|
||||
trajectory.append([
|
||||
"focus",
|
||||
current_time + 1
|
||||
])
|
||||
trajectory.append(["focus", current_time + 1])
|
||||
|
||||
click_duration = random.randint(80, 130)
|
||||
trajectory.append([
|
||||
"up",
|
||||
end_x,
|
||||
end_y,
|
||||
current_time + click_duration,
|
||||
"pointerup"
|
||||
])
|
||||
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]:
|
||||
def process_mouse_trajectory(
|
||||
events: list[Any], max_records: Optional[int] = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
处理鼠标/触摸轨迹数据,将绝对坐标转换为相对坐标和时间差
|
||||
|
||||
@@ -116,7 +104,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
处理后的事件列表
|
||||
"""
|
||||
if not events or len(events) == 0:
|
||||
return []
|
||||
return {"data": [], "first_event": None, "last_event": None, "total_events": 0}
|
||||
|
||||
# 初始化变量
|
||||
prev_x = 0 # 上一个X坐标
|
||||
@@ -130,8 +118,17 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"]
|
||||
|
||||
# 点击类事件(仅时间信息)
|
||||
CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup",
|
||||
"touchstart", "touchend", "pointerdown", "pointerup"]
|
||||
CLICK_EVENTS = [
|
||||
"down",
|
||||
"up",
|
||||
"click",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
"touchstart",
|
||||
"touchend",
|
||||
"pointerdown",
|
||||
"pointerup",
|
||||
]
|
||||
|
||||
# 特殊事件(仅时间信息)
|
||||
TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"]
|
||||
@@ -168,11 +165,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组
|
||||
result.append([
|
||||
event_type,
|
||||
[delta_x, delta_y],
|
||||
time_diff
|
||||
])
|
||||
result.append([event_type, [delta_x, delta_y], time_diff])
|
||||
|
||||
# 更新上一次的值
|
||||
prev_x = x
|
||||
@@ -190,11 +183,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组(坐标差为[0,0])
|
||||
result.append([
|
||||
event_type,
|
||||
[0, 0],
|
||||
time_diff
|
||||
])
|
||||
result.append([event_type, [0, 0], time_diff])
|
||||
|
||||
prev_time = timestamp
|
||||
|
||||
@@ -209,10 +198,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
time_diff = timestamp - prev_time
|
||||
|
||||
# 添加到结果数组(仅包含时间差)
|
||||
result.append([
|
||||
event_type,
|
||||
time_diff
|
||||
])
|
||||
result.append([event_type, time_diff])
|
||||
|
||||
prev_time = timestamp
|
||||
|
||||
@@ -220,10 +206,11 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
||||
"data": result,
|
||||
"first_event": first_event,
|
||||
"last_event": last_event,
|
||||
"total_events": len(result)
|
||||
"total_events": len(result),
|
||||
}
|
||||
|
||||
def compress_trajectory(e:list[Any]) -> str:
|
||||
|
||||
def compress_trajectory(e: list[Any]) -> str:
|
||||
"""
|
||||
压缩轨迹数据的完整实现
|
||||
|
||||
@@ -242,7 +229,7 @@ def compress_trajectory(e:list[Any]) -> str:
|
||||
"focus": 4,
|
||||
"blur": 5,
|
||||
"unload": 6,
|
||||
"unknown": 7
|
||||
"unknown": 7,
|
||||
}
|
||||
|
||||
def h(e, t):
|
||||
@@ -434,11 +421,13 @@ def compress_trajectory(e:list[Any]) -> str:
|
||||
"""Base64编码"""
|
||||
t = ""
|
||||
n = len(e) // 6
|
||||
base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
||||
base64_chars = (
|
||||
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
||||
)
|
||||
|
||||
for r in range(n):
|
||||
# 每次取6位二进制
|
||||
binary_str = e[6 * r: 6 * (r + 1)]
|
||||
binary_str = e[6 * r : 6 * (r + 1)]
|
||||
index = int(binary_str, 2)
|
||||
t += base64_chars[index]
|
||||
|
||||
@@ -446,9 +435,12 @@ def compress_trajectory(e:list[Any]) -> str:
|
||||
|
||||
return u(c_str)
|
||||
|
||||
|
||||
class TrajectoryEncoder:
|
||||
def __init__(self) -> None:
|
||||
self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
|
||||
self.CHARSET = (
|
||||
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
|
||||
)
|
||||
self.BASE = len(self.CHARSET) # 64
|
||||
self.DIRECTION_CHARS = "stuvwxyz~"
|
||||
self.DIRECTION_PATTERNS = [
|
||||
@@ -460,7 +452,7 @@ class TrajectoryEncoder:
|
||||
[0, -1], # x
|
||||
[3, 0], # y
|
||||
[2, -1], # z
|
||||
[2, 1] # ~
|
||||
[2, 1], # ~
|
||||
]
|
||||
|
||||
def encode_number(self, num):
|
||||
@@ -546,9 +538,11 @@ class TrajectoryEncoder:
|
||||
t_encoded.append(self.encode_number(dt))
|
||||
|
||||
# 拼接:x坐标 !! y坐标 !! 时间戳
|
||||
return "".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded)
|
||||
return (
|
||||
"".join(x_encoded) + "!!" + "".join(y_encoded) + "!!" + "".join(t_encoded)
|
||||
)
|
||||
|
||||
def encrypt_string(self,e, t, n):
|
||||
def encrypt_string(self, e, t, n):
|
||||
"""
|
||||
JS加密函数的Python实现
|
||||
|
||||
@@ -568,7 +562,7 @@ class TrajectoryEncoder:
|
||||
|
||||
# 每次读取2个字符(十六进制)
|
||||
while o < len(n):
|
||||
r = n[o:o + 2] # 取2个字符
|
||||
r = n[o : o + 2] # 取2个字符
|
||||
if len(r) < 2:
|
||||
break
|
||||
o += 2
|
||||
@@ -587,7 +581,8 @@ class TrajectoryEncoder:
|
||||
|
||||
return i
|
||||
|
||||
def H(t:int, e:str) -> str:
|
||||
|
||||
def H(t: int, e: str) -> str:
|
||||
# 解析后缀
|
||||
n = e[-2:]
|
||||
r = []
|
||||
@@ -625,4 +620,4 @@ def H(t:int, e:str) -> str:
|
||||
g.pop(d)
|
||||
d -= 1
|
||||
|
||||
return p
|
||||
return p
|
||||
|
||||
+39
-2
@@ -1,8 +1,37 @@
|
||||
"""虎牙协议与业务客户端。"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .http_client import HuyaHttpClient
|
||||
from .wss_client import HuyaWssClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .activity_structs import GetUserScoreReq, GetUserScoreResp
|
||||
from .app_login import (
|
||||
HuyaAppLoginError,
|
||||
HuyaAppPasswordLogin,
|
||||
HuyaAppQrAuthRequiredError,
|
||||
login_huya_app_password,
|
||||
)
|
||||
from .login import (
|
||||
HuyaCredentialError,
|
||||
HuyaLoginError,
|
||||
HuyaLoginResult,
|
||||
HuyaPasswordLogin,
|
||||
login_huya_password,
|
||||
)
|
||||
from .sms_login import (
|
||||
HuyaSmsCodeResult,
|
||||
HuyaSmsLogin,
|
||||
login_huya_sms,
|
||||
send_huya_sms_code,
|
||||
)
|
||||
from .verification import (
|
||||
HuyaVerificationError,
|
||||
HuyaVerificationSolver,
|
||||
solve_huya_verification,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HuyaHttpClient",
|
||||
"HuyaWssClient",
|
||||
@@ -103,8 +132,16 @@ def __getattr__(name: str):
|
||||
}
|
||||
globals().update(values)
|
||||
return values[name]
|
||||
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}:
|
||||
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
|
||||
if name in {
|
||||
"HuyaVerificationError",
|
||||
"HuyaVerificationSolver",
|
||||
"solve_huya_verification",
|
||||
}:
|
||||
from .verification import (
|
||||
HuyaVerificationError,
|
||||
HuyaVerificationSolver,
|
||||
solve_huya_verification,
|
||||
)
|
||||
|
||||
values = {
|
||||
"HuyaVerificationError": HuyaVerificationError,
|
||||
|
||||
+44
-19
@@ -18,6 +18,7 @@
|
||||
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
|
||||
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -176,13 +177,13 @@ def parse_cred(resp: bytes) -> bytes | None:
|
||||
e = resp.find(b"_wup_header")
|
||||
if s < 0 or e < 0:
|
||||
return None
|
||||
d = resp[s:e - 6]
|
||||
d = resp[s : e - 6]
|
||||
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
|
||||
if not m:
|
||||
return None
|
||||
ln = m.group(2)[0]
|
||||
st = m.start() + 4
|
||||
cred = d[st:st + ln]
|
||||
cred = d[st : st + ln]
|
||||
if len(cred) == 114 and cred[:1] == b"\x0a":
|
||||
return cred
|
||||
return None
|
||||
@@ -209,7 +210,10 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
|
||||
HuyaVerificationSolver,
|
||||
)
|
||||
|
||||
q = {k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()}
|
||||
q = {
|
||||
k: v[0]
|
||||
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
|
||||
}
|
||||
app_id = str(q.get("appId") or "5002")
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(max_retry):
|
||||
@@ -287,7 +291,11 @@ def login_cred_with_flow(
|
||||
return cred, uid
|
||||
risk_url = parse_risk_url(resp)
|
||||
if risk_url:
|
||||
kind = "pt_auth(滑块)" if "pt_auth" in risk_url else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
|
||||
kind = (
|
||||
"pt_auth(滑块)"
|
||||
if "pt_auth" in risk_url
|
||||
else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
|
||||
)
|
||||
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
|
||||
if "qr_auth" in risk_url:
|
||||
raise HuyaAppQrAuthRequiredError(
|
||||
@@ -317,12 +325,14 @@ class QrRole:
|
||||
self.context = f"{prefix}-{ctx_hex}-{tail}"
|
||||
self.page_id = random.randint(40_000_000, 41_000_000)
|
||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||
self.s.headers.update({
|
||||
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
})
|
||||
self.s.headers.update(
|
||||
{
|
||||
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
}
|
||||
)
|
||||
|
||||
def _headers(self, uri: str) -> dict:
|
||||
mid = "2.6" if self.pc else "2.5"
|
||||
@@ -334,7 +344,9 @@ class QrRole:
|
||||
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
|
||||
}
|
||||
|
||||
def call(self, path: str, uri: str, data: dict, cookies: dict | None = None) -> dict:
|
||||
def call(
|
||||
self, path: str, uri: str, data: dict, cookies: dict | None = None
|
||||
) -> dict:
|
||||
envelope = {
|
||||
"uri": uri,
|
||||
"version": "2.6" if self.pc else "2.5",
|
||||
@@ -389,13 +401,16 @@ class HuyaAppPasswordLogin:
|
||||
self.proxies = dict(proxies) if proxies else None
|
||||
self.timeout = timeout or (10.0, 25.0)
|
||||
self.force_new_device = force_new_device
|
||||
self.device_info = device_info or get_profile(self.username, force_new=force_new_device)
|
||||
self.device_info = device_info or get_profile(
|
||||
self.username, force_new=force_new_device
|
||||
)
|
||||
|
||||
def login(self) -> HuyaLoginResult:
|
||||
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
|
||||
result = self._login_impl()
|
||||
try:
|
||||
from .device_profile import record_login
|
||||
|
||||
record_login(self.username, result.success, result.message)
|
||||
except Exception: # 元数据记录失败不影响登录结果
|
||||
pass
|
||||
@@ -404,7 +419,9 @@ class HuyaAppPasswordLogin:
|
||||
def _login_impl(self) -> HuyaLoginResult:
|
||||
"""登录主体 (原 login)。"""
|
||||
acct = self.username
|
||||
logger.info(f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})...")
|
||||
logger.info(
|
||||
f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})..."
|
||||
)
|
||||
|
||||
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
||||
try:
|
||||
@@ -442,7 +459,9 @@ class HuyaAppPasswordLogin:
|
||||
|
||||
# 3) 信封补丁
|
||||
raw = bytearray(env.raw)
|
||||
raw[env.cert_off:env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
if env.cert_off is None or env.uid_off is None:
|
||||
raise ValueError("信封缺少证书或 uid 偏移")
|
||||
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
if env.uid != uid:
|
||||
struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
||||
@@ -457,9 +476,11 @@ class HuyaAppPasswordLogin:
|
||||
try:
|
||||
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
||||
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
||||
sdid_obj = get_huya_sdid(allow_fallback=True,
|
||||
state_dir=account_state_dir(self.username),
|
||||
device_hint=self.device_info)
|
||||
sdid_obj = get_huya_sdid(
|
||||
allow_fallback=True,
|
||||
state_dir=account_state_dir(self.username),
|
||||
device_hint=self.device_info,
|
||||
)
|
||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
|
||||
@@ -502,7 +523,9 @@ class HuyaAppPasswordLogin:
|
||||
},
|
||||
)
|
||||
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
|
||||
logger.warning(f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}")
|
||||
logger.warning(
|
||||
f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}"
|
||||
)
|
||||
|
||||
# 4.3 轮询 tryQrLogin
|
||||
biztoken = None
|
||||
@@ -552,7 +575,9 @@ class HuyaAppPasswordLogin:
|
||||
code="COOKIE_INCOMPLETE",
|
||||
)
|
||||
|
||||
logger.info(f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)")
|
||||
logger.info(
|
||||
f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)"
|
||||
)
|
||||
return HuyaLoginResult(
|
||||
success=True,
|
||||
cookie=cookie_str,
|
||||
|
||||
@@ -75,7 +75,9 @@ def generate_huya_password(prefix: str = "hy", random_length: int = 8) -> str:
|
||||
"""生成适合虎牙账号使用的随机密码。"""
|
||||
clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy"
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
suffix = "".join(random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length)))
|
||||
suffix = "".join(
|
||||
random.SystemRandom().choice(alphabet) for _ in range(max(6, random_length))
|
||||
)
|
||||
return f"{clean_prefix}{suffix}"
|
||||
|
||||
|
||||
@@ -86,13 +88,15 @@ def encode_change_password_behavior(stage: str) -> str:
|
||||
actions: list[dict] = []
|
||||
|
||||
if stage in {"send", "submit"}:
|
||||
actions.append({
|
||||
"id": "pmodify.btn.getsms",
|
||||
"x": random.randint(470, 520),
|
||||
"y": random.randint(120, 145),
|
||||
"d": elapsed,
|
||||
"time": now,
|
||||
})
|
||||
actions.append(
|
||||
{
|
||||
"id": "pmodify.btn.getsms",
|
||||
"x": random.randint(470, 520),
|
||||
"y": random.randint(120, 145),
|
||||
"d": elapsed,
|
||||
"time": now,
|
||||
}
|
||||
)
|
||||
if stage == "submit":
|
||||
for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"):
|
||||
now += random.randint(600, 3200)
|
||||
@@ -100,20 +104,24 @@ def encode_change_password_behavior(stage: str) -> str:
|
||||
actions.append({"id": action_id, "d": elapsed, "time": now})
|
||||
now += random.randint(800, 2400)
|
||||
elapsed += random.randint(800, 2400)
|
||||
actions.append({
|
||||
"id": "pmodify.btn.submit",
|
||||
"x": random.randint(395, 445),
|
||||
"y": random.randint(220, 245),
|
||||
"d": elapsed,
|
||||
"time": now,
|
||||
})
|
||||
actions.append(
|
||||
{
|
||||
"id": "pmodify.btn.submit",
|
||||
"x": random.randint(395, 445),
|
||||
"y": random.randint(220, 245),
|
||||
"d": elapsed,
|
||||
"time": now,
|
||||
}
|
||||
)
|
||||
|
||||
value = {
|
||||
"furl": CHANGE_PASSWORD_FROM_URL,
|
||||
"curl": CHANGE_PASSWORD_PAGE_URL,
|
||||
"user_action": actions,
|
||||
}
|
||||
return quote(json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'")
|
||||
return quote(
|
||||
json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'"
|
||||
)
|
||||
|
||||
|
||||
def _sleep_or_stop(stop_event: threading.Event | None, seconds: float) -> bool:
|
||||
@@ -132,7 +140,13 @@ def _payload_data(payload: dict) -> dict:
|
||||
|
||||
def _session_data(payload: dict, fallback: str = "") -> str:
|
||||
data = _payload_data(payload)
|
||||
return str(data.get("sessionData") or data.get("sessiondata") or payload.get("sessionData") or fallback or "")
|
||||
return str(
|
||||
data.get("sessionData")
|
||||
or data.get("sessiondata")
|
||||
or payload.get("sessionData")
|
||||
or fallback
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
class HuyaPasswordChanger:
|
||||
@@ -174,23 +188,25 @@ class HuyaPasswordChanger:
|
||||
self._setup_headers()
|
||||
|
||||
def _setup_headers(self) -> None:
|
||||
self.session.headers.update({
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Origin": "https://udbreg.huya.com",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": self.middle_url,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"User-Agent": self.ua,
|
||||
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
})
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Origin": "https://udbreg.huya.com",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": self.middle_url,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"User-Agent": self.ua,
|
||||
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_url(url: str) -> str:
|
||||
@@ -199,7 +215,9 @@ class HuyaPasswordChanger:
|
||||
|
||||
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
|
||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
||||
logger.debug(f"{method.upper()} {self._safe_url(url)} -> {response.status_code}")
|
||||
logger.debug(
|
||||
f"{method.upper()} {self._safe_url(url)} -> {response.status_code}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
return response.json()
|
||||
@@ -216,20 +234,26 @@ class HuyaPasswordChanger:
|
||||
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
|
||||
except requests.RequestException as exc:
|
||||
logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}")
|
||||
self.session.headers.update({
|
||||
"Origin": "https://udbreg.huya.com",
|
||||
"Referer": self.middle_url,
|
||||
})
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Origin": "https://udbreg.huya.com",
|
||||
"Referer": self.middle_url,
|
||||
}
|
||||
)
|
||||
|
||||
def prepare_device(self) -> str:
|
||||
"""获取虎牙风控 sdid。"""
|
||||
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
|
||||
token_res = self._request_json("post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload)
|
||||
token_res = self._request_json(
|
||||
"post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload
|
||||
)
|
||||
token = token_res.get("data", {}).get("token")
|
||||
if not token:
|
||||
raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}")
|
||||
|
||||
collect_res = self._request_json("post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token})
|
||||
collect_res = self._request_json(
|
||||
"post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token}
|
||||
)
|
||||
self.sdid = collect_res.get("data", {}).get("sdid", "")
|
||||
if not self.sdid:
|
||||
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
|
||||
@@ -240,7 +264,11 @@ class HuyaPasswordChanger:
|
||||
from .verification import HuyaVerificationSolver
|
||||
|
||||
solver = HuyaVerificationSolver(
|
||||
cookie=self.session.cookies.get_dict(),
|
||||
cookie={
|
||||
key: value
|
||||
for key, value in self.session.cookies.get_dict().items()
|
||||
if value is not None
|
||||
},
|
||||
ua=self.ua,
|
||||
sdid=self.sdid,
|
||||
session=self.session,
|
||||
@@ -402,12 +430,16 @@ class HuyaPasswordChanger:
|
||||
request_id=self.request_id,
|
||||
)
|
||||
|
||||
def submit_code(self, password: str, sms_code: str, session_data: str) -> HuyaChangePasswordResult:
|
||||
def submit_code(
|
||||
self, password: str, sms_code: str, session_data: str
|
||||
) -> HuyaChangePasswordResult:
|
||||
"""提交改密短信验证码。"""
|
||||
if not session_data:
|
||||
raise HuyaLoginError("缺少改密 sessionData,请先发送改密短信")
|
||||
|
||||
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data)
|
||||
payload = self._submit_once(
|
||||
password=password, sms_code=sms_code, session_data=session_data
|
||||
)
|
||||
for index in range(3):
|
||||
return_code = int(payload.get("returnCode") or 0)
|
||||
if return_code == 0:
|
||||
@@ -436,7 +468,9 @@ class HuyaPasswordChanger:
|
||||
logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)")
|
||||
self._solve_verification(payload)
|
||||
time.sleep(0.5)
|
||||
payload = self._submit_once(password=password, sms_code=sms_code, session_data=session_data)
|
||||
payload = self._submit_once(
|
||||
password=password, sms_code=sms_code, session_data=session_data
|
||||
)
|
||||
|
||||
return HuyaChangePasswordResult(
|
||||
success=False,
|
||||
|
||||
+36
-21
@@ -1,14 +1,23 @@
|
||||
"""
|
||||
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
from .taf_protocol import TafInputStream, TafType
|
||||
from .wup_protocol import normalize_wup_payload
|
||||
|
||||
# cmd 编号 → 名称
|
||||
CMD_NAMES = {
|
||||
0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH",
|
||||
0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV",
|
||||
0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP",
|
||||
0x03: "RPC_REQ",
|
||||
0x04: "RPC_RSP",
|
||||
0x0A: "AUTH",
|
||||
0x0B: "PUSH1",
|
||||
0x10: "HB_SEND",
|
||||
0x11: "HB_RECV",
|
||||
0x17: "CONFIRM",
|
||||
0x18: "PUSH2",
|
||||
0x21: "REGISTER",
|
||||
0x22: "CONFIRM_RSP",
|
||||
}
|
||||
|
||||
# TAIL_BYTES = 2c36004c5c6600
|
||||
@@ -18,10 +27,14 @@ _TAIL = bytes.fromhex("2c36004c5c6600")
|
||||
def _strip_tail(body: bytes) -> bytes:
|
||||
"""裁掉 body 末尾的 TAIL_BYTES"""
|
||||
if body.endswith(_TAIL):
|
||||
return body[:-len(_TAIL)]
|
||||
return body[: -len(_TAIL)]
|
||||
# 有时 TAIL 前还有 0c (ZERO tag)
|
||||
if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL):
|
||||
return body[:-len(_TAIL)-1]
|
||||
if (
|
||||
len(body) > 1
|
||||
and body[-len(_TAIL) - 1 : -len(_TAIL)] == b"\x0c"
|
||||
and body.endswith(_TAIL)
|
||||
):
|
||||
return body[: -len(_TAIL) - 1]
|
||||
return body
|
||||
|
||||
|
||||
@@ -33,16 +46,18 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
|
||||
return ins._read_int_value(dtype)
|
||||
if dtype in (TafType.FLOAT, TafType.DOUBLE):
|
||||
import struct as _s
|
||||
|
||||
if dtype == TafType.FLOAT:
|
||||
return round(_s.unpack('>f', ins.buf.read(4))[0], 4)
|
||||
return round(_s.unpack('>d', ins.buf.read(8))[0], 6)
|
||||
return round(_s.unpack(">f", ins.buf.read(4))[0], 4)
|
||||
return round(_s.unpack(">d", ins.buf.read(8))[0], 6)
|
||||
if dtype == TafType.STRING1:
|
||||
ln = ins.buf.read(1)[0]
|
||||
return ins.buf.read(ln).decode('utf-8', errors='replace')
|
||||
return ins.buf.read(ln).decode("utf-8", errors="replace")
|
||||
if dtype == TafType.STRING4:
|
||||
import struct as _s
|
||||
ln = _s.unpack('>I', ins.buf.read(4))[0]
|
||||
return ins.buf.read(ln).decode('utf-8', errors='replace')
|
||||
|
||||
ln = _s.unpack(">I", ins.buf.read(4))[0]
|
||||
return ins.buf.read(ln).decode("utf-8", errors="replace")
|
||||
if dtype == TafType.MAP:
|
||||
cnt = ins._read_int_len()
|
||||
m = {}
|
||||
@@ -106,14 +121,14 @@ def _extract_wup(body: bytes) -> bytes:
|
||||
return body
|
||||
# 大包格式: [1B prefix][4B wup_len][wup_body][tail]
|
||||
# prefix 可能是 0x00,必须优先于 4B total_len 判断。
|
||||
wup_len = int.from_bytes(body[1:5], 'big')
|
||||
if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03':
|
||||
return body[5:5 + wup_len]
|
||||
total_len = int.from_bytes(body[0:4], 'big')
|
||||
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03':
|
||||
wup_len = int.from_bytes(body[1:5], "big")
|
||||
if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03":
|
||||
return body[5 : 5 + wup_len]
|
||||
total_len = int.from_bytes(body[0:4], "big")
|
||||
if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03":
|
||||
return body[:total_len]
|
||||
if 5 + wup_len <= len(body):
|
||||
return body[5:5 + wup_len]
|
||||
return body[5 : 5 + wup_len]
|
||||
return body
|
||||
|
||||
|
||||
@@ -127,7 +142,7 @@ def _decode_wup_body(body: bytes) -> dict:
|
||||
ins = TafInputStream(wup)
|
||||
|
||||
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
|
||||
sBuffer = b''
|
||||
sBuffer = b""
|
||||
while True:
|
||||
try:
|
||||
tag, dtype = ins.peek_head()
|
||||
@@ -267,8 +282,8 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
|
||||
return f"{prefix} {label}"
|
||||
|
||||
# AUTH
|
||||
if cmd == 0x0a:
|
||||
text = _strip_tail(body).decode('utf-8', errors='replace')
|
||||
if cmd == 0x0A:
|
||||
text = _strip_tail(body).decode("utf-8", errors="replace")
|
||||
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
|
||||
|
||||
# REGISTER / CONFIRM / PUSH 等
|
||||
@@ -302,7 +317,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
|
||||
fields = {}
|
||||
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||
if fields:
|
||||
return f"{prefix} {cmd_name} {_fmt_fields(_truncate(fields))}"
|
||||
return f"{prefix} {cmd_name} {_fmt_fields(cast(dict[str, Any], _truncate(fields)))}"
|
||||
return f"{prefix} {cmd_name}"
|
||||
except Exception:
|
||||
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||
|
||||
Reference in New Issue
Block a user