diff --git a/background.js b/background.js index 81c9048..5846b83 100644 --- a/background.js +++ b/background.js @@ -4234,7 +4234,10 @@ function isAddPhoneAuthFailure(error) { return loggingStatus.isAddPhoneAuthFailure(error); } const message = getErrorMessage(error); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); } function getLoginAuthStateLabel(state) { diff --git a/background/logging-status.js b/background/logging-status.js index dc6659c..47692c1 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -63,7 +63,10 @@ function isAddPhoneAuthFailure(error) { const message = getErrorMessage(error); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); } function getLoginAuthStateLabel(state) { diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 0d1c9e8..f3209ea 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -11,7 +11,10 @@ getState, isAddPhoneAuthFailure = (error) => { const message = String(typeof error === 'string' ? error : error?.message || ''); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); }, isStep6RecoverableResult, isStep6SuccessResult, diff --git a/content/signup-page.js b/content/signup-page.js index fa867c7..4883f3e 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -291,11 +291,99 @@ async function handle405ResendError(step, remainingTimeout = 30000) { // ============================================================ const SIGNUP_ENTRY_TRIGGER_PATTERN = /免费注册|立即注册|注册|sign\s*up|register|create\s*account|create\s+account/i; -const SIGNUP_EMAIL_INPUT_SELECTOR = 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i]'; +const SIGNUP_EMAIL_INPUT_SELECTOR = [ + 'input[type="email"]', + 'input[autocomplete="email"]', + 'input[autocomplete="username"]', + 'input[name="email"]', + 'input[name="username"]', + 'input[id*="email"]', + 'input[placeholder*="email" i]', + 'input[placeholder*="电子邮件"]', + 'input[placeholder*="邮箱"]', + 'input[aria-label*="email" i]', + 'input[aria-label*="电子邮件"]', + 'input[aria-label*="邮箱"]', +].join(', '); +const SIGNUP_PHONE_INPUT_SELECTOR = [ + 'input[type="tel"]:not([maxlength="6"])', + 'input[name*="phone" i]', + 'input[id*="phone" i]', + 'input[autocomplete="tel"]', + 'input[placeholder*="手机"]', + 'input[aria-label*="手机"]', +].join(', '); +const SIGNUP_SWITCH_TO_EMAIL_PATTERN = new RegExp([ + String.raw`\u7ee7\u7eed\u4f7f\u7528(?:\u7535\u5b50\u90ae\u4ef6\u5730\u5740|\u90ae\u7bb1)\u767b\u5f55`, + String.raw`\u6539\u7528(?:\u7535\u5b50\u90ae\u4ef6\u5730\u5740|\u90ae\u7bb1)\u767b\u5f55`, + String.raw`continue\s+using\s+(?:an?\s+)?email(?:\s+address)?(?:\s+(?:to\s+)?(?:log\s*in|sign\s*in|sign\s*up))?`, + String.raw`continue\s+with\s+email(?:\s+address)?`, + String.raw`use\s+(?:an?\s+)?email(?:\s+address)?(?:\s+instead)?`, + String.raw`sign\s*(?:in|up)\s+with\s+email`, +].join('|'), 'i'); +const SIGNUP_SWITCH_ACTION_PATTERN = /\u7ee7\u7eed\u4f7f\u7528|\u6539\u7528|continue|use|sign\s*(?:in|up)/i; +const SIGNUP_EMAIL_ACTION_PATTERN = /\u7535\u5b50\u90ae\u4ef6|\u90ae\u7bb1|email/i; +const SIGNUP_WORK_EMAIL_PATTERN = /\u5de5\u4f5c|business|work\s+email/i; function getSignupEmailInput() { const input = document.querySelector(SIGNUP_EMAIL_INPUT_SELECTOR); - return input && isVisibleElement(input) ? input : null; + if (input && isVisibleElement(input)) { + return input; + } + + const fallback = Array.from(document.querySelectorAll('input')).find((el) => { + if (!isVisibleElement(el)) return false; + const type = String(el.getAttribute?.('type') || '').trim().toLowerCase(); + const name = String(el.getAttribute?.('name') || '').trim().toLowerCase(); + const id = String(el.getAttribute?.('id') || '').trim().toLowerCase(); + const placeholder = String(el.getAttribute?.('placeholder') || '').trim(); + const ariaLabel = String(el.getAttribute?.('aria-label') || '').trim(); + const autocomplete = String(el.getAttribute?.('autocomplete') || '').trim().toLowerCase(); + const combinedText = `${placeholder} ${ariaLabel}`; + return type === 'email' + || autocomplete === 'email' + || autocomplete === 'username' + || /email|username/i.test(`${name} ${id}`) + || /email|电子邮件|邮箱/i.test(combinedText); + }); + + return fallback || null; +} + +function getSignupPhoneInput() { + const input = document.querySelector(SIGNUP_PHONE_INPUT_SELECTOR); + if (input && isVisibleElement(input)) { + return input; + } + + const fallback = Array.from(document.querySelectorAll('input')).find((el) => { + if (!isVisibleElement(el)) return false; + const type = String(el.getAttribute?.('type') || '').trim().toLowerCase(); + const name = String(el.getAttribute?.('name') || '').trim().toLowerCase(); + const id = String(el.getAttribute?.('id') || '').trim().toLowerCase(); + const placeholder = String(el.getAttribute?.('placeholder') || '').trim(); + const ariaLabel = String(el.getAttribute?.('aria-label') || '').trim(); + const autocomplete = String(el.getAttribute?.('autocomplete') || '').trim().toLowerCase(); + const combinedText = `${placeholder} ${ariaLabel}`; + return type === 'tel' + || autocomplete === 'tel' + || /phone|tel/i.test(`${name} ${id}`) + || /手机|电话|手机号/.test(combinedText); + }); + + return fallback || null; +} + +function findSignupUseEmailTrigger() { + const candidates = document.querySelectorAll('button, a, [role="button"], [role="link"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + const text = getActionText(el); + if (!text) return false; + if (SIGNUP_WORK_EMAIL_PATTERN.test(text)) return false; + return SIGNUP_SWITCH_TO_EMAIL_PATTERN.test(text) + || (SIGNUP_SWITCH_ACTION_PATTERN.test(text) && SIGNUP_EMAIL_ACTION_PATTERN.test(text)); + }) || null; } function getSignupEmailContinueButton({ allowDisabled = false } = {}) { @@ -351,6 +439,16 @@ function inspectSignupEntryState() { }; } + const phoneInput = getSignupPhoneInput(); + if (phoneInput) { + return { + state: 'phone_entry', + phoneInput, + switchToEmailTrigger: findSignupUseEmailTrigger(), + url: location.href, + }; + } + const signupTrigger = findSignupEntryTrigger(); if (signupTrigger) { return { @@ -371,6 +469,7 @@ function getSignupEntryStateSummary(snapshot = inspectSignupEntryState()) { state: snapshot?.state || 'unknown', url: snapshot?.url || location.href, hasEmailInput: Boolean(snapshot?.emailInput || getSignupEmailInput()), + hasPhoneInput: Boolean(snapshot?.phoneInput || getSignupPhoneInput()), hasPasswordInput: Boolean(snapshot?.passwordInput || getSignupPasswordInput()), }; @@ -393,6 +492,14 @@ function getSignupEntryStateSummary(snapshot = inspectSignupEntryState()) { }; } + if (snapshot?.switchToEmailTrigger) { + summary.switchToEmailTrigger = { + tag: (snapshot.switchToEmailTrigger.tagName || '').toLowerCase(), + text: getActionText(snapshot.switchToEmailTrigger).slice(0, 80), + enabled: isActionEnabled(snapshot.switchToEmailTrigger), + }; + } + return summary; } @@ -522,7 +629,9 @@ function getSignupEntryDiagnostics() { devicePixelRatio: Number(view?.devicePixelRatio) || 0, }, hasEmailInput: Boolean(getSignupEmailInput()), + hasPhoneInput: Boolean(getSignupPhoneInput()), hasPasswordInput: Boolean(getSignupPasswordInput()), + hasSwitchToEmailAction: Boolean(findSignupUseEmailTrigger()), bodyContainsSignupText: SIGNUP_ENTRY_TRIGGER_PATTERN.test(getPageTextSnapshot()), signupLikeActionCounts: { total: signupLikeActions.length, @@ -653,6 +762,8 @@ async function waitForSignupEntryState(options = {}) { let clickAttempts = 0; let lastState = ''; let slowSnapshotLogged = false; + let lastSwitchToEmailAt = 0; + let loggedMissingSwitchToEmail = false; while (Date.now() - start < timeout) { throwIfStopped(); @@ -667,6 +778,34 @@ async function waitForSignupEntryState(options = {}) { return snapshot; } + if (snapshot.state === 'phone_entry') { + if (!autoOpenEntry) { + return snapshot; + } + + if (snapshot.switchToEmailTrigger && Date.now() - lastSwitchToEmailAt >= 1500) { + lastSwitchToEmailAt = Date.now(); + loggedMissingSwitchToEmail = false; + if (logDiagnostics) { + log(`步骤 ${step}:检测到手机号输入模式,准备点击切换邮箱入口:"${getActionText(snapshot.switchToEmailTrigger).slice(0, 80)}"`); + } + log('步骤 2:检测到手机号输入模式,正在切换到邮箱输入模式...'); + await humanPause(350, 900); + simulateClick(snapshot.switchToEmailTrigger); + } else if (!snapshot.switchToEmailTrigger && !loggedMissingSwitchToEmail) { + loggedMissingSwitchToEmail = true; + log('步骤 2:检测到手机号输入模式,但暂未识别到“改用邮箱/继续使用电子邮件地址登录”按钮,继续等待界面稳定...', 'warn'); + } + + if (logDiagnostics && !slowSnapshotLogged && Date.now() - start >= 5000) { + slowSnapshotLogged = true; + log(`步骤 ${step}:等待手机号入口切换超过 5 秒,页面诊断快照:${JSON.stringify(getSignupEntryDiagnostics())}`, 'warn'); + } + + await sleep(250); + continue; + } + if (snapshot.state === 'entry_home') { if (!autoOpenEntry) { return snapshot; @@ -701,7 +840,7 @@ async function waitForSignupEntryState(options = {}) { async function ensureSignupEntryReady(timeout = 15000) { const snapshot = await waitForSignupEntryState({ timeout, autoOpenEntry: false }); - if (snapshot.state === 'entry_home' || snapshot.state === 'email_entry' || snapshot.state === 'password_page') { + if (snapshot.state === 'entry_home' || snapshot.state === 'phone_entry' || snapshot.state === 'email_entry' || snapshot.state === 'password_page') { return { ready: true, state: snapshot.state, diff --git a/tests/background-logging-status-module.test.js b/tests/background-logging-status-module.test.js index 9abedb6..fbff2ff 100644 --- a/tests/background-logging-status-module.test.js +++ b/tests/background-logging-status-module.test.js @@ -15,3 +15,28 @@ test('logging/status module exposes a factory', () => { assert.equal(typeof api?.createLoggingStatus, 'function'); }); + +test('logging/status add-phone detection ignores step 2 phone-entry switch failures', () => { + const source = fs.readFileSync('background/logging-status.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundLoggingStatus;`)(globalScope); + + const loggingStatus = api.createLoggingStatus({ + chrome: { runtime: { sendMessage() { return Promise.resolve(); } } }, + DEFAULT_STATE: { stepStatuses: {} }, + getState: async () => ({ stepStatuses: {} }), + isRecoverableStep9AuthFailure: () => false, + LOG_PREFIX: '[test]', + setState: async () => {}, + STOP_ERROR_MESSAGE: 'stopped', + }); + + assert.equal( + loggingStatus.isAddPhoneAuthFailure('Step 2: the signup dialog is still in phone entry mode and has not switched back to email entry. URL: https://chatgpt.com/'), + false + ); + assert.equal( + loggingStatus.isAddPhoneAuthFailure('Step 8: verification submitted but the auth flow entered the phone number page. URL: https://auth.openai.com/add-phone'), + true + ); +}); diff --git a/tests/signup-entry-diagnostics.test.js b/tests/signup-entry-diagnostics.test.js index 79323c1..ab50b95 100644 --- a/tests/signup-entry-diagnostics.test.js +++ b/tests/signup-entry-diagnostics.test.js @@ -112,10 +112,18 @@ function getSignupEmailInput() { return null; } +function getSignupPhoneInput() { + return null; +} + function getSignupPasswordInput() { return null; } +function findSignupUseEmailTrigger() { + return null; +} + function getPageTextSnapshot() { return 'Welcome to ChatGPT. Try our latest models.'; } @@ -251,10 +259,18 @@ function getSignupEmailInput() { return null; } +function getSignupPhoneInput() { + return null; +} + function getSignupPasswordInput() { return null; } +function findSignupUseEmailTrigger() { + return null; +} + function getPageTextSnapshot() { return 'ChatGPT 登录'; } diff --git a/tests/signup-step2-email-switch.test.js b/tests/signup-step2-email-switch.test.js new file mode 100644 index 0000000..d8bbfb5 --- /dev/null +++ b/tests/signup-step2-email-switch.test.js @@ -0,0 +1,431 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function extractConst(name) { + const pattern = new RegExp(`const\\s+${name}\\s*=\\s*[\\s\\S]*?;`); + const match = source.match(pattern); + if (!match) { + throw new Error(`missing const ${name}`); + } + return match[0]; +} + +test('waitForSignupEntryState switches from phone mode to email mode before step 2 fills the address', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +let phase = 'phone'; +let now = 0; + +const phoneInput = { + kind: 'phone', + getAttribute(name) { + if (name === 'type') return 'tel'; + return ''; + }, +}; + +const switchButton = { + textContent: 'Continue using email address', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const emailInput = { + kind: 'email', + getAttribute(name) { + if (name === 'type') return 'email'; + return ''; + }, +}; + +const document = { + querySelector(selector) { + if (selector === SIGNUP_EMAIL_INPUT_SELECTOR) { + return phase === 'email' ? emailInput : null; + } + if (selector === SIGNUP_PHONE_INPUT_SELECTOR) { + return phase === 'phone' ? phoneInput : null; + } + return null; + }, + querySelectorAll(selector) { + if (selector === 'button, a, [role="button"], [role="link"]') { + return phase === 'phone' ? [switchButton] : []; + } + if (selector === 'a, button, [role="button"], [role="link"]') { + return []; + } + if (selector === 'input') { + return phase === 'phone' ? [phoneInput] : [emailInput]; + } + return []; + }, +}; + +const location = { + href: 'https://chatgpt.com/', +}; + +const Date = { + now() { + return now; + }, +}; + +${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')} +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} +${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')} +${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')} +${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')} +${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')} +${extractConst('SIGNUP_WORK_EMAIL_PATTERN')} + +function isVisibleElement(el) { + return Boolean(el); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getSignupPasswordInput() { + return null; +} + +function isSignupPasswordPage() { + return false; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +function findSignupEntryTrigger() { + return null; +} + +function getSignupPasswordDisplayedEmail() { + return ''; +} + +function throwIfStopped() {} + +function log(message, level = 'info') { + logs.push({ message, level }); +} + +async function humanPause() {} + +function simulateClick(target) { + clicks.push(getActionText(target)); + if (target === switchButton) { + phase = 'email'; + } +} + +async function sleep(ms) { + now += ms; +} + +${extractFunction('getSignupEmailInput')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('findSignupUseEmailTrigger')} +${extractFunction('getSignupEmailContinueButton')} +${extractFunction('inspectSignupEntryState')} +${extractFunction('waitForSignupEntryState')} + +return { + async run() { + return waitForSignupEntryState({ timeout: 5000, autoOpenEntry: true }); + }, + getClicks() { + return clicks.slice(); + }, + getLogs() { + return logs.slice(); + }, +}; +`)(); + + const snapshot = await api.run(); + + assert.equal(snapshot.state, 'email_entry'); + assert.deepEqual(api.getClicks(), ['Continue using email address']); + assert.equal(api.getLogs().length > 0, true); +}); + +test('waitForSignupEntryState also recognizes the Chinese switch-to-email button text', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +let phase = 'phone'; +let now = 0; + +const phoneInput = { + kind: 'phone', + getAttribute(name) { + if (name === 'type') return 'tel'; + return ''; + }, +}; + +const switchButton = { + textContent: '\\u7ee7\\u7eed\\u4f7f\\u7528\\u7535\\u5b50\\u90ae\\u4ef6\\u5730\\u5740\\u767b\\u5f55', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const workEmailButton = { + textContent: '\\u7ee7\\u7eed\\u4f7f\\u7528\\u5de5\\u4f5c\\u7535\\u5b50\\u90ae\\u4ef6\\u5730\\u5740\\u767b\\u5f55', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const emailInput = { + kind: 'email', + getAttribute(name) { + if (name === 'type') return 'email'; + return ''; + }, +}; + +const document = { + querySelector(selector) { + if (selector === SIGNUP_EMAIL_INPUT_SELECTOR) { + return phase === 'email' ? emailInput : null; + } + if (selector === SIGNUP_PHONE_INPUT_SELECTOR) { + return phase === 'phone' ? phoneInput : null; + } + return null; + }, + querySelectorAll(selector) { + if (selector === 'button, a, [role="button"], [role="link"]') { + return phase === 'phone' ? [switchButton, workEmailButton] : []; + } + if (selector === 'a, button, [role="button"], [role="link"]') { + return []; + } + if (selector === 'input') { + return phase === 'phone' ? [phoneInput] : [emailInput]; + } + return []; + }, +}; + +const location = { + href: 'https://chatgpt.com/', +}; + +const Date = { + now() { + return now; + }, +}; + +${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')} +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} +${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')} +${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')} +${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')} +${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')} +${extractConst('SIGNUP_WORK_EMAIL_PATTERN')} + +function isVisibleElement(el) { + return Boolean(el); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getSignupPasswordInput() { + return null; +} + +function isSignupPasswordPage() { + return false; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +function findSignupEntryTrigger() { + return null; +} + +function getSignupPasswordDisplayedEmail() { + return ''; +} + +function throwIfStopped() {} + +function log(message, level = 'info') { + logs.push({ message, level }); +} + +async function humanPause() {} + +function simulateClick(target) { + clicks.push(getActionText(target)); + if (target === switchButton) { + phase = 'email'; + } +} + +async function sleep(ms) { + now += ms; +} + +${extractFunction('getSignupEmailInput')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('findSignupUseEmailTrigger')} +${extractFunction('getSignupEmailContinueButton')} +${extractFunction('inspectSignupEntryState')} +${extractFunction('waitForSignupEntryState')} + +return { + async run() { + return waitForSignupEntryState({ timeout: 5000, autoOpenEntry: true }); + }, + getClicks() { + return clicks.slice(); + }, +}; +`)(); + + const snapshot = await api.run(); + + assert.equal(snapshot.state, 'email_entry'); + assert.deepEqual(api.getClicks(), ['继续使用电子邮件地址登录']); +}); + +test('getSignupEmailInput recognizes localized email placeholders in text inputs', () => { + const api = new Function(` +const localizedEmailInput = { + kind: 'localized-email', + getAttribute(name) { + if (name === 'placeholder') return '电子邮件地址'; + if (name === 'type') return 'text'; + return ''; + }, +}; + +const document = { + querySelector() { + return null; + }, + querySelectorAll(selector) { + if (selector === 'input') { + return [localizedEmailInput]; + } + return []; + }, +}; + +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} + +function isVisibleElement(el) { + return Boolean(el); +} + +${extractFunction('getSignupEmailInput')} + +return { + run() { + return getSignupEmailInput(); + }, +}; +`)(); + + assert.equal(api.run()?.kind, 'localized-email'); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 23d20e7..8698aa3 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -294,11 +294,12 @@ 1. 解析本轮应使用的邮箱 2. 打开或复用注册页 -3. 点击注册入口并提交邮箱 -4. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位 -5. 等待邮箱提交后的真实落地页 -6. 如果进入密码页,则继续执行 Step 3 -7. 如果直接进入邮箱验证码页,则自动跳过 Step 3 并进入 Step 4 +3. 如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式 +4. 在邮箱输入模式下提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label` +5. 以当前邮箱先写入一条“停止(流程尚未完成)”的记录占位 +6. 等待邮箱提交后的真实落地页 +7. 如果进入密码页,则继续执行 Step 3 +8. 如果直接进入邮箱验证码页,则自动跳过 Step 3 并进入 Step 4 ### Step 3 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index c656600..6bffabb 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -45,7 +45,7 @@ - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失别名基邮箱与 2925 切号上下文。 - `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口与自定义邮箱池读取;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路;当生成方式为 iCloud 且 `icloudFetchMode = always_new` 时,会强制跳过未用别名复用并新建别名;当生成方式为 `custom-pool` 时,会按当前目标轮次读取邮箱池中的对应邮箱。 -- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 +- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 @@ -81,7 +81,7 @@ - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。 - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 -- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前还会在登录链路中显式识别 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。 +- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 会在注册弹窗默认处于手机号输入模式时自动切回邮箱输入模式,并兼容本地化邮箱占位与 `aria-label`;登录链路还会显式识别 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。 - `content/utils.js`:内容脚本公共工具层,负责日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击。 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做步骤 10 判定。 @@ -191,6 +191,7 @@ - `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、共享号池表单脚本加载顺序、显隐交互与空态渲染。 - `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。 - `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。 +- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式,以及本地化邮箱输入框识别。 - `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。 - `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。 - `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。