mirror of
https://wget.la/https://github.com/432539/gpt
synced 2026-08-17 03:26:59 +08:00
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、 拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能 不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
1468 lines
84 KiB
JavaScript
1468 lines
84 KiB
JavaScript
// 浏览器注册 wrapper —— 用 CloakBrowser(隐身 Chromium,指纹在 C++ 源码层随机,开箱即用)
|
||
// 驱动真实 ChatGPT / OpenAI 注册 UI 完成注册,作为「账号注册」的第二种方式(第一种是
|
||
// get-rt.js 的 HTTP 协议注册)。设计上与协议注册共用同一套外围资源:
|
||
// · 代理池:由 Python 侧随机选一个代理,注入 CloakBrowser 的 proxy(http://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/authorize(PKCE)开始,注册收尾时服务端会 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-DD(18~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 位 TOTP(HMAC-SHA1,30s 步长)——用于向 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/Security,SPA 据此
|
||
// 「自动打开」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/session(3500→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 signin(state/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) 通用 combobox(aria-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,保持老行为不变)。
|
||
// 用 let:bindPhone 模式在「拿到会话→转 codex 授权」时会把它切回 false(此步必须真接码)。
|
||
let skipPhone = input.skipPhone === true;
|
||
// 网页客户端注册(走 chatgpt.com 普通 signup,一般不强制手机):opt-in,默认沿用 codex 入口。
|
||
// 用 let:bindPhone 模式先用网页登录建立会话(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`);
|
||
|
||
// 启动 CloakBrowser:launchContext 每次都是「全新隐身上下文 + 独立临时 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 signin(POST 带 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+DOM,classifyPage 判定当前步,
|
||
// 再精确处理对应步骤。授权只在「无任何可填输入 + 授权语境」时点,非授权页绝不点 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; // 采集到的会话 cookie(CK)
|
||
let webAccessToken = ''; // 采集到的网页 access token(AT)
|
||
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 RT:CK+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(整数)」→ 变体A(Full 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: '', // 网页 RT:next-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 }));
|
||
}
|
||
})();
|