cpa_oauth.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. """CPA Codex OAuth 授权:获取 OAuth URL、捕获 localhost 回调、提交给 CPA。"""
  2. from __future__ import annotations
  3. import time
  4. from typing import Callable
  5. from urllib.parse import urlparse
  6. CALLBACK_PATHS = {"/auth/callback", "/codex/callback"}
  7. CPA_OAUTH_URL_SELECTORS = ('[class*="authUrlValue"]', '.OAuthPage-module__authUrlValue___axvUJ')
  8. CPA_MANAGEMENT_KEY_SELECTORS = (
  9. '.LoginPage-module__loginCard___OgP-R input[type="password"]',
  10. 'input[type="password"]',
  11. 'input[placeholder*="管理"]',
  12. 'input[placeholder*="key" i]',
  13. 'input[placeholder*="Key"]',
  14. 'input[aria-label*="管理密钥"]',
  15. )
  16. CPA_OAUTH_NAV_SELECTORS = (
  17. 'a[href*="#/oauth"]',
  18. 'button:has-text("OAuth")',
  19. '[role="link"]:has-text("OAuth")',
  20. '[role="button"]:has-text("OAuth")',
  21. )
  22. OPENAI_EMAIL_SELECTORS = (
  23. 'input[type="email"]',
  24. 'input[name="email"]',
  25. 'input[name="username"]',
  26. 'input[autocomplete="username"]',
  27. 'input[placeholder*="email" i]',
  28. )
  29. def is_localhost_oauth_callback_url(raw_url: str) -> bool:
  30. try:
  31. parsed = urlparse(str(raw_url or ""))
  32. except Exception:
  33. return False
  34. if parsed.scheme not in ("http", "https"):
  35. return False
  36. if parsed.hostname not in ("localhost", "127.0.0.1"):
  37. return False
  38. if parsed.path not in CALLBACK_PATHS:
  39. return False
  40. query = parsed.query or ""
  41. return "code=" in query and "state=" in query
  42. def build_cpa_oauth_panel_url(cpa_url: str) -> str:
  43. url = str(cpa_url or "").strip()
  44. if not url:
  45. raise RuntimeError("CPA 地址未配置")
  46. parsed = urlparse(url)
  47. if not parsed.scheme or not parsed.netloc:
  48. raise RuntimeError(f"CPA 地址格式无效: {cpa_url}")
  49. if parsed.fragment == "/oauth" or parsed.fragment == "oauth":
  50. return url
  51. if parsed.path.endswith("/management.html"):
  52. return f"{parsed.scheme}://{parsed.netloc}{parsed.path}#/oauth"
  53. return f"{parsed.scheme}://{parsed.netloc}/management.html#/oauth"
  54. def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 300) -> bool:
  55. deadline = time.time() + timeout_sec
  56. while time.time() < deadline:
  57. try:
  58. if predicate():
  59. return True
  60. except Exception:
  61. pass
  62. time.sleep(interval_ms / 1000)
  63. return False
  64. def _safe_page_wait(page, timeout_ms: int) -> None:
  65. try:
  66. page.wait_for_timeout(timeout_ms)
  67. except Exception:
  68. time.sleep(timeout_ms / 1000)
  69. def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool:
  70. loc = page.locator(selector)
  71. count = loc.count()
  72. for i in range(count):
  73. item = loc.nth(i)
  74. try:
  75. if item.is_visible() and item.is_enabled():
  76. item.click(timeout=4000)
  77. log(f"[cpa-oauth] 点击 {label or selector} (idx={i}) 成功")
  78. return True
  79. except Exception as exc:
  80. log(f"[cpa-oauth] 点击 {label or selector} (idx={i}) 失败: {exc!r}")
  81. return False
  82. def _fill_first_visible(page, selectors: tuple[str, ...], value: str) -> str:
  83. for selector in selectors:
  84. loc = page.locator(selector)
  85. count = loc.count()
  86. for i in range(count):
  87. item = loc.nth(i)
  88. try:
  89. if item.is_visible():
  90. item.fill(value)
  91. return selector
  92. except Exception:
  93. continue
  94. return ""
  95. def _read_first_visible_text(page, selectors: tuple[str, ...]) -> str:
  96. for selector in selectors:
  97. loc = page.locator(selector)
  98. count = loc.count()
  99. for i in range(count):
  100. item = loc.nth(i)
  101. try:
  102. if item.is_visible():
  103. text = (item.inner_text(timeout=1000) or "").strip()
  104. if text.startswith(("http://", "https://")):
  105. return text
  106. except Exception:
  107. continue
  108. return ""
  109. def _has_visible(page, selectors: tuple[str, ...]) -> bool:
  110. for selector in selectors:
  111. loc = page.locator(selector)
  112. count = loc.count()
  113. for i in range(count):
  114. try:
  115. if loc.nth(i).is_visible():
  116. return True
  117. except Exception:
  118. continue
  119. return False
  120. def _context_pages(page) -> list:
  121. context = getattr(page, "context", None)
  122. pages_attr = getattr(context, "pages", None) if context is not None else None
  123. if pages_attr is None:
  124. return []
  125. try:
  126. pages = pages_attr() if callable(pages_attr) else pages_attr
  127. except Exception:
  128. return []
  129. try:
  130. return list(pages or [])
  131. except Exception:
  132. return []
  133. def _find_localhost_callback_url(page, log=None) -> str:
  134. current_url = str(getattr(page, "url", "") or "")
  135. if is_localhost_oauth_callback_url(current_url):
  136. return current_url
  137. for candidate in _context_pages(page):
  138. if candidate is page:
  139. continue
  140. candidate_url = str(getattr(candidate, "url", "") or "")
  141. if is_localhost_oauth_callback_url(candidate_url):
  142. if log:
  143. log("[cpa-oauth] 从同一浏览器上下文的其他页面捕获 localhost callback")
  144. return candidate_url
  145. return ""
  146. def _login_cpa_management_if_needed(page, management_key: str, log) -> bool:
  147. if not management_key:
  148. return False
  149. filled_selector = _fill_first_visible(
  150. page,
  151. CPA_MANAGEMENT_KEY_SELECTORS,
  152. management_key,
  153. )
  154. if not filled_selector:
  155. return False
  156. log(f"[cpa-oauth] 已填写 CPA 管理密钥 selector={filled_selector}")
  157. for selector in (
  158. 'button:has-text("登录")',
  159. 'button:has-text("Login")',
  160. 'button:has-text("Sign in")',
  161. 'button[type="submit"]',
  162. ):
  163. if _try_click_first_visible(page, selector, log, label=f"cpa-management-login({selector})"):
  164. _safe_page_wait(page, 1200)
  165. return True
  166. return False
  167. def _ensure_cpa_oauth_panel_ready(
  168. page,
  169. *,
  170. panel_url: str,
  171. management_key: str,
  172. log: Callable[[str], None],
  173. timeout_sec: int,
  174. ) -> str:
  175. """确保 CPA 面板已越过管理登录页,返回已存在的 OAuth URL(如有)。"""
  176. deadline = time.time() + timeout_sec
  177. management_login_attempts = 0
  178. while time.time() < deadline:
  179. oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
  180. if oauth_url:
  181. return oauth_url
  182. url = str(getattr(page, "url", "") or "").lower()
  183. login_visible = _has_visible(page, CPA_MANAGEMENT_KEY_SELECTORS)
  184. if "#/oauth" in url and not login_visible:
  185. return ""
  186. if login_visible:
  187. management_login_attempts += 1
  188. if management_login_attempts > 3:
  189. raise RuntimeError(f"CPA 管理登录失败,请检查管理密钥或权限 URL={page.url}")
  190. _login_cpa_management_if_needed(page, management_key, log)
  191. _safe_page_wait(page, 800)
  192. continue
  193. if "#/oauth" not in url:
  194. log(f"[cpa-oauth] CPA 登录后未在 OAuth 路由,重新打开 {panel_url}")
  195. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  196. _safe_page_wait(page, 800)
  197. continue
  198. nav_clicked = False
  199. for selector in CPA_OAUTH_NAV_SELECTORS:
  200. if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-nav({selector})"):
  201. nav_clicked = True
  202. break
  203. _safe_page_wait(page, 800 if nav_clicked else 300)
  204. raise RuntimeError(f"CPA 管理登录后未进入 OAuth 面板 URL={page.url}")
  205. def fetch_cpa_oauth_url(
  206. page,
  207. *,
  208. cpa_url: str,
  209. management_key: str,
  210. log: Callable[[str], None] = print,
  211. timeout_sec: int = 45,
  212. ) -> str:
  213. panel_url = build_cpa_oauth_panel_url(cpa_url)
  214. log(f"[cpa-oauth] 打开 CPA OAuth 面板 {panel_url}")
  215. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  216. try:
  217. page.wait_for_timeout(1200)
  218. except Exception:
  219. pass
  220. oauth_url = _ensure_cpa_oauth_panel_ready(
  221. page,
  222. panel_url=panel_url,
  223. management_key=management_key,
  224. log=log,
  225. timeout_sec=timeout_sec,
  226. )
  227. if oauth_url:
  228. log(f"[cpa-oauth] CPA 面板已有 OAuth URL {oauth_url[:80]}...")
  229. return oauth_url
  230. clicked = False
  231. for selector in (
  232. 'button:has-text("登录")',
  233. 'button:has-text("Login")',
  234. 'button:has-text("OAuth")',
  235. ):
  236. if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-login({selector})"):
  237. clicked = True
  238. break
  239. if not clicked:
  240. raise RuntimeError(f"CPA OAuth 面板未找到登录按钮 URL={page.url}")
  241. ok = _wait_until(
  242. lambda: bool(_read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)),
  243. timeout_sec,
  244. )
  245. if not ok:
  246. raise RuntimeError(f"点击 CPA OAuth 登录后未出现授权链接 URL={page.url}")
  247. oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
  248. log(f"[cpa-oauth] 获取 OAuth URL {oauth_url[:80]}...")
  249. return oauth_url
  250. def _click_openai_oauth_continue(page, log) -> bool:
  251. for selector in (
  252. 'button[type="submit"]',
  253. 'button:has-text("Continue")',
  254. 'button:has-text("Allow")',
  255. 'button:has-text("Authorize")',
  256. 'button:has-text("继续")',
  257. 'button:has-text("允许")',
  258. 'button:has-text("授权")',
  259. 'input[type="submit"]',
  260. ):
  261. if _try_click_first_visible(page, selector, log, label=f"openai-oauth-continue({selector})"):
  262. return True
  263. return False
  264. def _fill_openai_email_if_present(page, email_hint: str, log) -> bool:
  265. email = str(email_hint or "").strip()
  266. if not email:
  267. return False
  268. filled_selector = _fill_first_visible(page, OPENAI_EMAIL_SELECTORS, email)
  269. if not filled_selector:
  270. return False
  271. log(f"[cpa-oauth] 已填写 OpenAI OAuth 登录邮箱 selector={filled_selector}")
  272. if _click_openai_oauth_continue(page, log):
  273. _safe_page_wait(page, 1500)
  274. return True
  275. def _drive_openai_oauth_until_callback(page, email_hint: str, log, timeout_sec: int) -> str:
  276. deadline = time.time() + timeout_sec
  277. acted = False
  278. email_submitted = False
  279. while time.time() < deadline:
  280. callback_url = _find_localhost_callback_url(page, log)
  281. if callback_url:
  282. return callback_url
  283. if not email_submitted:
  284. email_submitted = _fill_openai_email_if_present(page, email_hint, log)
  285. callback_url = _find_localhost_callback_url(page, log)
  286. if callback_url:
  287. return callback_url
  288. if email_submitted:
  289. acted = True
  290. _safe_page_wait(page, 500)
  291. continue
  292. clicked = _click_openai_oauth_continue(page, log)
  293. acted = acted or clicked
  294. if clicked:
  295. _safe_page_wait(page, 1200)
  296. callback_url = _find_localhost_callback_url(page, log)
  297. if callback_url:
  298. return callback_url
  299. else:
  300. _safe_page_wait(page, 500)
  301. callback_url = _find_localhost_callback_url(page, log)
  302. if callback_url:
  303. return callback_url
  304. callback_url = _find_localhost_callback_url(page, log)
  305. if callback_url:
  306. return callback_url
  307. if email_submitted:
  308. acted = True
  309. if acted:
  310. raise RuntimeError(f"OAuth 授权后未捕获 localhost callback URL={page.url}")
  311. raise RuntimeError(f"OAuth 页面未找到可继续的登录或授权按钮 URL={page.url}")
  312. def approve_openai_oauth_and_capture_callback(
  313. page,
  314. oauth_url: str,
  315. *,
  316. email_hint: str = "",
  317. log: Callable[[str], None] = print,
  318. timeout_sec: int = 120,
  319. ) -> str:
  320. if not str(oauth_url or "").startswith(("http://", "https://")):
  321. raise RuntimeError(f"OAuth URL 无效: {oauth_url}")
  322. log(f"[cpa-oauth] 打开 OpenAI OAuth URL {oauth_url[:80]}...")
  323. try:
  324. page.goto(oauth_url, wait_until="domcontentloaded", timeout=60000)
  325. except Exception as exc:
  326. if not _find_localhost_callback_url(page, log):
  327. raise
  328. log(f"[cpa-oauth] localhost callback 导航报错但 URL 已捕获: {exc!r}")
  329. try:
  330. page.wait_for_timeout(1500)
  331. except Exception:
  332. pass
  333. callback_url = _find_localhost_callback_url(page, log)
  334. if callback_url:
  335. return callback_url
  336. callback_url = _drive_openai_oauth_until_callback(page, email_hint, log, timeout_sec)
  337. log(f"[cpa-oauth] 捕获 callback {callback_url[:80]}...")
  338. return callback_url
  339. def submit_oauth_callback_to_cpa(
  340. page,
  341. *,
  342. cpa_url: str,
  343. callback_url: str,
  344. management_key: str = "",
  345. log: Callable[[str], None] = print,
  346. wait_for_success: bool = True,
  347. timeout_sec: int = 120,
  348. ) -> str:
  349. if not is_localhost_oauth_callback_url(callback_url):
  350. raise RuntimeError("CPA OAuth callback URL 无效")
  351. panel_url = build_cpa_oauth_panel_url(cpa_url)
  352. log(f"[cpa-oauth] 回到 CPA 面板提交 callback {panel_url}")
  353. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  354. try:
  355. page.wait_for_timeout(1200)
  356. except Exception:
  357. pass
  358. _ensure_cpa_oauth_panel_ready(
  359. page,
  360. panel_url=panel_url,
  361. management_key=management_key,
  362. log=log,
  363. timeout_sec=45,
  364. )
  365. filled_selector = _fill_first_visible(
  366. page,
  367. (
  368. 'input[placeholder*="localhost"]',
  369. '[class*="callbackSection"] input.input',
  370. 'input.input',
  371. ),
  372. callback_url,
  373. )
  374. if not filled_selector:
  375. raise RuntimeError(f"CPA 面板未找到 callback 输入框 URL={page.url}")
  376. log(f"[cpa-oauth] 已填写 callback selector={filled_selector}")
  377. submitted = False
  378. for selector in (
  379. 'button:has-text("提交回调 URL")',
  380. 'button:has-text("Submit Callback URL")',
  381. 'button:has-text("Callback URL")',
  382. '[class*="callbackActions"] button',
  383. '[class*="callbackSection"] button',
  384. 'button.btn',
  385. ):
  386. if _try_click_first_visible(page, selector, log, label=f"cpa-callback-submit({selector})"):
  387. submitted = True
  388. break
  389. if not submitted:
  390. raise RuntimeError(f"CPA 面板未找到 callback 提交按钮 URL={page.url}")
  391. if not wait_for_success:
  392. return ""
  393. success_texts = ("认证成功", "Authentication successful", "Аутентификация успешна")
  394. failure_texts = ("认证失败", "回调 URL 提交失败", "oauth flow is not pending", "callback url submit failed")
  395. def _status_text() -> str:
  396. try:
  397. body = page.locator("body").first
  398. return body.inner_text(timeout=1000) or ""
  399. except Exception:
  400. return ""
  401. def _is_done() -> bool:
  402. text = _status_text()
  403. lowered = text.lower()
  404. if any(marker.lower() in lowered for marker in failure_texts):
  405. raise RuntimeError(f"CPA OAuth 回调提交失败: {text[:300]}")
  406. return any(marker.lower() in lowered for marker in success_texts)
  407. ok = _wait_until(_is_done, timeout_sec)
  408. if not ok:
  409. raise RuntimeError(f"等待 CPA OAuth 认证成功超时 URL={page.url}")
  410. log("[cpa-oauth] CPA OAuth 认证成功")
  411. return "Authentication successful!"
  412. def authorize_codex_oauth_to_cpa(
  413. page,
  414. *,
  415. cpa_url: str,
  416. management_key: str,
  417. email_hint: str = "",
  418. log: Callable[[str], None] = print,
  419. ) -> dict:
  420. oauth_url = fetch_cpa_oauth_url(page, cpa_url=cpa_url, management_key=management_key, log=log)
  421. oauth_page = page
  422. close_oauth_page = False
  423. try:
  424. context = getattr(page, "context", None)
  425. if context is not None and hasattr(context, "new_page"):
  426. oauth_page = context.new_page()
  427. close_oauth_page = True
  428. log("[cpa-oauth] 已打开独立 OpenAI OAuth 页面,CPA 面板页保持打开")
  429. except Exception as exc:
  430. log(f"[cpa-oauth] 打开独立 OAuth 页面失败,将复用当前页: {exc!r}")
  431. oauth_page = page
  432. close_oauth_page = False
  433. try:
  434. callback_url = approve_openai_oauth_and_capture_callback(
  435. oauth_page,
  436. oauth_url,
  437. email_hint=email_hint,
  438. log=log,
  439. )
  440. finally:
  441. if close_oauth_page:
  442. try:
  443. oauth_page.close()
  444. except Exception as exc:
  445. log(f"[cpa-oauth] 关闭 OpenAI OAuth 页面异常: {exc!r}")
  446. status = submit_oauth_callback_to_cpa(
  447. page,
  448. cpa_url=cpa_url,
  449. callback_url=callback_url,
  450. management_key=management_key,
  451. log=log,
  452. )
  453. return {
  454. "fileName": "",
  455. "email": email_hint,
  456. "planType": "codex-oauth",
  457. "hasRefreshToken": True,
  458. "status": status,
  459. "callbackUrl": callback_url,
  460. "oauthUrl": oauth_url,
  461. }