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

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