Files
gpt/account-manager/node/cloak_reg_runner.mjs
T
432539 fef45134d5 Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2026-08-05 14:54:32 +08:00

1468 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 浏览器注册 wrapper —— 用 CloakBrowser(隐身 Chromium,指纹在 C++ 源码层随机,开箱即用)
// 驱动真实 ChatGPT / OpenAI 注册 UI 完成注册,作为「账号注册」的第二种方式(第一种是
// get-rt.js 的 HTTP 协议注册)。设计上与协议注册共用同一套外围资源:
// · 代理池:由 Python 侧随机选一个代理,注入 CloakBrowser 的 proxyhttp://user:pass@host:port);
// · 邮箱池 OTP:验证码依旧从 Flask /api/reg/inbox 读(IMAP / 取件API),与协议模式同一数据源;
// · 接码:需要手机验证时复用 smscode / smsbower / hero-sms 模块(按 settings.sms.provider);
// · 日志:逐行 JSON 写 stderr{level,msg,account}),沿用协议注册那套中文短语,前端浮层直接展示;
// · 结果:stdout 输出一段 JSON(形状对齐 reg_runner),由 Python 的 _save_registered_account 落库。
//
// 为什么这样拿 RT:浏览器完成注册后即持有 auth.openai.com 的登录会话(cookie)。我们让浏览器
// 从 codex 客户端的 /oauth/authorizePKCE)开始,注册收尾时服务端会 302 到
// redirect_uri=http://localhost:1455/auth/callback?code=…。我们用 route 拦截这个跳转拿到
// authorization code,再用同一 context(同 cookie/同代理)POST /oauth/token 换 refresh_token。
// 这与协议模式换 RT 的原理一致,区别只是「授权页由真实浏览器走完」。
//
// 输入(stdin JSON):{ email, settings, proxy, otpBase, headless, licenseKey, password? }
// 输出(stdout JSON):成功 { ok:true, refreshToken, accessToken, clientId, password, subOk:false }
// 失败 { ok:false, registered?, banned?, error }
import crypto from 'node:crypto';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { launchContext } from 'cloakbrowser';
// 2FA 诊断落文件:Flask 侧 _pump_stderr 会过滤 [ 开头的诊断行,导致真实 E2E 注册流里的 2FA
// 分支信息看不到。这里把 [2FA-DIAG] 同步追加到固定文件,事后可读,定位「新会话点开关后 enroll
// 对话框到底出没出」。仅诊断用,稳定后可移除。
const _2FA_DIAG_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '_2fa_diag.log');
function diag2fa(msg) {
const line = `[2FA-DIAG] ${new Date().toISOString()} ${msg}`;
try { process.stderr.write(line + '\n'); } catch { /* */ }
try { appendFileSync(_2FA_DIAG_FILE, line + '\n'); } catch { /* */ }
}
import { extractCode } from './plus-mailbox.js';
import { resolveRegPassword } from './reg_password.mjs';
import { generateFingerprint } from './fingerprint.js';
import * as smscode from './smscode.js';
import * as smsbower from './smsbower.js';
import * as herosms from './hero-sms.js';
// —— stdout 纪律:CloakBrowser / 底层库可能往 stdout 打印(下载进度、告警),会污染结果 JSON。
// 把 console.* 与「非结果」的 stdout 全部改道 stderr,最终结果用保存的原始 writer 独占 stdout。
const realStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = process.stderr.write.bind(process.stderr);
const _errWrite = (...a) => { try { process.stderr.write(a.map(String).join(' ') + '\n'); } catch { /* ignore */ } };
console.log = _errWrite;
console.info = _errWrite;
console.warn = _errWrite;
console.debug = _errWrite;
const OAUTH_ISSUER = 'https://auth.openai.com';
// codex 客户端:/oauth/authorize?screen_hint=signup 直达注册,但 codex 授权流会强制手机验证
//(这是我们一直卡手机、skip-phone 拿不到 AT 的根因)。仅保留给「后续绑手机拿 RT」用。
const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const OAUTH_REDIRECT_URI = 'http://localhost:1455/auth/callback';
const OAUTH_SCOPE = 'openid profile email offline_access';
// ChatGPT 网页客户端(来自真实抓包 8.3chatgpt.com.har):走 chatgpt.com 普通 signup
// 一般不强制手机,注册完成即在 chatgpt.com 建立登录态 → 能直接拿「会话 cookie(CK)+网页 AT」。
// 手机 + codex RT 留到后续「批量绑手机拿RT」再做。
const WEB_CLIENT_ID = 'app_X8zY6vW2pQ9tR3dE7nK1jL5gH';
const WEB_REDIRECT_URI = 'https://chatgpt.com/api/auth/callback/openai';
const WEB_SCOPE = 'openid email profile offline_access model.request model.read organization.read organization.write';
function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { buf += c; });
process.stdin.on('end', () => resolve(buf));
});
}
function emitLog(level, msg, account) {
try { process.stderr.write(JSON.stringify({ level, msg: String(msg), account: account || '' }) + '\n'); } catch { /* ignore */ }
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
// 随机成年生日 YYYY-MM-DD18~48 岁),与 fingerprint.randomAdultBirthdate 口径一致。
function randomAdultBirthdate() {
const nowYear = new Date().getUTCFullYear();
const age = 18 + crypto.randomInt(0, 31);
const month = 1 + crypto.randomInt(0, 12);
const day = 1 + crypto.randomInt(0, 28);
return `${nowYear - age}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
const FIRST = ['James', 'Michael', 'David', 'Emily', 'Emma', 'Olivia', 'Daniel', 'Sarah', 'Grace', 'Ryan', 'Laura', 'Nathan'];
const LAST = ['Smith', 'Johnson', 'Brown', 'Miller', 'Davis', 'Wilson', 'Moore', 'Taylor', 'Anderson', 'Clark', 'Walker', 'Young'];
function randomFullName() { return `${FIRST[crypto.randomInt(0, FIRST.length)]} ${LAST[crypto.randomInt(0, LAST.length)]}`; }
// smsbower / sms-activate 的国家 id → { iso2, dial }。覆盖 settings 里配置的候选(越南/肯尼亚/
// 印尼/美国/菲律宾/阿根廷)+ 常见国家。用途:接码平台返回的号码是「含国码的完整号」,而 OpenAI
// 手机页默认停在美国 +1,必须按号码所属国家把页面上的国家选对,否则号码与国码对不上、永远收不到码。
const SMS_COUNTRY = {
0: { iso2: 'ru', dial: '7' }, 4: { iso2: 'ph', dial: '63' }, 6: { iso2: 'id', dial: '62' },
8: { iso2: 'ke', dial: '254' }, 10: { iso2: 'vn', dial: '84' }, 16: { iso2: 'gb', dial: '44' },
22: { iso2: 'in', dial: '91' }, 36: { iso2: 'ca', dial: '1' }, 39: { iso2: 'ar', dial: '54' },
187: { iso2: 'us', dial: '1' },
};
// 常见国码(做「号码前缀最长匹配」剥离国码,得到本国号;hintDial 优先)。
const DIAL_CODES = ['1', '7', '20', '27', '33', '34', '39', '44', '49', '52', '54', '55', '60', '61', '62', '63', '66', '81', '84', '90', '91', '234', '254', '351', '380', '852', '886'];
function splitDial(digits, hintDial) {
const d = String(digits || '');
if (hintDial && d.startsWith(hintDial)) return { dial: hintDial, national: d.slice(hintDial.length) };
for (const c of [...DIAL_CODES].sort((a, b) => b.length - a.length)) {
if (d.startsWith(c)) return { dial: c, national: d.slice(c.length) };
}
return { dial: '', national: d };
}
// 把 Python 传来的 proxy 对象转成 CloakBrowser/Playwright 认的字符串(与 get-rt.js getProxyAgent 同构)。
function proxyToString(proxy) {
if (!proxy || !proxy.host || !proxy.port) return undefined;
const auth = proxy.username
? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || '')}@`
: '';
return `http://${auth}${proxy.host}:${proxy.port}`;
}
// PKCE(与协议模式一致)。
function generatePkce() {
const verifier = crypto.randomBytes(64).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// ==================== UI 小工具(尽量鲁棒,找不到就返回 null,不抛错) ====================
// 返回第一个「可见」的定位器。提速:不再对每个候选各等满 timeout(N 个选择器最坏 N×timeout),
// 改为在 timeout 内轮询所有候选、命中即返回,总耗时被 timeout 封顶;不存在时也快速返回。
async function firstVisible(page, selectors, timeout = 1200) {
const deadline = Date.now() + timeout;
for (;;) {
for (const sel of selectors) {
try {
const loc = page.locator(sel).first();
if (await loc.isVisible()) return loc;
} catch { /* 试下一个 */ }
}
if (Date.now() >= deadline) return null;
await sleep(120);
}
}
// 建号后兜底定位「验证码输入框」:精确选择器失配时,取首个可见/可编辑/为空、且非
// password/email/tel 的输入框(此阶段唯一要输入的就是邮箱验证码)。返回 locator 或 null。
async function findGenericOtpBox(page) {
const cand = page.locator(
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="password"]):not([type="email"]):not([type="tel"])',
);
const n = await cand.count().catch(() => 0);
for (let i = 0; i < n; i++) {
const el = cand.nth(i);
try {
if (!(await el.isVisible())) continue;
if (!(await el.isEnabled())) continue;
if ((await el.getAttribute('readonly')) !== null) continue;
if (String(await el.inputValue().catch(() => '')).trim() !== '') continue;
return el;
} catch { /* 试下一个 */ }
}
return null;
}
// 提交当前步:优先点主提交按钮 / 精确文案 "Continue"(用 text-is 精确匹配,避免误点
// "Continue with Google/Apple/Microsoft" 这些第三方登录按钮),否则回退按回车提交表单。
async function submitStep(page) {
const btn = await firstVisible(page, [
'button[type="submit"]',
'button:text-is("Continue")',
'button:text-is("Next")',
'button:text-is("Sign up")',
], 1500);
if (btn) { await btn.click().catch(() => {}); return; }
await page.keyboard.press('Enter').catch(() => {});
}
// 提交某步后让页面稳定再判定:等 DOM 就绪 + 极短延时。提速:真正的「离开该步」判定交给
// waitLeaveType 用条件轮询处理,这里只做一次轻量稳定(1200ms→350ms),不再固定长等。
async function settle(page) {
try { await page.waitForLoadState('domcontentloaded', { timeout: 6000 }); } catch { /* ignore */ }
await sleep(350);
}
// 提交某步后等页面「真正离开该步」再继续——OpenAI 是 SPA,提交后同一页会先进入「校验/跳转中」
// 的过渡态(输入框被禁用/仍带旧值),若立即重新判定会把过渡页当成同一步二次处理(历史 bug:
// 邮箱验证通过后又回到验证码分支、对已禁用的框重填而报「未能填入」)。轮询到类型变化即返回。
async function waitLeaveType(page, type, ms = 20000) {
const start = Date.now();
// 提速:轮询间隔 1000ms→400ms,页面一离开该步立即返回;仍保留最大超时上限防卡死。
while (Date.now() - start < ms) {
await sleep(400);
const t = classifyPage(await detectPage(page));
if (t !== type) return t;
}
return type;
}
// 把当前页可见输入框/按钮/URL 转储到 stderr(原始行),便于按真实 DOM 校准选择器。
async function dumpDiag(page) {
try {
const diag = await page.evaluate(() => {
const ins = [...document.querySelectorAll('input')]
.filter((i) => i.offsetParent !== null)
.map((i) => `${i.name || i.id || '?'}|${i.type}|im=${i.getAttribute('inputmode') || ''}|ac=${i.getAttribute('autocomplete') || ''}|ml=${i.getAttribute('maxlength') || ''}`);
const btns = [...document.querySelectorAll('button')].map((b) => (b.innerText || '').trim()).filter(Boolean);
return { url: location.href, title: document.title || '', text: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 200), inputs: ins, buttons: btns };
});
process.stderr.write(`[CLOAK-DIAG] ${JSON.stringify(diag)}\n`);
} catch { /* ignore */ }
}
// ==================== 页面判定:严格按「当前页面真实特征」识别处于哪一步 ====================
// 教训:不能靠「有没有 Continue 按钮 + 步骤标志位」顺着走——OTP/密码页也有 Continue 按钮,
// 会被误当授权页空点,从而跳过邮箱验证码/手机验证,最终被带到登录页报「密码错误」。
// detectPage 一次性在页面上下文里统计各类输入是否可见、是否还有「可填空输入」、授权按钮/语境、
// 以及「密码错误 / 邮箱已注册」等硬信号;classifyPage 据此 + URL 判定当前步骤。
async function detectPage(page) {
let url = '';
try { url = page.url(); } catch { /* ignore */ }
const dom = await page.evaluate(() => {
const vis = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
const qa = (s) => [...document.querySelectorAll(s)];
const cnt = (s) => qa(s).filter(vis).length;
const bad = new Set(['hidden', 'checkbox', 'radio', 'submit', 'button', 'image', 'reset', 'file']);
// 是否还有「可见 / 可编辑 / 非只读 / 值为空」的待填输入——授权分支必须为 false 才允许点。
const anyFillable = qa('input, textarea').some((i) => {
if (!vis(i)) return false;
if (i.disabled || i.readOnly) return false;
const t = (i.getAttribute('type') || 'text').toLowerCase();
if (bad.has(t)) return false;
return String(i.value || '').trim() === '';
});
const email = cnt('input[type="email"], input[name="email"], input#email-input, input[autocomplete="username"], input[placeholder*="email" i]');
const pw = cnt('input[type="password"], input[name="password"], input[name="new-password"]');
const tel = cnt('input[type="tel"], input[name="phone"], input[name="phone_number"], input[autocomplete="tel"]');
const otp = cnt('input[autocomplete="one-time-code"], input[name="code"], input[name="otp"], input[inputmode="numeric"], input[maxlength="1"], input[maxlength="6"], [role="textbox"][aria-label*="code" i]');
const name = cnt('input[name="name"], input#name, input[autocomplete="name"], input[placeholder*="name" i]');
const bday = cnt('input[type="date"], input[name="birthdate"], input[name="bday"], input[placeholder*="birth" i]');
const btnTexts = qa('button, [role="button"], input[type="submit"]').filter(vis)
.map((b) => (b.innerText || b.value || '').trim().toLowerCase()).filter(Boolean);
const authorizeBtn = btnTexts.some((t) => /^(authorize|allow|agree|yes,|continue to)/.test(t) || /\bauthorize\b/.test(t));
const continueBtn = btnTexts.some((t) => /^(continue|next)\b/.test(t)) || cnt('button[type="submit"]') > 0;
const text = (document.body && document.body.innerText || '').toLowerCase();
const wrongCred = /incorrect email address or password|that password is incorrect|wrong password/.test(text);
const emailTaken = /already (has|have) an account|already exists|use a different email/.test(text);
const mentionsPhone = /phone number|text message|\bsms\b|mobile number|verify your phone/.test(text);
const mentionsEmailCode = /check your (inbox|email)|sent .{0,20}to your email|verify your email|enter the code .{0,20}email/.test(text);
const consentText = /want[s]? to access|is requesting access|authorize .{0,20}(app|access)|by (continuing|authorizing)/.test(text);
// 账号选择页(bindPhone 恢复会话后发起 codex 授权时会出现):「Choose an account to continue to …」,
// 页面无输入、只有账号磁贴/「Select account」,需点磁贴才继续到授权。用文本 + URL 双信号识别。
const chooseAccount = /choose an account to continue|select account/.test(text);
// about-you 强文本信号:「how old are you / full name / finish creating account / date of birth」等。
// 用途:变体A 的 Age(整数)输入会命中 inputmode=numeric 而被误判成验证码页,故用此强信号在
// classifyPage 里抢先判为 about_you,避免走到「等邮箱验证码」的错误分支。
const aboutYou = /how old are you|finish creating account|full name|tell us about you|what'?s your name|date of birth/.test(text);
return { email, pw, tel, otp, name, bday, anyFillable, authorizeBtn, continueBtn, wrongCred, emailTaken, mentionsPhone, mentionsEmailCode, consentText, aboutYou, chooseAccount };
}).catch(() => null);
return { url, dom };
}
// 按特征强弱判定当前步:手机 → 验证码 → 密码 → 邮箱 → 补资料 → 授权 → 过渡 → 未知。
// 只有「无任何可填输入 + 授权按钮/授权语境」才判为 consent(真·授权页);无输入但只有普通
// Continue、又无授权语境的,判为 interstitial(过渡页,稳定后才点)——绝不在有输入的页上点授权。
function classifyPage({ url, dom }) {
if (!dom) return 'loading';
const u = String(url || '').toLowerCase();
if (u.includes('localhost:1455')) return 'callback';
// 账号选择页:URL 或文本任一命中即判定(无输入、需点账号磁贴)。放在最前,避免落到 unknown。
if (u.includes('choose-an-account') || dom.chooseAccount) return 'choose_account';
// about-you 抢先判定(无密码/无电话时):变体A 的 Age 数字框会误命中 OTP 选择器,用强文本信号先兜住。
if (dom.aboutYou && dom.pw === 0 && dom.tel === 0) return 'about_you';
if (dom.tel > 0) return 'phone';
if (dom.otp > 0 && dom.pw === 0) {
const phoneCtx = /add-phone|phone-verification|phone[-_/]?otp/.test(u) || (dom.mentionsPhone && !dom.mentionsEmailCode);
return phoneCtx ? 'phone_otp' : 'email_otp';
}
if (dom.pw > 0) return 'password';
if (dom.email > 0) return 'email_entry';
if (dom.name > 0 || dom.bday > 0) return 'about_you';
if (!dom.anyFillable && (dom.authorizeBtn || dom.consentText)) return 'consent';
if (!dom.anyFillable && dom.continueBtn) return 'interstitial';
return 'unknown';
}
// ==================== 跳过手机 + 采集会话(CK+AT)——新策略核心 ====================
// 在 add_phone 页尝试找「以后再说/跳过」入口并点一次;同时把手机页可点元素转储到 stderr
// [CAP-DIAG] 原始行,便于确认到底有没有跳过入口)。找到并点了返回 true,否则 false。
async function trySkipPhone(page, say) {
try {
const diag = await page.evaluate(() => {
const text = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 300);
const controls = [...document.querySelectorAll('a, button, [role="button"]')]
.filter((e) => e.offsetParent !== null).map((e) => (e.innerText || '').trim()).filter(Boolean).slice(0, 30);
return { url: location.href, text, controls };
});
process.stderr.write(`[CAP-DIAG] phonepage ${JSON.stringify(diag)}\n`);
} catch { /* ignore */ }
// 文案启发式:not now / maybe later / do this later / skip / remind me later …
const re = /not now|maybe later|i.?ll do (this|it) later|do (this|it) later|remind me|^skip$|skip for now/i;
const cand = page.locator('a, button, [role="button"]');
const n = await cand.count().catch(() => 0);
for (let i = 0; i < n; i++) {
const el = cand.nth(i);
try {
if (!(await el.isVisible())) continue;
const txt = ((await el.innerText().catch(() => '')) || '').trim();
if (txt && re.test(txt)) { say('info', '尝试跳过手机'); await el.click().catch(() => {}); return true; }
} catch { /* 试下一个 */ }
}
return false;
}
// ==================== 登录后引导页 / 空白页兜底 ====================
// 「You're all set」引导页:黑色对勾 + 大标题 "You're all set" + 正文 "ChatGPT can make mistakes.
// Don't share sensitive info…" + 黑色 Continue 按钮。新号登录后常弹此页,不点 Continue 会一直被
// 弹回首页(实测导致进设置开 2FA 时始终被踢回 chatgpt.com/ 空转)。这里按可见文本健壮识别并点 Continue。
// 用「You're all set / Welcome」作引导页强信号(不能只用 "ChatGPT can make mistakes"——正常聊天页底部
// 也有这句,会误伤)。返回是否点了。
async function dismissOnboarding(page, say) {
const clicked = await page.evaluate(() => {
const txt = (document.body && document.body.innerText || '');
const isOnboard = /you'?re all set|welcome to chatgpt|准备就绪|全部设置完成|欢迎使用/i.test(txt);
if (!isOnboard) return '';
const re = /^(continue|okay,? let'?s go|get started|got it|done|next|继续|开始使用|知道了|完成)$/i;
const dlgs = [...document.querySelectorAll('[role="dialog"]')];
const scopes = dlgs.length ? dlgs : [document.body];
for (const sc of scopes) {
for (const b of sc.querySelectorAll('button, [role="button"], a')) {
const t = (b.innerText || b.getAttribute('aria-label') || '').trim();
if (t && re.test(t)) { b.click(); return t; }
}
}
return '';
}).catch(() => '');
if (clicked) { if (say) say('info', '关闭引导页'); await sleep(600); return true; }
return false;
}
// 空白/未加载兜底:页面几乎无正文且无任何可交互元素时,F5 刷新一次再继续检测(避免死等空白页)。
async function refreshIfBlank(page) {
const blank = await page.evaluate(() => {
const t = (document.body && document.body.innerText || '').trim();
const hasCtl = document.querySelector('button, input, textarea, [role="button"], a[href]');
return t.length < 5 && !hasCtl;
}).catch(() => false);
if (blank) { await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}); await sleep(800); return true; }
return false;
}
// ==================== TOTP(用于开启并确认 2FA 认证器)====================
// base32 解码(RFC4648,忽略空格/分隔)。
function base32Decode(s) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = String(s || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0, value = 0; const out = [];
for (const c of clean) {
const idx = alphabet.indexOf(c);
if (idx < 0) continue;
value = (value << 5) | idx; bits += 5;
if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
}
return Buffer.from(out);
}
// 由 base32 密钥算当前 6 位 TOTPHMAC-SHA130s 步长)——用于向 OpenAI 确认认证器已绑定。
function totpNow(secret, t = Date.now()) {
const key = base32Decode(secret);
let counter = Math.floor(t / 1000 / 30);
const buf = Buffer.alloc(8);
for (let i = 7; i >= 0; i--) { buf[i] = counter & 0xff; counter = Math.floor(counter / 256); }
const h = crypto.createHmac('sha1', key).update(buf).digest();
const off = h[h.length - 1] & 0x0f;
const bin = ((h[off] & 0x7f) << 24) | ((h[off + 1] & 0xff) << 16) | ((h[off + 2] & 0xff) << 8) | (h[off + 3] & 0xff);
return String(bin % 1000000).padStart(6, '0');
}
// 校验 2FA 是否真已开启:enroll 对话框(#enroll-totp-modal-title)应已关闭且无「验证码错误」提示;
// 认证器开关若可读到 aria-checked=true / 内部 checkbox.checked 更是铁证。
async function verify2faEnabled(page) {
await sleep(300); // 外层已按 1s 轮询,这里只需极短稳定
return await page.evaluate(() => {
const body = (document.body.innerText || '');
// enroll 对话框还在 + 有错误文案 → 明确失败(验证码不对)
if (document.querySelector('#enroll-totp-modal-title') && /incorrect|invalid|try again|wrong|didn'?t match/i.test(body)) return false;
const t = document.querySelector('[data-testid="mfa-authenticator-toggle"]');
if (t) {
if (t.getAttribute('aria-checked') === 'true') return true;
const inp = t.querySelector('input[type="checkbox"]');
if (inp && inp.checked) return true;
}
// 兜底:enroll 对话框已消失(点 Verify 成功后 OpenAI 会关闭它/进入备份码),且无错误文案。
return !document.querySelector('#enroll-totp-modal-title');
}).catch(() => false);
}
// 开启 TOTP 2FA 完整闭环并返回 base32 密钥(失败返回 '',绝不影响主注册结果)。实测流程:
// chatgpt.com/#settings/Security → 点 mfa-authenticator-toggle → 跳 auth.openai.com/email-verification
// (邮箱验证码二次验证身份)→ 填码 Continue → 回设置弹窗 → 再点一次开关 → 弹出「Connect your
// authenticator app」对话框(QR 链接 otpauth://…?secret=<base32>,输入框 name=totp_otp,按钮 Verify
// → 从 otpauth 链接解析 secret → 算当前 TOTP → 填入 → 点 Verify → 校验确已开启。
// 关键:一定走到「填码→提交→确认」,绝不「拿到 secret 就关」。整段 try/catch:2FA 失败不拖垮已拿的 AT/密码。
async function enableTotp2fa(context, page, email, otpBase, say) {
const TOGGLE = '[data-testid="mfa-authenticator-toggle"]';
try {
say('info', '开启2FA中');
// 关键:新号登录后停在「You're all set」引导页,不先关掉它,进 #settings 会被弹回首页 → 开关点了
// 也白点、页面空转到超时(实测就是这样)。先关引导页 + 空白页 F5 兜底,再进安全设置。
await dismissOnboarding(page, say);
await refreshIfBlank(page);
await page.goto('https://chatgpt.com/#settings/Security', { waitUntil: 'domcontentloaded' }).catch(() => {});
await settle(page);
await dismissOnboarding(page, say); // 进设置若又被引导页拦截,再关一次
// 提速:等开关出现从 20s 收到 12s;出现即继续(条件等待,不死等)。
try { await page.locator(TOGGLE).first().waitFor({ state: 'visible', timeout: 12000 }); } catch { /* 面板未出,下面循环兜底 */ }
// 基线设在点开关之前:只认此刻之后到达的「2FA 二次验证」邮件码,避开注册时的旧码。
const twoFaBaseline = Date.now();
// enroll 对话框检测:QR 可能是 otpauth 链接 / canvas / data:image,标题文案也可能变;用宽判据。
const detectEnroll = () => page.evaluate(() => {
if (document.querySelector('a[href^="otpauth://"]')) return true;
if (document.querySelector('#enroll-totp-modal-title, input[name="totp_otp"]')) return true;
const dlgs = [...document.querySelectorAll('[role="dialog"]')];
const top = dlgs[dlgs.length - 1];
if (top) {
const t = (top.innerText || '').toLowerCase();
if (/(authenticator app|scan (this |the )?qr|can'?t scan|trouble scanning|enter (this |the )?key|setup key|扫描|验证器|身份验证器)/.test(t)) return true;
if (top.querySelector('canvas, img[src^="data:image"], svg')) return true;
}
return false;
}).catch(() => false);
// 先点「Trouble scanning?」展开手动密钥(中英文健壮匹配;限最顶层对话框,避开设置面板里的 passkeys)。
const revealManualKey = () => page.evaluate(() => {
const r = /trouble scanning|can'?t scan|scan(ning)?\s*(issue|problem)|enter (this |the )?key manually|manual entry|扫描.*(问题|不了|失败)|无法扫描|遇到问题|手动(输入|录入|输入密钥)|输入密钥/i;
const dlgs = [...document.querySelectorAll('[role="dialog"]')];
const scope = dlgs[dlgs.length - 1] || document;
for (const n of scope.querySelectorAll('button, a, [role="button"], summary')) {
const t = (n.innerText || n.getAttribute('aria-label') || '');
if (t && r.test(t)) { n.click(); return; }
}
}).catch(() => {});
// 从 enroll 对话框抓 base32 secret。顺序很关键:
// ① 二维码 otpauth://…?secret=<base32> —— 权威来源,一次给出完整精确 secret;
// ② Trouble scanning 展开后的手动密钥文本 —— 必须按「4 位一组、空格/横线分隔」的形态严格捕获,
// 否则贪婪匹配会把 secret 和相邻大写文本连成一串(曾抓到 57 位废串导致 TOTP 算错、验证失败);
// ③ 只读框/等宽块里独立成段的严格 base32。全部经 ok() 严格校验(≥24 位且含数字),排除姓名/FREE 误抓。
const extractSecret = () => page.evaluate(() => {
const norm = (s) => (s || '').replace(/[\s-]+/g, '').toUpperCase();
const ok = (s) => /^[A-Z2-7]{16,64}$/.test(s) && /[2-7]/.test(s);
const dlgs = [...document.querySelectorAll('[role="dialog"]')];
const scope = dlgs[dlgs.length - 1] || document.body;
// ① otpauth 链接(权威)
const a = scope.querySelector('a[href^="otpauth://"]') || document.querySelector('a[href^="otpauth://"]');
if (a) { try { const u = new URL(a.getAttribute('href')); const s = norm(u.searchParams.get('secret') || ''); if (ok(s)) return s; } catch { /* */ } }
const stext = scope.innerText || '';
// ② 手动密钥:严格「4 位一组×4~13 组」,避免连带相邻文本
const grouped = stext.match(/\b([A-Z2-7]{4}(?:[ -][A-Z2-7]{4}){3,12})\b/);
if (grouped) { const n = norm(grouped[1]); if (ok(n)) return n; }
// ②b 手动密钥:label 后紧跟的一整段连续 base32(无分组时)
const near = stext.match(/(?:secret|key|密钥|代码|manual entry|setup key)[^A-Za-z0-9]{0,24}([A-Z2-7]{16,64})\b/i);
if (near) { const n = norm(near[1]); if (ok(n)) return n; }
// ③ 只读框/等宽块
for (const el of scope.querySelectorAll('code, pre, input[readonly], [class*="secret" i], [class*="key" i], [class*="mono" i]')) {
const v = norm(el.value || el.innerText || ''); if (ok(v)) return v;
}
return '';
}).catch(() => '');
// 处理一次邮箱二次验证(step-up):取码→填→Continue→回设置。可能在点开关后、也可能在打开
// enroll 对话框「那一刻」才触发(实测:对话框先闪现,随即整页跳 auth.openai.com/email-verification)。
const handleEmailVerify = async () => {
say('info', '2FA邮箱验证');
const code = await waitEmailOtp(otpBase, email, { baselineTs: twoFaBaseline, say });
if (!code) throw new Error('2FA邮箱码超时');
const box = await firstVisible(page, ['input[name="code"]', 'input[autocomplete="one-time-code"]', 'input[inputmode="numeric"]'], 6000);
if (box) { await box.fill(''); await box.type(code, { delay: 40 }); }
const cont = await firstVisible(page, ['button:has-text("Continue")', 'button[type="submit"]'], 3000);
if (cont) await cont.click().catch(() => {});
// 提速:条件等待——等到「离开 email-verification 页」(跳回 action=enable/设置)即继续,最多 8s,不死等 3s。
await page.waitForURL((u) => !/email-verification/.test(String(u)), { timeout: 8000 }).catch(() => {});
await settle(page);
};
// 统一状态循环:email-verification 可能在任意时刻出现(点开关后 / 交互 enroll 对话框时)。
// 每轮按「当前页」判定:验证页→做邮箱二次验证并重开开关;enroll 对话框→展开抓密钥;
// 都没有→隔几秒补点一次开关。直到抓到 secret 或超总预算。避免旧两段式在「对话框→中途跳验证」时空跑。
await page.locator(TOGGLE).first().click().catch(() => {});
let secret = '';
let emailDone = false;
let lastToggle = Date.now();
let lastTrace = 0;
const deadline = Date.now() + 210000; // 总预算 3.5 分钟(含等邮箱码)
while (!secret && Date.now() < deadline) {
const url = page.url();
if (Date.now() - lastTrace > 5000) { lastTrace = Date.now(); const en = await detectEnroll(); diag2fa(`loop url=${url.slice(0, 55)} enroll=${en}`); }
if (/email-verification/.test(url)) {
await handleEmailVerify();
emailDone = true;
// 关键:邮箱二次验证后 OpenAI 会跳回 /?action=enable&factor=totp#settings/SecuritySPA 据此
// 「自动打开」enroll 对话框——此时绝不能再点开关(再点等于取消这次开启、把刚弹的对话框关掉,
// 实测就是这样导致对话框始终 dlgs=0)。这里只给 SPA 一点时间,交给下面 detectEnroll 捕获。
lastToggle = Date.now(); // 抑制补点:给深链自动开对话框留出时间
await sleep(2500);
continue;
}
if (await detectEnroll()) {
await revealManualKey();
await sleep(1200);
secret = await extractSecret();
if (secret) break;
// 对话框在但没抓到:可能点开关那刻正要 step-up 跳验证页,下一轮由 url 分支处理。
await sleep(1200);
continue;
}
// 无对话框:分三种情形处理——
const onEnableLink = /action=enable/.test(url);
const onSettings = /#settings\/security/i.test(url) || /\/settings/i.test(url);
if (onEnableLink) {
// ① action=enable 深链:SPA 会自动开对话框,别乱点开关(再点=取消开启);超 12s 没开才补点一次。
if (Date.now() - lastToggle > 12000) { await page.locator(TOGGLE).first().click().catch(() => {}); lastToggle = Date.now(); }
} else if (!onSettings) {
// ② 不在设置页(多为登录后被弹回 chatgpt.com/ 首页 / You're all set 引导页 / 空白页):
// 别再点不存在的开关空转。先关引导页 → 空白页 F5 兜底 → 重新进设置页点开关。隔 3.5s 一次。
if (Date.now() - lastToggle > 3500) {
await dismissOnboarding(page, say);
await refreshIfBlank(page);
await page.goto('https://chatgpt.com/#settings/Security', { waitUntil: 'domcontentloaded' }).catch(() => {});
await settle(page);
await page.locator(TOGGLE).first().click().catch(() => {});
lastToggle = Date.now();
}
} else {
// ③ 在设置页但没弹对话框:隔 5s 补点一次开关触发开启。
if (Date.now() - lastToggle > 5000) { await page.locator(TOGGLE).first().click().catch(() => {}); lastToggle = Date.now(); }
}
await sleep(700);
}
diag2fa(`loopEnd url=${page.url().slice(0, 60)} emailDone=${emailDone} secret=${secret ? secret.slice(0, 4) + '…len' + secret.length : 'none'}`);
if (!secret || !/^[A-Z2-7]{24,64}$/.test(secret)) {
const diag = await page.evaluate(() => {
const dlgs = [...document.querySelectorAll('[role="dialog"]')];
const top = dlgs[dlgs.length - 1];
return { url: location.href, dlgs: dlgs.length, hasOtpauth: !!document.querySelector('a[href^="otpauth://"]'), topText: top ? (top.innerText || '').replace(/\s+/g, ' ').slice(0, 200) : '' };
}).catch(() => ({}));
diag2fa(`noSecret ${JSON.stringify(diag)}`);
throw new Error('未取到2FA密钥(enroll 对话框未稳定出现)');
}
diag2fa(`secret=${secret.slice(0, 4)}… len=${secret.length}`);
// 填 6 位 TOTP → Verify。刚好跨 30s 边界会失效,这里紧接着算+填+提交,窗口足够。
const inp = await firstVisible(page, ['input[name="totp_otp"]', 'input[placeholder*="6-digit" i]', 'input[inputmode="numeric"]', 'input[maxlength="6"]'], 6000);
if (!inp) throw new Error('未找到验证码输入框');
await inp.fill(''); await inp.type(totpNow(secret), { delay: 60 });
const verify = await firstVisible(page, ['button:has-text("Verify")', 'button:has-text("Enable")', 'button[type="submit"]'], 3000);
if (!verify) throw new Error('未找到 Verify 按钮');
await verify.click().catch(() => {});
// 提速:把固定 4s 等待改成条件轮询——最多 8s,一旦确认已开启立即继续,不死等。
let enabled = false;
for (let i = 0; i < 8 && !enabled; i++) { await sleep(1000); enabled = await verify2faEnabled(page); }
if (!enabled) throw new Error('提交验证码后未确认已开启');
// 关掉可能出现的备份码/成功后续弹窗,避免影响后续采集。
await page.evaluate(() => {
for (const b of document.querySelectorAll('button')) { if (/^(done|close|got it|continue)$/i.test((b.innerText || '').trim())) { b.click(); return; } }
}).catch(() => {});
say('ok', '2FA已开启');
return secret;
} catch (e) {
diag2fa(`fail ${String(e).slice(0, 160)}`);
say('warn', '2FA开启失败');
return '';
}
}
// 采集「会话 cookie(CK) + 网页 AT」:
// CK:导出 context 全量 cookie(含 auth.openai.com / chatgpt.com 的 session);
// AT:访问 chatgpt.com 建立会话后,fetch /api/auth/session 取 accessToken(若被 onboarding 拦回则拿不到)。
// 全程把关键信号写 [CAP-DIAG] 原始行,便于研判「手机前能否拿到可用会话/AT」。
async function captureWebSession(context, page, say) {
const out = { cookies: [], webAccessToken: '', chatgptUrl: '', sessionStatus: 0, sessionHead: '' };
try { out.cookies = await context.cookies(); } catch { /* ignore */ }
try {
const names = out.cookies.map((c) => `${c.domain}:${c.name}`);
process.stderr.write(`[CAP-DIAG] cookies n=${out.cookies.length} names=${JSON.stringify(names.slice(0, 40))}\n`);
} catch { /* ignore */ }
say('info', '采集会话中');
// 访问 chatgpt.com:看是否放行(拿到会话)还是被重定向回 onboarding/手机页
try {
await page.goto('https://chatgpt.com/', { waitUntil: 'domcontentloaded', timeout: 45000 });
await sleep(1000); // 提速:会话 cookie 已就绪,只需短暂稳定即可 fetch /api/auth/session3500→1000
} catch (e) { process.stderr.write(`[CAP-DIAG] goto chatgpt err ${String(e).slice(0, 140)}\n`); }
try { out.chatgptUrl = page.url(); } catch { /* ignore */ }
process.stderr.write(`[CAP-DIAG] chatgpt landing=${out.chatgptUrl}\n`);
// 取网页 AT/api/auth/session(登录态则返回 {accessToken,...})。
// 关键:在浏览器内解析「完整 body」再取 accessToken——之前把 body 截断到 2000 字符后再 JSON.parse
// 因 session 响应很大(WARNING_BANNER/user 在前、accessToken 在后)被截断成非法 JSON,导致 AT 一直取空。
try {
const r = await page.evaluate(async () => {
try {
const res = await fetch('/api/auth/session', { credentials: 'include', headers: { accept: 'application/json' } });
const t = await res.text();
let accessToken = '';
let accountId = '';
try {
const j = JSON.parse(t);
if (j && j.accessToken) accessToken = j.accessToken;
accountId = (j && j.user && (j.user.id || j.user.chatgpt_account_id)) || '';
} catch { /* 非 JSON */ }
return { status: res.status, url: res.url, head: t.slice(0, 160), accessToken, accountId };
} catch (e) { return { status: -1, url: '', head: 'ERR ' + String(e), accessToken: '', accountId: '' }; }
});
out.sessionStatus = r.status; out.sessionHead = r.head || '';
if (r.accessToken) out.webAccessToken = r.accessToken;
if (r.accountId) out.accountId = r.accountId;
process.stderr.write(`[CAP-DIAG] session status=${r.status} url=${r.url} at=${r.accessToken ? r.accessToken.slice(0, 12) + '…len' + r.accessToken.length : 'NONE'}\n`);
} catch (e) { process.stderr.write(`[CAP-DIAG] session err ${String(e).slice(0, 140)}\n`); }
// 导航后重新导出 cookie(可能新增 chatgpt.com 域的 session cookie
try { out.cookies = await context.cookies(); } catch { /* ignore */ }
return out;
}
// 归一化保存的 cookie 以喂给 context.addCookies:只保留必要字段,sameSite 归一到合法枚举,
// 过滤掉缺 domain/name 的脏数据(否则 addCookies 会整批抛错)。
function sanitizeCookies(list) {
const okSame = new Set(['Strict', 'Lax', 'None']);
const out = [];
for (const c of list || []) {
if (!c || !c.name || !c.domain) continue;
const ck = {
name: String(c.name),
value: String(c.value == null ? '' : c.value),
domain: String(c.domain),
path: c.path || '/',
httpOnly: !!c.httpOnly,
secure: !!c.secure,
};
if (typeof c.expires === 'number' && c.expires > 0) ck.expires = c.expires;
let ss = c.sameSite;
if (ss === 'no_restriction' || ss === 'None') ss = 'None';
else if (ss === 'lax' || ss === 'Lax') ss = 'Lax';
else if (ss === 'strict' || ss === 'Strict') ss = 'Strict';
ck.sameSite = okSame.has(ss) ? ss : 'Lax';
out.push(ck);
}
return out;
}
// 是否已存在 chatgpt.com 的 next-auth 会话 cookie(登录成功标志;匿名态没有)。
// 兼容分块 cookie__Secure-next-auth.session-token / .0 / .1),且要求 value 非空。
async function hasNextAuthSession(context) {
try {
const cs = await context.cookies();
return cs.some((c) => /(^|\.)?next-auth\.session-token(\.\d+)?$/i.test(c.name || '')
&& String(c.value || '').length > 10);
} catch { return false; }
}
// 打开 chatgpt.com 的登录/注册面板并露出邮箱输入框。
// chatgpt.com 落地页有两种变体:①右侧直接是「Email address + Continue」;②聊天页只有
// 「Log in / Sign up for free」按钮,需先点一下才弹出邮箱输入。让 chatgpt.com 自己发起
// next-auth signinstate/CSRF 由它维护,比手拼 authorize URL 稳)。返回是否已露出邮箱框。
async function openAuthPanel(page) {
const hasEmail = async () => {
try {
const el = await firstVisible(page, [
'input[type="email"]', 'input[name="email"]', 'input#email-input',
'input[autocomplete="username"]', 'input[placeholder*="email" i]',
], 600);
return !!el;
} catch { return false; }
};
const re = /sign ?up|log ?in|create.*account|get started|注册|登录/i;
for (let attempt = 0; attempt < 4; attempt++) {
if (await hasEmail()) return true;
// 找可点的「注册/登录」入口(优先注册),点开面板。
const cand = page.locator('a, button, [role="button"]');
const n = await cand.count().catch(() => 0);
let clicked = false;
for (const preferSignup of [true, false]) {
for (let i = 0; i < n; i++) {
const el = cand.nth(i);
try {
if (!(await el.isVisible())) continue;
const txt = ((await el.innerText().catch(() => '')) || '').trim();
if (!txt || txt.length > 30 || !re.test(txt)) continue;
if (preferSignup && !/sign ?up|get started|注册/i.test(txt)) continue;
await el.click().catch(() => {});
clicked = true;
break;
} catch { /* 试下一个 */ }
}
if (clicked) break;
}
// 提速:点开后用「条件等待」替代固定 2.5s——邮箱框出现 / 跳到 auth.openai.com 任一满足即返回。
const deadline = Date.now() + 3500;
while (Date.now() < deadline) {
if (await hasEmail()) return true;
try { if (/auth\.openai\.com/.test(page.url())) return true; } catch { /* ignore */ }
await sleep(250);
}
}
return await hasEmail();
}
// 按「可见 label 文本」健壮定位并填写字段(about-you 变体多、裸 input 无 name/placeholder 常见)。
// 依次尝试:1) getByLabel(正则)2) placeholder/aria-label 选择器;3) 传入的兜底选择器;
// 4) 就近法(找含 label 文案的元素 → for 关联或容器内最近 input)。填完读回 value 非空才算成功。
async function fillByLabel(page, labelTexts, value, extraSelectors = []) {
const tryLoc = async (loc) => {
try {
if (!(await loc.count())) return false;
if (!(await loc.isVisible().catch(() => false))) return false;
await loc.fill('').catch(() => {});
await loc.type(String(value), { delay: 30 });
const v = (await loc.inputValue().catch(() => '')) || '';
return v.trim() !== '';
} catch { return false; }
};
// 1) getByLabel
for (const lt of labelTexts) {
try {
if (await tryLoc(page.getByLabel(new RegExp(lt, 'i')).first())) return true;
} catch { /* ignore */ }
}
// 2) placeholder / aria-label
const sels = [];
for (const lt of labelTexts) sels.push(`input[placeholder*="${lt}" i]`, `input[aria-label*="${lt}" i]`);
sels.push(...extraSelectors);
for (const s of sels) {
if (await tryLoc(page.locator(s).first())) return true;
}
// 3) 就近法:在页面内按 label 文案找到关联 input,用 elementHandle 填
try {
const el = await page.evaluateHandle((texts) => {
const lc = texts.map((t) => t.toLowerCase());
const match = (t) => { t = (t || '').trim().toLowerCase(); return t && lc.some((k) => t === k || t.startsWith(k)); };
for (const lab of document.querySelectorAll('label, span, div, p')) {
if (!match(lab.textContent)) continue;
const forId = lab.getAttribute && lab.getAttribute('for');
if (forId) { const e = document.getElementById(forId); if (e && e.tagName === 'INPUT') return e; }
let p = lab;
for (let d = 0; d < 3 && p; d++, p = p.parentElement) {
const inp = p.querySelector && p.querySelector('input');
if (inp && inp.offsetParent !== null) return inp;
}
}
return null;
}, labelTexts);
const handle = el && el.asElement && el.asElement();
if (handle && await handle.isVisible().catch(() => false)) {
await handle.fill('').catch(() => {});
await handle.type(String(value), { delay: 30 }).catch(() => {});
const v = (await handle.inputValue().catch(() => '')) || '';
if (v.trim() !== '') return true;
}
} catch { /* ignore */ }
return false;
}
// ==================== 邮箱验证码:从 Flask /api/reg/inbox 轮询「新鲜」验证码 ====================
// 复刻协议模式 waitEmailOtp 的核心:只接受 baseline 之后到达、且像 OpenAI verification 的码,
// 优先 subject 含 "verification"score 高)的那封,避免误用 login code。
function scoreOtp(m) {
const from = ((m && m.from_addr) || '').toLowerCase();
const subj = ((m && m.subject) || '').toLowerCase();
const isOpenAi = /openai|chatgpt/.test(from) || /openai|chatgpt/.test(subj);
if (!isOpenAi) return 0;
if (/verification code/.test(subj)) return 4;
if (/verification/.test(subj)) return 3;
// 2FA 二次验证/登录场景,OpenAI 邮件主题可能是 "login code"/"security code"/"your code"
// 之前给 2 分被 verif(>=3) 与 generic(===1) 两个筛子都漏掉 → 永远取不到码。提到 3 分纳入正选。
if (/login code|security code|your .*code|one-?time|passcode/.test(subj)) return 3;
if (/\bcode\b/.test(subj)) return 1;
return 1;
}
async function waitEmailOtp(otpBase, email, { timeoutMs = 180000, intervalMs = 2500, baselineTs = 0, sinceSkewMs = 90000, say } = {}) {
const start = Date.now();
const threshold = baselineTs ? baselineTs - sinceSkewMs : 0;
let attempt = 0;
let fallback = null;
while (Date.now() - start < timeoutMs) {
attempt++;
try {
const res = await fetch(`${otpBase}/api/reg/inbox?email=${encodeURIComponent(email)}`);
const data = await res.json().catch(() => ({}));
const cands = [];
for (const m of (data.messages || [])) {
const ts = Date.parse(m.received_at || '') || 0;
const fresh = !threshold || ts >= threshold;
const code = m.extracted_code || extractCode(`${m.subject || ''} ${m.body || ''}`);
if (!code || !fresh) continue;
cands.push({ code, ts, score: scoreOtp(m) });
}
const verif = cands.filter((c) => c.score >= 3).sort((a, b) => (b.score - a.score) || (b.ts - a.ts))[0];
if (verif) { say && say('ok', '已收到验证码'); return verif.code; }
const generic = cands.filter((c) => c.score === 1).sort((a, b) => b.ts - a.ts)[0];
if (generic && !fallback) fallback = generic;
say && say('info', '等待验证码');
} catch { /* 单次失败继续轮询 */ }
await sleep(intervalMs);
}
if (fallback) { say && say('warn', '使用兜底验证码'); return fallback.code; }
return null;
}
// 在 OTP 输入区填入验证码:兼容单框(input[name=code])与分段 6 框(逐位输入)。
// known:上游若已通过启发式定位到验证码输入框,传进来做最后兜底,避免 OpenAI 改版后
// 特定选择器失配导致「明明在验证码页却填不进去」。
async function fillOtp(page, code, known = null) {
const single = await firstVisible(page, [
'input[name="code"]',
'input[autocomplete="one-time-code"]',
'input[inputmode="numeric"]',
'input[name="otp"]',
], 2000);
if (single) {
await single.fill('');
await single.type(code, { delay: 60 });
return true;
}
// 分段输入框:找一组 input,逐位敲
const segs = page.locator('input[maxlength="1"]');
const n = await segs.count().catch(() => 0);
if (n >= code.length) {
for (let i = 0; i < code.length; i++) {
await segs.nth(i).type(code[i], { delay: 60 }).catch(() => {});
}
return true;
}
// 兜底:用上游传入的已定位框(启发式命中的那个可见输入)。
if (known) {
try {
await known.fill('');
await known.type(code, { delay: 60 });
return true;
} catch { /* ignore */ }
}
return false;
}
// 在手机页把「国家选择器」切到号码所属国家(iso2)。兼容三类控件:
// 1) 原生 <select>option 值可能是 iso2 / 国码 / 含国家名);
// 2) intl-tel-input(点旗标按钮展开列表,选 data-country-code=iso2 的项);
// 3) 通用 comboboxaria-haspopup 按钮 + 列表项)。
// 成功返回 true(此后只需填「本国号」);失败返回 false(回退:直接填「+完整E.164」让控件自识别)。
async function selectPhoneCountry(page, iso2) {
if (!iso2) return false;
// 1) 原生 select:尝试按 value/label 选中
try {
const sel = page.locator('select').first();
if (await sel.count() && await sel.isVisible().catch(() => false)) {
for (const v of [iso2, iso2.toUpperCase()]) {
try { await sel.selectOption(v, { timeout: 800 }); return true; } catch { /* 试下一个 */ }
}
}
} catch { /* ignore */ }
// 2/3) 旗标 / combobox 按钮 → 展开 → 选对应国家项
const flag = await firstVisible(page, [
'.iti__selected-flag', '.iti__selected-country', 'button.iti__selected-country',
'[class*="selected-flag"]', '[class*="country-selector"]', 'button[aria-haspopup="listbox"]',
], 1200);
if (flag) {
await flag.click().catch(() => {});
await sleep(400);
const item = await firstVisible(page, [
`.iti__country[data-country-code="${iso2}"]`, `li[data-country-code="${iso2}"]`,
`[data-country-code="${iso2}"]`, `li[data-country="${iso2}"]`, `[role="option"][data-country-code="${iso2}"]`,
], 1200);
if (item) { await item.click().catch(() => {}); await sleep(300); return true; }
await page.keyboard.press('Escape').catch(() => {});
}
return false;
}
// ==================== 手机验证(浏览器 UI + 接码平台,best-effort ====================
async function verifyPhoneInBrowser(page, sms, say) {
const MODS = { smscode, smsbower, herosms };
const LABELS = { smscode: 'smscode.gg', smsbower: 'smsbower.app', herosms: 'hero-sms.com' };
const provider = (sms && MODS[sms.provider]) ? sms.provider : 'smscode';
const mod = MODS[provider];
const cfg = (sms && sms[provider]) || {};
if (!cfg.apiKey) throw new Error('需要手机验证但未配置接码 API Key');
say('info', '需要手机验证');
// 多国候选:与协议模式一致,逐个国家换号重试
const cand = Array.isArray(cfg.countryCandidates) && cfg.countryCandidates.length
? cfg.countryCandidates.map((c) => (c && typeof c === 'object' ? c : { id: c })) : null;
const MAX = cand ? cand.length : Math.max(1, Number(sms.maxAttempts) || 3);
const perTimeout = Math.max(Number(sms.pollTimeout) || 0, 120000);
let lastErr = null;
for (let attempt = 1; attempt <= MAX; attempt++) {
let orderId = null;
const acfg = cand ? { ...cfg, country: cand[attempt - 1].id, countryId: cand[attempt - 1].id } : cfg;
try {
say('info', '获取手机号中');
const acq = await mod.acquireNumber(acfg, { log: () => {} });
orderId = acq.orderId;
const digits = String(acq.phone || '').replace(/[^\d]/g, '');
// 号码所属国家:优先用本次请求的 smsbower 国家 id 映射,其次按号码前缀反推国码。
const info = SMS_COUNTRY[acfg.country] || SMS_COUNTRY[String(acfg.country)] || null;
const { national } = splitDial(digits, info ? info.dial : '');
const iso2 = info ? info.iso2 : '';
const e164 = `+${digits}`;
const tel = await firstVisible(page, [
'input[type="tel"]', 'input[name="phone"]', 'input[name="phone_number"]', 'input[autocomplete="tel"]',
], 3000);
if (!tel) throw new Error('未找到手机号输入框');
// 关键:把页面上的国家选到号码所属国家(否则默认停在美国 +1,号码与国码对不上)。
const picked = await selectPhoneCountry(page, iso2);
if (picked) say('info', `已选国家 ${(iso2 || '').toUpperCase()}`);
// 控件是否单独展示国码(separate dial code)——是则只填本国号。
const separateDial = await page.evaluate(
() => !!document.querySelector('.iti__selected-dial-code, [class*="selected-dial-code"]'),
).catch(() => false);
await tel.click().catch(() => {});
await tel.fill('');
if (picked || separateDial) {
// 已选对国家 / 控件自带国码:只填本国号(无国码),与所选国家拼成正确号码。
await tel.type(national || digits, { delay: 40 });
} else {
// 没选到国家:填「+完整E.164」,靠控件对「+」前缀自动识别国家(与 HTTP 模式 formatE164 一致)。
await tel.type(e164, { delay: 40 });
}
await tel.press('Enter').catch(() => {});
await sleep(1500);
say('info', '等待验证码');
const otp = await mod.pollOtp(acfg, orderId, { timeoutMs: perTimeout, log: () => {} });
const ok = await fillOtp(page, otp.code);
if (!ok) throw new Error('未找到短信验证码输入框');
await page.keyboard.press('Enter').catch(() => {});
await mod.finishOrder(acfg, orderId).catch(() => {});
say('ok', '手机验证通过');
return true;
} catch (e) {
lastErr = e;
const msg = String((e && e.message) || e);
if (/NO_BALANCE|余额/.test(msg)) { say('warn', '接码余额不足'); throw e; }
say('warn', '手机验证失败');
try { await mod.cancelOrder(acfg, orderId, { log: () => {} }); } catch { /* ignore */ }
}
}
throw new Error(`手机验证失败:${lastErr ? lastErr.message : '未知'}`);
}
// ==================== 主流程 ====================
(async () => {
let email = '';
let browser = null;
let context = null;
let registered = false; // 是否已越过「建号」步(用于让上游把邮箱标 burned)
try {
const input = JSON.parse((await readStdin()) || '{}');
email = input.email || '';
const settings = input.settings || {};
const proxy = input.proxy || null;
const otpBase = String(input.otpBase || 'http://127.0.0.1:5088').replace(/\/$/, '');
const headless = input.headless !== false; // 默认无头
const licenseKey = String(input.licenseKey || settings.cloakLicenseKey || '').trim();
// 固定/确定性密码(settings.regPassword,缺省用内置强口令):见 reg_password.mjs 说明。
const password = resolveRegPassword(input, settings);
const sms = settings.sms || {};
// 新策略:注册阶段不做手机验证——走到 add_phone 时不接码,改为采集「会话 cookie(CK)+网页 AT」入库,
// 手机/RT 由后续「批量绑手机拿RT」单独做。opt-in(默认 false,保持老行为不变)。
// 用 letbindPhone 模式在「拿到会话→转 codex 授权」时会把它切回 false(此步必须真接码)。
let skipPhone = input.skipPhone === true;
// 网页客户端注册(走 chatgpt.com 普通 signup,一般不强制手机):opt-in,默认沿用 codex 入口。
// 用 letbindPhone 模式先用网页登录建立会话(webClient=true),拿到会话后切 false 转 codex 授权拿 RT。
let webClient = input.webClient === true;
// 批量绑手机拿RT(针对无密码网页号):先用「保存的 CK 恢复会话」,失效则「邮箱验证码重登」,
// 建立 chatgpt.com 登录态后,再走 codex 授权(app_EMoam…)触发手机验证 → 拿独立 codex RT。
const bindPhone = input.bindPhone === true;
const restoreCookies = Array.isArray(input.cookies) ? input.cookies : [];
if (bindPhone) { webClient = true; skipPhone = false; } // 绑机:先网页登录,后 codex 必须接码
// 网页注册拿到登录态后是否开启 TOTP 2FA(默认开;绑机模式不开)。
const enable2fa = input.enable2fa !== false && !bindPhone;
if (!email) throw new Error('缺少邮箱');
emitLog('info', bindPhone ? '开始绑机' : '开始注册', email);
const proxyStr = proxyToString(proxy);
emitLog('info', `浏览器启动(${headless ? '无头' : '有头'}${proxyStr ? ' · 代理' : ''}`, email);
// 每号一套独立指纹:CloakBrowser 用 --fingerprint=<每次随机seed> 已在二进制层随机 canvas/webgl/
// 音频/字体等;这里再用 fingerprint.js 让「语言/时区」也逐号变化(经二进制 flag 设置、彼此自洽、
// 不走可检测的 CDP 模拟)。不覆盖 UA/sec-ch-ua——那会与二进制真实 146 头不一致反成强特征。
const fp = generateFingerprint();
process.stderr.write(`[FP-DIAG] locale=${fp.language} tz=${fp.timezone} screen=${fp.screenStr} ua=${fp.userAgent.slice(0, 40)}\n`);
// 启动 CloakBrowserlaunchContext 每次都是「全新隐身上下文 + 独立临时 profile」(chromium.launch()
// 分配一次性 user-data-dir,close 即弃),因此逐号天然隔离、无 cookie/localStorage 残留。
// --disable-quic:经 HTTP 代理时 QUIC/HTTP3(UDP) 无法隧道,实测不加此 flag 连 auth.openai.com 会
// ERR_CONNECTION_REFUSED;关掉 QUIC 强制走 TCP 后各 OpenAI 域名均可正常访问。
context = await launchContext({
headless,
proxy: proxyStr,
licenseKey: licenseKey || undefined,
locale: fp.language, // 逐号变化的英文区语言(en-US/en-GB/en-CA
timezone: fp.timezone, // 与语言自洽的时区
humanize: true,
args: ['--disable-quic'],
});
browser = context.browser();
// 兜底再清一遍会话态:新上下文本为空,但异常复用/残留时这一步保证「干净新注册」——用户实测
// 报「直接弹已登录账号」,显式清 cookie 后即便底层被复用也不会带上一号登录态。
try { await context.clearCookies(); } catch { /* ignore */ }
const page = await context.newPage();
page.setDefaultTimeout(45000);
// 有头模式:把窗口顶到前台,避免开在其它窗口后面让用户以为「没开浏览器」。
if (!headless) { try { await page.bringToFront(); } catch { /* ignore */ } }
// 拦截 redirect_uri:注册收尾会 302 到 localhost:1455/auth/callback?code=…,
// 这里 fulfill 一个空 200 并把 code 记下来(浏览器无需真的连到本地 1455)。
let authCode = null;
await context.route('http://localhost:1455/**', async (route) => {
try {
const u = new URL(route.request().url());
if (u.searchParams.get('code')) authCode = u.searchParams.get('code');
} catch { /* ignore */ }
try { await route.fulfill({ status: 200, contentType: 'text/html', body: 'ok' }); } catch { /* ignore */ }
});
// say 提前定义:网页入口块(openAuthPanel/日志)会用到,避免 TDZ「Cannot access 'say' before init」。
const say = (level, msg) => emitLog(level, msg, email);
// 测试/复用入口:mode2fa —— 用已存 CK 恢复登录态后,仅跑「开启 TOTP 2FA」完整闭环并回吐密钥。
// 供验证 2FA 闭环(填码→提交→确认)用,也可日后给「已登录账号补开 2FA」复用。
if (input.mode2fa === true) {
if (restoreCookies.length) { try { await context.addCookies(sanitizeCookies(restoreCookies)); } catch { /* */ } }
await page.goto('https://chatgpt.com/', { waitUntil: 'domcontentloaded' }).catch(() => {});
await sleep(3000);
const sec = await enableTotp2fa(context, page, email, otpBase, say);
try { await browser.close(); } catch { /* ignore */ }
realStdoutWrite(JSON.stringify({ ok: !!sec, twoFactorSecret: sec || '' }));
return;
}
// 从 codex 客户端的 /oauth/authorize 起步,并 screen_hint=signup 直达注册 UI。
const { verifier, challenge } = generatePkce();
const state = crypto.randomBytes(32).toString('base64url');
const authorizeUrl = `${OAUTH_ISSUER}/oauth/authorize?` + new URLSearchParams({
response_type: 'code', client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI,
scope: OAUTH_SCOPE, code_challenge: challenge, code_challenge_method: 'S256', state,
screen_hint: 'signup', prompt: 'login',
}).toString();
// codex 授权(用于 bindPhone:已在浏览器里建立 chatgpt.com 登录态后,对已存在账号发起 codex 授权,
// 触发手机验证并换取独立 codex RT)。不带 screen_hint=signup——是对既有账号授权而非建号;
// 关键:不带 prompt=login——否则会强制邮箱重新验证(步进验证),而我们要复用已恢复的会话直达授权/手机。
const codexAuthorizeUrl = `${OAUTH_ISSUER}/oauth/authorize?` + new URLSearchParams({
response_type: 'code', client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI,
scope: OAUTH_SCOPE, code_challenge: challenge, code_challenge_method: 'S256', state,
}).toString();
if (bindPhone) {
// 绑机阶段一:恢复 chatgpt.com 登录态。优先用保存的 CK;失效则走网页邮箱验证码重登。
if (restoreCookies.length) {
try { await context.addCookies(sanitizeCookies(restoreCookies)); say('info', '恢复会话中'); }
catch (e) { process.stderr.write(`[BIND-DIAG] addCookies err ${String(e).slice(0, 120)}\n`); }
}
await page.goto('https://chatgpt.com/', { waitUntil: 'domcontentloaded' }).catch(() => {});
await sleep(1200);
if (await hasNextAuthSession(context)) {
say('ok', '会话已恢复');
} else {
// CK 失效/为空:露出邮箱面板,交给状态机走「邮箱→验证码(→密码)」登录,拿到会话后再转 codex。
say('info', 'CK失效改邮箱登录');
await firstVisible(page, [
'input[type="email"]', 'input[placeholder*="email" i]',
'button:has-text("Sign up")', 'button:has-text("Log in")', 'button:text-is("Continue")',
], 6000).catch(() => null);
await openAuthPanel(page).catch(() => {});
}
} else if (webClient) {
// 网页注册入口:直接开 chatgpt.com 落地页。现代 chatgpt.com 右侧就是「Sign up or log in」面板
//Email address 输入 + Continue,属 login_or_signup 统一入口)——新邮箱填进去点 Continue 即进注册。
// 由 chatgpt.com 自己发起 next-auth signinPOST 带 CSRF),state/CSRF 全程它维护,最稳;
// 手拼 authorize URL / 直连 signin GET 都会得到「会话已结束 / auth/error」。
// 后续交给状态机:email_entry(填邮箱→Continue) → 密码 → 邮箱验证码 → about-you → 回落 chatgpt.com 登录态。
say('info', '进入网页注册');
await page.goto('https://chatgpt.com/', { waitUntil: 'domcontentloaded' }).catch(() => {});
// 提速:不再固定等 4s——等落地页出现可交互入口(邮箱框 / 登录·注册按钮)后再开面板,最多 ~6s。
await firstVisible(page, [
'input[type="email"]', 'input[placeholder*="email" i]',
'button:has-text("Sign up")', 'button:has-text("Log in")', 'button:text-is("Continue")',
], 6000).catch(() => null);
// 露出邮箱输入框(两种落地变体都兼容);露不出来也继续,交给状态机兜底判定。
await openAuthPanel(page).catch(() => {});
} else {
await page.goto(authorizeUrl, { waitUntil: 'domcontentloaded' }).catch(() => {});
}
const otpBaselineTs = Date.now();
// 按「当前页面真实特征」判定的状态机:每轮 detectPage 读 URL+DOMclassifyPage 判定当前步,
// 再精确处理对应步骤。授权只在「无任何可填输入 + 授权语境」时点,非授权页绝不点 Continue,
// 从根本上杜绝「在验证码/密码页上空点 Continue 跳过验证」的历史 bug。
let phoneDone = false; // 手机验证是否已在本次流程里处理过
let pwSubmitted = false; // 是否已提交过密码(建号或登录)——用于日志用词
let otpFilled = false; // 邮箱验证码是否已成功填过一次(用于识别「提交后过渡页」)
let skipTried = false; // skipPhone 模式下「找跳过入口」是否已尝试过(只试一次)
let phonePending = false; // skipPhone 模式:已到手机页但按策略不验、改为采集 CK+AT 入库
let capturedCookies = null; // 采集到的会话 cookieCK
let webAccessToken = ''; // 采集到的网页 access tokenAT
let capturedAccountId = ''; // 采集到的 chatgpt account id(可选)
let twoFactorSecret = ''; // 开启 TOTP 2FA 后拿到的 base32 密钥(失败留空)
let dumped = false; // 未知页面只转储一次 DOM 诊断,避免刷屏
let lastSig = ''; // 上一轮「类型|URL」签名,判断是否卡在同一页
let sameCount = 0;
// 提速:轮询间隔 350ms→250ms 更跟手;步数上限相应提到 180 以保住整体墙钟预算(邮箱验证码的长等
// 在 email_otp 分支内阻塞、不占这里的步数)。卡住判定的 sameCount 阈值保持不变,仍靠墙钟容忍。
for (let step = 0; step < 180 && !authCode; step++) {
await sleep(250);
if (authCode) break;
const snap = await detectPage(page);
if (authCode) break;
const type = classifyPage(snap);
const sig = `${type}|${snap.url}`;
if (sig === lastSig) sameCount++; else { sameCount = 0; lastSig = sig; }
// 硬信号:密码错误(多为「此邮箱曾用别的密码注册」)→ 直接失败,别再空转。
if (snap.dom && snap.dom.wrongCred) {
throw new Error('邮箱或密码不正确(此邮箱可能曾用别的密码注册)');
}
// 网页客户端终止条件:出现 chatgpt.com 的 next-auth 会话 cookie 即代表「已登录」。
// 关键:网页 signup 是「邮箱验证码即登录」的无密码流(实测无密码步、也不强制手机),
// 所以不能用 pwSubmitted 当门槛;只认 __Secure-next-auth.session-token(登录后才有、匿名态没有),
// 每轮都查(不导航),一出现就采集 CK+AT 并结束。
if (webClient && (await hasNextAuthSession(context))) {
if (bindPhone) {
// 绑机:已建立 chatgpt.com 登录态 → 转 codex 授权(同一浏览器会话,auth.openai.com 已有 SSO),
// 触发手机验证 → 拿独立 codex RT。切 webClient=false 让终止逻辑改用 codex 的 authCode。
say('ok', '会话就绪转授权');
webClient = false;
skipPhone = false; // 此步必须真接码
phoneDone = false;
await page.goto(codexAuthorizeUrl, { waitUntil: 'domcontentloaded' }).catch(() => {});
await settle(page);
continue;
}
const cap = await captureWebSession(context, page, say);
capturedCookies = cap.cookies;
webAccessToken = cap.webAccessToken;
capturedAccountId = cap.accountId || '';
// 拿到登录态后开启 TOTP 2FA 并保存密钥(默认开启,可传 enable2fa=false 关闭)。失败不影响入库。
if (enable2fa) {
twoFactorSecret = await enableTotp2fa(context, page, email, otpBase, say);
// 开 2FA 后会话 cookie 可能刷新,重采一次 CK,保证落库的是最新会话。
try { capturedCookies = await context.cookies(); } catch { /* ignore */ }
}
phonePending = true; // 网页号暂无 codex RTCK+AT(+2FA) 入库,SUB 列显示否
break;
}
if (type === 'email_entry') {
const el = await firstVisible(page, [
'input[name="email"]', 'input#email-input', 'input[type="email"]', 'input[autocomplete="username"]',
'input[placeholder*="email" i]',
], 1500);
if (el) {
say('info', '提交邮箱');
await el.fill('');
await el.type(email, { delay: 40 });
await submitStep(page);
await settle(page);
await waitLeaveType(page, 'email_entry');
}
continue;
}
if (type === 'password') {
const pw = await firstVisible(page, [
'input[type="password"]', 'input[name="password"]', 'input[name="new-password"]',
], 1500);
if (pw) {
// 建号与「已注册邮箱重进」的登录都用同一固定密码;已注册邮箱也能凭此登录拿状态。
say('info', pwSubmitted ? '重新登录中' : '创建账号中');
// 单页表单可能同时要邮箱:为空则补填,避免只填密码提交失败。
const em = await firstVisible(page, ['input[type="email"]', 'input[name="email"]'], 400);
if (em) {
const v = await em.inputValue().catch(() => '');
if (!v) { await em.fill(''); await em.type(email, { delay: 30 }); }
}
await pw.fill('');
await pw.type(password, { delay: 40 });
await submitStep(page);
registered = true;
pwSubmitted = true;
say('info', '发送验证码');
await settle(page);
await waitLeaveType(page, 'password');
}
continue;
}
if (type === 'email_otp') {
const otpBox = (await firstVisible(page, [
'input[name="code"]', 'input[autocomplete="one-time-code"]', 'input[inputmode="numeric"]',
'input[name="otp"]', 'input[maxlength="1"]', 'input[maxlength="6"]',
], 1200)) || await findGenericOtpBox(page);
// 已填过码后又回到验证码页:多为「刚回车、正在校验/跳转」的同一过渡页(框被禁用/仍带旧值)。
// 此时别重复填(会因框只读而报「未能填入」),等它跳走即可;长时间不走才判校验失败。
if (otpFilled) {
if (!otpBox) { await settle(page); continue; }
const busy = await page.evaluate(() => {
const skip = ['password', 'email', 'tel', 'hidden', 'checkbox', 'radio', 'submit', 'button'];
return [...document.querySelectorAll('input')].filter((i) => i.offsetParent !== null).some(
(i) => (i.readOnly || i.disabled || String(i.value || '').trim() !== '')
&& !skip.includes((i.getAttribute('type') || 'text').toLowerCase()),
);
}).catch(() => false);
if (busy) { await settle(page); continue; }
if (sameCount >= 8) throw new Error('邮箱验证码校验未通过');
}
say('info', '等待验证码');
const code = await waitEmailOtp(otpBase, email, { baselineTs: otpBaselineTs, say });
if (!code) throw new Error('邮箱验证码等待超时');
const ok = await fillOtp(page, code, otpBox);
if (!ok) throw new Error('未能填入邮箱验证码');
await page.keyboard.press('Enter').catch(() => {});
otpFilled = true;
say('ok', '邮箱验证通过');
// 关键:提交后主动等「离开验证码页」再继续,避免在过渡页二次误入验证码分支。
await waitLeaveType(page, 'email_otp');
continue;
}
if (type === 'phone') {
if (skipPhone) {
// 新策略:不接码。先试「以后再说/跳过」入口;能跳过就让状态机继续(可能直达 about-you/授权)。
if (!skipTried) {
skipTried = true;
const skipped = await trySkipPhone(page, say);
if (skipped) { await settle(page); continue; }
}
// 无跳过入口 / 跳过后仍回手机页:采集 CK+AT,结束状态机去入库(手机待绑)。
say('info', '跳过手机验证');
const cap = await captureWebSession(context, page, say);
capturedCookies = cap.cookies;
webAccessToken = cap.webAccessToken;
capturedAccountId = cap.accountId || '';
phonePending = true;
break;
}
if (!phoneDone) {
await verifyPhoneInBrowser(page, sms, say);
phoneDone = true;
await settle(page);
}
continue;
}
if (type === 'about_you') {
say('info', '完善资料中');
// 判定变体:优先看是否有「生日框」→ 变体B(名/姓+生日);否则若有「Age(整数)」→ 变体AFull name+Age)。
// 提速:两个探测合并为单次 page.evaluate,减少一次往返。
const { hasBday, hasAge } = await page.evaluate(() => {
const vis = (e) => e && e.offsetParent !== null;
const hasBday = [...document.querySelectorAll(
'input[type="date"], input[name="birthdate"], input[name="bday"], input[placeholder*="birth" i]',
)].some(vis);
let hasAge = [...document.querySelectorAll(
'input[name="age" i], input[placeholder*="age" i], input[aria-label*="age" i]',
)].some(vis);
if (!hasAge) {
for (const l of document.querySelectorAll('label, span, div, p')) {
const t = (l.textContent || '').trim().toLowerCase();
if (t === 'age' || t.startsWith('age')) {
let p = l;
for (let d = 0; d < 3 && p; d++, p = p.parentElement) { if (p.querySelector && p.querySelector('input')) { hasAge = true; break; } }
}
if (hasAge) break;
}
}
return { hasBday, hasAge };
}).catch(() => ({ hasBday: false, hasAge: false }));
let okFilled = false;
if (hasBday) {
// 变体B:名/姓(拿不到则退回单个 Full name+ 生日(YYYY-MM-DD)
const first = FIRST[crypto.randomInt(0, FIRST.length)];
const last = LAST[crypto.randomInt(0, LAST.length)];
const fOk = await fillByLabel(page, ['first name', 'first', 'given'], first,
['input[name="firstName" i]', 'input[autocomplete="given-name"]']);
const lOk = await fillByLabel(page, ['last name', 'last', 'family', 'surname'], last,
['input[name="lastName" i]', 'input[autocomplete="family-name"]']);
let nameOk = fOk && lOk;
if (!nameOk) {
nameOk = await fillByLabel(page, ['full name', 'name'], `${first} ${last}`,
['input[name="name"]', 'input#name', 'input[autocomplete="name"]']);
}
const bOk = await fillByLabel(page, ['date of birth', 'birthdate', 'birth', 'dob'], randomAdultBirthdate(),
['input[type="date"]', 'input[name="birthdate"]', 'input[name="bday"]']);
okFilled = nameOk && bOk;
} else {
// 变体A(截图这种):Full name + Age(整数 18~48)
const fullName = randomFullName();
const age = String(18 + crypto.randomInt(0, 31));
const nOk = await fillByLabel(page, ['full name', 'name'], fullName,
['input[name="name"]', 'input#name', 'input[autocomplete="name"]']);
const aOk = await fillByLabel(page, ['age'], age,
['input[name="age"]', 'input[type="number"]', 'input[inputmode="numeric"]']);
okFilled = nOk && aOk;
}
if (!okFilled) {
// 没填成功绝不点「Finish creating account」:转储诊断,稍等重试;久卡才判失败。
await dumpDiag(page);
if (sameCount >= 7) throw new Error('补资料填写失败(字段未识别)');
await settle(page);
continue;
}
say('ok', '资料已填写');
// 点「Finish creating account」/ Continue 提交
const fin = await firstVisible(page, [
'button:has-text("Finish creating account")', 'button:text-is("Continue")',
'button:text-is("Next")', 'button[type="submit"]',
], 1500);
if (fin) { await fin.click().catch(() => {}); } else { await submitStep(page); }
await settle(page);
await waitLeaveType(page, 'about_you');
continue;
}
if (type === 'choose_account') {
// 账号选择页:点「含本邮箱的账号磁贴」,避开「Log in to another account / Create account」。
// 用 Playwright 真实点击(React SPA 需真实指针事件;page.evaluate 里的 DOM .click() 常不触发导航)。
say('info', '选择账号中');
let clicked = false;
try {
const tile = page.locator('button, [role="button"], a').filter({ hasText: email }).first();
if (await tile.isVisible()) { await tile.click(); clicked = true; }
} catch { /* ignore */ }
if (!clicked) {
const b = await firstVisible(page, ['button:has-text("Select account")', `button:has-text("${email}")`], 1500);
if (b) { await b.click().catch(() => {}); clicked = true; }
}
await settle(page);
await waitLeaveType(page, 'choose_account', 12000);
continue;
}
if (type === 'consent' || type === 'callback') {
// 真·授权页(无可填输入 + 授权按钮/授权语境)才点授权。
say('info', '获取授权中');
const btn = await firstVisible(page, [
'button:text-is("Authorize")', 'button:text-is("Allow")', 'button:text-is("Agree")',
'button:text-is("Continue")', 'button[type="submit"]',
], 1200);
if (btn) { await btn.click().catch(() => {}); await settle(page); }
continue;
}
if (type === 'interstitial') {
// 「You're all set」引导页也常被归到过渡页:优先按引导页处理(点其 Continue)。命中即继续。
if (await dismissOnboarding(page, say)) { await settle(page); continue; }
// 无可填输入、只有普通 Continue、又无授权语境:多为过渡/加载页。稳定几轮后点一次
// (此时确无输入可填,点它安全、不会跳过输入步);限制点击轮次,避免对死按钮狂点。
if (sameCount >= 3 && sameCount <= 8) {
const btn = await firstVisible(page, ['button:text-is("Continue")', 'button[type="submit"]'], 1000);
if (btn) { await btn.click().catch(() => {}); await settle(page); }
} else {
await sleep(300);
}
continue;
}
// loading / unknown / phone_otp:等待页面稳定;未知页卡住先做「引导页 + 空白页 F5」兜底
// (新号登录后被弹回首页/空白页会长期归为 unknown),再转储诊断;久卡才中止(绝不盲进授权)。
if (type === 'unknown' && sameCount >= 2) {
if (await dismissOnboarding(page, say)) { await settle(page); continue; }
if (await refreshIfBlank(page)) { await settle(page); continue; }
}
if (!dumped && type === 'unknown' && sameCount >= 3) { dumped = true; await dumpDiag(page); }
if (type === 'unknown' && sameCount >= 14) {
await dumpDiag(page).catch(() => {});
throw new Error('停留在未知页面,已中止(未进入授权)');
}
await sleep(300);
}
// 网页模式兜底:循环结束但可能已在 chatgpt.com 建立登录态(session cookie 已就绪),
// 补采一次 CK+AT 并置 phonePending,走下面统一出口入库,避免误落到 codex 的 authCode 分支。
if (webClient && !phonePending && (await hasNextAuthSession(context))) {
const cap = await captureWebSession(context, page, say);
capturedCookies = cap.cookies;
webAccessToken = cap.webAccessToken;
capturedAccountId = cap.accountId || '';
phonePending = true;
}
if (webClient && !phonePending) {
throw new Error('网页注册未建立登录态(未拿到 chatgpt.com 会话)');
}
// 新策略:跳过手机 → 不换 RT,直接带「CK(cookies)+AT」入库(手机待绑,SUB=否)。
if (phonePending) {
// 仅在「连 cookie 都没采到」时补采一次。注意:网页 AT 在「未绑手机」状态被 OpenAI 挡住
///api/auth/session 返回 200 但为匿名、无 accessToken),所以不能因 AT 为空就重复采集,
// 否则每次都白跑一次 chatgpt.com 导航。AT 留待后续「批量绑手机拿RT」用 CK 恢复会话后再取。
if (!capturedCookies || !capturedCookies.length) {
const cap = await captureWebSession(context, page, say);
capturedCookies = (capturedCookies && capturedCookies.length) ? capturedCookies : cap.cookies;
webAccessToken = webAccessToken || cap.webAccessToken;
capturedAccountId = capturedAccountId || cap.accountId || '';
}
const gotAt = !!(webAccessToken && webAccessToken.length > 20);
const gotCk = Array.isArray(capturedCookies) && capturedCookies.length > 0;
say(gotAt || gotCk ? 'ok' : 'warn', gotAt ? '已拿会话与AT' : (gotCk ? '已拿会话' : '会话采集不全'));
const result = {
ok: true,
phonePending: true, // 标记:网页号暂无 codex RT(供后端标 SUB=否)
refreshToken: '', // 无 codex RT
accessToken: webAccessToken || '',
webAccessToken: webAccessToken || '',
webRefreshToken: '', // 网页 RTnext-auth 不向客户端暴露 refresh_token,无法自助获取(见汇报)
twoFactorSecret: twoFactorSecret || '', // 2FA base32 密钥(开启成功才有)
cookies: capturedCookies || [], // CK:序列化会话 cookie
chatgptAccountId: capturedAccountId || '',
clientId: OAUTH_CLIENT_ID,
password,
subOk: false,
};
try { await browser.close(); } catch { /* ignore */ }
realStdoutWrite(JSON.stringify(result));
return;
}
if (!authCode) {
// 兜底:若已完成登录态但未触发跳转,主动再访问一次 authorize,让服务端直接给 code。
// bindPhone 用 codex 授权地址(对既有账号),其它用注册 authorize。
await page.goto(bindPhone ? codexAuthorizeUrl : authorizeUrl, { waitUntil: 'domcontentloaded' }).catch(() => {});
for (let i = 0; i < 8 && !authCode; i++) await sleep(700);
}
if (!authCode) throw new Error('未能取得 authorization code(注册 UI 未走到授权跳转)');
say('ok', '获取授权成功');
// 用同一 context(同 cookie、同代理)POST /oauth/token 换 refresh_token。
say('info', '获取令牌中');
const tokResp = await context.request.post(`${OAUTH_ISSUER}/oauth/token`, {
form: {
grant_type: 'authorization_code',
code: authCode,
redirect_uri: OAUTH_REDIRECT_URI,
client_id: OAUTH_CLIENT_ID,
code_verifier: verifier,
},
});
if (!tokResp.ok()) {
const body = await tokResp.text().catch(() => '');
throw new Error(`换取 token 失败:HTTP ${tokResp.status()} ${body.slice(0, 160)}`);
}
const tok = await tokResp.json().catch(() => ({}));
if (!tok.refresh_token) throw new Error('token 响应缺少 refresh_token');
say('ok', '账号登录成功');
// 浏览器模式不再单独跑第二阶段 SUB(拿不到就 SUB=否,与需求一致)。
const result = {
ok: true,
refreshToken: tok.refresh_token,
accessToken: tok.access_token || '',
idToken: tok.id_token || '',
clientId: OAUTH_CLIENT_ID,
password,
subOk: false,
};
try { await browser.close(); } catch { /* ignore */ }
realStdoutWrite(JSON.stringify(result));
} catch (e) {
const msg = (e && e.message) || String(e);
emitLog('error', msg, email);
try { if (browser) await browser.close(); } catch { /* ignore */ }
const banned = /deleted or deactivated|deactivat|账号已停用/i.test(msg);
realStdoutWrite(JSON.stringify({ ok: false, banned, registered, error: msg }));
}
})();