"""ChatGPT 注册流:用 Playwright 完成"打开 chatgpt.com → 输邮箱 → 填密码 → 邮箱验证码 → 姓名生日 → 拿 session"。 参考 /Users/chendeben/code/chrome_extension/codex-oauth-automation-extension/content/signup-page.js。 """ from __future__ import annotations import random import re import string import time from datetime import datetime from typing import Callable from mail_provider import build_a4sky_email, poll_signup_code SIGNUP_ENTRY_URL = "https://chatgpt.com/" SESSION_URL = "https://chatgpt.com/api/auth/session" EMAIL_INPUT_SELECTORS = [ 'input[type="email"]', 'input[name="email"]', 'input[name="username"]', 'input[id*="email" i]', 'input[placeholder*="email" i]', ] PASSWORD_INPUT_SELECTORS = [ 'input[type="password"]', 'input[name="password"]', 'input[id*="password" i]', ] CONTINUE_BUTTON_TEXTS = ("Continue", "Next", "Sign up", "Create account", "继续", "下一步", "注册", "创建") SIGNUP_TRIGGER_PATTERN = re.compile( r"(免费注册|立即注册|注册|sign\s*up|register|create\s*account)", re.I ) PASSKEY_SKIP_TEXTS = ( "Skip for now", "Not now", "Maybe later", "I'll do this later", "Do this later", "Set up later", "稍后", "暂不", "以后再说", ) PASSKEY_SKIP_RE = re.compile( r"(skip\s+for\s+now|not\s+now|maybe\s+later|do\s+this\s+later|set\s+up\s+later|稍后|暂不|以后再说)", re.I, ) def _rand_password(length: int = 16) -> str: pools = [ random.choice(string.ascii_uppercase), random.choice(string.ascii_lowercase), random.choice(string.digits), random.choice("!@#$%^*"), ] pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^*") for _ in range(length - 4)] random.shuffle(pools) return "".join(pools) def _rand_name() -> tuple[str, str]: firsts = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", "William", "Elizabeth", "David", "Susan", "Daniel", "Sarah", "Thomas", "Karen"] lasts = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson"] return random.choice(firsts), random.choice(lasts) def _rand_birthday() -> tuple[int, int, int]: """随机 1985~2000 年的生日,避开 28 号以后避免月份冲突。""" year = random.randint(1985, 2000) month = random.randint(1, 12) day = random.randint(1, 28) return year, month, day def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool: loc = page.locator(selector) count = loc.count() if count == 0: return False for i in range(count): item = loc.nth(i) try: if item.is_visible() and item.is_enabled(): item.click(timeout=4000) log(f"[signup] 点击 {label or selector} (idx={i}) 成功") return True except Exception as exc: log(f"[signup] 点击 {label or selector} (idx={i}) 失败: {exc!r}") return False def _click_signup_entry(page, log) -> bool: """ChatGPT 首页右上角"注册"按钮。命中即返回 True;命中后等待 ~1s 让导航开始。""" candidates = [ 'a[data-testid="signup-button"]', 'button[data-testid="signup-button"]', 'a:has-text("Sign up")', 'button:has-text("Sign up")', 'a:has-text("注册")', 'button:has-text("注册")', ] for sel in candidates: if _try_click_first_visible(page, sel, log, label=f"signup entry({sel})"): try: page.wait_for_timeout(800) except Exception: pass return True # 文本兜底 try: all_btns = page.locator('a, button, [role="button"], [role="link"]') n = all_btns.count() for i in range(min(n, 200)): el = all_btns.nth(i) try: txt = (el.inner_text(timeout=500) or "").strip() except Exception: continue if txt and SIGNUP_TRIGGER_PATTERN.search(txt) and el.is_visible() and el.is_enabled(): el.click(timeout=3000) log(f"[signup] 点击文本注册入口 {txt!r}") return True except Exception as exc: log(f"[signup] 兜底查找注册入口异常: {exc!r}") return False def _find_visible(page, selectors: list[str]): for sel in selectors: loc = page.locator(sel) count = loc.count() for i in range(count): try: item = loc.nth(i) if item.is_visible(): return item, sel except Exception: continue return None, None def _click_continue(page, log, *, label: str = "continue") -> bool: """通用:点 type=submit / 文本 Continue / Next 等。""" direct = page.locator('button[type="submit"], input[type="submit"]') cnt = direct.count() for i in range(cnt): try: it = direct.nth(i) if it.is_visible() and it.is_enabled(): it.click(timeout=4000) log(f"[signup] {label} 点击 button[type=submit] (idx={i}) 成功") return True except Exception as exc: log(f"[signup] {label} button[type=submit] (idx={i}) 失败: {exc!r}") for txt in CONTINUE_BUTTON_TEXTS: loc = page.locator(f'button:has-text("{txt}")').first if loc.count() == 0: continue try: if loc.is_visible() and loc.is_enabled(): loc.click(timeout=4000) log(f"[signup] {label} 点击 has-text={txt!r} 成功") return True except Exception as exc: log(f"[signup] {label} has-text={txt!r} 失败: {exc!r}") return False def _is_password_page(page) -> bool: try: loc = page.locator(", ".join(PASSWORD_INPUT_SELECTORS)) if loc.count() == 0: return False for i in range(loc.count()): if loc.nth(i).is_visible(): return True except Exception: pass return False def _is_email_verification_page(page) -> bool: """6 位验证码输入页。""" url = (page.url or "").lower() if "/email-verification" in url or "/verify" in url: return True try: sel = ('input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], ' 'input[maxlength="6"], input[maxlength="1"]') loc = page.locator(sel) if loc.count() >= 1: return True except Exception: pass return False def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 250) -> bool: deadline = time.time() + timeout_sec while time.time() < deadline: try: if predicate(): return True except Exception: pass time.sleep(interval_ms / 1000) return False def _is_passkey_enrollment_url(url: str) -> bool: return "create-account-enroll-passkey" in (url or "").lower() def _is_sso_redirect(page) -> bool: """提交邮箱后是否跳转到 SSO 页面(auth.openai.com/sso 或 Keycloak)。""" url = (page.url or "").lower() return "auth.openai.com/sso" in url or "sso.claudeai.life" in url def _handle_passkey_enrollment_if_present(page, log, timeout_sec: int = 25) -> bool: """OpenAI 新账号可能进入 passkey 引导页;注册自动化选择跳过该可选步骤。""" if not _is_passkey_enrollment_url(page.url or ""): return False log(f"[signup] 检测到 passkey 引导页 url={page.url},尝试跳过") clicked = False for txt in PASSKEY_SKIP_TEXTS: for sel in ( f'button:has-text("{txt}")', f'a:has-text("{txt}")', f'[role="button"]:has-text("{txt}")', ): if _try_click_first_visible(page, sel, log, label=f"passkey-skip({txt})"): clicked = True break if clicked: break if not clicked: try: candidates = page.locator('button, a, [role="button"], [role="link"]') n = candidates.count() for i in range(min(n, 120)): el = candidates.nth(i) try: txt = (el.inner_text(timeout=400) or "").strip() aria = el.get_attribute("aria-label") or "" blob = f"{txt} {aria}" if PASSKEY_SKIP_RE.search(blob) and el.is_visible() and el.is_enabled(): el.click(timeout=4000) log(f"[signup] 点击 passkey 跳过候选 {blob!r}") clicked = True break except Exception as exc: log(f"[signup] passkey 候选 idx={i} 点击失败: {exc!r}") except Exception as exc: log(f"[signup] passkey 跳过按钮扫描异常: {exc!r}") if not clicked: log("[signup] 未找到 passkey 跳过按钮,保留当前页继续等待") deadline = time.time() + timeout_sec last_url = page.url or "" while time.time() < deadline: cur = page.url or "" if cur != last_url: log(f"[signup] passkey 页跳转 {last_url} -> {cur}") last_url = cur if not _is_passkey_enrollment_url(cur): log(f"[signup] 已离开 passkey 引导页 url={cur}") return True time.sleep(0.5) log(f"[signup] 警告:passkey 引导页 {timeout_sec}s 内未离开 url={page.url}") return False def _fill_signup_email(page, email: str, log): log(f"[signup] === 提交注册邮箱 {email} ===") # 邮箱页可能由前一步导航触发,需要等一会让 input 挂载 inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS) if not inp: # 等最多 8s:要么邮箱框出现,要么 URL 跳到 auth.openai.com 后再继续等 deadline = time.time() + 8 while time.time() < deadline: inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS) if inp: break time.sleep(0.4) if not inp: # 仍找不到 — 只有当确实还停在 chatgpt.com 主页时才补点注册入口(避免点已跳转的页面把表单关掉) cur_url = (page.url or "").lower() if "chatgpt.com" in cur_url and "auth" not in cur_url and "/email-verification" not in cur_url: log(f"[signup] 邮箱框未挂载且仍在 chatgpt.com 主页,补点一次注册入口 url={cur_url}") if _click_signup_entry(page, log): page.wait_for_load_state("domcontentloaded", timeout=20000) page.wait_for_timeout(1500) # 再等一会 deadline = time.time() + 8 while time.time() < deadline: inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS) if inp: break time.sleep(0.4) else: log(f"[signup] 已离开主页(url={cur_url}),不再点注册入口,仅继续等邮箱框") deadline = time.time() + 8 while time.time() < deadline: inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS) if inp: break time.sleep(0.4) if not inp: raise RuntimeError(f"未找到邮箱输入框 URL={page.url}") log(f"[signup] 命中邮箱输入框 selector={used_sel}") inp.click() inp.fill("") inp.type(email, delay=20) page.wait_for_timeout(300) if not _click_continue(page, log, label="email-continue"): raise RuntimeError("未找到邮箱页的继续按钮") # 等待跳到密码页 / 验证码页 / SSO 页 ok = _wait_until( lambda: _is_password_page(page) or _is_email_verification_page(page) or _is_sso_redirect(page), 25, ) if not ok: raise RuntimeError(f"提交邮箱后未进入密码/验证码/SSO页 URL={page.url}") log(f"[signup] 邮箱已提交 URL={page.url} is_password_page={_is_password_page(page)}") def _fill_password(page, password: str, log): log("[signup] === 填密码 ===") inp, used_sel = _find_visible(page, PASSWORD_INPUT_SELECTORS) if not inp: raise RuntimeError(f"未找到密码输入框 URL={page.url}") log(f"[signup] 命中密码输入框 selector={used_sel}") inp.click() inp.fill("") inp.type(password, delay=20) page.wait_for_timeout(300) submitted_at_ms = int(time.time() * 1000) if not _click_continue(page, log, label="password-continue"): raise RuntimeError("未找到密码页的继续按钮") return submitted_at_ms def _fill_verification_code(page, code: str, log): log(f"[signup] === 填入验证码 {code} ===") # 优先 6 位拆分输入框 split = page.locator('input[maxlength="1"]') n_split = split.count() if n_split >= 6: log(f"[signup] 检测到拆分输入框 count={n_split}") try: split.nth(0).click() except Exception: pass for idx, ch in enumerate(code[:n_split]): try: box = split.nth(idx) box.fill("") box.type(ch, delay=30) except Exception as exc: log(f"[signup] 拆分位 {idx} 输入失败: {exc!r}") return sel = 'input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], input[maxlength="6"]' loc = page.locator(sel).first if loc.count() == 0: raise RuntimeError("未找到验证码输入框") loc.fill("") loc.type(code, delay=30) log("[signup] 已填入单格验证码") page.wait_for_timeout(300) _click_continue(page, log, label="code-continue") def _wait_profile_page_ready(page, log, timeout_sec: int = 30) -> dict: """等待 profile 页可见控件出现,并返回它用的是哪种 UI。""" deadline = time.time() + timeout_sec while time.time() < deadline: try: info = page.evaluate( r"""() => { const visible = (el) => { if (!el) return false; const s = window.getComputedStyle(el); if (s.display === 'none' || s.visibility === 'hidden') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const name = document.querySelector('input[name="name"], input[autocomplete="name"], input[placeholder*="全名"]'); const age = document.querySelector('input[name="age"]'); const yearSpin = document.querySelector('[role="spinbutton"][data-type="year"]'); const monthSpin = document.querySelector('[role="spinbutton"][data-type="month"]'); const daySpin = document.querySelector('[role="spinbutton"][data-type="day"]'); const hiddenBday = document.querySelector('input[name="birthday"]'); // React Aria 下拉:button + listbox 隐藏 select const allButtons = Array.from(document.querySelectorAll('button[aria-haspopup="listbox"], [role="combobox"]')); const matchByLabel = (kw) => allButtons.find((b) => { const t = (b.innerText || b.getAttribute('aria-label') || '').trim(); return t && new RegExp(kw, 'i').test(t); }) || null; const yearBtn = matchByLabel('year|年'); const monthBtn = matchByLabel('month|月'); const dayBtn = matchByLabel('day|天|日'); return { url: location.href, nameVisible: visible(name), ageVisible: visible(age), spinVisible: visible(yearSpin) && visible(monthSpin) && visible(daySpin), selectVisible: visible(yearBtn) && visible(monthBtn) && visible(dayBtn), hasHiddenBday: Boolean(hiddenBday), bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 240), }; }""" ) or {} except Exception as exc: log(f"[signup] profile 页探测异常: {exc!r}") info = {} kind = "unknown" if info.get("ageVisible"): kind = "age" elif info.get("selectVisible"): kind = "select" elif info.get("spinVisible"): kind = "spin" if info.get("nameVisible") and kind != "unknown": log(f"[signup] profile 页就绪 url={info.get('url')} mode={kind} hidden_bday={info.get('hasHiddenBday')}") return {"kind": kind, **info} time.sleep(0.3) log(f"[signup] profile 页等待超时 url={page.url}") return {"kind": "unknown", "url": page.url} def _fill_name_and_birthday(page, first: str, last: str, year: int, month: int, day: int, log): log(f"[signup] === 姓名/生日 {first} {last} {year}-{month:02d}-{day:02d} ===") profile_info = _wait_profile_page_ready(page, log, timeout_sec=30) if profile_info["kind"] == "unknown": raise RuntimeError(f"profile 页未识别可见控件 url={page.url}") full_name = f"{first} {last}" name_input, name_sel = _find_visible(page, [ 'input[name="name"]', 'input[autocomplete="name"]', 'input[placeholder*="全名"]', ]) if not name_input: raise RuntimeError("未找到姓名输入框") log(f"[signup] 命中姓名输入框 selector={name_sel}") name_input.click() name_input.fill("") name_input.type(full_name, delay=20) log(f"[signup] 姓名已填写: {full_name}") page.wait_for_timeout(400) kind = profile_info["kind"] bday_value = f"{year:04d}-{month:02d}-{day:02d}" if kind == "age": age = max(18, datetime.now().year - year) # React Aria 用