增强登录稳定性

This commit is contained in:
yml2213
2026-06-24 09:17:15 +08:00
parent 177ec42416
commit 7bb50a9bbf
4 changed files with 106 additions and 14 deletions
+11 -1
View File
@@ -2,6 +2,7 @@
import html import html
import re import re
import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -261,6 +262,7 @@ class EmailVerifier:
interval: int = 3, interval: int = 3,
after_timestamp: Optional[float] = None, after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15, allow_old_seconds: int = 15,
stop_event: Optional[threading.Event] = None,
) -> str: ) -> str:
""" """
轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php。 轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php。
@@ -270,6 +272,7 @@ class EmailVerifier:
interval: 轮询间隔(秒) interval: 轮询间隔(秒)
after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件 after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件
allow_old_seconds: 允许的时间偏移(秒) allow_old_seconds: 允许的时间偏移(秒)
stop_event: 外部停止信号,触发后尽快中断等待
Returns: Returns:
6位验证码 6位验证码
@@ -281,6 +284,9 @@ class EmailVerifier:
tried_roundcube = False tried_roundcube = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
if stop_event and stop_event.is_set():
raise InterruptedError("任务已停止")
# 优先尝试 Roundcube # 优先尝试 Roundcube
if not tried_roundcube or self._rc_logged_in: if not tried_roundcube or self._rc_logged_in:
try: try:
@@ -304,7 +310,11 @@ class EmailVerifier:
logger.warning(f"read.php 读邮件异常: {e}") logger.warning(f"read.php 读邮件异常: {e}")
logger.debug("未找到验证码,等待中...") logger.debug("未找到验证码,等待中...")
time.sleep(interval) sleep_deadline = time.monotonic() + interval
while time.monotonic() < sleep_deadline:
if stop_event and stop_event.is_set():
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 ''}")
+62 -10
View File
@@ -1,7 +1,8 @@
"""斗鱼登录核心模块""" """斗鱼登录核心模块"""
import re
import json import json
import re
import threading
import time import time
import requests import requests
from typing import Mapping, Optional, Protocol, Tuple from typing import Mapping, Optional, Protocol, Tuple
@@ -58,6 +59,8 @@ class DouyuLogin:
CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298" CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
COOKIE_ENRICH_TIMEOUT = (5, 10)
COOKIE_ENRICH_RETRIES = 3
LOGIN_REFERER = ( LOGIN_REFERER = (
"https://passport.douyu.com/index/login?" "https://passport.douyu.com/index/login?"
"passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&" "passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&"
@@ -82,6 +85,7 @@ class DouyuLogin:
whitelist_uid: str = "", whitelist_uid: str = "",
whitelist_ukey: str = "", whitelist_ukey: str = "",
proxy_manager: Optional[ProxyManager] = None, proxy_manager: Optional[ProxyManager] = None,
stop_event: Optional[threading.Event] = None,
): ):
self.account = account self.account = account
self.proxy = proxy self.proxy = proxy
@@ -90,6 +94,7 @@ class DouyuLogin:
self.max_proxy_retries = max_proxy_retries # 0=无限切换直到成功 self.max_proxy_retries = max_proxy_retries # 0=无限切换直到成功
self.max_login_retries = max_login_retries # 登录整体重试次数(换代理从头重跑) self.max_login_retries = max_login_retries # 登录整体重试次数(换代理从头重跑)
self.max_total_time = max_total_time # 单账号登录总时长上限(秒),超时则放弃 self.max_total_time = max_total_time # 单账号登录总时长上限(秒),超时则放弃
self.stop_event = stop_event
self.session = requests.Session() self.session = requests.Session()
# 初始化代理管理器(优先使用外部传入的共享实例,避免并发刷新冲突) # 初始化代理管理器(优先使用外部传入的共享实例,避免并发刷新冲突)
@@ -108,6 +113,28 @@ class DouyuLogin:
self._cookie_enrich_error = "" self._cookie_enrich_error = ""
self._setup_session() self._setup_session()
def _is_stopped(self) -> bool:
"""判断外部批量任务是否请求停止。"""
return bool(self.stop_event and self.stop_event.is_set())
def _ensure_not_stopped(self) -> None:
"""在长流程边界主动中断。"""
if self._is_stopped():
raise InterruptedError("任务已停止")
def _sleep_interruptible(self, seconds: float) -> None:
"""可被 stop_event 打断的短睡眠。"""
if seconds <= 0:
self._ensure_not_stopped()
return
deadline = time.monotonic() + seconds
while True:
self._ensure_not_stopped()
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(0.2, remaining))
def _setup_session(self) -> None: def _setup_session(self) -> None:
"""配置Session""" """配置Session"""
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。 # 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
@@ -175,6 +202,7 @@ class DouyuLogin:
self.proxy_manager.mark_bad(self._current_proxy_url) self.proxy_manager.mark_bad(self._current_proxy_url)
new_proxy = self.proxy_manager.get_proxy() new_proxy = self.proxy_manager.get_proxy()
self._ensure_not_stopped()
if new_proxy: if new_proxy:
self._apply_proxy(new_proxy) self._apply_proxy(new_proxy)
logger.info(f"已切换代理: {new_proxy}{' (旧代理已标记坏)' if mark_bad else ' (旧代理保留)'}") logger.info(f"已切换代理: {new_proxy}{' (旧代理已标记坏)' if mark_bad else ' (旧代理保留)'}")
@@ -228,6 +256,7 @@ class DouyuLogin:
tag = self.account.username if self.account else "" tag = self.account.username if self.account else ""
for attempt in range(max_retries): for attempt in range(max_retries):
self._ensure_not_stopped()
started = time.monotonic() started = time.monotonic()
try: try:
@@ -281,7 +310,7 @@ class DouyuLogin:
if attempt < max_retries - 1: if attempt < max_retries - 1:
self._refresh_proxy() self._refresh_proxy()
current_proxy = self._current_proxy_url current_proxy = self._current_proxy_url
time.sleep(1) self._sleep_interruptible(1)
continue continue
raise ConnectionError( raise ConnectionError(
f"{method.upper()} {safe_url} 代理连接失败,已重试 {max_retries}" f"{method.upper()} {safe_url} 代理连接失败,已重试 {max_retries}"
@@ -289,7 +318,7 @@ class DouyuLogin:
# 非代理的 ConnectionError 也重试 # 非代理的 ConnectionError 也重试
logger.warning(f"连接失败,尝试 {attempt + 1}/{max_retries}: {exc}") logger.warning(f"连接失败,尝试 {attempt + 1}/{max_retries}: {exc}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
time.sleep(1) self._sleep_interruptible(1)
continue continue
raise ConnectionError( raise ConnectionError(
f"{method.upper()} {safe_url} 连接失败,已重试 {max_retries}" f"{method.upper()} {safe_url} 连接失败,已重试 {max_retries}"
@@ -336,6 +365,9 @@ class DouyuLogin:
deadline = start_time + self.max_total_time deadline = start_time + self.max_total_time
for attempt in range(1, self.max_login_retries + 1): for attempt in range(1, self.max_login_retries + 1):
if self._is_stopped():
logger.warning("登录任务已停止")
return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
if elapsed > self.max_total_time: if elapsed > self.max_total_time:
logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃") logger.warning(f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃")
@@ -385,6 +417,9 @@ class DouyuLogin:
self.proxy_manager.release_proxy(self._current_proxy_url) self.proxy_manager.release_proxy(self._current_proxy_url)
return LoginResult(success=True, cookie=cookie, message=message) return LoginResult(success=True, cookie=cookie, message=message)
except InterruptedError as e:
logger.warning(f"登录任务已停止: {e}")
return LoginResult(success=False, message=str(e))
except Exception as e: except Exception as e:
elapsed = time.monotonic() - start_time elapsed = time.monotonic() - start_time
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}") logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}")
@@ -392,7 +427,7 @@ class DouyuLogin:
# 还有重试机会且未超时:标记当前代理坏,下一轮自动换新代理 # 还有重试机会且未超时:标记当前代理坏,下一轮自动换新代理
if self.proxy_manager and self._current_proxy_url: if self.proxy_manager and self._current_proxy_url:
self.proxy_manager.mark_bad(self._current_proxy_url) self.proxy_manager.mark_bad(self._current_proxy_url)
time.sleep(2) self._sleep_interruptible(2)
continue continue
# 所有重试耗尽或超时 # 所有重试耗尽或超时
return LoginResult(success=False, message=str(e)) return LoginResult(success=False, message=str(e))
@@ -490,6 +525,7 @@ class DouyuLogin:
_SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换 _SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换
for attempt in range(max_attempts): for attempt in range(max_attempts):
self._ensure_not_stopped()
# 超时兜底:极验验证不应超过登录整体时间上限 # 超时兜底:极验验证不应超过登录整体时间上限
if deadline and time.monotonic() > deadline: if deadline and time.monotonic() > deadline:
raise ValueError(f"极验验证超时(登录整体时间耗尽)") raise ValueError(f"极验验证超时(登录整体时间耗尽)")
@@ -539,7 +575,7 @@ class DouyuLogin:
_soft_fail_streak = 0 _soft_fail_streak = 0
else: else:
logger.warning(f"极验验证失败: {message},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})") logger.warning(f"极验验证失败: {message},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
time.sleep(1) self._sleep_interruptible(1)
continue continue
else: else:
validate = str(result) validate = str(result)
@@ -566,7 +602,7 @@ class DouyuLogin:
_soft_fail_streak = 0 _soft_fail_streak = 0
else: else:
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})") logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
time.sleep(2) self._sleep_interruptible(2)
continue continue
if self.max_proxy_retries > 0: if self.max_proxy_retries > 0:
@@ -675,6 +711,7 @@ class DouyuLogin:
return verifier.get_verification_code( return verifier.get_verification_code(
max_wait=60, max_wait=60,
after_timestamp=after_timestamp, after_timestamp=after_timestamp,
stop_event=self.stop_event,
) )
def _submit_verify_code(self, remote_code: str, verify_code: str) -> str: def _submit_verify_code(self, remote_code: str, verify_code: str) -> str:
@@ -744,16 +781,19 @@ class DouyuLogin:
self._cookie_enrich_error = "" self._cookie_enrich_error = ""
try: try:
self._enrich_web_cookies_with_retry() self._enrich_web_cookies_with_retry()
except InterruptedError:
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()
def _enrich_web_cookies_with_retry(self, max_attempts: int = 3) -> None: def _enrich_web_cookies_with_retry(self, max_attempts: int = COOKIE_ENRICH_RETRIES) -> None:
"""独立重试补齐 cvl_csrf_token 和 acf_ccn,不触发整条登录链路重跑。""" """独立重试补齐 cvl_csrf_token 和 acf_ccn,不触发整条登录链路重跑。"""
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
self._ensure_not_stopped()
try: try:
logger.info(f"补CK尝试 {attempt}/{max_attempts}...") logger.info(f"补CK尝试 {attempt}/{max_attempts}...")
self._generate_csrf_cookie() self._generate_csrf_cookie()
@@ -764,7 +804,7 @@ class DouyuLogin:
last_error = e last_error = e
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
if attempt < max_attempts: if attempt < max_attempts:
time.sleep(1) self._sleep_interruptible(1)
raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error
def _generate_csrf_cookie(self) -> str: def _generate_csrf_cookie(self) -> str:
@@ -786,7 +826,13 @@ class DouyuLogin:
'Content-Type': None, 'Content-Type': None,
'X-Requested-With': None, 'X-Requested-With': None,
} }
response = self._request('post', self.CSRF_API, headers=headers) response = self._request(
'post',
self.CSRF_API,
headers=headers,
timeout=self.COOKIE_ENRICH_TIMEOUT,
max_retries=1,
)
response.raise_for_status() response.raise_for_status()
body = response.text.strip() body = response.text.strip()
@@ -831,7 +877,13 @@ class DouyuLogin:
'Content-Type': None, 'Content-Type': None,
'X-Requested-With': None, 'X-Requested-With': None,
} }
response = self._request('get', self.ACF_CCN_API, headers=headers) response = self._request(
'get',
self.ACF_CCN_API,
headers=headers,
timeout=self.COOKIE_ENRICH_TIMEOUT,
max_retries=1,
)
response.raise_for_status() response.raise_for_status()
cookies = self.session.cookies.get_dict() cookies = self.session.cookies.get_dict()
+17 -2
View File
@@ -2,7 +2,7 @@
import os import os
from pathlib import Path from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -10,7 +10,11 @@ DB_PATH = PROJECT_ROOT / "data" / "web.db"
DB_PATH.parent.mkdir(parents=True, exist_ok=True) DB_PATH.parent.mkdir(parents=True, exist_ok=True)
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}") DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} connect_args = (
{"check_same_thread": False, "timeout": 30}
if DATABASE_URL.startswith("sqlite")
else {}
)
engine = create_engine( engine = create_engine(
DATABASE_URL, DATABASE_URL,
@@ -18,6 +22,17 @@ engine = create_engine(
echo=False, echo=False,
) )
if DATABASE_URL.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record):
"""提升 SQLite 并发写入稳定性。"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base() Base = declarative_base()
+16 -1
View File
@@ -68,6 +68,15 @@ class LoginBatchRunner:
def stop(self): def stop(self):
self._stop.set() self._stop.set()
def _sleep_or_stop(self, seconds: float) -> bool:
"""可中断等待;返回 True 表示收到停止信号。"""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if self._stop.is_set():
return True
time.sleep(min(0.2, deadline - time.monotonic()))
return self._stop.is_set()
def _push_log(self, level: str, message: str): def _push_log(self, level: str, message: str):
if self.log_queue and self.loop: if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
@@ -145,7 +154,12 @@ class LoginBatchRunner:
task.finished_at = datetime.now(timezone.utc) task.finished_at = datetime.now(timezone.utc)
worker_db.commit() worker_db.commit()
return return
time.sleep(10) if self._sleep_or_stop(10):
task.status = "error"
task.message = "任务已停止"
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
return
test_proxy = self._shared_proxy_manager.get_proxy() test_proxy = self._shared_proxy_manager.get_proxy()
if test_proxy: if test_proxy:
self._shared_proxy_manager.release_proxy(test_proxy) self._shared_proxy_manager.release_proxy(test_proxy)
@@ -191,6 +205,7 @@ class LoginBatchRunner:
max_login_retries=self.max_login_retries, max_login_retries=self.max_login_retries,
max_total_time=self.max_total_time, max_total_time=self.max_total_time,
proxy_manager=self._shared_proxy_manager, proxy_manager=self._shared_proxy_manager,
stop_event=self._stop,
) )
result = loginer.login() result = loginer.login()