cpa_oauth.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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. while time.time() < deadline:
  178. oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
  179. if oauth_url:
  180. return oauth_url
  181. url = str(getattr(page, "url", "") or "").lower()
  182. login_visible = _has_visible(page, CPA_MANAGEMENT_KEY_SELECTORS)
  183. if "#/oauth" in url and not login_visible:
  184. return ""
  185. if login_visible:
  186. _login_cpa_management_if_needed(page, management_key, log)
  187. _safe_page_wait(page, 800)
  188. continue
  189. if "#/oauth" not in url:
  190. log(f"[cpa-oauth] CPA 登录后未在 OAuth 路由,重新打开 {panel_url}")
  191. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  192. _safe_page_wait(page, 800)
  193. continue
  194. nav_clicked = False
  195. for selector in CPA_OAUTH_NAV_SELECTORS:
  196. if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-nav({selector})"):
  197. nav_clicked = True
  198. break
  199. _safe_page_wait(page, 800 if nav_clicked else 300)
  200. raise RuntimeError(f"CPA 管理登录后未进入 OAuth 面板 URL={page.url}")
  201. def fetch_cpa_oauth_url(
  202. page,
  203. *,
  204. cpa_url: str,
  205. management_key: str,
  206. log: Callable[[str], None] = print,
  207. timeout_sec: int = 45,
  208. ) -> str:
  209. panel_url = build_cpa_oauth_panel_url(cpa_url)
  210. log(f"[cpa-oauth] 打开 CPA OAuth 面板 {panel_url}")
  211. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  212. try:
  213. page.wait_for_timeout(1200)
  214. except Exception:
  215. pass
  216. oauth_url = _ensure_cpa_oauth_panel_ready(
  217. page,
  218. panel_url=panel_url,
  219. management_key=management_key,
  220. log=log,
  221. timeout_sec=timeout_sec,
  222. )
  223. if oauth_url:
  224. log(f"[cpa-oauth] CPA 面板已有 OAuth URL {oauth_url[:80]}...")
  225. return oauth_url
  226. clicked = False
  227. for selector in (
  228. 'button:has-text("登录")',
  229. 'button:has-text("Login")',
  230. 'button:has-text("OAuth")',
  231. ):
  232. if _try_click_first_visible(page, selector, log, label=f"cpa-oauth-login({selector})"):
  233. clicked = True
  234. break
  235. if not clicked:
  236. raise RuntimeError(f"CPA OAuth 面板未找到登录按钮 URL={page.url}")
  237. ok = _wait_until(
  238. lambda: bool(_read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)),
  239. timeout_sec,
  240. )
  241. if not ok:
  242. raise RuntimeError(f"点击 CPA OAuth 登录后未出现授权链接 URL={page.url}")
  243. oauth_url = _read_first_visible_text(page, CPA_OAUTH_URL_SELECTORS)
  244. log(f"[cpa-oauth] 获取 OAuth URL {oauth_url[:80]}...")
  245. return oauth_url
  246. def _click_openai_oauth_continue(page, log) -> bool:
  247. for selector in (
  248. 'button[type="submit"]',
  249. 'button:has-text("Continue")',
  250. 'button:has-text("Allow")',
  251. 'button:has-text("Authorize")',
  252. 'button:has-text("继续")',
  253. 'button:has-text("允许")',
  254. 'button:has-text("授权")',
  255. 'input[type="submit"]',
  256. ):
  257. if _try_click_first_visible(page, selector, log, label=f"openai-oauth-continue({selector})"):
  258. return True
  259. return False
  260. def _fill_openai_email_if_present(page, email_hint: str, log) -> bool:
  261. email = str(email_hint or "").strip()
  262. if not email:
  263. return False
  264. filled_selector = _fill_first_visible(page, OPENAI_EMAIL_SELECTORS, email)
  265. if not filled_selector:
  266. return False
  267. log(f"[cpa-oauth] 已填写 OpenAI OAuth 登录邮箱 selector={filled_selector}")
  268. if _click_openai_oauth_continue(page, log):
  269. _safe_page_wait(page, 1500)
  270. return True
  271. def _drive_openai_oauth_until_callback(page, email_hint: str, log, timeout_sec: int) -> str:
  272. deadline = time.time() + timeout_sec
  273. acted = False
  274. email_submitted = False
  275. while time.time() < deadline:
  276. callback_url = _find_localhost_callback_url(page, log)
  277. if callback_url:
  278. return callback_url
  279. if not email_submitted:
  280. email_submitted = _fill_openai_email_if_present(page, email_hint, log)
  281. callback_url = _find_localhost_callback_url(page, log)
  282. if callback_url:
  283. return callback_url
  284. if email_submitted:
  285. acted = True
  286. _safe_page_wait(page, 500)
  287. continue
  288. clicked = _click_openai_oauth_continue(page, log)
  289. acted = acted or clicked
  290. if clicked:
  291. _safe_page_wait(page, 1200)
  292. callback_url = _find_localhost_callback_url(page, log)
  293. if callback_url:
  294. return callback_url
  295. else:
  296. _safe_page_wait(page, 500)
  297. callback_url = _find_localhost_callback_url(page, log)
  298. if callback_url:
  299. return callback_url
  300. callback_url = _find_localhost_callback_url(page, log)
  301. if callback_url:
  302. return callback_url
  303. if email_submitted:
  304. acted = True
  305. if acted:
  306. raise RuntimeError(f"OAuth 授权后未捕获 localhost callback URL={page.url}")
  307. raise RuntimeError(f"OAuth 页面未找到可继续的登录或授权按钮 URL={page.url}")
  308. def approve_openai_oauth_and_capture_callback(
  309. page,
  310. oauth_url: str,
  311. *,
  312. email_hint: str = "",
  313. log: Callable[[str], None] = print,
  314. timeout_sec: int = 120,
  315. ) -> str:
  316. if not str(oauth_url or "").startswith(("http://", "https://")):
  317. raise RuntimeError(f"OAuth URL 无效: {oauth_url}")
  318. log(f"[cpa-oauth] 打开 OpenAI OAuth URL {oauth_url[:80]}...")
  319. try:
  320. page.goto(oauth_url, wait_until="domcontentloaded", timeout=60000)
  321. except Exception as exc:
  322. if not _find_localhost_callback_url(page, log):
  323. raise
  324. log(f"[cpa-oauth] localhost callback 导航报错但 URL 已捕获: {exc!r}")
  325. try:
  326. page.wait_for_timeout(1500)
  327. except Exception:
  328. pass
  329. callback_url = _find_localhost_callback_url(page, log)
  330. if callback_url:
  331. return callback_url
  332. callback_url = _drive_openai_oauth_until_callback(page, email_hint, log, timeout_sec)
  333. log(f"[cpa-oauth] 捕获 callback {callback_url[:80]}...")
  334. return callback_url
  335. def submit_oauth_callback_to_cpa(
  336. page,
  337. *,
  338. cpa_url: str,
  339. callback_url: str,
  340. management_key: str = "",
  341. log: Callable[[str], None] = print,
  342. wait_for_success: bool = True,
  343. timeout_sec: int = 120,
  344. ) -> str:
  345. if not is_localhost_oauth_callback_url(callback_url):
  346. raise RuntimeError("CPA OAuth callback URL 无效")
  347. panel_url = build_cpa_oauth_panel_url(cpa_url)
  348. log(f"[cpa-oauth] 回到 CPA 面板提交 callback {panel_url}")
  349. page.goto(panel_url, wait_until="domcontentloaded", timeout=60000)
  350. try:
  351. page.wait_for_timeout(1200)
  352. except Exception:
  353. pass
  354. _ensure_cpa_oauth_panel_ready(
  355. page,
  356. panel_url=panel_url,
  357. management_key=management_key,
  358. log=log,
  359. timeout_sec=45,
  360. )
  361. filled_selector = _fill_first_visible(
  362. page,
  363. (
  364. 'input[placeholder*="localhost"]',
  365. '[class*="callbackSection"] input.input',
  366. 'input.input',
  367. ),
  368. callback_url,
  369. )
  370. if not filled_selector:
  371. raise RuntimeError(f"CPA 面板未找到 callback 输入框 URL={page.url}")
  372. log(f"[cpa-oauth] 已填写 callback selector={filled_selector}")
  373. submitted = False
  374. for selector in (
  375. 'button:has-text("提交回调 URL")',
  376. 'button:has-text("Submit Callback URL")',
  377. 'button:has-text("Callback URL")',
  378. '[class*="callbackActions"] button',
  379. '[class*="callbackSection"] button',
  380. 'button.btn',
  381. ):
  382. if _try_click_first_visible(page, selector, log, label=f"cpa-callback-submit({selector})"):
  383. submitted = True
  384. break
  385. if not submitted:
  386. raise RuntimeError(f"CPA 面板未找到 callback 提交按钮 URL={page.url}")
  387. if not wait_for_success:
  388. return ""
  389. success_texts = ("认证成功", "Authentication successful", "Аутентификация успешна")
  390. failure_texts = ("认证失败", "回调 URL 提交失败", "oauth flow is not pending", "callback url submit failed")
  391. def _status_text() -> str:
  392. try:
  393. body = page.locator("body").first
  394. return body.inner_text(timeout=1000) or ""
  395. except Exception:
  396. return ""
  397. def _is_done() -> bool:
  398. text = _status_text()
  399. lowered = text.lower()
  400. if any(marker.lower() in lowered for marker in failure_texts):
  401. raise RuntimeError(f"CPA OAuth 回调提交失败: {text[:300]}")
  402. return any(marker.lower() in lowered for marker in success_texts)
  403. ok = _wait_until(_is_done, timeout_sec)
  404. if not ok:
  405. raise RuntimeError(f"等待 CPA OAuth 认证成功超时 URL={page.url}")
  406. log("[cpa-oauth] CPA OAuth 认证成功")
  407. return "Authentication successful!"
  408. def authorize_codex_oauth_to_cpa(
  409. page,
  410. *,
  411. cpa_url: str,
  412. management_key: str,
  413. email_hint: str = "",
  414. log: Callable[[str], None] = print,
  415. ) -> dict:
  416. oauth_url = fetch_cpa_oauth_url(page, cpa_url=cpa_url, management_key=management_key, log=log)
  417. oauth_page = page
  418. close_oauth_page = False
  419. try:
  420. context = getattr(page, "context", None)
  421. if context is not None and hasattr(context, "new_page"):
  422. oauth_page = context.new_page()
  423. close_oauth_page = True
  424. log("[cpa-oauth] 已打开独立 OpenAI OAuth 页面,CPA 面板页保持打开")
  425. except Exception as exc:
  426. log(f"[cpa-oauth] 打开独立 OAuth 页面失败,将复用当前页: {exc!r}")
  427. oauth_page = page
  428. close_oauth_page = False
  429. try:
  430. callback_url = approve_openai_oauth_and_capture_callback(
  431. oauth_page,
  432. oauth_url,
  433. email_hint=email_hint,
  434. log=log,
  435. )
  436. finally:
  437. if close_oauth_page:
  438. try:
  439. oauth_page.close()
  440. except Exception as exc:
  441. log(f"[cpa-oauth] 关闭 OpenAI OAuth 页面异常: {exc!r}")
  442. status = submit_oauth_callback_to_cpa(
  443. page,
  444. cpa_url=cpa_url,
  445. callback_url=callback_url,
  446. management_key=management_key,
  447. log=log,
  448. )
  449. return {
  450. "fileName": "",
  451. "email": email_hint,
  452. "planType": "codex-oauth",
  453. "hasRefreshToken": True,
  454. "status": status,
  455. "callbackUrl": callback_url,
  456. "oauthUrl": oauth_url,
  457. }