fix: prevent browser process leaks and improve robustness

- fix(browser): wrap context lifecycle in try/finally to ensure browser
  closes on exceptions and KeyboardInterrupt (login, sniffer, generator)
- fix(browser): replace time.sleep with Playwright native waits
  (wait_for, wait_for_timeout) for more reliable element interaction
- fix(browser): use parameterized page.evaluate instead of f-string
  JS injection in generator polling
- fix(api): add retry logic in wait_for_completion to survive transient
  network errors
- fix(config): add prepare_profile_dir to copy profile to temp dir,
  preventing Chromium SingletonLock conflicts when tools run concurrently
- fix(sniffer): stream API logs to tempfile instead of unbounded memory
  list to avoid OOM
- fix(api): specify encoding='utf-8' when loading cookies from file

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-24 23:08:46 +08:00
parent b2f549af01
commit 734d53dafb
6 changed files with 255 additions and 180 deletions
+78 -59
View File
@@ -4,8 +4,12 @@
使用 cloakbrowser 在浏览器内完成所有操作(包括签名生成)
"""
import contextlib
import json
import os
import shutil
import sys
import tempfile
import time
from datetime import datetime
from cloakbrowser import launch_persistent_context
@@ -15,6 +19,36 @@ from ..config import get_profile_dir
PROFILE_DIR = str(get_profile_dir())
@contextlib.contextmanager
def _temp_persistent_context(headless=True):
"""
将标准 profile 复制到临时目录后启动持久化上下文,
避免与 login 进程发生 Chromium SingletonLock 冲突。
"""
standard = str(get_profile_dir())
if not os.path.exists(standard):
raise FileNotFoundError(f"未找到登录状态目录: {standard}")
temp_root = tempfile.mkdtemp(prefix="hunyuan3dweb-profile-")
temp_profile = os.path.join(temp_root, "profile")
shutil.copytree(standard, temp_profile, dirs_exist_ok=True)
context = None
try:
context = launch_persistent_context(temp_profile, headless=headless)
yield context
finally:
if context is not None:
try:
context.close()
except Exception:
pass
try:
shutil.rmtree(temp_root)
except Exception:
pass
def generate_3d(image_path, wait_for_complete=False, timeout=300):
"""
上传图片并触发生成3D模型
@@ -30,59 +64,55 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
if not os.path.exists(image_path):
raise FileNotFoundError(f"图片不存在: {image_path}")
if not os.path.exists(PROFILE_DIR):
raise FileNotFoundError(f"未找到登录状态目录: {PROFILE_DIR}")
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
# 用于存储结果
result = {"creationsId": None, "status": None, "modelUrl": None}
# 用于存储结果
result = {"creationsId": None, "status": None, "modelUrl": None}
# 监听响应
def handle_response(response):
url = response.url
if "creations/generations" in url and response.status == 200:
try:
body = response.json()
if "creationsId" in body:
result["creationsId"] = body["creationsId"]
print(f"✅ 生成已触发,creationsId: {body['creationsId']}")
except:
pass
elif "creations/detail" in url and response.status == 200:
try:
body = response.json()
result["status"] = body.get("status")
if "result" in body and isinstance(body["result"], list) and len(body["result"]) > 0:
model_data = body["result"][0]
if "modelUrl" in model_data:
result["modelUrl"] = model_data["modelUrl"]
except:
pass
# 监听响应
def handle_response(response):
url = response.url
if "creations/generations" in url and response.status == 200:
try:
body = response.json()
if "creationsId" in body:
result["creationsId"] = body["creationsId"]
print(f"✅ 生成已触发,creationsId: {body['creationsId']}")
except:
pass
elif "creations/detail" in url and response.status == 200:
try:
body = response.json()
result["status"] = body.get("status")
if "result" in body and isinstance(body["result"], list) and len(body["result"]) > 0:
model_data = body["result"][0]
if "modelUrl" in model_data:
result["modelUrl"] = model_data["modelUrl"]
except:
pass
page.on("response", handle_response)
page.on("response", handle_response)
try:
print("[1/6] 打开首页...")
page.goto("https://3d.hunyuan.tencent.com/")
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
print("[2/6] 点击 AI创作...")
page.locator("button").filter(has_text="AI创作").first.click()
page.wait_for_timeout(2000)
page.locator("text=图生3D").wait_for(state="visible", timeout=10000)
print("[3/6] 点击 图生3D...")
page.locator("text=图生3D").first.click()
page.wait_for_timeout(2000)
page.locator("text=单张图片").wait_for(state="visible", timeout=10000)
print("[4/6] 点击 单张图片...")
page.locator("text=单张图片").first.click()
page.wait_for_timeout(2000)
page.locator('input[type=file]').first.wait_for(state="visible", timeout=10000)
print("[5/6] 上传图片...")
page.locator('input[type=file]').first.set_input_files(image_path)
page.wait_for_timeout(3000)
page.locator("text=立即生成").wait_for(state="visible", timeout=10000)
print("[6/6] 点击 立即生成...")
page.locator("text=立即生成").first.click()
@@ -100,14 +130,14 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
last_status = None
while time.time() - start < timeout:
# 轮询状态
status_result = page.evaluate(f'''
async () => {{
const resp = await fetch('https://3d.hunyuan.tencent.com/api/3d/creations/detail?creationsId={result["creationsId"]}', {{
headers: {{'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'}}
}});
status_result = page.evaluate('''
async (creationsId) => {
const resp = await fetch(`https://3d.hunyuan.tencent.com/api/3d/creations/detail?creationsId=${creationsId}`, {
headers: {'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'}
});
return await resp.json();
}}
''')
}
''', result["creationsId"])
status = status_result.get("status", "unknown")
progress = status_result.get("progress", 0)
@@ -134,17 +164,13 @@ def generate_3d(image_path, wait_for_complete=False, timeout=300):
return result
finally:
context.close()
def get_quota():
"""获取当前配额信息"""
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
try:
page.goto("https://3d.hunyuan.tencent.com/")
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
result = page.evaluate('''
@@ -158,17 +184,14 @@ def get_quota():
}
''')
return result
finally:
context.close()
def get_creations_list():
"""获取作品列表"""
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
with _temp_persistent_context(headless=True) as context:
page = context.new_page()
try:
page.goto("https://3d.hunyuan.tencent.com/")
page.goto("https://3d.hunyuan.tencent.com/", timeout=60000)
page.wait_for_timeout(3000)
result = page.evaluate('''
@@ -182,13 +205,9 @@ def get_creations_list():
}
''')
return result
finally:
context.close()
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法:")
print(f" python {sys.argv[0]} quota # 查询配额")