chatgpt_signup.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  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 _handle_passkey_enrollment_if_present(page, log, timeout_sec: int = 25) -> bool:
  191. """OpenAI 新账号可能进入 passkey 引导页;注册自动化选择跳过该可选步骤。"""
  192. if not _is_passkey_enrollment_url(page.url or ""):
  193. return False
  194. log(f"[signup] 检测到 passkey 引导页 url={page.url},尝试跳过")
  195. clicked = False
  196. for txt in PASSKEY_SKIP_TEXTS:
  197. for sel in (
  198. f'button:has-text("{txt}")',
  199. f'a:has-text("{txt}")',
  200. f'[role="button"]:has-text("{txt}")',
  201. ):
  202. if _try_click_first_visible(page, sel, log, label=f"passkey-skip({txt})"):
  203. clicked = True
  204. break
  205. if clicked:
  206. break
  207. if not clicked:
  208. try:
  209. candidates = page.locator('button, a, [role="button"], [role="link"]')
  210. n = candidates.count()
  211. for i in range(min(n, 120)):
  212. el = candidates.nth(i)
  213. try:
  214. txt = (el.inner_text(timeout=400) or "").strip()
  215. aria = el.get_attribute("aria-label") or ""
  216. blob = f"{txt} {aria}"
  217. if PASSKEY_SKIP_RE.search(blob) and el.is_visible() and el.is_enabled():
  218. el.click(timeout=4000)
  219. log(f"[signup] 点击 passkey 跳过候选 {blob!r}")
  220. clicked = True
  221. break
  222. except Exception as exc:
  223. log(f"[signup] passkey 候选 idx={i} 点击失败: {exc!r}")
  224. except Exception as exc:
  225. log(f"[signup] passkey 跳过按钮扫描异常: {exc!r}")
  226. if not clicked:
  227. log("[signup] 未找到 passkey 跳过按钮,保留当前页继续等待")
  228. deadline = time.time() + timeout_sec
  229. last_url = page.url or ""
  230. while time.time() < deadline:
  231. cur = page.url or ""
  232. if cur != last_url:
  233. log(f"[signup] passkey 页跳转 {last_url} -> {cur}")
  234. last_url = cur
  235. if not _is_passkey_enrollment_url(cur):
  236. log(f"[signup] 已离开 passkey 引导页 url={cur}")
  237. return True
  238. time.sleep(0.5)
  239. log(f"[signup] 警告:passkey 引导页 {timeout_sec}s 内未离开 url={page.url}")
  240. return False
  241. def _fill_signup_email(page, email: str, log):
  242. log(f"[signup] === 提交注册邮箱 {email} ===")
  243. # 邮箱页可能由前一步导航触发,需要等一会让 input 挂载
  244. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  245. if not inp:
  246. # 等最多 8s:要么邮箱框出现,要么 URL 跳到 auth.openai.com 后再继续等
  247. deadline = time.time() + 8
  248. while time.time() < deadline:
  249. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  250. if inp:
  251. break
  252. time.sleep(0.4)
  253. if not inp:
  254. # 仍找不到 — 只有当确实还停在 chatgpt.com 主页时才补点注册入口(避免点已跳转的页面把表单关掉)
  255. cur_url = (page.url or "").lower()
  256. if "chatgpt.com" in cur_url and "auth" not in cur_url and "/email-verification" not in cur_url:
  257. log(f"[signup] 邮箱框未挂载且仍在 chatgpt.com 主页,补点一次注册入口 url={cur_url}")
  258. if _click_signup_entry(page, log):
  259. page.wait_for_load_state("domcontentloaded", timeout=20000)
  260. page.wait_for_timeout(1500)
  261. # 再等一会
  262. deadline = time.time() + 8
  263. while time.time() < deadline:
  264. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  265. if inp:
  266. break
  267. time.sleep(0.4)
  268. else:
  269. log(f"[signup] 已离开主页(url={cur_url}),不再点注册入口,仅继续等邮箱框")
  270. deadline = time.time() + 8
  271. while time.time() < deadline:
  272. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  273. if inp:
  274. break
  275. time.sleep(0.4)
  276. if not inp:
  277. raise RuntimeError(f"未找到邮箱输入框 URL={page.url}")
  278. log(f"[signup] 命中邮箱输入框 selector={used_sel}")
  279. inp.click()
  280. inp.fill("")
  281. inp.type(email, delay=20)
  282. page.wait_for_timeout(300)
  283. if not _click_continue(page, log, label="email-continue"):
  284. raise RuntimeError("未找到邮箱页的继续按钮")
  285. # 等待跳到密码页
  286. ok = _wait_until(lambda: _is_password_page(page) or _is_email_verification_page(page), 25)
  287. if not ok:
  288. raise RuntimeError(f"提交邮箱后未进入密码/验证码页 URL={page.url}")
  289. log(f"[signup] 邮箱已提交 URL={page.url} is_password_page={_is_password_page(page)}")
  290. def _fill_password(page, password: str, log):
  291. log("[signup] === 填密码 ===")
  292. inp, used_sel = _find_visible(page, PASSWORD_INPUT_SELECTORS)
  293. if not inp:
  294. raise RuntimeError(f"未找到密码输入框 URL={page.url}")
  295. log(f"[signup] 命中密码输入框 selector={used_sel}")
  296. inp.click()
  297. inp.fill("")
  298. inp.type(password, delay=20)
  299. page.wait_for_timeout(300)
  300. submitted_at_ms = int(time.time() * 1000)
  301. if not _click_continue(page, log, label="password-continue"):
  302. raise RuntimeError("未找到密码页的继续按钮")
  303. return submitted_at_ms
  304. def _fill_verification_code(page, code: str, log):
  305. log(f"[signup] === 填入验证码 {code} ===")
  306. # 优先 6 位拆分输入框
  307. split = page.locator('input[maxlength="1"]')
  308. n_split = split.count()
  309. if n_split >= 6:
  310. log(f"[signup] 检测到拆分输入框 count={n_split}")
  311. try:
  312. split.nth(0).click()
  313. except Exception:
  314. pass
  315. for idx, ch in enumerate(code[:n_split]):
  316. try:
  317. box = split.nth(idx)
  318. box.fill("")
  319. box.type(ch, delay=30)
  320. except Exception as exc:
  321. log(f"[signup] 拆分位 {idx} 输入失败: {exc!r}")
  322. return
  323. sel = 'input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], input[maxlength="6"]'
  324. loc = page.locator(sel).first
  325. if loc.count() == 0:
  326. raise RuntimeError("未找到验证码输入框")
  327. loc.fill("")
  328. loc.type(code, delay=30)
  329. log("[signup] 已填入单格验证码")
  330. page.wait_for_timeout(300)
  331. _click_continue(page, log, label="code-continue")
  332. def _wait_profile_page_ready(page, log, timeout_sec: int = 30) -> dict:
  333. """等待 profile 页可见控件出现,并返回它用的是哪种 UI。"""
  334. deadline = time.time() + timeout_sec
  335. while time.time() < deadline:
  336. try:
  337. info = page.evaluate(
  338. r"""() => {
  339. const visible = (el) => {
  340. if (!el) return false;
  341. const s = window.getComputedStyle(el);
  342. if (s.display === 'none' || s.visibility === 'hidden') return false;
  343. const r = el.getBoundingClientRect();
  344. return r.width > 0 && r.height > 0;
  345. };
  346. const name = document.querySelector('input[name="name"], input[autocomplete="name"], input[placeholder*="全名"]');
  347. const age = document.querySelector('input[name="age"]');
  348. const yearSpin = document.querySelector('[role="spinbutton"][data-type="year"]');
  349. const monthSpin = document.querySelector('[role="spinbutton"][data-type="month"]');
  350. const daySpin = document.querySelector('[role="spinbutton"][data-type="day"]');
  351. const hiddenBday = document.querySelector('input[name="birthday"]');
  352. // React Aria 下拉:button + listbox 隐藏 select
  353. const allButtons = Array.from(document.querySelectorAll('button[aria-haspopup="listbox"], [role="combobox"]'));
  354. const matchByLabel = (kw) => allButtons.find((b) => {
  355. const t = (b.innerText || b.getAttribute('aria-label') || '').trim();
  356. return t && new RegExp(kw, 'i').test(t);
  357. }) || null;
  358. const yearBtn = matchByLabel('year|年');
  359. const monthBtn = matchByLabel('month|月');
  360. const dayBtn = matchByLabel('day|天|日');
  361. return {
  362. url: location.href,
  363. nameVisible: visible(name),
  364. ageVisible: visible(age),
  365. spinVisible: visible(yearSpin) && visible(monthSpin) && visible(daySpin),
  366. selectVisible: visible(yearBtn) && visible(monthBtn) && visible(dayBtn),
  367. hasHiddenBday: Boolean(hiddenBday),
  368. bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 240),
  369. };
  370. }"""
  371. ) or {}
  372. except Exception as exc:
  373. log(f"[signup] profile 页探测异常: {exc!r}")
  374. info = {}
  375. kind = "unknown"
  376. if info.get("ageVisible"):
  377. kind = "age"
  378. elif info.get("selectVisible"):
  379. kind = "select"
  380. elif info.get("spinVisible"):
  381. kind = "spin"
  382. if info.get("nameVisible") and kind != "unknown":
  383. log(f"[signup] profile 页就绪 url={info.get('url')} mode={kind} hidden_bday={info.get('hasHiddenBday')}")
  384. return {"kind": kind, **info}
  385. time.sleep(0.3)
  386. log(f"[signup] profile 页等待超时 url={page.url}")
  387. return {"kind": "unknown", "url": page.url}
  388. def _fill_name_and_birthday(page, first: str, last: str, year: int, month: int, day: int, log):
  389. log(f"[signup] === 姓名/生日 {first} {last} {year}-{month:02d}-{day:02d} ===")
  390. profile_info = _wait_profile_page_ready(page, log, timeout_sec=30)
  391. if profile_info["kind"] == "unknown":
  392. raise RuntimeError(f"profile 页未识别可见控件 url={page.url}")
  393. full_name = f"{first} {last}"
  394. name_input, name_sel = _find_visible(page, [
  395. 'input[name="name"]',
  396. 'input[autocomplete="name"]',
  397. 'input[placeholder*="全名"]',
  398. ])
  399. if not name_input:
  400. raise RuntimeError("未找到姓名输入框")
  401. log(f"[signup] 命中姓名输入框 selector={name_sel}")
  402. name_input.click()
  403. name_input.fill("")
  404. name_input.type(full_name, delay=20)
  405. log(f"[signup] 姓名已填写: {full_name}")
  406. page.wait_for_timeout(400)
  407. kind = profile_info["kind"]
  408. bday_value = f"{year:04d}-{month:02d}-{day:02d}"
  409. if kind == "age":
  410. age = max(18, datetime.now().year - year)
  411. # React Aria 用 <label> 浮在 input 上面拦截了 click,所以走 focus+JS 赋值
  412. try:
  413. ok = page.evaluate(
  414. r"""(ageStr) => {
  415. const el = document.querySelector('input[name="age"]');
  416. if (!el) return false;
  417. el.focus();
  418. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  419. setter.call(el, '');
  420. el.dispatchEvent(new Event('input', { bubbles: true }));
  421. setter.call(el, String(ageStr));
  422. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: String(ageStr), bubbles: true }));
  423. el.dispatchEvent(new Event('input', { bubbles: true }));
  424. el.dispatchEvent(new Event('change', { bubbles: true }));
  425. el.blur();
  426. return el.value;
  427. }""",
  428. str(age),
  429. )
  430. log(f"[signup] age 已通过 JS 设置 value={ok!r}")
  431. except Exception as exc:
  432. log(f"[signup] JS 设 age 失败: {exc!r},回退键盘输入")
  433. try:
  434. # 用 page.keyboard:先 focus 后 type
  435. page.evaluate("() => document.querySelector('input[name=\"age\"]').focus()")
  436. page.keyboard.type(str(age), delay=30)
  437. log(f"[signup] 键盘输入 age={age} 成功")
  438. except Exception as exc2:
  439. raise RuntimeError(f"填年龄失败(JS+键盘均失败): {exc!r} / {exc2!r}")
  440. # 校验
  441. try:
  442. real = page.evaluate("() => document.querySelector('input[name=\"age\"]').value")
  443. log(f"[signup] age input 实际 value={real!r} (期望 {age})")
  444. except Exception:
  445. pass
  446. elif kind == "spin":
  447. log("[signup] 使用 spinbutton 三段式生日")
  448. for kw, val in (("year", year), ("month", f"{month:02d}"), ("day", f"{day:02d}")):
  449. try:
  450. ok = page.evaluate(
  451. r"""([sel, valStr]) => {
  452. const el = document.querySelector(sel);
  453. if (!el) return false;
  454. el.focus();
  455. document.execCommand('selectAll', false, null);
  456. for (const ch of String(valStr)) {
  457. el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: 'Digit'+ch, bubbles: true }));
  458. el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, code: 'Digit'+ch, bubbles: true }));
  459. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true }));
  460. el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
  461. }
  462. el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
  463. el.blur();
  464. return true;
  465. }""",
  466. [f'[role="spinbutton"][data-type="{kw}"]', str(val)],
  467. )
  468. log(f"[signup] spin {kw} <- {val} ok={ok}")
  469. except Exception as exc:
  470. log(f"[signup] spin {kw} 失败: {exc!r}")
  471. # spinbutton 模式有时也会同步 hidden birthday;保险起见显式设置
  472. try:
  473. page.evaluate(
  474. r"""([sel, val]) => {
  475. const el = document.querySelector(sel);
  476. if (!el) return false;
  477. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  478. setter.call(el, val);
  479. el.dispatchEvent(new Event('input', { bubbles: true }));
  480. el.dispatchEvent(new Event('change', { bubbles: true }));
  481. return true;
  482. }""",
  483. ['input[name="birthday"]', bday_value],
  484. )
  485. except Exception:
  486. pass
  487. elif kind == "select":
  488. log("[signup] 使用 React Aria 下拉式生日")
  489. # year/month/day 各自是 button[aria-haspopup=listbox],点开后选 option
  490. # 用 inner_text 含关键字定位三个 button;click 失败则回退 dispatchEvent
  491. def click_select_option(label_kw_re: str, option_value: str | int) -> bool:
  492. buttons = page.locator('button[aria-haspopup="listbox"], [role="combobox"]')
  493. n = buttons.count()
  494. target_idx = -1
  495. for i in range(min(n, 80)):
  496. b = buttons.nth(i)
  497. try:
  498. if not b.is_visible():
  499. continue
  500. txt = (b.inner_text(timeout=400) or "").strip()
  501. aria = b.get_attribute("aria-label") or ""
  502. blob = f"{txt} {aria}"
  503. except Exception:
  504. continue
  505. if re.search(label_kw_re, blob, re.I):
  506. target_idx = i
  507. break
  508. if target_idx < 0:
  509. log(f"[signup] 未找到下拉按钮 kw={label_kw_re}")
  510. return False
  511. target = buttons.nth(target_idx)
  512. try:
  513. target.click(timeout=3000)
  514. except Exception:
  515. # label/positioner 拦截:force click + JS click 双兜底
  516. try:
  517. target.click(timeout=3000, force=True)
  518. except Exception:
  519. try:
  520. target.evaluate("el => el.click()")
  521. except Exception as exc:
  522. log(f"[signup] 三种 click 都失败 kw={label_kw_re}: {exc!r}")
  523. return False
  524. page.wait_for_timeout(350)
  525. opt = page.locator(f'[role="option"]:has-text("{option_value}")').first
  526. if opt.count() == 0:
  527. opt = page.locator(f'[role="option"]:has-text("{int(option_value)}")').first
  528. if opt.count() == 0:
  529. log(f"[signup] 未找到 option={option_value}")
  530. return False
  531. try:
  532. opt.click(timeout=3000)
  533. except Exception:
  534. try:
  535. opt.click(timeout=3000, force=True)
  536. except Exception:
  537. try:
  538. opt.evaluate("el => el.click()")
  539. except Exception as exc:
  540. log(f"[signup] 选 option 三种 click 都失败: {exc!r}")
  541. return False
  542. log(f"[signup] 下拉 kw={label_kw_re} 已选 {option_value}")
  543. return True
  544. click_select_option(r"year|年", year)
  545. page.wait_for_timeout(300)
  546. click_select_option(r"month|月", month)
  547. page.wait_for_timeout(300)
  548. click_select_option(r"day|天|日", day)
  549. page.wait_for_timeout(300)
  550. # 验证 hidden birthday(如果存在)确实被写入
  551. try:
  552. hidden_val = page.evaluate(
  553. r"""() => {
  554. const el = document.querySelector('input[name="birthday"]');
  555. return el ? el.value || '' : '__no_hidden__';
  556. }"""
  557. )
  558. log(f"[signup] hidden birthday 当前值={hidden_val!r} (期望 {bday_value})")
  559. except Exception:
  560. pass
  561. # 同意复选框(如出现)
  562. try:
  563. page.evaluate(
  564. r"""() => {
  565. const cbs = document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"], input[type="checkbox"]');
  566. let n = 0;
  567. cbs.forEach((cb) => {
  568. const lbl = cb.closest('label');
  569. if (cb.checked) return;
  570. const txt = (lbl?.textContent || cb.getAttribute('aria-label') || '').replace(/\s+/g, ' ');
  571. if (/agree|同意|i\s+agree/i.test(txt) || cb.name === 'allCheckboxes') {
  572. try { (lbl || cb).click(); n++; } catch (_) {}
  573. }
  574. });
  575. return n;
  576. }"""
  577. )
  578. except Exception as exc:
  579. log(f"[signup] 勾选同意复选框异常: {exc!r}")
  580. page.wait_for_timeout(600)
  581. # 提交"完成帐户创建"
  582. submit = page.locator('button[type="submit"]').first
  583. if submit.count() == 0:
  584. log("[signup] 未找到 button[type=submit],尝试文本兜底")
  585. for txt in ("完成", "Create account", "Continue", "Finish", "Done", "Agree"):
  586. cand = page.locator(f'button:has-text("{txt}")').first
  587. if cand.count() > 0:
  588. submit = cand
  589. break
  590. if submit.count() == 0:
  591. raise RuntimeError("profile 页未找到提交按钮")
  592. try:
  593. submit.scroll_into_view_if_needed(timeout=2000)
  594. except Exception:
  595. pass
  596. try:
  597. submit.click(timeout=5000)
  598. log("[signup] 已点击完成帐户创建按钮")
  599. except Exception as exc:
  600. log(f"[signup] click submit 失败: {exc!r},回退 force click")
  601. submit.click(timeout=5000, force=True)
  602. # 等待页面真正离开 profile 页
  603. deadline = time.time() + 25
  604. last_url = page.url or ""
  605. while time.time() < deadline:
  606. try:
  607. cur = page.url or ""
  608. still = page.locator('input[name="name"]').count() > 0
  609. if cur != last_url:
  610. log(f"[signup] profile 页跳转 {last_url} -> {cur}")
  611. last_url = cur
  612. if not still and "chatgpt.com" in cur:
  613. log(f"[signup] profile 页已离开,进入 {cur}")
  614. return
  615. if "chatgpt.com" in cur and "/auth" not in cur:
  616. # 已经回到 chatgpt.com 主域
  617. log(f"[signup] 已回到 chatgpt.com: {cur}")
  618. return
  619. except Exception:
  620. pass
  621. time.sleep(0.5)
  622. log(f"[signup] 警告:profile 提交后 25s 未确认离开,url={page.url}")
  623. def _fetch_session(page, log) -> dict:
  624. """读取 chatgpt.com/api/auth/session。"""
  625. log(f"[signup] === 拉取 session: {SESSION_URL} ===")
  626. # 确保 cookies 已写入;先回到 chatgpt.com 主域
  627. if "chatgpt.com" not in (page.url or ""):
  628. try:
  629. page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
  630. page.wait_for_timeout(2000)
  631. except Exception as exc:
  632. log(f"[signup] 回 chatgpt.com 异常: {exc!r}")
  633. deadline = time.time() + 60
  634. last_text = ""
  635. while time.time() < deadline:
  636. try:
  637. resp = page.evaluate(
  638. """async () => {
  639. try {
  640. const r = await fetch('/api/auth/session', { credentials: 'include' });
  641. const text = await r.text();
  642. return { status: r.status, text };
  643. } catch (e) {
  644. return { status: 0, text: '', error: String(e) };
  645. }
  646. }"""
  647. )
  648. text = (resp or {}).get("text") or ""
  649. status = (resp or {}).get("status") or 0
  650. if text and text != last_text:
  651. last_text = text
  652. log(f"[signup] session HTTP {status} 长度 {len(text)} 预览 {text[:200]}")
  653. if status == 200 and text:
  654. import json
  655. try:
  656. data = json.loads(text)
  657. except Exception:
  658. data = None
  659. if isinstance(data, dict) and data.get("accessToken"):
  660. return data
  661. except Exception as exc:
  662. log(f"[signup] fetch session 异常: {exc!r}")
  663. time.sleep(2)
  664. raise TimeoutError("拉取 session 超时(未取到 accessToken)")
  665. def signup_chatgpt(
  666. page,
  667. *,
  668. helper_url: str,
  669. mail_domain: str = "edu.a4sky.com",
  670. mail_poll_interval_sec: int = 4,
  671. mail_poll_max_attempts: int = 60,
  672. log: Callable[[str], None] = print,
  673. on_stage: Callable[[str], None] | None = None,
  674. ) -> dict:
  675. """跑完整注册流,返回 { email, password, session }。"""
  676. def stage(name: str):
  677. log(f"[stage] {name}")
  678. if on_stage:
  679. try:
  680. on_stage(name)
  681. except Exception:
  682. pass
  683. email = build_a4sky_email(mail_domain)
  684. password = _rand_password()
  685. first, last = _rand_name()
  686. year, month, day = _rand_birthday()
  687. log(f"[signup] 生成 email={email} password={password} name={first} {last} bday={year}-{month:02d}-{day:02d}")
  688. stage("打开 chatgpt.com")
  689. page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
  690. page.wait_for_timeout(2000)
  691. # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
  692. inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
  693. if not inp and not _is_password_page(page):
  694. stage("点击注册入口")
  695. _click_signup_entry(page, log)
  696. page.wait_for_load_state("domcontentloaded", timeout=20000)
  697. page.wait_for_timeout(1500)
  698. stage("填写邮箱")
  699. if not _is_password_page(page):
  700. _fill_signup_email(page, email, log)
  701. if _is_password_page(page):
  702. stage("填写密码")
  703. # 邮件助手仅看邮件接收时间,过滤起点用"现在"-30s 比较稳
  704. code_started_ms = int(time.time() * 1000) - 30 * 1000
  705. _fill_password(page, password, log)
  706. else:
  707. code_started_ms = int(time.time() * 1000) - 30 * 1000
  708. # 等待进入验证码页
  709. stage("等待验证码页")
  710. if not _wait_until(lambda: _is_email_verification_page(page), 30):
  711. log(f"[signup] 警告:未确认进入验证码页 URL={page.url},仍尝试拉验证码")
  712. stage("轮询邮箱验证码")
  713. code = poll_signup_code(
  714. helper_url,
  715. email,
  716. started_at_ms=code_started_ms,
  717. interval_sec=mail_poll_interval_sec,
  718. max_attempts=mail_poll_max_attempts,
  719. log=log,
  720. )
  721. stage("填入验证码")
  722. _fill_verification_code(page, code, log)
  723. page.wait_for_timeout(2500)
  724. stage("填姓名/生日")
  725. _fill_name_and_birthday(page, first, last, year, month, day, log)
  726. page.wait_for_timeout(2500)
  727. stage("处理 passkey 引导")
  728. _handle_passkey_enrollment_if_present(page, log)
  729. stage("回 chatgpt.com 拉 session")
  730. session = _fetch_session(page, log)
  731. log(f"[signup] 注册完成 email={email} accessToken={(session.get('accessToken') or '')[:24]}... planType={(session.get('account') or {}).get('planType')}")
  732. return {"email": email, "password": password, "session": session}
  733. def fetch_current_session(page, log: Callable[[str], None] = print) -> dict:
  734. return _fetch_session(page, log)