chatgpt_signup.py 28 KB

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