// 浏览器注册 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=,输入框 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= —— 权威来源,一次给出完整精确 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) 原生