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}")
+1 -1
View File
@@ -54,7 +54,7 @@ class TestEliteLockExchange:
assert result["order_id"] == "5874"
assert result["exchange_id"] == "11098933"
assert self.client.csrf_token.call_count == 2
assert self.client.__dict__["csrf_token"].call_count == 2
create_call, pay_call = self.client._request_json.call_args_list
assert create_call.args[:3] == (
"post",
+3 -1
View File
@@ -80,7 +80,9 @@ class TestDouyuProxyMode:
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
assert runner._proxies_for_task()["http"] == "http://10.0.0.3:3128"
refreshed = runner._proxies_for_task()
assert refreshed is not None
assert refreshed["http"] == "http://10.0.0.3:3128"
assert fake.fetch_new_proxy.call_count == 3
def test_api_mode_failure_falls_back_to_direct(self):
+1
View File
@@ -226,6 +226,7 @@ class TestHuyaAppLogin:
.filter(HuyaAccount.username == "mock_test_huya_user")
.first()
)
assert acc is not None
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
batch_resp = app_password_login_selected_accounts(
req=batch_req, db=self.session, current=self.admin
+2 -1
View File
@@ -11,6 +11,7 @@
import pytest
import base64
import json
import pytest
from pathlib import Path
from unittest.mock import patch
@@ -69,7 +70,7 @@ class TestParseResponse:
def test_parse_golden_evidence(self):
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
if not CHAIN_FILE.exists():
self.skipTest("缺失 golden 注册链模板")
pytest.skip("缺失 golden 注册链模板")
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
t1, t2, t5 = _parse_response(resp)
+5 -5
View File
@@ -96,11 +96,11 @@ class TestMigrationSmoke:
missing_columns = model_columns - db_columns
assert not missing_columns
db_indexes = {
index["name"]
for index in inspector.get_indexes(name)
if not index["name"].startswith("sqlite_autoindex")
}
db_indexes = set()
for index in inspector.get_indexes(name):
index_name = index["name"]
if index_name and not index_name.startswith("sqlite_autoindex"):
db_indexes.add(index_name)
all_db_indexes |= db_indexes
model_indexes = {
index.name for index in table.indexes if index.name
+5 -2
View File
@@ -34,9 +34,10 @@ def get_current_user(
payload = decode_access_token(token)
if payload is None:
raise credentials_exc
user_id: int = payload.get("sub")
if user_id is None:
user_id_raw = payload.get("sub")
if user_id_raw is None:
raise credentials_exc
user_id = int(user_id_raw)
except JWTError:
raise credentials_exc
@@ -48,6 +49,7 @@ def get_current_user(
def require_permission(permission: str):
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
def checker(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_active:
raise HTTPException(status_code=403, detail="账号已禁用")
@@ -55,6 +57,7 @@ def require_permission(permission: str):
if permission not in perms:
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
return current_user
return checker
+193 -73
View File
@@ -1,16 +1,32 @@
"""账号管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
from ..models import (
User,
Account,
AuditLog,
LoginTask,
DouyuTask,
DouyuWorkbenchAccount,
)
from ..schemas import (
AccountBulkSelection,
AccountBulkTag,
AccountImport,
AccountAssign,
AccountTag,
AccountOut,
BatchAssign,
)
from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission
from ..services.account_service import (
cookie_account_ids_query, parse_and_build_accounts,
cookie_account_ids_query,
parse_and_build_accounts,
)
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
@@ -46,15 +62,19 @@ def _filter_accounts_query(
search_text = (search or "").strip()
if search_text:
pattern = f"%{search_text}%"
query = query.filter(or_(
Account.username.ilike(pattern),
Account.tag.ilike(pattern),
Account.remark.ilike(pattern),
))
query = query.filter(
or_(
Account.username.ilike(pattern),
Account.tag.ilike(pattern),
Account.remark.ilike(pattern),
)
)
return query
def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection) -> list[int]:
def _selected_account_ids(
db: Session, current: User, req: AccountBulkSelection
) -> list[int]:
"""解析批量操作目标:当前筛选全部或显式选择的 ID。"""
if req.all_matching:
rows = (
@@ -71,7 +91,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
.order_by(None)
.all()
)
return [account_id for account_id, in rows]
return [account_id for (account_id,) in rows]
seen = set()
ids = []
@@ -88,7 +108,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
.order_by(None)
.all()
)
allowed = {account_id for account_id, in rows}
allowed = {account_id for (account_id,) in rows}
return [account_id for account_id in ids if account_id in allowed]
@@ -131,19 +151,25 @@ def list_accounts(
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
can_include_sensitive = include_sensitive and user_has_permission(current, "account:view_full")
can_include_sensitive = include_sensitive and user_has_permission(
current, "account:view_full"
)
options = [joinedload(Account.assigned_user)]
if not can_include_sensitive:
options.extend([
defer(Account.password),
defer(Account.email),
defer(Account.email_password),
])
options.extend(
[
defer(Account.password),
defer(Account.email),
defer(Account.email_password),
]
)
accounts = query.options(*options).all()
result = []
for acc in accounts:
item = AccountOut(
id=acc.id, username=acc.username, remark=acc.remark or "",
id=acc.id,
username=acc.username,
remark=acc.remark or "",
tag=acc.tag or "",
assigned_to=acc.assigned_to,
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
@@ -156,7 +182,12 @@ def list_accounts(
item.email_password = acc.email_password
result.append(item)
if page is not None:
return {"items": result, "total": total or 0, "page": page, "page_size": page_size}
return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result
@@ -209,8 +240,14 @@ def import_accounts(
if accounts:
db.add_all(accounts)
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:import", target=f"导入{len(accounts)}"))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:import",
target=f"导入{len(accounts)}",
)
)
db.commit()
duplicated_note = f",重复跳过 {duplicated}" if duplicated else ""
@@ -240,17 +277,27 @@ def assign_account(
raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配已成功登录过的账号(有cookie)
has_success = db.query(LoginTask).filter(
LoginTask.account_id == account_id,
LoginTask.status == 'success',
LoginTask.cookie != '',
).first()
has_success = (
db.query(LoginTask)
.filter(
LoginTask.account_id == account_id,
LoginTask.status == "success",
LoginTask.cookie != "",
)
.first()
)
if not has_success:
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
acc.assigned_to = req.assigned_to
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:assign", target=acc.username))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=acc.username,
)
)
db.commit()
return {"message": "已分配", "success": True}
@@ -274,31 +321,52 @@ def batch_assign_accounts(
raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配已成功登录过的账号(有cookie)
cookie_ids_query = cookie_account_ids_query(db).subquery()
invalid_ids = db.query(Account.id).filter(
Account.id.in_(req.account_ids),
Account.id.notin_(cookie_ids_query),
).all()
cookie_ids_query = select(LoginTask.account_id).where(
LoginTask.status == "success",
LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
invalid_ids = (
db.query(Account.id)
.filter(
Account.id.in_(req.account_ids),
Account.id.notin_(cookie_ids_query),
)
.all()
)
if invalid_ids:
names = db.query(Account.username).filter(Account.id.in_([i[0] for i in invalid_ids])).all()
name_list = ', '.join([n[0] for n in names[:5]])
suffix = '...' if len(invalid_ids) > 5 else ''
names = (
db.query(Account.username)
.filter(Account.id.in_([i[0] for i in invalid_ids]))
.all()
)
name_list = ", ".join([n[0] for n in names[:5]])
suffix = "..." if len(invalid_ids) > 5 else ""
raise HTTPException(
status_code=400,
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
)
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
{Account.assigned_to: req.assigned_to}, synchronize_session=False
count = (
db.query(Account)
.filter(Account.id.in_(req.account_ids))
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号",
)
)
db.add(AuditLog(
user_id=current.id, username=current.username,
action="account:assign",
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号"
))
db.commit()
action = "分配" if req.assigned_to else "取消分配"
return {"message": f"已批量{action} {count} 个账号", "success": True, "count": count}
return {
"message": f"已批量{action} {count} 个账号",
"success": True,
"count": count,
}
@router.get("/assignments/summary")
@@ -307,11 +375,17 @@ def assignments_summary(
current: User = Depends(require_permission("account:assign")),
):
"""分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。"""
cookie_subq = cookie_account_ids_query(db).subquery()
cookie_subq = select(LoginTask.account_id).where(
LoginTask.status == "success",
LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
results = (
db.query(User.id, User.username, func.count(cookie_accounts.c.id).label("count"))
db.query(
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
)
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
.filter(User.role == "support")
.group_by(User.id, User.username)
@@ -345,8 +419,15 @@ def set_account_tag(
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
acc.tag = (req.tag or "").strip()
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:tag", target=acc.username, detail=acc.tag))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:tag",
target=acc.username,
detail=acc.tag,
)
)
db.commit()
return {"message": "标签已更新", "success": True}
@@ -362,8 +443,10 @@ def batch_tag(
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag or "").strip()
count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update(
{Account.tag: tag}, synchronize_session=False
count = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(req.account_ids))
.update({Account.tag: tag}, synchronize_session=False)
)
db.commit()
return {"message": f"已为 {count} 个账号设置标签", "success": True}
@@ -388,7 +471,11 @@ def batch_tag_selection(
)
db.commit()
scope = "当前筛选下" if req.all_matching else "选中的"
return {"message": f"已为{scope} {count} 个账号设置标签", "success": True, "count": count}
return {
"message": f"已为{scope} {count} 个账号设置标签",
"success": True,
"count": count,
}
@router.get("/tags/list")
@@ -422,17 +509,29 @@ def batch_delete_accounts(
raise HTTPException(status_code=400, detail="无效的账号ID")
# 先删除关联的登录任务
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id.in_(ids)
).delete(synchronize_session=False)
# 删除账号
deleted = db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
deleted = (
db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
)
db.add(AuditLog(
user_id=current.id, username=current.username,
action="account:delete", target=f"批量删除{deleted}个账号"
))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=f"批量删除{deleted}个账号",
)
)
db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -448,19 +547,28 @@ def batch_delete_accounts_selection(
if not ids:
raise HTTPException(status_code=400, detail="请选择账号")
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id.in_(ids)).delete(synchronize_session=False)
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id.in_(ids)
).delete(synchronize_session=False)
deleted = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(ids))
.delete(synchronize_session=False)
)
db.add(AuditLog(
user_id=current.id, username=current.username,
action="account:delete",
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
)
)
db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -476,12 +584,24 @@ def delete_account(
raise HTTPException(status_code=404, detail="账号不存在")
# 先删除关联的登录任务,避免外键约束失败
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False)
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.account_id == account_id).delete(synchronize_session=False)
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(
synchronize_session=False
)
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(
synchronize_session=False
)
db.query(DouyuWorkbenchAccount).filter(
DouyuWorkbenchAccount.account_id == account_id
).delete(synchronize_session=False)
db.add(AuditLog(user_id=current.id, username=current.username,
action="account:delete", target=acc.username))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=acc.username,
)
)
db.delete(acc)
db.commit()
return {"message": "已删除", "success": True}
+148 -77
View File
@@ -27,6 +27,7 @@ def _fmt_dt(dt) -> str | None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
cookie_relogin_registry = BatchRegistry()
@@ -44,7 +45,9 @@ def _parse_account_names(raw_names: str) -> list[str]:
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
names = []
seen = set()
for value in (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
for value in (
(raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
):
name = value.strip()
if name and name not in seen:
names.append(name)
@@ -60,7 +63,9 @@ def _order_cookie_tasks(query, selected_names: list[str]):
value=Account.username,
else_=len(selected_names),
)
return query.order_by(input_order, LoginTask.finished_at.desc(), LoginTask.id.desc())
return query.order_by(
input_order, LoginTask.finished_at.desc(), LoginTask.id.desc()
)
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
@@ -88,7 +93,9 @@ def _visible_cookie_operation_tasks_query(db: Session, current: User):
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
query = db.query(LoginTask).filter(
LoginTask.cookie != "",
LoginTask.status.in_(("success", "relogin_pending", "relogin_running", "relogin_failed")),
LoginTask.status.in_(
("success", "relogin_pending", "relogin_running", "relogin_failed")
),
)
if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
@@ -112,7 +119,9 @@ def list_cookies(
if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view")
if not include_cookie:
query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie))
query = _visible_cookie_tasks_query(db, current).options(
defer(LoginTask.cookie)
)
else:
query = _visible_cookie_tasks_query(db, current).options(
joinedload(LoginTask.account).joinedload(Account.assigned_user),
@@ -137,15 +146,19 @@ def list_cookies(
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_(
Account.username.ilike(pattern),
Account.tag.ilike(pattern),
User.username.ilike(pattern),
))
query = query.outerjoin(User, Account.assigned_to == User.id).filter(
or_(
Account.username.ilike(pattern),
Account.tag.ilike(pattern),
User.username.ilike(pattern),
)
)
total = None
if page is not None:
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
total = (
query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
)
query = _order_cookie_tasks(query, selected_names)
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
@@ -175,8 +188,10 @@ def list_cookies(
"batch_id": t.batch_id,
"account_id": t.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(t.finished_at),
"ck_check_status": t.ck_check_status or "",
"ck_check_result": t.ck_check_result,
@@ -194,7 +209,12 @@ def list_cookies(
item["account_password"] = ""
result.append(item)
if page is not None:
return {"items": result, "total": total or 0, "page": page, "page_size": page_size}
return {
"items": result,
"total": total or 0,
"page": page,
"page_size": page_size,
}
return result
@@ -242,10 +262,12 @@ def list_cookie_operations(
if tag_value:
query = query.filter(Account.tag == tag_value)
if search_text:
query = query.filter(or_(
Account.username.ilike(f"%{search_text}%"),
Account.tag.ilike(f"%{search_text}%"),
))
query = query.filter(
or_(
Account.username.ilike(f"%{search_text}%"),
Account.tag.ilike(f"%{search_text}%"),
)
)
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
tasks = (
@@ -265,7 +287,9 @@ def list_cookie_operations(
"created_at": _fmt_dt(task.finished_at),
"relogin_status": task.status if task.status != "success" else "",
"relogin_message": task.message or "",
"relogin_batch_id": task.batch_id if task.status in {"relogin_pending", "relogin_running"} else "",
"relogin_batch_id": task.batch_id
if task.status in {"relogin_pending", "relogin_running"}
else "",
}
for task in tasks
],
@@ -292,7 +316,7 @@ def list_cookie_operation_tags(
.order_by(Account.tag.asc())
.all()
)
return [tag for tag, in rows if tag]
return [tag for (tag,) in rows if tag]
@router.get("/duplicates")
@@ -317,7 +341,9 @@ def find_duplicate_cookies(
if not user_has_permission(current, "login:view_all"):
query = query.filter(Account.assigned_to == current.id)
rows = query.order_by(Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()).all()
rows = query.order_by(
Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()
).all()
grouped: dict[str, dict] = {}
for row in rows:
username = (row.username or "").strip()
@@ -337,26 +363,30 @@ def find_duplicate_cookies(
group["account_names"].add(username)
group["account_ids"].add(row.account_id)
group["cookie_ids"].append(row.cookie_id)
group["records"].append({
"id": row.cookie_id,
"account_id": row.account_id,
"batch_id": row.batch_id,
"finished_at": _fmt_dt(row.finished_at),
})
group["records"].append(
{
"id": row.cookie_id,
"account_id": row.account_id,
"batch_id": row.batch_id,
"finished_at": _fmt_dt(row.finished_at),
}
)
duplicate_groups = []
for group in grouped.values():
if len(group["cookie_ids"]) < 2:
continue
duplicate_groups.append({
"account_key": group["account_key"],
"account_names": sorted(group["account_names"]),
"cookie_count": len(group["cookie_ids"]),
"account_count": len(group["account_ids"]),
"cookie_ids": group["cookie_ids"],
"account_ids": sorted(group["account_ids"]),
"records": group["records"],
})
duplicate_groups.append(
{
"account_key": group["account_key"],
"account_names": sorted(group["account_names"]),
"cookie_count": len(group["cookie_ids"]),
"account_count": len(group["account_ids"]),
"cookie_ids": group["cookie_ids"],
"account_ids": sorted(group["account_ids"]),
"records": group["records"],
}
)
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
return {
@@ -432,10 +462,16 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
if not id_list:
raise HTTPException(status_code=400, detail="无效的ID")
base_query = _visible_cookie_tasks_query(db, current) if detailed else _visible_cookie_operation_tasks_query(db, current)
tasks = base_query.filter(LoginTask.id.in_(id_list)).filter(
LoginTask.status.in_(("success", "relogin_failed"))
).all()
base_query = (
_visible_cookie_tasks_query(db, current)
if detailed
else _visible_cookie_operation_tasks_query(db, current)
)
tasks = (
base_query.filter(LoginTask.id.in_(id_list))
.filter(LoginTask.status.in_(("success", "relogin_failed")))
.all()
)
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
@@ -447,15 +483,17 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
results.append(future.result())
except Exception as exc:
task = futures[future]
results.append({
"id": task.id,
"valid": False,
"message": f"检测异常: {exc}",
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": datetime.now(timezone.utc).isoformat(),
})
results.append(
{
"id": task.id,
"valid": False,
"message": f"检测异常: {exc}",
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": datetime.now(timezone.utc).isoformat(),
}
)
results.sort(key=lambda item: item["id"])
# 持久化检测结果,刷新/翻页不丢失
@@ -472,12 +510,14 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
"message": item.get("message", ""),
}
task.ck_checked_at = datetime.now(timezone.utc)
db.add(AuditLog(
user_id=current.id,
username=current.username,
action="cookie:check",
target=f"检测 {len(results)} 条已分配账号 CK",
))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:check",
target=f"检测 {len(results)} 条已分配账号 CK",
)
)
db.commit()
if not detailed:
results = [
@@ -510,7 +550,13 @@ def check_cookie_operations(
return _check_cookies(ids, db, current, detailed=False)
def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, action: str = "cookie:relogin"):
def _start_relogin_tasks(
tasks: list[LoginTask],
db: Session,
current: User,
*,
action: str = "cookie:relogin",
):
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
@@ -538,7 +584,9 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
if not task_ids:
db.commit()
raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态")
raise HTTPException(
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
)
proxy = db.query(ProxyConfigModel).first()
thread_db = SessionLocal()
@@ -558,13 +606,15 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
relogin_task_ids=task_ids,
)
batch_id = runner.batch_id
db.add(AuditLog(
user_id=current.id,
username=current.username,
action=action,
target=f"重登 {len(task_ids)} 条账号 CK",
detail="旧 Cookie 将在新登录成功后替换",
))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action=action,
target=f"重登 {len(task_ids)} 条账号 CK",
detail="旧 Cookie 将在新登录成功后替换",
)
)
db.commit()
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
@@ -599,19 +649,28 @@ def stop_cookie_relogin(
_require_cookie_operation_perm(current)
batch = cookie_relogin_registry.get(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启")
if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"):
raise HTTPException(
status_code=404, detail="重登批次不存在、已结束或服务已重启"
)
if batch.get("owner_id") != current.id and not user_has_permission(
current, "login:view_all"
):
raise HTTPException(status_code=403, detail="无权限停止该重登批次")
batch["runner"].stop()
db.add(AuditLog(
user_id=current.id,
username=current.username,
action="cookie:relogin_stop",
target=f"停止 CK 重登批次 {batch_id}",
))
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:relogin_stop",
target=f"停止 CK 重登批次 {batch_id}",
)
)
db.commit()
return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True}
return {
"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止",
"success": True,
}
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
@@ -667,7 +726,9 @@ def relogin_invalid_cookie_operations(
query = query.filter(Account.tag == tag.strip())
if search.strip():
pattern = f"%{search.strip()}%"
query = query.filter(or_(Account.username.ilike(pattern), Account.tag.ilike(pattern)))
query = query.filter(
or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))
)
tasks = query.order_by(LoginTask.id.asc()).all()
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
@@ -681,7 +742,9 @@ def get_cookie(
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view")
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
task = (
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
)
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
acc = db.query(Account).filter(Account.id == task.account_id).first()
@@ -691,7 +754,9 @@ def get_cookie(
"account_id": task.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(task.finished_at),
"ck_check_status": task.ck_check_status or "",
"ck_check_result": task.ck_check_result,
@@ -726,7 +791,11 @@ def delete_cookies_batch(
t.status = "failed"
t.message = "Cookie已清除"
db.commit()
return {"message": f"已删除 {len(tasks)}", "deleted": len(tasks), "success": True}
return {
"message": f"已删除 {len(tasks)}",
"deleted": len(tasks),
"success": True,
}
@router.delete("/{task_id}")
@@ -736,7 +805,9 @@ def delete_cookie(
current: User = Depends(require_permission("cookie:export")),
):
"""删除一条 Cookie 记录。"""
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
task = (
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
)
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
task.cookie = ""
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -17,6 +17,8 @@ if not SECRET_KEY:
stacklevel=2,
)
SECRET_KEY = secrets.token_urlsafe(32)
assert SECRET_KEY is not None
SECRET_KEY_TYPED: str = SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
@@ -35,15 +37,17 @@ def verify_password(plain: str, hashed: str) -> bool:
return False
def create_access_token(data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS) -> str:
def create_access_token(
data: dict, expires_hours: int = ACCESS_TOKEN_EXPIRE_HOURS
) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return jwt.encode(to_encode, SECRET_KEY_TYPED, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
except JWTError:
return None
+77 -30
View File
@@ -10,9 +10,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Optional
from typing import Optional, cast
from core.douyu import DouyuLogin, WgapiLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import ProxyConfig as ProxyConfigModel
@@ -118,13 +119,15 @@ def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
f"或 账号|密码|邮箱|邮箱密码"
)
accounts.append(AccountCheckInput(
line=line_no,
username=parts[0],
password=parts[1],
email=parts[2],
email_password=parts[3],
))
accounts.append(
AccountCheckInput(
line=line_no,
username=parts[0],
password=parts[1],
email=parts[2],
email_password=parts[3],
)
)
return accounts
@@ -158,9 +161,15 @@ class AccountCheckRunner:
wl_platform = "xiequ"
wl_credentials = None
if self.proxy_config.whitelist_enabled:
wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey:
if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = {
"uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey,
@@ -209,25 +218,38 @@ class AccountCheckRunner:
def _run_one(self, index: int, account: AccountCheckInput):
if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
self._set_item(
index, status="stopped", message="已停止", finished_at=_now()
)
return
proxy_dict, proxy_error = self._resolve_static_proxy()
if proxy_error:
self._set_item(index, status="error", message=proxy_error, finished_at=_now())
self._set_item(
index, status="error", message=proxy_error, finished_at=_now()
)
return
self._set_item(index, status="running", message="检测中", started_at=_now(), finished_at=None)
self._set_item(
index,
status="running",
message="检测中",
started_at=_now(),
finished_at=None,
)
try:
result = DouyuLogin(
SimpleNamespace(
username=account.username,
password=account.password,
email=account.email,
email_password=account.email_password,
email_imap_server="",
email_imap_port=993,
email_imap_ssl=True,
cast(
AccountLike,
SimpleNamespace(
username=account.username,
password=account.password,
email=account.email,
email_password=account.email_password,
email_imap_server="",
email_imap_port=993,
email_imap_ssl=True,
),
),
proxy=proxy_dict,
max_login_retries=self.batch.max_login_retries,
@@ -237,15 +259,24 @@ class AccountCheckRunner:
api_strategy=WgapiLoginAPI(),
).check_account()
except Exception as exc:
self._set_item(index, status="error", message=f"检测异常: {exc}", finished_at=_now())
self._set_item(
index, status="error", message=f"检测异常: {exc}", finished_at=_now()
)
return
if self._stop.is_set() and not result.success:
self._set_item(index, status="stopped", message=result.message or "已停止", finished_at=_now())
self._set_item(
index,
status="stopped",
message=result.message or "已停止",
finished_at=_now(),
)
return
if result.success:
status = result.code if result.code in STATUS_LABELS else "account_auth_unknown"
status = (
result.code if result.code in STATUS_LABELS else "account_auth_unknown"
)
message = result.message or STATUS_LABELS.get(status, "认证状态未知")
else:
status = "error"
@@ -260,7 +291,9 @@ class AccountCheckRunner:
status: sum(1 for item in self.batch.items if item.status == status)
for status in STATUS_LABELS
}
running_count = sum(1 for item in self.batch.items if item.status in {"pending", "running"})
running_count = sum(
1 for item in self.batch.items if item.status in {"pending", "running"}
)
finished_count = len(self.batch.items) - running_count
return {
"batch_id": self.batch.batch_id,
@@ -296,7 +329,10 @@ class AccountCheckRunner:
if item.status != status:
continue
line = item.export_text
if status in {"error", "stopped", "account_auth_unknown"} and item.message:
if (
status in {"error", "stopped", "account_auth_unknown"}
and item.message
):
line = f"{line}----{item.message}"
lines.append(line)
content = "\n".join(lines)
@@ -309,7 +345,9 @@ class AccountCheckRunner:
def run(self):
"""线程入口。"""
self._set_batch(status="running", message="批次运行中", started_at=_now(), finished_at=None)
self._set_batch(
status="running", message="批次运行中", started_at=_now(), finished_at=None
)
try:
if self._shared_proxy_fetcher:
self._shared_proxy_fetcher.warmup_whitelist()
@@ -318,14 +356,21 @@ class AccountCheckRunner:
futures = []
for index, account in enumerate(self.accounts):
if self._stop.is_set():
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
self._set_item(
index,
status="stopped",
message="已停止",
finished_at=_now(),
)
continue
futures.append(executor.submit(self._run_one, index, account))
for future in as_completed(futures):
future.result()
except Exception as exc:
self._set_batch(status="error", message=f"批次执行异常: {exc}", finished_at=_now())
self._set_batch(
status="error", message=f"批次执行异常: {exc}", finished_at=_now()
)
return
if self._stop.is_set():
@@ -370,7 +415,9 @@ class AccountCheckRegistry:
for account in accounts
],
)
runner = AccountCheckRunner(batch=batch, accounts=accounts, proxy_config=proxy_config)
runner = AccountCheckRunner(
batch=batch, accounts=accounts, proxy_config=proxy_config
)
with self._lock:
self._runners[batch_id] = runner
return runner
+23 -8
View File
@@ -42,9 +42,17 @@ def check_douyu_cookie(cookie: str) -> dict:
).json()
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
fish_ok = True
fish_ball = (fish_data.get("data") or {}).get("count") if isinstance(fish_data.get("data"), dict) else None
fish_ball = (
(fish_data.get("data") or {}).get("count")
if isinstance(fish_data.get("data"), dict)
else None
)
else:
fish_msg = str(fish_data.get("msg") or fish_data.get("error") or "响应异常") if isinstance(fish_data, dict) else "响应异常"
fish_msg = (
str(fish_data.get("msg") or fish_data.get("error") or "响应异常")
if isinstance(fish_data, dict)
else "响应异常"
)
except Exception as exc:
fish_msg = f"请求失败: {exc}"
@@ -66,11 +74,16 @@ def check_douyu_cookie(cookie: str) -> dict:
).json()
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
level_ok = True
info = level_data.get("data") if isinstance(level_data.get("data"), dict) else {}
info_raw = level_data.get("data")
info = info_raw if isinstance(info_raw, dict) else {}
nickname = str(info.get("nn") or "") or None
level = info.get("lv")
else:
level_msg = str(level_data.get("msg") or level_data.get("error") or "响应异常") if isinstance(level_data, dict) else "响应异常"
level_msg = (
str(level_data.get("msg") or level_data.get("error") or "响应异常")
if isinstance(level_data, dict)
else "响应异常"
)
except Exception as exc:
level_msg = f"请求失败: {exc}"
@@ -78,10 +91,12 @@ def check_douyu_cookie(cookie: str) -> dict:
if valid:
message = "有效"
else:
message = "".join([
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
])
message = "".join(
[
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
]
)
return {
**base,
"valid": valid,
+26 -7
View File
@@ -21,7 +21,13 @@ from .douyu_runner_xpd import XpdMixin
class DouyuBatchRunner(
DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin,
DouyuBatchRunnerCore,
BindMixin,
ManualMixin,
GoldMixin,
DonateMixin,
GoodsMixin,
XpdMixin,
):
"""批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。"""
@@ -43,13 +49,18 @@ class DouyuBatchRunner(
self._started += 1
current = self._started
self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}")
self._push_log(
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
)
login_task = latest_success_login_task(worker_db, account.id)
cookie = login_task.cookie if login_task else ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie")
self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie")
self._push_log(
"warning", f"[{current}] {self._account_name(account)} 无 Cookie"
)
return
assert login_task is not None
cookie_check = check_douyu_cookie(cookie)
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
@@ -62,7 +73,9 @@ class DouyuBatchRunner(
if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
self._mark_task(worker_db, task, "failed", message)
self._push_log("warning", f"[{current}] {self._account_name(account)} {message}")
self._push_log(
"warning", f"[{current}] {self._account_name(account)} {message}"
)
return
update_account_profile_from_cookie(account, cookie)
@@ -109,7 +122,9 @@ class DouyuBatchRunner(
return
handler(worker_db, task, account, cookie, config)
self._push_log("success", f"[{current}] {self._account_name(account)} {task.message}")
self._push_log(
"success", f"[{current}] {self._account_name(account)} {task.message}"
)
except DouyuActivityError as exc:
if "task" in locals() and task:
self._mark_task(worker_db, task, "failed", str(exc))
@@ -128,7 +143,9 @@ class DouyuBatchRunner(
config = self._config_info(self.db)
tasks = (
self.db.query(DouyuTask)
.filter(DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned")
.filter(
DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned"
)
.order_by(DouyuTask.id.asc())
.all()
)
@@ -150,7 +167,9 @@ class DouyuBatchRunner(
for task in tasks:
if self._stop.is_set():
break
futures.append(executor.submit(self._execute_one, task.id, config, total))
futures.append(
executor.submit(self._execute_one, task.id, config, total)
)
for future in as_completed(futures):
try:
future.result()
+28 -10
View File
@@ -21,7 +21,12 @@ from ..models import (
DouyuXpdGoodsSnapshot,
ProxyConfig as ProxyConfigModel,
)
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload
from .douyu_service import (
DOUYU_CONFIG_FIELDS,
douyu_config_value,
ensure_douyu_config,
douyu_task_payload,
)
if TYPE_CHECKING:
from .douyu_runner import DouyuBatchRunner
@@ -78,7 +83,9 @@ class DouyuBatchRunnerCore:
self._proxy_fetcher = self._create_proxy_fetcher()
self._static_proxies = self._resolve_static_proxies()
if self._static_proxies:
logger.info(f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}")
logger.info(
f"[douyu] 写操作任务将走静态代理: {self._static_proxies.get('https', '')}"
)
elif self._proxy_fetcher:
logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理")
@@ -89,7 +96,11 @@ class DouyuBatchRunnerCore:
return None
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
wl_credentials = getattr(cfg, "whitelist_credentials", None)
if not wl_credentials and getattr(cfg, "whitelist_uid", "") and getattr(cfg, "whitelist_ukey", ""):
if (
not wl_credentials
and getattr(cfg, "whitelist_uid", "")
and getattr(cfg, "whitelist_ukey", "")
):
wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
return ProxyFetcher(
api_url=cfg.api_url,
@@ -267,8 +278,7 @@ class DouyuBatchRunnerCore:
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
now = datetime.now(timezone.utc)
commodity_ids = {
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
for raw in goods
str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods
}
commodity_ids.discard("")
query = db.query(DouyuXpdGoodsSnapshot)
@@ -304,11 +314,15 @@ class DouyuBatchRunnerCore:
def _config_info(self, db: Session) -> dict:
config = ensure_douyu_config(db)
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
return {
field: douyu_config_value(field, getattr(config, field, None))
for field in DOUYU_CONFIG_FIELDS
}
def _task_payload(self, task: DouyuTask) -> dict:
result = task.result if isinstance(task.result, dict) else {}
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
payload_raw = result.get("payload")
payload = payload_raw if isinstance(payload_raw, dict) else {}
return {**payload, **self.payload}
def _client(self, cookie: str) -> DouyuActivityClient:
@@ -338,8 +352,13 @@ class DouyuBatchRegistry:
def __init__(self):
self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
def register(
self,
batch_id: str,
log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop,
runner: DouyuBatchRunner,
):
self._batches[batch_id] = {
"log_queue": log_queue,
"loop": loop,
@@ -368,4 +387,3 @@ class DouyuBatchRegistry:
douyu_batch_registry = DouyuBatchRegistry()
+99 -34
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.douyu.activity_client import DouyuActivityClient
@@ -13,7 +14,8 @@ from core.douyu.cookie_utils import cookie_value
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
SUPPORTED_DOUYU_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码",
"confirm_bind": "确认绑定",
"create_elite_qr": "开通精英宝典30",
"prepare_esports_bind": "绑定电竞手册角色",
@@ -53,26 +55,55 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = {
"elite": {
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time",
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
"get_bind_qr",
"confirm_bind",
"create_elite_qr",
"create_gold_qr",
"donate_elite_gift",
"query_points",
"lock_goods",
"pay_locked_order",
"exchange_goods",
"query_game_name",
"query_change_bind_time",
"query_limited_goods",
"query_gold_balance",
"refresh_goods",
"query_exchange_records",
"prefetch_csrf_token",
},
"esports": {
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind",
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time",
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr",
"donate_esports_chicken_gift", "donate_esports_firework_gift",
"prepare_esports_bind",
"get_esports_bind_qr",
"query_esports_game_name",
"confirm_esports_bind",
"create_esports_qr",
"query_esports_points",
"query_gold_balance",
"query_change_bind_time",
"query_limited_goods",
"refresh_esports_goods",
"exchange_esports_goods",
"create_gold_qr",
"donate_esports_chicken_gift",
"donate_esports_firework_gift",
},
"peace": {
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role",
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments",
"query_xpd_purchase_records", "exchange_xpd_goods",
"get_xpd_bind_qr",
"query_xpd_bind_info",
"confirm_xpd_bind",
"query_xpd_role",
"refresh_xpd_goods",
"query_xpd_balance",
"query_xpd_fragments",
"query_xpd_purchase_records",
"exchange_xpd_goods",
},
}
DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
DOUYU_CONFIG_DEFAULTS = {
"manual_id": "G4KA4Qnz4LDp7",
"rid": "9263298",
"bind_act_alias": "20260120QYOOB",
"confirm_act_alias": "20260120QYOOB",
@@ -117,12 +148,18 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
"""补齐斗鱼配置默认值,返回是否发生变更。"""
changed = False
for field in DOUYU_CONFIG_FIELDS:
if field == "bind_act_alias" and str(getattr(config, field, "") or "").strip() == "20250213NQCYX":
if (
field == "bind_act_alias"
and str(getattr(config, field, "") or "").strip() == "20250213NQCYX"
):
setattr(config, field, DOUYU_CONFIG_DEFAULTS[field])
changed = True
continue
normalized = douyu_config_value(field, getattr(config, field, None))
if field == "gold_recharge_channel" and normalized not in {"wechat_qr", "supplier_api"}:
if field == "gold_recharge_channel" and normalized not in {
"wechat_qr",
"supplier_api",
}:
normalized = DOUYU_CONFIG_DEFAULTS[field]
if getattr(config, field, None) != normalized:
setattr(config, field, normalized)
@@ -178,7 +215,11 @@ def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Acc
"""只保留存在成功 Cookie 的斗鱼账号。"""
if not account_ids:
return []
cookie_ids = cookie_account_ids_query(db).subquery()
cookie_ids = select(LoginTask.account_id).where(
LoginTask.status == "success",
LoginTask.cookie != "",
LoginTask.cookie.isnot(None),
)
return (
db.query(Account)
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
@@ -217,23 +258,28 @@ def create_douyu_planned_tasks(
raise ValueError("该任务不属于当前工作台")
accounts = visible_douyu_task_accounts(db, account_ids)
if task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"} and accounts:
if (
task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"}
and accounts
):
# 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
accounts = accounts[:1]
batch_id = uuid.uuid4().hex[:12]
payload = payload or {}
for account in accounts:
db.add(DouyuTask(
batch_id=batch_id,
account_id=account.id,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",
message="任务已创建,等待执行",
result={"payload": payload} if payload else None,
created_by=created_by,
))
db.add(
DouyuTask(
batch_id=batch_id,
account_id=account.id,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",
message="任务已创建,等待执行",
result={"payload": payload} if payload else None,
created_by=created_by,
)
)
db.commit()
return batch_id, len(accounts)
@@ -272,7 +318,17 @@ def slim_douyu_goods(goods: object) -> object:
return {
key: value
for key, value in goods.items()
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"}
if key
in {
"commodityId",
"commodity_id",
"commodityName",
"name",
"webPic",
"pic",
"score",
"status",
}
}
@@ -284,11 +340,13 @@ def slim_douyu_limited_goods(goods: object) -> list[dict[str, object]]:
for item in goods[:5]:
if not isinstance(item, dict):
continue
result.append({
key: value
for key, value in item.items()
if key in {"commodityId", "commodity_id", "commodityName", "name"}
})
result.append(
{
key: value
for key, value in item.items()
if key in {"commodityId", "commodity_id", "commodityName", "name"}
}
)
return result
@@ -305,7 +363,9 @@ def strip_douyu_raw_snapshots(value: object) -> object:
return value
def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None:
def sanitize_douyu_task_result(
result: dict | None, task_type: str, *, include_detail: bool = False
) -> dict | None:
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
if not isinstance(result, dict):
return result
@@ -346,7 +406,12 @@ def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_d
if "limited_goods" in data:
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr", "get_xpd_bind_qr"}:
if task_type not in {
"get_bind_qr",
"prepare_esports_bind",
"get_esports_bind_qr",
"get_xpd_bind_qr",
}:
data.pop("url", None)
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
data.pop("pay_url", None)
+170 -66
View File
@@ -8,12 +8,13 @@ import uuid
from types import SimpleNamespace
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from typing import Optional
from typing import Optional, cast
from sqlalchemy.orm import Session
from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
from .cookie_check_service import check_douyu_cookie
@@ -60,21 +61,28 @@ def get_relogin_limits() -> tuple[int, int]:
)
def _snapshot_proxy_config(proxy_config: Optional[ProxyConfigModel]) -> Optional[SimpleNamespace]:
def _snapshot_proxy_config(
proxy_config: Optional[ProxyConfigModel],
) -> Optional[ProxyConfigModel]:
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
if proxy_config is None:
return None
credentials = getattr(proxy_config, "whitelist_credentials", None)
return SimpleNamespace(
enabled=bool(getattr(proxy_config, "enabled", False)),
http=getattr(proxy_config, "http", "") or "",
https=getattr(proxy_config, "https", "") or "",
api_url=getattr(proxy_config, "api_url", "") or "",
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
whitelist_credentials=dict(credentials) if isinstance(credentials, dict) else credentials,
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
return cast(
ProxyConfigModel,
SimpleNamespace(
enabled=bool(getattr(proxy_config, "enabled", False)),
http=getattr(proxy_config, "http", "") or "",
https=getattr(proxy_config, "https", "") or "",
api_url=getattr(proxy_config, "api_url", "") or "",
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
whitelist_credentials=dict(credentials)
if isinstance(credentials, dict)
else credentials,
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
),
)
@@ -124,12 +132,21 @@ class LoginBatchRunner:
wl_platform = "xiequ"
wl_credentials = None
if proxy_config.whitelist_enabled:
wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ"
wl_credentials = getattr(proxy_config, 'whitelist_credentials', None)
wl_platform = (
getattr(proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(proxy_config, "whitelist_credentials", None)
# 向后兼容
if not wl_credentials and proxy_config.whitelist_uid and proxy_config.whitelist_ukey:
if (
not wl_credentials
and proxy_config.whitelist_uid
and proxy_config.whitelist_ukey
):
wl_platform = "xiequ"
wl_credentials = {"uid": proxy_config.whitelist_uid, "ukey": proxy_config.whitelist_ukey}
wl_credentials = {
"uid": proxy_config.whitelist_uid,
"ukey": proxy_config.whitelist_ukey,
}
self._shared_proxy_fetcher = ProxyFetcher(
api_url=proxy_config.api_url,
@@ -170,7 +187,11 @@ class LoginBatchRunner:
def _push_log(self, level: str, message: str):
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
if message:
log_level = level if level in {"debug", "info", "warning", "error", "success"} else "debug"
log_level = (
level
if level in {"debug", "info", "warning", "error", "success"}
else "debug"
)
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe(
@@ -181,15 +202,15 @@ class LoginBatchRunner:
def _resolve_static_proxy(self) -> tuple[Optional[dict], str]:
"""解析静态代理配置。"""
if not self.proxy_config or not self.proxy_config.enabled:
return None, ''
return None, ""
# 静态代理
if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https
return {'http': proxy_url, 'https': proxy_url}, f'使用静态代理: {proxy_url}'
return {"http": proxy_url, "https": proxy_url}, f"使用静态代理: {proxy_url}"
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
return None, ''
return None, ""
def _execute_one(self, task_id: int, acc_info: dict, total: int):
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
@@ -216,8 +237,16 @@ class LoginBatchRunner:
self._completed += 1
current = self._completed
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}")
action_name = (
"检测"
if self.mode == "check"
else "重新登录"
if self.mode == "relogin"
else "登录"
)
self._push_log(
"info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}"
)
try:
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
@@ -226,26 +255,39 @@ class LoginBatchRunner:
self._push_log("info", f"[{current}] {proxy_msg}")
# 静态代理启用但配置为空 → 不可用
if self.proxy_config and self.proxy_config.enabled and not (self.proxy_config.http or self.proxy_config.https) and not self._shared_proxy_fetcher and not proxy_dict:
if (
self.proxy_config
and self.proxy_config.enabled
and not (self.proxy_config.http or self.proxy_config.https)
and not self._shared_proxy_fetcher
and not proxy_dict
):
if self.mode == "relogin":
task.status = "relogin_failed"
task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
task.message = (
"重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
)
else:
task.status = "error"
task.message = "代理不可用: 未配置代理"
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用")
self._push_log(
"error", f"[{current}] {acc_info['username']} 代理不可用"
)
return
account = SimpleNamespace(
username=acc_info["username"],
password=acc_info["password"],
email=acc_info["email"],
email_password=acc_info["email_password"],
email_imap_server=acc_info["email_imap_server"] or "",
email_imap_port=acc_info["email_imap_port"] or 993,
email_imap_ssl=acc_info["email_imap_ssl"],
account = cast(
AccountLike,
SimpleNamespace(
username=acc_info["username"],
password=acc_info["password"],
email=acc_info["email"],
email_password=acc_info["email_password"],
email_imap_server=acc_info["email_imap_server"] or "",
email_imap_port=acc_info["email_imap_port"] or 993,
email_imap_ssl=acc_info["email_imap_ssl"],
),
)
loginer = DouyuLogin(
@@ -257,22 +299,33 @@ class LoginBatchRunner:
stop_event=self._stop,
api_strategy=self.api_strategy,
)
result = loginer.check_account() if self.mode == "check" else loginer.login()
result = (
loginer.check_account() if self.mode == "check" else loginer.login()
)
if self.mode == "check" and result.success:
status = result.code if result.code in CHECK_STATUS_MESSAGES else "account_auth_unknown"
status = (
result.code
if result.code in CHECK_STATUS_MESSAGES
else "account_auth_unknown"
)
task.status = status
task.cookie = ""
task.message = result.message or CHECK_STATUS_MESSAGES[status]
level = CHECK_STATUS_LOG_LEVELS.get(status, "info")
self._push_log(level, f"[{current}] {acc_info['username']} 检测结果: {task.message}")
self._push_log(
level,
f"[{current}] {acc_info['username']} 检测结果: {task.message}",
)
elif result.success:
task.status = "success"
task.cookie = result.cookie
task.message = result.message or "登录成功"
if self.mode == "relogin":
check_result = check_douyu_cookie(result.cookie)
task.ck_check_status = "valid" if check_result["valid"] else "invalid"
task.ck_check_status = (
"valid" if check_result["valid"] else "invalid"
)
task.ck_check_result = {
"fish_ball": check_result["fish_ball"],
"nickname": check_result["nickname"],
@@ -282,32 +335,54 @@ class LoginBatchRunner:
task.ck_checked_at = check_result["checked_at"]
if check_result["valid"]:
task.message = "重新登录成功,Cookie 有效"
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效")
self._push_log(
"success",
f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效",
)
else:
task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
self._push_log("warning", f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}")
self._push_log(
"warning",
f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}",
)
else:
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}")
self._push_log(
"success",
f"[{current}] {acc_info['username']} {task.message}",
)
else:
if self.mode == "relogin":
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
task.status = "relogin_failed"
task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}")
task.message = (
f"重新登录失败: {result.message}(旧 Cookie 已保留)"
)
self._push_log(
"error",
f"[{current}] {acc_info['username']} 重新登录失败: {result.message}",
)
else:
task.status = "failed"
task.message = result.message
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}")
self._push_log(
"error",
f"[{current}] {acc_info['username']} {action_name}失败: {result.message}",
)
except Exception as e:
if self.mode == "relogin":
task.status = "relogin_failed"
task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}")
self._push_log(
"error", f"[{current}] {acc_info['username']} 重新登录异常: {e}"
)
else:
task.status = "error"
task.message = str(e)
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}")
self._push_log(
"error",
f"[{current}] {acc_info['username']} {action_name}异常: {e}",
)
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
@@ -319,8 +394,17 @@ class LoginBatchRunner:
"""在线程中执行批量登录。"""
batch_id = self.batch_id
concurrency = self.concurrency
action_name = "账号检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}")
action_name = (
"账号检测"
if self.mode == "check"
else "重新登录"
if self.mode == "relogin"
else "登录"
)
self._push_log(
"info",
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}",
)
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
if self._shared_proxy_fetcher:
@@ -339,18 +423,22 @@ class LoginBatchRunner:
task.message = ""
task.finished_at = None
self.db.flush()
task_infos.append({
"task_id": task.id,
"acc_info": {
"username": acc.username,
"password": acc.password,
"email": acc.email,
"email_password": acc.email_password,
"email_imap_server": acc.email_imap_server or "",
"email_imap_port": acc.email_imap_port or 993,
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
},
})
task_infos.append(
{
"task_id": task.id,
"acc_info": {
"username": acc.username,
"password": acc.password,
"email": acc.email,
"email_password": acc.email_password,
"email_imap_server": acc.email_imap_server or "",
"email_imap_port": acc.email_imap_port or 993,
"email_imap_ssl": acc.email_imap_ssl
if acc.email_imap_ssl is not None
else True,
},
}
)
try:
# 创建或复用任务记录(顺序执行,线程安全)
@@ -358,10 +446,16 @@ class LoginBatchRunner:
if self.relogin_task_ids:
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
for task_id in self.relogin_task_ids:
task = self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
task = (
self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
)
if not task:
continue
acc = self.db.query(AccountModel).filter(AccountModel.id == task.account_id).first()
acc = (
self.db.query(AccountModel)
.filter(AccountModel.id == task.account_id)
.first()
)
if not acc:
self._push_log("warning", f"跳过无账号的任务 #{task_id}")
continue
@@ -393,7 +487,9 @@ class LoginBatchRunner:
# 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。
latest_success_task = (
self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status == "success")
.filter(
LoginTask.account_id == aid, LoginTask.status == "success"
)
.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
.first()
)
@@ -414,7 +510,10 @@ class LoginBatchRunner:
# 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。
existing_task = (
self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"]))
.filter(
LoginTask.account_id == aid,
LoginTask.status.in_(["failed", "error"]),
)
.order_by(LoginTask.id.desc())
.first()
)
@@ -479,9 +578,14 @@ class BatchRegistry:
def __init__(self):
self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: Optional[asyncio.Queue],
loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner,
owner_id: Optional[int] = None):
def register(
self,
batch_id: str,
log_queue: Optional[asyncio.Queue],
loop: Optional[asyncio.AbstractEventLoop],
runner: LoginBatchRunner,
owner_id: Optional[int] = None,
):
self._batches[batch_id] = {
"log_queue": log_queue,
"loop": loop,