feat(flow): unify sms multi-provider fallback and stabilize proxy/auth retries

This commit is contained in:
daniellee2015
2026-05-03 01:20:10 +08:00
parent 99313843c1
commit 2437e6e67d
37 changed files with 12254 additions and 1142 deletions
+3 -1
View File
@@ -71,8 +71,9 @@
: false;
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
return null;
}
@@ -84,6 +85,7 @@
titleMatched,
detailMatched,
routeErrorMatched,
fetchFailedMatched,
maxCheckAttemptsBlocked,
userAlreadyExistsBlocked,
};
+78 -5
View File
@@ -1,5 +1,6 @@
const ICLOUD_MAIL_PREFIX = '[MultiPage:icloud-mail]';
const isTopFrame = window === window.top;
const ICLOUD_POLL_SESSION_CACHE = new Map();
console.log(ICLOUD_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
@@ -12,7 +13,10 @@ function isMailApplicationFrame() {
if (isTopFrame) {
console.log(ICLOUD_MAIL_PREFIX, 'Top frame detected; waiting for mail iframe.');
} else {
}
const shouldHandlePollEmailInCurrentFrame = !isTopFrame || isMailApplicationFrame();
if (shouldHandlePollEmailInCurrentFrame) {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
if (!isMailApplicationFrame()) {
@@ -221,19 +225,76 @@ if (isTopFrame) {
}
}
function normalizePollSessionKey(payload = {}) {
const raw = String(payload?.sessionKey || '').trim();
if (raw) {
return raw;
}
return '';
}
function getOrCreatePollSessionBaseline(sessionKey, currentItems = []) {
if (!sessionKey) {
return {
signatures: new Set(currentItems.map(buildItemSignature)),
fallbackCarry: 0,
fromCache: false,
};
}
const cached = ICLOUD_POLL_SESSION_CACHE.get(sessionKey);
if (cached && cached.signatures instanceof Set) {
return {
signatures: new Set(cached.signatures),
fallbackCarry: Math.max(0, Number(cached.fallbackCarry) || 0),
fromCache: true,
};
}
return {
signatures: new Set(currentItems.map(buildItemSignature)),
fallbackCarry: 0,
fromCache: false,
};
}
function persistPollSessionBaseline(sessionKey, signatures, fallbackCarry = 0) {
if (!sessionKey) {
return;
}
ICLOUD_POLL_SESSION_CACHE.set(sessionKey, {
signatures: new Set(signatures || []),
fallbackCarry: Math.max(0, Number(fallbackCarry) || 0),
updatedAt: Date.now(),
});
if (ICLOUD_POLL_SESSION_CACHE.size > 12) {
const oldest = Array.from(ICLOUD_POLL_SESSION_CACHE.entries())
.sort((left, right) => Number(left?.[1]?.updatedAt || 0) - Number(right?.[1]?.updatedAt || 0))
.slice(0, ICLOUD_POLL_SESSION_CACHE.size - 12);
oldest.forEach(([key]) => ICLOUD_POLL_SESSION_CACHE.delete(key));
}
}
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload;
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
const FALLBACK_AFTER = 3;
const pollSessionKey = normalizePollSessionKey(payload);
const normalizedSenderFilters = senderFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
const normalizedSubjectFilters = subjectFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
log(`步骤 ${step}:开始轮询 iCloud 邮箱(最多 ${maxAttempts} 次)`);
await waitForElement('.content-container', 10000);
await sleep(1500);
const existingSignatures = new Set(collectThreadItems().map(buildItemSignature));
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
const currentItems = collectThreadItems();
const sessionBaseline = getOrCreatePollSessionBaseline(pollSessionKey, currentItems);
const existingSignatures = sessionBaseline.signatures;
let fallbackCarry = sessionBaseline.fallbackCarry;
if (sessionBaseline.fromCache) {
log(`步骤 ${step}:已复用当前会话旧邮件快照(${existingSignatures.size} 封)。`);
} else {
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
}
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
log(`步骤 ${step}:正在轮询 iCloud 邮箱,第 ${attempt}/${maxAttempts}`);
@@ -244,7 +305,7 @@ if (isTopFrame) {
}
const items = collectThreadItems();
const useFallback = attempt > FALLBACK_AFTER;
const useFallback = (fallbackCarry + attempt) > FALLBACK_AFTER;
for (const item of items) {
const signature = buildItemSignature(item);
@@ -287,6 +348,11 @@ if (isTopFrame) {
const source = useFallback && existingSignatures.has(signature) ? '回退匹配邮件' : '新邮件';
log(`步骤 ${step}:已找到验证码:${code}(来源:${source}`, 'ok');
persistPollSessionBaseline(
pollSessionKey,
new Set(collectThreadItems().map(buildItemSignature)),
0
);
return {
ok: true,
code,
@@ -304,6 +370,13 @@ if (isTopFrame) {
}
}
fallbackCarry += maxAttempts;
persistPollSessionBaseline(
pollSessionKey,
new Set(collectThreadItems().map(buildItemSignature)),
fallbackCarry
);
throw new Error(
`${Math.round((maxAttempts * intervalMs) / 1000)} 秒后仍未在 iCloud 邮箱中找到新的匹配邮件。请手动检查收件箱。`
);
+226 -48
View File
@@ -19,7 +19,15 @@
waitForElement,
} = deps;
const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::';
const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::';
const PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS = 6000;
const PHONE_RESEND_ROUTE_405_MAX_RECOVERIES = 2;
const PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS = 12000;
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i;
const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3;
let lastPhoneRoute405RecoveryFailedAt = 0;
let activePhoneResendPromise = null;
function dispatchInputEvents(element) {
if (!element) return;
@@ -35,6 +43,10 @@
return digits;
}
function isExplicitInternationalPhoneInput(value) {
return /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim());
}
function normalizeCountryLabel(value) {
return String(value || '')
.normalize('NFKD')
@@ -151,23 +163,31 @@
function toNationalPhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
if (!digits) {
return '';
}
if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) {
return digits.slice(normalizedDialCode.length);
}
if (isExplicitInternational) {
return digits;
}
return digits;
}
function toE164PhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
if (!digits) {
return '';
}
if (isExplicitInternational) {
return `+${digits}`;
}
if (!normalizedDialCode) {
return digits.startsWith('+') ? digits : `+${digits}`;
return `+${digits}`;
}
if (digits.startsWith(normalizedDialCode)) {
return `+${digits}`;
@@ -234,34 +254,71 @@
.map((label) => normalizeCountryLabel(label))
.filter(Boolean);
return normalizedLabels.some((optionLabel) => (
optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)
optionLabel.length > 2
&& normalizedTarget.length > 2
&& (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel))
));
})
|| null;
}
async function ensureCountrySelected(countryLabel) {
function findCountryOptionByPhoneNumber(phoneNumber) {
const select = getCountrySelect();
if (!select) {
return null;
}
const digits = normalizePhoneDigits(phoneNumber);
if (!digits) {
return null;
}
let bestMatch = null;
let bestDialCodeLength = 0;
for (const option of Array.from(select.options || [])) {
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getOptionLabel(option)));
if (!dialCode || !digits.startsWith(dialCode)) {
continue;
}
if (dialCode.length > bestDialCodeLength) {
bestMatch = option;
bestDialCodeLength = dialCode.length;
}
}
return bestMatch;
}
async function trySelectCountryOption(select, targetOption) {
if (!select || !targetOption) {
return false;
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await sleep(250);
const nextSelectedOption = getSelectedCountryOption();
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
}
async function ensureCountrySelected(countryLabel, phoneNumber = '') {
const select = getCountrySelect();
if (!select) {
return false;
}
const targetOption = findCountryOptionByLabel(countryLabel);
if (!targetOption) {
throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`);
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
const byLabel = findCountryOptionByLabel(countryLabel);
if (await trySelectCountryOption(select, byLabel)) {
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await sleep(250);
const byPhoneNumber = findCountryOptionByPhoneNumber(phoneNumber);
if (await trySelectCountryOption(select, byPhoneNumber)) {
return true;
}
const nextSelectedOption = getSelectedCountryOption();
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
return Boolean(getSelectedCountryOption());
}
function getAddPhoneSubmitButton() {
@@ -398,6 +455,81 @@
return '';
}
function getAuthRetryButton(options = {}) {
const { allowDisabled = false } = options;
const direct = document.querySelector('button[data-dd-action-name="Try again"]');
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
return direct;
}
const candidates = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]');
return Array.from(candidates).find((element) => {
if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) {
return false;
}
const text = String(getActionText?.(element) || '').trim();
return /重试|try\s+again/i.test(text);
}) || null;
}
function is405MethodNotAllowedPage() {
const path = String(location?.pathname || '');
if (!/\/phone-verification(?:[/?#]|$)/i.test(path) && !/\/add-phone(?:[/?#]|$)/i.test(path)) {
return false;
}
const text = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim();
const title = String(document?.title || '');
const matched = PHONE_ROUTE_405_PATTERN.test(text) || PHONE_ROUTE_405_PATTERN.test(title);
if (!matched) {
return false;
}
return Boolean(getAuthRetryButton({ allowDisabled: true }));
}
async function recoverPhoneRoute405(timeout = 12000, options = {}) {
const now = Date.now();
if (
lastPhoneRoute405RecoveryFailedAt > 0
&& now - lastPhoneRoute405RecoveryFailedAt < PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS
) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route is still in 405 recovery cooldown (${Math.ceil((PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS - (now - lastPhoneRoute405RecoveryFailedAt)) / 1000)}s left). URL: ${location.href}`
);
}
const startedAt = Date.now();
let clicked = 0;
const maxRetryClicks = Math.max(
1,
Math.floor(Number(options?.maxRetryClicks) || PHONE_ROUTE_405_MAX_RECOVERY_CLICKS)
);
while (Date.now() - startedAt < timeout) {
throwIfStopped();
if (!is405MethodNotAllowedPage()) {
return;
}
const retryButton = getAuthRetryButton({ allowDisabled: true });
if (retryButton && isActionEnabled(retryButton)) {
if (clicked >= maxRetryClicks) {
lastPhoneRoute405RecoveryFailedAt = Date.now();
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route stayed on 405 after ${clicked} retry click(s). URL: ${location.href}`
);
}
clicked += 1;
await humanPause(200, 500);
simulateClick(retryButton);
await sleep(1000);
continue;
}
await sleep(250);
}
lastPhoneRoute405RecoveryFailedAt = Date.now();
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route 405 recovery timed out after ${clicked} retry click(s). URL: ${location.href}`
);
}
async function waitForAddPhoneReady(timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
@@ -414,6 +546,10 @@
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
continue;
}
if (isPhoneVerificationPageReady()) {
return {
phoneVerificationPage: true,
@@ -448,18 +584,15 @@
async function submitPhoneNumber(payload = {}) {
const countryLabel = String(payload.countryLabel || '').trim();
if (!countryLabel) {
throw new Error('Missing country label for add-phone submission.');
}
const isExplicitInternational = isExplicitInternationalPhoneInput(payload.phoneNumber);
await waitForAddPhoneReady();
const countrySelected = await ensureCountrySelected(countryLabel);
const countrySelected = await ensureCountrySelected(countryLabel, payload.phoneNumber);
if (!countrySelected) {
throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`);
throw new Error(`Failed to select "${countryLabel || 'target country'}" on the add-phone page.`);
}
const dialCode = getDisplayedDialCode();
if (!dialCode) {
if (!dialCode && !isExplicitInternational) {
throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`);
}
@@ -498,6 +631,10 @@
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
continue;
}
const errorText = getVerificationErrorText();
if (errorText) {
@@ -565,40 +702,81 @@
fillInput(codeInput, code);
await sleep(250);
simulateClick(submitButton);
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(12000);
}
return waitForPhoneVerificationOutcome();
}
async function resendPhoneVerificationCode(timeout = 45000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const throttledText = getPhoneResendThrottleText();
if (throttledText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
}
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
if (resendButton && isActionEnabled(resendButton)) {
await humanPause(250, 700);
simulateClick(resendButton);
await sleep(1000);
const afterClickThrottleText = getPhoneResendThrottleText();
if (afterClickThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
if (activePhoneResendPromise) {
return activePhoneResendPromise;
}
activePhoneResendPromise = (async () => {
const start = Date.now();
const route405RecoveryStart = Date.now();
let route405RecoveryCount = 0;
const recoverRoute405WithinResend = async () => {
route405RecoveryCount += 1;
if (route405RecoveryCount > PHONE_RESEND_ROUTE_405_MAX_RECOVERIES) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend stayed on route-405 page after ${PHONE_RESEND_ROUTE_405_MAX_RECOVERIES} recovery round(s). URL: ${location.href}`
);
}
return {
resent: true,
url: location.href,
};
const recoveryBudgetLeft = PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS - (Date.now() - route405RecoveryStart);
if (recoveryBudgetLeft <= 0) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend exceeded route-405 recovery budget (${PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS}ms). URL: ${location.href}`
);
}
const remainingTimeout = Math.max(1000, timeout - (Date.now() - start));
const recoveryTimeout = Math.max(1000, Math.min(12000, recoveryBudgetLeft, remainingTimeout));
await recoverPhoneRoute405(recoveryTimeout);
};
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverRoute405WithinResend();
continue;
}
const throttledText = getPhoneResendThrottleText();
if (throttledText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
}
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
if (resendButton && isActionEnabled(resendButton)) {
await humanPause(250, 700);
simulateClick(resendButton);
await sleep(1000);
if (is405MethodNotAllowedPage()) {
await recoverRoute405WithinResend();
continue;
}
const afterClickThrottleText = getPhoneResendThrottleText();
if (afterClickThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
}
return {
resent: true,
url: location.href,
};
}
await sleep(250);
}
await sleep(250);
}
const timeoutThrottleText = getPhoneResendThrottleText();
if (timeoutThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`);
}
const timeoutThrottleText = getPhoneResendThrottleText();
if (timeoutThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`);
}
throw new Error('Timed out waiting for the phone verification resend button.');
throw new Error('Timed out waiting for the phone verification resend button.');
})().finally(() => {
activePhoneResendPromise = null;
});
return activePhoneResendPromise;
}
async function returnToAddPhone(timeout = 20000) {
+5 -3
View File
@@ -1036,8 +1036,8 @@ const CONTINUE_ACTION_PATTERN = /继续|continue/i;
const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/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|请求超时|操作超时/i;
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/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;
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i;
const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::';
const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i;
@@ -1536,10 +1536,11 @@ function getAuthTimeoutErrorPageState(options = {}) {
|| AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(document.title || '');
const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text);
const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text);
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
return null;
}
@@ -1551,6 +1552,7 @@ function getAuthTimeoutErrorPageState(options = {}) {
titleMatched,
detailMatched,
routeErrorMatched,
fetchFailedMatched,
maxCheckAttemptsBlocked,
userAlreadyExistsBlocked,
};