chatgpt_signup.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  1. """ChatGPT 注册流:用 Playwright 完成"打开 chatgpt.com → 输邮箱 → 填密码 → 邮箱验证码 → 姓名生日 → 拿 session"。
  2. 参考 /Users/chendeben/code/chrome_extension/codex-oauth-automation-extension/content/signup-page.js。
  3. """
  4. from __future__ import annotations
  5. import random
  6. import re
  7. import string
  8. import time
  9. from datetime import datetime
  10. from typing import Callable
  11. from mail_provider import build_a4sky_email, poll_signup_code
  12. SIGNUP_ENTRY_URL = "https://chatgpt.com/"
  13. SESSION_URL = "https://chatgpt.com/api/auth/session"
  14. EMAIL_INPUT_SELECTORS = [
  15. 'input[type="email"]',
  16. 'input[name="email"]',
  17. 'input[name="username"]',
  18. 'input[id*="email" i]',
  19. 'input[placeholder*="email" i]',
  20. ]
  21. PASSWORD_INPUT_SELECTORS = [
  22. 'input[type="password"]',
  23. 'input[name="password"]',
  24. 'input[id*="password" i]',
  25. ]
  26. CONTINUE_BUTTON_TEXTS = ("Continue", "Next", "Sign up", "Create account", "继续", "下一步", "注册", "创建")
  27. SIGNUP_TRIGGER_PATTERN = re.compile(
  28. r"(免费注册|立即注册|注册|sign\s*up|register|create\s*account)", re.I
  29. )
  30. PASSKEY_SKIP_TEXTS = (
  31. "Skip for now",
  32. "Not now",
  33. "Maybe later",
  34. "I'll do this later",
  35. "Do this later",
  36. "Set up later",
  37. "稍后",
  38. "暂不",
  39. "以后再说",
  40. )
  41. PASSKEY_SKIP_RE = re.compile(
  42. r"(skip\s+for\s+now|not\s+now|maybe\s+later|do\s+this\s+later|set\s+up\s+later|稍后|暂不|以后再说)",
  43. re.I,
  44. )
  45. def _rand_password(length: int = 16) -> str:
  46. pools = [
  47. random.choice(string.ascii_uppercase),
  48. random.choice(string.ascii_lowercase),
  49. random.choice(string.digits),
  50. random.choice("!@#$%^*"),
  51. ]
  52. pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^*") for _ in range(length - 4)]
  53. random.shuffle(pools)
  54. return "".join(pools)
  55. def _rand_name() -> tuple[str, str]:
  56. firsts = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
  57. "William", "Elizabeth", "David", "Susan", "Daniel", "Sarah", "Thomas", "Karen"]
  58. lasts = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
  59. "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson"]
  60. return random.choice(firsts), random.choice(lasts)
  61. def _rand_birthday() -> tuple[int, int, int]:
  62. """随机 1985~2000 年的生日,避开 28 号以后避免月份冲突。"""
  63. year = random.randint(1985, 2000)
  64. month = random.randint(1, 12)
  65. day = random.randint(1, 28)
  66. return year, month, day
  67. def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool:
  68. loc = page.locator(selector)
  69. count = loc.count()
  70. if count == 0:
  71. return False
  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"[signup] 点击 {label or selector} (idx={i}) 成功")
  78. return True
  79. except Exception as exc:
  80. log(f"[signup] 点击 {label or selector} (idx={i}) 失败: {exc!r}")
  81. return False
  82. def _click_signup_entry(page, log) -> bool:
  83. """ChatGPT 首页右上角"注册"按钮。命中即返回 True;命中后等待 ~1s 让导航开始。"""
  84. candidates = [
  85. 'a[data-testid="signup-button"]',
  86. 'button[data-testid="signup-button"]',
  87. 'a:has-text("Sign up")',
  88. 'button:has-text("Sign up")',
  89. 'a:has-text("注册")',
  90. 'button:has-text("注册")',
  91. ]
  92. for sel in candidates:
  93. if _try_click_first_visible(page, sel, log, label=f"signup entry({sel})"):
  94. try:
  95. page.wait_for_timeout(800)
  96. except Exception:
  97. pass
  98. return True
  99. # 文本兜底
  100. try:
  101. all_btns = page.locator('a, button, [role="button"], [role="link"]')
  102. n = all_btns.count()
  103. for i in range(min(n, 200)):
  104. el = all_btns.nth(i)
  105. try:
  106. txt = (el.inner_text(timeout=500) or "").strip()
  107. except Exception:
  108. continue
  109. if txt and SIGNUP_TRIGGER_PATTERN.search(txt) and el.is_visible() and el.is_enabled():
  110. el.click(timeout=3000)
  111. log(f"[signup] 点击文本注册入口 {txt!r}")
  112. return True
  113. except Exception as exc:
  114. log(f"[signup] 兜底查找注册入口异常: {exc!r}")
  115. return False
  116. def _find_visible(page, selectors: list[str]):
  117. for sel in selectors:
  118. loc = page.locator(sel)
  119. count = loc.count()
  120. for i in range(count):
  121. try:
  122. item = loc.nth(i)
  123. if item.is_visible():
  124. return item, sel
  125. except Exception:
  126. continue
  127. return None, None
  128. def _click_continue(page, log, *, label: str = "continue") -> bool:
  129. """通用:点 type=submit / 文本 Continue / Next 等。"""
  130. direct = page.locator('button[type="submit"], input[type="submit"]')
  131. cnt = direct.count()
  132. for i in range(cnt):
  133. try:
  134. it = direct.nth(i)
  135. if it.is_visible() and it.is_enabled():
  136. it.click(timeout=4000)
  137. log(f"[signup] {label} 点击 button[type=submit] (idx={i}) 成功")
  138. return True
  139. except Exception as exc:
  140. log(f"[signup] {label} button[type=submit] (idx={i}) 失败: {exc!r}")
  141. for txt in CONTINUE_BUTTON_TEXTS:
  142. loc = page.locator(f'button:has-text("{txt}")').first
  143. if loc.count() == 0:
  144. continue
  145. try:
  146. if loc.is_visible() and loc.is_enabled():
  147. loc.click(timeout=4000)
  148. log(f"[signup] {label} 点击 has-text={txt!r} 成功")
  149. return True
  150. except Exception as exc:
  151. log(f"[signup] {label} has-text={txt!r} 失败: {exc!r}")
  152. return False
  153. def _is_password_page(page) -> bool:
  154. try:
  155. loc = page.locator(", ".join(PASSWORD_INPUT_SELECTORS))
  156. if loc.count() == 0:
  157. return False
  158. for i in range(loc.count()):
  159. if loc.nth(i).is_visible():
  160. return True
  161. except Exception:
  162. pass
  163. return False
  164. def _is_email_verification_page(page) -> bool:
  165. """6 位验证码输入页。"""
  166. url = (page.url or "").lower()
  167. if "/email-verification" in url or "/verify" in url:
  168. return True
  169. try:
  170. sel = ('input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], '
  171. 'input[maxlength="6"], input[maxlength="1"]')
  172. loc = page.locator(sel)
  173. if loc.count() >= 1:
  174. return True
  175. except Exception:
  176. pass
  177. return False
  178. def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 250) -> bool:
  179. deadline = time.time() + timeout_sec
  180. while time.time() < deadline:
  181. try:
  182. if predicate():
  183. return True
  184. except Exception:
  185. pass
  186. time.sleep(interval_ms / 1000)
  187. return False
  188. def _is_passkey_enrollment_url(url: str) -> bool:
  189. return "create-account-enroll-passkey" in (url or "").lower()
  190. def _is_sso_redirect(page) -> bool:
  191. """提交邮箱后是否跳转到 SSO 页面(auth.openai.com/sso 或 Keycloak)。"""
  192. url = (page.url or "").lower()
  193. return "auth.openai.com/sso" in url or "sso.claudeai.life" in url
  194. def _handle_passkey_enrollment_if_present(page, log, timeout_sec: int = 25) -> bool:
  195. """OpenAI 新账号可能进入 passkey 引导页;注册自动化选择跳过该可选步骤。"""
  196. if not _is_passkey_enrollment_url(page.url or ""):
  197. return False
  198. log(f"[signup] 检测到 passkey 引导页 url={page.url},尝试跳过")
  199. clicked = False
  200. for txt in PASSKEY_SKIP_TEXTS:
  201. for sel in (
  202. f'button:has-text("{txt}")',
  203. f'a:has-text("{txt}")',
  204. f'[role="button"]:has-text("{txt}")',
  205. ):
  206. if _try_click_first_visible(page, sel, log, label=f"passkey-skip({txt})"):
  207. clicked = True
  208. break
  209. if clicked:
  210. break
  211. if not clicked:
  212. try:
  213. candidates = page.locator('button, a, [role="button"], [role="link"]')
  214. n = candidates.count()
  215. for i in range(min(n, 120)):
  216. el = candidates.nth(i)
  217. try:
  218. txt = (el.inner_text(timeout=400) or "").strip()
  219. aria = el.get_attribute("aria-label") or ""
  220. blob = f"{txt} {aria}"
  221. if PASSKEY_SKIP_RE.search(blob) and el.is_visible() and el.is_enabled():
  222. el.click(timeout=4000)
  223. log(f"[signup] 点击 passkey 跳过候选 {blob!r}")
  224. clicked = True
  225. break
  226. except Exception as exc:
  227. log(f"[signup] passkey 候选 idx={i} 点击失败: {exc!r}")
  228. except Exception as exc:
  229. log(f"[signup] passkey 跳过按钮扫描异常: {exc!r}")
  230. if not clicked:
  231. log("[signup] 未找到 passkey 跳过按钮,保留当前页继续等待")
  232. deadline = time.time() + timeout_sec
  233. last_url = page.url or ""
  234. while time.time() < deadline:
  235. cur = page.url or ""
  236. if cur != last_url:
  237. log(f"[signup] passkey 页跳转 {last_url} -> {cur}")
  238. last_url = cur
  239. if not _is_passkey_enrollment_url(cur):
  240. log(f"[signup] 已离开 passkey 引导页 url={cur}")
  241. return True
  242. time.sleep(0.5)
  243. log(f"[signup] 警告:passkey 引导页 {timeout_sec}s 内未离开 url={page.url}")
  244. return False
  245. def _fill_signup_email(page, email: str, log):
  246. log(f"[signup] === 提交注册邮箱 {email} ===")
  247. # 邮箱页可能由前一步导航触发,需要等一会让 input 挂载
  248. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  249. if not inp:
  250. # 等最多 8s:要么邮箱框出现,要么 URL 跳到 auth.openai.com 后再继续等
  251. deadline = time.time() + 8
  252. while time.time() < deadline:
  253. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  254. if inp:
  255. break
  256. time.sleep(0.4)
  257. if not inp:
  258. # 仍找不到 — 只有当确实还停在 chatgpt.com 主页时才补点注册入口(避免点已跳转的页面把表单关掉)
  259. cur_url = (page.url or "").lower()
  260. if "chatgpt.com" in cur_url and "auth" not in cur_url and "/email-verification" not in cur_url:
  261. log(f"[signup] 邮箱框未挂载且仍在 chatgpt.com 主页,补点一次注册入口 url={cur_url}")
  262. if _click_signup_entry(page, log):
  263. page.wait_for_load_state("domcontentloaded", timeout=20000)
  264. page.wait_for_timeout(1500)
  265. # 再等一会
  266. deadline = time.time() + 8
  267. while time.time() < deadline:
  268. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  269. if inp:
  270. break
  271. time.sleep(0.4)
  272. else:
  273. log(f"[signup] 已离开主页(url={cur_url}),不再点注册入口,仅继续等邮箱框")
  274. deadline = time.time() + 8
  275. while time.time() < deadline:
  276. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  277. if inp:
  278. break
  279. time.sleep(0.4)
  280. if not inp:
  281. raise RuntimeError(f"未找到邮箱输入框 URL={page.url}")
  282. log(f"[signup] 命中邮箱输入框 selector={used_sel}")
  283. inp.click()
  284. inp.fill("")
  285. inp.type(email, delay=20)
  286. page.wait_for_timeout(300)
  287. if not _click_continue(page, log, label="email-continue"):
  288. raise RuntimeError("未找到邮箱页的继续按钮")
  289. # 等待跳到密码页 / 验证码页 / SSO 页
  290. ok = _wait_until(
  291. lambda: _is_password_page(page) or _is_email_verification_page(page) or _is_sso_redirect(page),
  292. 25,
  293. )
  294. if not ok:
  295. raise RuntimeError(f"提交邮箱后未进入密码/验证码/SSO页 URL={page.url}")
  296. log(f"[signup] 邮箱已提交 URL={page.url} is_password_page={_is_password_page(page)}")
  297. def _fill_password(page, password: str, log):
  298. log("[signup] === 填密码 ===")
  299. inp, used_sel = _find_visible(page, PASSWORD_INPUT_SELECTORS)
  300. if not inp:
  301. raise RuntimeError(f"未找到密码输入框 URL={page.url}")
  302. log(f"[signup] 命中密码输入框 selector={used_sel}")
  303. inp.click()
  304. inp.fill("")
  305. inp.type(password, delay=20)
  306. page.wait_for_timeout(300)
  307. submitted_at_ms = int(time.time() * 1000)
  308. if not _click_continue(page, log, label="password-continue"):
  309. raise RuntimeError("未找到密码页的继续按钮")
  310. return submitted_at_ms
  311. def _fill_verification_code(page, code: str, log):
  312. log(f"[signup] === 填入验证码 {code} ===")
  313. # 优先 6 位拆分输入框
  314. split = page.locator('input[maxlength="1"]')
  315. n_split = split.count()
  316. if n_split >= 6:
  317. log(f"[signup] 检测到拆分输入框 count={n_split}")
  318. try:
  319. split.nth(0).click()
  320. except Exception:
  321. pass
  322. for idx, ch in enumerate(code[:n_split]):
  323. try:
  324. box = split.nth(idx)
  325. box.fill("")
  326. box.type(ch, delay=30)
  327. except Exception as exc:
  328. log(f"[signup] 拆分位 {idx} 输入失败: {exc!r}")
  329. return
  330. sel = 'input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], input[maxlength="6"]'
  331. loc = page.locator(sel).first
  332. if loc.count() == 0:
  333. raise RuntimeError("未找到验证码输入框")
  334. loc.fill("")
  335. loc.type(code, delay=30)
  336. log("[signup] 已填入单格验证码")
  337. page.wait_for_timeout(300)
  338. _click_continue(page, log, label="code-continue")
  339. def _wait_profile_page_ready(page, log, timeout_sec: int = 30) -> dict:
  340. """等待 profile 页可见控件出现,并返回它用的是哪种 UI。"""
  341. deadline = time.time() + timeout_sec
  342. while time.time() < deadline:
  343. try:
  344. info = page.evaluate(
  345. r"""() => {
  346. const visible = (el) => {
  347. if (!el) return false;
  348. const s = window.getComputedStyle(el);
  349. if (s.display === 'none' || s.visibility === 'hidden') return false;
  350. const r = el.getBoundingClientRect();
  351. return r.width > 0 && r.height > 0;
  352. };
  353. const name = document.querySelector('input[name="name"], input[autocomplete="name"], input[placeholder*="全名"]');
  354. const age = document.querySelector('input[name="age"]');
  355. const yearSpin = document.querySelector('[role="spinbutton"][data-type="year"]');
  356. const monthSpin = document.querySelector('[role="spinbutton"][data-type="month"]');
  357. const daySpin = document.querySelector('[role="spinbutton"][data-type="day"]');
  358. const hiddenBday = document.querySelector('input[name="birthday"]');
  359. // React Aria 下拉:button + listbox 隐藏 select
  360. const allButtons = Array.from(document.querySelectorAll('button[aria-haspopup="listbox"], [role="combobox"]'));
  361. const matchByLabel = (kw) => allButtons.find((b) => {
  362. const t = (b.innerText || b.getAttribute('aria-label') || '').trim();
  363. return t && new RegExp(kw, 'i').test(t);
  364. }) || null;
  365. const yearBtn = matchByLabel('year|年');
  366. const monthBtn = matchByLabel('month|月');
  367. const dayBtn = matchByLabel('day|天|日');
  368. return {
  369. url: location.href,
  370. nameVisible: visible(name),
  371. ageVisible: visible(age),
  372. spinVisible: visible(yearSpin) && visible(monthSpin) && visible(daySpin),
  373. selectVisible: visible(yearBtn) && visible(monthBtn) && visible(dayBtn),
  374. hasHiddenBday: Boolean(hiddenBday),
  375. bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 240),
  376. };
  377. }"""
  378. ) or {}
  379. except Exception as exc:
  380. log(f"[signup] profile 页探测异常: {exc!r}")
  381. info = {}
  382. kind = "unknown"
  383. if info.get("ageVisible"):
  384. kind = "age"
  385. elif info.get("selectVisible"):
  386. kind = "select"
  387. elif info.get("spinVisible"):
  388. kind = "spin"
  389. if info.get("nameVisible") and kind != "unknown":
  390. log(f"[signup] profile 页就绪 url={info.get('url')} mode={kind} hidden_bday={info.get('hasHiddenBday')}")
  391. return {"kind": kind, **info}
  392. time.sleep(0.3)
  393. log(f"[signup] profile 页等待超时 url={page.url}")
  394. return {"kind": "unknown", "url": page.url}
  395. def _fill_name_and_birthday(page, first: str, last: str, year: int, month: int, day: int, log):
  396. log(f"[signup] === 姓名/生日 {first} {last} {year}-{month:02d}-{day:02d} ===")
  397. profile_info = _wait_profile_page_ready(page, log, timeout_sec=30)
  398. if profile_info["kind"] == "unknown":
  399. raise RuntimeError(f"profile 页未识别可见控件 url={page.url}")
  400. full_name = f"{first} {last}"
  401. name_input, name_sel = _find_visible(page, [
  402. 'input[name="name"]',
  403. 'input[autocomplete="name"]',
  404. 'input[placeholder*="全名"]',
  405. ])
  406. if not name_input:
  407. raise RuntimeError("未找到姓名输入框")
  408. log(f"[signup] 命中姓名输入框 selector={name_sel}")
  409. name_input.click()
  410. name_input.fill("")
  411. name_input.type(full_name, delay=20)
  412. log(f"[signup] 姓名已填写: {full_name}")
  413. page.wait_for_timeout(400)
  414. kind = profile_info["kind"]
  415. bday_value = f"{year:04d}-{month:02d}-{day:02d}"
  416. if kind == "age":
  417. age = max(18, datetime.now().year - year)
  418. # React Aria 用 <label> 浮在 input 上面拦截了 click,所以走 focus+JS 赋值
  419. try:
  420. ok = page.evaluate(
  421. r"""(ageStr) => {
  422. const el = document.querySelector('input[name="age"]');
  423. if (!el) return false;
  424. el.focus();
  425. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  426. setter.call(el, '');
  427. el.dispatchEvent(new Event('input', { bubbles: true }));
  428. setter.call(el, String(ageStr));
  429. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: String(ageStr), bubbles: true }));
  430. el.dispatchEvent(new Event('input', { bubbles: true }));
  431. el.dispatchEvent(new Event('change', { bubbles: true }));
  432. el.blur();
  433. return el.value;
  434. }""",
  435. str(age),
  436. )
  437. log(f"[signup] age 已通过 JS 设置 value={ok!r}")
  438. except Exception as exc:
  439. log(f"[signup] JS 设 age 失败: {exc!r},回退键盘输入")
  440. try:
  441. # 用 page.keyboard:先 focus 后 type
  442. page.evaluate("() => document.querySelector('input[name=\"age\"]').focus()")
  443. page.keyboard.type(str(age), delay=30)
  444. log(f"[signup] 键盘输入 age={age} 成功")
  445. except Exception as exc2:
  446. raise RuntimeError(f"填年龄失败(JS+键盘均失败): {exc!r} / {exc2!r}")
  447. # 校验
  448. try:
  449. real = page.evaluate("() => document.querySelector('input[name=\"age\"]').value")
  450. log(f"[signup] age input 实际 value={real!r} (期望 {age})")
  451. except Exception:
  452. pass
  453. elif kind == "spin":
  454. log("[signup] 使用 spinbutton 三段式生日")
  455. for kw, val in (("year", year), ("month", f"{month:02d}"), ("day", f"{day:02d}")):
  456. try:
  457. ok = page.evaluate(
  458. r"""([sel, valStr]) => {
  459. const el = document.querySelector(sel);
  460. if (!el) return false;
  461. el.focus();
  462. document.execCommand('selectAll', false, null);
  463. for (const ch of String(valStr)) {
  464. el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: 'Digit'+ch, bubbles: true }));
  465. el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, code: 'Digit'+ch, bubbles: true }));
  466. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true }));
  467. el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
  468. }
  469. el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
  470. el.blur();
  471. return true;
  472. }""",
  473. [f'[role="spinbutton"][data-type="{kw}"]', str(val)],
  474. )
  475. log(f"[signup] spin {kw} <- {val} ok={ok}")
  476. except Exception as exc:
  477. log(f"[signup] spin {kw} 失败: {exc!r}")
  478. # spinbutton 模式有时也会同步 hidden birthday;保险起见显式设置
  479. try:
  480. page.evaluate(
  481. r"""([sel, val]) => {
  482. const el = document.querySelector(sel);
  483. if (!el) return false;
  484. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  485. setter.call(el, val);
  486. el.dispatchEvent(new Event('input', { bubbles: true }));
  487. el.dispatchEvent(new Event('change', { bubbles: true }));
  488. return true;
  489. }""",
  490. ['input[name="birthday"]', bday_value],
  491. )
  492. except Exception:
  493. pass
  494. elif kind == "select":
  495. log("[signup] 使用 React Aria 下拉式生日")
  496. # year/month/day 各自是 button[aria-haspopup=listbox],点开后选 option
  497. # 用 inner_text 含关键字定位三个 button;click 失败则回退 dispatchEvent
  498. def click_select_option(label_kw_re: str, option_value: str | int) -> bool:
  499. buttons = page.locator('button[aria-haspopup="listbox"], [role="combobox"]')
  500. n = buttons.count()
  501. target_idx = -1
  502. for i in range(min(n, 80)):
  503. b = buttons.nth(i)
  504. try:
  505. if not b.is_visible():
  506. continue
  507. txt = (b.inner_text(timeout=400) or "").strip()
  508. aria = b.get_attribute("aria-label") or ""
  509. blob = f"{txt} {aria}"
  510. except Exception:
  511. continue
  512. if re.search(label_kw_re, blob, re.I):
  513. target_idx = i
  514. break
  515. if target_idx < 0:
  516. log(f"[signup] 未找到下拉按钮 kw={label_kw_re}")
  517. return False
  518. target = buttons.nth(target_idx)
  519. try:
  520. target.click(timeout=3000)
  521. except Exception:
  522. # label/positioner 拦截:force click + JS click 双兜底
  523. try:
  524. target.click(timeout=3000, force=True)
  525. except Exception:
  526. try:
  527. target.evaluate("el => el.click()")
  528. except Exception as exc:
  529. log(f"[signup] 三种 click 都失败 kw={label_kw_re}: {exc!r}")
  530. return False
  531. page.wait_for_timeout(350)
  532. opt = page.locator(f'[role="option"]:has-text("{option_value}")').first
  533. if opt.count() == 0:
  534. opt = page.locator(f'[role="option"]:has-text("{int(option_value)}")').first
  535. if opt.count() == 0:
  536. log(f"[signup] 未找到 option={option_value}")
  537. return False
  538. try:
  539. opt.click(timeout=3000)
  540. except Exception:
  541. try:
  542. opt.click(timeout=3000, force=True)
  543. except Exception:
  544. try:
  545. opt.evaluate("el => el.click()")
  546. except Exception as exc:
  547. log(f"[signup] 选 option 三种 click 都失败: {exc!r}")
  548. return False
  549. log(f"[signup] 下拉 kw={label_kw_re} 已选 {option_value}")
  550. return True
  551. click_select_option(r"year|年", year)
  552. page.wait_for_timeout(300)
  553. click_select_option(r"month|月", month)
  554. page.wait_for_timeout(300)
  555. click_select_option(r"day|天|日", day)
  556. page.wait_for_timeout(300)
  557. # 验证 hidden birthday(如果存在)确实被写入
  558. try:
  559. hidden_val = page.evaluate(
  560. r"""() => {
  561. const el = document.querySelector('input[name="birthday"]');
  562. return el ? el.value || '' : '__no_hidden__';
  563. }"""
  564. )
  565. log(f"[signup] hidden birthday 当前值={hidden_val!r} (期望 {bday_value})")
  566. except Exception:
  567. pass
  568. # 同意复选框(如出现)
  569. try:
  570. page.evaluate(
  571. r"""() => {
  572. const cbs = document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"], input[type="checkbox"]');
  573. let n = 0;
  574. cbs.forEach((cb) => {
  575. const lbl = cb.closest('label');
  576. if (cb.checked) return;
  577. const txt = (lbl?.textContent || cb.getAttribute('aria-label') || '').replace(/\s+/g, ' ');
  578. if (/agree|同意|i\s+agree/i.test(txt) || cb.name === 'allCheckboxes') {
  579. try { (lbl || cb).click(); n++; } catch (_) {}
  580. }
  581. });
  582. return n;
  583. }"""
  584. )
  585. except Exception as exc:
  586. log(f"[signup] 勾选同意复选框异常: {exc!r}")
  587. page.wait_for_timeout(600)
  588. # 提交"完成帐户创建"
  589. submit = page.locator('button[type="submit"]').first
  590. if submit.count() == 0:
  591. log("[signup] 未找到 button[type=submit],尝试文本兜底")
  592. for txt in ("完成", "Create account", "Continue", "Finish", "Done", "Agree"):
  593. cand = page.locator(f'button:has-text("{txt}")').first
  594. if cand.count() > 0:
  595. submit = cand
  596. break
  597. if submit.count() == 0:
  598. raise RuntimeError("profile 页未找到提交按钮")
  599. try:
  600. submit.scroll_into_view_if_needed(timeout=2000)
  601. except Exception:
  602. pass
  603. try:
  604. submit.click(timeout=5000)
  605. log("[signup] 已点击完成帐户创建按钮")
  606. except Exception as exc:
  607. log(f"[signup] click submit 失败: {exc!r},回退 force click")
  608. submit.click(timeout=5000, force=True)
  609. # 等待页面真正离开 profile 页
  610. deadline = time.time() + 25
  611. last_url = page.url or ""
  612. while time.time() < deadline:
  613. try:
  614. cur = page.url or ""
  615. still = page.locator('input[name="name"]').count() > 0
  616. if cur != last_url:
  617. log(f"[signup] profile 页跳转 {last_url} -> {cur}")
  618. last_url = cur
  619. if not still and "chatgpt.com" in cur:
  620. log(f"[signup] profile 页已离开,进入 {cur}")
  621. return
  622. if "chatgpt.com" in cur and "/auth" not in cur:
  623. # 已经回到 chatgpt.com 主域
  624. log(f"[signup] 已回到 chatgpt.com: {cur}")
  625. return
  626. except Exception:
  627. pass
  628. time.sleep(0.5)
  629. log(f"[signup] 警告:profile 提交后 25s 未确认离开,url={page.url}")
  630. def _fetch_session(page, log) -> dict:
  631. """读取 chatgpt.com/api/auth/session。"""
  632. log(f"[signup] === 拉取 session: {SESSION_URL} ===")
  633. # 确保 cookies 已写入;先回到 chatgpt.com 主域
  634. if "chatgpt.com" not in (page.url or ""):
  635. try:
  636. page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
  637. page.wait_for_timeout(2000)
  638. except Exception as exc:
  639. log(f"[signup] 回 chatgpt.com 异常: {exc!r}")
  640. deadline = time.time() + 60
  641. last_text = ""
  642. while time.time() < deadline:
  643. try:
  644. resp = page.evaluate(
  645. """async () => {
  646. try {
  647. const r = await fetch('/api/auth/session', { credentials: 'include' });
  648. const text = await r.text();
  649. return { status: r.status, text };
  650. } catch (e) {
  651. return { status: 0, text: '', error: String(e) };
  652. }
  653. }"""
  654. )
  655. text = (resp or {}).get("text") or ""
  656. status = (resp or {}).get("status") or 0
  657. if text and text != last_text:
  658. last_text = text
  659. log(f"[signup] session HTTP {status} 长度 {len(text)} 预览 {text[:200]}")
  660. if status == 200 and text:
  661. import json
  662. try:
  663. data = json.loads(text)
  664. except Exception:
  665. data = None
  666. if isinstance(data, dict) and data.get("accessToken"):
  667. return data
  668. except Exception as exc:
  669. log(f"[signup] fetch session 异常: {exc!r}")
  670. time.sleep(2)
  671. raise TimeoutError("拉取 session 超时(未取到 accessToken)")
  672. def signup_chatgpt(
  673. page,
  674. *,
  675. helper_url: str,
  676. mail_domain: str = "edu.a4sky.com",
  677. mail_poll_interval_sec: int = 4,
  678. mail_poll_max_attempts: int = 60,
  679. log: Callable[[str], None] = print,
  680. on_stage: Callable[[str], None] | None = None,
  681. ) -> dict:
  682. """跑完整注册流,返回 { email, password, session }。"""
  683. def stage(name: str):
  684. log(f"[stage] {name}")
  685. if on_stage:
  686. try:
  687. on_stage(name)
  688. except Exception:
  689. pass
  690. stage("打开 chatgpt.com")
  691. page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
  692. page.wait_for_timeout(2000)
  693. email = build_a4sky_email(mail_domain)
  694. password = _rand_password()
  695. first, last = _rand_name()
  696. year, month, day = _rand_birthday()
  697. log(f"[signup] 生成 email={email} password={password} name={first} {last} bday={year}-{month:02d}-{day:02d}")
  698. # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
  699. inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
  700. if not inp and not _is_password_page(page):
  701. stage("点击注册入口")
  702. _click_signup_entry(page, log)
  703. page.wait_for_load_state("domcontentloaded", timeout=20000)
  704. page.wait_for_timeout(1500)
  705. stage("填写邮箱")
  706. if not _is_password_page(page):
  707. _fill_signup_email(page, email, log)
  708. if _is_password_page(page):
  709. stage("填写密码")
  710. # 邮件助手仅看邮件接收时间,过滤起点用"现在"-30s 比较稳
  711. code_started_ms = int(time.time() * 1000) - 30 * 1000
  712. _fill_password(page, password, log)
  713. else:
  714. code_started_ms = int(time.time() * 1000) - 30 * 1000
  715. # 等待进入验证码页
  716. stage("等待验证码页")
  717. if not _wait_until(lambda: _is_email_verification_page(page), 30):
  718. log(f"[signup] 警告:未确认进入验证码页 URL={page.url},仍尝试拉验证码")
  719. stage("轮询邮箱验证码")
  720. code = poll_signup_code(
  721. helper_url,
  722. email,
  723. started_at_ms=code_started_ms,
  724. interval_sec=mail_poll_interval_sec,
  725. max_attempts=mail_poll_max_attempts,
  726. log=log,
  727. )
  728. stage("填入验证码")
  729. _fill_verification_code(page, code, log)
  730. page.wait_for_timeout(2500)
  731. stage("填姓名/生日")
  732. _fill_name_and_birthday(page, first, last, year, month, day, log)
  733. page.wait_for_timeout(2500)
  734. stage("处理 passkey 引导")
  735. _handle_passkey_enrollment_if_present(page, log)
  736. stage("回 chatgpt.com 拉 session")
  737. session = _fetch_session(page, log)
  738. log(f"[signup] 注册完成 email={email} accessToken={(session.get('accessToken') or '')[:24]}... planType={(session.get('account') or {}).get('planType')}")
  739. return {"email": email, "password": password, "session": session}
  740. def fetch_current_session(page, log: Callable[[str], None] = print) -> dict:
  741. return _fetch_session(page, log)
  742. # ---------------------------------------------------------------------------
  743. # SSO 注册流(aef.claudeai.life 域名 → Keycloak SSO → 注册 → 自动登录)
  744. # ---------------------------------------------------------------------------
  745. SSO_MAIL_DOMAIN = "aef.claudeai.life"
  746. SSO_KEYCLOAK_HOST = "sso.claudeai.life"
  747. SSO_INTERSTITIAL_HOST = "external.auth.openai.com"
  748. def _build_sso_email(domain: str = SSO_MAIL_DOMAIN) -> str:
  749. ts = datetime.now().strftime("%Y%m%d%H%M%S")
  750. return f"n{ts}@{domain}"
  751. def _is_sso_redirect_page(page) -> bool:
  752. url = (page.url or "").lower()
  753. return SSO_KEYCLOAK_HOST in url or "auth.openai.com/sso" in url
  754. def _is_keycloak_login_page(page) -> bool:
  755. url = (page.url or "").lower()
  756. return SSO_KEYCLOAK_HOST in url and "login-actions" in url
  757. def _is_keycloak_register_page(page) -> bool:
  758. url = (page.url or "").lower()
  759. return SSO_KEYCLOAK_HOST in url and "registration" in url
  760. def _is_interstitial_page(page) -> bool:
  761. url = (page.url or "").lower()
  762. return SSO_INTERSTITIAL_HOST in url and ("interstitial" in url or "signin-consent" in url)
  763. def _wait_for_sso_or_keycloak(page, log, timeout_sec: int = 30) -> bool:
  764. """等待页面跳转到 SSO/Keycloak 登录页。"""
  765. return _wait_until(
  766. lambda: _is_sso_redirect_page(page) or _is_keycloak_login_page(page),
  767. timeout_sec,
  768. )
  769. def _click_sso_workspace_option(page, log, timeout_sec: int = 15) -> bool:
  770. """在 auth.openai.com/sso 选择 Workspace 入口,进入 Keycloak。"""
  771. deadline = time.time() + timeout_sec
  772. while time.time() < deadline:
  773. for sel in (
  774. 'button:has-text("Workspace")',
  775. '[role="button"]:has-text("Workspace")',
  776. 'a:has-text("Workspace")',
  777. 'button:has-text("工作区")',
  778. '[role="button"]:has-text("工作区")',
  779. 'a:has-text("工作区")',
  780. ):
  781. if _try_click_first_visible(page, sel, log, label=f"sso-workspace({sel})"):
  782. page.wait_for_timeout(1000)
  783. return True
  784. try:
  785. candidates = page.locator('button, a, [role="button"], [role="link"]')
  786. count = candidates.count()
  787. for i in range(min(count, 100)):
  788. el = candidates.nth(i)
  789. try:
  790. text = (el.inner_text(timeout=500) or "").strip()
  791. lowered = text.lower()
  792. if not text:
  793. continue
  794. if any(skip in lowered for skip in ("google", "microsoft", "apple", "password")):
  795. continue
  796. if ("workspace" in lowered or "single sign-on" in lowered or "sso" in lowered) and el.is_visible() and el.is_enabled():
  797. el.click(timeout=3000)
  798. log(f"[sso] 点击 Workspace 入口 text={text!r}")
  799. page.wait_for_timeout(1000)
  800. return True
  801. except Exception:
  802. continue
  803. except Exception as exc:
  804. log(f"[sso] 兜底查找 Workspace 入口异常: {exc!r}")
  805. time.sleep(0.5)
  806. return False
  807. def _click_keycloak_register(page, log, timeout_sec: int = 15) -> bool:
  808. """在 Keycloak 登录页点击 Register 链接。"""
  809. deadline = time.time() + timeout_sec
  810. while time.time() < deadline:
  811. for sel in (
  812. 'a:has-text("Register")',
  813. 'a:has-text("register")',
  814. 'a:has-text("注册")',
  815. 'a[href*="registration"]',
  816. 'a.register-link',
  817. '#kc-registration a',
  818. '#kc-registration-container a',
  819. ):
  820. if _try_click_first_visible(page, sel, log, label=f"keycloak-register({sel})"):
  821. page.wait_for_timeout(800)
  822. return True
  823. # 文本兜底
  824. try:
  825. links = page.locator("a")
  826. n = links.count()
  827. for i in range(min(n, 100)):
  828. el = links.nth(i)
  829. try:
  830. txt = (el.inner_text(timeout=400) or "").strip().lower()
  831. if txt and ("register" in txt or "注册" in txt) and el.is_visible():
  832. el.click(timeout=3000)
  833. log(f"[sso] 点击 Register 链接 text={txt!r}")
  834. page.wait_for_timeout(800)
  835. return True
  836. except Exception:
  837. continue
  838. except Exception as exc:
  839. log(f"[sso] 兜底查找 Register 链接异常: {exc!r}")
  840. time.sleep(0.5)
  841. return False
  842. def _fill_keycloak_registration(page, email: str, first: str, last: str, log):
  843. """填写 Keycloak 注册表单:firstName, lastName, email, password, password-confirm。"""
  844. log(f"[sso] === 填 Keycloak 注册表单 email={email} name={first} {last} ===")
  845. field_map = [
  846. ("firstName", first, [
  847. 'input[name="firstName"]',
  848. 'input#firstName',
  849. 'input[id*="firstName" i]',
  850. ]),
  851. ("lastName", last, [
  852. 'input[name="lastName"]',
  853. 'input#lastName',
  854. 'input[id*="lastName" i]',
  855. ]),
  856. ("email", email, [
  857. 'input[name="email"]',
  858. 'input#email',
  859. 'input[type="email"]',
  860. 'input[id*="email" i]',
  861. ]),
  862. ("password", email, [
  863. 'input[name="password"]',
  864. 'input#password',
  865. 'input[type="password"]:nth-of-type(1)',
  866. ]),
  867. ("password-confirm", email, [
  868. 'input[name="password-confirm"]',
  869. 'input#password-confirm',
  870. ]),
  871. ]
  872. for field_name, value, selectors in field_map:
  873. inp, used_sel = _find_visible(page, selectors)
  874. if not inp:
  875. deadline = time.time() + 8
  876. while time.time() < deadline:
  877. inp, used_sel = _find_visible(page, selectors)
  878. if inp:
  879. break
  880. time.sleep(0.4)
  881. if not inp:
  882. if field_name in ("password-confirm",):
  883. log(f"[sso] 字段 {field_name} 未找到,跳过(可能不存在)")
  884. continue
  885. raise RuntimeError(f"Keycloak 注册表单未找到 {field_name} 输入框 URL={page.url}")
  886. log(f"[sso] 命中 {field_name} 输入框 selector={used_sel}")
  887. inp.click()
  888. inp.fill("")
  889. inp.type(value, delay=20)
  890. page.wait_for_timeout(200)
  891. page.wait_for_timeout(500)
  892. # 提交注册
  893. submitted = False
  894. for sel in (
  895. 'input[type="submit"]',
  896. 'button[type="submit"]',
  897. 'input[value*="Register" i]',
  898. 'input[value*="注册"]',
  899. 'button:has-text("Register")',
  900. 'button:has-text("注册")',
  901. ):
  902. if _try_click_first_visible(page, sel, log, label=f"keycloak-submit({sel})"):
  903. submitted = True
  904. break
  905. if not submitted:
  906. raise RuntimeError(f"Keycloak 注册表单未找到提交按钮 URL={page.url}")
  907. log("[sso] 已提交 Keycloak 注册表单")
  908. def _handle_interstitial_confirm(page, log, timeout_sec: int = 30) -> bool:
  909. """处理 external.auth.openai.com 的 SSO 批准页面。"""
  910. log(f"[sso] 等待 SSO 批准页 url={page.url}")
  911. if not _wait_until(lambda: _is_interstitial_page(page), timeout_sec):
  912. log(f"[sso] 未检测到 SSO 批准页 url={page.url},继续")
  913. return False
  914. log(f"[sso] 检测到 SSO 批准页 url={page.url}")
  915. page.wait_for_timeout(1500)
  916. # 点击 Approve sign-in / Confirm / Continue / 确认
  917. for sel in (
  918. 'button[type="submit"]',
  919. 'input[type="submit"]',
  920. 'button:has-text("Approve sign-in")',
  921. 'button:has-text("Approve")',
  922. 'button:has-text("Confirm")',
  923. 'button:has-text("Continue")',
  924. 'button:has-text("批准")',
  925. 'button:has-text("确认")',
  926. 'button:has-text("继续")',
  927. 'input[value*="Approve" i]',
  928. 'input[value*="Confirm" i]',
  929. 'input[value*="Continue" i]',
  930. ):
  931. if _try_click_first_visible(page, sel, log, label=f"interstitial-confirm({sel})"):
  932. log("[sso] 已点击 interstitial 批准按钮")
  933. return True
  934. # 如果页面有隐藏的自动提交 form,尝试 JS 提交
  935. try:
  936. auto_submitted = page.evaluate(r"""() => {
  937. const forms = document.querySelectorAll('form');
  938. for (const f of forms) {
  939. if (f.querySelector('input[name="interstitial_token"]')) {
  940. f.submit();
  941. return true;
  942. }
  943. }
  944. return false;
  945. }""")
  946. if auto_submitted:
  947. log("[sso] 已通过 JS 自动提交 interstitial form")
  948. return True
  949. except Exception as exc:
  950. log(f"[sso] JS 自动提交 interstitial 异常: {exc!r}")
  951. log(f"[sso] 警告:interstitial 页面未找到批准按钮 url={page.url}")
  952. return False
  953. def signup_chatgpt_sso(
  954. page,
  955. *,
  956. sso_mail_domain: str = SSO_MAIL_DOMAIN,
  957. log: Callable[[str], None] = print,
  958. on_stage: Callable[[str], None] | None = None,
  959. ) -> dict:
  960. """SSO 注册流:chatgpt.com → 输入 SSO 邮箱 → Keycloak 注册 → 批准。
  961. 返回 { email, password, session }。
  962. """
  963. def stage(name: str):
  964. log(f"[stage] {name}")
  965. if on_stage:
  966. try:
  967. on_stage(name)
  968. except Exception:
  969. pass
  970. stage("打开 chatgpt.com")
  971. page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
  972. page.wait_for_timeout(2000)
  973. email = _build_sso_email(sso_mail_domain)
  974. first, last = _rand_name()
  975. log(f"[sso] 生成 email={email} name={first} {last}")
  976. # 入口:点击注册
  977. inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
  978. if not inp and not _is_password_page(page):
  979. stage("点击注册入口")
  980. _click_signup_entry(page, log)
  981. page.wait_for_load_state("domcontentloaded", timeout=20000)
  982. page.wait_for_timeout(1500)
  983. # 填写 SSO 邮箱并提交
  984. stage("填写 SSO 邮箱")
  985. _fill_signup_email(page, email, log)
  986. # 等待跳转到 SSO 页面(auth.openai.com/sso 或直接到 Keycloak)
  987. stage("等待 SSO 跳转")
  988. if not _wait_for_sso_or_keycloak(page, log, timeout_sec=30):
  989. log(f"[sso] 警告:未检测到 SSO 跳转 url={page.url},尝试继续")
  990. page.wait_for_timeout(2000)
  991. log(f"[sso] 当前页面 url={page.url}")
  992. # 如果在 auth.openai.com/sso,先选择 Workspace,再等待跳转到 Keycloak
  993. if "auth.openai.com/sso" in (page.url or "").lower():
  994. stage("选择 SSO Workspace")
  995. if not _click_sso_workspace_option(page, log, timeout_sec=15):
  996. log(f"[sso] 警告:未能自动点击 Workspace 入口 url={page.url}")
  997. stage("等待 Keycloak 跳转")
  998. _wait_until(lambda: _is_keycloak_login_page(page) or _is_keycloak_register_page(page), 20)
  999. page.wait_for_timeout(1500)
  1000. # 在 Keycloak 登录页点击 Register
  1001. stage("点击 Register")
  1002. if not _is_keycloak_register_page(page):
  1003. if not _click_keycloak_register(page, log):
  1004. raise RuntimeError(f"未能点击 Keycloak Register 链接 url={page.url}")
  1005. _wait_until(lambda: _is_keycloak_register_page(page), 15)
  1006. page.wait_for_timeout(1000)
  1007. log(f"[sso] 进入 Keycloak 注册页 url={page.url}")
  1008. # 填写注册表单
  1009. stage("填写 Keycloak 注册表单")
  1010. _fill_keycloak_registration(page, email, first, last, log)
  1011. page.wait_for_timeout(3000)
  1012. # 注册完成后可能跳到 interstitial 批准页
  1013. stage("处理 SSO 批准")
  1014. _handle_interstitial_confirm(page, log, timeout_sec=30)
  1015. page.wait_for_timeout(3000)
  1016. # 处理 passkey 引导(如果出现)
  1017. stage("处理 passkey 引导")
  1018. _handle_passkey_enrollment_if_present(page, log)
  1019. log(f"[sso] SSO 注册完成 email={email},等待 CPA Codex OAuth 授权")
  1020. return {"email": email, "password": email, "session": {}}