chatgpt_signup.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  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. def _rand_password(length: int = 16) -> str:
  31. pools = [
  32. random.choice(string.ascii_uppercase),
  33. random.choice(string.ascii_lowercase),
  34. random.choice(string.digits),
  35. random.choice("!@#$%^*"),
  36. ]
  37. pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^*") for _ in range(length - 4)]
  38. random.shuffle(pools)
  39. return "".join(pools)
  40. def _rand_name() -> tuple[str, str]:
  41. firsts = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
  42. "William", "Elizabeth", "David", "Susan", "Daniel", "Sarah", "Thomas", "Karen"]
  43. lasts = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
  44. "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson"]
  45. return random.choice(firsts), random.choice(lasts)
  46. def _rand_birthday() -> tuple[int, int, int]:
  47. """随机 1985~2000 年的生日,避开 28 号以后避免月份冲突。"""
  48. year = random.randint(1985, 2000)
  49. month = random.randint(1, 12)
  50. day = random.randint(1, 28)
  51. return year, month, day
  52. def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool:
  53. loc = page.locator(selector)
  54. count = loc.count()
  55. if count == 0:
  56. return False
  57. for i in range(count):
  58. item = loc.nth(i)
  59. try:
  60. if item.is_visible() and item.is_enabled():
  61. item.click(timeout=4000)
  62. log(f"[signup] 点击 {label or selector} (idx={i}) 成功")
  63. return True
  64. except Exception as exc:
  65. log(f"[signup] 点击 {label or selector} (idx={i}) 失败: {exc!r}")
  66. return False
  67. def _click_signup_entry(page, log) -> bool:
  68. """ChatGPT 首页右上角"注册"按钮。命中即返回 True;命中后等待 ~1s 让导航开始。"""
  69. candidates = [
  70. 'a[data-testid="signup-button"]',
  71. 'button[data-testid="signup-button"]',
  72. 'a:has-text("Sign up")',
  73. 'button:has-text("Sign up")',
  74. 'a:has-text("注册")',
  75. 'button:has-text("注册")',
  76. ]
  77. for sel in candidates:
  78. if _try_click_first_visible(page, sel, log, label=f"signup entry({sel})"):
  79. try:
  80. page.wait_for_timeout(800)
  81. except Exception:
  82. pass
  83. return True
  84. # 文本兜底
  85. try:
  86. all_btns = page.locator('a, button, [role="button"], [role="link"]')
  87. n = all_btns.count()
  88. for i in range(min(n, 200)):
  89. el = all_btns.nth(i)
  90. try:
  91. txt = (el.inner_text(timeout=500) or "").strip()
  92. except Exception:
  93. continue
  94. if txt and SIGNUP_TRIGGER_PATTERN.search(txt) and el.is_visible() and el.is_enabled():
  95. el.click(timeout=3000)
  96. log(f"[signup] 点击文本注册入口 {txt!r}")
  97. return True
  98. except Exception as exc:
  99. log(f"[signup] 兜底查找注册入口异常: {exc!r}")
  100. return False
  101. def _find_visible(page, selectors: list[str]):
  102. for sel in selectors:
  103. loc = page.locator(sel)
  104. count = loc.count()
  105. for i in range(count):
  106. try:
  107. item = loc.nth(i)
  108. if item.is_visible():
  109. return item, sel
  110. except Exception:
  111. continue
  112. return None, None
  113. def _click_continue(page, log, *, label: str = "continue") -> bool:
  114. """通用:点 type=submit / 文本 Continue / Next 等。"""
  115. direct = page.locator('button[type="submit"], input[type="submit"]')
  116. cnt = direct.count()
  117. for i in range(cnt):
  118. try:
  119. it = direct.nth(i)
  120. if it.is_visible() and it.is_enabled():
  121. it.click(timeout=4000)
  122. log(f"[signup] {label} 点击 button[type=submit] (idx={i}) 成功")
  123. return True
  124. except Exception as exc:
  125. log(f"[signup] {label} button[type=submit] (idx={i}) 失败: {exc!r}")
  126. for txt in CONTINUE_BUTTON_TEXTS:
  127. loc = page.locator(f'button:has-text("{txt}")').first
  128. if loc.count() == 0:
  129. continue
  130. try:
  131. if loc.is_visible() and loc.is_enabled():
  132. loc.click(timeout=4000)
  133. log(f"[signup] {label} 点击 has-text={txt!r} 成功")
  134. return True
  135. except Exception as exc:
  136. log(f"[signup] {label} has-text={txt!r} 失败: {exc!r}")
  137. return False
  138. def _is_password_page(page) -> bool:
  139. try:
  140. loc = page.locator(", ".join(PASSWORD_INPUT_SELECTORS))
  141. if loc.count() == 0:
  142. return False
  143. for i in range(loc.count()):
  144. if loc.nth(i).is_visible():
  145. return True
  146. except Exception:
  147. pass
  148. return False
  149. def _is_email_verification_page(page) -> bool:
  150. """6 位验证码输入页。"""
  151. url = (page.url or "").lower()
  152. if "/email-verification" in url or "/verify" in url:
  153. return True
  154. try:
  155. sel = ('input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], '
  156. 'input[maxlength="6"], input[maxlength="1"]')
  157. loc = page.locator(sel)
  158. if loc.count() >= 1:
  159. return True
  160. except Exception:
  161. pass
  162. return False
  163. def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 250) -> bool:
  164. deadline = time.time() + timeout_sec
  165. while time.time() < deadline:
  166. try:
  167. if predicate():
  168. return True
  169. except Exception:
  170. pass
  171. time.sleep(interval_ms / 1000)
  172. return False
  173. def _fill_signup_email(page, email: str, log):
  174. log(f"[signup] === 提交注册邮箱 {email} ===")
  175. # 邮箱页可能由前一步导航触发,需要等一会让 input 挂载
  176. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  177. if not inp:
  178. # 等最多 8s:要么邮箱框出现,要么 URL 跳到 auth.openai.com 后再继续等
  179. deadline = time.time() + 8
  180. while time.time() < deadline:
  181. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  182. if inp:
  183. break
  184. time.sleep(0.4)
  185. if not inp:
  186. # 仍找不到 — 只有当确实还停在 chatgpt.com 主页时才补点注册入口(避免点已跳转的页面把表单关掉)
  187. cur_url = (page.url or "").lower()
  188. if "chatgpt.com" in cur_url and "auth" not in cur_url and "/email-verification" not in cur_url:
  189. log(f"[signup] 邮箱框未挂载且仍在 chatgpt.com 主页,补点一次注册入口 url={cur_url}")
  190. if _click_signup_entry(page, log):
  191. page.wait_for_load_state("domcontentloaded", timeout=20000)
  192. page.wait_for_timeout(1500)
  193. # 再等一会
  194. deadline = time.time() + 8
  195. while time.time() < deadline:
  196. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  197. if inp:
  198. break
  199. time.sleep(0.4)
  200. else:
  201. log(f"[signup] 已离开主页(url={cur_url}),不再点注册入口,仅继续等邮箱框")
  202. deadline = time.time() + 8
  203. while time.time() < deadline:
  204. inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
  205. if inp:
  206. break
  207. time.sleep(0.4)
  208. if not inp:
  209. raise RuntimeError(f"未找到邮箱输入框 URL={page.url}")
  210. log(f"[signup] 命中邮箱输入框 selector={used_sel}")
  211. inp.click()
  212. inp.fill("")
  213. inp.type(email, delay=20)
  214. page.wait_for_timeout(300)
  215. if not _click_continue(page, log, label="email-continue"):
  216. raise RuntimeError("未找到邮箱页的继续按钮")
  217. # 等待跳到密码页
  218. ok = _wait_until(lambda: _is_password_page(page) or _is_email_verification_page(page), 25)
  219. if not ok:
  220. raise RuntimeError(f"提交邮箱后未进入密码/验证码页 URL={page.url}")
  221. log(f"[signup] 邮箱已提交 URL={page.url} is_password_page={_is_password_page(page)}")
  222. def _fill_password(page, password: str, log):
  223. log("[signup] === 填密码 ===")
  224. inp, used_sel = _find_visible(page, PASSWORD_INPUT_SELECTORS)
  225. if not inp:
  226. raise RuntimeError(f"未找到密码输入框 URL={page.url}")
  227. log(f"[signup] 命中密码输入框 selector={used_sel}")
  228. inp.click()
  229. inp.fill("")
  230. inp.type(password, delay=20)
  231. page.wait_for_timeout(300)
  232. submitted_at_ms = int(time.time() * 1000)
  233. if not _click_continue(page, log, label="password-continue"):
  234. raise RuntimeError("未找到密码页的继续按钮")
  235. return submitted_at_ms
  236. def _fill_verification_code(page, code: str, log):
  237. log(f"[signup] === 填入验证码 {code} ===")
  238. # 优先 6 位拆分输入框
  239. split = page.locator('input[maxlength="1"]')
  240. n_split = split.count()
  241. if n_split >= 6:
  242. log(f"[signup] 检测到拆分输入框 count={n_split}")
  243. try:
  244. split.nth(0).click()
  245. except Exception:
  246. pass
  247. for idx, ch in enumerate(code[:n_split]):
  248. try:
  249. box = split.nth(idx)
  250. box.fill("")
  251. box.type(ch, delay=30)
  252. except Exception as exc:
  253. log(f"[signup] 拆分位 {idx} 输入失败: {exc!r}")
  254. return
  255. sel = 'input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], input[maxlength="6"]'
  256. loc = page.locator(sel).first
  257. if loc.count() == 0:
  258. raise RuntimeError("未找到验证码输入框")
  259. loc.fill("")
  260. loc.type(code, delay=30)
  261. log("[signup] 已填入单格验证码")
  262. page.wait_for_timeout(300)
  263. _click_continue(page, log, label="code-continue")
  264. def _wait_profile_page_ready(page, log, timeout_sec: int = 30) -> dict:
  265. """等待 profile 页可见控件出现,并返回它用的是哪种 UI。"""
  266. deadline = time.time() + timeout_sec
  267. while time.time() < deadline:
  268. try:
  269. info = page.evaluate(
  270. r"""() => {
  271. const visible = (el) => {
  272. if (!el) return false;
  273. const s = window.getComputedStyle(el);
  274. if (s.display === 'none' || s.visibility === 'hidden') return false;
  275. const r = el.getBoundingClientRect();
  276. return r.width > 0 && r.height > 0;
  277. };
  278. const name = document.querySelector('input[name="name"], input[autocomplete="name"], input[placeholder*="全名"]');
  279. const age = document.querySelector('input[name="age"]');
  280. const yearSpin = document.querySelector('[role="spinbutton"][data-type="year"]');
  281. const monthSpin = document.querySelector('[role="spinbutton"][data-type="month"]');
  282. const daySpin = document.querySelector('[role="spinbutton"][data-type="day"]');
  283. const hiddenBday = document.querySelector('input[name="birthday"]');
  284. // React Aria 下拉:button + listbox 隐藏 select
  285. const allButtons = Array.from(document.querySelectorAll('button[aria-haspopup="listbox"], [role="combobox"]'));
  286. const matchByLabel = (kw) => allButtons.find((b) => {
  287. const t = (b.innerText || b.getAttribute('aria-label') || '').trim();
  288. return t && new RegExp(kw, 'i').test(t);
  289. }) || null;
  290. const yearBtn = matchByLabel('year|年');
  291. const monthBtn = matchByLabel('month|月');
  292. const dayBtn = matchByLabel('day|天|日');
  293. return {
  294. url: location.href,
  295. nameVisible: visible(name),
  296. ageVisible: visible(age),
  297. spinVisible: visible(yearSpin) && visible(monthSpin) && visible(daySpin),
  298. selectVisible: visible(yearBtn) && visible(monthBtn) && visible(dayBtn),
  299. hasHiddenBday: Boolean(hiddenBday),
  300. bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 240),
  301. };
  302. }"""
  303. ) or {}
  304. except Exception as exc:
  305. log(f"[signup] profile 页探测异常: {exc!r}")
  306. info = {}
  307. kind = "unknown"
  308. if info.get("ageVisible"):
  309. kind = "age"
  310. elif info.get("selectVisible"):
  311. kind = "select"
  312. elif info.get("spinVisible"):
  313. kind = "spin"
  314. if info.get("nameVisible") and kind != "unknown":
  315. log(f"[signup] profile 页就绪 url={info.get('url')} mode={kind} hidden_bday={info.get('hasHiddenBday')}")
  316. return {"kind": kind, **info}
  317. time.sleep(0.3)
  318. log(f"[signup] profile 页等待超时 url={page.url}")
  319. return {"kind": "unknown", "url": page.url}
  320. def _fill_name_and_birthday(page, first: str, last: str, year: int, month: int, day: int, log):
  321. log(f"[signup] === 姓名/生日 {first} {last} {year}-{month:02d}-{day:02d} ===")
  322. profile_info = _wait_profile_page_ready(page, log, timeout_sec=30)
  323. if profile_info["kind"] == "unknown":
  324. raise RuntimeError(f"profile 页未识别可见控件 url={page.url}")
  325. full_name = f"{first} {last}"
  326. name_input, name_sel = _find_visible(page, [
  327. 'input[name="name"]',
  328. 'input[autocomplete="name"]',
  329. 'input[placeholder*="全名"]',
  330. ])
  331. if not name_input:
  332. raise RuntimeError("未找到姓名输入框")
  333. log(f"[signup] 命中姓名输入框 selector={name_sel}")
  334. name_input.click()
  335. name_input.fill("")
  336. name_input.type(full_name, delay=20)
  337. log(f"[signup] 姓名已填写: {full_name}")
  338. page.wait_for_timeout(400)
  339. kind = profile_info["kind"]
  340. bday_value = f"{year:04d}-{month:02d}-{day:02d}"
  341. if kind == "age":
  342. age = max(18, datetime.now().year - year)
  343. # React Aria 用 <label> 浮在 input 上面拦截了 click,所以走 focus+JS 赋值
  344. try:
  345. ok = page.evaluate(
  346. r"""(ageStr) => {
  347. const el = document.querySelector('input[name="age"]');
  348. if (!el) return false;
  349. el.focus();
  350. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  351. setter.call(el, '');
  352. el.dispatchEvent(new Event('input', { bubbles: true }));
  353. setter.call(el, String(ageStr));
  354. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: String(ageStr), bubbles: true }));
  355. el.dispatchEvent(new Event('input', { bubbles: true }));
  356. el.dispatchEvent(new Event('change', { bubbles: true }));
  357. el.blur();
  358. return el.value;
  359. }""",
  360. str(age),
  361. )
  362. log(f"[signup] age 已通过 JS 设置 value={ok!r}")
  363. except Exception as exc:
  364. log(f"[signup] JS 设 age 失败: {exc!r},回退键盘输入")
  365. try:
  366. # 用 page.keyboard:先 focus 后 type
  367. page.evaluate("() => document.querySelector('input[name=\"age\"]').focus()")
  368. page.keyboard.type(str(age), delay=30)
  369. log(f"[signup] 键盘输入 age={age} 成功")
  370. except Exception as exc2:
  371. raise RuntimeError(f"填年龄失败(JS+键盘均失败): {exc!r} / {exc2!r}")
  372. # 校验
  373. try:
  374. real = page.evaluate("() => document.querySelector('input[name=\"age\"]').value")
  375. log(f"[signup] age input 实际 value={real!r} (期望 {age})")
  376. except Exception:
  377. pass
  378. elif kind == "spin":
  379. log("[signup] 使用 spinbutton 三段式生日")
  380. for kw, val in (("year", year), ("month", f"{month:02d}"), ("day", f"{day:02d}")):
  381. try:
  382. ok = page.evaluate(
  383. r"""([sel, valStr]) => {
  384. const el = document.querySelector(sel);
  385. if (!el) return false;
  386. el.focus();
  387. document.execCommand('selectAll', false, null);
  388. for (const ch of String(valStr)) {
  389. el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: 'Digit'+ch, bubbles: true }));
  390. el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, code: 'Digit'+ch, bubbles: true }));
  391. el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true }));
  392. el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
  393. }
  394. el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
  395. el.blur();
  396. return true;
  397. }""",
  398. [f'[role="spinbutton"][data-type="{kw}"]', str(val)],
  399. )
  400. log(f"[signup] spin {kw} <- {val} ok={ok}")
  401. except Exception as exc:
  402. log(f"[signup] spin {kw} 失败: {exc!r}")
  403. # spinbutton 模式有时也会同步 hidden birthday;保险起见显式设置
  404. try:
  405. page.evaluate(
  406. r"""([sel, val]) => {
  407. const el = document.querySelector(sel);
  408. if (!el) return false;
  409. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  410. setter.call(el, val);
  411. el.dispatchEvent(new Event('input', { bubbles: true }));
  412. el.dispatchEvent(new Event('change', { bubbles: true }));
  413. return true;
  414. }""",
  415. ['input[name="birthday"]', bday_value],
  416. )
  417. except Exception:
  418. pass
  419. elif kind == "select":
  420. log("[signup] 使用 React Aria 下拉式生日")
  421. # year/month/day 各自是 button[aria-haspopup=listbox],点开后选 option
  422. # 用 inner_text 含关键字定位三个 button;click 失败则回退 dispatchEvent
  423. def click_select_option(label_kw_re: str, option_value: str | int) -> bool:
  424. buttons = page.locator('button[aria-haspopup="listbox"], [role="combobox"]')
  425. n = buttons.count()
  426. target_idx = -1
  427. for i in range(min(n, 80)):
  428. b = buttons.nth(i)
  429. try:
  430. if not b.is_visible():
  431. continue
  432. txt = (b.inner_text(timeout=400) or "").strip()
  433. aria = b.get_attribute("aria-label") or ""
  434. blob = f"{txt} {aria}"
  435. except Exception:
  436. continue
  437. if re.search(label_kw_re, blob, re.I):
  438. target_idx = i
  439. break
  440. if target_idx < 0:
  441. log(f"[signup] 未找到下拉按钮 kw={label_kw_re}")
  442. return False
  443. target = buttons.nth(target_idx)
  444. try:
  445. target.click(timeout=3000)
  446. except Exception:
  447. # label/positioner 拦截:force click + JS click 双兜底
  448. try:
  449. target.click(timeout=3000, force=True)
  450. except Exception:
  451. try:
  452. target.evaluate("el => el.click()")
  453. except Exception as exc:
  454. log(f"[signup] 三种 click 都失败 kw={label_kw_re}: {exc!r}")
  455. return False
  456. page.wait_for_timeout(350)
  457. opt = page.locator(f'[role="option"]:has-text("{option_value}")').first
  458. if opt.count() == 0:
  459. opt = page.locator(f'[role="option"]:has-text("{int(option_value)}")').first
  460. if opt.count() == 0:
  461. log(f"[signup] 未找到 option={option_value}")
  462. return False
  463. try:
  464. opt.click(timeout=3000)
  465. except Exception:
  466. try:
  467. opt.click(timeout=3000, force=True)
  468. except Exception:
  469. try:
  470. opt.evaluate("el => el.click()")
  471. except Exception as exc:
  472. log(f"[signup] 选 option 三种 click 都失败: {exc!r}")
  473. return False
  474. log(f"[signup] 下拉 kw={label_kw_re} 已选 {option_value}")
  475. return True
  476. click_select_option(r"year|年", year)
  477. page.wait_for_timeout(300)
  478. click_select_option(r"month|月", month)
  479. page.wait_for_timeout(300)
  480. click_select_option(r"day|天|日", day)
  481. page.wait_for_timeout(300)
  482. # 验证 hidden birthday(如果存在)确实被写入
  483. try:
  484. hidden_val = page.evaluate(
  485. r"""() => {
  486. const el = document.querySelector('input[name="birthday"]');
  487. return el ? el.value || '' : '__no_hidden__';
  488. }"""
  489. )
  490. log(f"[signup] hidden birthday 当前值={hidden_val!r} (期望 {bday_value})")
  491. except Exception:
  492. pass
  493. # 同意复选框(如出现)
  494. try:
  495. page.evaluate(
  496. r"""() => {
  497. const cbs = document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"], input[type="checkbox"]');
  498. let n = 0;
  499. cbs.forEach((cb) => {
  500. const lbl = cb.closest('label');
  501. if (cb.checked) return;
  502. const txt = (lbl?.textContent || cb.getAttribute('aria-label') || '').replace(/\s+/g, ' ');
  503. if (/agree|同意|i\s+agree/i.test(txt) || cb.name === 'allCheckboxes') {
  504. try { (lbl || cb).click(); n++; } catch (_) {}
  505. }
  506. });
  507. return n;
  508. }"""
  509. )
  510. except Exception as exc:
  511. log(f"[signup] 勾选同意复选框异常: {exc!r}")
  512. page.wait_for_timeout(600)
  513. # 提交"完成帐户创建"
  514. submit = page.locator('button[type="submit"]').first
  515. if submit.count() == 0:
  516. log("[signup] 未找到 button[type=submit],尝试文本兜底")
  517. for txt in ("完成", "Create account", "Continue", "Finish", "Done", "Agree"):
  518. cand = page.locator(f'button:has-text("{txt}")').first
  519. if cand.count() > 0:
  520. submit = cand
  521. break
  522. if submit.count() == 0:
  523. raise RuntimeError("profile 页未找到提交按钮")
  524. try:
  525. submit.scroll_into_view_if_needed(timeout=2000)
  526. except Exception:
  527. pass
  528. try:
  529. submit.click(timeout=5000)
  530. log("[signup] 已点击完成帐户创建按钮")
  531. except Exception as exc:
  532. log(f"[signup] click submit 失败: {exc!r},回退 force click")
  533. submit.click(timeout=5000, force=True)
  534. # 等待页面真正离开 profile 页
  535. deadline = time.time() + 25
  536. last_url = page.url or ""
  537. while time.time() < deadline:
  538. try:
  539. cur = page.url or ""
  540. still = page.locator('input[name="name"]').count() > 0
  541. if cur != last_url:
  542. log(f"[signup] profile 页跳转 {last_url} -> {cur}")
  543. last_url = cur
  544. if not still and "chatgpt.com" in cur:
  545. log(f"[signup] profile 页已离开,进入 {cur}")
  546. return
  547. if "chatgpt.com" in cur and "/auth" not in cur:
  548. # 已经回到 chatgpt.com 主域
  549. log(f"[signup] 已回到 chatgpt.com: {cur}")
  550. return
  551. except Exception:
  552. pass
  553. time.sleep(0.5)
  554. log(f"[signup] 警告:profile 提交后 25s 未确认离开,url={page.url}")
  555. def _fetch_session(page, log) -> dict:
  556. """读取 chatgpt.com/api/auth/session。"""
  557. log(f"[signup] === 拉取 session: {SESSION_URL} ===")
  558. # 确保 cookies 已写入;先回到 chatgpt.com 主域
  559. if "chatgpt.com" not in (page.url or ""):
  560. try:
  561. page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
  562. page.wait_for_timeout(2000)
  563. except Exception as exc:
  564. log(f"[signup] 回 chatgpt.com 异常: {exc!r}")
  565. deadline = time.time() + 60
  566. last_text = ""
  567. while time.time() < deadline:
  568. try:
  569. resp = page.evaluate(
  570. """async () => {
  571. try {
  572. const r = await fetch('/api/auth/session', { credentials: 'include' });
  573. const text = await r.text();
  574. return { status: r.status, text };
  575. } catch (e) {
  576. return { status: 0, text: '', error: String(e) };
  577. }
  578. }"""
  579. )
  580. text = (resp or {}).get("text") or ""
  581. status = (resp or {}).get("status") or 0
  582. if text and text != last_text:
  583. last_text = text
  584. log(f"[signup] session HTTP {status} 长度 {len(text)} 预览 {text[:200]}")
  585. if status == 200 and text:
  586. import json
  587. try:
  588. data = json.loads(text)
  589. except Exception:
  590. data = None
  591. if isinstance(data, dict) and data.get("accessToken"):
  592. return data
  593. except Exception as exc:
  594. log(f"[signup] fetch session 异常: {exc!r}")
  595. time.sleep(2)
  596. raise TimeoutError("拉取 session 超时(未取到 accessToken)")
  597. def signup_chatgpt(
  598. page,
  599. *,
  600. helper_url: str,
  601. mail_domain: str = "edu.a4sky.com",
  602. mail_poll_interval_sec: int = 4,
  603. mail_poll_max_attempts: int = 60,
  604. log: Callable[[str], None] = print,
  605. on_stage: Callable[[str], None] | None = None,
  606. ) -> dict:
  607. """跑完整注册流,返回 { email, password, session }。"""
  608. def stage(name: str):
  609. log(f"[stage] {name}")
  610. if on_stage:
  611. try:
  612. on_stage(name)
  613. except Exception:
  614. pass
  615. email = build_a4sky_email(mail_domain)
  616. password = _rand_password()
  617. first, last = _rand_name()
  618. year, month, day = _rand_birthday()
  619. log(f"[signup] 生成 email={email} password={password} name={first} {last} bday={year}-{month:02d}-{day:02d}")
  620. stage("打开 chatgpt.com")
  621. page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
  622. page.wait_for_timeout(2000)
  623. # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
  624. inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
  625. if not inp and not _is_password_page(page):
  626. stage("点击注册入口")
  627. _click_signup_entry(page, log)
  628. page.wait_for_load_state("domcontentloaded", timeout=20000)
  629. page.wait_for_timeout(1500)
  630. stage("填写邮箱")
  631. if not _is_password_page(page):
  632. _fill_signup_email(page, email, log)
  633. if _is_password_page(page):
  634. stage("填写密码")
  635. # 邮件助手仅看邮件接收时间,过滤起点用"现在"-30s 比较稳
  636. code_started_ms = int(time.time() * 1000) - 30 * 1000
  637. _fill_password(page, password, log)
  638. else:
  639. code_started_ms = int(time.time() * 1000) - 30 * 1000
  640. # 等待进入验证码页
  641. stage("等待验证码页")
  642. if not _wait_until(lambda: _is_email_verification_page(page), 30):
  643. log(f"[signup] 警告:未确认进入验证码页 URL={page.url},仍尝试拉验证码")
  644. stage("轮询邮箱验证码")
  645. code = poll_signup_code(
  646. helper_url,
  647. email,
  648. started_at_ms=code_started_ms,
  649. interval_sec=mail_poll_interval_sec,
  650. max_attempts=mail_poll_max_attempts,
  651. log=log,
  652. )
  653. stage("填入验证码")
  654. _fill_verification_code(page, code, log)
  655. page.wait_for_timeout(2500)
  656. stage("填姓名/生日")
  657. _fill_name_and_birthday(page, first, last, year, month, day, log)
  658. page.wait_for_timeout(2500)
  659. stage("回 chatgpt.com 拉 session")
  660. session = _fetch_session(page, log)
  661. log(f"[signup] 注册完成 email={email} accessToken={(session.get('accessToken') or '')[:24]}... planType={(session.get('account') or {}).get('planType')}")
  662. return {"email": email, "password": password, "session": session}
  663. def fetch_current_session(page, log: Callable[[str], None] = print) -> dict:
  664. return _fetch_session(page, log)