"""WeChat login session for https://3d.hunyuan.tencent.com via Playwright. CLI 友好:同步阻塞扫码登录(非后台线程)。 未登录 / 凭证失效 / 额度满换号 时弹出有头 Edge 供用户扫码。 """ from __future__ import annotations import json import os import time from pathlib import Path from typing import Any, Callable, Optional HUNYUAN_URL = "https://3d.hunyuan.tencent.com/" LOGIN_TIMEOUT_SEC = 300 UpdateFn = Callable[..., None] def _tools_root() -> Path: return Path(__file__).resolve().parent.parent def session_dir() -> Path: override = os.environ.get("HUNYUAN_SESSION_DIR", "").strip() if override: return Path(override).expanduser().resolve() return _tools_root() / "data" / "hunyuan" def storage_state_path() -> Path: return session_dir() / "storage_state.json" def status_file_path() -> Path: return session_dir() / "status.json" def jobs_dir() -> Path: path = session_dir() / "jobs" path.mkdir(parents=True, exist_ok=True) return path def _browser_channel() -> str: return os.environ.get("HUNYUAN_BROWSER_CHANNEL", "msedge").strip() or "msedge" def _write_status(**values: Any) -> dict: directory = session_dir() directory.mkdir(parents=True, exist_ok=True) payload = status() payload.update(values, updated_at=time.time()) temporary = status_file_path().with_suffix(".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(status_file_path()) return payload def status() -> dict: path = status_file_path() if path.is_file(): try: data = json.loads(path.read_text("utf-8")) if storage_state_path().is_file() and data.get("state") not in { "waiting_scan", "error", "timeout", }: data["logged_in"] = True return data except (ValueError, OSError): pass logged = storage_state_path().is_file() return { "logged_in": logged, "state": "ready" if logged else "logged_out", "message": "已登录混元3D" if logged else "尚未微信登录", "session_dir": str(session_dir()), } def is_logged_in() -> bool: path = storage_state_path() if not path.is_file() or path.stat().st_size < 8: return False try: with path.open("rb") as fp: if fp.read(1) != b"{": return False json.loads(path.read_text("utf-8")) return True except (OSError, ValueError, UnicodeDecodeError): return False def logout() -> dict: for path in (storage_state_path(), status_file_path()): if path.is_file(): path.unlink() return _write_status(logged_in=False, state="logged_out", message="已退出混元3D 登录") def _click_first(page, labels, exact=False) -> bool: for label in labels: candidates = [ page.get_by_role("button", name=label), page.get_by_role("link", name=label), page.get_by_text(label, exact=exact), ] for locator in candidates: try: if locator.count() and locator.first.is_visible(timeout=800): locator.first.click(timeout=3000) page.wait_for_timeout(600) return True except Exception: continue return False def _looks_logged_in(page) -> bool: try: if page.get_by_text("登录", exact=True).count(): login = page.get_by_text("登录", exact=True).first if login.is_visible(timeout=300): return False except Exception: pass cookies = page.context.cookies() interesting = [ cookie for cookie in cookies if any( token in cookie["name"].lower() for token in ("token", "openid", "uin", "skey", "hy3d", "session", "userid") ) ] try: storage_keys = page.evaluate( "() => Object.keys({...localStorage, ...sessionStorage})" ) except Exception: storage_keys = [] storage_hit = any( any(token in key.lower() for token in ("token", "user", "auth", "openid", "uin")) for key in storage_keys ) has_generate = False try: has_generate = bool(page.get_by_text("立即生成").count()) except Exception: pass # 有生成入口,且 cookie/storage 任一命中,或页面上已无「登录」 if has_generate and (interesting or storage_hit): return True if has_generate and not _page_shows_login_cta(page): return True return bool(interesting and storage_hit) def _page_shows_login_cta(page) -> bool: try: if page.get_by_text("登录", exact=True).count(): return page.get_by_text("登录", exact=True).first.is_visible(timeout=300) except Exception: pass return False def _try_site_logout(page) -> None: """尽量在官网侧退出,方便额度满后换号扫码。""" for label in ("退出登录", "退出", "登出", "注销"): if _click_first(page, (label,), exact=False): page.wait_for_timeout(800) break # 常见头像/用户菜单再点一次退出 try: avatar = page.locator( '[class*="avatar"], [class*="user-info"], [class*="UserInfo"]' ).first if avatar.count() and avatar.is_visible(timeout=500): avatar.click(timeout=2000) page.wait_for_timeout(400) _click_first(page, ("退出登录", "退出", "登出"), exact=False) except Exception: pass def start_wechat_login_blocking( timeout_sec: int = LOGIN_TIMEOUT_SEC, *, update: Optional[UpdateFn] = None, reason: str = "auth", clear_existing: bool = False, ) -> dict: """打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。 reason: - auth: 未登录或凭证失效 - quota: 当前账号额度满,需换号 clear_existing: 先清本地会话;quota 场景会再尝试官网退出。 """ from playwright.sync_api import sync_playwright def _emit(event: str, **payload: Any) -> None: if update: update(event=event, **payload) if clear_existing or reason == "quota": logout() if reason == "quota": message = ( "当前混元账号额度/次数已满,请在弹出的浏览器中退出旧号并用微信扫码登录其他账号" ) else: message = "请在弹出的浏览器窗口中用微信扫码登录混元3D" _write_status( logged_in=False, state="waiting_scan", message=message, reason=reason, ) _emit( "login_waiting", stage=message, progress=5, timeout_sec=timeout_sec, reason=reason, ) # 同步打到 stderr,方便 Agent/终端注意弹窗 print(f"[hunyuan] {message}(最长等待 {timeout_sec}s)", flush=True) try: with sync_playwright() as playwright: browser = playwright.chromium.launch( channel=_browser_channel(), headless=False ) context = browser.new_context( locale="zh-CN", viewport={"width": 1280, "height": 900} ) page = context.new_page() page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000) page.wait_for_timeout(2000) if reason == "quota": _try_site_logout(page) page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000) page.wait_for_timeout(1500) _click_first(page, ("登录", "立即登录"), exact=True) page.wait_for_timeout(800) _click_first(page, ("微信", "微信登录", "微信扫码"), exact=False) deadline = time.monotonic() + timeout_sec while time.monotonic() < deadline: if _looks_logged_in(page): session_dir().mkdir(parents=True, exist_ok=True) context.storage_state(path=str(storage_state_path())) result = _write_status( logged_in=True, state="ready", message="微信登录成功", reason=reason, ) browser.close() _emit("login_ok", stage="微信登录成功", progress=15, **result) print("[hunyuan] 微信登录成功,会话已保存", flush=True) return result page.wait_for_timeout(1500) result = _write_status( logged_in=False, state="timeout", message="微信登录超时,请重试", reason=reason, ) browser.close() _emit("login_failed", stage="微信登录超时", progress=0, **result) raise TimeoutError("微信登录超时,请重试") except TimeoutError: raise except Exception as exc: result = _write_status( logged_in=False, state="error", message=f"登录失败:{exc}", reason=reason ) _emit("login_failed", stage=str(exc), progress=0, **result) raise RuntimeError(f"登录失败:{exc}") from exc def ensure_login( *, interactive: bool = True, timeout_sec: int = LOGIN_TIMEOUT_SEC, update: Optional[UpdateFn] = None, force: bool = False, reason: str = "auth", ) -> dict: """若无会话则弹微信登录;interactive=False 且未登录则抛错。 force=True 时忽略本地凭证,强制打开登录页(换号 / 重登)。 """ if is_logged_in() and not force: current = status() if update: update( event="login_cached", stage="已有混元登录凭证", progress=12, **{k: v for k, v in current.items() if k != "event"}, ) return current if not interactive: raise RuntimeError("尚未微信登录混元3D,请先执行 --login 或允许交互登录。") if update: update( event="login_required", stage=( "当前账号额度已满,需要换号登录" if reason == "quota" else "需要微信登录混元3D" ), progress=2, reason=reason, ) return start_wechat_login_blocking( timeout_sec=timeout_sec, update=update, reason=reason, clear_existing=force or reason == "quota", )