type: 收敛测试 schemas 与协议层类型
This commit is contained in:
@@ -49,7 +49,9 @@ class EmailVerifier:
|
|||||||
self.password = password # 邮箱密码
|
self.password = password # 邮箱密码
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
|
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 会话(懒初始化)
|
# Roundcube 会话(懒初始化)
|
||||||
self._rc_session: Optional[requests.Session] = None
|
self._rc_session: Optional[requests.Session] = None
|
||||||
@@ -87,7 +89,9 @@ class EmailVerifier:
|
|||||||
|
|
||||||
for index, password in enumerate(passwords):
|
for index, password in enumerate(passwords):
|
||||||
is_backup = index > 0
|
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:
|
if ok:
|
||||||
return True
|
return True
|
||||||
credential_failed = credential_failed or password_failed
|
credential_failed = credential_failed or password_failed
|
||||||
@@ -112,13 +116,17 @@ class EmailVerifier:
|
|||||||
seen.add(password)
|
seen.add(password)
|
||||||
return result
|
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 登录,返回 (是否成功, 是否明确为密码错误)。"""
|
"""尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。"""
|
||||||
try:
|
try:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.headers.update({
|
session.headers.update(
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
{
|
||||||
})
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 1. 访问首页获取 token
|
# 1. 访问首页获取 token
|
||||||
resp = session.get(self.roundcube_url, timeout=self.timeout)
|
resp = session.get(self.roundcube_url, timeout=self.timeout)
|
||||||
@@ -143,17 +151,20 @@ class EmailVerifier:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。
|
# 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:
|
if not new_token_match:
|
||||||
logger.warning("Roundcube: 登录失败(未找到 request_token)")
|
logger.warning("Roundcube: 登录失败(未找到 request_token)")
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
text_lower = resp.text.lower()
|
text_lower = resp.text.lower()
|
||||||
login_failed = "_err=loginfailed" in resp.url or "loginfailed" in 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
|
login_form_present = (
|
||||||
mail_page = (
|
'name="_user"' in resp.text and 'name="_pass"' in resp.text
|
||||||
"_task=mail" in resp.url
|
)
|
||||||
or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', 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:
|
if login_failed or login_form_present or not mail_page:
|
||||||
@@ -183,6 +194,8 @@ class EmailVerifier:
|
|||||||
if self._rc_login_error:
|
if self._rc_login_error:
|
||||||
raise EmailLoginError(self._rc_login_error)
|
raise EmailLoginError(self._rc_login_error)
|
||||||
return []
|
return []
|
||||||
|
if self._rc_session is None:
|
||||||
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._rc_session.get(
|
resp = self._rc_session.get(
|
||||||
@@ -201,7 +214,7 @@ class EmailVerifier:
|
|||||||
|
|
||||||
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
|
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r'add_message_row\((\d+),\s*(\{[^}]+\})',
|
r"add_message_row\((\d+),\s*(\{[^}]+\})",
|
||||||
exec_text,
|
exec_text,
|
||||||
):
|
):
|
||||||
uid = int(m.group(1))
|
uid = int(m.group(1))
|
||||||
@@ -210,11 +223,13 @@ class EmailVerifier:
|
|||||||
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
|
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
|
||||||
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
|
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
|
||||||
|
|
||||||
messages.append({
|
messages.append(
|
||||||
"uid": uid,
|
{
|
||||||
"subject": subject_m.group(1) if subject_m else "",
|
"uid": uid,
|
||||||
"date": date_m.group(1) if date_m else "",
|
"subject": subject_m.group(1) if subject_m else "",
|
||||||
})
|
"date": date_m.group(1) if date_m else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@@ -230,11 +245,12 @@ class EmailVerifier:
|
|||||||
if self._rc_login_error:
|
if self._rc_login_error:
|
||||||
raise EmailLoginError(self._rc_login_error)
|
raise EmailLoginError(self._rc_login_error)
|
||||||
return None
|
return None
|
||||||
|
if self._rc_session is None:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._rc_session.get(
|
resp = self._rc_session.get(
|
||||||
f"{self.roundcube_url}?_task=mail&_action=show"
|
f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}",
|
||||||
f"&_mbox=INBOX&_uid={uid}",
|
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
return resp.text
|
return resp.text
|
||||||
@@ -280,8 +296,14 @@ class EmailVerifier:
|
|||||||
|
|
||||||
# 星期X
|
# 星期X
|
||||||
weekday_map = {
|
weekday_map = {
|
||||||
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
|
"星期一": 0,
|
||||||
"星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6,
|
"星期二": 1,
|
||||||
|
"星期三": 2,
|
||||||
|
"星期四": 3,
|
||||||
|
"星期五": 4,
|
||||||
|
"星期六": 5,
|
||||||
|
"星期日": 6,
|
||||||
|
"星期天": 6,
|
||||||
}
|
}
|
||||||
for prefix, wd in weekday_map.items():
|
for prefix, wd in weekday_map.items():
|
||||||
if date_str.startswith(prefix):
|
if date_str.startswith(prefix):
|
||||||
@@ -295,7 +317,9 @@ class EmailVerifier:
|
|||||||
target = now
|
target = now
|
||||||
else:
|
else:
|
||||||
target = now - timedelta(days=days_ago)
|
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):
|
except (ValueError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -366,7 +390,9 @@ class EmailVerifier:
|
|||||||
raise InterruptedError("任务已停止")
|
raise InterruptedError("任务已停止")
|
||||||
|
|
||||||
try:
|
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:
|
if code:
|
||||||
logger.success(f"获取到验证码: {code}")
|
logger.success(f"获取到验证码: {code}")
|
||||||
return code
|
return code
|
||||||
@@ -383,7 +409,9 @@ class EmailVerifier:
|
|||||||
raise InterruptedError("任务已停止")
|
raise InterruptedError("任务已停止")
|
||||||
time.sleep(min(0.2, sleep_deadline - time.monotonic()))
|
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 方式获取验证码 ────────────────────────────
|
# ── Roundcube 方式获取验证码 ────────────────────────────
|
||||||
|
|
||||||
@@ -397,7 +425,8 @@ class EmailVerifier:
|
|||||||
|
|
||||||
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
||||||
douyu_msgs = [
|
douyu_msgs = [
|
||||||
msg for msg in messages
|
msg
|
||||||
|
for msg in messages
|
||||||
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -407,7 +436,9 @@ class EmailVerifier:
|
|||||||
msg_dt = self._parse_rc_date(msg["date"])
|
msg_dt = self._parse_rc_date(msg["date"])
|
||||||
if msg_dt is None:
|
if msg_dt is None:
|
||||||
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
|
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
|
||||||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过")
|
logger.debug(
|
||||||
|
f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
|
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
|
||||||
# 而 after_timestamp 是秒级精度(如14:20:49)。
|
# 而 after_timestamp 是秒级精度(如14:20:49)。
|
||||||
@@ -415,7 +446,9 @@ class EmailVerifier:
|
|||||||
# 解决:将 after_timestamp 也向下取整到分钟后再比较。
|
# 解决:将 after_timestamp 也向下取整到分钟后再比较。
|
||||||
after_minute_ts = after_timestamp - (after_timestamp % 60)
|
after_minute_ts = after_timestamp - (after_timestamp % 60)
|
||||||
if msg_dt.timestamp() < after_minute_ts - allow_old_seconds:
|
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
|
continue
|
||||||
|
|
||||||
# 读取邮件正文提取验证码
|
# 读取邮件正文提取验证码
|
||||||
@@ -436,16 +469,16 @@ class EmailVerifier:
|
|||||||
"""从文本中提取6位验证码"""
|
"""从文本中提取6位验证码"""
|
||||||
# 清理HTML标签和实体
|
# 清理HTML标签和实体
|
||||||
text = html.unescape(text)
|
text = html.unescape(text)
|
||||||
text = re.sub(r'<[^>]+>', ' ', text)
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
text = re.sub(r'\s+', ' ', text)
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
|
||||||
# 查找6位数字验证码(优先匹配带上下文的)
|
# 查找6位数字验证码(优先匹配带上下文的)
|
||||||
patterns = [
|
patterns = [
|
||||||
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})',
|
r"(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})",
|
||||||
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',
|
r"(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})",
|
||||||
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)',
|
r"(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)",
|
||||||
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})',
|
r"(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})",
|
||||||
r'\b(\d{6})\b',
|
r"\b(\d{6})\b",
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
@@ -459,7 +492,7 @@ class EmailVerifier:
|
|||||||
def get_email_config_for_account(email_address: str) -> dict:
|
def get_email_config_for_account(email_address: str) -> dict:
|
||||||
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
||||||
return {
|
return {
|
||||||
'server': '',
|
"server": "",
|
||||||
'port': 143,
|
"port": 143,
|
||||||
'ssl': False,
|
"ssl": False,
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-116
@@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI
|
|||||||
from .proxy_fetcher import ProxyFetcher
|
from .proxy_fetcher import ProxyFetcher
|
||||||
|
|
||||||
from core.geetest.v3_slide.solver import (
|
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 (
|
from core.geetest.common.network import (
|
||||||
get_js_address,
|
get_js_address,
|
||||||
@@ -44,6 +46,7 @@ class CredentialError(ValueError):
|
|||||||
|
|
||||||
class AccountLike(Protocol):
|
class AccountLike(Protocol):
|
||||||
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
email: str
|
email: str
|
||||||
@@ -56,7 +59,9 @@ class AccountLike(Protocol):
|
|||||||
class LoginResult:
|
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.success = success
|
||||||
self.cookie = cookie
|
self.cookie = cookie
|
||||||
self.message = message
|
self.message = message
|
||||||
@@ -68,7 +73,9 @@ class DouyuLogin:
|
|||||||
|
|
||||||
# 斗鱼API地址
|
# 斗鱼API地址
|
||||||
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
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"
|
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||||||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||||||
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
|
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
|
||||||
@@ -141,50 +148,52 @@ class DouyuLogin:
|
|||||||
"""配置Session"""
|
"""配置Session"""
|
||||||
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
|
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
|
||||||
self.session.trust_env = False
|
self.session.trust_env = False
|
||||||
self.session.headers.update({
|
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',
|
"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-Language': 'zh-CN,zh;q=0.9',
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Referer': self.LOGIN_REFERER,
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
'Origin': 'https://passport.douyu.com',
|
"Referer": self.LOGIN_REFERER,
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"Origin": "https://passport.douyu.com",
|
||||||
})
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 设置代理
|
# 设置代理
|
||||||
self._apply_proxy()
|
self._apply_proxy()
|
||||||
|
|
||||||
def _apply_proxy(self, proxy: str = None) -> None:
|
def _apply_proxy(self, proxy: str | None = None) -> None:
|
||||||
"""应用代理到Session"""
|
"""应用代理到Session"""
|
||||||
if proxy:
|
if proxy:
|
||||||
# 使用指定的代理
|
# 使用指定的代理
|
||||||
self.session.proxies = {
|
self.session.proxies = {
|
||||||
'http': proxy,
|
"http": proxy,
|
||||||
'https': proxy,
|
"https": proxy,
|
||||||
}
|
}
|
||||||
self._current_proxy_url = proxy
|
self._current_proxy_url = proxy
|
||||||
elif self.proxy:
|
elif self.proxy:
|
||||||
# 使用配置的静态代理
|
# 使用配置的静态代理
|
||||||
if isinstance(self.proxy, str):
|
if isinstance(self.proxy, str):
|
||||||
self.session.proxies = {
|
self.session.proxies = {
|
||||||
'http': self.proxy,
|
"http": self.proxy,
|
||||||
'https': self.proxy,
|
"https": self.proxy,
|
||||||
}
|
}
|
||||||
self._current_proxy_url = self.proxy
|
self._current_proxy_url = self.proxy
|
||||||
else:
|
else:
|
||||||
self.session.proxies = {
|
self.session.proxies = {
|
||||||
scheme: url
|
scheme: url for scheme, url in self.proxy.items() if 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:
|
elif self.proxy_fetcher:
|
||||||
# 从代理获取器取新代理
|
# 从代理获取器取新代理
|
||||||
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
||||||
if new_proxy:
|
if new_proxy:
|
||||||
self.session.proxies = {
|
self.session.proxies = {
|
||||||
'http': new_proxy,
|
"http": new_proxy,
|
||||||
'https': new_proxy,
|
"https": new_proxy,
|
||||||
}
|
}
|
||||||
self._current_proxy_url = new_proxy
|
self._current_proxy_url = new_proxy
|
||||||
else:
|
else:
|
||||||
@@ -207,7 +216,7 @@ class DouyuLogin:
|
|||||||
统一发送请求,附带分段超时和更明确的错误信息。
|
统一发送请求,附带分段超时和更明确的错误信息。
|
||||||
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
|
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
|
||||||
"""
|
"""
|
||||||
timeout = kwargs.pop('timeout', self.timeout)
|
timeout = kwargs.pop("timeout", self.timeout)
|
||||||
safe_url = self._safe_url(url)
|
safe_url = self._safe_url(url)
|
||||||
|
|
||||||
self._ensure_not_stopped()
|
self._ensure_not_stopped()
|
||||||
@@ -256,19 +265,21 @@ class DouyuLogin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
|
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
|
||||||
"""识别登录接口返回的账号类终态。"""
|
"""识别登录接口返回的账号类终态。"""
|
||||||
error_code = payload.get('error')
|
error_code = payload.get("error")
|
||||||
error_msg = str(payload.get('msg') or '')
|
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", "账号已注销"
|
return "account_cancelled", "账号已注销"
|
||||||
|
|
||||||
password_keywords = ['密码错误', '账号或密码', '账号或者密码']
|
password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
|
||||||
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
|
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
|
||||||
return "password_wrong", "账号密码错误"
|
return "password_wrong", "账号密码错误"
|
||||||
|
|
||||||
return None
|
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。"""
|
"""把账号类错误转换为不重试的 CredentialError。"""
|
||||||
classified = self._classify_credential_payload(payload)
|
classified = self._classify_credential_payload(payload)
|
||||||
if not classified:
|
if not classified:
|
||||||
@@ -293,10 +304,14 @@ class DouyuLogin:
|
|||||||
# 3️⃣ 第二次登录(带极验)
|
# 3️⃣ 第二次登录(带极验)
|
||||||
logger.info("步骤3: 第二次登录(带极验验证)...")
|
logger.info("步骤3: 第二次登录(带极验验证)...")
|
||||||
next_step, value = self._second_login(
|
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: 跳过手机号绑定,完成登录...")
|
logger.info("步骤4: 跳过手机号绑定,完成登录...")
|
||||||
return self._complete_login(value)
|
return self._complete_login(value)
|
||||||
|
|
||||||
@@ -339,12 +354,19 @@ class DouyuLogin:
|
|||||||
return LoginResult(success=False, message="任务已停止")
|
return LoginResult(success=False, message="任务已停止")
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||||
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
logger.warning(
|
||||||
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)")
|
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 attempt > 1:
|
||||||
if self.max_login_retries > 0:
|
if self.max_login_retries > 0:
|
||||||
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
logger.info(
|
||||||
|
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
|
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
|
||||||
# 重试前:取新代理 + 重置 session
|
# 重试前:取新代理 + 重置 session
|
||||||
@@ -371,15 +393,23 @@ class DouyuLogin:
|
|||||||
return LoginResult(success=False, message=str(e), code=e.code)
|
return LoginResult(success=False, message=str(e), code=e.code)
|
||||||
except EmailLoginError as e:
|
except EmailLoginError as e:
|
||||||
logger.error(f"登录失败(邮箱登录失败,不再重试): {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:
|
except Exception as e:
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if self.max_login_retries > 0:
|
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:
|
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
|
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||||
if has_retry and has_time:
|
if has_retry and has_time:
|
||||||
self._sleep_interruptible(1)
|
self._sleep_interruptible(1)
|
||||||
@@ -405,14 +435,23 @@ class DouyuLogin:
|
|||||||
return LoginResult(success=False, message="任务已停止")
|
return LoginResult(success=False, message="任务已停止")
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||||
logger.warning(f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
logger.warning(
|
||||||
return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)")
|
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 attempt > 1:
|
||||||
if self.max_login_retries > 0:
|
if self.max_login_retries > 0:
|
||||||
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
logger.info(
|
||||||
|
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始")
|
logger.info(
|
||||||
|
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
|
||||||
|
)
|
||||||
if not self._prepare_retry():
|
if not self._prepare_retry():
|
||||||
return LoginResult(
|
return LoginResult(
|
||||||
success=False,
|
success=False,
|
||||||
@@ -431,15 +470,23 @@ class DouyuLogin:
|
|||||||
return LoginResult(success=True, message=e.status_message, code=e.code)
|
return LoginResult(success=True, message=e.status_message, code=e.code)
|
||||||
except EmailLoginError as e:
|
except EmailLoginError as e:
|
||||||
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {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:
|
except Exception as e:
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if self.max_login_retries > 0:
|
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:
|
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
|
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||||
if has_retry and has_time:
|
if has_retry and has_time:
|
||||||
self._sleep_interruptible(1)
|
self._sleep_interruptible(1)
|
||||||
@@ -449,40 +496,44 @@ class DouyuLogin:
|
|||||||
def _check_certification_status(self) -> LoginResult:
|
def _check_certification_status(self) -> LoginResult:
|
||||||
"""请求个人中心接口并判断实名状态。"""
|
"""请求个人中心接口并判断实名状态。"""
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
"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',
|
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
'Pragma': 'no-cache',
|
"Pragma": "no-cache",
|
||||||
'Priority': 'u=1, i',
|
"Priority": "u=1, i",
|
||||||
'Referer': self.CP_REFERER,
|
"Referer": self.CP_REFERER,
|
||||||
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||||
'Sec-CH-UA-Mobile': '?0',
|
"Sec-CH-UA-Mobile": "?0",
|
||||||
'Sec-CH-UA-Platform': '"macOS"',
|
"Sec-CH-UA-Platform": '"macOS"',
|
||||||
'Sec-Fetch-Dest': 'empty',
|
"Sec-Fetch-Dest": "empty",
|
||||||
'Sec-Fetch-Mode': 'cors',
|
"Sec-Fetch-Mode": "cors",
|
||||||
'Sec-Fetch-Site': 'same-origin',
|
"Sec-Fetch-Site": "same-origin",
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
'Origin': None,
|
"Origin": None,
|
||||||
'Content-Type': None,
|
"Content-Type": None,
|
||||||
}
|
}
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
'get',
|
"get",
|
||||||
self.CP_RPC_API,
|
self.CP_RPC_API,
|
||||||
'账号认证状态接口',
|
"账号认证状态接口",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=(5, 10),
|
timeout=(5, 10),
|
||||||
)
|
)
|
||||||
info = payload.get('info') or {}
|
info = payload.get("info") or {}
|
||||||
ident_status = str(info.get('ident_status', ''))
|
ident_status = str(info.get("ident_status", ""))
|
||||||
ident_type = str(info.get('ident_type', ''))
|
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("账号检测完成: 账号未认证")
|
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("账号检测完成: 账号已认证")
|
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 '-'}"
|
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
|
||||||
logger.warning(message)
|
logger.warning(message)
|
||||||
@@ -528,17 +579,19 @@ class DouyuLogin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
'post',
|
"post",
|
||||||
self.api.first_login_url,
|
self.api.first_login_url,
|
||||||
'第一次登录接口',
|
"第一次登录接口",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||||||
|
|
||||||
if payload.get('error') != 81:
|
if payload.get("error") != 81:
|
||||||
error_msg = payload.get('msg', '未知错误')
|
error_msg = payload.get("msg", "未知错误")
|
||||||
credential_error = self._credential_error_from_payload("第一次登录", payload)
|
credential_error = self._credential_error_from_payload(
|
||||||
|
"第一次登录", payload
|
||||||
|
)
|
||||||
if credential_error:
|
if credential_error:
|
||||||
raise credential_error
|
raise credential_error
|
||||||
raise ValueError(f"第一次登录失败: {error_msg}")
|
raise ValueError(f"第一次登录失败: {error_msg}")
|
||||||
@@ -553,7 +606,9 @@ class DouyuLogin:
|
|||||||
|
|
||||||
return gt, challenge, code_token, self.session.cookies.get_dict()
|
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换新代理)
|
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
|
||||||
|
|
||||||
@@ -578,7 +633,9 @@ class DouyuLogin:
|
|||||||
finally:
|
finally:
|
||||||
_geetest_semaphore.release()
|
_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次尝试,失败直接抛异常。"""
|
"""极验验证内部实现:最多3次尝试,失败直接抛异常。"""
|
||||||
_MAX_ATTEMPTS = 3
|
_MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
@@ -620,14 +677,18 @@ class DouyuLogin:
|
|||||||
|
|
||||||
# 从 fullpage ajax.php 响应中提取 validate
|
# 从 fullpage ajax.php 响应中提取 validate
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
data = result.get('data', {})
|
data = result.get("data", {})
|
||||||
validate = data.get('validate', '') or result.get('validate', '')
|
validate = data.get("validate", "") or result.get("validate", "")
|
||||||
success = data.get('result') == 'success' or result.get('success') == 1
|
success = (
|
||||||
message = data.get('result') or result.get('message', '')
|
data.get("result") == "success" or result.get("success") == 1
|
||||||
|
)
|
||||||
|
message = data.get("result") or result.get("message", "")
|
||||||
|
|
||||||
if success and validate:
|
if success and validate:
|
||||||
seccode = f"{validate}|jordan"
|
seccode = f"{validate}|jordan"
|
||||||
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
|
logger.success(
|
||||||
|
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||||
|
)
|
||||||
return validate, seccode
|
return validate, seccode
|
||||||
else:
|
else:
|
||||||
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
|
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
|
||||||
@@ -636,7 +697,9 @@ class DouyuLogin:
|
|||||||
validate = str(result)
|
validate = str(result)
|
||||||
if validate:
|
if validate:
|
||||||
seccode = f"{validate}|jordan"
|
seccode = f"{validate}|jordan"
|
||||||
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
|
logger.success(
|
||||||
|
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||||
|
)
|
||||||
return validate, seccode
|
return validate, seccode
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -645,11 +708,16 @@ class DouyuLogin:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_str = str(e)
|
err_str = str(e)
|
||||||
# 所有网络/代理异常都直接上抛,由 login 换新代理
|
# 所有网络/代理异常都直接上抛,由 login 换新代理
|
||||||
logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理")
|
logger.warning(
|
||||||
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
|
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,
|
def _second_login(
|
||||||
seccode: str, code_token: str) -> tuple[str, str]:
|
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(
|
data = self.api.build_second_login_data(
|
||||||
self.account.username,
|
self.account.username,
|
||||||
self.account.password,
|
self.account.password,
|
||||||
gt, challenge, validate, seccode, code_token,
|
gt,
|
||||||
|
challenge,
|
||||||
|
validate,
|
||||||
|
seccode,
|
||||||
|
code_token,
|
||||||
self.LOGIN_REFERER,
|
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(
|
payload = self._request_json(
|
||||||
'post',
|
"post",
|
||||||
self.api.login_url,
|
self.api.login_url,
|
||||||
'第二次登录接口',
|
"第二次登录接口",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -689,11 +763,13 @@ class DouyuLogin:
|
|||||||
if unique_key:
|
if unique_key:
|
||||||
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
|
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
|
||||||
login_url = self._skip_mobile_bind(unique_key)
|
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:
|
if payload.get("error") != 130014:
|
||||||
error_msg = payload.get('msg', '未知错误')
|
error_msg = payload.get("msg", "未知错误")
|
||||||
credential_error = self._credential_error_from_payload("第二次登录", payload)
|
credential_error = self._credential_error_from_payload(
|
||||||
|
"第二次登录", payload
|
||||||
|
)
|
||||||
if credential_error:
|
if credential_error:
|
||||||
raise credential_error
|
raise credential_error
|
||||||
raise ValueError(f"第二次登录失败: {error_msg}")
|
raise ValueError(f"第二次登录失败: {error_msg}")
|
||||||
@@ -703,26 +779,26 @@ class DouyuLogin:
|
|||||||
|
|
||||||
if not remote_code:
|
if not remote_code:
|
||||||
# 提取可能的风控提示
|
# 提取可能的风控提示
|
||||||
data = payload.get('data', {})
|
data = payload.get("data", {})
|
||||||
security_quiz = data.get('securityQuiz', '')
|
security_quiz = data.get("securityQuiz", "")
|
||||||
msg = payload.get('msg', '')
|
msg = payload.get("msg", "")
|
||||||
detail = security_quiz or msg or '未知原因'
|
detail = security_quiz or msg or "未知原因"
|
||||||
raise ValueError(f"获取remote_code失败: {detail}")
|
raise ValueError(f"获取remote_code失败: {detail}")
|
||||||
|
|
||||||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
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:
|
def _skip_mobile_bind(self, unique_key: str) -> str:
|
||||||
"""跳过手机号绑定并获取后续登录回调地址。"""
|
"""跳过手机号绑定并获取后续登录回调地址。"""
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
'post',
|
"post",
|
||||||
self.api.login_url,
|
self.api.login_url,
|
||||||
'跳过手机号绑定接口',
|
"跳过手机号绑定接口",
|
||||||
data=self.api.build_skip_mobile_bind_data(unique_key),
|
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', '未知错误')}")
|
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
|
||||||
|
|
||||||
login_url = self.api.extract_login_url(payload)
|
login_url = self.api.extract_login_url(payload)
|
||||||
@@ -738,13 +814,13 @@ class DouyuLogin:
|
|||||||
data = self.api.build_send_email_data(remote_code)
|
data = self.api.build_send_email_data(remote_code)
|
||||||
|
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
'post',
|
"post",
|
||||||
self.api.send_email_url,
|
self.api.send_email_url,
|
||||||
'发送验证邮件接口',
|
"发送验证邮件接口",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload.get('error') != 0:
|
if payload.get("error") != 0:
|
||||||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||||||
|
|
||||||
logger.info("验证邮件已发送")
|
logger.info("验证邮件已发送")
|
||||||
@@ -775,13 +851,13 @@ class DouyuLogin:
|
|||||||
data = self.api.build_verify_data(remote_code, verify_code)
|
data = self.api.build_verify_data(remote_code, verify_code)
|
||||||
|
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
'post',
|
"post",
|
||||||
self.api.verify_url,
|
self.api.verify_url,
|
||||||
'提交验证码接口',
|
"提交验证码接口",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload.get('error') != 0:
|
if payload.get("error") != 0:
|
||||||
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
||||||
|
|
||||||
login_url = self.api.extract_login_url(payload)
|
login_url = self.api.extract_login_url(payload)
|
||||||
@@ -798,8 +874,8 @@ class DouyuLogin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_login_url(login_url: str) -> str:
|
def _normalize_login_url(login_url: str) -> str:
|
||||||
"""补全斗鱼接口返回的协议相对登录回调地址。"""
|
"""补全斗鱼接口返回的协议相对登录回调地址。"""
|
||||||
if login_url.startswith('//'):
|
if login_url.startswith("//"):
|
||||||
return 'https:' + login_url
|
return "https:" + login_url
|
||||||
return login_url
|
return login_url
|
||||||
|
|
||||||
def _complete_login(self, login_url: str) -> str:
|
def _complete_login(self, login_url: str) -> str:
|
||||||
@@ -810,16 +886,16 @@ class DouyuLogin:
|
|||||||
cookie: 完整的Cookie字符串
|
cookie: 完整的Cookie字符串
|
||||||
"""
|
"""
|
||||||
# 访问登录URL
|
# 访问登录URL
|
||||||
response = self._request('get', login_url)
|
response = self._request("get", login_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# 尝试访问webLogin获取用户信息
|
# 尝试访问webLogin获取用户信息
|
||||||
try:
|
try:
|
||||||
code_match = re.search(r'code=([^&]+)', login_url)
|
code_match = re.search(r"code=([^&]+)", login_url)
|
||||||
if code_match:
|
if code_match:
|
||||||
code = code_match.group(1)
|
code = code_match.group(1)
|
||||||
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
|
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:
|
if response2.status_code == 200:
|
||||||
logger.info("WebLogin成功")
|
logger.info("WebLogin成功")
|
||||||
@@ -839,7 +915,9 @@ class DouyuLogin:
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._cookie_enrich_error = self._truncate_error(str(e), 160)
|
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()
|
return self._format_cookie_string()
|
||||||
|
|
||||||
@@ -848,6 +926,6 @@ class DouyuLogin:
|
|||||||
cookies = self.session.cookies.get_dict()
|
cookies = self.session.cookies.get_dict()
|
||||||
|
|
||||||
# 格式化Cookie字符串
|
# 格式化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
|
return cookie_str
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
|
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 .base import BaseWhitelistAdapter, get_exit_ip_via_proxy
|
||||||
from .xiequ import XiequAdapter
|
from .xiequ import XiequAdapter
|
||||||
@@ -22,19 +22,30 @@ _PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = {
|
|||||||
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
|
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
|
||||||
"xiequ": [
|
"xiequ": [
|
||||||
{"key": "uid", "label": "UID", "placeholder": "如: 99769"},
|
{"key": "uid", "label": "UID", "placeholder": "如: 99769"},
|
||||||
{"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"},
|
{
|
||||||
|
"key": "ukey",
|
||||||
|
"label": "UKEY",
|
||||||
|
"placeholder": "如: C99371082B965B70F46DCAA87A04618B",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"xkdaili": [
|
"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"},
|
{"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 平台显示名 ──
|
# ── 平台显示名 ──
|
||||||
_PLATFORM_LABELS: dict[str, str] = {
|
_PLATFORM_LABELS: dict[str, str] = {
|
||||||
name: cls(dict()).platform_label
|
name: cast(Any, cls)({}).platform_label for name, cls in _PLATFORM_REGISTRY.items()
|
||||||
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)
|
cls = _PLATFORM_REGISTRY.get(platform)
|
||||||
if not cls:
|
if not cls:
|
||||||
return None
|
return None
|
||||||
return cls(credentials)
|
return cast(Any, cls)(credentials)
|
||||||
|
|
||||||
|
|
||||||
def get_platform_names() -> list[str]:
|
def get_platform_names() -> list[str]:
|
||||||
|
|||||||
@@ -110,20 +110,26 @@ class BaseWhitelistAdapter(ABC):
|
|||||||
return True, msg
|
return True, msg
|
||||||
|
|
||||||
# 当前IP已存在但备注不同,不处理
|
# 当前IP已存在但备注不同,不处理
|
||||||
if any(r.get('ip') == current_ip for r in records):
|
if any(r.get("ip") == current_ip for r in records):
|
||||||
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加")
|
logger.info(
|
||||||
|
f"白名单IP {current_ip} 已存在(备注不同),无需重复添加"
|
||||||
|
)
|
||||||
return True, f"白名单IP已存在: {current_ip}"
|
return True, f"白名单IP已存在: {current_ip}"
|
||||||
|
|
||||||
# 超过上限,删除最老的
|
# 超过上限,删除最老的
|
||||||
if len(memo_ips) >= keep_recent:
|
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:
|
for old_ip in to_delete:
|
||||||
|
if not old_ip:
|
||||||
|
continue
|
||||||
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
|
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
|
||||||
self.delete_ip(old_ip)
|
self.delete_ip(old_ip)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# 添加新IP
|
# 添加新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)
|
ok, resp = self.add_ip(current_ip)
|
||||||
if ok:
|
if ok:
|
||||||
msg = f"白名单IP已添加: {current_ip}"
|
msg = f"白名单IP已添加: {current_ip}"
|
||||||
@@ -152,10 +158,12 @@ def _get_local_exit_ip() -> Optional[str]:
|
|||||||
]
|
]
|
||||||
for url in targets:
|
for url in targets:
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
text = response.text.strip()
|
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:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -174,7 +182,9 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
|
|||||||
for url in targets:
|
for url in targets:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url, proxies=proxies, timeout=6,
|
url,
|
||||||
|
proxies=proxies,
|
||||||
|
timeout=6,
|
||||||
headers={"User-Agent": "Mozilla/5.0"},
|
headers={"User-Agent": "Mozilla/5.0"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class DouyuWhitelistSyncer:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
platform: str = "xiequ",
|
platform: str = "xiequ",
|
||||||
credentials: dict = None,
|
credentials: Optional[dict] = None,
|
||||||
uid: str = "",
|
uid: str = "",
|
||||||
ukey: str = "",
|
ukey: str = "",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
|
|
||||||
# 生成类人的鼠标轨迹
|
# 生成类人的鼠标轨迹
|
||||||
def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int, start_time:int) -> list[Any]:
|
def generate_realistic_trajectory(
|
||||||
|
start_x: int, start_y: int, end_x: int, end_y: int, start_time: int
|
||||||
|
) -> list[Any]:
|
||||||
"""生成类人的鼠标轨迹"""
|
"""生成类人的鼠标轨迹"""
|
||||||
trajectory = []
|
trajectory = []
|
||||||
|
|
||||||
@@ -41,70 +43,56 @@ def generate_realistic_trajectory(start_x:int, start_y:int, end_x:int, end_y:int
|
|||||||
# 随机时间间隔(3-25ms,符合人类反应)
|
# 随机时间间隔(3-25ms,符合人类反应)
|
||||||
time_delta = random.choices(
|
time_delta = random.choices(
|
||||||
[3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 24],
|
[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]
|
)[0]
|
||||||
current_time += time_delta
|
current_time += time_delta
|
||||||
|
|
||||||
trajectory.append([
|
trajectory.append(["move", current_x, current_y, current_time, "pointermove"])
|
||||||
"move",
|
|
||||||
current_x,
|
|
||||||
current_y,
|
|
||||||
current_time,
|
|
||||||
"pointermove"
|
|
||||||
])
|
|
||||||
|
|
||||||
# 偶尔在同一位置停留(模拟视觉确认)
|
# 偶尔在同一位置停留(模拟视觉确认)
|
||||||
if random.random() < 0.15:
|
if random.random() < 0.15:
|
||||||
trajectory.append([
|
trajectory.append(
|
||||||
"move",
|
[
|
||||||
current_x,
|
"move",
|
||||||
current_y,
|
current_x,
|
||||||
current_time + random.randint(5, 15),
|
current_y,
|
||||||
"pointermove"
|
current_time + random.randint(5, 15),
|
||||||
])
|
"pointermove",
|
||||||
|
]
|
||||||
|
)
|
||||||
current_time += random.randint(5, 15)
|
current_time += random.randint(5, 15)
|
||||||
|
|
||||||
# 到达目标后的悬停
|
# 到达目标后的悬停
|
||||||
hover_time = random.randint(50, 150)
|
hover_time = random.randint(50, 150)
|
||||||
for _ in range(random.randint(2, 5)):
|
for _ in range(random.randint(2, 5)):
|
||||||
current_time += random.randint(8, 25)
|
current_time += random.randint(8, 25)
|
||||||
trajectory.append([
|
trajectory.append(
|
||||||
"move",
|
[
|
||||||
end_x + random.randint(-1, 1),
|
"move",
|
||||||
end_y + random.randint(-1, 1),
|
end_x + random.randint(-1, 1),
|
||||||
current_time,
|
end_y + random.randint(-1, 1),
|
||||||
"pointermove"
|
current_time,
|
||||||
])
|
"pointermove",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
current_time += hover_time
|
current_time += hover_time
|
||||||
|
|
||||||
# 点击事件
|
# 点击事件
|
||||||
trajectory.append([
|
trajectory.append(["down", end_x, end_y, current_time, "pointerdown"])
|
||||||
"down",
|
|
||||||
end_x,
|
|
||||||
end_y,
|
|
||||||
current_time,
|
|
||||||
"pointerdown"
|
|
||||||
])
|
|
||||||
|
|
||||||
trajectory.append([
|
trajectory.append(["focus", current_time + 1])
|
||||||
"focus",
|
|
||||||
current_time + 1
|
|
||||||
])
|
|
||||||
|
|
||||||
click_duration = random.randint(80, 130)
|
click_duration = random.randint(80, 130)
|
||||||
trajectory.append([
|
trajectory.append(["up", end_x, end_y, current_time + click_duration, "pointerup"])
|
||||||
"up",
|
|
||||||
end_x,
|
|
||||||
end_y,
|
|
||||||
current_time + click_duration,
|
|
||||||
"pointerup"
|
|
||||||
])
|
|
||||||
|
|
||||||
return trajectory
|
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:
|
if not events or len(events) == 0:
|
||||||
return []
|
return {"data": [], "first_event": None, "last_event": None, "total_events": 0}
|
||||||
|
|
||||||
# 初始化变量
|
# 初始化变量
|
||||||
prev_x = 0 # 上一个X坐标
|
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"]
|
MOVE_EVENTS = ["move", "mousemove", "touchmove", "pointermove"]
|
||||||
|
|
||||||
# 点击类事件(仅时间信息)
|
# 点击类事件(仅时间信息)
|
||||||
CLICK_EVENTS = ["down", "up", "click", "mousedown", "mouseup",
|
CLICK_EVENTS = [
|
||||||
"touchstart", "touchend", "pointerdown", "pointerup"]
|
"down",
|
||||||
|
"up",
|
||||||
|
"click",
|
||||||
|
"mousedown",
|
||||||
|
"mouseup",
|
||||||
|
"touchstart",
|
||||||
|
"touchend",
|
||||||
|
"pointerdown",
|
||||||
|
"pointerup",
|
||||||
|
]
|
||||||
|
|
||||||
# 特殊事件(仅时间信息)
|
# 特殊事件(仅时间信息)
|
||||||
TIME_ONLY_EVENTS = ["focus", "blur", "keydown", "keyup"]
|
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
|
time_diff = timestamp - prev_time
|
||||||
|
|
||||||
# 添加到结果数组
|
# 添加到结果数组
|
||||||
result.append([
|
result.append([event_type, [delta_x, delta_y], time_diff])
|
||||||
event_type,
|
|
||||||
[delta_x, delta_y],
|
|
||||||
time_diff
|
|
||||||
])
|
|
||||||
|
|
||||||
# 更新上一次的值
|
# 更新上一次的值
|
||||||
prev_x = x
|
prev_x = x
|
||||||
@@ -190,11 +183,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
|||||||
time_diff = timestamp - prev_time
|
time_diff = timestamp - prev_time
|
||||||
|
|
||||||
# 添加到结果数组(坐标差为[0,0])
|
# 添加到结果数组(坐标差为[0,0])
|
||||||
result.append([
|
result.append([event_type, [0, 0], time_diff])
|
||||||
event_type,
|
|
||||||
[0, 0],
|
|
||||||
time_diff
|
|
||||||
])
|
|
||||||
|
|
||||||
prev_time = timestamp
|
prev_time = timestamp
|
||||||
|
|
||||||
@@ -209,10 +198,7 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
|||||||
time_diff = timestamp - prev_time
|
time_diff = timestamp - prev_time
|
||||||
|
|
||||||
# 添加到结果数组(仅包含时间差)
|
# 添加到结果数组(仅包含时间差)
|
||||||
result.append([
|
result.append([event_type, time_diff])
|
||||||
event_type,
|
|
||||||
time_diff
|
|
||||||
])
|
|
||||||
|
|
||||||
prev_time = timestamp
|
prev_time = timestamp
|
||||||
|
|
||||||
@@ -220,10 +206,11 @@ def process_mouse_trajectory(events:list[Any], max_records: Optional[int] = None
|
|||||||
"data": result,
|
"data": result,
|
||||||
"first_event": first_event,
|
"first_event": first_event,
|
||||||
"last_event": last_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,
|
"focus": 4,
|
||||||
"blur": 5,
|
"blur": 5,
|
||||||
"unload": 6,
|
"unload": 6,
|
||||||
"unknown": 7
|
"unknown": 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
def h(e, t):
|
def h(e, t):
|
||||||
@@ -434,11 +421,13 @@ def compress_trajectory(e:list[Any]) -> str:
|
|||||||
"""Base64编码"""
|
"""Base64编码"""
|
||||||
t = ""
|
t = ""
|
||||||
n = len(e) // 6
|
n = len(e) // 6
|
||||||
base64_chars = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
base64_chars = (
|
||||||
|
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
||||||
|
)
|
||||||
|
|
||||||
for r in range(n):
|
for r in range(n):
|
||||||
# 每次取6位二进制
|
# 每次取6位二进制
|
||||||
binary_str = e[6 * r: 6 * (r + 1)]
|
binary_str = e[6 * r : 6 * (r + 1)]
|
||||||
index = int(binary_str, 2)
|
index = int(binary_str, 2)
|
||||||
t += base64_chars[index]
|
t += base64_chars[index]
|
||||||
|
|
||||||
@@ -446,9 +435,12 @@ def compress_trajectory(e:list[Any]) -> str:
|
|||||||
|
|
||||||
return u(c_str)
|
return u(c_str)
|
||||||
|
|
||||||
|
|
||||||
class TrajectoryEncoder:
|
class TrajectoryEncoder:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.CHARSET = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
|
self.CHARSET = (
|
||||||
|
"()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
|
||||||
|
)
|
||||||
self.BASE = len(self.CHARSET) # 64
|
self.BASE = len(self.CHARSET) # 64
|
||||||
self.DIRECTION_CHARS = "stuvwxyz~"
|
self.DIRECTION_CHARS = "stuvwxyz~"
|
||||||
self.DIRECTION_PATTERNS = [
|
self.DIRECTION_PATTERNS = [
|
||||||
@@ -460,7 +452,7 @@ class TrajectoryEncoder:
|
|||||||
[0, -1], # x
|
[0, -1], # x
|
||||||
[3, 0], # y
|
[3, 0], # y
|
||||||
[2, -1], # z
|
[2, -1], # z
|
||||||
[2, 1] # ~
|
[2, 1], # ~
|
||||||
]
|
]
|
||||||
|
|
||||||
def encode_number(self, num):
|
def encode_number(self, num):
|
||||||
@@ -546,9 +538,11 @@ class TrajectoryEncoder:
|
|||||||
t_encoded.append(self.encode_number(dt))
|
t_encoded.append(self.encode_number(dt))
|
||||||
|
|
||||||
# 拼接:x坐标 !! y坐标 !! 时间戳
|
# 拼接: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实现
|
JS加密函数的Python实现
|
||||||
|
|
||||||
@@ -568,7 +562,7 @@ class TrajectoryEncoder:
|
|||||||
|
|
||||||
# 每次读取2个字符(十六进制)
|
# 每次读取2个字符(十六进制)
|
||||||
while o < len(n):
|
while o < len(n):
|
||||||
r = n[o:o + 2] # 取2个字符
|
r = n[o : o + 2] # 取2个字符
|
||||||
if len(r) < 2:
|
if len(r) < 2:
|
||||||
break
|
break
|
||||||
o += 2
|
o += 2
|
||||||
@@ -587,7 +581,8 @@ class TrajectoryEncoder:
|
|||||||
|
|
||||||
return i
|
return i
|
||||||
|
|
||||||
def H(t:int, e:str) -> str:
|
|
||||||
|
def H(t: int, e: str) -> str:
|
||||||
# 解析后缀
|
# 解析后缀
|
||||||
n = e[-2:]
|
n = e[-2:]
|
||||||
r = []
|
r = []
|
||||||
@@ -625,4 +620,4 @@ def H(t:int, e:str) -> str:
|
|||||||
g.pop(d)
|
g.pop(d)
|
||||||
d -= 1
|
d -= 1
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|||||||
+39
-2
@@ -1,8 +1,37 @@
|
|||||||
"""虎牙协议与业务客户端。"""
|
"""虎牙协议与业务客户端。"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .http_client import HuyaHttpClient
|
from .http_client import HuyaHttpClient
|
||||||
from .wss_client import HuyaWssClient
|
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__ = [
|
__all__ = [
|
||||||
"HuyaHttpClient",
|
"HuyaHttpClient",
|
||||||
"HuyaWssClient",
|
"HuyaWssClient",
|
||||||
@@ -103,8 +132,16 @@ def __getattr__(name: str):
|
|||||||
}
|
}
|
||||||
globals().update(values)
|
globals().update(values)
|
||||||
return values[name]
|
return values[name]
|
||||||
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}:
|
if name in {
|
||||||
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
|
"HuyaVerificationError",
|
||||||
|
"HuyaVerificationSolver",
|
||||||
|
"solve_huya_verification",
|
||||||
|
}:
|
||||||
|
from .verification import (
|
||||||
|
HuyaVerificationError,
|
||||||
|
HuyaVerificationSolver,
|
||||||
|
solve_huya_verification,
|
||||||
|
)
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"HuyaVerificationError": HuyaVerificationError,
|
"HuyaVerificationError": HuyaVerificationError,
|
||||||
|
|||||||
+44
-19
@@ -18,6 +18,7 @@
|
|||||||
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
|
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
|
||||||
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -176,13 +177,13 @@ def parse_cred(resp: bytes) -> bytes | None:
|
|||||||
e = resp.find(b"_wup_header")
|
e = resp.find(b"_wup_header")
|
||||||
if s < 0 or e < 0:
|
if s < 0 or e < 0:
|
||||||
return None
|
return None
|
||||||
d = resp[s:e - 6]
|
d = resp[s : e - 6]
|
||||||
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
|
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
ln = m.group(2)[0]
|
ln = m.group(2)[0]
|
||||||
st = m.start() + 4
|
st = m.start() + 4
|
||||||
cred = d[st:st + ln]
|
cred = d[st : st + ln]
|
||||||
if len(cred) == 114 and cred[:1] == b"\x0a":
|
if len(cred) == 114 and cred[:1] == b"\x0a":
|
||||||
return cred
|
return cred
|
||||||
return None
|
return None
|
||||||
@@ -209,7 +210,10 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
|
|||||||
HuyaVerificationSolver,
|
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")
|
app_id = str(q.get("appId") or "5002")
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
for attempt in range(max_retry):
|
for attempt in range(max_retry):
|
||||||
@@ -287,7 +291,11 @@ def login_cred_with_flow(
|
|||||||
return cred, uid
|
return cred, uid
|
||||||
risk_url = parse_risk_url(resp)
|
risk_url = parse_risk_url(resp)
|
||||||
if risk_url:
|
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}")
|
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
|
||||||
if "qr_auth" in risk_url:
|
if "qr_auth" in risk_url:
|
||||||
raise HuyaAppQrAuthRequiredError(
|
raise HuyaAppQrAuthRequiredError(
|
||||||
@@ -317,12 +325,14 @@ class QrRole:
|
|||||||
self.context = f"{prefix}-{ctx_hex}-{tail}"
|
self.context = f"{prefix}-{ctx_hex}-{tail}"
|
||||||
self.page_id = random.randint(40_000_000, 41_000_000)
|
self.page_id = random.randint(40_000_000, 41_000_000)
|
||||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||||
self.s.headers.update({
|
self.s.headers.update(
|
||||||
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
{
|
||||||
"Origin": UDB_BASE,
|
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
||||||
"content-type": "application/json;charset=UTF-8",
|
"Origin": UDB_BASE,
|
||||||
"Accept": "*/*",
|
"content-type": "application/json;charset=UTF-8",
|
||||||
})
|
"Accept": "*/*",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _headers(self, uri: str) -> dict:
|
def _headers(self, uri: str) -> dict:
|
||||||
mid = "2.6" if self.pc else "2.5"
|
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]}",
|
"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 = {
|
envelope = {
|
||||||
"uri": uri,
|
"uri": uri,
|
||||||
"version": "2.6" if self.pc else "2.5",
|
"version": "2.6" if self.pc else "2.5",
|
||||||
@@ -389,13 +401,16 @@ class HuyaAppPasswordLogin:
|
|||||||
self.proxies = dict(proxies) if proxies else None
|
self.proxies = dict(proxies) if proxies else None
|
||||||
self.timeout = timeout or (10.0, 25.0)
|
self.timeout = timeout or (10.0, 25.0)
|
||||||
self.force_new_device = force_new_device
|
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:
|
def login(self) -> HuyaLoginResult:
|
||||||
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
|
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
|
||||||
result = self._login_impl()
|
result = self._login_impl()
|
||||||
try:
|
try:
|
||||||
from .device_profile import record_login
|
from .device_profile import record_login
|
||||||
|
|
||||||
record_login(self.username, result.success, result.message)
|
record_login(self.username, result.success, result.message)
|
||||||
except Exception: # 元数据记录失败不影响登录结果
|
except Exception: # 元数据记录失败不影响登录结果
|
||||||
pass
|
pass
|
||||||
@@ -404,7 +419,9 @@ class HuyaAppPasswordLogin:
|
|||||||
def _login_impl(self) -> HuyaLoginResult:
|
def _login_impl(self) -> HuyaLoginResult:
|
||||||
"""登录主体 (原 login)。"""
|
"""登录主体 (原 login)。"""
|
||||||
acct = self.username
|
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 滑块)
|
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
||||||
try:
|
try:
|
||||||
@@ -442,7 +459,9 @@ class HuyaAppPasswordLogin:
|
|||||||
|
|
||||||
# 3) 信封补丁
|
# 3) 信封补丁
|
||||||
raw = bytearray(env.raw)
|
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:
|
if env.uid != uid:
|
||||||
struct.pack_into(">Q", raw, env.uid_off, uid)
|
struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||||
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
||||||
@@ -457,9 +476,11 @@ class HuyaAppPasswordLogin:
|
|||||||
try:
|
try:
|
||||||
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
||||||
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
||||||
sdid_obj = get_huya_sdid(allow_fallback=True,
|
sdid_obj = get_huya_sdid(
|
||||||
state_dir=account_state_dir(self.username),
|
allow_fallback=True,
|
||||||
device_hint=self.device_info)
|
state_dir=account_state_dir(self.username),
|
||||||
|
device_hint=self.device_info,
|
||||||
|
)
|
||||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||||
ph = QrRole(pc=False, 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:
|
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
|
# 4.3 轮询 tryQrLogin
|
||||||
biztoken = None
|
biztoken = None
|
||||||
@@ -552,7 +575,9 @@ class HuyaAppPasswordLogin:
|
|||||||
code="COOKIE_INCOMPLETE",
|
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(
|
return HuyaLoginResult(
|
||||||
success=True,
|
success=True,
|
||||||
cookie=cookie_str,
|
cookie=cookie_str,
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ def generate_huya_password(prefix: str = "hy", random_length: int = 8) -> str:
|
|||||||
"""生成适合虎牙账号使用的随机密码。"""
|
"""生成适合虎牙账号使用的随机密码。"""
|
||||||
clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy"
|
clean_prefix = "".join(ch for ch in str(prefix or "hy") if ch.isalnum())[:8] or "hy"
|
||||||
alphabet = string.ascii_lowercase + string.digits
|
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}"
|
return f"{clean_prefix}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
@@ -86,13 +88,15 @@ def encode_change_password_behavior(stage: str) -> str:
|
|||||||
actions: list[dict] = []
|
actions: list[dict] = []
|
||||||
|
|
||||||
if stage in {"send", "submit"}:
|
if stage in {"send", "submit"}:
|
||||||
actions.append({
|
actions.append(
|
||||||
"id": "pmodify.btn.getsms",
|
{
|
||||||
"x": random.randint(470, 520),
|
"id": "pmodify.btn.getsms",
|
||||||
"y": random.randint(120, 145),
|
"x": random.randint(470, 520),
|
||||||
"d": elapsed,
|
"y": random.randint(120, 145),
|
||||||
"time": now,
|
"d": elapsed,
|
||||||
})
|
"time": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
if stage == "submit":
|
if stage == "submit":
|
||||||
for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"):
|
for action_id in ("pmodify.input.sms", "pmodify.input.pw", "pmodify.input.sms"):
|
||||||
now += random.randint(600, 3200)
|
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})
|
actions.append({"id": action_id, "d": elapsed, "time": now})
|
||||||
now += random.randint(800, 2400)
|
now += random.randint(800, 2400)
|
||||||
elapsed += random.randint(800, 2400)
|
elapsed += random.randint(800, 2400)
|
||||||
actions.append({
|
actions.append(
|
||||||
"id": "pmodify.btn.submit",
|
{
|
||||||
"x": random.randint(395, 445),
|
"id": "pmodify.btn.submit",
|
||||||
"y": random.randint(220, 245),
|
"x": random.randint(395, 445),
|
||||||
"d": elapsed,
|
"y": random.randint(220, 245),
|
||||||
"time": now,
|
"d": elapsed,
|
||||||
})
|
"time": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
value = {
|
value = {
|
||||||
"furl": CHANGE_PASSWORD_FROM_URL,
|
"furl": CHANGE_PASSWORD_FROM_URL,
|
||||||
"curl": CHANGE_PASSWORD_PAGE_URL,
|
"curl": CHANGE_PASSWORD_PAGE_URL,
|
||||||
"user_action": actions,
|
"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:
|
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:
|
def _session_data(payload: dict, fallback: str = "") -> str:
|
||||||
data = _payload_data(payload)
|
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:
|
class HuyaPasswordChanger:
|
||||||
@@ -174,23 +188,25 @@ class HuyaPasswordChanger:
|
|||||||
self._setup_headers()
|
self._setup_headers()
|
||||||
|
|
||||||
def _setup_headers(self) -> None:
|
def _setup_headers(self) -> None:
|
||||||
self.session.headers.update({
|
self.session.headers.update(
|
||||||
"Accept": "application/json, text/plain, */*",
|
{
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
"Accept": "application/json, text/plain, */*",
|
||||||
"Cache-Control": "no-cache",
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
"Connection": "keep-alive",
|
"Cache-Control": "no-cache",
|
||||||
"Content-Type": "application/json;charset=UTF-8",
|
"Connection": "keep-alive",
|
||||||
"Origin": "https://udbreg.huya.com",
|
"Content-Type": "application/json;charset=UTF-8",
|
||||||
"Pragma": "no-cache",
|
"Origin": "https://udbreg.huya.com",
|
||||||
"Referer": self.middle_url,
|
"Pragma": "no-cache",
|
||||||
"Sec-Fetch-Dest": "empty",
|
"Referer": self.middle_url,
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Dest": "empty",
|
||||||
"Sec-Fetch-Site": "same-origin",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": self.ua,
|
"Sec-Fetch-Site": "same-origin",
|
||||||
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
"User-Agent": self.ua,
|
||||||
"sec-ch-ua-mobile": "?0",
|
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||||
"sec-ch-ua-platform": '"macOS"',
|
"sec-ch-ua-mobile": "?0",
|
||||||
})
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_url(url: str) -> str:
|
def _safe_url(url: str) -> str:
|
||||||
@@ -199,7 +215,9 @@ class HuyaPasswordChanger:
|
|||||||
|
|
||||||
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
|
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
|
||||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
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()
|
response.raise_for_status()
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -216,20 +234,26 @@ class HuyaPasswordChanger:
|
|||||||
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
|
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}")
|
logger.debug(f"虎牙改密 middle 初始化失败: {host}: {exc}")
|
||||||
self.session.headers.update({
|
self.session.headers.update(
|
||||||
"Origin": "https://udbreg.huya.com",
|
{
|
||||||
"Referer": self.middle_url,
|
"Origin": "https://udbreg.huya.com",
|
||||||
})
|
"Referer": self.middle_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def prepare_device(self) -> str:
|
def prepare_device(self) -> str:
|
||||||
"""获取虎牙风控 sdid。"""
|
"""获取虎牙风控 sdid。"""
|
||||||
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
|
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")
|
token = token_res.get("data", {}).get("token")
|
||||||
if not token:
|
if not token:
|
||||||
raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}")
|
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", "")
|
self.sdid = collect_res.get("data", {}).get("sdid", "")
|
||||||
if not self.sdid:
|
if not self.sdid:
|
||||||
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
|
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
|
||||||
@@ -240,7 +264,11 @@ class HuyaPasswordChanger:
|
|||||||
from .verification import HuyaVerificationSolver
|
from .verification import HuyaVerificationSolver
|
||||||
|
|
||||||
solver = 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,
|
ua=self.ua,
|
||||||
sdid=self.sdid,
|
sdid=self.sdid,
|
||||||
session=self.session,
|
session=self.session,
|
||||||
@@ -402,12 +430,16 @@ class HuyaPasswordChanger:
|
|||||||
request_id=self.request_id,
|
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:
|
if not session_data:
|
||||||
raise HuyaLoginError("缺少改密 sessionData,请先发送改密短信")
|
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):
|
for index in range(3):
|
||||||
return_code = int(payload.get("returnCode") or 0)
|
return_code = int(payload.get("returnCode") or 0)
|
||||||
if return_code == 0:
|
if return_code == 0:
|
||||||
@@ -436,7 +468,9 @@ class HuyaPasswordChanger:
|
|||||||
logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)")
|
logger.info(f"虎牙短信改密触发风控: {return_code} ({index + 1}/3)")
|
||||||
self._solve_verification(payload)
|
self._solve_verification(payload)
|
||||||
time.sleep(0.5)
|
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(
|
return HuyaChangePasswordResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|||||||
+36
-21
@@ -1,14 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
|
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
from .taf_protocol import TafInputStream, TafType
|
from .taf_protocol import TafInputStream, TafType
|
||||||
from .wup_protocol import normalize_wup_payload
|
from .wup_protocol import normalize_wup_payload
|
||||||
|
|
||||||
# cmd 编号 → 名称
|
# cmd 编号 → 名称
|
||||||
CMD_NAMES = {
|
CMD_NAMES = {
|
||||||
0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH",
|
0x03: "RPC_REQ",
|
||||||
0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV",
|
0x04: "RPC_RSP",
|
||||||
0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP",
|
0x0A: "AUTH",
|
||||||
|
0x0B: "PUSH1",
|
||||||
|
0x10: "HB_SEND",
|
||||||
|
0x11: "HB_RECV",
|
||||||
|
0x17: "CONFIRM",
|
||||||
|
0x18: "PUSH2",
|
||||||
|
0x21: "REGISTER",
|
||||||
|
0x22: "CONFIRM_RSP",
|
||||||
}
|
}
|
||||||
|
|
||||||
# TAIL_BYTES = 2c36004c5c6600
|
# TAIL_BYTES = 2c36004c5c6600
|
||||||
@@ -18,10 +27,14 @@ _TAIL = bytes.fromhex("2c36004c5c6600")
|
|||||||
def _strip_tail(body: bytes) -> bytes:
|
def _strip_tail(body: bytes) -> bytes:
|
||||||
"""裁掉 body 末尾的 TAIL_BYTES"""
|
"""裁掉 body 末尾的 TAIL_BYTES"""
|
||||||
if body.endswith(_TAIL):
|
if body.endswith(_TAIL):
|
||||||
return body[:-len(_TAIL)]
|
return body[: -len(_TAIL)]
|
||||||
# 有时 TAIL 前还有 0c (ZERO tag)
|
# 有时 TAIL 前还有 0c (ZERO tag)
|
||||||
if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL):
|
if (
|
||||||
return body[:-len(_TAIL)-1]
|
len(body) > 1
|
||||||
|
and body[-len(_TAIL) - 1 : -len(_TAIL)] == b"\x0c"
|
||||||
|
and body.endswith(_TAIL)
|
||||||
|
):
|
||||||
|
return body[: -len(_TAIL) - 1]
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@@ -33,16 +46,18 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
|
|||||||
return ins._read_int_value(dtype)
|
return ins._read_int_value(dtype)
|
||||||
if dtype in (TafType.FLOAT, TafType.DOUBLE):
|
if dtype in (TafType.FLOAT, TafType.DOUBLE):
|
||||||
import struct as _s
|
import struct as _s
|
||||||
|
|
||||||
if dtype == TafType.FLOAT:
|
if dtype == TafType.FLOAT:
|
||||||
return round(_s.unpack('>f', ins.buf.read(4))[0], 4)
|
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(">d", ins.buf.read(8))[0], 6)
|
||||||
if dtype == TafType.STRING1:
|
if dtype == TafType.STRING1:
|
||||||
ln = ins.buf.read(1)[0]
|
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:
|
if dtype == TafType.STRING4:
|
||||||
import struct as _s
|
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:
|
if dtype == TafType.MAP:
|
||||||
cnt = ins._read_int_len()
|
cnt = ins._read_int_len()
|
||||||
m = {}
|
m = {}
|
||||||
@@ -106,14 +121,14 @@ def _extract_wup(body: bytes) -> bytes:
|
|||||||
return body
|
return body
|
||||||
# 大包格式: [1B prefix][4B wup_len][wup_body][tail]
|
# 大包格式: [1B prefix][4B wup_len][wup_body][tail]
|
||||||
# prefix 可能是 0x00,必须优先于 4B total_len 判断。
|
# prefix 可能是 0x00,必须优先于 4B total_len 判断。
|
||||||
wup_len = int.from_bytes(body[1:5], 'big')
|
wup_len = int.from_bytes(body[1:5], "big")
|
||||||
if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03':
|
if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03":
|
||||||
return body[5:5 + wup_len]
|
return body[5 : 5 + wup_len]
|
||||||
total_len = int.from_bytes(body[0:4], 'big')
|
total_len = int.from_bytes(body[0:4], "big")
|
||||||
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03':
|
if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03":
|
||||||
return body[:total_len]
|
return body[:total_len]
|
||||||
if 5 + wup_len <= len(body):
|
if 5 + wup_len <= len(body):
|
||||||
return body[5:5 + wup_len]
|
return body[5 : 5 + wup_len]
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +142,7 @@ def _decode_wup_body(body: bytes) -> dict:
|
|||||||
ins = TafInputStream(wup)
|
ins = TafInputStream(wup)
|
||||||
|
|
||||||
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
|
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
|
||||||
sBuffer = b''
|
sBuffer = b""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
tag, dtype = ins.peek_head()
|
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}"
|
return f"{prefix} {label}"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
if cmd == 0x0a:
|
if cmd == 0x0A:
|
||||||
text = _strip_tail(body).decode('utf-8', errors='replace')
|
text = _strip_tail(body).decode("utf-8", errors="replace")
|
||||||
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
|
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
|
||||||
|
|
||||||
# REGISTER / CONFIRM / PUSH 等
|
# REGISTER / CONFIRM / PUSH 等
|
||||||
@@ -302,7 +317,7 @@ def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
|
|||||||
fields = {}
|
fields = {}
|
||||||
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||||
if fields:
|
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}"
|
return f"{prefix} {cmd_name}"
|
||||||
except Exception:
|
except Exception:
|
||||||
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class TestEliteLockExchange:
|
|||||||
|
|
||||||
assert result["order_id"] == "5874"
|
assert result["order_id"] == "5874"
|
||||||
assert result["exchange_id"] == "11098933"
|
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
|
create_call, pay_call = self.client._request_json.call_args_list
|
||||||
assert create_call.args[:3] == (
|
assert create_call.args[:3] == (
|
||||||
"post",
|
"post",
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ class TestDouyuProxyMode:
|
|||||||
|
|
||||||
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
|
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
|
||||||
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
|
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
|
assert fake.fetch_new_proxy.call_count == 3
|
||||||
|
|
||||||
def test_api_mode_failure_falls_back_to_direct(self):
|
def test_api_mode_failure_falls_back_to_direct(self):
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ class TestHuyaAppLogin:
|
|||||||
.filter(HuyaAccount.username == "mock_test_huya_user")
|
.filter(HuyaAccount.username == "mock_test_huya_user")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
assert acc is not None
|
||||||
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
|
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
|
||||||
batch_resp = app_password_login_selected_accounts(
|
batch_resp = app_password_login_selected_accounts(
|
||||||
req=batch_req, db=self.session, current=self.admin
|
req=batch_req, db=self.session, current=self.admin
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class TestParseResponse:
|
|||||||
def test_parse_golden_evidence(self):
|
def test_parse_golden_evidence(self):
|
||||||
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
|
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
|
||||||
if not CHAIN_FILE.exists():
|
if not CHAIN_FILE.exists():
|
||||||
self.skipTest("缺失 golden 注册链模板")
|
pytest.skip("缺失 golden 注册链模板")
|
||||||
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
||||||
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
|
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
|
||||||
t1, t2, t5 = _parse_response(resp)
|
t1, t2, t5 = _parse_response(resp)
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ class TestMigrationSmoke:
|
|||||||
missing_columns = model_columns - db_columns
|
missing_columns = model_columns - db_columns
|
||||||
assert not missing_columns
|
assert not missing_columns
|
||||||
|
|
||||||
db_indexes = {
|
db_indexes = set()
|
||||||
index["name"]
|
for index in inspector.get_indexes(name):
|
||||||
for index in inspector.get_indexes(name)
|
index_name = index["name"]
|
||||||
if not index["name"].startswith("sqlite_autoindex")
|
if index_name and not index_name.startswith("sqlite_autoindex"):
|
||||||
}
|
db_indexes.add(index_name)
|
||||||
all_db_indexes |= db_indexes
|
all_db_indexes |= db_indexes
|
||||||
model_indexes = {
|
model_indexes = {
|
||||||
index.name for index in table.indexes if index.name
|
index.name for index in table.indexes if index.name
|
||||||
|
|||||||
+5
-2
@@ -34,9 +34,10 @@ def get_current_user(
|
|||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
user_id: int = payload.get("sub")
|
user_id_raw = payload.get("sub")
|
||||||
if user_id is None:
|
if user_id_raw is None:
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
|
user_id = int(user_id_raw)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exc
|
raise credentials_exc
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ def get_current_user(
|
|||||||
|
|
||||||
def require_permission(permission: str):
|
def require_permission(permission: str):
|
||||||
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
|
"""权限检查依赖工厂。用法: Depends(require_permission('user:create'))"""
|
||||||
|
|
||||||
def checker(current_user: User = Depends(get_current_user)) -> User:
|
def checker(current_user: User = Depends(get_current_user)) -> User:
|
||||||
if not current_user.is_active:
|
if not current_user.is_active:
|
||||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||||
@@ -55,6 +57,7 @@ def require_permission(permission: str):
|
|||||||
if permission not in perms:
|
if permission not in perms:
|
||||||
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
raise HTTPException(status_code=403, detail=f"无权限: {permission}")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+193
-73
@@ -1,16 +1,32 @@
|
|||||||
"""账号管理路由"""
|
"""账号管理路由"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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 sqlalchemy.orm import Session, defer, joinedload
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, Account, AuditLog, LoginTask, DouyuTask, DouyuWorkbenchAccount
|
from ..models import (
|
||||||
from ..schemas import AccountBulkSelection, AccountBulkTag, AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
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 ..deps import get_current_user, require_permission
|
||||||
from ..permissions import user_has_permission
|
from ..permissions import user_has_permission
|
||||||
from ..services.account_service import (
|
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=["账号管理"])
|
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||||
@@ -46,15 +62,19 @@ def _filter_accounts_query(
|
|||||||
search_text = (search or "").strip()
|
search_text = (search or "").strip()
|
||||||
if search_text:
|
if search_text:
|
||||||
pattern = f"%{search_text}%"
|
pattern = f"%{search_text}%"
|
||||||
query = query.filter(or_(
|
query = query.filter(
|
||||||
Account.username.ilike(pattern),
|
or_(
|
||||||
Account.tag.ilike(pattern),
|
Account.username.ilike(pattern),
|
||||||
Account.remark.ilike(pattern),
|
Account.tag.ilike(pattern),
|
||||||
))
|
Account.remark.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
return query
|
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。"""
|
"""解析批量操作目标:当前筛选全部或显式选择的 ID。"""
|
||||||
if req.all_matching:
|
if req.all_matching:
|
||||||
rows = (
|
rows = (
|
||||||
@@ -71,7 +91,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
|
|||||||
.order_by(None)
|
.order_by(None)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [account_id for account_id, in rows]
|
return [account_id for (account_id,) in rows]
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
ids = []
|
ids = []
|
||||||
@@ -88,7 +108,7 @@ def _selected_account_ids(db: Session, current: User, req: AccountBulkSelection)
|
|||||||
.order_by(None)
|
.order_by(None)
|
||||||
.all()
|
.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]
|
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:
|
if page is not None:
|
||||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
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)]
|
options = [joinedload(Account.assigned_user)]
|
||||||
if not can_include_sensitive:
|
if not can_include_sensitive:
|
||||||
options.extend([
|
options.extend(
|
||||||
defer(Account.password),
|
[
|
||||||
defer(Account.email),
|
defer(Account.password),
|
||||||
defer(Account.email_password),
|
defer(Account.email),
|
||||||
])
|
defer(Account.email_password),
|
||||||
|
]
|
||||||
|
)
|
||||||
accounts = query.options(*options).all()
|
accounts = query.options(*options).all()
|
||||||
result = []
|
result = []
|
||||||
for acc in accounts:
|
for acc in accounts:
|
||||||
item = AccountOut(
|
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 "",
|
tag=acc.tag or "",
|
||||||
assigned_to=acc.assigned_to,
|
assigned_to=acc.assigned_to,
|
||||||
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
|
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
|
item.email_password = acc.email_password
|
||||||
result.append(item)
|
result.append(item)
|
||||||
if page is not None:
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -209,8 +240,14 @@ def import_accounts(
|
|||||||
|
|
||||||
if accounts:
|
if accounts:
|
||||||
db.add_all(accounts)
|
db.add_all(accounts)
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="account:import", target=f"导入{len(accounts)}个"))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="account:import",
|
||||||
|
target=f"导入{len(accounts)}个",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
duplicated_note = f",重复跳过 {duplicated} 个" if duplicated else ""
|
duplicated_note = f",重复跳过 {duplicated} 个" if duplicated else ""
|
||||||
@@ -240,17 +277,27 @@ def assign_account(
|
|||||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||||
|
|
||||||
# 只能分配已成功登录过的账号(有cookie)
|
# 只能分配已成功登录过的账号(有cookie)
|
||||||
has_success = db.query(LoginTask).filter(
|
has_success = (
|
||||||
LoginTask.account_id == account_id,
|
db.query(LoginTask)
|
||||||
LoginTask.status == 'success',
|
.filter(
|
||||||
LoginTask.cookie != '',
|
LoginTask.account_id == account_id,
|
||||||
).first()
|
LoginTask.status == "success",
|
||||||
|
LoginTask.cookie != "",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not has_success:
|
if not has_success:
|
||||||
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
|
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
|
||||||
|
|
||||||
acc.assigned_to = req.assigned_to
|
acc.assigned_to = req.assigned_to
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="account:assign", target=acc.username))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="account:assign",
|
||||||
|
target=acc.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已分配", "success": True}
|
return {"message": "已分配", "success": True}
|
||||||
|
|
||||||
@@ -274,31 +321,52 @@ def batch_assign_accounts(
|
|||||||
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
raise HTTPException(status_code=400, detail="只能分配给客服角色")
|
||||||
|
|
||||||
# 只能分配已成功登录过的账号(有cookie)
|
# 只能分配已成功登录过的账号(有cookie)
|
||||||
cookie_ids_query = cookie_account_ids_query(db).subquery()
|
cookie_ids_query = select(LoginTask.account_id).where(
|
||||||
invalid_ids = db.query(Account.id).filter(
|
LoginTask.status == "success",
|
||||||
Account.id.in_(req.account_ids),
|
LoginTask.cookie != "",
|
||||||
Account.id.notin_(cookie_ids_query),
|
LoginTask.cookie.isnot(None),
|
||||||
).all()
|
)
|
||||||
|
invalid_ids = (
|
||||||
|
db.query(Account.id)
|
||||||
|
.filter(
|
||||||
|
Account.id.in_(req.account_ids),
|
||||||
|
Account.id.notin_(cookie_ids_query),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
if invalid_ids:
|
if invalid_ids:
|
||||||
names = db.query(Account.username).filter(Account.id.in_([i[0] for i in invalid_ids])).all()
|
names = (
|
||||||
name_list = ', '.join([n[0] for n in names[:5]])
|
db.query(Account.username)
|
||||||
suffix = '...' if len(invalid_ids) > 5 else ''
|
.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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
|
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
|
||||||
)
|
)
|
||||||
|
|
||||||
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
|
count = (
|
||||||
{Account.assigned_to: req.assigned_to}, synchronize_session=False
|
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()
|
db.commit()
|
||||||
action = "分配" if req.assigned_to else "取消分配"
|
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")
|
@router.get("/assignments/summary")
|
||||||
@@ -307,11 +375,17 @@ def assignments_summary(
|
|||||||
current: User = Depends(require_permission("account:assign")),
|
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()
|
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||||
|
|
||||||
results = (
|
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)
|
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||||
.filter(User.role == "support")
|
.filter(User.role == "support")
|
||||||
.group_by(User.id, User.username)
|
.group_by(User.id, User.username)
|
||||||
@@ -345,8 +419,15 @@ def set_account_tag(
|
|||||||
if not acc:
|
if not acc:
|
||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
acc.tag = (req.tag or "").strip()
|
acc.tag = (req.tag or "").strip()
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="account:tag", target=acc.username, detail=acc.tag))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="account:tag",
|
||||||
|
target=acc.username,
|
||||||
|
detail=acc.tag,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "标签已更新", "success": True}
|
return {"message": "标签已更新", "success": True}
|
||||||
|
|
||||||
@@ -362,8 +443,10 @@ def batch_tag(
|
|||||||
if not req.account_ids:
|
if not req.account_ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择账号")
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
tag = (req.tag or "").strip()
|
tag = (req.tag or "").strip()
|
||||||
count = _visible_accounts_query(db, current).filter(Account.id.in_(req.account_ids)).update(
|
count = (
|
||||||
{Account.tag: tag}, synchronize_session=False
|
_visible_accounts_query(db, current)
|
||||||
|
.filter(Account.id.in_(req.account_ids))
|
||||||
|
.update({Account.tag: tag}, synchronize_session=False)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": f"已为 {count} 个账号设置标签", "success": True}
|
return {"message": f"已为 {count} 个账号设置标签", "success": True}
|
||||||
@@ -388,7 +471,11 @@ def batch_tag_selection(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
scope = "当前筛选下" if req.all_matching else "选中的"
|
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")
|
@router.get("/tags/list")
|
||||||
@@ -422,17 +509,29 @@ def batch_delete_accounts(
|
|||||||
raise HTTPException(status_code=400, detail="无效的账号ID")
|
raise HTTPException(status_code=400, detail="无效的账号ID")
|
||||||
|
|
||||||
# 先删除关联的登录任务
|
# 先删除关联的登录任务
|
||||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
|
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
|
||||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
|
synchronize_session=False
|
||||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.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(
|
db.add(
|
||||||
user_id=current.id, username=current.username,
|
AuditLog(
|
||||||
action="account:delete", target=f"批量删除{deleted}个账号"
|
user_id=current.id,
|
||||||
))
|
username=current.username,
|
||||||
|
action="account:delete",
|
||||||
|
target=f"批量删除{deleted}个账号",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
||||||
|
|
||||||
@@ -448,19 +547,28 @@ def batch_delete_accounts_selection(
|
|||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="请选择账号")
|
raise HTTPException(status_code=400, detail="请选择账号")
|
||||||
|
|
||||||
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(synchronize_session=False)
|
db.query(LoginTask).filter(LoginTask.account_id.in_(ids)).delete(
|
||||||
db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).delete(synchronize_session=False)
|
synchronize_session=False
|
||||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.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 = (
|
deleted = (
|
||||||
_visible_accounts_query(db, current)
|
_visible_accounts_query(db, current)
|
||||||
.filter(Account.id.in_(ids))
|
.filter(Account.id.in_(ids))
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current.id, username=current.username,
|
AuditLog(
|
||||||
action="account:delete",
|
user_id=current.id,
|
||||||
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
|
username=current.username,
|
||||||
))
|
action="account:delete",
|
||||||
|
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
|
||||||
|
|
||||||
@@ -476,12 +584,24 @@ def delete_account(
|
|||||||
raise HTTPException(status_code=404, detail="账号不存在")
|
raise HTTPException(status_code=404, detail="账号不存在")
|
||||||
|
|
||||||
# 先删除关联的登录任务,避免外键约束失败
|
# 先删除关联的登录任务,避免外键约束失败
|
||||||
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(synchronize_session=False)
|
db.query(LoginTask).filter(LoginTask.account_id == account_id).delete(
|
||||||
db.query(DouyuTask).filter(DouyuTask.account_id == account_id).delete(synchronize_session=False)
|
synchronize_session=False
|
||||||
db.query(DouyuWorkbenchAccount).filter(DouyuWorkbenchAccount.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,
|
db.add(
|
||||||
action="account:delete", target=acc.username))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="account:delete",
|
||||||
|
target=acc.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.delete(acc)
|
db.delete(acc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已删除", "success": True}
|
return {"message": "已删除", "success": True}
|
||||||
|
|||||||
+148
-77
@@ -27,6 +27,7 @@ def _fmt_dt(dt) -> str | None:
|
|||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
return dt.isoformat()
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
||||||
cookie_relogin_registry = BatchRegistry()
|
cookie_relogin_registry = BatchRegistry()
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ def _parse_account_names(raw_names: str) -> list[str]:
|
|||||||
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
|
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
|
||||||
names = []
|
names = []
|
||||||
seen = set()
|
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()
|
name = value.strip()
|
||||||
if name and name not in seen:
|
if name and name not in seen:
|
||||||
names.append(name)
|
names.append(name)
|
||||||
@@ -60,7 +63,9 @@ def _order_cookie_tasks(query, selected_names: list[str]):
|
|||||||
value=Account.username,
|
value=Account.username,
|
||||||
else_=len(selected_names),
|
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())
|
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 记录,重登中或失败时仍保留在操作列表。"""
|
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
|
||||||
query = db.query(LoginTask).filter(
|
query = db.query(LoginTask).filter(
|
||||||
LoginTask.cookie != "",
|
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"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
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"):
|
if not user_has_permission(current, "cookie:view"):
|
||||||
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
raise HTTPException(status_code=403, detail="无权限: cookie:view")
|
||||||
if not include_cookie:
|
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:
|
else:
|
||||||
query = _visible_cookie_tasks_query(db, current).options(
|
query = _visible_cookie_tasks_query(db, current).options(
|
||||||
joinedload(LoginTask.account).joinedload(Account.assigned_user),
|
joinedload(LoginTask.account).joinedload(Account.assigned_user),
|
||||||
@@ -137,15 +146,19 @@ def list_cookies(
|
|||||||
if not account_joined:
|
if not account_joined:
|
||||||
query = query.join(Account, LoginTask.account_id == Account.id)
|
query = query.join(Account, LoginTask.account_id == Account.id)
|
||||||
account_joined = True
|
account_joined = True
|
||||||
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_(
|
query = query.outerjoin(User, Account.assigned_to == User.id).filter(
|
||||||
Account.username.ilike(pattern),
|
or_(
|
||||||
Account.tag.ilike(pattern),
|
Account.username.ilike(pattern),
|
||||||
User.username.ilike(pattern),
|
Account.tag.ilike(pattern),
|
||||||
))
|
User.username.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
total = None
|
total = None
|
||||||
if page is not 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)
|
query = _order_cookie_tasks(query, selected_names)
|
||||||
if page is not None:
|
if page is not None:
|
||||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
@@ -175,8 +188,10 @@ def list_cookies(
|
|||||||
"batch_id": t.batch_id,
|
"batch_id": t.batch_id,
|
||||||
"account_id": t.account_id,
|
"account_id": t.account_id,
|
||||||
"account_username": acc.username if acc else "",
|
"account_username": acc.username if acc else "",
|
||||||
"assigned_to": acc.assigned_to,
|
"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(t.finished_at),
|
"created_at": _fmt_dt(t.finished_at),
|
||||||
"ck_check_status": t.ck_check_status or "",
|
"ck_check_status": t.ck_check_status or "",
|
||||||
"ck_check_result": t.ck_check_result,
|
"ck_check_result": t.ck_check_result,
|
||||||
@@ -194,7 +209,12 @@ def list_cookies(
|
|||||||
item["account_password"] = ""
|
item["account_password"] = ""
|
||||||
result.append(item)
|
result.append(item)
|
||||||
if page is not None:
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -242,10 +262,12 @@ def list_cookie_operations(
|
|||||||
if tag_value:
|
if tag_value:
|
||||||
query = query.filter(Account.tag == tag_value)
|
query = query.filter(Account.tag == tag_value)
|
||||||
if search_text:
|
if search_text:
|
||||||
query = query.filter(or_(
|
query = query.filter(
|
||||||
Account.username.ilike(f"%{search_text}%"),
|
or_(
|
||||||
Account.tag.ilike(f"%{search_text}%"),
|
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
|
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
|
||||||
tasks = (
|
tasks = (
|
||||||
@@ -265,7 +287,9 @@ def list_cookie_operations(
|
|||||||
"created_at": _fmt_dt(task.finished_at),
|
"created_at": _fmt_dt(task.finished_at),
|
||||||
"relogin_status": task.status if task.status != "success" else "",
|
"relogin_status": task.status if task.status != "success" else "",
|
||||||
"relogin_message": task.message or "",
|
"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
|
for task in tasks
|
||||||
],
|
],
|
||||||
@@ -292,7 +316,7 @@ def list_cookie_operation_tags(
|
|||||||
.order_by(Account.tag.asc())
|
.order_by(Account.tag.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [tag for tag, in rows if tag]
|
return [tag for (tag,) in rows if tag]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/duplicates")
|
@router.get("/duplicates")
|
||||||
@@ -317,7 +341,9 @@ def find_duplicate_cookies(
|
|||||||
if not user_has_permission(current, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
query = query.filter(Account.assigned_to == current.id)
|
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] = {}
|
grouped: dict[str, dict] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
username = (row.username or "").strip()
|
username = (row.username or "").strip()
|
||||||
@@ -337,26 +363,30 @@ def find_duplicate_cookies(
|
|||||||
group["account_names"].add(username)
|
group["account_names"].add(username)
|
||||||
group["account_ids"].add(row.account_id)
|
group["account_ids"].add(row.account_id)
|
||||||
group["cookie_ids"].append(row.cookie_id)
|
group["cookie_ids"].append(row.cookie_id)
|
||||||
group["records"].append({
|
group["records"].append(
|
||||||
"id": row.cookie_id,
|
{
|
||||||
"account_id": row.account_id,
|
"id": row.cookie_id,
|
||||||
"batch_id": row.batch_id,
|
"account_id": row.account_id,
|
||||||
"finished_at": _fmt_dt(row.finished_at),
|
"batch_id": row.batch_id,
|
||||||
})
|
"finished_at": _fmt_dt(row.finished_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
duplicate_groups = []
|
duplicate_groups = []
|
||||||
for group in grouped.values():
|
for group in grouped.values():
|
||||||
if len(group["cookie_ids"]) < 2:
|
if len(group["cookie_ids"]) < 2:
|
||||||
continue
|
continue
|
||||||
duplicate_groups.append({
|
duplicate_groups.append(
|
||||||
"account_key": group["account_key"],
|
{
|
||||||
"account_names": sorted(group["account_names"]),
|
"account_key": group["account_key"],
|
||||||
"cookie_count": len(group["cookie_ids"]),
|
"account_names": sorted(group["account_names"]),
|
||||||
"account_count": len(group["account_ids"]),
|
"cookie_count": len(group["cookie_ids"]),
|
||||||
"cookie_ids": group["cookie_ids"],
|
"account_count": len(group["account_ids"]),
|
||||||
"account_ids": sorted(group["account_ids"]),
|
"cookie_ids": group["cookie_ids"],
|
||||||
"records": group["records"],
|
"account_ids": sorted(group["account_ids"]),
|
||||||
})
|
"records": group["records"],
|
||||||
|
}
|
||||||
|
)
|
||||||
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
|
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
|
||||||
|
|
||||||
return {
|
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()]
|
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
|
||||||
if not id_list:
|
if not id_list:
|
||||||
raise HTTPException(status_code=400, detail="无效的ID")
|
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)
|
base_query = (
|
||||||
tasks = base_query.filter(LoginTask.id.in_(id_list)).filter(
|
_visible_cookie_tasks_query(db, current)
|
||||||
LoginTask.status.in_(("success", "relogin_failed"))
|
if detailed
|
||||||
).all()
|
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:
|
if not tasks:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
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())
|
results.append(future.result())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
task = futures[future]
|
task = futures[future]
|
||||||
results.append({
|
results.append(
|
||||||
"id": task.id,
|
{
|
||||||
"valid": False,
|
"id": task.id,
|
||||||
"message": f"检测异常: {exc}",
|
"valid": False,
|
||||||
"fish_ball": None,
|
"message": f"检测异常: {exc}",
|
||||||
"nickname": None,
|
"fish_ball": None,
|
||||||
"level": None,
|
"nickname": None,
|
||||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
"level": None,
|
||||||
})
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
results.sort(key=lambda item: item["id"])
|
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", ""),
|
"message": item.get("message", ""),
|
||||||
}
|
}
|
||||||
task.ck_checked_at = datetime.now(timezone.utc)
|
task.ck_checked_at = datetime.now(timezone.utc)
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current.id,
|
AuditLog(
|
||||||
username=current.username,
|
user_id=current.id,
|
||||||
action="cookie:check",
|
username=current.username,
|
||||||
target=f"检测 {len(results)} 条已分配账号 CK",
|
action="cookie:check",
|
||||||
))
|
target=f"检测 {len(results)} 条已分配账号 CK",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
if not detailed:
|
if not detailed:
|
||||||
results = [
|
results = [
|
||||||
@@ -510,7 +550,13 @@ def check_cookie_operations(
|
|||||||
return _check_cookies(ids, db, current, detailed=False)
|
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 保留到新登录成功后才替换。"""
|
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
|
||||||
if not tasks:
|
if not tasks:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
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:
|
if not task_ids:
|
||||||
db.commit()
|
db.commit()
|
||||||
raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
|
||||||
|
)
|
||||||
|
|
||||||
proxy = db.query(ProxyConfigModel).first()
|
proxy = db.query(ProxyConfigModel).first()
|
||||||
thread_db = SessionLocal()
|
thread_db = SessionLocal()
|
||||||
@@ -558,13 +606,15 @@ def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *,
|
|||||||
relogin_task_ids=task_ids,
|
relogin_task_ids=task_ids,
|
||||||
)
|
)
|
||||||
batch_id = runner.batch_id
|
batch_id = runner.batch_id
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current.id,
|
AuditLog(
|
||||||
username=current.username,
|
user_id=current.id,
|
||||||
action=action,
|
username=current.username,
|
||||||
target=f"重登 {len(task_ids)} 条账号 CK",
|
action=action,
|
||||||
detail="旧 Cookie 将在新登录成功后替换",
|
target=f"重登 {len(task_ids)} 条账号 CK",
|
||||||
))
|
detail="旧 Cookie 将在新登录成功后替换",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
|
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)
|
_require_cookie_operation_perm(current)
|
||||||
batch = cookie_relogin_registry.get(batch_id)
|
batch = cookie_relogin_registry.get(batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启")
|
raise HTTPException(
|
||||||
if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"):
|
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="无权限停止该重登批次")
|
raise HTTPException(status_code=403, detail="无权限停止该重登批次")
|
||||||
|
|
||||||
batch["runner"].stop()
|
batch["runner"].stop()
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current.id,
|
AuditLog(
|
||||||
username=current.username,
|
user_id=current.id,
|
||||||
action="cookie:relogin_stop",
|
username=current.username,
|
||||||
target=f"停止 CK 重登批次 {batch_id}",
|
action="cookie:relogin_stop",
|
||||||
))
|
target=f"停止 CK 重登批次 {batch_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True}
|
return {
|
||||||
|
"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止",
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
|
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())
|
query = query.filter(Account.tag == tag.strip())
|
||||||
if search.strip():
|
if search.strip():
|
||||||
pattern = f"%{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()
|
tasks = query.order_by(LoginTask.id.asc()).all()
|
||||||
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
|
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
|
||||||
|
|
||||||
@@ -681,7 +742,9 @@ def get_cookie(
|
|||||||
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
|
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
|
||||||
if not user_has_permission(current, "cookie:view"):
|
if not user_has_permission(current, "cookie:view"):
|
||||||
raise HTTPException(status_code=403, detail="无权限: 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:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
acc = db.query(Account).filter(Account.id == task.account_id).first()
|
acc = db.query(Account).filter(Account.id == task.account_id).first()
|
||||||
@@ -691,7 +754,9 @@ def get_cookie(
|
|||||||
"account_id": task.account_id,
|
"account_id": task.account_id,
|
||||||
"account_username": acc.username if acc else "",
|
"account_username": acc.username if acc else "",
|
||||||
"assigned_to": acc.assigned_to if acc 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,
|
"assigned_username": acc.assigned_user.username
|
||||||
|
if acc and acc.assigned_user
|
||||||
|
else None,
|
||||||
"created_at": _fmt_dt(task.finished_at),
|
"created_at": _fmt_dt(task.finished_at),
|
||||||
"ck_check_status": task.ck_check_status or "",
|
"ck_check_status": task.ck_check_status or "",
|
||||||
"ck_check_result": task.ck_check_result,
|
"ck_check_result": task.ck_check_result,
|
||||||
@@ -726,7 +791,11 @@ def delete_cookies_batch(
|
|||||||
t.status = "failed"
|
t.status = "failed"
|
||||||
t.message = "Cookie已清除"
|
t.message = "Cookie已清除"
|
||||||
db.commit()
|
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}")
|
@router.delete("/{task_id}")
|
||||||
@@ -736,7 +805,9 @@ def delete_cookie(
|
|||||||
current: User = Depends(require_permission("cookie:export")),
|
current: User = Depends(require_permission("cookie:export")),
|
||||||
):
|
):
|
||||||
"""删除一条 Cookie 记录。"""
|
"""删除一条 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:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
task.cookie = ""
|
task.cookie = ""
|
||||||
|
|||||||
+494
-238
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ if not SECRET_KEY:
|
|||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
SECRET_KEY = secrets.token_urlsafe(32)
|
SECRET_KEY = secrets.token_urlsafe(32)
|
||||||
|
assert SECRET_KEY is not None
|
||||||
|
SECRET_KEY_TYPED: str = SECRET_KEY
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "24"))
|
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
|
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()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
expire = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
||||||
to_encode.update({"exp": expire})
|
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]:
|
def decode_access_token(token: str) -> Optional[dict]:
|
||||||
try:
|
try:
|
||||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
return jwt.decode(token, SECRET_KEY_TYPED, algorithms=[ALGORITHM])
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Optional
|
from typing import Optional, cast
|
||||||
|
|
||||||
from core.douyu import DouyuLogin, WgapiLoginAPI
|
from core.douyu import DouyuLogin, WgapiLoginAPI
|
||||||
|
from core.douyu.login import AccountLike
|
||||||
from core.douyu.proxy_fetcher import ProxyFetcher
|
from core.douyu.proxy_fetcher import ProxyFetcher
|
||||||
|
|
||||||
from ..models import ProxyConfig as ProxyConfigModel
|
from ..models import ProxyConfig as ProxyConfigModel
|
||||||
@@ -118,13 +119,15 @@ def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
|
|||||||
f"或 账号|密码|邮箱|邮箱密码"
|
f"或 账号|密码|邮箱|邮箱密码"
|
||||||
)
|
)
|
||||||
|
|
||||||
accounts.append(AccountCheckInput(
|
accounts.append(
|
||||||
line=line_no,
|
AccountCheckInput(
|
||||||
username=parts[0],
|
line=line_no,
|
||||||
password=parts[1],
|
username=parts[0],
|
||||||
email=parts[2],
|
password=parts[1],
|
||||||
email_password=parts[3],
|
email=parts[2],
|
||||||
))
|
email_password=parts[3],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
@@ -158,9 +161,15 @@ class AccountCheckRunner:
|
|||||||
wl_platform = "xiequ"
|
wl_platform = "xiequ"
|
||||||
wl_credentials = None
|
wl_credentials = None
|
||||||
if self.proxy_config.whitelist_enabled:
|
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)
|
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 = {
|
wl_credentials = {
|
||||||
"uid": self.proxy_config.whitelist_uid,
|
"uid": self.proxy_config.whitelist_uid,
|
||||||
"ukey": self.proxy_config.whitelist_ukey,
|
"ukey": self.proxy_config.whitelist_ukey,
|
||||||
@@ -209,25 +218,38 @@ class AccountCheckRunner:
|
|||||||
|
|
||||||
def _run_one(self, index: int, account: AccountCheckInput):
|
def _run_one(self, index: int, account: AccountCheckInput):
|
||||||
if self._stop.is_set():
|
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
|
return
|
||||||
|
|
||||||
proxy_dict, proxy_error = self._resolve_static_proxy()
|
proxy_dict, proxy_error = self._resolve_static_proxy()
|
||||||
if proxy_error:
|
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
|
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:
|
try:
|
||||||
result = DouyuLogin(
|
result = DouyuLogin(
|
||||||
SimpleNamespace(
|
cast(
|
||||||
username=account.username,
|
AccountLike,
|
||||||
password=account.password,
|
SimpleNamespace(
|
||||||
email=account.email,
|
username=account.username,
|
||||||
email_password=account.email_password,
|
password=account.password,
|
||||||
email_imap_server="",
|
email=account.email,
|
||||||
email_imap_port=993,
|
email_password=account.email_password,
|
||||||
email_imap_ssl=True,
|
email_imap_server="",
|
||||||
|
email_imap_port=993,
|
||||||
|
email_imap_ssl=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
proxy=proxy_dict,
|
proxy=proxy_dict,
|
||||||
max_login_retries=self.batch.max_login_retries,
|
max_login_retries=self.batch.max_login_retries,
|
||||||
@@ -237,15 +259,24 @@ class AccountCheckRunner:
|
|||||||
api_strategy=WgapiLoginAPI(),
|
api_strategy=WgapiLoginAPI(),
|
||||||
).check_account()
|
).check_account()
|
||||||
except Exception as exc:
|
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
|
return
|
||||||
|
|
||||||
if self._stop.is_set() and not result.success:
|
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
|
return
|
||||||
|
|
||||||
if result.success:
|
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, "认证状态未知")
|
message = result.message or STATUS_LABELS.get(status, "认证状态未知")
|
||||||
else:
|
else:
|
||||||
status = "error"
|
status = "error"
|
||||||
@@ -260,7 +291,9 @@ class AccountCheckRunner:
|
|||||||
status: sum(1 for item in self.batch.items if item.status == status)
|
status: sum(1 for item in self.batch.items if item.status == status)
|
||||||
for status in STATUS_LABELS
|
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
|
finished_count = len(self.batch.items) - running_count
|
||||||
return {
|
return {
|
||||||
"batch_id": self.batch.batch_id,
|
"batch_id": self.batch.batch_id,
|
||||||
@@ -296,7 +329,10 @@ class AccountCheckRunner:
|
|||||||
if item.status != status:
|
if item.status != status:
|
||||||
continue
|
continue
|
||||||
line = item.export_text
|
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}"
|
line = f"{line}----{item.message}"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
@@ -309,7 +345,9 @@ class AccountCheckRunner:
|
|||||||
|
|
||||||
def run(self):
|
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:
|
try:
|
||||||
if self._shared_proxy_fetcher:
|
if self._shared_proxy_fetcher:
|
||||||
self._shared_proxy_fetcher.warmup_whitelist()
|
self._shared_proxy_fetcher.warmup_whitelist()
|
||||||
@@ -318,14 +356,21 @@ class AccountCheckRunner:
|
|||||||
futures = []
|
futures = []
|
||||||
for index, account in enumerate(self.accounts):
|
for index, account in enumerate(self.accounts):
|
||||||
if self._stop.is_set():
|
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
|
continue
|
||||||
futures.append(executor.submit(self._run_one, index, account))
|
futures.append(executor.submit(self._run_one, index, account))
|
||||||
|
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
future.result()
|
future.result()
|
||||||
except Exception as exc:
|
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
|
return
|
||||||
|
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
@@ -370,7 +415,9 @@ class AccountCheckRegistry:
|
|||||||
for account in accounts
|
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:
|
with self._lock:
|
||||||
self._runners[batch_id] = runner
|
self._runners[batch_id] = runner
|
||||||
return runner
|
return runner
|
||||||
|
|||||||
@@ -42,9 +42,17 @@ def check_douyu_cookie(cookie: str) -> dict:
|
|||||||
).json()
|
).json()
|
||||||
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
|
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
|
||||||
fish_ok = True
|
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:
|
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:
|
except Exception as exc:
|
||||||
fish_msg = f"请求失败: {exc}"
|
fish_msg = f"请求失败: {exc}"
|
||||||
|
|
||||||
@@ -66,11 +74,16 @@ def check_douyu_cookie(cookie: str) -> dict:
|
|||||||
).json()
|
).json()
|
||||||
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
|
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
|
||||||
level_ok = True
|
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
|
nickname = str(info.get("nn") or "") or None
|
||||||
level = info.get("lv")
|
level = info.get("lv")
|
||||||
else:
|
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:
|
except Exception as exc:
|
||||||
level_msg = f"请求失败: {exc}"
|
level_msg = f"请求失败: {exc}"
|
||||||
|
|
||||||
@@ -78,10 +91,12 @@ def check_douyu_cookie(cookie: str) -> dict:
|
|||||||
if valid:
|
if valid:
|
||||||
message = "有效"
|
message = "有效"
|
||||||
else:
|
else:
|
||||||
message = ";".join([
|
message = ";".join(
|
||||||
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
|
[
|
||||||
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
|
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
|
||||||
])
|
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
|
||||||
|
]
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
**base,
|
**base,
|
||||||
"valid": valid,
|
"valid": valid,
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ from .douyu_runner_xpd import XpdMixin
|
|||||||
|
|
||||||
|
|
||||||
class DouyuBatchRunner(
|
class DouyuBatchRunner(
|
||||||
DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin,
|
DouyuBatchRunnerCore,
|
||||||
|
BindMixin,
|
||||||
|
ManualMixin,
|
||||||
|
GoldMixin,
|
||||||
|
DonateMixin,
|
||||||
|
GoodsMixin,
|
||||||
|
XpdMixin,
|
||||||
):
|
):
|
||||||
"""批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。"""
|
"""批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。"""
|
||||||
|
|
||||||
@@ -43,13 +49,18 @@ class DouyuBatchRunner(
|
|||||||
self._started += 1
|
self._started += 1
|
||||||
current = self._started
|
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)
|
login_task = latest_success_login_task(worker_db, account.id)
|
||||||
cookie = login_task.cookie if login_task else ""
|
cookie = login_task.cookie if login_task else ""
|
||||||
if not cookie:
|
if not cookie:
|
||||||
self._mark_task(worker_db, task, "failed", "账号没有成功登录 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
|
return
|
||||||
|
assert login_task is not None
|
||||||
|
|
||||||
cookie_check = check_douyu_cookie(cookie)
|
cookie_check = check_douyu_cookie(cookie)
|
||||||
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
|
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
|
||||||
@@ -62,7 +73,9 @@ class DouyuBatchRunner(
|
|||||||
if not cookie_check["valid"]:
|
if not cookie_check["valid"]:
|
||||||
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
|
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
|
||||||
self._mark_task(worker_db, task, "failed", 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
|
return
|
||||||
|
|
||||||
update_account_profile_from_cookie(account, cookie)
|
update_account_profile_from_cookie(account, cookie)
|
||||||
@@ -109,7 +122,9 @@ class DouyuBatchRunner(
|
|||||||
return
|
return
|
||||||
|
|
||||||
handler(worker_db, task, account, cookie, config)
|
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:
|
except DouyuActivityError as exc:
|
||||||
if "task" in locals() and task:
|
if "task" in locals() and task:
|
||||||
self._mark_task(worker_db, task, "failed", str(exc))
|
self._mark_task(worker_db, task, "failed", str(exc))
|
||||||
@@ -128,7 +143,9 @@ class DouyuBatchRunner(
|
|||||||
config = self._config_info(self.db)
|
config = self._config_info(self.db)
|
||||||
tasks = (
|
tasks = (
|
||||||
self.db.query(DouyuTask)
|
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())
|
.order_by(DouyuTask.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -150,7 +167,9 @@ class DouyuBatchRunner(
|
|||||||
for task in tasks:
|
for task in tasks:
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
break
|
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):
|
for future in as_completed(futures):
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ from ..models import (
|
|||||||
DouyuXpdGoodsSnapshot,
|
DouyuXpdGoodsSnapshot,
|
||||||
ProxyConfig as ProxyConfigModel,
|
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:
|
if TYPE_CHECKING:
|
||||||
from .douyu_runner import DouyuBatchRunner
|
from .douyu_runner import DouyuBatchRunner
|
||||||
@@ -78,7 +83,9 @@ class DouyuBatchRunnerCore:
|
|||||||
self._proxy_fetcher = self._create_proxy_fetcher()
|
self._proxy_fetcher = self._create_proxy_fetcher()
|
||||||
self._static_proxies = self._resolve_static_proxies()
|
self._static_proxies = self._resolve_static_proxies()
|
||||||
if self._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:
|
elif self._proxy_fetcher:
|
||||||
logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理")
|
logger.info("[douyu] 写操作任务将按任务从代理 API 取新代理")
|
||||||
|
|
||||||
@@ -89,7 +96,11 @@ class DouyuBatchRunnerCore:
|
|||||||
return None
|
return None
|
||||||
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
|
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
|
||||||
wl_credentials = getattr(cfg, "whitelist_credentials", None)
|
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}
|
wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
|
||||||
return ProxyFetcher(
|
return ProxyFetcher(
|
||||||
api_url=cfg.api_url,
|
api_url=cfg.api_url,
|
||||||
@@ -267,8 +278,7 @@ class DouyuBatchRunnerCore:
|
|||||||
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
commodity_ids = {
|
commodity_ids = {
|
||||||
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods
|
||||||
for raw in goods
|
|
||||||
}
|
}
|
||||||
commodity_ids.discard("")
|
commodity_ids.discard("")
|
||||||
query = db.query(DouyuXpdGoodsSnapshot)
|
query = db.query(DouyuXpdGoodsSnapshot)
|
||||||
@@ -304,11 +314,15 @@ class DouyuBatchRunnerCore:
|
|||||||
|
|
||||||
def _config_info(self, db: Session) -> dict:
|
def _config_info(self, db: Session) -> dict:
|
||||||
config = ensure_douyu_config(db)
|
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:
|
def _task_payload(self, task: DouyuTask) -> dict:
|
||||||
result = task.result if isinstance(task.result, dict) else {}
|
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}
|
return {**payload, **self.payload}
|
||||||
|
|
||||||
def _client(self, cookie: str) -> DouyuActivityClient:
|
def _client(self, cookie: str) -> DouyuActivityClient:
|
||||||
@@ -338,8 +352,13 @@ class DouyuBatchRegistry:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._batches: dict[str, dict] = {}
|
self._batches: dict[str, dict] = {}
|
||||||
|
|
||||||
def register(self, batch_id: str, log_queue: asyncio.Queue,
|
def register(
|
||||||
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
log_queue: asyncio.Queue,
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
runner: DouyuBatchRunner,
|
||||||
|
):
|
||||||
self._batches[batch_id] = {
|
self._batches[batch_id] = {
|
||||||
"log_queue": log_queue,
|
"log_queue": log_queue,
|
||||||
"loop": loop,
|
"loop": loop,
|
||||||
@@ -368,4 +387,3 @@ class DouyuBatchRegistry:
|
|||||||
|
|
||||||
|
|
||||||
douyu_batch_registry = DouyuBatchRegistry()
|
douyu_batch_registry = DouyuBatchRegistry()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.douyu.activity_client import DouyuActivityClient
|
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
|
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
SUPPORTED_DOUYU_TASK_TYPES = {
|
||||||
|
"get_bind_qr": "获取绑定二维码",
|
||||||
"confirm_bind": "确认绑定",
|
"confirm_bind": "确认绑定",
|
||||||
"create_elite_qr": "开通精英宝典30",
|
"create_elite_qr": "开通精英宝典30",
|
||||||
"prepare_esports_bind": "绑定电竞手册角色",
|
"prepare_esports_bind": "绑定电竞手册角色",
|
||||||
@@ -53,26 +55,55 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
|||||||
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
|
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
|
||||||
DOUYU_HANDBOOK_TASK_TYPES = {
|
DOUYU_HANDBOOK_TASK_TYPES = {
|
||||||
"elite": {
|
"elite": {
|
||||||
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
|
"get_bind_qr",
|
||||||
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time",
|
"confirm_bind",
|
||||||
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
|
"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",
|
"prefetch_csrf_token",
|
||||||
},
|
},
|
||||||
"esports": {
|
"esports": {
|
||||||
"prepare_esports_bind", "get_esports_bind_qr", "query_esports_game_name", "confirm_esports_bind",
|
"prepare_esports_bind",
|
||||||
"create_esports_qr", "query_esports_points", "query_gold_balance", "query_change_bind_time",
|
"get_esports_bind_qr",
|
||||||
"query_limited_goods", "refresh_esports_goods", "exchange_esports_goods", "create_gold_qr",
|
"query_esports_game_name",
|
||||||
"donate_esports_chicken_gift", "donate_esports_firework_gift",
|
"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": {
|
"peace": {
|
||||||
"get_xpd_bind_qr", "query_xpd_bind_info", "confirm_xpd_bind", "query_xpd_role",
|
"get_xpd_bind_qr",
|
||||||
"refresh_xpd_goods", "query_xpd_balance", "query_xpd_fragments",
|
"query_xpd_bind_info",
|
||||||
"query_xpd_purchase_records", "exchange_xpd_goods",
|
"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",
|
"rid": "9263298",
|
||||||
"bind_act_alias": "20260120QYOOB",
|
"bind_act_alias": "20260120QYOOB",
|
||||||
"confirm_act_alias": "20260120QYOOB",
|
"confirm_act_alias": "20260120QYOOB",
|
||||||
@@ -117,12 +148,18 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
|
|||||||
"""补齐斗鱼配置默认值,返回是否发生变更。"""
|
"""补齐斗鱼配置默认值,返回是否发生变更。"""
|
||||||
changed = False
|
changed = False
|
||||||
for field in DOUYU_CONFIG_FIELDS:
|
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])
|
setattr(config, field, DOUYU_CONFIG_DEFAULTS[field])
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
normalized = douyu_config_value(field, getattr(config, field, None))
|
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]
|
normalized = DOUYU_CONFIG_DEFAULTS[field]
|
||||||
if getattr(config, field, None) != normalized:
|
if getattr(config, field, None) != normalized:
|
||||||
setattr(config, field, normalized)
|
setattr(config, field, normalized)
|
||||||
@@ -178,7 +215,11 @@ def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Acc
|
|||||||
"""只保留存在成功 Cookie 的斗鱼账号。"""
|
"""只保留存在成功 Cookie 的斗鱼账号。"""
|
||||||
if not account_ids:
|
if not account_ids:
|
||||||
return []
|
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 (
|
return (
|
||||||
db.query(Account)
|
db.query(Account)
|
||||||
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
|
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
|
||||||
@@ -217,23 +258,28 @@ def create_douyu_planned_tasks(
|
|||||||
raise ValueError("该任务不属于当前工作台")
|
raise ValueError("该任务不属于当前工作台")
|
||||||
|
|
||||||
accounts = visible_douyu_task_accounts(db, account_ids)
|
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 时前端无法选账号创建任务。
|
# 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
|
||||||
accounts = accounts[:1]
|
accounts = accounts[:1]
|
||||||
|
|
||||||
batch_id = uuid.uuid4().hex[:12]
|
batch_id = uuid.uuid4().hex[:12]
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
db.add(DouyuTask(
|
db.add(
|
||||||
batch_id=batch_id,
|
DouyuTask(
|
||||||
account_id=account.id,
|
batch_id=batch_id,
|
||||||
task_type=task_type,
|
account_id=account.id,
|
||||||
handbook_scope=handbook_scope,
|
task_type=task_type,
|
||||||
status="planned",
|
handbook_scope=handbook_scope,
|
||||||
message="任务已创建,等待执行",
|
status="planned",
|
||||||
result={"payload": payload} if payload else None,
|
message="任务已创建,等待执行",
|
||||||
created_by=created_by,
|
result={"payload": payload} if payload else None,
|
||||||
))
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return batch_id, len(accounts)
|
return batch_id, len(accounts)
|
||||||
|
|
||||||
@@ -272,7 +318,17 @@ def slim_douyu_goods(goods: object) -> object:
|
|||||||
return {
|
return {
|
||||||
key: value
|
key: value
|
||||||
for key, value in goods.items()
|
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]:
|
for item in goods[:5]:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
result.append({
|
result.append(
|
||||||
key: value
|
{
|
||||||
for key, value in item.items()
|
key: value
|
||||||
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
for key, value in item.items()
|
||||||
})
|
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
||||||
|
}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +363,9 @@ def strip_douyu_raw_snapshots(value: object) -> object:
|
|||||||
return value
|
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。"""
|
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
return result
|
return result
|
||||||
@@ -346,7 +406,12 @@ def sanitize_douyu_task_result(result: dict | None, task_type: str, *, include_d
|
|||||||
if "limited_goods" in data:
|
if "limited_goods" in data:
|
||||||
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
|
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)
|
data.pop("url", None)
|
||||||
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
||||||
data.pop("pay_url", None)
|
data.pop("pay_url", None)
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import uuid
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional, cast
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
|
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
|
||||||
|
from core.douyu.login import AccountLike
|
||||||
from core.douyu.proxy_fetcher import ProxyFetcher
|
from core.douyu.proxy_fetcher import ProxyFetcher
|
||||||
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
|
||||||
from .cookie_check_service import check_douyu_cookie
|
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 对象。"""
|
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
|
||||||
if proxy_config is None:
|
if proxy_config is None:
|
||||||
return None
|
return None
|
||||||
credentials = getattr(proxy_config, "whitelist_credentials", None)
|
credentials = getattr(proxy_config, "whitelist_credentials", None)
|
||||||
return SimpleNamespace(
|
return cast(
|
||||||
enabled=bool(getattr(proxy_config, "enabled", False)),
|
ProxyConfigModel,
|
||||||
http=getattr(proxy_config, "http", "") or "",
|
SimpleNamespace(
|
||||||
https=getattr(proxy_config, "https", "") or "",
|
enabled=bool(getattr(proxy_config, "enabled", False)),
|
||||||
api_url=getattr(proxy_config, "api_url", "") or "",
|
http=getattr(proxy_config, "http", "") or "",
|
||||||
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
|
https=getattr(proxy_config, "https", "") or "",
|
||||||
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
|
api_url=getattr(proxy_config, "api_url", "") or "",
|
||||||
whitelist_credentials=dict(credentials) if isinstance(credentials, dict) else credentials,
|
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
|
||||||
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
|
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
|
||||||
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
|
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_platform = "xiequ"
|
||||||
wl_credentials = None
|
wl_credentials = None
|
||||||
if proxy_config.whitelist_enabled:
|
if proxy_config.whitelist_enabled:
|
||||||
wl_platform = getattr(proxy_config, 'whitelist_platform', None) or "xiequ"
|
wl_platform = (
|
||||||
wl_credentials = getattr(proxy_config, 'whitelist_credentials', None)
|
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_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(
|
self._shared_proxy_fetcher = ProxyFetcher(
|
||||||
api_url=proxy_config.api_url,
|
api_url=proxy_config.api_url,
|
||||||
@@ -170,7 +187,11 @@ class LoginBatchRunner:
|
|||||||
def _push_log(self, level: str, message: str):
|
def _push_log(self, level: str, message: str):
|
||||||
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
|
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
|
||||||
if message:
|
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}")
|
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
|
||||||
if self.log_queue and self.loop:
|
if self.log_queue and self.loop:
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
@@ -181,15 +202,15 @@ class LoginBatchRunner:
|
|||||||
def _resolve_static_proxy(self) -> tuple[Optional[dict], str]:
|
def _resolve_static_proxy(self) -> tuple[Optional[dict], str]:
|
||||||
"""解析静态代理配置。"""
|
"""解析静态代理配置。"""
|
||||||
if not self.proxy_config or not self.proxy_config.enabled:
|
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:
|
if self.proxy_config.http or self.proxy_config.https:
|
||||||
proxy_url = 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 内部管理
|
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
|
||||||
return None, ''
|
return None, ""
|
||||||
|
|
||||||
def _execute_one(self, task_id: int, acc_info: dict, total: int):
|
def _execute_one(self, task_id: int, acc_info: dict, total: int):
|
||||||
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
|
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
|
||||||
@@ -216,8 +237,16 @@ class LoginBatchRunner:
|
|||||||
self._completed += 1
|
self._completed += 1
|
||||||
current = self._completed
|
current = self._completed
|
||||||
|
|
||||||
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
|
action_name = (
|
||||||
self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}")
|
"检测"
|
||||||
|
if self.mode == "check"
|
||||||
|
else "重新登录"
|
||||||
|
if self.mode == "relogin"
|
||||||
|
else "登录"
|
||||||
|
)
|
||||||
|
self._push_log(
|
||||||
|
"info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
|
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
|
||||||
@@ -226,26 +255,39 @@ class LoginBatchRunner:
|
|||||||
self._push_log("info", f"[{current}] {proxy_msg}")
|
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":
|
if self.mode == "relogin":
|
||||||
task.status = "relogin_failed"
|
task.status = "relogin_failed"
|
||||||
task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
|
task.message = (
|
||||||
|
"重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
task.status = "error"
|
task.status = "error"
|
||||||
task.message = "代理不可用: 未配置代理"
|
task.message = "代理不可用: 未配置代理"
|
||||||
task.finished_at = datetime.now(timezone.utc)
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
worker_db.commit()
|
worker_db.commit()
|
||||||
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用")
|
self._push_log(
|
||||||
|
"error", f"[{current}] {acc_info['username']} 代理不可用"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
account = SimpleNamespace(
|
account = cast(
|
||||||
username=acc_info["username"],
|
AccountLike,
|
||||||
password=acc_info["password"],
|
SimpleNamespace(
|
||||||
email=acc_info["email"],
|
username=acc_info["username"],
|
||||||
email_password=acc_info["email_password"],
|
password=acc_info["password"],
|
||||||
email_imap_server=acc_info["email_imap_server"] or "",
|
email=acc_info["email"],
|
||||||
email_imap_port=acc_info["email_imap_port"] or 993,
|
email_password=acc_info["email_password"],
|
||||||
email_imap_ssl=acc_info["email_imap_ssl"],
|
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(
|
loginer = DouyuLogin(
|
||||||
@@ -257,22 +299,33 @@ class LoginBatchRunner:
|
|||||||
stop_event=self._stop,
|
stop_event=self._stop,
|
||||||
api_strategy=self.api_strategy,
|
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:
|
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.status = status
|
||||||
task.cookie = ""
|
task.cookie = ""
|
||||||
task.message = result.message or CHECK_STATUS_MESSAGES[status]
|
task.message = result.message or CHECK_STATUS_MESSAGES[status]
|
||||||
level = CHECK_STATUS_LOG_LEVELS.get(status, "info")
|
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:
|
elif result.success:
|
||||||
task.status = "success"
|
task.status = "success"
|
||||||
task.cookie = result.cookie
|
task.cookie = result.cookie
|
||||||
task.message = result.message or "登录成功"
|
task.message = result.message or "登录成功"
|
||||||
if self.mode == "relogin":
|
if self.mode == "relogin":
|
||||||
check_result = check_douyu_cookie(result.cookie)
|
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 = {
|
task.ck_check_result = {
|
||||||
"fish_ball": check_result["fish_ball"],
|
"fish_ball": check_result["fish_ball"],
|
||||||
"nickname": check_result["nickname"],
|
"nickname": check_result["nickname"],
|
||||||
@@ -282,32 +335,54 @@ class LoginBatchRunner:
|
|||||||
task.ck_checked_at = check_result["checked_at"]
|
task.ck_checked_at = check_result["checked_at"]
|
||||||
if check_result["valid"]:
|
if check_result["valid"]:
|
||||||
task.message = "重新登录成功,Cookie 有效"
|
task.message = "重新登录成功,Cookie 有效"
|
||||||
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效")
|
self._push_log(
|
||||||
|
"success",
|
||||||
|
f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
|
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:
|
else:
|
||||||
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}")
|
self._push_log(
|
||||||
|
"success",
|
||||||
|
f"[{current}] {acc_info['username']} {task.message}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if self.mode == "relogin":
|
if self.mode == "relogin":
|
||||||
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
|
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
|
||||||
task.status = "relogin_failed"
|
task.status = "relogin_failed"
|
||||||
task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)"
|
task.message = (
|
||||||
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}")
|
f"重新登录失败: {result.message}(旧 Cookie 已保留)"
|
||||||
|
)
|
||||||
|
self._push_log(
|
||||||
|
"error",
|
||||||
|
f"[{current}] {acc_info['username']} 重新登录失败: {result.message}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
task.status = "failed"
|
task.status = "failed"
|
||||||
task.message = result.message
|
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:
|
except Exception as e:
|
||||||
if self.mode == "relogin":
|
if self.mode == "relogin":
|
||||||
task.status = "relogin_failed"
|
task.status = "relogin_failed"
|
||||||
task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
|
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:
|
else:
|
||||||
task.status = "error"
|
task.status = "error"
|
||||||
task.message = str(e)
|
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)
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
worker_db.commit()
|
worker_db.commit()
|
||||||
@@ -319,8 +394,17 @@ class LoginBatchRunner:
|
|||||||
"""在线程中执行批量登录。"""
|
"""在线程中执行批量登录。"""
|
||||||
batch_id = self.batch_id
|
batch_id = self.batch_id
|
||||||
concurrency = self.concurrency
|
concurrency = self.concurrency
|
||||||
action_name = "账号检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
|
action_name = (
|
||||||
self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}")
|
"账号检测"
|
||||||
|
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 不再主动同步
|
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
|
||||||
if self._shared_proxy_fetcher:
|
if self._shared_proxy_fetcher:
|
||||||
@@ -339,18 +423,22 @@ class LoginBatchRunner:
|
|||||||
task.message = ""
|
task.message = ""
|
||||||
task.finished_at = None
|
task.finished_at = None
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
task_infos.append({
|
task_infos.append(
|
||||||
"task_id": task.id,
|
{
|
||||||
"acc_info": {
|
"task_id": task.id,
|
||||||
"username": acc.username,
|
"acc_info": {
|
||||||
"password": acc.password,
|
"username": acc.username,
|
||||||
"email": acc.email,
|
"password": acc.password,
|
||||||
"email_password": acc.email_password,
|
"email": acc.email,
|
||||||
"email_imap_server": acc.email_imap_server or "",
|
"email_password": acc.email_password,
|
||||||
"email_imap_port": acc.email_imap_port or 993,
|
"email_imap_server": acc.email_imap_server or "",
|
||||||
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
|
"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:
|
try:
|
||||||
# 创建或复用任务记录(顺序执行,线程安全)
|
# 创建或复用任务记录(顺序执行,线程安全)
|
||||||
@@ -358,10 +446,16 @@ class LoginBatchRunner:
|
|||||||
if self.relogin_task_ids:
|
if self.relogin_task_ids:
|
||||||
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
|
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
|
||||||
for task_id in self.relogin_task_ids:
|
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:
|
if not task:
|
||||||
continue
|
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:
|
if not acc:
|
||||||
self._push_log("warning", f"跳过无账号的任务 #{task_id}")
|
self._push_log("warning", f"跳过无账号的任务 #{task_id}")
|
||||||
continue
|
continue
|
||||||
@@ -393,7 +487,9 @@ class LoginBatchRunner:
|
|||||||
# 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。
|
# 一个斗鱼账号只保留一条成功 CK:再次普通登录时更新最新成功记录。
|
||||||
latest_success_task = (
|
latest_success_task = (
|
||||||
self.db.query(LoginTask)
|
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())
|
.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -414,7 +510,10 @@ class LoginBatchRunner:
|
|||||||
# 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。
|
# 复用该账号最近一条失败任务记录,避免重复产生多条失败历史。
|
||||||
existing_task = (
|
existing_task = (
|
||||||
self.db.query(LoginTask)
|
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())
|
.order_by(LoginTask.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -479,9 +578,14 @@ class BatchRegistry:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._batches: dict[str, dict] = {}
|
self._batches: dict[str, dict] = {}
|
||||||
|
|
||||||
def register(self, batch_id: str, log_queue: Optional[asyncio.Queue],
|
def register(
|
||||||
loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner,
|
self,
|
||||||
owner_id: Optional[int] = None):
|
batch_id: str,
|
||||||
|
log_queue: Optional[asyncio.Queue],
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop],
|
||||||
|
runner: LoginBatchRunner,
|
||||||
|
owner_id: Optional[int] = None,
|
||||||
|
):
|
||||||
self._batches[batch_id] = {
|
self._batches[batch_id] = {
|
||||||
"log_queue": log_queue,
|
"log_queue": log_queue,
|
||||||
"loop": loop,
|
"loop": loop,
|
||||||
|
|||||||
Reference in New Issue
Block a user