| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826 |
- """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 _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("未找到邮箱页的继续按钮")
- # 等待跳到密码页
- ok = _wait_until(lambda: _is_password_page(page) or _is_email_verification_page(page), 25)
- if not ok:
- raise RuntimeError(f"提交邮箱后未进入密码/验证码页 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 用 <label> 浮在 input 上面拦截了 click,所以走 focus+JS 赋值
- try:
- ok = page.evaluate(
- r"""(ageStr) => {
- const el = document.querySelector('input[name="age"]');
- if (!el) return false;
- el.focus();
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
- setter.call(el, '');
- el.dispatchEvent(new Event('input', { bubbles: true }));
- setter.call(el, String(ageStr));
- el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: String(ageStr), bubbles: true }));
- el.dispatchEvent(new Event('input', { bubbles: true }));
- el.dispatchEvent(new Event('change', { bubbles: true }));
- el.blur();
- return el.value;
- }""",
- str(age),
- )
- log(f"[signup] age 已通过 JS 设置 value={ok!r}")
- except Exception as exc:
- log(f"[signup] JS 设 age 失败: {exc!r},回退键盘输入")
- try:
- # 用 page.keyboard:先 focus 后 type
- page.evaluate("() => document.querySelector('input[name=\"age\"]').focus()")
- page.keyboard.type(str(age), delay=30)
- log(f"[signup] 键盘输入 age={age} 成功")
- except Exception as exc2:
- raise RuntimeError(f"填年龄失败(JS+键盘均失败): {exc!r} / {exc2!r}")
- # 校验
- try:
- real = page.evaluate("() => document.querySelector('input[name=\"age\"]').value")
- log(f"[signup] age input 实际 value={real!r} (期望 {age})")
- except Exception:
- pass
- elif kind == "spin":
- log("[signup] 使用 spinbutton 三段式生日")
- for kw, val in (("year", year), ("month", f"{month:02d}"), ("day", f"{day:02d}")):
- try:
- ok = page.evaluate(
- r"""([sel, valStr]) => {
- const el = document.querySelector(sel);
- if (!el) return false;
- el.focus();
- document.execCommand('selectAll', false, null);
- for (const ch of String(valStr)) {
- el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: 'Digit'+ch, bubbles: true }));
- el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, code: 'Digit'+ch, bubbles: true }));
- el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true }));
- el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
- }
- el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
- el.blur();
- return true;
- }""",
- [f'[role="spinbutton"][data-type="{kw}"]', str(val)],
- )
- log(f"[signup] spin {kw} <- {val} ok={ok}")
- except Exception as exc:
- log(f"[signup] spin {kw} 失败: {exc!r}")
- # spinbutton 模式有时也会同步 hidden birthday;保险起见显式设置
- try:
- page.evaluate(
- r"""([sel, val]) => {
- const el = document.querySelector(sel);
- if (!el) return false;
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
- setter.call(el, val);
- el.dispatchEvent(new Event('input', { bubbles: true }));
- el.dispatchEvent(new Event('change', { bubbles: true }));
- return true;
- }""",
- ['input[name="birthday"]', bday_value],
- )
- except Exception:
- pass
- elif kind == "select":
- log("[signup] 使用 React Aria 下拉式生日")
- # year/month/day 各自是 button[aria-haspopup=listbox],点开后选 option
- # 用 inner_text 含关键字定位三个 button;click 失败则回退 dispatchEvent
- def click_select_option(label_kw_re: str, option_value: str | int) -> bool:
- buttons = page.locator('button[aria-haspopup="listbox"], [role="combobox"]')
- n = buttons.count()
- target_idx = -1
- for i in range(min(n, 80)):
- b = buttons.nth(i)
- try:
- if not b.is_visible():
- continue
- txt = (b.inner_text(timeout=400) or "").strip()
- aria = b.get_attribute("aria-label") or ""
- blob = f"{txt} {aria}"
- except Exception:
- continue
- if re.search(label_kw_re, blob, re.I):
- target_idx = i
- break
- if target_idx < 0:
- log(f"[signup] 未找到下拉按钮 kw={label_kw_re}")
- return False
- target = buttons.nth(target_idx)
- try:
- target.click(timeout=3000)
- except Exception:
- # label/positioner 拦截:force click + JS click 双兜底
- try:
- target.click(timeout=3000, force=True)
- except Exception:
- try:
- target.evaluate("el => el.click()")
- except Exception as exc:
- log(f"[signup] 三种 click 都失败 kw={label_kw_re}: {exc!r}")
- return False
- page.wait_for_timeout(350)
- opt = page.locator(f'[role="option"]:has-text("{option_value}")').first
- if opt.count() == 0:
- opt = page.locator(f'[role="option"]:has-text("{int(option_value)}")').first
- if opt.count() == 0:
- log(f"[signup] 未找到 option={option_value}")
- return False
- try:
- opt.click(timeout=3000)
- except Exception:
- try:
- opt.click(timeout=3000, force=True)
- except Exception:
- try:
- opt.evaluate("el => el.click()")
- except Exception as exc:
- log(f"[signup] 选 option 三种 click 都失败: {exc!r}")
- return False
- log(f"[signup] 下拉 kw={label_kw_re} 已选 {option_value}")
- return True
- click_select_option(r"year|年", year)
- page.wait_for_timeout(300)
- click_select_option(r"month|月", month)
- page.wait_for_timeout(300)
- click_select_option(r"day|天|日", day)
- page.wait_for_timeout(300)
- # 验证 hidden birthday(如果存在)确实被写入
- try:
- hidden_val = page.evaluate(
- r"""() => {
- const el = document.querySelector('input[name="birthday"]');
- return el ? el.value || '' : '__no_hidden__';
- }"""
- )
- log(f"[signup] hidden birthday 当前值={hidden_val!r} (期望 {bday_value})")
- except Exception:
- pass
- # 同意复选框(如出现)
- try:
- page.evaluate(
- r"""() => {
- const cbs = document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"], input[type="checkbox"]');
- let n = 0;
- cbs.forEach((cb) => {
- const lbl = cb.closest('label');
- if (cb.checked) return;
- const txt = (lbl?.textContent || cb.getAttribute('aria-label') || '').replace(/\s+/g, ' ');
- if (/agree|同意|i\s+agree/i.test(txt) || cb.name === 'allCheckboxes') {
- try { (lbl || cb).click(); n++; } catch (_) {}
- }
- });
- return n;
- }"""
- )
- except Exception as exc:
- log(f"[signup] 勾选同意复选框异常: {exc!r}")
- page.wait_for_timeout(600)
- # 提交"完成帐户创建"
- submit = page.locator('button[type="submit"]').first
- if submit.count() == 0:
- log("[signup] 未找到 button[type=submit],尝试文本兜底")
- for txt in ("完成", "Create account", "Continue", "Finish", "Done", "Agree"):
- cand = page.locator(f'button:has-text("{txt}")').first
- if cand.count() > 0:
- submit = cand
- break
- if submit.count() == 0:
- raise RuntimeError("profile 页未找到提交按钮")
- try:
- submit.scroll_into_view_if_needed(timeout=2000)
- except Exception:
- pass
- try:
- submit.click(timeout=5000)
- log("[signup] 已点击完成帐户创建按钮")
- except Exception as exc:
- log(f"[signup] click submit 失败: {exc!r},回退 force click")
- submit.click(timeout=5000, force=True)
- # 等待页面真正离开 profile 页
- deadline = time.time() + 25
- last_url = page.url or ""
- while time.time() < deadline:
- try:
- cur = page.url or ""
- still = page.locator('input[name="name"]').count() > 0
- if cur != last_url:
- log(f"[signup] profile 页跳转 {last_url} -> {cur}")
- last_url = cur
- if not still and "chatgpt.com" in cur:
- log(f"[signup] profile 页已离开,进入 {cur}")
- return
- if "chatgpt.com" in cur and "/auth" not in cur:
- # 已经回到 chatgpt.com 主域
- log(f"[signup] 已回到 chatgpt.com: {cur}")
- return
- except Exception:
- pass
- time.sleep(0.5)
- log(f"[signup] 警告:profile 提交后 25s 未确认离开,url={page.url}")
- def _fetch_session(page, log) -> dict:
- """读取 chatgpt.com/api/auth/session。"""
- log(f"[signup] === 拉取 session: {SESSION_URL} ===")
- # 确保 cookies 已写入;先回到 chatgpt.com 主域
- if "chatgpt.com" not in (page.url or ""):
- try:
- page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
- page.wait_for_timeout(2000)
- except Exception as exc:
- log(f"[signup] 回 chatgpt.com 异常: {exc!r}")
- deadline = time.time() + 60
- last_text = ""
- while time.time() < deadline:
- try:
- resp = page.evaluate(
- """async () => {
- try {
- const r = await fetch('/api/auth/session', { credentials: 'include' });
- const text = await r.text();
- return { status: r.status, text };
- } catch (e) {
- return { status: 0, text: '', error: String(e) };
- }
- }"""
- )
- text = (resp or {}).get("text") or ""
- status = (resp or {}).get("status") or 0
- if text and text != last_text:
- last_text = text
- log(f"[signup] session HTTP {status} 长度 {len(text)} 预览 {text[:200]}")
- if status == 200 and text:
- import json
- try:
- data = json.loads(text)
- except Exception:
- data = None
- if isinstance(data, dict) and data.get("accessToken"):
- return data
- except Exception as exc:
- log(f"[signup] fetch session 异常: {exc!r}")
- time.sleep(2)
- raise TimeoutError("拉取 session 超时(未取到 accessToken)")
- def signup_chatgpt(
- page,
- *,
- helper_url: str,
- mail_domain: str = "edu.a4sky.com",
- mail_poll_interval_sec: int = 4,
- mail_poll_max_attempts: int = 60,
- log: Callable[[str], None] = print,
- on_stage: Callable[[str], None] | None = None,
- ) -> dict:
- """跑完整注册流,返回 { email, password, session }。"""
- def stage(name: str):
- log(f"[stage] {name}")
- if on_stage:
- try:
- on_stage(name)
- except Exception:
- pass
- email = build_a4sky_email(mail_domain)
- password = _rand_password()
- first, last = _rand_name()
- year, month, day = _rand_birthday()
- log(f"[signup] 生成 email={email} password={password} name={first} {last} bday={year}-{month:02d}-{day:02d}")
- stage("打开 chatgpt.com")
- page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
- page.wait_for_timeout(2000)
- # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
- inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
- if not inp and not _is_password_page(page):
- stage("点击注册入口")
- _click_signup_entry(page, log)
- page.wait_for_load_state("domcontentloaded", timeout=20000)
- page.wait_for_timeout(1500)
- stage("填写邮箱")
- if not _is_password_page(page):
- _fill_signup_email(page, email, log)
- if _is_password_page(page):
- stage("填写密码")
- # 邮件助手仅看邮件接收时间,过滤起点用"现在"-30s 比较稳
- code_started_ms = int(time.time() * 1000) - 30 * 1000
- _fill_password(page, password, log)
- else:
- code_started_ms = int(time.time() * 1000) - 30 * 1000
- # 等待进入验证码页
- stage("等待验证码页")
- if not _wait_until(lambda: _is_email_verification_page(page), 30):
- log(f"[signup] 警告:未确认进入验证码页 URL={page.url},仍尝试拉验证码")
- stage("轮询邮箱验证码")
- code = poll_signup_code(
- helper_url,
- email,
- started_at_ms=code_started_ms,
- interval_sec=mail_poll_interval_sec,
- max_attempts=mail_poll_max_attempts,
- log=log,
- )
- stage("填入验证码")
- _fill_verification_code(page, code, log)
- page.wait_for_timeout(2500)
- stage("填姓名/生日")
- _fill_name_and_birthday(page, first, last, year, month, day, log)
- page.wait_for_timeout(2500)
- stage("处理 passkey 引导")
- _handle_passkey_enrollment_if_present(page, log)
- stage("回 chatgpt.com 拉 session")
- session = _fetch_session(page, log)
- log(f"[signup] 注册完成 email={email} accessToken={(session.get('accessToken') or '')[:24]}... planType={(session.get('account') or {}).get('planType')}")
- return {"email": email, "password": password, "session": session}
- def fetch_current_session(page, log: Callable[[str], None] = print) -> dict:
- return _fetch_session(page, log)
|