From bf4e1e57559ac10afdb21a310b10d37d498ce32b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:47:11 +0800 Subject: [PATCH] feat: add pure Python COS upload and improve login detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(cos): add cos_upload.py for direct file upload without browser - implements COS V1 signature algorithm with temporary credentials - upload_image() pipeline: get_upload_info → sign → PUT to COS - feat(api): auto-load cookies from file when cookies arg is omitted - both Hunyuan3DAPI and Hunyuan3DAPIComplete now fall back to ~/.config/hunyuan3dweb/cookies.txt automatically - fix(login): strengthen login-state detection using both URL and DOM - checks "login" not in page.url AND no login button on page - docs: update README / README_CN with COS upload examples Co-Authored-By: Claude Opus 4.7 --- README.md | 15 +++- README_CN.md | 15 +++- hunyuan3dweb/api.py | 8 +- hunyuan3dweb/api_complete.py | 8 +- hunyuan3dweb/browser/login.py | 15 ++-- hunyuan3dweb/cos_upload.py | 157 ++++++++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 hunyuan3dweb/cos_upload.py diff --git a/README.md b/README.md index c4aa505..4b01a90 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ if result["status"] == "success": ### 2. Image-to-3D (Local File) -For local image files, use the browser automation tool. The pure API client cannot upload images directly because Tencent COS requires browser-side decryption of temporary credentials. +For local image files, you can use either the browser automation tool or the pure Python COS uploader to obtain a `resourceUrl` and then call the API. ```bash hunyuan3dweb-generate /path/to/image.png wait @@ -102,6 +102,18 @@ result = generate_3d("/path/to/image.png", wait_for_complete=True) print(f"Model URL: {result.get('modelUrl')}") ``` +Or via pure Python upload: + +```python +from hunyuan3dweb.cos_upload import upload_image +from hunyuan3dweb import Hunyuan3DAPIComplete + +resource_url = upload_image("/path/to/image.png") + +api = Hunyuan3DAPIComplete() +result = api.generate_from_image(resource_url, title="My Model") +``` + ### 3. Image-to-3D (API with Existing resourceUrl) If you already have a `resourceUrl` (e.g. from a previous browser upload), use the pure API client: @@ -283,6 +295,7 @@ hunyuan3dweb-sniffer | `hunyuan3dweb/api.py` | Basic API client (image2model, text2model) | | `hunyuan3dweb/api_complete.py` | Full API client (all generation modes) | | `hunyuan3dweb/sign.py` | Tencent Hunyuan 3D signing algorithm | +| `hunyuan3dweb/cos_upload.py` | Pure Python COS upload helper | | `hunyuan3dweb/config.py` | User config path management | | `hunyuan3dweb/cli.py` | CLI entry point | | `hunyuan3dweb/browser/login.py` | Browser login tool | diff --git a/README_CN.md b/README_CN.md index 47e617e..a99c82f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -87,7 +87,7 @@ if result["status"] == "success": ### 2. 图生3D(本地文件) -本地图片文件需通过浏览器自动化工具上传。纯 API 客户端无法直接上传图片,因为腾讯 COS 临时凭证需要浏览器端解密。 +本地图片文件可通过浏览器自动化工具上传,也可使用纯 Python 的 COS 上传模块先拿到 `resourceUrl` 再走 API。 ```bash hunyuan3dweb-generate /path/to/image.png wait @@ -102,6 +102,18 @@ result = generate_3d("/path/to/image.png", wait_for_complete=True) print(f"模型地址: {result.get('modelUrl')}") ``` +或通过纯 Python 上传: + +```python +from hunyuan3dweb.cos_upload import upload_image +from hunyuan3dweb import Hunyuan3DAPIComplete + +resource_url = upload_image("/path/to/image.png") + +api = Hunyuan3DAPIComplete() +result = api.generate_from_image(resource_url, title="我的模型") +``` + ### 3. 图生3D(API,已有 resourceUrl) 如果你已经有 `resourceUrl`(例如之前通过浏览器上传过),可直接调用 API: @@ -283,6 +295,7 @@ hunyuan3dweb-sniffer | `hunyuan3dweb/api.py` | 基础 API 客户端(图生3D、文生3D) | | `hunyuan3dweb/api_complete.py` | 完整 API 客户端(所有生成模式) | | `hunyuan3dweb/sign.py` | 腾讯混元3D 签名算法 | +| `hunyuan3dweb/cos_upload.py` | 纯 Python COS 上传工具 | | `hunyuan3dweb/config.py` | 用户配置路径管理 | | `hunyuan3dweb/cli.py` | CLI 入口 | | `hunyuan3dweb/browser/login.py` | 浏览器登录工具 | diff --git a/hunyuan3dweb/api.py b/hunyuan3dweb/api.py index 4a38c4d..952232b 100644 --- a/hunyuan3dweb/api.py +++ b/hunyuan3dweb/api.py @@ -34,8 +34,12 @@ class Hunyuan3DAPI: "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) + try: + cookie_value = cookies if cookies is not None else load_cookies_from_file() + except FileNotFoundError: + cookie_value = None + if cookie_value: + self.set_cookies(cookie_value) def set_cookies(self, cookies: str): """设置Cookie""" diff --git a/hunyuan3dweb/api_complete.py b/hunyuan3dweb/api_complete.py index 3ace696..68b7aa3 100644 --- a/hunyuan3dweb/api_complete.py +++ b/hunyuan3dweb/api_complete.py @@ -57,8 +57,12 @@ class Hunyuan3DAPI: "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) + try: + cookie_value = cookies if cookies is not None else load_cookies_from_file() + except FileNotFoundError: + cookie_value = None + if cookie_value: + self.set_cookies(cookie_value) def set_cookies(self, cookies: str): self.session.headers["Cookie"] = cookies diff --git a/hunyuan3dweb/browser/login.py b/hunyuan3dweb/browser/login.py index 8a03546..c007f38 100644 --- a/hunyuan3dweb/browser/login.py +++ b/hunyuan3dweb/browser/login.py @@ -56,9 +56,12 @@ def main(): page.goto("https://3d.hunyuan.tencent.com/", timeout=60000) page.wait_for_timeout(2000) - # 检查是否已经是登录状态(没有登录按钮说明已登录) + # 检查是否已经是登录状态:URL 不含 login 且页面上没有登录按钮 + is_login_page = "login" in page.url login_btn = page.locator("button").filter(has_text="登录").first - if login_btn.count() == 0: + has_login_btn = login_btn.count() > 0 + + if not is_login_page and not has_login_btn: print("检测到已有登录状态,无需重新登录。") print(f"当前页面: {page.url}") _extract_and_save_cookies(context) @@ -70,6 +73,10 @@ def main(): login_btn.click() page.locator("text=邮箱").wait_for(state="visible", timeout=10000) + # 定位元素 + email_input = page.locator('input[placeholder="请输入邮箱地址"]') + code_input = page.locator('input[type="number"][placeholder="请输入邮箱验证码"]') + # 切换到邮箱登录 email_tab = page.locator("text=邮箱").first if email_tab.count() > 0: @@ -78,10 +85,6 @@ def main(): else: print("未找到邮箱登录选项") 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") diff --git a/hunyuan3dweb/cos_upload.py b/hunyuan3dweb/cos_upload.py new file mode 100644 index 0000000..a2d7507 --- /dev/null +++ b/hunyuan3dweb/cos_upload.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Pure Python COS upload for Tencent Hunyuan 3D. +Uses the API to get temporary credentials, then directly uploads to COS. +""" + +import hashlib +import hmac +import mimetypes +from pathlib import Path +from urllib.parse import quote + +import requests + +from . import Hunyuan3DAPIComplete +from .api import load_cookies_from_file + + +def _cam_safe_url_encode(s): + return quote(str(s), safe="") + + +def _get_object_keys(obj): + return sorted(obj.keys(), key=lambda x: x.lower()) + + +def _obj2str(obj, lower_case_key=False): + items = [] + for key in _get_object_keys(obj): + val = obj[key] + if val is None: + val = "" + else: + val = str(val) + encoded_key = _cam_safe_url_encode(key).lower() if lower_case_key else _cam_safe_url_encode(key) + encoded_val = _cam_safe_url_encode(val) or "" + items.append(f"{encoded_key}={encoded_val}") + return "&".join(items) + + +def _cos_v1_auth(method, pathname, query_params, headers, secret_id, secret_key, key_time): + """Generate COS V1 authorization header.""" + sign_key = hmac.new(secret_key.encode("utf-8"), key_time.encode("utf-8"), hashlib.sha1).hexdigest() + + query_str = _obj2str(query_params, True) + headers_str = _obj2str(headers, True) + format_string = f"{method}\n{pathname}\n{query_str}\n{headers_str}\n" + + format_string_sha1 = hashlib.sha1(format_string.encode("utf-8")).hexdigest() + string_to_sign = f"sha1\n{key_time}\n{format_string_sha1}\n" + + signature = hmac.new(sign_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).hexdigest() + + q_header_list = ";".join(_get_object_keys(headers)).lower() + q_url_param_list = ";".join(_get_object_keys(query_params)).lower() + + return "&".join([ + "q-sign-algorithm=sha1", + f"q-ak={secret_id}", + f"q-sign-time={key_time}", + f"q-key-time={key_time}", + f"q-header-list={q_header_list}", + f"q-url-param-list={q_url_param_list}", + f"q-signature={signature}", + ]) + + +def _guess_content_type(image_path): + content_type, _ = mimetypes.guess_type(str(image_path)) + return content_type or "application/octet-stream" + + +def upload_file_to_cos(image_path, upload_info, use_accelerate=True): + """ + Upload a local file to Tencent COS using temporary credentials from get_upload_info. + + Args: + image_path: Local file path + upload_info: Response from get_upload_info API + use_accelerate: Use COS global accelerate endpoint + + Returns: + requests.Response + """ + bucket = upload_info["bucketName"] + region = upload_info["region"] + location = upload_info["location"] + secret_id = upload_info["encryptTmpSecretId"] + secret_key = upload_info["encryptTmpSecretKey"] + token = upload_info["encryptToken"] + start_time = upload_info["startTime"] + expired_time = upload_info["expiredTime"] + + host = f"{bucket}.cos.accelerate.myqcloud.com" if use_accelerate else f"{bucket}.cos.{region}.myqcloud.com" + key_time = f"{start_time};{expired_time}" + file_size = Path(image_path).stat().st_size + content_type = _guess_content_type(image_path) + + headers_for_sign = { + "content-length": file_size, + "content-type": content_type, + "host": host, + } + + auth = _cos_v1_auth( + method="put", + pathname=f"/{location}", + query_params={}, + headers=headers_for_sign, + secret_id=secret_id, + secret_key=secret_key, + key_time=key_time, + ) + + upload_url = f"https://{host}/{location}" + + with open(image_path, "rb") as f: + resp = requests.put( + upload_url, + data=f, + headers={ + "Authorization": auth, + "x-cos-security-token": token, + "Content-Type": content_type, + "Content-Length": str(file_size), + "Host": host, + }, + timeout=120, + ) + + return resp + + +def upload_image(image_path, use_accelerate=True): + """ + Full pipeline: get upload credentials from Hunyuan3D API -> upload to COS. + + Args: + image_path: Local image file path + use_accelerate: Use COS global accelerate endpoint + + Returns: + str: resourceUrl for subsequent 3D generation + """ + try: + cookies = load_cookies_from_file() + except FileNotFoundError as exc: + raise RuntimeError("Cookies file not found. Please run 'hunyuan3dweb-login' first.") from exc + api = Hunyuan3DAPIComplete(cookies=cookies) + + filename = Path(image_path).name + upload_info = api.get_upload_info(filename) + + resp = upload_file_to_cos(image_path, upload_info, use_accelerate) + resp.raise_for_status() + + return upload_info["resourceUrl"]