type: 收敛测试 schemas 与协议层类型

This commit is contained in:
yml2213
2026-08-30 20:35:08 +08:00
parent 92dc461e52
commit c891ac982e
26 changed files with 1846 additions and 882 deletions
+70 -37
View File
@@ -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
View File
@@ -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
+18 -7
View File
@@ -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]:
+17 -7
View File
@@ -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()
+1 -1
View File
@@ -17,7 +17,7 @@ class DouyuWhitelistSyncer:
def __init__(
self,
platform: str = "xiequ",
credentials: dict = None,
credentials: Optional[dict] = None,
uid: str = "",
ukey: str = "",
):
+66 -71
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+79 -45
View File
@@ -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
View File
@@ -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}")