| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160 |
- """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 用 <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
- stage("打开 chatgpt.com")
- page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
- page.wait_for_timeout(2000)
- 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}")
- # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
- 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)
- # ---------------------------------------------------------------------------
- # SSO 注册流(aef.claudeai.life 域名 → Keycloak SSO → 注册 → 自动登录)
- # ---------------------------------------------------------------------------
- SSO_MAIL_DOMAIN = "aef.claudeai.life"
- SSO_KEYCLOAK_HOST = "sso.claudeai.life"
- SSO_INTERSTITIAL_HOST = "external.auth.openai.com"
- def _build_sso_email(domain: str = SSO_MAIL_DOMAIN) -> str:
- ts = datetime.now().strftime("%Y%m%d%H%M%S")
- return f"n{ts}@{domain}"
- def _is_sso_redirect_page(page) -> bool:
- url = (page.url or "").lower()
- return SSO_KEYCLOAK_HOST in url or "auth.openai.com/sso" in url
- def _is_keycloak_login_page(page) -> bool:
- url = (page.url or "").lower()
- return SSO_KEYCLOAK_HOST in url and "login-actions" in url
- def _is_keycloak_register_page(page) -> bool:
- url = (page.url or "").lower()
- return SSO_KEYCLOAK_HOST in url and "registration" in url
- def _is_interstitial_page(page) -> bool:
- url = (page.url or "").lower()
- return SSO_INTERSTITIAL_HOST in url and ("interstitial" in url or "signin-consent" in url)
- def _wait_for_sso_or_keycloak(page, log, timeout_sec: int = 30) -> bool:
- """等待页面跳转到 SSO/Keycloak 登录页。"""
- return _wait_until(
- lambda: _is_sso_redirect_page(page) or _is_keycloak_login_page(page),
- timeout_sec,
- )
- def _click_sso_workspace_option(page, log, timeout_sec: int = 15) -> bool:
- """在 auth.openai.com/sso 选择 Workspace 入口,进入 Keycloak。"""
- deadline = time.time() + timeout_sec
- while time.time() < deadline:
- for sel in (
- 'button:has-text("Workspace")',
- '[role="button"]:has-text("Workspace")',
- 'a:has-text("Workspace")',
- 'button:has-text("工作区")',
- '[role="button"]:has-text("工作区")',
- 'a:has-text("工作区")',
- ):
- if _try_click_first_visible(page, sel, log, label=f"sso-workspace({sel})"):
- page.wait_for_timeout(1000)
- return True
- try:
- candidates = page.locator('button, a, [role="button"], [role="link"]')
- count = candidates.count()
- for i in range(min(count, 100)):
- el = candidates.nth(i)
- try:
- text = (el.inner_text(timeout=500) or "").strip()
- lowered = text.lower()
- if not text:
- continue
- if any(skip in lowered for skip in ("google", "microsoft", "apple", "password")):
- continue
- if ("workspace" in lowered or "single sign-on" in lowered or "sso" in lowered) and el.is_visible() and el.is_enabled():
- el.click(timeout=3000)
- log(f"[sso] 点击 Workspace 入口 text={text!r}")
- page.wait_for_timeout(1000)
- return True
- except Exception:
- continue
- except Exception as exc:
- log(f"[sso] 兜底查找 Workspace 入口异常: {exc!r}")
- time.sleep(0.5)
- return False
- def _click_keycloak_register(page, log, timeout_sec: int = 15) -> bool:
- """在 Keycloak 登录页点击 Register 链接。"""
- deadline = time.time() + timeout_sec
- while time.time() < deadline:
- for sel in (
- 'a:has-text("Register")',
- 'a:has-text("register")',
- 'a:has-text("注册")',
- 'a[href*="registration"]',
- 'a.register-link',
- '#kc-registration a',
- '#kc-registration-container a',
- ):
- if _try_click_first_visible(page, sel, log, label=f"keycloak-register({sel})"):
- page.wait_for_timeout(800)
- return True
- # 文本兜底
- try:
- links = page.locator("a")
- n = links.count()
- for i in range(min(n, 100)):
- el = links.nth(i)
- try:
- txt = (el.inner_text(timeout=400) or "").strip().lower()
- if txt and ("register" in txt or "注册" in txt) and el.is_visible():
- el.click(timeout=3000)
- log(f"[sso] 点击 Register 链接 text={txt!r}")
- page.wait_for_timeout(800)
- return True
- except Exception:
- continue
- except Exception as exc:
- log(f"[sso] 兜底查找 Register 链接异常: {exc!r}")
- time.sleep(0.5)
- return False
- def _fill_keycloak_registration(page, email: str, first: str, last: str, log):
- """填写 Keycloak 注册表单:firstName, lastName, email, password, password-confirm。"""
- log(f"[sso] === 填 Keycloak 注册表单 email={email} name={first} {last} ===")
- field_map = [
- ("firstName", first, [
- 'input[name="firstName"]',
- 'input#firstName',
- 'input[id*="firstName" i]',
- ]),
- ("lastName", last, [
- 'input[name="lastName"]',
- 'input#lastName',
- 'input[id*="lastName" i]',
- ]),
- ("email", email, [
- 'input[name="email"]',
- 'input#email',
- 'input[type="email"]',
- 'input[id*="email" i]',
- ]),
- ("password", email, [
- 'input[name="password"]',
- 'input#password',
- 'input[type="password"]:nth-of-type(1)',
- ]),
- ("password-confirm", email, [
- 'input[name="password-confirm"]',
- 'input#password-confirm',
- ]),
- ]
- for field_name, value, selectors in field_map:
- inp, used_sel = _find_visible(page, selectors)
- if not inp:
- deadline = time.time() + 8
- while time.time() < deadline:
- inp, used_sel = _find_visible(page, selectors)
- if inp:
- break
- time.sleep(0.4)
- if not inp:
- if field_name in ("password-confirm",):
- log(f"[sso] 字段 {field_name} 未找到,跳过(可能不存在)")
- continue
- raise RuntimeError(f"Keycloak 注册表单未找到 {field_name} 输入框 URL={page.url}")
- log(f"[sso] 命中 {field_name} 输入框 selector={used_sel}")
- inp.click()
- inp.fill("")
- inp.type(value, delay=20)
- page.wait_for_timeout(200)
- page.wait_for_timeout(500)
- # 提交注册
- submitted = False
- for sel in (
- 'input[type="submit"]',
- 'button[type="submit"]',
- 'input[value*="Register" i]',
- 'input[value*="注册"]',
- 'button:has-text("Register")',
- 'button:has-text("注册")',
- ):
- if _try_click_first_visible(page, sel, log, label=f"keycloak-submit({sel})"):
- submitted = True
- break
- if not submitted:
- raise RuntimeError(f"Keycloak 注册表单未找到提交按钮 URL={page.url}")
- log("[sso] 已提交 Keycloak 注册表单")
- def _handle_interstitial_confirm(page, log, timeout_sec: int = 30) -> bool:
- """处理 external.auth.openai.com 的 SSO 批准页面。"""
- log(f"[sso] 等待 SSO 批准页 url={page.url}")
- if not _wait_until(lambda: _is_interstitial_page(page), timeout_sec):
- log(f"[sso] 未检测到 SSO 批准页 url={page.url},继续")
- return False
- log(f"[sso] 检测到 SSO 批准页 url={page.url}")
- page.wait_for_timeout(1500)
- # 点击 Approve sign-in / Confirm / Continue / 确认
- for sel in (
- 'button[type="submit"]',
- 'input[type="submit"]',
- 'button:has-text("Approve sign-in")',
- 'button:has-text("Approve")',
- 'button:has-text("Confirm")',
- 'button:has-text("Continue")',
- 'button:has-text("批准")',
- 'button:has-text("确认")',
- 'button:has-text("继续")',
- 'input[value*="Approve" i]',
- 'input[value*="Confirm" i]',
- 'input[value*="Continue" i]',
- ):
- if _try_click_first_visible(page, sel, log, label=f"interstitial-confirm({sel})"):
- log("[sso] 已点击 interstitial 批准按钮")
- return True
- # 如果页面有隐藏的自动提交 form,尝试 JS 提交
- try:
- auto_submitted = page.evaluate(r"""() => {
- const forms = document.querySelectorAll('form');
- for (const f of forms) {
- if (f.querySelector('input[name="interstitial_token"]')) {
- f.submit();
- return true;
- }
- }
- return false;
- }""")
- if auto_submitted:
- log("[sso] 已通过 JS 自动提交 interstitial form")
- return True
- except Exception as exc:
- log(f"[sso] JS 自动提交 interstitial 异常: {exc!r}")
- log(f"[sso] 警告:interstitial 页面未找到批准按钮 url={page.url}")
- return False
- def signup_chatgpt_sso(
- page,
- *,
- sso_mail_domain: str = SSO_MAIL_DOMAIN,
- log: Callable[[str], None] = print,
- on_stage: Callable[[str], None] | None = None,
- ) -> dict:
- """SSO 注册流:chatgpt.com → 输入 SSO 邮箱 → Keycloak 注册 → 批准。
- 返回 { email, password, session }。
- """
- def stage(name: str):
- log(f"[stage] {name}")
- if on_stage:
- try:
- on_stage(name)
- except Exception:
- pass
- stage("打开 chatgpt.com")
- page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
- page.wait_for_timeout(2000)
- email = _build_sso_email(sso_mail_domain)
- first, last = _rand_name()
- log(f"[sso] 生成 email={email} name={first} {last}")
- # 入口:点击注册
- 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)
- # 填写 SSO 邮箱并提交
- stage("填写 SSO 邮箱")
- _fill_signup_email(page, email, log)
- # 等待跳转到 SSO 页面(auth.openai.com/sso 或直接到 Keycloak)
- stage("等待 SSO 跳转")
- if not _wait_for_sso_or_keycloak(page, log, timeout_sec=30):
- log(f"[sso] 警告:未检测到 SSO 跳转 url={page.url},尝试继续")
- page.wait_for_timeout(2000)
- log(f"[sso] 当前页面 url={page.url}")
- # 如果在 auth.openai.com/sso,先选择 Workspace,再等待跳转到 Keycloak
- if "auth.openai.com/sso" in (page.url or "").lower():
- stage("选择 SSO Workspace")
- if not _click_sso_workspace_option(page, log, timeout_sec=15):
- log(f"[sso] 警告:未能自动点击 Workspace 入口 url={page.url}")
- stage("等待 Keycloak 跳转")
- _wait_until(lambda: _is_keycloak_login_page(page) or _is_keycloak_register_page(page), 20)
- page.wait_for_timeout(1500)
- # 在 Keycloak 登录页点击 Register
- stage("点击 Register")
- if not _is_keycloak_register_page(page):
- if not _click_keycloak_register(page, log):
- raise RuntimeError(f"未能点击 Keycloak Register 链接 url={page.url}")
- _wait_until(lambda: _is_keycloak_register_page(page), 15)
- page.wait_for_timeout(1000)
- log(f"[sso] 进入 Keycloak 注册页 url={page.url}")
- # 填写注册表单
- stage("填写 Keycloak 注册表单")
- _fill_keycloak_registration(page, email, first, last, log)
- page.wait_for_timeout(3000)
- # 注册完成后可能跳到 interstitial 批准页
- stage("处理 SSO 批准")
- _handle_interstitial_confirm(page, log, timeout_sec=30)
- page.wait_for_timeout(3000)
- # 处理 passkey 引导(如果出现)
- stage("处理 passkey 引导")
- _handle_passkey_enrollment_if_present(page, log)
- log(f"[sso] SSO 注册完成 email={email},等待 CPA Codex OAuth 授权")
- return {"email": email, "password": email, "session": {}}
|