feat: 增强手机号登录入口识别逻辑,添加相关测试用例

This commit is contained in:
QLHazyCoder
2026-05-04 22:35:43 +08:00
parent 0795108014
commit efa77757aa
4 changed files with 421 additions and 28 deletions
+105 -26
View File
@@ -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]'
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)
);
return input && isVisibleElement(input) ? input : null;
}) || 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);
}
+313
View File
@@ -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)']);
});
+1 -1
View File
@@ -436,7 +436,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
2. 打开最新 OAuth 链接
3. 根据统一账号标识选择登录身份:
- 邮箱账号:沿用现有邮箱登录链路
- 手机号账号:优先探测登录页是否存在手机号登录入口,必要时展开更多选项或从邮箱页切到手机号页
- 手机号账号:优先探测登录页是否存在手机号登录入口,必要时展开更多选项或从邮箱页切到手机号页;进入手机号输入页后会复用 Step 2 的可视国家下拉框同步逻辑,按本轮手机号最长区号切到目标国家,避免英国 `+44` 号码仍按默认澳大利亚 `+61` 提交
4. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录
5. 确保真正进入验证码页;手机号登录允许以 `phone-verification` 作为 Step 7 的成功落点
6. 如果页面没有可用的手机号登录入口,则 Step 7 直接抛出明确业务错误,不再误进入 Step 8 邮箱轮询
+1
View File
@@ -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。