mirror of
https://github.com/TheSmallHanCat/flow2api.git
synced 2026-06-20 09:12:29 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 超时"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user