From efa77757aade79f80b88ad04b99a9b00fc88008c Mon Sep 17 00:00:00 2001 From: QLHazyCoder Date: Mon, 4 May 2026 22:35:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7=E7=99=BB=E5=BD=95=E5=85=A5=E5=8F=A3=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/signup-page.js | 133 ++++++++--- tests/step7-phone-login-entry.test.js | 313 ++++++++++++++++++++++++++ 项目完整链路说明.md | 2 +- 项目文件结构说明.md | 1 + 4 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 tests/step7-phone-login-entry.test.js diff --git a/content/signup-page.js b/content/signup-page.js index 482fc89..124d45f 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -2117,7 +2117,7 @@ const VERIFICATION_PAGE_PATTERN = /检查您的收件箱|输入我们刚刚向| const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|sign\s+in\s+to\s+codex(?:\s+with\s+chatgpt)?|login\s+to\s+codex|log\s+in\s+to\s+codex|authorize|授权/i; const OAUTH_CONSENT_FORM_SELECTOR = 'form[action*="/sign-in-with-chatgpt/" i][action*="/consent" i]'; const CONTINUE_ACTION_PATTERN = /继续|continue/i; -const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i; +const ADD_PHONE_PAGE_PATTERN = /add[\s-]*(?:a\s+)?phone|添加(?:手机|手机号|电话号码)|绑定(?:手机|手机号|电话号码)|验证(?:你的|您)?(?:手机|手机号|电话号码)|需要(?:手机|手机号|电话号码)|提供(?:手机|手机号|电话号码)|provide\s+(?:a\s+)?phone\s+number|phone\s+number\s+(?:required|verification)|verify\s+(?:your\s+)?phone|confirm\s+(?:your\s+)?phone/i; const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i; @@ -2398,10 +2398,8 @@ function isAddPhonePageReady() { const path = `${location.pathname || ''} ${location.href || ''}`; if (/\/add-phone(?:[/?#]|$)/i.test(path)) return true; - const phoneInput = document.querySelector( - 'input[type="tel"]:not([maxlength="6"]), input[name*="phone" i], input[id*="phone" i], input[autocomplete="tel"]' - ); - if (phoneInput && isVisibleElement(phoneInput)) { + const addPhoneForm = document.querySelector('form[action*="/add-phone" i]'); + if (addPhoneForm && isVisibleElement(addPhoneForm)) { return true; } @@ -2880,10 +2878,85 @@ function getLoginEmailInput() { } function getLoginPhoneInput() { - const input = document.querySelector( - 'input[type="tel"], input[autocomplete="tel"], input[name*="phone" i], input[id*="phone" i], input[placeholder*="phone" i], input[aria-label*="phone" i], input[placeholder*="telephone" i], input[aria-label*="telephone" i]' - ); - return input && isVisibleElement(input) ? input : null; + const input = document.querySelector([ + 'input[type="tel"]:not([maxlength="6"])', + 'input[autocomplete="tel"]', + 'input[inputmode="tel"]', + 'input[name*="phone" i]', + 'input[id*="phone" i]', + 'input[name*="telephone" i]', + 'input[id*="telephone" i]', + 'input[placeholder*="phone" i]', + 'input[aria-label*="phone" i]', + 'input[placeholder*="telephone" i]', + 'input[aria-label*="telephone" i]', + 'input[placeholder*="手机"]', + 'input[aria-label*="手机"]', + 'input[placeholder*="电话"]', + 'input[aria-label*="电话"]', + ].join(', ')); + if (input && isVisibleElement(input)) { + return input; + } + + return Array.from(document.querySelectorAll('input')).find((el) => { + if (!isVisibleElement(el)) return false; + const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase(); + if (['email', 'password', 'hidden', 'submit', 'button', 'checkbox', 'radio'].includes(type)) return false; + const maxLength = Number(el.getAttribute?.('maxlength') || el.maxLength || 0); + if (maxLength > 0 && maxLength <= 6) return false; + + const id = String(el.getAttribute?.('id') || '').trim(); + const ariaLabelledBy = String(el.getAttribute?.('aria-labelledby') || '').trim(); + const labelTexts = []; + if (id && typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => { + labelTexts.push(label.textContent || ''); + }); + } + if (ariaLabelledBy) { + ariaLabelledBy.split(/\s+/).forEach((labelId) => { + if (labelId && typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + labelTexts.push(document.getElementById?.(labelId)?.textContent || ''); + } + }); + } + const closestLabel = el.closest?.('label'); + if (closestLabel) { + labelTexts.push(closestLabel.textContent || ''); + } + + const attributeText = [ + el.getAttribute?.('name'), + el.getAttribute?.('id'), + el.getAttribute?.('placeholder'), + el.getAttribute?.('aria-label'), + el.getAttribute?.('autocomplete'), + el.getAttribute?.('inputmode'), + ...labelTexts, + ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (/phone|telephone|tel|手机|手机号|电话|电话号码/i.test(attributeText)) { + return true; + } + if (/email|邮箱|电子邮件/i.test(attributeText)) { + return false; + } + + const fieldRoot = el.closest?.('fieldset, [role="group"], [data-rac]'); + const fieldText = String(fieldRoot?.textContent || '').replace(/\s+/g, ' ').trim(); + if (fieldText && /phone|telephone|手机|手机号|电话|电话号码/i.test(fieldText) && !/email|邮箱|电子邮件/i.test(fieldText)) { + return true; + } + + const compactRoot = el.closest?.('div'); + const compactText = String(compactRoot?.textContent || '').replace(/\s+/g, ' ').trim(); + return Boolean( + compactText + && compactText.length <= 220 + && /phone|telephone|手机|手机号|电话|电话号码/i.test(compactText) + && !/email|邮箱|电子邮件/i.test(compactText) + ); + }) || null; } function getLoginPasswordInput() { @@ -3034,23 +3107,29 @@ function findLoginPhoneCountryOptionByNumber(select, phoneNumber) { return bestMatch; } -async function selectCountryForPhoneInput(phoneInput, phoneNumber = '', countryLabel = '') { +async function selectCountryForPhoneInput(phoneInput, phoneNumber = '', countryLabel = '', options = {}) { + const visibleStep = Math.floor(Number(options?.visibleStep) || 0) || 7; + const selection = await ensureSignupPhoneCountrySelected(phoneInput, { + countryLabel, + phoneNumber, + }); + const selectedOption = selection.selectedOption || getSignupPhoneSelectedCountryOption(phoneInput); + const targetDialCode = resolveSignupPhoneTargetDialCode({ countryLabel, phoneNumber }, selectedOption); + const displayedDialCode = getSignupPhoneDisplayedDialCode(phoneInput); + + if (selection.hasCountryControl && targetDialCode) { + if (!selection.matched || (displayedDialCode && displayedDialCode !== targetDialCode)) { + const currentCountryText = getSignupPhoneCountryButtonText(phoneInput) || displayedDialCode || '未知'; + throw new Error(`步骤 ${visibleStep}:手机号登录国家下拉框未能自动切换到 ${countryLabel || phoneNumber},当前显示为 ${currentCountryText},已停止提交以避免区号不匹配。`); + } + return targetDialCode; + } + const select = getLoginPhoneCountrySelect(phoneInput); - if (!select) { - return ''; - } - - const targetOption = findLoginPhoneCountryOptionByLabel(select, countryLabel) - || findLoginPhoneCountryOptionByNumber(select, phoneNumber); - if (targetOption && String(select.value || '') !== String(targetOption.value || '')) { - select.value = String(targetOption.value || ''); - select.dispatchEvent(new Event('input', { bubbles: true })); - select.dispatchEvent(new Event('change', { bubbles: true })); - await sleep(250); - } - - const selectedOption = select.options?.[select.selectedIndex] || targetOption || null; - return extractDialCodeFromText(getLoginPhoneCountryOptionLabel(selectedOption)); + const fallbackSelectedOption = select?.options?.[select.selectedIndex] || null; + return extractDialCodeFromText(getLoginPhoneCountryOptionLabel(fallbackSelectedOption)) + || displayedDialCode + || resolveSignupPhoneDialCodeFromNumber(phoneNumber); } function resolveLoginPhoneDialCode(phoneInput, options = {}) { @@ -4496,7 +4575,7 @@ async function step6LoginFromPhonePage(payload, snapshot) { }); } - const dialCodeFromSelection = await selectCountryForPhoneInput(phoneInput, phoneNumber, countryLabel); + const dialCodeFromSelection = await selectCountryForPhoneInput(phoneInput, phoneNumber, countryLabel, { visibleStep }); const dialCode = dialCodeFromSelection || resolveLoginPhoneDialCode(phoneInput, { phoneNumber, countryId, @@ -4579,7 +4658,7 @@ async function switchFromEmailPageToPhoneLogin(payload, snapshot) { log(`步骤 ${visibleStep}:当前在邮箱入口,正在切换到手机号登录...`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); await humanPause(350, 900); simulateClick(phoneEntryTrigger); - const nextSnapshot = normalizeStep6Snapshot(await waitForPhoneLoginEntrySwitchTransition()); + const nextSnapshot = normalizeStep6Snapshot(await waitForPhoneLoginEntrySwitchTransition(20000)); if (nextSnapshot.state === 'phone_entry_page') { return step6LoginFromPhonePage(payload, nextSnapshot); } diff --git a/tests/step7-phone-login-entry.test.js b/tests/step7-phone-login-entry.test.js new file mode 100644 index 0000000..ccfa88d --- /dev/null +++ b/tests/step7-phone-login-entry.test.js @@ -0,0 +1,313 @@ +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]; +} + +function createPhoneLoginEntryApi(options = {}) { + const { + href = 'https://auth.openai.com/log-in', + pathname = '/log-in', + inputAttributes = {}, + inputRootText = '', + pageText = '', + addPhoneForm = false, + } = options; + + return new Function(` +${extractConst('ADD_PHONE_PAGE_PATTERN')} + +const location = { + href: ${JSON.stringify(href)}, + pathname: ${JSON.stringify(pathname)}, +}; + +const phoneInput = { + type: ${JSON.stringify(inputAttributes.type || 'text')}, + maxLength: ${JSON.stringify(inputAttributes.maxLength ?? -1)}, + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : ''; + }, + closest(selector) { + if (!${JSON.stringify(Boolean(inputRootText))}) return null; + if (String(selector || '').includes('fieldset') || String(selector || '').includes('div')) { + return { textContent: ${JSON.stringify(inputRootText)} }; + } + return null; + }, + attributes: ${JSON.stringify(inputAttributes)}, +}; + +const form = ${addPhoneForm ? '{ textContent: "Add phone number" }' : 'null'}; + +const document = { + body: { + innerText: ${JSON.stringify(pageText || inputRootText)}, + textContent: ${JSON.stringify(pageText || inputRootText)}, + }, + querySelector(selector) { + const text = String(selector || ''); + if (text === 'form[action*="/add-phone" i]') return form; + return null; + }, + querySelectorAll(selector) { + if (selector === 'input') return [phoneInput]; + return []; + }, + getElementById() { + return null; + }, +}; + +const CSS = { + escape(value) { + return String(value || ''); + }, +}; + +function isVisibleElement(element) { + return Boolean(element); +} + +${extractFunction('getPageTextSnapshot')} +${extractFunction('getLoginPhoneInput')} +${extractFunction('isAddPhonePageReady')} + +return { + getLoginPhoneInput, + isAddPhonePageReady, +}; + `)(); +} + +test('step 7 treats localized phone login entry as phone input instead of add-phone', () => { + const api = createPhoneLoginEntryApi({ + inputRootText: '\u6b22\u8fce\u56de\u6765 \u7535\u8bdd\u53f7\u7801 +61 \u7ee7\u7eed \u8fd8\u6ca1\u6709\u5e10\u6237\uff1f\u8bf7\u6ce8\u518c', + }); + + assert.ok(api.getLoginPhoneInput(), 'localized phone login input should be detected'); + assert.equal(api.isAddPhonePageReady(), false); +}); + +test('step 7 does not mistake email entry with a phone switch action for phone input', () => { + const api = createPhoneLoginEntryApi({ + inputRootText: '\u7ee7\u7eed \u7ee7\u7eed\u4f7f\u7528\u624b\u673a\u767b\u5f55', + inputAttributes: { type: 'text', placeholder: '\u7535\u5b50\u90ae\u4ef6\u5730\u5740' }, + }); + + assert.equal(api.getLoginPhoneInput(), null); + assert.equal(api.isAddPhonePageReady(), false); +}); + +test('add-phone detection stays true for real add-phone urls and forms', () => { + assert.equal( + createPhoneLoginEntryApi({ + href: 'https://auth.openai.com/add-phone', + pathname: '/add-phone', + inputRootText: '\u7535\u8bdd\u53f7\u7801', + }).isAddPhonePageReady(), + true + ); + + assert.equal( + createPhoneLoginEntryApi({ + addPhoneForm: true, + inputRootText: 'Add phone number', + }).isAddPhonePageReady(), + true + ); +}); + +test('phone login switch waits longer for slow OpenAI entry transitions', () => { + assert.match( + extractFunction('switchFromEmailPageToPhoneLogin'), + /waitForPhoneLoginEntrySwitchTransition\(20000\)/ + ); +}); + +test('step 7 switches visible phone login country by provider dial code before filling number', async () => { + const api = new Function(` +const clicks = []; +let visibleCountryText = '\\u6fb3\\u5927\\u5229\\u4e9a (+61)'; +let listboxOpen = false; + +const phoneInput = { + closest() { + return null; + }, +}; + +const countryButton = { + textContent: '', + querySelector(selector) { + if (selector === '.react-aria-SelectValue') { + return { + get textContent() { + return visibleCountryText; + }, + }; + } + return null; + }, +}; + +const indonesiaOption = { textContent: '\\u5370\\u5ea6\\u5c3c\\u897f\\u4e9a +(62)' }; +const unitedKingdomOption = { textContent: '\\u82f1\\u56fd +(44)' }; + +const document = { + querySelectorAll(selector) { + const text = String(selector || ''); + if (text === '[role="listbox"] [role="option"], [role="option"]') { + return listboxOpen ? [indonesiaOption, unitedKingdomOption] : []; + } + if (text.includes('aria-haspopup="listbox"') || text.includes('aria-expanded')) { + return [countryButton]; + } + if (text === 'select') { + return []; + } + return []; + }, + querySelector(selector) { + const matches = this.querySelectorAll(selector); + return matches[0] || null; + }, +}; + +function isVisibleElement(element) { + return Boolean(element); +} + +function getActionText(element) { + if (element === countryButton) return visibleCountryText; + return String(element?.textContent || '').replace(/\\s+/g, ' ').trim(); +} + +function getPageTextSnapshot() { + return visibleCountryText; +} + +function simulateClick(target) { + clicks.push(getActionText(target)); + if (target === countryButton) { + listboxOpen = true; + } + if (target === unitedKingdomOption) { + visibleCountryText = '\\u82f1\\u56fd +(44)'; + listboxOpen = false; + } +} + +async function sleep() {} +function throwIfStopped() {} + +${extractFunction('normalizePhoneDigits')} +${extractFunction('extractDialCodeFromText')} +${extractFunction('dispatchSignupPhoneFieldEvents')} +${extractFunction('normalizeSignupCountryLabel')} +${extractFunction('getSignupCountryLabelAliases')} +${extractFunction('getSignupPhoneOptionLabel')} +${extractFunction('normalizeSignupCountryOptionValue')} +${extractFunction('getSignupRegionDisplayName')} +${extractFunction('getSignupPhoneCountryMatchLabels')} +${extractFunction('isSameSignupCountryOption')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('getSignupPhoneControlRoots')} +${extractFunction('querySignupPhoneCountryElements')} +${extractFunction('isSignupPhoneCountrySelect')} +${extractFunction('getSignupPhoneCountrySelect')} +${extractFunction('getSignupPhoneSelectedCountryOption')} +${extractFunction('getSignupPhoneCountryButtonText')} +${extractFunction('getSignupPhoneCountryButton')} +${extractFunction('getSignupPhoneDisplayedDialCode')} +${extractFunction('resolveSignupPhoneDialCodeFromNumber')} +${extractFunction('resolveSignupPhoneTargetDialCode')} +${extractFunction('getSignupPhoneCountryTargetLabels')} +${extractFunction('doesSignupPhoneCountryTextMatchTarget')} +${extractFunction('isSignupPhoneCountrySelectionSynced')} +${extractFunction('findSignupPhoneCountryOptionByLabel')} +${extractFunction('findSignupPhoneCountryOptionByPhoneNumber')} +${extractFunction('trySelectSignupPhoneCountryOption')} +${extractFunction('getVisibleSignupPhoneCountryListboxOptions')} +${extractFunction('findSignupPhoneCountryListboxOption')} +${extractFunction('trySelectSignupPhoneCountryListboxOption')} +${extractFunction('ensureSignupPhoneCountrySelected')} +function getLoginPhoneCountrySelect() { return null; } +function getLoginPhoneCountryOptionLabel() { return ''; } +${extractFunction('selectCountryForPhoneInput')} + +return { + async run() { + return selectCountryForPhoneInput(phoneInput, '447423278610', '', { visibleStep: 7 }); + }, + getClicks() { + return clicks.slice(); + }, + getVisibleCountryText() { + return visibleCountryText; + }, +}; + `)(); + + const dialCode = await api.run(); + + assert.equal(dialCode, '44'); + assert.equal(api.getVisibleCountryText(), '\u82f1\u56fd +(44)'); + assert.deepEqual(api.getClicks(), ['\u6fb3\u5927\u5229\u4e9a (+61)', '\u82f1\u56fd +(44)']); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index ae4424b..9d8d90e 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -436,7 +436,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 2. 打开最新 OAuth 链接 3. 根据统一账号标识选择登录身份: - 邮箱账号:沿用现有邮箱登录链路 - - 手机号账号:优先探测登录页是否存在手机号登录入口,必要时展开更多选项或从邮箱页切到手机号页 + - 手机号账号:优先探测登录页是否存在手机号登录入口,必要时展开更多选项或从邮箱页切到手机号页;进入手机号输入页后会复用 Step 2 的可视国家下拉框同步逻辑,按本轮手机号最长区号切到目标国家,避免英国 `+44` 号码仍按默认澳大利亚 `+61` 提交 4. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录 5. 确保真正进入验证码页;手机号登录允许以 `phone-verification` 作为 Step 7 的成功落点 6. 如果页面没有可用的手机号登录入口,则 Step 7 直接抛出明确业务错误,不再误进入 Step 8 邮箱轮询 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index c8331c9..20a1834 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -248,6 +248,7 @@ - `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。 - `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路,并覆盖 `phone-verification` 页面不会被误判成邮箱验证码页。 - `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。 +- `tests/step7-phone-login-entry.test.js`:测试 OAuth 手机号登录入口识别、真实 add-phone 与手机号登录页区分、慢切换等待窗口,以及手机号登录页可视国家下拉框会按本轮号码区号自动切换。 - `tests/step8-callback-handling.test.js`:测试 localhost 回调地址捕获逻辑,当前对应步骤 9。 - `tests/step8-debugger-stop.test.js`:测试调试器点击在 Stop 场景下的中止行为,当前对应步骤 9。 - `tests/step8-retry-page-recovery.test.js`:测试 OAuth 同意页点击后的重试页恢复分支,以及手机号验证完成后会继续回到 OAuth 同意页等待,当前对应步骤 9。