feat: continue OAuth flow through HeroSMS phone verification

- 合并 dev 最新主线改动:同步当前认证页恢复、来源配置和侧栏能力的现有基线

- 本地补充修复:补齐 HeroSMS 手机验证与 sidepanel 初始化冲突,修正 Step 9 等待逻辑里的未声明变量,并同步结构/链路文档与回归测试

- 影响范围:OAuth 步骤 8/9、HeroSMS 配置、认证页内容脚本、侧栏初始化、项目文档与测试
This commit is contained in:
QLHazyCoder
2026-04-25 14:04:11 +08:00
73 changed files with 8442 additions and 462 deletions
+9 -1
View File
@@ -47,7 +47,15 @@
return true;
}
return /(?:认证失败|回调 URL 提交失败):\s*/i.test(text);
if (/请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text)) {
return true;
}
if (/bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(text)) {
return true;
}
return /(?:认证失败|回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[:]?\s*/i.test(text);
}
return {
+366 -31
View File
@@ -69,15 +69,141 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Find mail items
// ============================================================
function findMailItems() {
return document.querySelectorAll('div[sign="letter"]');
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function getCurrentMailIds() {
function isVisibleNode(node) {
if (!node) return false;
if (node.hidden) return false;
const style = typeof window.getComputedStyle === 'function'
? window.getComputedStyle(node)
: null;
if (style && (style.display === 'none' || style.visibility === 'hidden')) {
return false;
}
const rect = typeof node.getBoundingClientRect === 'function'
? node.getBoundingClientRect()
: null;
if (rect && rect.width <= 0 && rect.height <= 0) {
return false;
}
return true;
}
function isLikelyMailItemNode(node) {
if (!isVisibleNode(node)) {
return false;
}
if (node.matches?.('div[sign="letter"]')) {
return true;
}
if (node.querySelector?.('.nui-user, span.da0, [sign="trash"], [title="删除邮件"], [class*="subject"], [class*="sender"]')) {
return true;
}
const summaryText = normalizeText(
node.getAttribute?.('aria-label')
|| node.getAttribute?.('title')
|| node.textContent
);
if (!summaryText) {
return false;
}
return /发件人|验证码|verification|chatgpt|openai|code/i.test(summaryText);
}
function findMailItems() {
const selectorGroups = [
'div[sign="letter"]',
'[role="option"][aria-label]',
'[role="listitem"][aria-label]',
'tr[aria-label]',
'li[aria-label]',
'div[aria-label]',
];
for (const selector of selectorGroups) {
const matches = Array.from(document.querySelectorAll(selector)).filter(isLikelyMailItemNode);
if (matches.length > 0) {
return matches;
}
}
return [];
}
function getMailTextBySelectors(item, selectors = []) {
for (const selector of selectors) {
const candidates = item.querySelectorAll(selector);
for (const candidate of candidates) {
const texts = [
candidate.getAttribute?.('title'),
candidate.getAttribute?.('aria-label'),
candidate.textContent,
];
for (const text of texts) {
const normalized = normalizeText(text);
if (normalized) {
return normalized;
}
}
}
}
return '';
}
function getMailSenderText(item) {
return getMailTextBySelectors(item, [
'.nui-user',
'[class*="sender"]',
'[class*="from"]',
'[data-sender]',
]);
}
function getMailSubjectText(item) {
return getMailTextBySelectors(item, [
'span.da0',
'[class*="subject"]',
'[data-subject]',
'strong',
]);
}
function getMailRowText(item) {
const ariaLabel = normalizeText(item.getAttribute('aria-label'));
const sender = getMailSenderText(item);
const subject = getMailSubjectText(item);
const fullText = normalizeText(item.textContent || '');
return normalizeText([ariaLabel, sender, subject, fullText].filter(Boolean).join(' '));
}
function getMailItemId(item, index = 0) {
const candidates = [
item.getAttribute('id'),
item.getAttribute('data-id'),
item.dataset?.id,
item.getAttribute('data-key'),
item.getAttribute('key'),
].filter(Boolean);
if (candidates.length > 0) {
return String(candidates[0]);
}
return `${index}|${getMailRowText(item).slice(0, 240)}`;
}
function getCurrentMailIds(items = []) {
const ids = new Set();
findMailItems().forEach(item => {
const id = item.getAttribute('id') || '';
if (id) ids.add(id);
const sourceItems = items.length > 0 ? items : findMailItems();
sourceItems.forEach((item, index) => {
ids.add(getMailItemId(item, index));
});
return ids;
}
@@ -90,7 +216,7 @@ function normalizeMinuteTimestamp(timestamp) {
}
function parseMail163Timestamp(rawText) {
const text = (rawText || '').replace(/\s+/g, ' ').trim();
const text = normalizeText(rawText);
if (!text) return null;
let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
@@ -107,6 +233,37 @@ function parseMail163Timestamp(rawText) {
).getTime();
}
match = text.match(/今天\s*(\d{1,2}):(\d{2})/);
if (match) {
const [, hour, minute] = match;
const now = new Date();
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
Number(hour),
Number(minute),
0,
0
).getTime();
}
match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
if (match) {
const [, hour, minute] = match;
const now = new Date();
now.setDate(now.getDate() - 1);
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
Number(hour),
Number(minute),
0,
0
).getTime();
}
match = text.match(/\b(\d{1,2}):(\d{2})\b/);
if (match) {
const [, hour, minute] = match;
@@ -125,18 +282,48 @@ function parseMail163Timestamp(rawText) {
return null;
}
function getMailTimestamp(item) {
const candidates = [];
const timeCell = item.querySelector('.e00[title], [title*="年"][title*=":"]');
if (timeCell?.getAttribute('title')) candidates.push(timeCell.getAttribute('title'));
if (timeCell?.textContent) candidates.push(timeCell.textContent);
function isLikelyMailTimestampText(value) {
const text = normalizeText(value);
if (!text) {
return false;
}
return /(\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2})|今天\s*\d{1,2}:\d{2}|昨天\s*\d{1,2}:\d{2}|\b\d{1,2}:\d{2}\b/.test(text);
}
const titledNodes = item.querySelectorAll('[title]');
titledNodes.forEach((node) => {
const title = node.getAttribute('title');
if (title) candidates.push(title);
function collectMailTimestampCandidates(item) {
const candidates = [];
const seen = new Set();
const pushCandidate = (value) => {
const normalized = normalizeText(value);
if (!normalized || !isLikelyMailTimestampText(normalized) || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
};
const priorityNodes = item.querySelectorAll('.e00, [title], [aria-label], time, [class*="time"], [class*="date"]');
priorityNodes.forEach((node) => {
pushCandidate(node.getAttribute?.('title'));
pushCandidate(node.getAttribute?.('aria-label'));
pushCandidate(node.textContent);
});
const textNodes = item.querySelectorAll('span, div, td, strong, b');
textNodes.forEach((node) => {
const text = normalizeText(node.textContent);
if (text && text.length <= 24) {
pushCandidate(text);
}
});
pushCandidate(item.getAttribute('aria-label'));
pushCandidate(item.getAttribute('title'));
return candidates;
}
function getMailTimestamp(item) {
const candidates = collectMailTimestampCandidates(item);
for (const candidate of candidates) {
const parsed = parseMail163Timestamp(candidate);
if (parsed) return parsed;
@@ -145,6 +332,134 @@ function getMailTimestamp(item) {
return null;
}
function collectOpenedMailTextCandidates() {
const texts = [];
const seen = new Set();
const pushText = (value) => {
const normalized = normalizeText(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
texts.push(normalized);
};
const selectors = [
'.readHtml',
'[class*="readmail"]',
'[class*="mailread"]',
'[class*="mailBody"]',
'[class*="mailbody"]',
'[class*="mail-content"]',
'[class*="mailContent"]',
'[class*="mail-detail"]',
'[class*="mailDetail"]',
'[class*="detail"] [class*="content"]',
'[class*="read"] [class*="content"]',
'[role="main"]',
];
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((node) => {
pushText(node.innerText || node.textContent);
});
});
document.querySelectorAll('iframe').forEach((frame) => {
try {
pushText(frame.contentDocument?.body?.innerText || frame.contentDocument?.body?.textContent);
} catch {
// Ignore cross-frame access errors and keep trying other candidates.
}
});
pushText(document.body?.innerText || document.body?.textContent);
return texts.sort((a, b) => b.length - a.length);
}
function selectOpenedMailTextCandidate(item, candidates = [], options = {}) {
const subject = normalizeText(getMailSubjectText(item)).toLowerCase();
const sender = normalizeText(getMailSenderText(item)).toLowerCase();
const excludedSet = new Set((options.excludedTexts || []).map((value) => normalizeText(value)));
const allowExcludedFallback = options.allowExcludedFallback !== false;
const pickCandidate = (source) => source.find((candidate) => {
const lower = candidate.toLowerCase();
if (subject && lower.includes(subject)) {
return true;
}
if (sender && lower.includes(sender)) {
return true;
}
return Boolean(extractVerificationCode(candidate) && /chatgpt|openai|verification|验证码|login code/i.test(lower));
}) || source[0] || '';
const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate)));
const preferred = pickCandidate(filteredCandidates);
if (preferred || !allowExcludedFallback) {
return preferred;
}
return pickCandidate(candidates);
}
function readOpenedMailText(item, options = {}) {
const candidates = collectOpenedMailTextCandidates();
return selectOpenedMailTextCandidate(item, candidates, options);
}
async function returnToInbox() {
const inboxLink = document.querySelector('.nui-tree-item-text[title="收件箱"], [title="收件箱"]');
if (inboxLink) {
if (typeof simulateClick === 'function') {
simulateClick(inboxLink);
} else {
inboxLink.click();
}
}
for (let i = 0; i < 20; i += 1) {
if (findMailItems().length > 0) {
return true;
}
await sleep(250);
}
return false;
}
async function openMailAndGetMessageText(item) {
const beforeCandidates = collectOpenedMailTextCandidates();
const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates);
if (typeof simulateClick === 'function') {
simulateClick(item);
} else {
item.click();
}
let openedText = '';
for (let i = 0; i < 24; i += 1) {
await sleep(250);
const candidate = readOpenedMailText(item, {
excludedTexts: beforeCandidates,
allowExcludedFallback: false,
});
if (!candidate) {
continue;
}
openedText = candidate;
if (extractVerificationCode(candidate)) {
break;
}
if (candidate !== beforeText && candidate.length > beforeText.length + 24) {
break;
}
}
await returnToInbox();
return openedText;
}
function scheduleEmailCleanup(item, step) {
setTimeout(() => {
Promise.resolve(deleteEmail(item, step)).catch(() => {
@@ -199,7 +514,7 @@ async function handlePollEmail(step, payload) {
log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`);
// Snapshot existing mail IDs
const existingMailIds = getCurrentMailIds();
const existingMailIds = getCurrentMailIds(items);
log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`);
const FALLBACK_AFTER = 3;
@@ -215,38 +530,58 @@ async function handlePollEmail(step, payload) {
const allItems = findMailItems();
const useFallback = attempt > FALLBACK_AFTER;
for (const item of allItems) {
const id = item.getAttribute('id') || '';
for (let index = 0; index < allItems.length; index++) {
const item = allItems[index];
const id = getMailItemId(item, index);
const mailTimestamp = getMailTimestamp(item);
const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0);
const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute);
const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && mailMinute > 0);
if (!passesTimeFilter) {
continue;
}
if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(id)) continue;
if (!useFallback && existingMailIds.has(id)) {
continue;
}
const senderEl = item.querySelector('.nui-user');
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
const sender = getMailSenderText(item).toLowerCase();
const subject = getMailSubjectText(item);
const rowText = getMailRowText(item);
const ariaLabel = normalizeText(item.getAttribute('aria-label')).toLowerCase();
const combinedText = normalizeText([subject, ariaLabel, rowText].filter(Boolean).join(' '));
const subjectEl = item.querySelector('span.da0');
const subject = subjectEl ? subjectEl.textContent : '';
if (!mailTimestamp) {
log(`步骤 ${step}:邮件 ${id.slice(0, 60)} 未读取到时间,已跳过时间窗口校验后的文本匹配阶段。`, 'info');
}
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
const senderMatch = senderFilters.some((filter) => {
const normalizedFilter = String(filter || '').toLowerCase();
return normalizedFilter && (sender.includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
});
const subjectMatch = subjectFilters.some((filter) => {
const normalizedFilter = String(filter || '').toLowerCase();
return normalizedFilter && (subject.toLowerCase().includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
});
if (senderMatch || subjectMatch) {
const code = extractVerificationCode(subject + ' ' + ariaLabel);
let code = extractVerificationCode(combinedText);
let codeSource = '邮件列表';
if (!code) {
const openedText = await openMailAndGetMessageText(item);
code = extractVerificationCode(openedText);
if (code) {
codeSource = '邮件正文';
}
}
if (code && excludedCodeSet.has(code)) {
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
} else if (code && !seenCodes.has(code)) {
seenCodes.add(code);
persistSeenCodes();
const source = useFallback && existingMailIds.has(id) ? '回退匹配邮件' : '新邮件';
const source = useFallback && existingMailIds.has(id) ? `回退匹配${codeSource}` : `新邮件${codeSource}`;
const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)}`, 'ok');
+86 -2
View File
@@ -515,8 +515,9 @@ function detectMail2925ViewState() {
return { view: 'limit', limitMessage };
}
if (findMailItems().length > 0) {
return { view: 'mailbox', limitMessage: '' };
const mailboxEmail = getMail2925DisplayedMailboxEmail();
if (findMailItems().length > 0 || mailboxEmail) {
return { view: 'mailbox', limitMessage: '', mailboxEmail };
}
if (findMail2925LoginPasswordInput() && findMail2925LoginEmailInput()) {
@@ -531,6 +532,67 @@ function detectMail2925ViewState() {
return { view: 'unknown', limitMessage: '' };
}
function getMail2925DisplayedMailboxEmail() {
const directSelectors = [
'.right-header',
'[class~="right-header"]',
'[class*="right-header"]',
'[class*="user"] [class*="mail"]',
'[class*="user"] [class*="email"]',
'[class*="account"] [class*="mail"]',
'[class*="account"] [class*="email"]',
'[class*="header"] [class*="mail"]',
'[class*="header"] [class*="email"]',
];
for (const selector of directSelectors) {
const candidates = document.querySelectorAll(selector);
for (const candidate of candidates) {
if (!isVisibleNode(candidate) || isMailItemNode(candidate)) {
continue;
}
const email = extractEmails(candidate.textContent || candidate.innerText || '')
.find((value) => /@2925\.com$/i.test(String(value || '').trim())) || '';
if (email) {
return email;
}
}
}
const topCandidates = Array.from(document.querySelectorAll('body *'))
.filter((node) => {
if (!isVisibleNode(node) || isMailItemNode(node)) {
return false;
}
const rect = typeof node.getBoundingClientRect === 'function'
? node.getBoundingClientRect()
: null;
if (!rect) return false;
return rect.top >= 0 && rect.top <= Math.max(window.innerHeight * 0.35, 280);
})
.map((node) => {
const email = extractEmails(node.textContent || node.innerText || '')
.find((value) => /@2925\.com$/i.test(String(value || '').trim())) || '';
return { node, email };
})
.filter((entry) => entry.email);
if (!topCandidates.length) {
return '';
}
topCandidates.sort((left, right) => {
const leftRect = left.node.getBoundingClientRect();
const rightRect = right.node.getBoundingClientRect();
if (leftRect.top !== rightRect.top) {
return leftRect.top - rightRect.top;
}
return leftRect.left - rightRect.left;
});
return topCandidates[0]?.email || '';
}
function isCheckboxChecked(node) {
const checkbox = node?.matches?.('input[type="checkbox"], [role="checkbox"]')
? node
@@ -873,6 +935,7 @@ async function ensureMail2925Session(payload = {}) {
const email = String(payload?.email || '').trim();
const password = String(payload?.password || '');
const forceLogin = Boolean(payload?.forceLogin);
const allowLoginWhenOnLoginPage = payload?.allowLoginWhenOnLoginPage !== false;
log(`步骤 0:2925 登录态检查开始,当前地址 ${location.href}forceLogin=${forceLogin ? 'true' : 'false'}`, 'info');
for (let attempt = 0; attempt < 10; attempt += 1) {
@@ -893,9 +956,19 @@ async function ensureMail2925Session(payload = {}) {
ok: true,
loggedIn: true,
currentView: 'mailbox',
mailboxEmail: currentState.mailboxEmail || '',
};
}
if (currentState.view === 'login') {
if (!forceLogin && !allowLoginWhenOnLoginPage) {
return {
ok: false,
loggedIn: false,
currentView: 'login',
requiresLogin: true,
mailboxEmail: '',
};
}
break;
}
await sleep(500);
@@ -908,6 +981,7 @@ async function ensureMail2925Session(payload = {}) {
ok: true,
loggedIn: true,
currentView: 'mailbox',
mailboxEmail: loginState.mailboxEmail || '',
};
}
if (loginState.view === 'limit') {
@@ -919,6 +993,15 @@ async function ensureMail2925Session(payload = {}) {
limitMessage: loginState.limitMessage,
};
}
if (!forceLogin && !allowLoginWhenOnLoginPage && loginState.view === 'login') {
return {
ok: false,
loggedIn: false,
currentView: 'login',
requiresLogin: true,
mailboxEmail: '',
};
}
const emailInput = findMail2925LoginEmailInput();
const passwordInput = findMail2925LoginPasswordInput();
@@ -951,6 +1034,7 @@ async function ensureMail2925Session(payload = {}) {
loggedIn: true,
currentView: 'mailbox',
usedCredentials: true,
mailboxEmail: finalState.mailboxEmail || getMail2925DisplayedMailboxEmail() || '',
};
}
+441 -48
View File
@@ -397,13 +397,82 @@ function getSignupEntryStateSummary(snapshot = inspectSignupEntryState()) {
}
function getSignupEntryDiagnostics() {
const view = typeof window !== 'undefined' ? window : globalThis;
const safeGetComputedStyle = (el) => {
if (!el || typeof view?.getComputedStyle !== 'function') {
return null;
}
try {
return view.getComputedStyle(el);
} catch {
return null;
}
};
const buildRectSummary = (el) => {
const rect = typeof el?.getBoundingClientRect === 'function'
? el.getBoundingClientRect()
: null;
return rect
? {
width: Math.round(rect.width || 0),
height: Math.round(rect.height || 0),
}
: null;
};
const buildVisibilityMeta = (el) => {
const style = safeGetComputedStyle(el);
return {
className: String(el?.className || '').slice(0, 200),
hidden: Boolean(el?.hidden),
ariaHidden: el?.getAttribute?.('aria-hidden') || '',
inert: typeof el?.hasAttribute === 'function' ? el.hasAttribute('inert') : false,
display: style?.display || '',
visibility: style?.visibility || '',
opacity: style?.opacity || '',
pointerEvents: style?.pointerEvents || '',
};
};
const findBlockingAncestor = (el) => {
let current = el?.parentElement || null;
while (current) {
const style = safeGetComputedStyle(current);
const rect = buildRectSummary(current);
const hidden = Boolean(current.hidden);
const ariaHidden = current.getAttribute?.('aria-hidden') || '';
const inert = typeof current.hasAttribute === 'function' ? current.hasAttribute('inert') : false;
const blockedByStyle = Boolean(
style
&& (
style.display === 'none'
|| style.visibility === 'hidden'
|| style.opacity === '0'
|| style.pointerEvents === 'none'
)
);
const blockedByRect = Boolean(rect && (rect.width === 0 || rect.height === 0));
if (hidden || ariaHidden === 'true' || inert || blockedByStyle || blockedByRect) {
return {
tag: (current.tagName || '').toLowerCase(),
id: current.id || '',
className: String(current.className || '').slice(0, 200),
hidden,
ariaHidden,
inert,
display: style?.display || '',
visibility: style?.visibility || '',
opacity: style?.opacity || '',
pointerEvents: style?.pointerEvents || '',
rect,
};
}
current = current.parentElement;
}
return null;
};
const actionCandidates = document.querySelectorAll(
'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]'
);
const allActions = Array.from(actionCandidates).map((el) => {
const rect = typeof el?.getBoundingClientRect === 'function'
? el.getBoundingClientRect()
: null;
const text = getActionText(el);
return {
tag: (el.tagName || '').toLowerCase(),
@@ -411,12 +480,7 @@ function getSignupEntryDiagnostics() {
text: text.slice(0, 80),
visible: isVisibleElement(el),
enabled: isActionEnabled(el),
rect: rect
? {
width: Math.round(rect.width || 0),
height: Math.round(rect.height || 0),
}
: null,
rect: buildRectSummary(el),
};
});
const visibleActions = Array.from(actionCandidates)
@@ -429,7 +493,20 @@ function getSignupEntryDiagnostics() {
enabled: isActionEnabled(el),
}))
.filter((item) => item.text);
const signupLikeActions = allActions
const signupLikeActions = Array.from(actionCandidates)
.map((el) => {
const text = getActionText(el);
return {
tag: (el.tagName || '').toLowerCase(),
type: el.getAttribute?.('type') || '',
text: text.slice(0, 80),
visible: isVisibleElement(el),
enabled: isActionEnabled(el),
rect: buildRectSummary(el),
...buildVisibilityMeta(el),
blockingAncestor: findBlockingAncestor(el),
};
})
.filter((item) => item.text && SIGNUP_ENTRY_TRIGGER_PATTERN.test(item.text))
.slice(0, 12);
@@ -437,15 +514,133 @@ function getSignupEntryDiagnostics() {
url: location.href,
title: document.title || '',
readyState: document.readyState || '',
viewport: {
innerWidth: Math.round(Number(view?.innerWidth) || 0),
innerHeight: Math.round(Number(view?.innerHeight) || 0),
outerWidth: Math.round(Number(view?.outerWidth) || 0),
outerHeight: Math.round(Number(view?.outerHeight) || 0),
devicePixelRatio: Number(view?.devicePixelRatio) || 0,
},
hasEmailInput: Boolean(getSignupEmailInput()),
hasPasswordInput: Boolean(getSignupPasswordInput()),
bodyContainsSignupText: SIGNUP_ENTRY_TRIGGER_PATTERN.test(getPageTextSnapshot()),
signupLikeActionCounts: {
total: signupLikeActions.length,
visible: signupLikeActions.filter((item) => item.visible).length,
hidden: signupLikeActions.filter((item) => !item.visible).length,
},
signupLikeActions,
visibleActions,
bodyTextPreview: getPageTextSnapshot().slice(0, 240),
};
}
function getSignupPasswordDiagnostics() {
const view = typeof window !== 'undefined' ? window : globalThis;
const safeGetComputedStyle = (el) => {
if (!el || typeof view?.getComputedStyle !== 'function') {
return null;
}
try {
return view.getComputedStyle(el);
} catch {
return null;
}
};
const buildRectSummary = (el) => {
const rect = typeof el?.getBoundingClientRect === 'function'
? el.getBoundingClientRect()
: null;
return rect
? {
width: Math.round(rect.width || 0),
height: Math.round(rect.height || 0),
}
: null;
};
const buildInputSummary = (el) => {
const style = safeGetComputedStyle(el);
return {
tag: (el?.tagName || '').toLowerCase(),
type: el?.getAttribute?.('type') || el?.type || '',
name: el?.getAttribute?.('name') || el?.name || '',
id: el?.id || '',
autocomplete: el?.getAttribute?.('autocomplete') || '',
placeholder: String(el?.getAttribute?.('placeholder') || '').slice(0, 80),
visible: isVisibleElement(el),
enabled: isActionEnabled(el),
valueLength: String(el?.value || '').length,
rect: buildRectSummary(el),
className: String(el?.className || '').slice(0, 200),
display: style?.display || '',
visibility: style?.visibility || '',
opacity: style?.opacity || '',
pointerEvents: style?.pointerEvents || '',
formAction: el?.form?.action || '',
};
};
const buildActionSummary = (el) => {
const style = safeGetComputedStyle(el);
return {
tag: (el?.tagName || '').toLowerCase(),
type: el?.getAttribute?.('type') || el?.type || '',
role: el?.getAttribute?.('role') || '',
text: getActionText(el).slice(0, 120),
visible: isVisibleElement(el),
enabled: isActionEnabled(el),
rect: buildRectSummary(el),
className: String(el?.className || '').slice(0, 200),
display: style?.display || '',
visibility: style?.visibility || '',
opacity: style?.opacity || '',
pointerEvents: style?.pointerEvents || '',
dataDdActionName: el?.getAttribute?.('data-dd-action-name') || '',
formAction: el?.form?.action || '',
};
};
const passwordInputs = Array.from(document.querySelectorAll(
'input[type="password"], input[name*="password" i], input[autocomplete="new-password"], input[autocomplete="current-password"]'
))
.map(buildInputSummary)
.slice(0, 8);
const actionCandidates = Array.from(document.querySelectorAll(
'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]'
))
.map(buildActionSummary)
.filter((item) => item.text)
.slice(0, 16);
const visibleActions = actionCandidates.filter((item) => item.visible).slice(0, 12);
const submitButton = getSignupPasswordSubmitButton({ allowDisabled: true });
const oneTimeCodeTrigger = findOneTimeCodeLoginTrigger();
const retryState = getSignupPasswordTimeoutErrorPageState();
return {
url: location.href,
title: document.title || '',
readyState: document.readyState || '',
displayedEmail: getSignupPasswordDisplayedEmail(),
hasVisiblePasswordInput: Boolean(getSignupPasswordInput()),
passwordInputCount: passwordInputs.length,
visiblePasswordInputCount: passwordInputs.filter((item) => item.visible).length,
passwordInputs,
submitButton: submitButton ? buildActionSummary(submitButton) : null,
oneTimeCodeTrigger: oneTimeCodeTrigger ? buildActionSummary(oneTimeCodeTrigger) : null,
retryPage: Boolean(retryState),
retryEnabled: Boolean(retryState?.retryEnabled),
userAlreadyExistsBlocked: Boolean(retryState?.userAlreadyExistsBlocked),
visibleActions,
bodyTextPreview: getPageTextSnapshot().slice(0, 240),
};
}
function logSignupPasswordDiagnostics(context, level = 'warn') {
try {
log(`${context}:密码页诊断快照:${JSON.stringify(getSignupPasswordDiagnostics())}`, level);
} catch (error) {
console.warn('[MultiPage:signup-page] failed to build signup password diagnostics:', error?.message || error);
}
}
async function waitForSignupEntryState(options = {}) {
const {
timeout = 15000,
@@ -632,6 +827,10 @@ async function step3_fillEmailPassword(payload) {
snapshot = inspectSignupEntryState();
}
if (snapshot.state !== 'password_page' || !snapshot.passwordInput) {
logSignupPasswordDiagnostics('步骤 3:未能识别可填写的密码输入框');
}
if (snapshot.state !== 'password_page' || !snapshot.passwordInput) {
throw new Error('在密码页未找到密码输入框。URL: ' + location.href);
}
@@ -647,6 +846,12 @@ async function step3_fillEmailPassword(payload) {
|| getSignupPasswordSubmitButton({ allowDisabled: true })
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
if (!submitBtn) {
logSignupPasswordDiagnostics('步骤 3:未找到可提交的密码页按钮');
} else if (typeof findOneTimeCodeLoginTrigger === 'function' && findOneTimeCodeLoginTrigger()) {
logSignupPasswordDiagnostics('步骤 3:当前密码页同时存在一次性验证码入口', 'info');
}
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
const signupVerificationRequestedAt = submitBtn ? Date.now() : null;
@@ -1501,8 +1706,13 @@ function createStep6RecoverableResult(reason, snapshot, options = {}) {
};
}
async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, message) {
const resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, message, options = {}) {
const {
loginVerificationRequestedAt = null,
via = 'login_timeout_recovered',
} = options;
let resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
let recovered = false;
if (resolvedSnapshot?.state === 'login_timeout_error_page') {
try {
const recoveryResult = await recoverCurrentAuthRetryPage({
@@ -1511,8 +1721,9 @@ async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, messag
step: 7,
timeoutMs: 12000,
});
if (recoveryResult?.recovered) {
log('步骤 7:登录超时报错页已点击“重试”,准备重新执行当前步骤。', 'warn');
recovered = Boolean(recoveryResult?.recovered);
if (recovered) {
log('步骤 7:登录超时报错页已点击“重试”,正在按恢复后的页面状态继续当前流程。', 'warn');
}
} catch (error) {
if (/CF_SECURITY_BLOCKED::/i.test(String(error?.message || error || ''))) {
@@ -1522,7 +1733,46 @@ async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, messag
}
}
return createStep6RecoverableResult(reason, resolvedSnapshot, {
resolvedSnapshot = recovered
? normalizeStep6Snapshot(await waitForKnownLoginAuthState(4000))
: normalizeStep6Snapshot(inspectLoginAuthState());
if (resolvedSnapshot.state === 'verification_page') {
return {
action: 'done',
result: createStep6SuccessResult(resolvedSnapshot, {
via,
loginVerificationRequestedAt,
}),
};
}
if (resolvedSnapshot.state === 'password_page') {
log('步骤 7:登录超时报错页恢复后已进入密码页,继续当前登录流程。', 'warn');
return { action: 'password', snapshot: resolvedSnapshot };
}
if (resolvedSnapshot.state === 'email_page') {
log('步骤 7:登录超时报错页恢复后已回到邮箱输入页,继续当前登录流程。', 'warn');
return { action: 'email', snapshot: resolvedSnapshot };
}
return {
action: 'recoverable',
result: createStep6RecoverableResult(reason, resolvedSnapshot, {
message,
loginVerificationRequestedAt,
}),
};
}
async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, message) {
const transition = await createStep6LoginTimeoutRecoveryTransition(reason, snapshot, message);
if (transition?.action === 'done' || transition?.action === 'recoverable') {
return transition.result;
}
return createStep6RecoverableResult(reason, transition?.snapshot || normalizeStep6Snapshot(inspectLoginAuthState()), {
message,
});
}
@@ -1725,6 +1975,7 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
const start = Date.now();
let recoveryRound = 0;
const maxRecoveryRounds = 3;
let passwordPageDiagnosticsLogged = false;
while (Date.now() - start < timeout && recoveryRound < maxRecoveryRounds) {
throwIfStopped();
@@ -1763,6 +2014,10 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
}
if (snapshot.state === 'password') {
if (!passwordPageDiagnosticsLogged) {
passwordPageDiagnosticsLogged = true;
logSignupPasswordDiagnostics(`${prepareLogLabel}:页面仍停留在密码页`);
}
if (!password) {
throw new Error('当前回到了密码页,但没有可用密码,无法自动重新提交。');
}
@@ -1952,6 +2207,20 @@ async function fillVerificationCode(step, payload) {
const { code } = payload;
if (!code) throw new Error('未提供验证码。');
if (step === 4 && isStep5Ready()) {
log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok');
return { success: true, assumed: true, alreadyAdvanced: true };
}
if (step === 8) {
if (isStep8Ready()) {
log(`步骤 ${step}:检测到页面已进入 OAuth 同意页,本次验证码提交按成功处理。`, 'ok');
return { success: true, assumed: true, alreadyAdvanced: true };
}
if (isAddPhonePageReady()) {
return { success: true, addPhonePage: true, url: location.href };
}
}
log(`步骤 ${step}:正在填写验证码:${code}`);
if (step === 8) {
@@ -2097,13 +2366,30 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
}
if (snapshot.state === 'login_timeout_error_page') {
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'提交邮箱后进入登录超时报错页。',
{
loginVerificationRequestedAt: emailSubmittedAt,
via: 'email_submit_timeout_recovered',
}
);
if (transition.action === 'done') {
return {
action: 'done',
result: transition.result,
};
}
if (transition.action === 'password') {
return { action: 'password', snapshot: transition.snapshot };
}
if (transition.action === 'email') {
return { action: 'email', snapshot: transition.snapshot };
}
return {
action: 'recoverable',
result: await createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'提交邮箱后进入登录超时报错页。'
),
result: transition.result,
};
}
@@ -2132,13 +2418,30 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
return { action: 'password', snapshot };
}
if (snapshot.state === 'login_timeout_error_page') {
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'提交邮箱后进入登录超时报错页。',
{
loginVerificationRequestedAt: emailSubmittedAt,
via: 'email_submit_timeout_recovered',
}
);
if (transition.action === 'done') {
return {
action: 'done',
result: transition.result,
};
}
if (transition.action === 'password') {
return { action: 'password', snapshot: transition.snapshot };
}
if (transition.action === 'email') {
return { action: 'email', snapshot: transition.snapshot };
}
return {
action: 'recoverable',
result: await createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'提交邮箱后进入登录超时报错页。'
),
result: transition.result,
};
}
if (snapshot.state === 'oauth_consent_page') {
@@ -2175,13 +2478,30 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
}
if (snapshot.state === 'login_timeout_error_page') {
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'提交密码后进入登录超时报错页。',
{
loginVerificationRequestedAt: passwordSubmittedAt,
via: 'password_submit_timeout_recovered',
}
);
if (transition.action === 'done') {
return {
action: 'done',
result: transition.result,
};
}
if (transition.action === 'password') {
return { action: 'password', snapshot: transition.snapshot };
}
if (transition.action === 'email') {
return { action: 'email', snapshot: transition.snapshot };
}
return {
action: 'recoverable',
result: await createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'提交密码后进入登录超时报错页。'
),
result: transition.result,
};
}
@@ -2207,13 +2527,30 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
};
}
if (snapshot.state === 'login_timeout_error_page') {
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'提交密码后进入登录超时报错页。',
{
loginVerificationRequestedAt: passwordSubmittedAt,
via: 'password_submit_timeout_recovered',
}
);
if (transition.action === 'done') {
return {
action: 'done',
result: transition.result,
};
}
if (transition.action === 'password') {
return { action: 'password', snapshot: transition.snapshot };
}
if (transition.action === 'email') {
return { action: 'email', snapshot: transition.snapshot };
}
return {
action: 'recoverable',
result: await createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'提交密码后进入登录超时报错页。'
),
result: transition.result,
};
}
if (snapshot.state === 'oauth_consent_page') {
@@ -2250,11 +2587,22 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
}
if (snapshot.state === 'login_timeout_error_page') {
return await createStep6LoginTimeoutRecoverableResult(
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'切换到一次性验证码登录后进入登录超时报错页。'
'切换到一次性验证码登录后进入登录超时报错页。',
{
loginVerificationRequestedAt,
via: 'switch_to_one_time_code_timeout_recovered',
}
);
if (transition.action === 'done') {
return transition.result;
}
if (transition.action === 'password' || transition.action === 'email') {
return transition;
}
return transition.result;
}
if (snapshot.state === 'oauth_consent_page') {
@@ -2276,11 +2624,22 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
});
}
if (snapshot.state === 'login_timeout_error_page') {
return await createStep6LoginTimeoutRecoverableResult(
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'切换到一次性验证码登录后进入登录超时报错页。'
'切换到一次性验证码登录后进入登录超时报错页。',
{
loginVerificationRequestedAt,
via: 'switch_to_one_time_code_timeout_recovered',
}
);
if (transition.action === 'done') {
return transition.result;
}
if (transition.action === 'password' || transition.action === 'email') {
return transition;
}
return transition.result;
}
if (snapshot.state === 'oauth_consent_page') {
throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
@@ -2294,7 +2653,7 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
});
}
async function step6SwitchToOneTimeCodeLogin(snapshot) {
async function step6SwitchToOneTimeCodeLogin(payload, snapshot) {
const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger();
if (!switchTrigger || !isActionEnabled(switchTrigger)) {
return createStep6RecoverableResult('missing_one_time_code_trigger', normalizeStep6Snapshot(inspectLoginAuthState()), {
@@ -2316,6 +2675,12 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) {
via: result.via || 'switch_to_one_time_code_login',
});
}
if (result?.action === 'password') {
return step6LoginFromPasswordPage(payload, result.snapshot);
}
if (result?.action === 'email') {
return step6LoginFromEmailPage(payload, result.snapshot);
}
return result;
}
@@ -2327,7 +2692,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
if (!hasPassword) {
if (currentSnapshot.switchTrigger) {
log('步骤 7:当前未提供密码,改走一次性验证码登录。', 'warn');
return step6SwitchToOneTimeCodeLogin(currentSnapshot);
return step6SwitchToOneTimeCodeLogin(payload, currentSnapshot);
}
return createStep6RecoverableResult('missing_password_and_one_time_code_trigger', currentSnapshot, {
@@ -2357,8 +2722,14 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
log(`步骤 7${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 7。'}`, 'warn');
return transition.result;
}
if (transition.action === 'password') {
return step6LoginFromPasswordPage(payload, transition.snapshot);
}
if (transition.action === 'email') {
return step6LoginFromEmailPage(payload, transition.snapshot);
}
if (transition.action === 'switch') {
return step6SwitchToOneTimeCodeLogin(transition.snapshot);
return step6SwitchToOneTimeCodeLogin(payload, transition.snapshot);
}
return createStep6RecoverableResult('password_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), {
@@ -2367,7 +2738,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
}
if (currentSnapshot.switchTrigger) {
return step6SwitchToOneTimeCodeLogin(currentSnapshot);
return step6SwitchToOneTimeCodeLogin(payload, currentSnapshot);
}
return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, {
@@ -2407,6 +2778,9 @@ async function step6LoginFromEmailPage(payload, snapshot) {
log(`步骤 7${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 7。'}`, 'warn');
return transition.result;
}
if (transition.action === 'email') {
return step6LoginFromEmailPage(payload, transition.snapshot);
}
if (transition.action === 'password') {
return step6LoginFromPasswordPage(payload, transition.snapshot);
}
@@ -2420,11 +2794,10 @@ async function step6_login(payload) {
const { email } = payload;
if (!email) throw new Error('登录时缺少邮箱地址。');
log(`步骤 7:正在使用 ${email} 登录...`);
const snapshot = normalizeStep6Snapshot(await waitForKnownLoginAuthState(15000));
if (snapshot.state === 'verification_page') {
log('步骤 7:认证页已在登录验证码页,开始确认页面是否稳定。');
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: null,
@@ -2433,19 +2806,39 @@ async function step6_login(payload) {
}
if (snapshot.state === 'login_timeout_error_page') {
log('步骤 7:检测到登录超时报错,准备重新执行步骤 7。', 'warn');
return await createStep6LoginTimeoutRecoverableResult(
log('步骤 7:检测到登录超时报错页,先尝试恢复当前页面。', 'warn');
const transition = await createStep6LoginTimeoutRecoveryTransition(
'login_timeout_error_page',
snapshot,
'当前页面处于登录超时报错页。'
'当前页面处于登录超时报错页。',
{
loginVerificationRequestedAt: null,
via: 'login_timeout_initial_recovered',
}
);
if (transition.action === 'done') {
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || null,
via: transition.result.via || 'login_timeout_initial_recovered',
});
}
if (transition.action === 'email') {
return step6LoginFromEmailPage(payload, transition.snapshot);
}
if (transition.action === 'password') {
return step6LoginFromPasswordPage(payload, transition.snapshot);
}
return transition.result;
}
if (snapshot.state === 'email_page') {
log(`步骤 7:正在使用 ${email} 登录...`);
return step6LoginFromEmailPage(payload, snapshot);
}
if (snapshot.state === 'password_page') {
log('步骤 7:认证页已在密码页,继续当前登录流程。');
return step6LoginFromPasswordPage(payload, snapshot);
}
+269 -41
View File
@@ -19,7 +19,10 @@
// <div class="OAuthPage-module__callbackSection___8kA31">
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
// <div class="status-badge success">回调 URL 已提交,等待认证中...</div>
// <div class="status-badge error">回调 URL 提交失败: ...</div>
// </div>
// <div class="status-badge">等待认证中... / 认证成功! / 认证失败: ...</div>
// </div>
// </div>
@@ -192,19 +195,19 @@ function isLocalhostOAuthCallbackUrl(rawUrl) {
function getStatusBadgeSelectors() {
return [
'#root > div > div > div > main > div > div > div > div > div:nth-child(1) > div > div.OAuthPage-module__cardContent___1sXLA > div.status-badge',
'#root .OAuthPage-module__cardContent___1sXLA > .status-badge',
'.OAuthPage-module__cardContent___1sXLA > .status-badge',
'#root .OAuthPage-module__cardContent___1sXLA .status-badge',
'[class*="cardContent"] .status-badge',
'.status-badge',
];
}
function getStatusBadgeEntries() {
const searchRoot = findCodexOAuthCard() || document;
const seen = new Set();
const entries = [];
for (const selector of getStatusBadgeSelectors()) {
const candidates = document.querySelectorAll(selector);
const candidates = searchRoot.querySelectorAll(selector);
for (const candidate of candidates) {
if (seen.has(candidate)) continue;
seen.add(candidate);
@@ -238,21 +241,63 @@ function normalizeStep9StatusText(statusText) {
}
function isOAuthCallbackTimeoutFailure(statusText) {
return /认证失败:\s*(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded)/i.test(statusText || '');
return /(?:认证失败\s*[:]?\s*)?(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded|OAuth flow timed out)/i.test(statusText || '');
}
function getStep10StatusBadgeLocation(element) {
if (element?.closest?.('[class*="callbackSection"]')) {
return 'callback';
}
if (element?.closest?.('[class*="cardContent"]')) {
return 'main';
}
return 'page';
}
function isStep10CallbackSubmittedStatus(statusText) {
const text = normalizeStep9StatusText(statusText);
return /回调\s*url\s*已提交.*等待认证中/i.test(text)
|| /callback\s*url\s*submitted.*waiting/i.test(text);
}
function isStep10CallbackFailureText(statusText) {
const text = normalizeStep9StatusText(statusText);
if (!text) return false;
return /(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[:,]?\s*/i.test(text)
|| /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text);
}
function isStep10MainWaitingStatus(statusText) {
const text = normalizeStep9StatusText(statusText);
return /等待认证中/i.test(text);
}
function isStep10MainFailureText(statusText) {
const text = normalizeStep9StatusText(statusText);
if (!text) return false;
if (/^认证失败\s*[:]?\s*/i.test(text)) return true;
return /bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out|request failed with status code \d+|timeout of \d+ms exceeded|network error|failed to fetch/i.test(text);
}
function isStep9FailureText(statusText) {
const text = normalizeStep9StatusText(statusText);
if (!text) return false;
if (isOAuthCallbackTimeoutFailure(text)) return true;
if (isStep10CallbackFailureText(text)) return true;
if (isStep10MainFailureText(text)) return true;
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(text)) {
return true;
}
return /回调\s*url\s*提交失败|callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
return /callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
}
function isStep9SuccessStatus(statusText) {
return STEP9_SUCCESS_STATUSES.has(normalizeStep9StatusText(statusText));
const text = normalizeStep9StatusText(statusText);
if (!text) return false;
return STEP9_SUCCESS_STATUSES.has(text)
|| /^认证成功[!]?$/i.test(text)
|| /^Authentication successful!?$/i.test(text)
|| /^Аутентификация успешна!?$/i.test(text);
}
function isStep9SuccessLikeStatus(statusText) {
@@ -340,6 +385,7 @@ function createStep9Entry(candidate, selector) {
return {
element: candidate,
selector,
location: getStep10StatusBadgeLocation(candidate),
visible: isVisibleElement(candidate),
text: normalizeStep9StatusText(candidate?.textContent || ''),
className,
@@ -364,36 +410,72 @@ function getStep9PageErrorSelectors() {
}
function getStep9PageErrorEntries() {
const cardRoot = findCodexOAuthCard();
const searchRoots = [cardRoot, document].filter(Boolean);
const seen = new Set();
const entries = [];
for (const selector of getStep9PageErrorSelectors()) {
const candidates = document.querySelectorAll(selector);
for (const candidate of candidates) {
if (seen.has(candidate)) continue;
seen.add(candidate);
if (!isVisibleElement(candidate)) continue;
for (const root of searchRoots) {
for (const selector of getStep9PageErrorSelectors()) {
const candidates = root.querySelectorAll(selector);
for (const candidate of candidates) {
if (seen.has(candidate)) continue;
seen.add(candidate);
if (!isVisibleElement(candidate)) continue;
const entry = createStep9Entry(candidate, selector);
if (!isStep9FailureText(entry.text)) continue;
entries.push(entry);
const entry = createStep9Entry(candidate, selector);
if (/\bstatus-badge\b/i.test(entry.className)) continue;
if (!isStep9FailureText(entry.text)) continue;
entries.push(entry);
}
}
}
return entries;
}
function formatStep10StatusSummaryValue(text, emptyText = '无') {
return text ? `"${getInlineTextSnippet(text, 80)}"` : emptyText;
}
function isStep10BrowserSwitchRequiredConflict(diagnostics = {}) {
return Boolean(diagnostics?.hasExactSuccessVisibleBadge)
&& /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(String(diagnostics?.callbackFailureText || ''));
}
function getStep10BrowserSwitchRequiredMessage(diagnostics = {}) {
const callbackFailureText = normalizeStep9StatusText(diagnostics?.callbackFailureText || '');
return [
'检测到 CPA 页面同时显示“认证成功”和“回调 URL 提交失败: 请更新CLI Proxy API或检查连接”。',
'这类冲突状态通常通过更换浏览器可以解决,请更换浏览器后重新进行注册登录。',
callbackFailureText ? `面板原文:${callbackFailureText}` : '',
].filter(Boolean).join(' ');
}
function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSnippet = '') {
const visibleEntries = entries.filter((entry) => entry.visible);
const successLikeEntries = visibleEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
const exactSuccessEntries = visibleEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
const failureEntries = visibleEntries.filter((entry) => isStep9FailureText(entry.text));
const callbackEntries = visibleEntries.filter((entry) => entry.location === 'callback');
const mainEntries = visibleEntries.filter((entry) => entry.location === 'main');
const successLikeEntries = mainEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
const exactSuccessEntries = mainEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
const callbackSubmittedEntries = callbackEntries.filter((entry) => isStep10CallbackSubmittedStatus(entry.text) && !entry.hasErrorVisualSignal);
const callbackFailureEntries = callbackEntries.filter((entry) => isStep10CallbackFailureText(entry.text));
const mainWaitingEntries = mainEntries.filter((entry) => isStep10MainWaitingStatus(entry.text) && !entry.hasErrorVisualSignal);
const mainFailureEntries = mainEntries.filter((entry) => isStep10MainFailureText(entry.text));
const failureEntries = [...callbackFailureEntries, ...mainFailureEntries];
const errorStyledEntries = visibleEntries.filter((entry) => entry.hasErrorVisualSignal);
const allFailureEntries = [...failureEntries, ...pageErrorEntries];
const decisiveFailureEntry = allFailureEntries[0] || null;
const selectedEntry = decisiveFailureEntry || exactSuccessEntries[0] || visibleEntries[0] || null;
const selectedEntry = decisiveFailureEntry
|| exactSuccessEntries[0]
|| callbackSubmittedEntries[0]
|| mainWaitingEntries[0]
|| visibleEntries[0]
|| null;
const selectedText = selectedEntry?.text || '';
const visibleSummary = summarizeStatusBadgeEntries(visibleEntries);
const callbackSummary = summarizeStatusBadgeEntries(callbackEntries);
const mainSummary = summarizeStatusBadgeEntries(mainEntries);
const successLikeSummary = summarizeStatusBadgeEntries(successLikeEntries);
const exactSuccessSummary = summarizeStatusBadgeEntries(exactSuccessEntries);
const failureSummary = summarizeStatusBadgeEntries(failureEntries);
@@ -406,10 +488,20 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
selectedText,
exactSuccessText: exactSuccessEntries[0]?.text || '',
failureText: decisiveFailureEntry?.text || '',
failureSource: decisiveFailureEntry?.location || (pageErrorEntries.length ? 'page' : ''),
visibleCount: visibleEntries.length,
visibleSummary,
callbackSummary,
mainSummary,
callbackStatusText: callbackEntries[0]?.text || '',
callbackSubmittedText: callbackSubmittedEntries[0]?.text || '',
callbackFailureText: callbackFailureEntries[0]?.text || '',
mainStatusText: mainEntries[0]?.text || '',
mainWaitingText: mainWaitingEntries[0]?.text || '',
mainFailureText: mainFailureEntries[0]?.text || '',
hasSuccessLikeVisibleBadge: successLikeEntries.length > 0,
hasExactSuccessVisibleBadge: exactSuccessEntries.length > 0,
hasCallbackSubmittedBadge: callbackSubmittedEntries.length > 0,
hasFailureVisibleBadge: allFailureEntries.length > 0,
hasErrorStyledVisibleBadge: errorStyledEntries.length > 0,
successLikeSummary,
@@ -422,6 +514,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
selectedText,
visibleCount: visibleEntries.length,
visibleSummary,
callbackSummary,
mainSummary,
successLikeSummary,
exactSuccessSummary,
failureSummary,
@@ -429,8 +523,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
errorStyledSummary,
}),
summary: selectedText
? `当前聚焦状态="${getInlineTextSnippet(selectedText, 80)}";可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
: `当前未选中任何可见状态徽标;可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
? `当前聚焦状态=${formatStep10StatusSummaryValue(selectedText)};回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
: `当前未选中任何可见状态;回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
};
}
@@ -452,11 +546,129 @@ function getStatusBadgeText() {
return diagnostics.selectedText;
}
function extractStep10FailureDetail(statusText, sourceKind = '') {
const text = normalizeStep9StatusText(statusText);
if (!text) return '';
if (sourceKind === 'callback' || isStep10CallbackFailureText(text)) {
return text.replace(/^(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[:,]?\s*/i, '').trim();
}
if (sourceKind === 'main' || isStep10MainFailureText(text)) {
return text.replace(/^认证失败\s*[:]?\s*/i, '').trim();
}
return text;
}
function explainStep10Failure(statusText, sourceKind = 'unknown') {
const rawText = normalizeStep9StatusText(statusText);
const detail = extractStep10FailureDetail(rawText, sourceKind) || rawText;
const phaseLabel = sourceKind === 'callback'
? '回调提交阶段'
: sourceKind === 'main'
? '认证结果阶段'
: '页面状态阶段';
const rules = [
{
code: 'callback_submit_api_unavailable',
pattern: /请更新\s*cli\s*proxy\s*api\s*或检查连接/i,
message: 'CPA 面板无法把回调提交给后台,通常是 CLI Proxy API 版本过旧、管理接口未启动,或当前面板与后端连接异常。',
},
{
code: 'oauth_state_expired',
pattern: /unknown or expired state/i,
message: '当前 OAuth 会话在 CPA 中已不存在或已过期,通常是使用了旧回调链接、刷新过新的授权链接后仍提交旧链接,或 CPA 刚重启过。',
},
{
code: 'oauth_not_pending',
pattern: /oauth flow is not pending/i,
message: '当前 OAuth 会话已经不在等待状态,通常是重复提交、提交过慢,或这轮认证此前已经结束。',
},
{
code: 'callback_state_invalid',
pattern: /invalid state|state is required|missing_state/i,
message: '回调链接里的 state 缺失或无效,通常是复制了不完整的 localhost 回调链接,或提交了不属于这一轮的旧链接。',
},
{
code: 'callback_missing_result',
pattern: /code or error is required/i,
message: '回调链接里既没有授权码,也没有错误信息,通常是复制的 localhost 回调链接不完整。',
},
{
code: 'callback_invalid_url',
pattern: /invalid redirect_url/i,
message: '提交给 CPA 的回调链接格式无法解析,通常是粘贴内容不完整、带了多余字符,或并不是 localhost OAuth 回调地址。',
},
{
code: 'callback_provider_mismatch',
pattern: /provider does not match state/i,
message: '这条回调不属于当前这次 Codex OAuth,会话与回调来源对不上,通常是混用了其他轮次或其他提供方的回调。',
},
{
code: 'callback_persist_failed',
pattern: /failed to persist oauth callback/i,
message: 'CPA 已收到回调,但无法把回调结果写入本地缓存文件,通常是认证目录权限、磁盘或运行环境异常。',
},
{
code: 'oauth_bad_request',
pattern: /^bad request$/i,
message: 'CPA 已收到回调,但 OpenAI OAuth 回调本身返回了错误。常见于用户取消授权、请求过期,或这条回调已经失效。',
},
{
code: 'oauth_state_mismatch',
pattern: /state code error/i,
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是步骤 1 已刷新过新的授权链接,但步骤 10 仍提交旧回调。',
},
{
code: 'oauth_code_exchange_failed',
pattern: /failed to exchange authorization code for tokens/i,
message: 'CPA 已收到授权码,但向 OpenAI 交换令牌失败。常见于 CPA 到 OpenAI 的网络或代理异常,或授权码已过期。',
},
{
code: 'oauth_token_save_failed',
pattern: /failed to save authentication tokens/i,
message: 'CPA 已完成认证,但保存认证文件失败。常见于认证目录权限、磁盘写入,或 post-auth hook 异常。',
},
{
code: 'oauth_callback_timeout',
pattern: /timeout waiting for oauth callback|oauth flow timed out/i,
message: 'CPA 长时间没有把这轮 OAuth 流程走完。常见于提交太晚、面板轮询异常,或后端状态没有及时刷新。',
},
{
code: 'oauth_http_timeout',
pattern: /timeout of \d+ms exceeded/i,
message: 'CPA 面板在请求后台接口时超时,通常是 CLI Proxy API 响应过慢、接口未启动,或网络连接不稳定。',
},
{
code: 'oauth_http_status_error',
pattern: /request failed with status code \d+/i,
message: 'CPA 面板请求后台接口时收到了异常 HTTP 状态码,通常是接口异常、反向代理配置错误,或当前会话已失效。',
},
{
code: 'oauth_network_error',
pattern: /network error|failed to fetch/i,
message: 'CPA 面板与后台通信失败,通常是网络不通、管理接口未启动,或浏览器当前连接已断开。',
},
];
const matchedRule = rules.find((rule) => rule.pattern.test(detail) || rule.pattern.test(rawText));
const message = matchedRule
? matchedRule.message
: `CPA 在${phaseLabel}返回了未归类的失败,请结合面板原文进一步排查。`;
return {
code: matchedRule?.code || 'oauth_unknown_failure',
phaseLabel,
rawText,
detail,
userMessage: `CPA 在${phaseLabel}返回失败:${message} 面板原文:${rawText}`,
};
}
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
const start = Date.now();
let lastDiagnosticsSignature = '';
let lastHeartbeatLoggedAt = 0;
let lastSuccessLikeMismatchSignature = '';
let lastCallbackSubmittedSignature = '';
let lastSuccessFailureConflictSignature = '';
while (Date.now() - start < timeout) {
@@ -475,26 +687,28 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
console.log(LOG_PREFIX, '[Step 9] still waiting for success badge', diagnostics);
}
if (diagnostics.hasSuccessLikeVisibleBadge && !diagnostics.hasExactSuccessVisibleBadge) {
const mismatchSignature = JSON.stringify({
selectedText: diagnostics.selectedText,
successLikeSummary: diagnostics.successLikeSummary,
visibleSummary: diagnostics.visibleSummary,
errorStyledSummary: diagnostics.errorStyledSummary,
if (diagnostics.hasCallbackSubmittedBadge && !diagnostics.hasExactSuccessVisibleBadge) {
const callbackSubmittedSignature = JSON.stringify({
callbackStatusText: diagnostics.callbackStatusText,
mainStatusText: diagnostics.mainStatusText,
});
if (mismatchSignature !== lastSuccessLikeMismatchSignature) {
lastSuccessLikeMismatchSignature = mismatchSignature;
const errorStyledSuffix = diagnostics.hasErrorStyledVisibleBadge
? `;错误样式徽标:${diagnostics.errorStyledSummary}`
: '';
if (callbackSubmittedSignature !== lastCallbackSubmittedSignature) {
lastCallbackSubmittedSignature = callbackSubmittedSignature;
log(
`步骤 10检测到“认证成功”相关徽标,但未命中精确成功条件。当前聚焦="${getInlineTextSnippet(diagnostics.selectedText || '(空)', 80)}";成功相关徽标:${diagnostics.successLikeSummary}${errorStyledSuffix}`,
'warn'
`步骤 10CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
'info'
);
console.warn(LOG_PREFIX, '[Step 9] success-like badge detected without exact match', diagnostics);
console.info(LOG_PREFIX, '[Step 9] callback accepted and waiting for auth completion', diagnostics);
}
}
if (isStep10BrowserSwitchRequiredConflict(diagnostics)) {
const browserSwitchMessage = getStep10BrowserSwitchRequiredMessage(diagnostics);
log(`步骤 10${browserSwitchMessage}`, 'error');
console.error(LOG_PREFIX, '[Step 9] browser-switch conflict detected', diagnostics);
throw new Error(`BROWSER_SWITCH_REQUIRED::${browserSwitchMessage}`);
}
if (diagnostics.hasExactSuccessVisibleBadge && diagnostics.hasFailureVisibleBadge) {
const conflictSignature = JSON.stringify({
exactSuccessSummary: diagnostics.exactSuccessSummary,
@@ -515,10 +729,11 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
}
if (diagnostics.failureText) {
const failureExplanation = explainStep10Failure(diagnostics.failureText, diagnostics.failureSource || 'unknown');
if (isOAuthCallbackTimeoutFailure(diagnostics.failureText)) {
throw new Error(`STEP9_OAUTH_TIMEOUT::${diagnostics.failureText}`);
throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}`);
}
throw new Error(`STEP9_OAUTH_RETRY::${diagnostics.failureText}`);
throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}`);
}
if (diagnostics.exactSuccessText) {
return diagnostics.exactSuccessText;
@@ -530,10 +745,18 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
const finalText = finalDiagnostics.failureText || finalDiagnostics.selectedText;
const diagnosticsSuffix = ` 当前诊断:${finalDiagnostics.summary}`;
if (isOAuthCallbackTimeoutFailure(finalText)) {
throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}${diagnosticsSuffix}`);
const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'main');
throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}${diagnosticsSuffix}`);
}
if (isStep9FailureText(finalText)) {
throw new Error(`STEP9_OAUTH_RETRY::${finalText}${diagnosticsSuffix}`);
const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'unknown');
throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}${diagnosticsSuffix}`);
}
if (finalDiagnostics.hasCallbackSubmittedBadge || finalDiagnostics.mainWaitingText) {
throw new Error(
'STEP9_OAUTH_TIMEOUT::CPA 已接受回调,但 120 秒内仍未进入认证成功状态。通常是 CPA 后台处理过慢、面板轮询异常,或 CPA 到 OpenAI 的网络/代理存在问题。'
+ diagnosticsSuffix
);
}
throw new Error(finalText
? `CPA 面板状态未进入成功状态,当前为“${finalText}”。${diagnosticsSuffix}`
@@ -583,6 +806,11 @@ function findCodexOAuthHeader() {
}) || null;
}
function findCodexOAuthCard() {
const header = findCodexOAuthHeader();
return header?.closest('.card, [class*="card"]') || header || null;
}
function findOAuthCardLoginButton(header) {
const card = header?.closest('.card, [class*="card"]') || header?.parentElement || document;
const candidates = card.querySelectorAll('button.btn.btn-primary, button.btn-primary, button.btn');