Initial commit

This commit is contained in:
2026-05-24 21:47:44 +08:00
commit 2ce35563c4
20 changed files with 3698 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
from .api import Hunyuan3DAPI
from .api_complete import Hunyuan3DAPI as Hunyuan3DAPIComplete
from .sign import sign, sign_with_custom_nonce
__version__ = "0.1.0"
__all__ = [
"Hunyuan3DAPI",
"Hunyuan3DAPIComplete",
"sign",
"sign_with_custom_nonce",
]
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
腾讯混元3D纯Python API客户端
无需浏览器,直接HTTP调用
"""
import requests
import json
import time
from typing import Optional, Dict, Any
from .sign import sign, sign_with_custom_nonce
BASE_URL = "https://3d.hunyuan.tencent.com"
API_BASE = f"{BASE_URL}/api/3d"
class Hunyuan3DAPI:
"""腾讯混元3D API客户端"""
def __init__(self, cookies: Optional[str] = None):
"""
初始化API客户端
Args:
cookies: 浏览器Cookie字符串,包含hunyuan_user和hunyuan_token
"""
self.session = requests.Session()
self.session.headers.update({
"Content-Type": "application/json",
"x-source": "web",
"x-product": "hunyuan3d",
"Origin": BASE_URL,
"Referer": f"{BASE_URL}/",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
})
if cookies:
self.set_cookies(cookies)
def set_cookies(self, cookies: str):
"""设置Cookie"""
self.session.headers["Cookie"] = cookies
def set_cookie_dict(self, cookie_dict: Dict[str, str]):
"""从字典设置Cookie"""
cookie_str = "; ".join(f"{k}={v}" for k, v in cookie_dict.items())
self.session.headers["Cookie"] = cookie_str
def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None,
data: Optional[Dict] = None) -> Dict[str, Any]:
"""
发送带签名的API请求
注意: 腾讯混元3D的签名只包含URL查询参数,不包含请求体数据
"""
url = f"{API_BASE}{endpoint}"
# 签名只针对查询参数,不包含请求体
sign_params = {}
if params:
sign_params.update(params)
# 生成签名
signed_params = sign(sign_params)
# 分离签名参数
timestamp = signed_params.pop("timestamp")
nonce = signed_params.pop("nonce")
sign_value = signed_params.pop("sign")
# 构建最终URL查询参数(只包含签名相关)
query_params = {}
if params:
query_params.update(params)
query_params.update({
"timestamp": timestamp,
"nonce": nonce,
"sign": sign_value
})
if method.upper() == "GET":
response = self.session.get(url, params=query_params, timeout=30)
else:
# POST请求:签名在URL参数中,数据在body中
response = self.session.post(url, params=query_params, json=data, timeout=30)
response.raise_for_status()
return response.json()
def get_user_info(self) -> Dict[str, Any]:
"""获取用户信息"""
return self._make_request("GET", "/getuserinfo")
def get_quota_info(self, scene_type: str = "3dCreations") -> Dict[str, Any]:
"""获取配额信息"""
return self._make_request("POST", "/quotainfo", data={"sceneType": scene_type})
def get_creation_list(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取创作列表"""
return self._make_request("POST", "/creations/list", data={
"page": page,
"pageSize": page_size
})
def generate_3d(self, image_url: str, scene_type: str = "playGround3D-2.0",
model_type: str = "image2ModelV3.1", title: str = "",
enable_pbr: bool = True, enable_low_poly: bool = False,
face_count: int = 1500000, count: int = 1) -> Dict[str, Any]:
"""
生成3D模型
Args:
image_url: 图片URL(需要先上传到腾讯云COS)
scene_type: 场景类型
model_type: 模型类型
title: 标题
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否启用低多边形
face_count: 面数
count: 生成数量
Returns:
包含creationsId的响应
"""
data = {
"sceneType": scene_type,
"count": count,
"modelType": model_type,
"title": title,
"style": "",
"imageList": [image_url],
"enable_pbr": enable_pbr,
"enableLowPoly": enable_low_poly,
"faceCount": face_count
}
return self._make_request("POST", "/creations/generations", data=data)
def generate_text(self, prompt: str, scene_type: str = "playGround3D-2.0",
title: str = "", style: str = "",
enable_pbr: bool = True, enable_low_poly: bool = False,
face_count: int = 1500000, count: int = 4) -> Dict[str, Any]:
"""
文生3D
Args:
prompt: 文本描述/提示词
scene_type: 场景类型
title: 作品标题
style: 纹理风格
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否低多边形
face_count: 面数
count: 生成数量,文生3D固定为4
Returns:
包含creationsId的响应
"""
data = {
"sceneType": scene_type,
"count": count,
"modelType": "text2ModelV3.1",
"title": title or prompt,
"style": style,
"prompt": prompt,
"enable_pbr": enable_pbr,
"enableLowPoly": enable_low_poly,
"faceCount": face_count
}
return self._make_request("POST", "/creations/generations", data=data)
def get_generation_status(self, creation_id: str) -> Dict[str, Any]:
"""获取生成状态"""
return self._make_request("GET", "/creations/detail", params={
"creationsId": creation_id
})
def wait_for_completion(self, creation_id: str, timeout: int = 600,
poll_interval: int = 5) -> Dict[str, Any]:
"""
等待生成完成
Args:
creation_id: 创作ID
timeout: 超时时间(秒)
poll_interval: 轮询间隔(秒)
Returns:
完成的创作信息
"""
start_time = time.time()
while time.time() - start_time < timeout:
status = self.get_generation_status(creation_id)
# Handle both wrapped {code, data} and flat response formats
if "code" in status:
data = status.get("data", {})
else:
data = status
state = data.get("status") or data.get("state")
if state == "success":
return status
elif state == "fail":
raise Exception(f"Generation failed: {data.get('errorMsg', 'Unknown error')}")
print(f"State: {state}, progress: {data.get('progress', 0)}%")
time.sleep(poll_interval)
raise TimeoutError(f"Generation timeout after {timeout} seconds")
from .config import get_cookie_path
def load_cookies_from_file(filepath: Optional[str] = None) -> str:
"""从文件加载Cookie,默认读取用户配置目录的 cookies.txt"""
path = filepath or str(get_cookie_path())
with open(path, 'r') as f:
return f.read().strip()
if __name__ == "__main__":
# 示例用法
import sys
if len(sys.argv) > 1 and sys.argv[1] == "--cookie-string":
cookies = sys.argv[2]
else:
cookies = load_cookies_from_file(sys.argv[1] if len(sys.argv) > 1 else None)
api = Hunyuan3DAPI(cookies)
# 测试配额查询
print("查询配额...")
quota = api.get_quota_info()
print(json.dumps(quota, indent=2, ensure_ascii=False))
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""
腾讯混元3D 完整API客户端
支持所有生成模式:图生3D、文生3D、草图生3D、动画生成、纹理生成、智能拓扑
"""
import requests
import json
import time
from typing import Optional, Dict, Any, List
from .sign import sign
BASE_URL = "https://3d.hunyuan.tencent.com"
API_BASE = f"{BASE_URL}/api/3d"
class Hunyuan3DAPI:
"""腾讯混元3D 完整API客户端"""
# 生成模式常量
MODEL_TYPE_IMAGE = "image2ModelV3.1"
MODEL_TYPE_TEXT = "text2ModelV3.1"
MODEL_TYPE_SKETCH = "sketch2ModelV3.1"
MODEL_TYPE_ANIMATION = "animation3dV2"
MODEL_TYPE_TEXTURE = "textureTo3DV2"
MODEL_TYPE_LOWPOLY = "lowpolyV2"
# 动作类型
MOTION_CAPOEIRA = 9
MOTION_FALLING = 10
MOTION_JUMPING = 11
MOTION_KICKING = 12
MOTION_SWORD = 13
MOTION_RUNNING = 15
MOTION_DANCING = 16
# 纹理风格
STYLE_DEFAULT = ""
STYLE_SCULPTURE = "sculpture"
STYLE_QINGHUA = "qinghuaci"
STYLE_CHINA = "china_style"
STYLE_CARTOON = "cartoon"
STYLE_CYBERPUNK = "cyberpunk"
# 拓扑面数
TOPO_LOW = 5000
TOPO_MEDIUM = 18000
TOPO_HIGH = 30000
def __init__(self, cookies: Optional[str] = None):
self.session = requests.Session()
self.session.headers.update({
"Content-Type": "application/json",
"x-source": "web",
"x-product": "hunyuan3d",
"Origin": BASE_URL,
"Referer": f"{BASE_URL}/",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
})
if cookies:
self.set_cookies(cookies)
def set_cookies(self, cookies: str):
self.session.headers["Cookie"] = cookies
def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None,
data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{API_BASE}{endpoint}"
sign_params = dict(params) if params else {}
signed_params = sign(sign_params)
timestamp = signed_params.pop("timestamp")
nonce = signed_params.pop("nonce")
sign_value = signed_params.pop("sign")
query_params = {}
if params:
query_params.update(params)
query_params.update({
"timestamp": timestamp,
"nonce": nonce,
"sign": sign_value
})
if method.upper() == "GET":
response = self.session.get(url, params=query_params, timeout=30)
else:
response = self.session.post(url, params=query_params, json=data, timeout=30)
response.raise_for_status()
return response.json()
# ========== 用户接口 ==========
def get_user_info(self) -> Dict[str, Any]:
"""获取用户信息"""
return self._make_request("GET", "/getuserinfo")
def get_quota_info(self, scene_type: str = "3dCreations") -> Dict[str, Any]:
"""获取配额信息"""
return self._make_request("POST", "/quotainfo", data={"sceneType": scene_type})
# ========== 作品管理 ==========
def get_creation_list(self, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取作品列表"""
return self._make_request("POST", "/creations/list", data={
"page": page,
"pageSize": page_size
})
def get_creation_count(self, status_list: Optional[List[str]] = None) -> Dict[str, Any]:
"""获取作品数量"""
data = {"statusList": status_list or ["wait", "processing", "success", "fail"]}
return self._make_request("POST", "/creations/count", data=data)
def get_generation_status(self, creation_id: str) -> Dict[str, Any]:
"""获取生成状态"""
return self._make_request("GET", "/creations/detail", params={
"creationsId": creation_id
})
def cancel_generation(self, creation_id: str) -> Dict[str, Any]:
"""取消生成任务"""
return self._make_request("POST", "/creations/cancel", data={
"creationsId": creation_id
})
# ========== 核心生成接口 ==========
def _generate(self, model_type: str, scene_type: str = "playGround3D-2.0",
title: str = "", style: str = "", text: str = "",
image_list: Optional[List[str]] = None,
enable_pbr: bool = True, enable_low_poly: bool = False,
face_count: int = 1500000, motion_type: Optional[int] = None,
topology_format: Optional[str] = None) -> Dict[str, Any]:
"""通用生成接口"""
data = {
"sceneType": scene_type,
"count": 1,
"modelType": model_type,
"title": title,
"style": style,
"enable_pbr": enable_pbr,
"enableLowPoly": enable_low_poly,
"faceCount": face_count
}
if text:
data["prompt"] = text
if image_list:
data["imageList"] = image_list
if motion_type is not None:
data["motionType"] = motion_type
if topology_format:
data["topology_format"] = topology_format
return self._make_request("POST", "/creations/generations", data=data)
# ========== 图生3D ==========
def generate_from_image(self, image_url: str, title: str = "",
style: str = "", enable_pbr: bool = True,
enable_low_poly: bool = False,
face_count: int = 1500000) -> Dict[str, Any]:
"""
图生3D
Args:
image_url: 图片URL(需先上传获取resourceId
title: 作品标题
style: 纹理风格
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否低多边形
face_count: 面数
"""
return self._generate(
model_type=self.MODEL_TYPE_IMAGE,
title=title,
style=style,
image_list=[image_url],
enable_pbr=enable_pbr,
enable_low_poly=enable_low_poly,
face_count=face_count
)
def generate_from_multi_view(self, image_urls: List[str], title: str = "",
style: str = "", enable_pbr: bool = True,
enable_low_poly: bool = False,
face_count: int = 1500000) -> Dict[str, Any]:
"""
多图视角生3D(多视图)
Args:
image_urls: 多角度图片URL列表(通常2-4张不同视角)
title: 作品标题
style: 纹理风格
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否低多边形
face_count: 面数
"""
return self._generate(
model_type=self.MODEL_TYPE_IMAGE,
title=title,
style=style,
image_list=image_urls,
enable_pbr=enable_pbr,
enable_low_poly=enable_low_poly,
face_count=face_count
)
# ========== 文生3D ==========
def generate_from_text(self, prompt: str, title: str = "",
style: str = "", enable_pbr: bool = True,
enable_low_poly: bool = False,
face_count: int = 1500000, count: int = 4) -> Dict[str, Any]:
"""
文生3D
Args:
prompt: 文本描述/提示词
title: 作品标题
style: 纹理风格
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否低多边形
face_count: 面数
count: 生成数量,文生3D固定为4
"""
data = {
"sceneType": "playGround3D-2.0",
"count": count,
"modelType": self.MODEL_TYPE_TEXT,
"title": title or prompt,
"style": style,
"prompt": prompt,
"enable_pbr": enable_pbr,
"enableLowPoly": enable_low_poly,
"faceCount": face_count
}
return self._make_request("POST", "/creations/generations", data=data)
# ========== 草图生3D ==========
def generate_from_sketch(self, sketch_url: str, prompt: str,
title: str = "", style: str = "",
enable_pbr: bool = True,
enable_low_poly: bool = False,
face_count: int = 1500000) -> Dict[str, Any]:
"""
草图生3D
Args:
sketch_url: 草图图片URL
prompt: 草图描述提示词
title: 作品标题
style: 纹理风格
enable_pbr: 是否启用PBR材质
enable_low_poly: 是否低多边形
face_count: 面数
"""
return self._generate(
model_type=self.MODEL_TYPE_SKETCH,
title=title,
style=style,
text=prompt,
image_list=[sketch_url],
enable_pbr=enable_pbr,
enable_low_poly=enable_low_poly,
face_count=face_count
)
# ========== 3D动画生成 ==========
def generate_animation(self, model_image_url: str, motion_type: int,
title: str = "") -> Dict[str, Any]:
"""
3D动画生成
Args:
model_image_url: 3D模型图片URL
motion_type: 动作类型ID
title: 作品标题
"""
return self._generate(
model_type=self.MODEL_TYPE_ANIMATION,
title=title,
image_list=[model_image_url],
motion_type=motion_type
)
# ========== 3D纹理生成 ==========
def generate_texture(self, white_model_url: str, prompt: str,
title: str = "") -> Dict[str, Any]:
"""
3D纹理生成
Args:
white_model_url: 白模图片URL
prompt: 纹理描述
title: 作品标题
"""
return self._generate(
model_type=self.MODEL_TYPE_TEXTURE,
title=title,
text=prompt,
image_list=[white_model_url]
)
# ========== 3D智能拓扑 ==========
def generate_lowpoly(self, model_url: str, face_count: int = 5000,
topology_format: str = "glb",
title: str = "") -> Dict[str, Any]:
"""
3D智能拓扑(减面)
Args:
model_url: 模型图片URL
face_count: 目标面数(5000/18000/30000
topology_format: 输出格式(glb/obj
title: 作品标题
"""
return self._generate(
model_type=self.MODEL_TYPE_LOWPOLY,
title=title,
image_list=[model_url],
enable_low_poly=True,
face_count=face_count,
topology_format=topology_format
)
# ========== 资源上传 ==========
def get_upload_info(self, filename: str) -> Dict[str, Any]:
"""获取上传凭证"""
return self._make_request("POST", "/resource/genUploadInfo", data={
"fileName": filename
})
def review_resource(self, resource_url: str, scene_type: str = "playGround3D-2.0",
resource_type: str = "image", text: str = "") -> Dict[str, Any]:
"""资源审核"""
return self._make_request("POST", "/resource/review", data={
"sceneType": scene_type,
"text": text,
"resourceType": resource_type,
"resourceUrl": resource_url
})
# ========== 分享 ==========
def create_share(self, creation_id: str, platform: str = "3dPlayground") -> Dict[str, Any]:
"""创建分享"""
return self._make_request("POST", "/share", data={
"contentType": "creation",
"contentId": creation_id,
"sharedContent": "",
"platform": platform
})
# ========== 配置 ==========
def get_config(self) -> Dict[str, Any]:
"""获取全局配置"""
return self._make_request("GET", "/config")
def get_action_templates(self) -> Dict[str, Any]:
"""获取动画动作模板"""
return self._make_request("GET", "/workflow/action/templates")
# ========== 轮询等待 ==========
def wait_for_completion(self, creation_id: str, timeout: int = 600,
poll_interval: int = 5) -> Dict[str, Any]:
"""
等待生成完成
Args:
creation_id: 创作ID
timeout: 超时时间(秒)
poll_interval: 轮询间隔(秒)
"""
start_time = time.time()
while time.time() - start_time < timeout:
status = self.get_generation_status(creation_id)
# Handle both wrapped {code, data} and flat response formats
if "code" in status:
data = status.get("data", {})
else:
data = status
state = data.get("status")
if state == "success":
return status
elif state == "fail":
raise Exception(f"Generation failed: {data}")
progress = data.get("progress", 0)
print(f"State: {state}, progress: {progress}%")
time.sleep(poll_interval)
raise TimeoutError(f"Generation timeout after {timeout} seconds")
from .config import get_cookie_path
def load_cookies_from_file(filepath: Optional[str] = None) -> str:
"""从文件加载Cookie,默认读取用户配置目录的 cookies.txt"""
path = filepath or str(get_cookie_path())
with open(path, 'r') as f:
return f.read().strip()
if __name__ == "__main__":
import sys
cookies = load_cookies_from_file(sys.argv[1] if len(sys.argv) > 1 else None)
api = Hunyuan3DAPI(cookies)
# 测试配额查询
print("查询配额...")
quota = api.get_quota_info()
print(json.dumps(quota, indent=2, ensure_ascii=False))
# 测试获取配置
print("\n获取配置...")
config = api.get_config()
print(f"Config keys: {list(config.keys())}")
View File
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
腾讯混元3D 图生3D 完整自动化脚本
使用 cloakbrowser 在浏览器内完成所有操作(包括签名生成)
"""
import json
import os
import time
from datetime import datetime
from cloakbrowser import launch_persistent_context
from ..config import get_profile_dir
PROFILE_DIR = str(get_profile_dir())
def generate_3d(image_path, wait_for_complete=False, timeout=300):
"""
上传图片并触发生成3D模型
Args:
image_path: 图片路径
wait_for_complete: 是否等待生成完成
timeout: 最长等待时间(秒)
Returns:
dict: 包含 creationsId 和状态信息
"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"图片不存在: {image_path}")
if not os.path.exists(PROFILE_DIR):
raise FileNotFoundError(f"未找到登录状态目录: {PROFILE_DIR}")
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
# 用于存储结果
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
page.on("response", handle_response)
try:
print("[1/6] 打开首页...")
page.goto("https://3d.hunyuan.tencent.com/")
page.wait_for_timeout(3000)
print("[2/6] 点击 AI创作...")
page.locator("button").filter(has_text="AI创作").first.click()
page.wait_for_timeout(2000)
print("[3/6] 点击 图生3D...")
page.locator("text=图生3D").first.click()
page.wait_for_timeout(2000)
print("[4/6] 点击 单张图片...")
page.locator("text=单张图片").first.click()
page.wait_for_timeout(2000)
print("[5/6] 上传图片...")
page.locator('input[type=file]').first.set_input_files(image_path)
page.wait_for_timeout(3000)
print("[6/6] 点击 立即生成...")
page.locator("text=立即生成").first.click()
# 等待生成触发
page.wait_for_timeout(5000)
if not result["creationsId"]:
print("⚠️ 未获取到 creationsId,可能生成失败")
return result
if wait_for_complete:
print(f"\n⏳ 等待生成完成(最长 {timeout} 秒)...")
start = time.time()
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/'}}
}});
return await resp.json();
}}
''')
status = status_result.get("status", "unknown")
progress = status_result.get("progress", 0)
if status != last_status:
print(f" 状态: {status} (进度: {progress}%)")
last_status = status
if status == "success":
# 提取模型URL
if "result" in status_result and len(status_result["result"]) > 0:
model_data = status_result["result"][0]
result["modelUrl"] = model_data.get("modelUrl")
result["previewUrl"] = model_data.get("previewUrl")
print(f"\n✅ 生成完成!")
break
elif status == "fail":
print(f"\n❌ 生成失败")
break
time.sleep(3)
else:
print(f"\n⏰ 超时,当前状态: {last_status}")
return result
finally:
context.close()
def get_quota():
"""获取当前配额信息"""
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
try:
page.goto("https://3d.hunyuan.tencent.com/")
page.wait_for_timeout(3000)
result = page.evaluate('''
async () => {
const resp = await fetch('https://3d.hunyuan.tencent.com/api/3d/quotainfo', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'},
body: '{"sceneType":"3dCreations"}'
});
return await resp.json();
}
''')
return result
finally:
context.close()
def get_creations_list():
"""获取作品列表"""
context = launch_persistent_context(PROFILE_DIR, headless=True)
page = context.new_page()
try:
page.goto("https://3d.hunyuan.tencent.com/")
page.wait_for_timeout(3000)
result = page.evaluate('''
async () => {
const resp = await fetch('https://3d.hunyuan.tencent.com/api/3d/creations/list', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Source': 'web', 'Referer': 'https://3d.hunyuan.tencent.com/'},
body: '{"page":1,"pageSize":20}'
});
return await resp.json();
}
''')
return result
finally:
context.close()
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法:")
print(f" python {sys.argv[0]} quota # 查询配额")
print(f" python {sys.argv[0]} list # 查询作品列表")
print(f" python {sys.argv[0]} generate <图片路径> [wait] # 生成3D模型")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "quota":
quota = get_quota()
print(json.dumps(quota, indent=2, ensure_ascii=False))
elif cmd == "list":
creations = get_creations_list()
print(json.dumps(creations, indent=2, ensure_ascii=False))
elif cmd == "generate":
if len(sys.argv) < 3:
print("请提供图片路径")
sys.exit(1)
image_path = sys.argv[2]
wait = len(sys.argv) > 3 and sys.argv[3] == "wait"
result = generate_3d(image_path, wait_for_complete=wait)
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(f"未知命令: {cmd}")
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
腾讯混元3D 邮箱验证码登录 CLI 工具 (CloakBrowser 持久化版本)
第一次输入邮箱回车后自动发送验证码
第二次输入验证码后回车自动点击登录按钮
登录状态会自动保存到 ./hunyuan3d_profile,下次运行无需重新登录
"""
import os
import sys
import time
from cloakbrowser import launch_persistent_context
from ..config import get_profile_dir, get_cookie_path
PROFILE_DIR = str(get_profile_dir())
def _extract_and_save_cookies(context):
"""从浏览器上下文提取 Cookie 并保存到文件"""
try:
cookies = context.cookies()
relevant_names = {"hunyuan_user", "hunyuan_token", "hunyuan_source", "hy_user"}
cookie_dict = {c["name"]: c["value"] for c in cookies if c["name"] in relevant_names}
if cookie_dict:
cookie_str = "; ".join(f"{k}={v}" for k, v in cookie_dict.items())
cookie_path = get_cookie_path()
cookie_path.write_text(cookie_str, encoding="utf-8")
print(f"Cookie 已自动保存到: {cookie_path}")
return True
else:
print("警告: 未找到 hunyuan 相关 CookieAPI 客户端可能无法使用")
except Exception as e:
print(f"自动提取 Cookie 失败: {e}")
return False
def main():
# 使用持久化上下文,cookie 和登录状态会保存到 PROFILE_DIR
context = launch_persistent_context(PROFILE_DIR, headless=False)
page = context.new_page()
print("正在打开腾讯混元3D...")
page.goto("https://3d.hunyuan.tencent.com/")
time.sleep(2)
# 检查是否已经是登录状态(没有登录按钮说明已登录)
login_btn = page.locator("button").filter(has_text="登录").first
if login_btn.count() == 0:
print("检测到已有登录状态,无需重新登录。")
print(f"当前页面: {page.url}")
else:
# 未登录,走登录流程
login_btn.click()
time.sleep(1.5)
# 切换到邮箱登录
email_tab = page.locator("text=邮箱").first
if email_tab.count() > 0:
email_tab.click()
time.sleep(1.5)
else:
print("未找到邮箱登录选项")
context.close()
return
# 定位元素
email_input = page.locator('input[placeholder="请输入邮箱地址"]')
code_input = page.locator('input[type="number"][placeholder="请输入邮箱验证码"]')
send_code_btn = page.locator("a.hyc-email-login__send-code")
login_submit_btn = page.locator("button.hyc-email-login__btn")
checkbox = page.locator(".t-checkbox__former")
# 第一次交互:输入邮箱并发送验证码
print("\n============================================")
email = input("请输入邮箱地址,按回车发送验证码: ").strip()
if not email:
print("邮箱不能为空,退出")
context.close()
return
email_input.fill(email)
time.sleep(0.5)
# 勾选协议(如果未勾选)
if checkbox.count() > 0:
is_checked = checkbox.evaluate("el => el.checked")
if not is_checked:
checkbox.evaluate("el => el.click()")
time.sleep(0.3)
if send_code_btn.count() > 0:
send_code_btn.click()
print("已点击发送验证码,请查收邮件...")
else:
print("未找到发送验证码按钮")
context.close()
return
# 第二次交互:输入验证码并登录
print("\n============================================")
code = input("请输入邮箱验证码,按回车登录: ").strip()
if not code:
print("验证码不能为空,退出")
context.close()
return
code_input.fill(code)
time.sleep(0.5)
# 点击登录
if login_submit_btn.count() > 0:
login_submit_btn.click()
print("已点击登录按钮,等待跳转...")
else:
print("未找到登录提交按钮")
context.close()
return
# 等待登录成功跳转
try:
page.wait_for_url(lambda url: "login" not in url, timeout=30000)
print(f"\n登录成功!当前页面: {page.url}")
print(f"登录状态已保存到: {os.path.abspath(PROFILE_DIR)}")
_extract_and_save_cookies(context)
except Exception:
print("\n登录可能仍在处理中,或出现错误。请检查浏览器状态。")
# 保持浏览器打开
print("\n按 Enter 键关闭浏览器并退出...")
input()
context.close()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n已取消")
sys.exit(0)
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
腾讯混元3D API 拦截分析工具 (CloakBrowser)
利用持久化登录状态,自动捕获所有 API 请求和响应
"""
import json
import os
import sys
import time
from datetime import datetime
from cloakbrowser import launch_persistent_context
from ..config import get_profile_dir
PROFILE_DIR = str(get_profile_dir())
API_LOG_FILE = "./api_requests.log.json"
def main():
logs = []
def log_request(request):
url = request.url
# 只关注同域 API 和关键第三方接口
if "/api/" in url or "hunyuan" in url:
entry = {
"time": datetime.now().isoformat(),
"type": "request",
"method": request.method,
"url": url,
"headers": dict(request.headers) if hasattr(request, "headers") else {},
}
# 尝试获取 POST body
if request.method in ("POST", "PUT", "PATCH") and hasattr(request, "post_data"):
try:
entry["body"] = request.post_data
except Exception:
pass
logs.append(entry)
print(f"[REQ] {request.method} {url}")
def log_response(response):
url = response.url
if "/api/" in url or "hunyuan" in url:
entry = {
"time": datetime.now().isoformat(),
"type": "response",
"status": response.status,
"url": url,
}
# 尝试读取响应体
try:
# 只读取 JSON 响应
content_type = response.headers.get("content-type", "")
if "json" in content_type:
body = response.json()
entry["body"] = body
else:
text = response.text()
# 限制文本长度,避免过大
entry["body_preview"] = text[:500]
except Exception as e:
entry["body_error"] = str(e)
logs.append(entry)
print(f"[RES] {response.status} {url}")
if not os.path.exists(PROFILE_DIR):
print(f"错误: 未找到持久化目录 {PROFILE_DIR}")
print("请先运行 hunyuan3dweb-login 完成登录")
sys.exit(1)
context = launch_persistent_context(PROFILE_DIR, headless=False)
page = context.new_page()
page.on("request", log_request)
page.on("response", log_response)
print("正在打开腾讯混元3D并捕获 API...")
page.goto("https://3d.hunyuan.tencent.com/")
time.sleep(3)
# 检查是否已登录
login_btn = page.locator("button").filter(has_text="登录").first
if login_btn.count() > 0:
print("\n警告: 当前未检测到登录状态,API 可能返回未授权")
else:
print("\n已检测到登录状态,开始捕获 API...")
print("\n你可以手动在浏览器中操作(切换页面、生成3D等)")
print("所有 API 请求会实时打印在终端中")
print("按 Enter 停止捕获并保存结果...\n")
input()
# 保存日志
with open(API_LOG_FILE, "w", encoding="utf-8") as f:
json.dump(logs, f, ensure_ascii=False, indent=2)
print(f"\n共捕获 {len([l for l in logs if l['type'] == 'request'])} 个请求")
print(f"结果已保存到: {os.path.abspath(API_LOG_FILE)}")
context.close()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n已取消")
sys.exit(0)
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
Hunyuan3D Web CLI
"""
import argparse
import json
import sys
from .api import Hunyuan3DAPI, load_cookies_from_file
from .config import get_cookie_path
_DEFAULT_COOKIE = str(get_cookie_path())
def main():
parser = argparse.ArgumentParser(description="Hunyuan3D Web CLI")
subparsers = parser.add_subparsers(dest="command")
quota_parser = subparsers.add_parser("quota", help="查询配额")
quota_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
list_parser = subparsers.add_parser("list", help="查询作品列表")
list_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
text_parser = subparsers.add_parser("text", help="文生3D")
text_parser.add_argument("prompt", help="文本描述")
text_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
text_parser.add_argument("--title", "-t", default="", help="作品标题")
status_parser = subparsers.add_parser("status", help="查询生成状态")
status_parser.add_argument("creation_id", help="创作ID")
status_parser.add_argument("--cookies", "-c", default=_DEFAULT_COOKIE, help="Cookie文件路径")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
cookies = load_cookies_from_file(args.cookies)
api = Hunyuan3DAPI(cookies)
if args.command == "quota":
print(json.dumps(api.get_quota_info(), indent=2, ensure_ascii=False))
elif args.command == "list":
print(json.dumps(api.get_creation_list(), indent=2, ensure_ascii=False))
elif args.command == "text":
result = api.generate_text(args.prompt, title=args.title)
print(json.dumps(result, indent=2, ensure_ascii=False))
elif args.command == "status":
print(json.dumps(api.get_generation_status(args.creation_id), indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
+23
View File
@@ -0,0 +1,23 @@
import os
from pathlib import Path
def get_config_dir() -> Path:
"""Return the user configuration directory for hunyuan3dweb."""
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else Path.home() / ".config"
path = base / "hunyuan3dweb"
path.mkdir(parents=True, exist_ok=True)
return path
def get_cookie_path() -> Path:
"""Return the path to the cookies file."""
return get_config_dir() / "cookies.txt"
def get_profile_dir() -> Path:
"""Return the path to the browser profile directory."""
path = get_config_dir() / "profile"
path.mkdir(parents=True, exist_ok=True)
return path
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
腾讯混元3D签名算法实现
从 webpack 模块逆向提取
"""
import hmac
import hashlib
import time
import random
import string
# 密钥派生相关常量(从 JS 提取)
C = bytes([122, 59, 92, 165, 30, 79, 166, 139, 142, 129, 139, 89, 219, 131, 101, 204])
D = bytes([122, 59, 92, 45, 30, 79, 106, 139, 156, 13, 46, 63, 74, 91, 108, 125])
U = [3, 5, 2, 7, 1, 4, 6, 2, 5, 3, 1, 4, 2, 6, 3, 5]
M = [14, 11, 13, 9, 15, 10, 12, 8, 6, 3, 5, 1, 7, 2, 4, 0]
def derive_key(c: bytes) -> str:
"""密钥派生函数 - 从硬编码常量派生签名密钥"""
if len(c) != 16:
raise ValueError("输入必须是一个16字节的数组")
# 步骤1: XOR with D
t = bytearray(16)
for i in range(16):
t[i] = c[i] ^ D[i]
# 步骤2: 循环左移
o = bytearray(16)
for i in range(16):
n = U[i]
# Python 的移位和 JS 不同,需要模拟 8 位无符号整数
val = t[i]
o[i] = (val << n | val >> (8 - n)) & 0xFF
# 步骤3: 置换
n = bytearray(16)
for i in range(16):
n[i] = o[M[i]]
# 步骤4: 找到第一个0字节,截断
try:
r = n.index(0)
except ValueError:
r = 16
return n[:r].decode('utf-8')
def generate_nonce(length: int = 16) -> str:
"""生成随机 nonce"""
chars = string.ascii_letters + string.digits # 62字符
return ''.join(random.choice(chars) for _ in range(length))
def get_timestamp() -> int:
"""获取当前时间戳(秒)"""
return int(time.time())
def sort_params(params: dict) -> list:
"""排序参数,过滤空值"""
import json
items = []
for k, v in params.items():
if v is not None and v != "":
# 对列表和字典使用 JSON 格式
if isinstance(v, (list, dict, bool)):
items.append((k, json.dumps(v, separators=(',', ':'), ensure_ascii=False)))
else:
items.append((k, str(v)))
return sorted(items, key=lambda x: x[0])
def join_params(items: list) -> str:
"""拼接参数为查询字符串"""
return '&'.join(f"{k}={v}" for k, v in items)
def sign(params: dict,
nonce_length: int = 16,
timestamp_field: str = "timestamp",
nonce_field: str = "nonce",
sign_field: str = "sign") -> dict:
"""
主签名函数
Args:
params: 请求参数
nonce_length: nonce 长度
timestamp_field: 时间戳字段名
nonce_field: nonce 字段名
sign_field: 签名字段名
Returns:
包含签名的新参数字典
"""
result = dict(params)
result[timestamp_field] = get_timestamp()
result[nonce_field] = generate_nonce(nonce_length)
# 排序并拼接
sorted_items = sort_params(result)
param_str = join_params(sorted_items)
# 派生密钥
key = derive_key(C)
# HMAC-SHA256 签名,然后转为 Hex
signature = hmac.new(
key.encode('utf-8'),
param_str.encode('utf-8'),
hashlib.sha256
).hexdigest()
result[sign_field] = signature
return result
def sign_with_custom_nonce(params: dict, timestamp: int, nonce: str,
timestamp_field: str = "timestamp",
nonce_field: str = "nonce",
sign_field: str = "sign") -> dict:
"""使用指定的 timestamp 和 nonce 生成签名(用于验证)"""
result = dict(params)
result[timestamp_field] = timestamp
result[nonce_field] = nonce
sorted_items = sort_params(result)
param_str = join_params(sorted_items)
key = derive_key(C)
signature = hmac.new(
key.encode('utf-8'),
param_str.encode('utf-8'),
hashlib.sha256
).hexdigest()
result[sign_field] = signature
return result
if __name__ == "__main__":
# 测试密钥派生
key = derive_key(C)
print(f"派生密钥: {key}")
print(f"密钥长度: {len(key)}")
# 测试签名
test_params = {
"sceneType": "playGround3D-2.0",
"count": 1,
"modelType": "image2ModelV3.1"
}
signed = sign(test_params)
print(f"\n测试签名:")
for k, v in sorted(signed.items()):
print(f" {k}: {v}")