| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527 |
- """CPA Codex OAuth 授权:获取 OAuth URL、捕获 localhost 回调、提交给 CPA。"""
- from __future__ import annotations
- import time
- from typing import Callable
- from urllib.parse import urlparse
- CALLBACK_PATHS = {"/auth/callback", "/codex/callback"}
- CPA_OAUTH_URL_SELECTORS = ('[class*="authUrlValue"]', '.OAuthPage-module__authUrlValue___axvUJ')
- CPA_MANAGEMENT_KEY_SELECTORS = (
- '.LoginPage-module__loginCard___OgP-R input[type="password"]',
- 'input[type="password"]',
- 'input[placeholder*="管理"]',
- 'input[placeholder*="key" i]',
- 'input[placeholder*="Key"]',
- 'input[aria-label*="管理密钥"]',
- )
- CPA_OAUTH_NAV_SELECTORS = (
- 'a[href*="#/oauth"]',
- 'button:has-text("OAuth")',
- '[role="link"]:has-text("OAuth")',
- '[role="button"]:has-text("OAuth")',
- )
- OPENAI_EMAIL_SELECTORS = (
- 'input[type="email"]',
- 'input[name="email"]',
- 'input[name="username"]',
- 'input[autocomplete="username"]',
- 'input[placeholder*="email" i]',
- )
- def is_localhost_oauth_callback_url(raw_url: str) -> bool:
- try:
- parsed = urlparse(str(raw_url or ""))
- except Exception:
- return False
- if parsed.scheme not in ("http", "https"):
- return False
- if parsed.hostname not in ("localhost", "127.0.0.1"):
- return False
- if parsed.path not in CALLBACK_PATHS:
- return False
- query = parsed.query or ""
- return "code=" in query and "state=" in query
- def build_cpa_oauth_panel_url(cpa_url: str) -> str:
- url = str(cpa_url or "").strip()
- if not url:
- raise RuntimeError("CPA 地址未配置")
- parsed = urlparse(url)
- if not parsed.scheme or not parsed.netloc:
- raise RuntimeError(f"CPA 地址格式无效: {cpa_url}")
- if parsed.fragment == "/oauth" or parsed.fragment == "oauth":
- return url
- if parsed.path.endswith("/management.html"):
- return f"{parsed.scheme}://{parsed.netloc}{parsed.path}#/oauth"
- return f"{parsed.scheme}://{parsed.netloc}/management.html#/oauth"
- def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 300) -> bool:
- deadline = time.time() + timeout_sec
- while time.time() < deadline:
- try:
- if predicate():
- return True
- except Exception:
- pass
- time.sleep(interval_ms / 1000)
- return False
- def _safe_page_wait(page, timeout_ms: int) -> None:
- try:
- page.wait_for_timeout(timeout_ms)
- except Exception:
- time.sleep(timeout_ms / 1000)
- def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool:
- loc = page.locator(selector)
- count = loc.count()
- for i in range(count):
- item = loc.nth(i)
- try:
- if item.is_visible() and item.is_enabled():
- item.click(timeout=4000)
- log(f"[cpa-oauth] 点击 {label or selector} (idx={i}) 成功")
- return True
- except Exception as exc:
- log(f"[cpa-oauth] 点击 {label or selector} (idx={i}) 失败: {exc!r}")
- return False
- def _fill_first_visible(page, selectors: tuple[str, ...], value: str) -> str:
- for selector in selectors:
- loc = page.locator(selector)
- count = loc.count()
- for i in range(count):
- item = loc.nth(i)
- try:
- if item.is_visible():
- item.fill(value)
- return selector
- except Exception:
- continue
- return ""
- def _read_first_visible_text(page, selectors: tuple[str, ...]) -> str:
- for selector in selectors:
- loc = page.locator(selector)
- count = loc.count()
- for i in range(count):
- item = loc.nth(i)
- try:
- if item.is_visible():
- text = (item.inner_text(timeout=1000) or "").strip()
- if text.startswith(("http://", "https://")):
- return text
- except Exception:
- continue
- return ""
- def _has_visible(page, selectors: tuple[str, ...]) -> bool:
- for selector in selectors:
- loc = page.locator(selector)
- count = loc.count()
- for i in range(count):
- try:
- if loc.nth(i).is_visible():
- return True
- except Exception:
- continue
- return False
- def _context_pages(page) -> list:
- context = getattr(page, "context", None)
- pages_attr = getattr(context, "pages", None) if context is not None else None
- if pages_attr is None:
- return []
- try:
- pages = pages_attr() if callable(pages_attr) else pages_attr
- except Exception:
- return []
- try:
- return list(pages or [])
- except Exception:
- return []
- def _find_localhost_callback_url(page, log=None) -> str:
- current_url = str(getattr(page, "url", "") or "")
- if is_localhost_oauth_callback_url(current_url):
- return current_url
- for candidate in _context_pages(page):
- if candidate is page:
- continue
- candidate_url = str(getattr(candidate, "url", "") or "")
- if is_localhost_oauth_callback_url(candidate_url):
- if log:
- log("[cpa-oauth] 从同一浏览器上下文的其他页面捕获 localhost callback")
- return candidate_url
- return ""
- def _login_cpa_management_if_needed(page, management_key: str, log) -> bool:
- if not management_key:
- return False
- filled_selector = _fill_first_visible(
- page,
- CPA_MANAGEMENT_KEY_SELECTORS,
- management_key,
- )
- if not filled_selector:
- return False
- log(f"[cpa-oauth] 已填写 CPA 管理密钥 selector={filled_selector}")
- for selector in (
- 'button:has-text("登录")',
- 'button:has-text("Login")',
- 'button:has-text("Sign in")',
- 'button[type="submit"]',
- ):
- if _try_click_first_visible(page, selector, log, label=f"cpa-management-login({selector})"):
- _safe_page_wait(page, 1200)
- return True
- return False
- def _ensure_cpa_oauth_panel_ready(
- page,
- *,
- panel_url: str,
- management_key: str,
- log: Callable[[str], None],
- timeout_sec: int,
- ) -> str:
- """确保 CPA 面板已越过管理登录页,返回已存在的 OAuth URL(如有)。"""
- deadline = time.time() + timeout_sec
- management_login_attempts = 0
- while time.time() < deadline:
- oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
- if oauth_url:
- return oauth_url
- url = str(getattr(page, "url", "") or "").lower()
- login_visible = _has_visible(page, CPA_MANAGEMENT_KEY_SELECTORS)
- if "#/oauth" in url and not login_visible:
- return ""
- if login_visible:
- management_login_attempts += 1
- if management_login_attempts > 3:
- raise RuntimeError(f"CPA 管理登录失败,请检查管理密钥或权限 URL={page.url}")
- _login_cpa_management_if_needed(page, management_key, log)
- _safe_page_wait(page, 800)
- continue
- if "#/oauth" not in url:
- log(f"[cpa-oauth] CPA 登录后未在 OAuth 路由,重新打开 {panel_url}")
- page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
- _safe_page_wait(page, 800)
- continue
- nav_clicked = False
- for selector in CPA_OAUTH_NAV_SELECTORS:
- if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-nav({selector})"):
- nav_clicked = True
- break
- _safe_page_wait(page, 800 if nav_clicked else 300)
- raise RuntimeError(f"CPA 管理登录后未进入 OAuth 面板 URL={page.url}")
- def fetch_cpa_oauth_url(
- page,
- *,
- cpa_url: str,
- management_key: str,
- log: Callable[[str], None] = print,
- timeout_sec: int = 45,
- ) -> str:
- panel_url = build_cpa_oauth_panel_url(cpa_url)
- log(f"[cpa-oauth] 打开 CPA OAuth 面板 {panel_url}")
- page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
- try:
- page.wait_for_timeout(1200)
- except Exception:
- pass
- oauth_url = _ensure_cpa_oauth_panel_ready(
- page,
- panel_url=panel_url,
- management_key=management_key,
- log=log,
- timeout_sec=timeout_sec,
- )
- if oauth_url:
- log(f"[cpa-oauth] CPA 面板已有 OAuth URL {oauth_url[:80]}...")
- return oauth_url
- clicked = False
- for selector in (
- 'button:has-text("登录")',
- 'button:has-text("Login")',
- 'button:has-text("OAuth")',
- ):
- if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-login({selector})"):
- clicked = True
- break
- if not clicked:
- raise RuntimeError(f"CPA OAuth 面板未找到登录按钮 URL={page.url}")
- ok = _wait_until(
- lambda: bool(_read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)),
- timeout_sec,
- )
- if not ok:
- raise RuntimeError(f"点击 CPA OAuth 登录后未出现授权链接 URL={page.url}")
- oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
- log(f"[cpa-oauth] 获取 OAuth URL {oauth_url[:80]}...")
- return oauth_url
- def _click_openai_oauth_continue(page, log) -> bool:
- for selector in (
- 'button[type="submit"]',
- 'button:has-text("Continue")',
- 'button:has-text("Allow")',
- 'button:has-text("Authorize")',
- 'button:has-text("继续")',
- 'button:has-text("允许")',
- 'button:has-text("授权")',
- 'input[type="submit"]',
- ):
- if _try_click_first_visible(page, selector, log, label=f"openai-oauth-continue({selector})"):
- return True
- return False
- def _fill_openai_email_if_present(page, email_hint: str, log) -> bool:
- email = str(email_hint or "").strip()
- if not email:
- return False
- filled_selector = _fill_first_visible(page, OPENAI_EMAIL_SELECTORS, email)
- if not filled_selector:
- return False
- log(f"[cpa-oauth] 已填写 OpenAI OAuth 登录邮箱 selector={filled_selector}")
- if _click_openai_oauth_continue(page, log):
- _safe_page_wait(page, 1500)
- return True
- def _drive_openai_oauth_until_callback(page, email_hint: str, log, timeout_sec: int) -> str:
- deadline = time.time() + timeout_sec
- acted = False
- email_submitted = False
- while time.time() < deadline:
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- if not email_submitted:
- email_submitted = _fill_openai_email_if_present(page, email_hint, log)
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- if email_submitted:
- acted = True
- _safe_page_wait(page, 500)
- continue
- clicked = _click_openai_oauth_continue(page, log)
- acted = acted or clicked
- if clicked:
- _safe_page_wait(page, 1200)
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- else:
- _safe_page_wait(page, 500)
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- if email_submitted:
- acted = True
- if acted:
- raise RuntimeError(f"OAuth 授权后未捕获 localhost callback URL={page.url}")
- raise RuntimeError(f"OAuth 页面未找到可继续的登录或授权按钮 URL={page.url}")
- def approve_openai_oauth_and_capture_callback(
- page,
- oauth_url: str,
- *,
- email_hint: str = "",
- log: Callable[[str], None] = print,
- timeout_sec: int = 120,
- ) -> str:
- if not str(oauth_url or "").startswith(("http://", "https://")):
- raise RuntimeError(f"OAuth URL 无效: {oauth_url}")
- log(f"[cpa-oauth] 打开 OpenAI OAuth URL {oauth_url[:80]}...")
- try:
- page.goto(oauth_url, wait_until="domcontentloaded", timeout=60000)
- except Exception as exc:
- if not _find_localhost_callback_url(page, log):
- raise
- log(f"[cpa-oauth] localhost callback 导航报错但 URL 已捕获: {exc!r}")
- try:
- page.wait_for_timeout(1500)
- except Exception:
- pass
- callback_url = _find_localhost_callback_url(page, log)
- if callback_url:
- return callback_url
- callback_url = _drive_openai_oauth_until_callback(page, email_hint, log, timeout_sec)
- log(f"[cpa-oauth] 捕获 callback {callback_url[:80]}...")
- return callback_url
- def submit_oauth_callback_to_cpa(
- page,
- *,
- cpa_url: str,
- callback_url: str,
- management_key: str = "",
- log: Callable[[str], None] = print,
- wait_for_success: bool = True,
- timeout_sec: int = 120,
- ) -> str:
- if not is_localhost_oauth_callback_url(callback_url):
- raise RuntimeError("CPA OAuth callback URL 无效")
- panel_url = build_cpa_oauth_panel_url(cpa_url)
- log(f"[cpa-oauth] 回到 CPA 面板提交 callback {panel_url}")
- page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
- try:
- page.wait_for_timeout(1200)
- except Exception:
- pass
- _ensure_cpa_oauth_panel_ready(
- page,
- panel_url=panel_url,
- management_key=management_key,
- log=log,
- timeout_sec=45,
- )
- filled_selector = _fill_first_visible(
- page,
- (
- 'input[placeholder*="localhost"]',
- '[class*="callbackSection"] input.input',
- 'input.input',
- ),
- callback_url,
- )
- if not filled_selector:
- raise RuntimeError(f"CPA 面板未找到 callback 输入框 URL={page.url}")
- log(f"[cpa-oauth] 已填写 callback selector={filled_selector}")
- submitted = False
- for selector in (
- 'button:has-text("提交回调 URL")',
- 'button:has-text("Submit Callback URL")',
- 'button:has-text("Callback URL")',
- '[class*="callbackActions"] button',
- '[class*="callbackSection"] button',
- 'button.btn',
- ):
- if _try_click_first_visible(page, selector, log, label=f"cpa-callback-submit({selector})"):
- submitted = True
- break
- if not submitted:
- raise RuntimeError(f"CPA 面板未找到 callback 提交按钮 URL={page.url}")
- if not wait_for_success:
- return ""
- success_texts = ("认证成功", "Authentication successful", "Аутентификация успешна")
- failure_texts = ("认证失败", "回调 URL 提交失败", "oauth flow is not pending", "callback url submit failed")
- def _status_text() -> str:
- try:
- body = page.locator("body").first
- return body.inner_text(timeout=1000) or ""
- except Exception:
- return ""
- def _is_done() -> bool:
- text = _status_text()
- lowered = text.lower()
- if any(marker.lower() in lowered for marker in failure_texts):
- raise RuntimeError(f"CPA OAuth 回调提交失败: {text[:300]}")
- return any(marker.lower() in lowered for marker in success_texts)
- ok = _wait_until(_is_done, timeout_sec)
- if not ok:
- raise RuntimeError(f"等待 CPA OAuth 认证成功超时 URL={page.url}")
- log("[cpa-oauth] CPA OAuth 认证成功")
- return "Authentication successful!"
- def authorize_codex_oauth_to_cpa(
- page,
- *,
- cpa_url: str,
- management_key: str,
- email_hint: str = "",
- log: Callable[[str], None] = print,
- ) -> dict:
- oauth_url = fetch_cpa_oauth_url(page, cpa_url=cpa_url, management_key=management_key, log=log)
- oauth_page = page
- close_oauth_page = False
- try:
- context = getattr(page, "context", None)
- if context is not None and hasattr(context, "new_page"):
- oauth_page = context.new_page()
- close_oauth_page = True
- log("[cpa-oauth] 已打开独立 OpenAI OAuth 页面,CPA 面板页保持打开")
- except Exception as exc:
- log(f"[cpa-oauth] 打开独立 OAuth 页面失败,将复用当前页: {exc!r}")
- oauth_page = page
- close_oauth_page = False
- try:
- callback_url = approve_openai_oauth_and_capture_callback(
- oauth_page,
- oauth_url,
- email_hint=email_hint,
- log=log,
- )
- finally:
- if close_oauth_page:
- try:
- oauth_page.close()
- except Exception as exc:
- log(f"[cpa-oauth] 关闭 OpenAI OAuth 页面异常: {exc!r}")
- status = submit_oauth_callback_to_cpa(
- page,
- cpa_url=cpa_url,
- callback_url=callback_url,
- management_key=management_key,
- log=log,
- )
- return {
- "fileName": "",
- "email": email_hint,
- "planType": "codex-oauth",
- "hasRefreshToken": True,
- "status": status,
- "callbackUrl": callback_url,
- "oauthUrl": oauth_url,
- }
|