feat: 添加 personal 模式标签页配置和热更新支持

主要改动:
- 添加 personal_max_resident_tabs 和 personal_idle_tab_ttl_seconds 配置项
- 支持在管理页面动态修改标签页数量和空闲超时
- 实现配置热更新,无需重启服务
- 优化浏览器启动参数,窗口不占用前台
- 增强日志输出,显示标签页使用情况
- 更新配置说明,明确标签页与 project_id 的关系

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
genz27
2026-03-30 11:02:08 +08:00
parent 3069d2c85e
commit 03acb4484d
9 changed files with 182 additions and 27 deletions

View File

@@ -1528,6 +1528,8 @@ async def update_captcha_config(
browser_proxy_enabled = request.get("browser_proxy_enabled", False)
browser_proxy_url = request.get("browser_proxy_url", "")
browser_count = request.get("browser_count", 1)
personal_max_resident_tabs = request.get("personal_max_resident_tabs")
personal_idle_tab_ttl_seconds = request.get("personal_idle_tab_ttl_seconds")
# 验证浏览器代理URL格式
if browser_proxy_enabled and browser_proxy_url:
@@ -1567,9 +1569,14 @@ async def update_captcha_config(
remote_browser_timeout=remote_browser_timeout,
browser_proxy_enabled=browser_proxy_enabled,
browser_proxy_url=browser_proxy_url if browser_proxy_enabled else None,
browser_count=max(1, int(browser_count)) if browser_count else 1
browser_count=max(1, int(browser_count)) if browser_count else 1,
personal_max_resident_tabs=personal_max_resident_tabs,
personal_idle_tab_ttl_seconds=personal_idle_tab_ttl_seconds
)
# 🔥 Hot reload: sync database config to memory
await db.reload_config_to_memory()
# 如果使用 browser 打码,热重载浏览器数量配置
if captcha_method == "browser":
try:
@@ -1579,8 +1586,14 @@ async def update_captcha_config(
except Exception:
pass
# 🔥 Hot reload: sync database config to memory
await db.reload_config_to_memory()
# 如果使用 personal 打码,热重载配置
if captcha_method == "personal":
try:
from ..services.browser_captcha_personal import BrowserCaptchaService
service = await BrowserCaptchaService.get_instance(db)
await service.reload_config()
except Exception as e:
print(f"[Admin] Personal 配置热更新失败: {e}")
return {"success": True, "message": "验证码配置更新成功"}
@@ -1604,7 +1617,9 @@ async def get_captcha_config(token: str = Depends(verify_admin_token)):
"remote_browser_timeout": captcha_config.remote_browser_timeout,
"browser_proxy_enabled": captcha_config.browser_proxy_enabled,
"browser_proxy_url": captcha_config.browser_proxy_url or "",
"browser_count": captcha_config.browser_count
"browser_count": captcha_config.browser_count,
"personal_max_resident_tabs": captcha_config.personal_max_resident_tabs,
"personal_idle_tab_ttl_seconds": captcha_config.personal_idle_tab_ttl_seconds
}

View File

@@ -389,6 +389,36 @@ class Config:
except Exception:
return 600
@property
def personal_max_resident_tabs(self) -> int:
"""内置浏览器打码最大常驻标签页数量"""
value = self._config.get("captcha", {}).get("personal_max_resident_tabs", 5)
try:
return max(1, min(50, int(value))) # 限制在1-50之间
except Exception:
return 5
@property
def personal_idle_tab_ttl_seconds(self) -> int:
"""内置浏览器打码标签页空闲超时(秒)"""
value = self._config.get("captcha", {}).get("personal_idle_tab_ttl_seconds", 600)
try:
return max(60, int(value))
except Exception:
return 600
def set_personal_max_resident_tabs(self, value: int):
"""设置内置浏览器打码最大常驻标签页数量"""
if "captcha" not in self._config:
self._config["captcha"] = {}
self._config["captcha"]["personal_max_resident_tabs"] = max(1, min(50, int(value)))
def set_personal_idle_tab_ttl_seconds(self, value: int):
"""设置内置浏览器打码标签页空闲超时(秒)"""
if "captcha" not in self._config:
self._config["captcha"] = {}
self._config["captcha"]["personal_idle_tab_ttl_seconds"] = max(60, int(value))
@property
def yescaptcha_api_key(self) -> str:
"""Get YesCaptcha API key"""

View File

@@ -459,6 +459,21 @@ class Database:
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
# Check and add missing columns to captcha_config table
if await self._table_exists(db, "captcha_config"):
captcha_columns_to_add = [
("personal_max_resident_tabs", "INTEGER DEFAULT 5"),
("personal_idle_tab_ttl_seconds", "INTEGER DEFAULT 600"),
]
for col_name, col_type in captcha_columns_to_add:
if not await self._column_exists(db, "captcha_config", col_name):
try:
await db.execute(f"ALTER TABLE captcha_config ADD COLUMN {col_name} {col_type}")
print(f" ✓ Added column '{col_name}' to captcha_config table")
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
# ========== Step 3: Ensure all config tables have default rows ==========
# Note: This will NOT overwrite existing config rows
# It only ensures missing rows are created with default values from setting.toml
@@ -662,6 +677,8 @@ class Database:
browser_proxy_enabled BOOLEAN DEFAULT 0,
browser_proxy_url TEXT,
browser_count INTEGER DEFAULT 1,
personal_max_resident_tabs INTEGER DEFAULT 5,
personal_idle_tab_ttl_seconds INTEGER DEFAULT 600,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -1506,6 +1523,8 @@ class Database:
config.set_remote_browser_base_url(captcha_config.remote_browser_base_url)
config.set_remote_browser_api_key(captcha_config.remote_browser_api_key)
config.set_remote_browser_timeout(captcha_config.remote_browser_timeout)
config.set_personal_max_resident_tabs(captcha_config.personal_max_resident_tabs)
config.set_personal_idle_tab_ttl_seconds(captcha_config.personal_idle_tab_ttl_seconds)
# Cache config operations
async def get_cache_config(self) -> CacheConfig:
@@ -1637,7 +1656,9 @@ class Database:
remote_browser_timeout: int = None,
browser_proxy_enabled: bool = None,
browser_proxy_url: str = None,
browser_count: int = None
browser_count: int = None,
personal_max_resident_tabs: int = None,
personal_idle_tab_ttl_seconds: int = None
):
"""Update captcha configuration"""
async with self._connect(write=True) as db:
@@ -1662,7 +1683,11 @@ class Database:
new_proxy_enabled = browser_proxy_enabled if browser_proxy_enabled is not None else current.get("browser_proxy_enabled", False)
new_proxy_url = browser_proxy_url if browser_proxy_url is not None else current.get("browser_proxy_url")
new_browser_count = browser_count if browser_count is not None else current.get("browser_count", 1)
new_personal_max_tabs = personal_max_resident_tabs if personal_max_resident_tabs is not None else current.get("personal_max_resident_tabs", 5)
new_personal_idle_ttl = personal_idle_tab_ttl_seconds if personal_idle_tab_ttl_seconds is not None else current.get("personal_idle_tab_ttl_seconds", 600)
new_remote_timeout = max(5, int(new_remote_timeout)) if new_remote_timeout is not None else 60
new_personal_max_tabs = max(1, min(50, int(new_personal_max_tabs))) # 限制1-50
new_personal_idle_ttl = max(60, int(new_personal_idle_ttl)) # 最少60秒
await db.execute("""
UPDATE captcha_config
@@ -1671,12 +1696,15 @@ class Database:
ezcaptcha_api_key = ?, ezcaptcha_base_url = ?,
capsolver_api_key = ?, capsolver_base_url = ?,
remote_browser_base_url = ?, remote_browser_api_key = ?, remote_browser_timeout = ?,
browser_proxy_enabled = ?, browser_proxy_url = ?, browser_count = ?, updated_at = CURRENT_TIMESTAMP
browser_proxy_enabled = ?, browser_proxy_url = ?, browser_count = ?,
personal_max_resident_tabs = ?, personal_idle_tab_ttl_seconds = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (new_method, new_yes_key, new_yes_url, new_cap_key, new_cap_url,
new_ez_key, new_ez_url, new_cs_key, new_cs_url,
(new_remote_base_url or "").strip(), (new_remote_api_key or "").strip(), new_remote_timeout,
new_proxy_enabled, new_proxy_url, new_browser_count))
new_proxy_enabled, new_proxy_url, new_browser_count,
new_personal_max_tabs, new_personal_idle_ttl))
else:
new_method = captcha_method if captcha_method is not None else "yescaptcha"
new_yes_key = yescaptcha_api_key if yescaptcha_api_key is not None else ""
@@ -1693,19 +1721,25 @@ class Database:
new_proxy_enabled = browser_proxy_enabled if browser_proxy_enabled is not None else False
new_proxy_url = browser_proxy_url
new_browser_count = browser_count if browser_count is not None else 1
new_personal_max_tabs = personal_max_resident_tabs if personal_max_resident_tabs is not None else 5
new_personal_idle_ttl = personal_idle_tab_ttl_seconds if personal_idle_tab_ttl_seconds is not None else 600
new_remote_timeout = max(5, int(new_remote_timeout))
new_personal_max_tabs = max(1, min(50, int(new_personal_max_tabs)))
new_personal_idle_ttl = max(60, int(new_personal_idle_ttl))
await db.execute("""
INSERT INTO captcha_config (id, captcha_method, yescaptcha_api_key, yescaptcha_base_url,
capmonster_api_key, capmonster_base_url, ezcaptcha_api_key, ezcaptcha_base_url,
capsolver_api_key, capsolver_base_url,
remote_browser_base_url, remote_browser_api_key, remote_browser_timeout,
browser_proxy_enabled, browser_proxy_url, browser_count)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
browser_proxy_enabled, browser_proxy_url, browser_count,
personal_max_resident_tabs, personal_idle_tab_ttl_seconds)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (new_method, new_yes_key, new_yes_url, new_cap_key, new_cap_url,
new_ez_key, new_ez_url, new_cs_key, new_cs_url,
(new_remote_base_url or "").strip(), (new_remote_api_key or "").strip(), new_remote_timeout,
new_proxy_enabled, new_proxy_url, new_browser_count))
new_proxy_enabled, new_proxy_url, new_browser_count,
new_personal_max_tabs, new_personal_idle_ttl))
await db.commit()

View File

@@ -193,6 +193,8 @@ class CaptchaConfig(BaseModel):
browser_proxy_enabled: bool = False # 浏览器打码是否启用代理
browser_proxy_url: Optional[str] = None # 浏览器打码代理URL
browser_count: int = 1 # 浏览器打码实例数量
personal_max_resident_tabs: int = 5 # 内置浏览器最大常驻标签页数量
personal_idle_tab_ttl_seconds: int = 600 # 内置浏览器标签页空闲超时(秒)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -1104,8 +1104,9 @@ class TokenBrowser:
try:
page = await context.new_page()
await page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
page_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
# 使用更简单的 API 地址,避免加载复杂页面
page_url = "https://labs.google/fx/api/auth/providers"
primary_host = "https://www.recaptcha.net" if self._browser_proxy_active else "https://www.google.com"
secondary_host = "https://www.google.com" if primary_host == "https://www.recaptcha.net" else "https://www.recaptcha.net"
debug_logger.log_info(
@@ -1175,13 +1176,13 @@ class TokenBrowser:
page.on("response", handle_response)
try:
await page.goto(page_url, wait_until="load", timeout=30000)
await page.goto(page_url, wait_until="load", timeout=15000) # 减少到15秒
except Exception as e:
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} page.goto 失败: {type(e).__name__}: {str(e)[:200]}")
return None
try:
await page.wait_for_function("typeof grecaptcha !== 'undefined'", timeout=15000)
await page.wait_for_function("typeof grecaptcha !== 'undefined'", timeout=10000) # 减少到10秒
except Exception as e:
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} grecaptcha 未就绪: {type(e).__name__}: {str(e)[:200]}")
return None
@@ -1205,7 +1206,7 @@ class TokenBrowser:
# 按要求:等待 enterprise/reload 与 enterprise/clr 均出现并返回 200
try:
await asyncio.wait_for(reload_ok_event.wait(), timeout=12)
await asyncio.wait_for(reload_ok_event.wait(), timeout=8) # 减少到8秒
except asyncio.TimeoutError:
debug_logger.log_warning(
f"[BrowserCaptcha] Token-{self.token_id} 等待 recaptcha enterprise/reload 200 超时"
@@ -1213,7 +1214,7 @@ class TokenBrowser:
return None
try:
await asyncio.wait_for(clr_ok_event.wait(), timeout=12)
await asyncio.wait_for(clr_ok_event.wait(), timeout=8) # 减少到8秒
except asyncio.TimeoutError:
debug_logger.log_warning(
f"[BrowserCaptcha] Token-{self.token_id} 等待 recaptcha enterprise/clr 200 超时"

View File

@@ -200,7 +200,22 @@ class BrowserCaptchaService:
cls._instance._idle_tab_reaper_loop()
)
return cls._instance
async def reload_config(self):
"""热更新配置(从数据库重新加载)"""
from ..core.config import config
old_max_tabs = self._max_resident_tabs
old_idle_ttl = self._idle_tab_ttl_seconds
self._max_resident_tabs = config.personal_max_resident_tabs
self._idle_tab_ttl_seconds = config.personal_idle_tab_ttl_seconds
debug_logger.log_info(
f"[BrowserCaptcha] Personal 配置已热更新: "
f"max_tabs {old_max_tabs}->{self._max_resident_tabs}, "
f"idle_ttl {old_idle_ttl}s->{self._idle_tab_ttl_seconds}s"
)
def _check_available(self):
"""检查服务是否可用"""
if DOCKER_HEADED_BLOCKED:
@@ -305,7 +320,7 @@ class BrowserCaptchaService:
f"[BrowserCaptcha] 使用指定浏览器可执行文件: {browser_executable_path}"
)
# 启动 nodriver 浏览器
# 启动 nodriver 浏览器(后台启动,不占用前台)
config = uc.Config(
headless=self.headless,
user_data_dir=self.user_data_dir,
@@ -316,7 +331,15 @@ class BrowserCaptchaService:
'--disable-setuid-sandbox',
'--disable-gpu',
'--window-size=1280,720',
'--window-position=3000,3000', # 窗口位置移到屏幕外
'--profile-directory=Default',
'--disable-extensions',
'--disable-background-networking',
'--disable-sync',
'--disable-translate',
'--disable-default-apps',
'--no-first-run',
'--no-default-browser-check',
]
)
self.browser = await uc.start(config)
@@ -1059,7 +1082,7 @@ class BrowserCaptchaService:
Returns:
reCAPTCHA token字符串如果获取失败返回None
"""
debug_logger.log_info(f"[BrowserCaptcha] get_token 开始: project_id={project_id}, action={action}")
debug_logger.log_info(f"[BrowserCaptcha] get_token 开始: project_id={project_id}, action={action}, 当前标签页数={len(self._resident_tabs)}/{self._max_resident_tabs}")
# 确保浏览器已初始化
await self.initialize()
@@ -1074,7 +1097,7 @@ class BrowserCaptchaService:
# 双重检查,避免并发创建
resident_info = self._resident_tabs.get(project_id)
if resident_info is None:
debug_logger.log_info(f"[BrowserCaptcha] 开始创建标签页 (project: {project_id})")
debug_logger.log_info(f"[BrowserCaptcha] 开始创建标签页 (project: {project_id}, 当前: {len(self._resident_tabs)}/{self._max_resident_tabs})")
# 先检查是否需要淘汰旧标签页
await self._evict_lru_tab_if_needed()
@@ -1083,9 +1106,11 @@ class BrowserCaptchaService:
debug_logger.log_warning(f"[BrowserCaptcha] 创建标签页失败fallback 到传统模式 (project: {project_id})")
return await self._get_token_legacy(project_id, action)
self._resident_tabs[project_id] = resident_info
debug_logger.log_info(f"[BrowserCaptcha] ✅ 标签页创建成功 (project: {project_id}, 当前共 {len(self._resident_tabs)} 个)")
debug_logger.log_info(f"[BrowserCaptcha] ✅ 标签页创建成功 (project: {project_id}, 当前共 {len(self._resident_tabs)}/{self._max_resident_tabs} 个)")
else:
debug_logger.log_info(f"[BrowserCaptcha] 标签页已存在,复用 (project: {project_id})")
debug_logger.log_info(f"[BrowserCaptcha] 准备执行打码 (project: {project_id})")
debug_logger.log_info(f"[BrowserCaptcha] 准备执行打码 (project: {project_id}, 标签页使用次数: {resident_info.use_count if resident_info else 0})")
# 使用常驻标签页生成 token在锁外执行避免阻塞
if resident_info and resident_info.recaptcha_ready and resident_info.tab:

View File

@@ -2269,13 +2269,18 @@ class FlowClient:
- 其他模式: browser_id 为 None
"""
captcha_method = config.captcha_method
debug_logger.log_info(f"[reCAPTCHA] 开始获取 token: method={captcha_method}, project_id={project_id}, action={action}")
# 内置浏览器打码 (nodriver)
if captcha_method == "personal":
debug_logger.log_info(f"[reCAPTCHA] 使用 personal 模式")
try:
from .browser_captcha_personal import BrowserCaptchaService
debug_logger.log_info(f"[reCAPTCHA] 导入 BrowserCaptchaService 成功")
service = await BrowserCaptchaService.get_instance(self.db)
debug_logger.log_info(f"[reCAPTCHA] 获取服务实例成功,准备调用 get_token")
token = await service.get_token(project_id, action)
debug_logger.log_info(f"[reCAPTCHA] get_token 返回: {token[:50] if token else None}...")
fingerprint = service.get_last_fingerprint() if token else None
self._set_request_fingerprint(fingerprint if token else None)
return token, None