diff --git a/src/services/browser_captcha.py b/src/services/browser_captcha.py
index 69535da..922d521 100644
--- a/src/services/browser_captcha.py
+++ b/src/services/browser_captcha.py
@@ -199,9 +199,21 @@ def validate_browser_proxy_url(proxy_url: str) -> tuple[bool, str]:
return True, None
class TokenBrowser:
- """简化版浏览器:每次获取 token 时启动新浏览器,用完即关
-
- 每次都是新的随机 UA,避免长时间运行导致的各种问题
+ """持久化浏览器:复用浏览器进程和上下文,保持 cookie 连续性
+
+ 架构说明:
+ - 浏览器进程常驻,避免重复启动开销
+ - BrowserContext 每 50 次请求后轮换,获得新 UA/指纹
+ - 惰性初始化:首次调用 get_token() 时才启动浏览器
+ - 异常时自动重建浏览器进程
+
+ 与临时浏览器架构的区别:
+ - 旧架构:每次获取 token 都启动新浏览器(慢)
+ - 新架构:复用浏览器进程,仅在必要时轮换 context(快)
+
+ 线程安全:
+ - 并发控制由服务层的 semaphore 管理
+ - 多个 TokenBrowser 实例之间独立运行
"""
# UA 池
@@ -349,79 +361,160 @@ def __init__(self, token_id: int, user_data_dir: str, db=None):
self.token_id = token_id
self.user_data_dir = user_data_dir
self.db = db
- self._semaphore = asyncio.Semaphore(1) # 同时只能有一个任务
+ # 注意:移除了 self._semaphore,避免与 BrowserCaptchaService._token_semaphore 形成双重锁定
+ # 并发控制由服务层统一管理
self._solve_count = 0
self._error_count = 0
-
- async def _create_browser(self) -> tuple:
- """创建新浏览器实例(新 UA),返回 (playwright, browser, context)"""
- import random
-
- random_ua = random.choice(self.UA_LIST)
- base_w, base_h = random.choice(self.RESOLUTIONS)
- width, height = base_w, base_h - random.randint(0, 80)
- viewport = {"width": width, "height": height}
-
- playwright = await async_playwright().start()
- Path(self.user_data_dir).mkdir(parents=True, exist_ok=True)
-
- # 代理配置
- proxy_option = None
+ # 持久化浏览器状态
+ self._playwright = None
+ self._browser = None
+ self._context = None
+ self._request_count = 0
+ self._max_requests_per_context = 50 # 每 50 次请求轮换 context
+
+ async def _get_proxy_option(self) -> Optional[Dict[str, str]]:
+ """获取代理配置"""
try:
if self.db:
captcha_config = await self.db.get_captcha_config()
- raw_url = captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url
- if raw_url:
- proxy_option = parse_proxy_url(raw_url.strip())
- if proxy_option:
- debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 使用代理: {proxy_option['server']}")
- except: pass
-
- try:
- browser = await playwright.chromium.launch(
- headless=False,
- proxy=proxy_option,
- args=[
- '--disable-blink-features=AutomationControlled',
- '--no-sandbox',
- '--disable-dev-shm-usage',
- '--disable-setuid-sandbox',
- '--no-first-run',
- '--no-zygote',
- f'--window-size={width},{height}',
- '--disable-infobars',
- '--hide-scrollbars',
- ]
- )
- context = await browser.new_context(
- user_agent=random_ua,
- viewport=viewport,
- )
- return playwright, browser, context
+ if captcha_config: # 检查 None
+ raw_url = (captcha_config.browser_proxy_enabled and
+ captcha_config.browser_proxy_url)
+ if raw_url:
+ proxy_option = parse_proxy_url(raw_url.strip())
+ if proxy_option:
+ debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 使用代理: {proxy_option['server']}")
+ return proxy_option
except Exception as e:
- debug_logger.log_error(f"[BrowserCaptcha] Token-{self.token_id} 启动浏览器失败: {type(e).__name__}: {str(e)[:200]}")
- # 确保清理已创建的对象
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 获取代理配置失败: {type(e).__name__}: {str(e)[:100]}")
+ return None
+
+ async def _ensure_browser(self):
+ """确保浏览器进程已启动(惰性初始化)"""
+ if self._browser and self._playwright:
+ return
+ # 清理旧的残留
+ await self._close_browser_only()
+
+ self._playwright = await async_playwright().start()
+ Path(self.user_data_dir).mkdir(parents=True, exist_ok=True)
+
+ proxy_option = await self._get_proxy_option()
+ base_w, base_h = random.choice(self.RESOLUTIONS)
+ width, height = base_w, base_h - random.randint(0, 80)
+
+ self._browser = await self._playwright.chromium.launch(
+ headless=False,
+ proxy=proxy_option,
+ args=[
+ '--disable-blink-features=AutomationControlled',
+ '--no-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-setuid-sandbox',
+ '--no-first-run',
+ '--no-zygote',
+ f'--window-size={width},{height}',
+ '--disable-infobars',
+ '--hide-scrollbars',
+ ]
+ )
+ debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 浏览器进程已启动")
+
+ async def _get_or_create_context(self):
+ """获取或创建浏览器上下文,到期后轮换"""
+ await self._ensure_browser()
+
+ # 检查是否需要轮换 context
+ if self._context and self._request_count < self._max_requests_per_context:
+ self._request_count += 1
+ return self._context
+
+ # 关闭旧 context
+ if self._context:
try:
- if playwright:
- await playwright.stop()
- except: pass
- raise
-
- async def _close_browser(self, playwright, browser, context):
- """关闭浏览器实例"""
+ await self._context.close()
+ except:
+ pass
+ debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 轮换 context(已用 {self._request_count} 次)")
+
+ random_ua = random.choice(self.UA_LIST)
+ base_w, base_h = random.choice(self.RESOLUTIONS)
+ width, height = base_w, base_h - random.randint(0, 80)
+ viewport = {"width": width, "height": height}
+
+ self._context = await self._browser.new_context(
+ user_agent=random_ua,
+ viewport=viewport,
+ )
+ self._request_count = 1
+ return self._context
+
+ async def _close_browser_only(self):
+ """关闭浏览器进程和 context,供内部使用"""
try:
- if context:
- await context.close()
- except: pass
+ if self._context:
+ await self._context.close()
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 关闭 context 失败: {type(e).__name__}: {str(e)[:100]}")
+ self._context = None
try:
- if browser:
- await browser.close()
- except: pass
+ if self._browser:
+ await self._browser.close()
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 关闭 browser 失败: {type(e).__name__}: {str(e)[:100]}")
+ self._browser = None
try:
- if playwright:
- await playwright.stop()
- except: pass
+ if self._playwright:
+ await self._playwright.stop()
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 停止 playwright 失败: {type(e).__name__}: {str(e)[:100]}")
+ self._playwright = None
+ self._request_count = 0
+
+ async def close(self):
+ """公开的关闭方法"""
+ await self._close_browser_only()
+ async def _simulate_human_behavior(self, page):
+ """模拟人类浏览行为,提升 reCAPTCHA v3 评分"""
+ try:
+ viewport = page.viewport_size or {"width": 1920, "height": 1080}
+ w, h = viewport["width"], viewport["height"]
+
+ # 初始延迟 - 模拟用户看到页面后的反应时间
+ await asyncio.sleep(random.uniform(0.5, 1.5))
+
+ # 随机鼠标移动 2-4 次
+ for _ in range(random.randint(2, 4)):
+ x = random.randint(100, w - 100)
+ y = random.randint(100, h - 100)
+ await page.mouse.move(x, y, steps=random.randint(3, 8))
+ await asyncio.sleep(random.uniform(0.1, 0.4))
+
+ # 随机滚动
+ await page.mouse.wheel(0, random.randint(50, 200))
+ await asyncio.sleep(random.uniform(0.2, 0.5))
+
+ # 尝试点击 textarea(模拟用户准备输入)
+ try:
+ textarea = page.locator("textarea").first
+ if await textarea.is_visible(timeout=500):
+ await textarea.click()
+ await asyncio.sleep(random.uniform(0.2, 0.5))
+ except Exception as e:
+ debug_logger.log_debug(f"[BrowserCaptcha] Token-{self.token_id} 点击 textarea 失败: {type(e).__name__}")
+
+ # 最后再移一下鼠标
+ await page.mouse.move(
+ random.randint(200, w - 200),
+ random.randint(200, h - 200),
+ steps=random.randint(3, 6)
+ )
+ await asyncio.sleep(random.uniform(0.3, 0.8))
+ except Exception as e:
+ # 行为模拟失败不影响主流程,但记录日志便于调试
+ debug_logger.log_debug(f"[BrowserCaptcha] Token-{self.token_id} 行为模拟失败: {type(e).__name__}: {str(e)[:100]}")
+
async def _execute_captcha(self, context, project_id: str, website_key: str, action: str) -> Optional[str]:
"""在给定 context 中执行打码逻辑"""
page = None
@@ -433,7 +526,63 @@ async def _execute_captcha(self, context, project_id: str, website_key: str, act
async def handle_route(route):
if route.request.url.rstrip('/') == page_url.rstrip('/'):
- html = f"""
"""
+ html = f"""
+
+
+
+
+ Flow - Google Labs
+
+
+
+
+
+
+
+
+
My Project
+
+
+
+
+
+
+
+
+
+
+
+"""
await route.fulfill(status=200, content_type="text/html", body=html)
elif any(d in route.request.url for d in ["google.com", "gstatic.com", "recaptcha.net"]):
await route.continue_()
@@ -452,7 +601,10 @@ async def handle_route(route):
except Exception as e:
debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} grecaptcha 未就绪: {type(e).__name__}: {str(e)[:200]}")
return None
-
+
+ # 模拟人类行为,提升 reCAPTCHA v3 评分
+ await self._simulate_human_behavior(page)
+
token = await asyncio.wait_for(
page.evaluate(f"""
(actionName) => {{
@@ -473,47 +625,57 @@ async def handle_route(route):
return None
finally:
if page:
- try: await page.close()
- except: pass
-
- async def get_token(self, project_id: str, website_key: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
- """获取 Token:启动新浏览器 -> 打码 -> 关闭浏览器"""
- async with self._semaphore:
- MAX_RETRIES = 3
-
- for attempt in range(MAX_RETRIES):
- playwright = None
- browser = None
- context = None
try:
- start_ts = time.time()
-
- # 每次都启动新浏览器(新 UA)
- playwright, browser, context = await self._create_browser()
-
- # 执行打码
- token = await self._execute_captcha(context, project_id, website_key, action)
-
- if token:
- self._solve_count += 1
- debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 获取成功 ({(time.time()-start_ts)*1000:.0f}ms)")
- return token
-
- self._error_count += 1
- debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 尝试 {attempt+1}/{MAX_RETRIES} 失败")
-
+ await page.close()
except Exception as e:
- self._error_count += 1
- debug_logger.log_error(f"[BrowserCaptcha] Token-{self.token_id} 浏览器错误: {type(e).__name__}: {str(e)[:200]}")
- finally:
- # 无论成功失败都关闭浏览器
- await self._close_browser(playwright, browser, context)
-
- # 重试前等待
- if attempt < MAX_RETRIES - 1:
- await asyncio.sleep(1)
-
- return None
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 关闭页面失败: {type(e).__name__}: {str(e)[:100]}")
+
+ async def get_token(self, project_id: str, website_key: str, action: str = "IMAGE_GENERATION") -> Optional[str]:
+ """获取 Token:复用持久化浏览器上下文
+
+ Args:
+ project_id: Flow 项目 ID
+ website_key: reCAPTCHA website key
+ action: reCAPTCHA action 名称,默认 "IMAGE_GENERATION"
+
+ Returns:
+ 成功时返回 reCAPTCHA token 字符串,失败返回 None
+
+ Note:
+ - 最多重试 3 次
+ - 异常时重建浏览器进程
+ - 并发控制由服务层的 semaphore 管理
+ """
+ MAX_RETRIES = 3
+
+ for attempt in range(MAX_RETRIES):
+ try:
+ start_ts = time.time()
+
+ context = await self._get_or_create_context()
+
+ # 执行打码
+ token = await self._execute_captcha(context, project_id, website_key, action)
+
+ if token:
+ self._solve_count += 1
+ debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 获取成功 ({(time.time()-start_ts)*1000:.0f}ms)")
+ return token
+
+ self._error_count += 1
+ debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 尝试 {attempt+1}/{MAX_RETRIES} 失败")
+
+ except Exception as e:
+ self._error_count += 1
+ debug_logger.log_error(f"[BrowserCaptcha] Token-{self.token_id} 浏览器错误: {type(e).__name__}: {str(e)[:200]}")
+ # 浏览器异常时强制重建
+ await self._close_browser_only()
+
+ # 重试前等待
+ if attempt < MAX_RETRIES - 1:
+ await asyncio.sleep(1)
+
+ return None
class BrowserCaptchaService:
@@ -549,12 +711,12 @@ def __init__(self, db=None):
@classmethod
async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
- if cls._instance is None:
- async with cls._lock:
- if cls._instance is None:
- cls._instance = cls(db)
- # 从数据库加载 browser_count 配置
- await cls._instance._load_browser_count()
+ # 直接获取锁,避免竞态条件
+ async with cls._lock:
+ if cls._instance is None:
+ cls._instance = cls(db)
+ # 在锁内完成初始化(包括异步操作)
+ await cls._instance._load_browser_count()
return cls._instance
def _check_available(self):
@@ -674,9 +836,12 @@ async def get_token(self, project_id: str, action: str = "IMAGE_GENERATION", tok
async def report_error(self, browser_id: int = None):
"""上层举报:Token 无效(统计用)
-
+
Args:
- browser_id: 浏览器 ID(当前架构下每次都是新浏览器,此参数仅用于日志)
+ browser_id: 浏览器 ID(用于日志记录和错误追踪)
+
+ Note:
+ 当前使用持久化浏览器架构,browser_id 用于标识哪个浏览器实例产生了无效 token
"""
async with self._browsers_lock:
self._stats["api_403"] += 1
@@ -684,12 +849,24 @@ async def report_error(self, browser_id: int = None):
debug_logger.log_info(f"[BrowserCaptcha] 浏览器 {browser_id} 的 token 验证失败")
async def remove_browser(self, browser_id: int):
+ """移除指定浏览器实例并关闭其资源
+
+ Args:
+ browser_id: 要移除的浏览器 ID
+
+ Note:
+ 此方法会调用 browser.close() 清理资源
+ """
async with self._browsers_lock:
- if browser_id in self._browsers:
- self._browsers.pop(browser_id)
+ browser = self._browsers.pop(browser_id, None)
+ if browser:
+ await browser.close()
async def close(self):
+ """关闭所有浏览器实例并清理资源"""
async with self._browsers_lock:
+ for browser in self._browsers.values():
+ await browser.close()
self._browsers.clear()
async def open_login_browser(self): return {"success": False, "error": "Not implemented"}
diff --git a/src/services/browser_captcha_personal.py b/src/services/browser_captcha_personal.py
index 66039a7..69102b7 100644
--- a/src/services/browser_captcha_personal.py
+++ b/src/services/browser_captcha_personal.py
@@ -27,8 +27,10 @@ def _has_display_environment() -> bool:
if result.returncode == 0:
# xvfb-run 可用,说明在 Xvfb 环境中
return True
- except:
- pass
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+ debug_logger.log_debug(f"[BrowserCaptcha] 检测 xvfb-run 失败: {type(e).__name__}")
+ except Exception as e:
+ debug_logger.log_debug(f"[BrowserCaptcha] 检测 xvfb-run 异常: {type(e).__name__}")
# 有 DISPLAY 环境变量即认为有显示环境
return True
@@ -305,8 +307,8 @@ async def stop_resident_mode(self, project_id: Optional[str] = None):
if resident_info and resident_info.tab:
try:
await resident_info.tab.close()
- except Exception:
- pass
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭标签页失败: {type(e).__name__}")
debug_logger.log_info(f"[BrowserCaptcha] 已关闭所有常驻标签页 (共 {len(project_ids)} 个)")
# 向后兼容:清理旧属性
@@ -317,8 +319,8 @@ async def stop_resident_mode(self, project_id: Optional[str] = None):
if self.resident_tab:
try:
await self.resident_tab.close()
- except Exception:
- pass
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭常驻标签页失败: {type(e).__name__}")
self.resident_tab = None
self.resident_project_id = None
@@ -348,7 +350,7 @@ async def _wait_for_recaptcha(self, tab) -> bool:
(() => {{
if (document.querySelector('script[src*="recaptcha"]')) return;
const script = document.createElement('script');
- script.src = 'https://www.google.com/recaptcha/api.js?render={self.website_key}';
+ script.src = 'https://www.google.com/recaptcha/enterprise.js?render={self.website_key}';
script.async = true;
document.head.appendChild(script);
}})()
@@ -425,8 +427,8 @@ async def _execute_recaptcha_on_tab(self, tab, action: str = "IMAGE_GENERATION")
# 清理临时变量
try:
await tab.evaluate(f"delete window.{token_var}; delete window.{error_var};")
- except:
- pass
+ except Exception as e:
+ debug_logger.log_debug(f"[BrowserCaptcha] 清理临时变量失败: {type(e).__name__}")
return token
@@ -533,8 +535,8 @@ async def _create_resident_tab(self, project_id: str) -> Optional[ResidentTabInf
debug_logger.log_error(f"[BrowserCaptcha] 页面加载超时 (project: {project_id})")
try:
await tab.close()
- except:
- pass
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭超时的标签页失败: {type(e).__name__}")
return None
# 等待 reCAPTCHA 加载
@@ -544,8 +546,8 @@ async def _create_resident_tab(self, project_id: str) -> Optional[ResidentTabInf
debug_logger.log_error(f"[BrowserCaptcha] reCAPTCHA 加载失败 (project: {project_id})")
try:
await tab.close()
- except:
- pass
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭失败的标签页失败: {type(e).__name__}")
return None
# 创建常驻信息对象
@@ -636,8 +638,8 @@ async def _get_token_legacy(self, project_id: str, action: str = "IMAGE_GENERATI
if tab:
try:
await tab.close()
- except Exception:
- pass
+ except Exception as e:
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭标签页失败: {type(e).__name__}")
async def close(self):
"""关闭浏览器"""
diff --git a/src/services/flow_client.py b/src/services/flow_client.py
index cfdbc68..2cfd77d 100644
--- a/src/services/flow_client.py
+++ b/src/services/flow_client.py
@@ -1290,7 +1290,7 @@ async def _get_api_captcha_token(self, method: str, project_id: str, action: str
debug_logger.log_info(f"[reCAPTCHA {method}] Token获取成功")
return response
- time.sleep(3)
+ await asyncio.sleep(3)
debug_logger.log_error(f"[reCAPTCHA {method}] Timeout waiting for token")
return None