type: 收敛测试 schemas 与协议层类型
This commit is contained in:
@@ -49,7 +49,9 @@ class EmailVerifier:
|
||||
self.password = password # 邮箱密码
|
||||
self.timeout = timeout
|
||||
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
|
||||
self.backup_passwords = backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
|
||||
self.backup_passwords = (
|
||||
backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
|
||||
)
|
||||
|
||||
# Roundcube 会话(懒初始化)
|
||||
self._rc_session: Optional[requests.Session] = None
|
||||
@@ -87,7 +89,9 @@ class EmailVerifier:
|
||||
|
||||
for index, password in enumerate(passwords):
|
||||
is_backup = index > 0
|
||||
ok, password_failed = self._try_roundcube_login(password, is_backup=is_backup)
|
||||
ok, password_failed = self._try_roundcube_login(
|
||||
password, is_backup=is_backup
|
||||
)
|
||||
if ok:
|
||||
return True
|
||||
credential_failed = credential_failed or password_failed
|
||||
@@ -112,13 +116,17 @@ class EmailVerifier:
|
||||
seen.add(password)
|
||||
return result
|
||||
|
||||
def _try_roundcube_login(self, password: str, is_backup: bool = False) -> tuple[bool, bool]:
|
||||
def _try_roundcube_login(
|
||||
self, password: str, is_backup: bool = False
|
||||
) -> tuple[bool, bool]:
|
||||
"""尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。"""
|
||||
try:
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
})
|
||||
session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
}
|
||||
)
|
||||
|
||||
# 1. 访问首页获取 token
|
||||
resp = session.get(self.roundcube_url, timeout=self.timeout)
|
||||
@@ -143,17 +151,20 @@ class EmailVerifier:
|
||||
)
|
||||
|
||||
# 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。
|
||||
new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text)
|
||||
new_token_match = re.search(
|
||||
r'request_token["\s:]+["\']([^"\']+)', resp.text
|
||||
)
|
||||
if not new_token_match:
|
||||
logger.warning("Roundcube: 登录失败(未找到 request_token)")
|
||||
return False, False
|
||||
|
||||
text_lower = resp.text.lower()
|
||||
login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower
|
||||
login_form_present = 'name="_user"' in resp.text and 'name="_pass"' in resp.text
|
||||
mail_page = (
|
||||
"_task=mail" in resp.url
|
||||
or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text)
|
||||
login_form_present = (
|
||||
'name="_user"' in resp.text and 'name="_pass"' in resp.text
|
||||
)
|
||||
mail_page = "_task=mail" in resp.url or re.search(
|
||||
r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text
|
||||
)
|
||||
|
||||
if login_failed or login_form_present or not mail_page:
|
||||
@@ -183,6 +194,8 @@ class EmailVerifier:
|
||||
if self._rc_login_error:
|
||||
raise EmailLoginError(self._rc_login_error)
|
||||
return []
|
||||
if self._rc_session is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = self._rc_session.get(
|
||||
@@ -201,7 +214,7 @@ class EmailVerifier:
|
||||
|
||||
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
|
||||
for m in re.finditer(
|
||||
r'add_message_row\((\d+),\s*(\{[^}]+\})',
|
||||
r"add_message_row\((\d+),\s*(\{[^}]+\})",
|
||||
exec_text,
|
||||
):
|
||||
uid = int(m.group(1))
|
||||
@@ -210,11 +223,13 @@ class EmailVerifier:
|
||||
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
|
||||
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
|
||||
|
||||
messages.append({
|
||||
"uid": uid,
|
||||
"subject": subject_m.group(1) if subject_m else "",
|
||||
"date": date_m.group(1) if date_m else "",
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"uid": uid,
|
||||
"subject": subject_m.group(1) if subject_m else "",
|
||||
"date": date_m.group(1) if date_m else "",
|
||||
}
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
@@ -230,11 +245,12 @@ class EmailVerifier:
|
||||
if self._rc_login_error:
|
||||
raise EmailLoginError(self._rc_login_error)
|
||||
return None
|
||||
if self._rc_session is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = self._rc_session.get(
|
||||
f"{self.roundcube_url}?_task=mail&_action=show"
|
||||
f"&_mbox=INBOX&_uid={uid}",
|
||||
f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}",
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return resp.text
|
||||
@@ -280,8 +296,14 @@ class EmailVerifier:
|
||||
|
||||
# 星期X
|
||||
weekday_map = {
|
||||
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
|
||||
"星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6,
|
||||
"星期一": 0,
|
||||
"星期二": 1,
|
||||
"星期三": 2,
|
||||
"星期四": 3,
|
||||
"星期五": 4,
|
||||
"星期六": 5,
|
||||
"星期日": 6,
|
||||
"星期天": 6,
|
||||
}
|
||||
for prefix, wd in weekday_map.items():
|
||||
if date_str.startswith(prefix):
|
||||
@@ -295,7 +317,9 @@ class EmailVerifier:
|
||||
target = now
|
||||
else:
|
||||
target = now - timedelta(days=days_ago)
|
||||
return target.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
|
||||
return target.replace(
|
||||
hour=int(h), minute=int(m), second=0, microsecond=0
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
@@ -366,7 +390,9 @@ class EmailVerifier:
|
||||
raise InterruptedError("任务已停止")
|
||||
|
||||
try:
|
||||
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
|
||||
code = self._fetch_code_via_roundcube(
|
||||
after_timestamp, allow_old_seconds
|
||||
)
|
||||
if code:
|
||||
logger.success(f"获取到验证码: {code}")
|
||||
return code
|
||||
@@ -383,7 +409,9 @@ class EmailVerifier:
|
||||
raise InterruptedError("任务已停止")
|
||||
time.sleep(min(0.2, sleep_deadline - time.monotonic()))
|
||||
|
||||
raise TimeoutError(f"等待验证码超时{'(' + last_error + ')' if last_error else ''}")
|
||||
raise TimeoutError(
|
||||
f"等待验证码超时{'(' + last_error + ')' if last_error else ''}"
|
||||
)
|
||||
|
||||
# ── Roundcube 方式获取验证码 ────────────────────────────
|
||||
|
||||
@@ -397,7 +425,8 @@ class EmailVerifier:
|
||||
|
||||
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
||||
douyu_msgs = [
|
||||
msg for msg in messages
|
||||
msg
|
||||
for msg in messages
|
||||
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
||||
]
|
||||
|
||||
@@ -407,7 +436,9 @@ class EmailVerifier:
|
||||
msg_dt = self._parse_rc_date(msg["date"])
|
||||
if msg_dt is None:
|
||||
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
|
||||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过")
|
||||
logger.debug(
|
||||
f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过"
|
||||
)
|
||||
continue
|
||||
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
|
||||
# 而 after_timestamp 是秒级精度(如14:20:49)。
|
||||
@@ -415,7 +446,9 @@ class EmailVerifier:
|
||||
# 解决:将 after_timestamp 也向下取整到分钟后再比较。
|
||||
after_minute_ts = after_timestamp - (after_timestamp % 60)
|
||||
if msg_dt.timestamp() < after_minute_ts - allow_old_seconds:
|
||||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过")
|
||||
logger.debug(
|
||||
f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过"
|
||||
)
|
||||
continue
|
||||
|
||||
# 读取邮件正文提取验证码
|
||||
@@ -436,16 +469,16 @@ class EmailVerifier:
|
||||
"""从文本中提取6位验证码"""
|
||||
# 清理HTML标签和实体
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
|
||||
# 查找6位数字验证码(优先匹配带上下文的)
|
||||
patterns = [
|
||||
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})',
|
||||
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',
|
||||
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)',
|
||||
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})',
|
||||
r'\b(\d{6})\b',
|
||||
r"(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})",
|
||||
r"(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})",
|
||||
r"(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)",
|
||||
r"(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})",
|
||||
r"\b(\d{6})\b",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
@@ -459,7 +492,7 @@ class EmailVerifier:
|
||||
def get_email_config_for_account(email_address: str) -> dict:
|
||||
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
||||
return {
|
||||
'server': '',
|
||||
'port': 143,
|
||||
'ssl': False,
|
||||
"server": "",
|
||||
"port": 143,
|
||||
"ssl": False,
|
||||
}
|
||||
|
||||
+194
-116
@@ -16,7 +16,9 @@ from .login_api_wgapi import WgapiLoginAPI
|
||||
from .proxy_fetcher import ProxyFetcher
|
||||
|
||||
from core.geetest.v3_slide.solver import (
|
||||
_generate_seed, get_w1, get_w2,
|
||||
_generate_seed,
|
||||
get_w1,
|
||||
get_w2,
|
||||
)
|
||||
from core.geetest.common.network import (
|
||||
get_js_address,
|
||||
@@ -44,6 +46,7 @@ class CredentialError(ValueError):
|
||||
|
||||
class AccountLike(Protocol):
|
||||
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
email: str
|
||||
@@ -56,7 +59,9 @@ class AccountLike(Protocol):
|
||||
class LoginResult:
|
||||
"""登录结果"""
|
||||
|
||||
def __init__(self, success: bool, cookie: str = "", message: str = "", code: str = ""):
|
||||
def __init__(
|
||||
self, success: bool, cookie: str = "", message: str = "", code: str = ""
|
||||
):
|
||||
self.success = success
|
||||
self.cookie = cookie
|
||||
self.message = message
|
||||
@@ -68,7 +73,9 @@ class DouyuLogin:
|
||||
|
||||
# 斗鱼API地址
|
||||
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
||||
SEND_EMAIL_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||||
SEND_EMAIL_API = (
|
||||
"https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||||
)
|
||||
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||||
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
|
||||
@@ -141,50 +148,52 @@ class DouyuLogin:
|
||||
"""配置Session"""
|
||||
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
|
||||
self.session.trust_env = False
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Referer': self.LOGIN_REFERER,
|
||||
'Origin': 'https://passport.douyu.com',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
})
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Referer": self.LOGIN_REFERER,
|
||||
"Origin": "https://passport.douyu.com",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
)
|
||||
|
||||
# 设置代理
|
||||
self._apply_proxy()
|
||||
|
||||
def _apply_proxy(self, proxy: str = None) -> None:
|
||||
def _apply_proxy(self, proxy: str | None = None) -> None:
|
||||
"""应用代理到Session"""
|
||||
if proxy:
|
||||
# 使用指定的代理
|
||||
self.session.proxies = {
|
||||
'http': proxy,
|
||||
'https': proxy,
|
||||
"http": proxy,
|
||||
"https": proxy,
|
||||
}
|
||||
self._current_proxy_url = proxy
|
||||
elif self.proxy:
|
||||
# 使用配置的静态代理
|
||||
if isinstance(self.proxy, str):
|
||||
self.session.proxies = {
|
||||
'http': self.proxy,
|
||||
'https': self.proxy,
|
||||
"http": self.proxy,
|
||||
"https": self.proxy,
|
||||
}
|
||||
self._current_proxy_url = self.proxy
|
||||
else:
|
||||
self.session.proxies = {
|
||||
scheme: url
|
||||
for scheme, url in self.proxy.items()
|
||||
if url
|
||||
scheme: url for scheme, url in self.proxy.items() if url
|
||||
}
|
||||
self._current_proxy_url = self.proxy.get('http') or self.proxy.get('https')
|
||||
self._current_proxy_url = self.proxy.get("http") or self.proxy.get(
|
||||
"https"
|
||||
)
|
||||
elif self.proxy_fetcher:
|
||||
# 从代理获取器取新代理
|
||||
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
||||
if new_proxy:
|
||||
self.session.proxies = {
|
||||
'http': new_proxy,
|
||||
'https': new_proxy,
|
||||
"http": new_proxy,
|
||||
"https": new_proxy,
|
||||
}
|
||||
self._current_proxy_url = new_proxy
|
||||
else:
|
||||
@@ -207,7 +216,7 @@ class DouyuLogin:
|
||||
统一发送请求,附带分段超时和更明确的错误信息。
|
||||
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
|
||||
"""
|
||||
timeout = kwargs.pop('timeout', self.timeout)
|
||||
timeout = kwargs.pop("timeout", self.timeout)
|
||||
safe_url = self._safe_url(url)
|
||||
|
||||
self._ensure_not_stopped()
|
||||
@@ -256,19 +265,21 @@ class DouyuLogin:
|
||||
@staticmethod
|
||||
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
|
||||
"""识别登录接口返回的账号类终态。"""
|
||||
error_code = payload.get('error')
|
||||
error_msg = str(payload.get('msg') or '')
|
||||
error_code = payload.get("error")
|
||||
error_msg = str(payload.get("msg") or "")
|
||||
|
||||
if error_code == 110022 or '账号不存在' in error_msg:
|
||||
if error_code == 110022 or "账号不存在" in error_msg:
|
||||
return "account_cancelled", "账号已注销"
|
||||
|
||||
password_keywords = ['密码错误', '账号或密码', '账号或者密码']
|
||||
password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
|
||||
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
|
||||
return "password_wrong", "账号密码错误"
|
||||
|
||||
return None
|
||||
|
||||
def _credential_error_from_payload(self, stage: str, payload: dict) -> CredentialError | None:
|
||||
def _credential_error_from_payload(
|
||||
self, stage: str, payload: dict
|
||||
) -> CredentialError | None:
|
||||
"""把账号类错误转换为不重试的 CredentialError。"""
|
||||
classified = self._classify_credential_payload(payload)
|
||||
if not classified:
|
||||
@@ -293,10 +304,14 @@ class DouyuLogin:
|
||||
# 3️⃣ 第二次登录(带极验)
|
||||
logger.info("步骤3: 第二次登录(带极验验证)...")
|
||||
next_step, value = self._second_login(
|
||||
gt, challenge, validate, seccode, code_token,
|
||||
gt,
|
||||
challenge,
|
||||
validate,
|
||||
seccode,
|
||||
code_token,
|
||||
)
|
||||
|
||||
if next_step == 'mobile_bind_skip':
|
||||
if next_step == "mobile_bind_skip":
|
||||
logger.info("步骤4: 跳过手机号绑定,完成登录...")
|
||||
return self._complete_login(value)
|
||||
|
||||
@@ -339,12 +354,19 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message="任务已停止")
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
||||
return LoginResult(success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)")
|
||||
logger.warning(
|
||||
f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||||
)
|
||||
return LoginResult(
|
||||
success=False,
|
||||
message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||||
)
|
||||
|
||||
if attempt > 1:
|
||||
if self.max_login_retries > 0:
|
||||
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
||||
logger.info(
|
||||
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||
)
|
||||
else:
|
||||
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
|
||||
# 重试前:取新代理 + 重置 session
|
||||
@@ -371,15 +393,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message=str(e), code=e.code)
|
||||
except EmailLoginError as e:
|
||||
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
|
||||
return LoginResult(success=False, message=str(e), code="email_login_failed")
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_login_failed"
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_login_retries > 0:
|
||||
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
|
||||
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
has_retry = (
|
||||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
)
|
||||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||
if has_retry and has_time:
|
||||
self._sleep_interruptible(1)
|
||||
@@ -405,14 +435,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=False, message="任务已停止")
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||||
logger.warning(f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
|
||||
return LoginResult(success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)")
|
||||
logger.warning(
|
||||
f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||||
)
|
||||
return LoginResult(
|
||||
success=False,
|
||||
message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||||
)
|
||||
|
||||
if attempt > 1:
|
||||
if self.max_login_retries > 0:
|
||||
logger.info(f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始")
|
||||
logger.info(
|
||||
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||||
)
|
||||
else:
|
||||
logger.info(f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始")
|
||||
logger.info(
|
||||
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
|
||||
)
|
||||
if not self._prepare_retry():
|
||||
return LoginResult(
|
||||
success=False,
|
||||
@@ -431,15 +470,23 @@ class DouyuLogin:
|
||||
return LoginResult(success=True, message=e.status_message, code=e.code)
|
||||
except EmailLoginError as e:
|
||||
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}")
|
||||
return LoginResult(success=False, message=str(e), code="email_login_failed")
|
||||
return LoginResult(
|
||||
success=False, message=str(e), code="email_login_failed"
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if self.max_login_retries > 0:
|
||||
logger.error(f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}")
|
||||
logger.error(
|
||||
f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||||
)
|
||||
|
||||
has_retry = self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
has_retry = (
|
||||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||||
)
|
||||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||||
if has_retry and has_time:
|
||||
self._sleep_interruptible(1)
|
||||
@@ -449,40 +496,44 @@ class DouyuLogin:
|
||||
def _check_certification_status(self) -> LoginResult:
|
||||
"""请求个人中心接口并判断实名状态。"""
|
||||
headers = {
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Priority': 'u=1, i',
|
||||
'Referer': self.CP_REFERER,
|
||||
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': None,
|
||||
'Content-Type': None,
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": self.CP_REFERER,
|
||||
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Origin": None,
|
||||
"Content-Type": None,
|
||||
}
|
||||
payload = self._request_json(
|
||||
'get',
|
||||
"get",
|
||||
self.CP_RPC_API,
|
||||
'账号认证状态接口',
|
||||
"账号认证状态接口",
|
||||
headers=headers,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
info = payload.get('info') or {}
|
||||
ident_status = str(info.get('ident_status', ''))
|
||||
ident_type = str(info.get('ident_type', ''))
|
||||
info = payload.get("info") or {}
|
||||
ident_status = str(info.get("ident_status", ""))
|
||||
ident_type = str(info.get("ident_type", ""))
|
||||
|
||||
if ident_status == '0' and ident_type == '0':
|
||||
if ident_status == "0" and ident_type == "0":
|
||||
logger.success("账号检测完成: 账号未认证")
|
||||
return LoginResult(success=True, message="账号未认证", code="account_unverified")
|
||||
return LoginResult(
|
||||
success=True, message="账号未认证", code="account_unverified"
|
||||
)
|
||||
|
||||
if ident_status == '2' and ident_type == '2':
|
||||
if ident_status == "2" and ident_type == "2":
|
||||
logger.success("账号检测完成: 账号已认证")
|
||||
return LoginResult(success=True, message="账号已认证", code="account_verified")
|
||||
return LoginResult(
|
||||
success=True, message="账号已认证", code="account_verified"
|
||||
)
|
||||
|
||||
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
|
||||
logger.warning(message)
|
||||
@@ -528,17 +579,19 @@ class DouyuLogin:
|
||||
)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.first_login_url,
|
||||
'第一次登录接口',
|
||||
"第一次登录接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||||
|
||||
if payload.get('error') != 81:
|
||||
error_msg = payload.get('msg', '未知错误')
|
||||
credential_error = self._credential_error_from_payload("第一次登录", payload)
|
||||
if payload.get("error") != 81:
|
||||
error_msg = payload.get("msg", "未知错误")
|
||||
credential_error = self._credential_error_from_payload(
|
||||
"第一次登录", payload
|
||||
)
|
||||
if credential_error:
|
||||
raise credential_error
|
||||
raise ValueError(f"第一次登录失败: {error_msg}")
|
||||
@@ -553,7 +606,9 @@ class DouyuLogin:
|
||||
|
||||
return gt, challenge, code_token, self.session.cookies.get_dict()
|
||||
|
||||
def _solve_geetest(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
|
||||
def _solve_geetest(
|
||||
self, gt: str, challenge: str, deadline: float = 0
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
|
||||
|
||||
@@ -578,7 +633,9 @@ class DouyuLogin:
|
||||
finally:
|
||||
_geetest_semaphore.release()
|
||||
|
||||
def _solve_geetest_inner(self, gt: str, challenge: str, deadline: float = 0) -> Tuple[str, str]:
|
||||
def _solve_geetest_inner(
|
||||
self, gt: str, challenge: str, deadline: float = 0
|
||||
) -> Tuple[str, str]:
|
||||
"""极验验证内部实现:最多3次尝试,失败直接抛异常。"""
|
||||
_MAX_ATTEMPTS = 3
|
||||
|
||||
@@ -620,14 +677,18 @@ class DouyuLogin:
|
||||
|
||||
# 从 fullpage ajax.php 响应中提取 validate
|
||||
if isinstance(result, dict):
|
||||
data = result.get('data', {})
|
||||
validate = data.get('validate', '') or result.get('validate', '')
|
||||
success = data.get('result') == 'success' or result.get('success') == 1
|
||||
message = data.get('result') or result.get('message', '')
|
||||
data = result.get("data", {})
|
||||
validate = data.get("validate", "") or result.get("validate", "")
|
||||
success = (
|
||||
data.get("result") == "success" or result.get("success") == 1
|
||||
)
|
||||
message = data.get("result") or result.get("message", "")
|
||||
|
||||
if success and validate:
|
||||
seccode = f"{validate}|jordan"
|
||||
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
|
||||
logger.success(
|
||||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||
)
|
||||
return validate, seccode
|
||||
else:
|
||||
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
|
||||
@@ -636,7 +697,9 @@ class DouyuLogin:
|
||||
validate = str(result)
|
||||
if validate:
|
||||
seccode = f"{validate}|jordan"
|
||||
logger.success(f"极验 fullpage 验证成功! validate={validate[:20]}...")
|
||||
logger.success(
|
||||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||||
)
|
||||
return validate, seccode
|
||||
|
||||
except ValueError:
|
||||
@@ -645,11 +708,16 @@ class DouyuLogin:
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
# 所有网络/代理异常都直接上抛,由 login 换新代理
|
||||
logger.warning(f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理")
|
||||
raise ValueError(f"极验验证异常: {self._truncate_error(err_str)}") from e
|
||||
logger.warning(
|
||||
f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理"
|
||||
)
|
||||
raise ValueError(
|
||||
f"极验验证异常: {self._truncate_error(err_str)}"
|
||||
) from e
|
||||
|
||||
def _second_login(self, gt: str, challenge: str, validate: str,
|
||||
seccode: str, code_token: str) -> tuple[str, str]:
|
||||
def _second_login(
|
||||
self, gt: str, challenge: str, validate: str, seccode: str, code_token: str
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
第二次登录(带极验验证)
|
||||
|
||||
@@ -668,16 +736,22 @@ class DouyuLogin:
|
||||
data = self.api.build_second_login_data(
|
||||
self.account.username,
|
||||
self.account.password,
|
||||
gt, challenge, validate, seccode, code_token,
|
||||
gt,
|
||||
challenge,
|
||||
validate,
|
||||
seccode,
|
||||
code_token,
|
||||
self.LOGIN_REFERER,
|
||||
)
|
||||
|
||||
logger.debug(f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}...")
|
||||
logger.debug(
|
||||
f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..."
|
||||
)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.login_url,
|
||||
'第二次登录接口',
|
||||
"第二次登录接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
@@ -689,11 +763,13 @@ class DouyuLogin:
|
||||
if unique_key:
|
||||
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
|
||||
login_url = self._skip_mobile_bind(unique_key)
|
||||
return 'mobile_bind_skip', login_url
|
||||
return "mobile_bind_skip", login_url
|
||||
|
||||
if payload.get('error') != 130014:
|
||||
error_msg = payload.get('msg', '未知错误')
|
||||
credential_error = self._credential_error_from_payload("第二次登录", payload)
|
||||
if payload.get("error") != 130014:
|
||||
error_msg = payload.get("msg", "未知错误")
|
||||
credential_error = self._credential_error_from_payload(
|
||||
"第二次登录", payload
|
||||
)
|
||||
if credential_error:
|
||||
raise credential_error
|
||||
raise ValueError(f"第二次登录失败: {error_msg}")
|
||||
@@ -703,26 +779,26 @@ class DouyuLogin:
|
||||
|
||||
if not remote_code:
|
||||
# 提取可能的风控提示
|
||||
data = payload.get('data', {})
|
||||
security_quiz = data.get('securityQuiz', '')
|
||||
msg = payload.get('msg', '')
|
||||
detail = security_quiz or msg or '未知原因'
|
||||
data = payload.get("data", {})
|
||||
security_quiz = data.get("securityQuiz", "")
|
||||
msg = payload.get("msg", "")
|
||||
detail = security_quiz or msg or "未知原因"
|
||||
raise ValueError(f"获取remote_code失败: {detail}")
|
||||
|
||||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
||||
|
||||
return 'remote_email', remote_code
|
||||
return "remote_email", remote_code
|
||||
|
||||
def _skip_mobile_bind(self, unique_key: str) -> str:
|
||||
"""跳过手机号绑定并获取后续登录回调地址。"""
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.login_url,
|
||||
'跳过手机号绑定接口',
|
||||
"跳过手机号绑定接口",
|
||||
data=self.api.build_skip_mobile_bind_data(unique_key),
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
|
||||
|
||||
login_url = self.api.extract_login_url(payload)
|
||||
@@ -738,13 +814,13 @@ class DouyuLogin:
|
||||
data = self.api.build_send_email_data(remote_code)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.send_email_url,
|
||||
'发送验证邮件接口',
|
||||
"发送验证邮件接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||||
|
||||
logger.info("验证邮件已发送")
|
||||
@@ -775,13 +851,13 @@ class DouyuLogin:
|
||||
data = self.api.build_verify_data(remote_code, verify_code)
|
||||
|
||||
payload = self._request_json(
|
||||
'post',
|
||||
"post",
|
||||
self.api.verify_url,
|
||||
'提交验证码接口',
|
||||
"提交验证码接口",
|
||||
data=data,
|
||||
)
|
||||
|
||||
if payload.get('error') != 0:
|
||||
if payload.get("error") != 0:
|
||||
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
||||
|
||||
login_url = self.api.extract_login_url(payload)
|
||||
@@ -798,8 +874,8 @@ class DouyuLogin:
|
||||
@staticmethod
|
||||
def _normalize_login_url(login_url: str) -> str:
|
||||
"""补全斗鱼接口返回的协议相对登录回调地址。"""
|
||||
if login_url.startswith('//'):
|
||||
return 'https:' + login_url
|
||||
if login_url.startswith("//"):
|
||||
return "https:" + login_url
|
||||
return login_url
|
||||
|
||||
def _complete_login(self, login_url: str) -> str:
|
||||
@@ -810,16 +886,16 @@ class DouyuLogin:
|
||||
cookie: 完整的Cookie字符串
|
||||
"""
|
||||
# 访问登录URL
|
||||
response = self._request('get', login_url)
|
||||
response = self._request("get", login_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# 尝试访问webLogin获取用户信息
|
||||
try:
|
||||
code_match = re.search(r'code=([^&]+)', login_url)
|
||||
code_match = re.search(r"code=([^&]+)", login_url)
|
||||
if code_match:
|
||||
code = code_match.group(1)
|
||||
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
|
||||
response2 = self._request('get', weblogin_url)
|
||||
response2 = self._request("get", weblogin_url)
|
||||
|
||||
if response2.status_code == 200:
|
||||
logger.info("WebLogin成功")
|
||||
@@ -839,7 +915,9 @@ class DouyuLogin:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._cookie_enrich_error = self._truncate_error(str(e), 160)
|
||||
logger.warning(f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}")
|
||||
logger.warning(
|
||||
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
|
||||
)
|
||||
|
||||
return self._format_cookie_string()
|
||||
|
||||
@@ -848,6 +926,6 @@ class DouyuLogin:
|
||||
cookies = self.session.cookies.get_dict()
|
||||
|
||||
# 格式化Cookie字符串
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
|
||||
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
return cookie_str
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
3. 在 _PLATFORM_CREDENTIAL_FIELDS 中定义凭据字段
|
||||
"""
|
||||
|
||||
from typing import Optional, Type
|
||||
from typing import Any, Optional, Type, cast
|
||||
|
||||
from .base import BaseWhitelistAdapter, get_exit_ip_via_proxy
|
||||
from .xiequ import XiequAdapter
|
||||
@@ -22,19 +22,30 @@ _PLATFORM_REGISTRY: dict[str, Type[BaseWhitelistAdapter]] = {
|
||||
_PLATFORM_CREDENTIAL_FIELDS: dict[str, list[dict]] = {
|
||||
"xiequ": [
|
||||
{"key": "uid", "label": "UID", "placeholder": "如: 99769"},
|
||||
{"key": "ukey", "label": "UKEY", "placeholder": "如: C99371082B965B70F46DCAA87A04618B"},
|
||||
{
|
||||
"key": "ukey",
|
||||
"label": "UKEY",
|
||||
"placeholder": "如: C99371082B965B70F46DCAA87A04618B",
|
||||
},
|
||||
],
|
||||
"xkdaili": [
|
||||
{"key": "apikey", "label": "API Key", "placeholder": "如: XK86872CC0C85A415461"},
|
||||
{"key": "wl_sign", "label": "白名单签名(Sign)", "placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027"},
|
||||
{
|
||||
"key": "apikey",
|
||||
"label": "API Key",
|
||||
"placeholder": "如: XK86872CC0C85A415461",
|
||||
},
|
||||
{
|
||||
"key": "wl_sign",
|
||||
"label": "白名单签名(Sign)",
|
||||
"placeholder": "白名单接口专用,如: 8953b3082173aa4188e7403e81249027",
|
||||
},
|
||||
{"key": "flag", "label": "Flag", "placeholder": "套餐标识,如: 8"},
|
||||
],
|
||||
}
|
||||
|
||||
# ── 平台显示名 ──
|
||||
_PLATFORM_LABELS: dict[str, str] = {
|
||||
name: cls(dict()).platform_label
|
||||
for name, cls in _PLATFORM_REGISTRY.items()
|
||||
name: cast(Any, cls)({}).platform_label for name, cls in _PLATFORM_REGISTRY.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +54,7 @@ def create_adapter(platform: str, credentials: dict) -> Optional[BaseWhitelistAd
|
||||
cls = _PLATFORM_REGISTRY.get(platform)
|
||||
if not cls:
|
||||
return None
|
||||
return cls(credentials)
|
||||
return cast(Any, cls)(credentials)
|
||||
|
||||
|
||||
def get_platform_names() -> list[str]:
|
||||
|
||||
@@ -110,20 +110,26 @@ class BaseWhitelistAdapter(ABC):
|
||||
return True, msg
|
||||
|
||||
# 当前IP已存在但备注不同,不处理
|
||||
if any(r.get('ip') == current_ip for r in records):
|
||||
logger.info(f"白名单IP {current_ip} 已存在(备注不同),无需重复添加")
|
||||
if any(r.get("ip") == current_ip for r in records):
|
||||
logger.info(
|
||||
f"白名单IP {current_ip} 已存在(备注不同),无需重复添加"
|
||||
)
|
||||
return True, f"白名单IP已存在: {current_ip}"
|
||||
|
||||
# 超过上限,删除最老的
|
||||
if len(memo_ips) >= keep_recent:
|
||||
to_delete = memo_ips[:len(memo_ips) - keep_recent + 1]
|
||||
to_delete = memo_ips[: len(memo_ips) - keep_recent + 1]
|
||||
for old_ip in to_delete:
|
||||
if not old_ip:
|
||||
continue
|
||||
logger.info(f"白名单同备注IP超限,删除旧的: {old_ip}")
|
||||
self.delete_ip(old_ip)
|
||||
time.sleep(1)
|
||||
|
||||
# 添加新IP
|
||||
logger.info(f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)")
|
||||
logger.info(
|
||||
f"白名单添加新出口IP: {current_ip} (当前 {len(memo_ips)} 个同备注)"
|
||||
)
|
||||
ok, resp = self.add_ip(current_ip)
|
||||
if ok:
|
||||
msg = f"白名单IP已添加: {current_ip}"
|
||||
@@ -152,10 +158,12 @@ def _get_local_exit_ip() -> Optional[str]:
|
||||
]
|
||||
for url in targets:
|
||||
try:
|
||||
response = requests.get(url, timeout=8, headers={"User-Agent": "Mozilla/5.0"})
|
||||
response = requests.get(
|
||||
url, timeout=8, headers={"User-Agent": "Mozilla/5.0"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip()
|
||||
match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', text)
|
||||
match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
@@ -174,7 +182,9 @@ def get_exit_ip_via_proxy(proxy: str) -> Optional[str]:
|
||||
for url in targets:
|
||||
try:
|
||||
response = requests.get(
|
||||
url, proxies=proxies, timeout=6,
|
||||
url,
|
||||
proxies=proxies,
|
||||
timeout=6,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -17,7 +17,7 @@ class DouyuWhitelistSyncer:
|
||||
def __init__(
|
||||
self,
|
||||
platform: str = "xiequ",
|
||||
credentials: dict = None,
|
||||
credentials: Optional[dict] = None,
|
||||
uid: str = "",
|
||||
ukey: str = "",
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user