feat: add account run history module and logging functionality

- Implemented `background/account-run-history.js` to persist account run results (success, failure, stop) in `chrome.storage.local` and log to a text file when the local Hotmail helper is enabled.
- Enhanced `background/auto-run-controller.js` and `background/message-router.js` to utilize the new account run record functionality.
- Updated steps in `background/steps/fetch-login-code.js`, `background/steps/fetch-signup-code.js`, and `background/steps/oauth-login.js` to handle retries and logging appropriately.
- Introduced tests for account run history and step retry limits to ensure functionality and reliability.
- Modified documentation to reflect new features and changes in behavior for specific providers (e.g., 2925).
This commit is contained in:
QLHazyCoder
2026-04-17 02:52:26 +08:00
parent e48941806f
commit 6652899758
15 changed files with 858 additions and 67 deletions
+46 -2
View File
@@ -1,6 +1,7 @@
// background.js — Service Worker: orchestration, state, tab management, message routing // background.js — Service Worker: orchestration, state, tab management, message routing
importScripts( importScripts(
'background/account-run-history.js',
'background/panel-bridge.js', 'background/panel-bridge.js',
'background/generated-email-helpers.js', 'background/generated-email-helpers.js',
'background/signup-flow-helpers.js', 'background/signup-flow-helpers.js',
@@ -123,6 +124,7 @@ const HOTMAIL_MAILBOXES = ['INBOX', 'Junk'];
const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const STOP_ERROR_MESSAGE = '流程已被用户停止。';
const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MIN = 700;
const HUMAN_STEP_DELAY_MAX = 2200; const HUMAN_STEP_DELAY_MAX = 2200;
const STEP6_MAX_ATTEMPTS = 3;
const STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS = 8; const STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS = 8;
const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000; const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000; const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
@@ -154,6 +156,7 @@ const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const DISPLAY_TIMEZONE = 'Asia/Shanghai';
const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001;
const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases'];
const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory';
initializeSessionStorageAccess(); initializeSessionStorageAccess();
setupDeclarativeNetRequestRules(); setupDeclarativeNetRequestRules();
@@ -4400,16 +4403,34 @@ function notifyStepError(step, error) {
async function completeStepFromBackground(step, payload = {}) { async function completeStepFromBackground(step, payload = {}) {
if (stopRequested) { if (stopRequested) {
await setStepStatus(step, 'stopped'); await setStepStatus(step, 'stopped');
await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, null, STOP_ERROR_MESSAGE);
notifyStepError(step, STOP_ERROR_MESSAGE); notifyStepError(step, STOP_ERROR_MESSAGE);
return; return;
} }
const completionState = step === 9 ? await getState() : null;
await setStepStatus(step, 'completed'); await setStepStatus(step, 'completed');
await addLog(`步骤 ${step} 已完成`, 'ok'); await addLog(`步骤 ${step} 已完成`, 'ok');
await handleStepData(step, payload); await handleStepData(step, payload);
if (step === 9 && accountRunHistoryHelpers?.appendAccountRunRecord) {
await accountRunHistoryHelpers.appendAccountRunRecord('success', completionState);
}
notifyStepComplete(step, payload); notifyStepComplete(step, payload);
} }
async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null, reason = '') {
if (!accountRunHistoryHelpers?.appendAccountRunRecord) {
return null;
}
const state = stateOverride || await getState();
if (isAutoRunLockedState(state)) {
return null;
}
return accountRunHistoryHelpers.appendAccountRunRecord(status, state, reason);
}
async function finalizeDeferredStepExecutionError(step, error) { async function finalizeDeferredStepExecutionError(step, error) {
const latestState = await getState(); const latestState = await getState();
const currentStatus = latestState.stepStatuses?.[step]; const currentStatus = latestState.stepStatuses?.[step];
@@ -4420,11 +4441,13 @@ async function finalizeDeferredStepExecutionError(step, error) {
if (isStopError(error)) { if (isStopError(error)) {
await setStepStatus(step, 'stopped'); await setStepStatus(step, 'stopped');
await addLog(`步骤 ${step} 已被用户停止`, 'warn'); await addLog(`步骤 ${step} 已被用户停止`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, latestState, getErrorMessage(error));
return; return;
} }
await setStepStatus(step, 'failed'); await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${getErrorMessage(error)}`, 'error'); await addLog(`步骤 ${step} 失败:${getErrorMessage(error)}`, 'error');
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, latestState, getErrorMessage(error));
} }
async function executeStepViaCompletionSignal(step, timeoutMs = AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS) { async function executeStepViaCompletionSignal(step, timeoutMs = AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS) {
@@ -4592,11 +4615,13 @@ async function executeStep(step, options = {}) {
if (isStopError(err)) { if (isStopError(err)) {
await setStepStatus(step, 'stopped'); await setStepStatus(step, 'stopped');
await addLog(`步骤 ${step} 已被用户停止`, 'warn'); await addLog(`步骤 ${step} 已被用户停止`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, state, getErrorMessage(err));
throw err; throw err;
} }
if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) { if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) {
await setStepStatus(step, 'failed'); await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${err.message}`, 'error'); await addLog(`步骤 ${step} 失败:${err.message}`, 'error');
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, state, getErrorMessage(err));
} else { } else {
console.warn( console.warn(
LOG_PREFIX, LOG_PREFIX,
@@ -4713,6 +4738,8 @@ let autoRunAttemptRun = 0;
const EMAIL_FETCH_MAX_ATTEMPTS = 5; const EMAIL_FETCH_MAX_ATTEMPTS = 5;
const VERIFICATION_POLL_MAX_ROUNDS = 5; const VERIFICATION_POLL_MAX_ROUNDS = 5;
const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000; const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000;
const MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15;
const MAIL_2925_VERIFICATION_INTERVAL_MS = 15000;
const AUTO_STEP_DELAYS = { const AUTO_STEP_DELAYS = {
1: 2000, 1: 2000,
2: 2000, 2: 2000,
@@ -4724,8 +4751,19 @@ const AUTO_STEP_DELAYS = {
8: 2000, 8: 2000,
9: 1000, 9: 1000,
}; };
const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.createAccountRunHistoryHelpers({
ACCOUNT_RUN_HISTORY_STORAGE_KEY,
addLog,
buildHotmailLocalEndpoint,
chrome,
getErrorMessage,
getState,
HOTMAIL_SERVICE_MODE_LOCAL,
normalizeHotmailLocalBaseUrl,
});
const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({
addLog, addLog,
appendAccountRunRecord: (...args) => accountRunHistoryHelpers?.appendAccountRunRecord?.(...args),
AUTO_RUN_MAX_RETRIES_PER_ROUND, AUTO_RUN_MAX_RETRIES_PER_ROUND,
AUTO_RUN_RETRY_DELAY_MS, AUTO_RUN_RETRY_DELAY_MS,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY, AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
@@ -5117,6 +5155,8 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
HOTMAIL_PROVIDER, HOTMAIL_PROVIDER,
isStopError, isStopError,
LUCKMAIL_PROVIDER, LUCKMAIL_PROVIDER,
MAIL_2925_VERIFICATION_INTERVAL_MS,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
pollCloudflareTempEmailVerificationCode, pollCloudflareTempEmailVerificationCode,
pollHotmailVerificationCode, pollHotmailVerificationCode,
pollLuckmailVerificationCode, pollLuckmailVerificationCode,
@@ -5190,6 +5230,7 @@ const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({
const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({ const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({
addLog, addLog,
completeStepFromBackground, completeStepFromBackground,
getErrorMessage,
getLoginAuthStateLabel, getLoginAuthStateLabel,
getState, getState,
isStep6RecoverableResult, isStep6RecoverableResult,
@@ -5200,6 +5241,7 @@ const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({
sendToContentScriptResilient, sendToContentScriptResilient,
shouldSkipLoginVerificationForCpaCallback, shouldSkipLoginVerificationForCpaCallback,
skipLoginVerificationStepsForCpaCallback, skipLoginVerificationStepsForCpaCallback,
STEP6_MAX_ATTEMPTS,
throwIfStopped, throwIfStopped,
}); });
const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
@@ -5209,6 +5251,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass,
ensureStep7VerificationPageReady, ensureStep7VerificationPageReady,
executeStep6: (...args) => executeStep6(...args), executeStep6: (...args) => executeStep6(...args),
getPanelMode,
getMailConfig, getMailConfig,
getState, getState,
getTabId, getTabId,
@@ -5259,6 +5302,7 @@ const stepExecutorsByKey = {
}; };
const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({
addLog, addLog,
appendAccountRunRecord: (...args) => accountRunHistoryHelpers?.appendAccountRunRecord?.(...args),
batchUpdateLuckmailPurchases, batchUpdateLuckmailPurchases,
buildLocalhostCleanupPrefix, buildLocalhostCleanupPrefix,
buildLuckmailSessionSettingsPayload, buildLuckmailSessionSettingsPayload,
@@ -5801,8 +5845,8 @@ async function skipLoginVerificationStepsForCpaCallback() {
} }
} }
async function executeStep6(state) { async function executeStep6(state, options = {}) {
return step6Executor.executeStep6(state); return step6Executor.executeStep6(state, options);
} }
// ============================================================ // ============================================================
+168
View File
@@ -0,0 +1,168 @@
(function attachBackgroundAccountRunHistory(root, factory) {
root.MultiPageBackgroundAccountRunHistory = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundAccountRunHistoryModule() {
function createAccountRunHistoryHelpers(deps = {}) {
const {
ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory',
addLog,
buildHotmailLocalEndpoint,
chrome,
getErrorMessage,
getState,
HOTMAIL_SERVICE_MODE_LOCAL = 'local',
normalizeHotmailLocalBaseUrl,
} = deps;
function normalizeAccountRunHistory(records) {
if (!Array.isArray(records)) {
return [];
}
return records
.filter((item) => item && typeof item === 'object')
.map((item) => ({
email: String(item.email || '').trim(),
password: String(item.password || '').trim(),
status: String(item.status || '').trim().toLowerCase(),
recordedAt: String(item.recordedAt || '').trim(),
reason: String(item.reason || '').trim(),
}))
.filter((item) => item.email && item.password && item.status);
}
async function getPersistedAccountRunHistory() {
try {
const stored = await chrome.storage.local.get(ACCOUNT_RUN_HISTORY_STORAGE_KEY);
return normalizeAccountRunHistory(stored[ACCOUNT_RUN_HISTORY_STORAGE_KEY]);
} catch (err) {
console.warn('[MultiPage:account-run-history] Failed to read account run history:', err?.message || err);
return [];
}
}
function buildAccountRunHistoryRecord(state = {}, status = '', reason = '') {
const email = String(state.email || '').trim();
const password = String(state.password || state.customPassword || '').trim();
const normalizedStatus = String(status || '').trim().toLowerCase();
const normalizedReason = String(reason || '').trim();
if (!email || !password || !normalizedStatus) {
return null;
}
return {
email,
password,
status: normalizedStatus,
recordedAt: new Date().toISOString(),
reason: normalizedReason,
};
}
async function appendAccountRunHistoryRecord(status, stateOverride = null, reason = '') {
const state = stateOverride || await getState();
const record = buildAccountRunHistoryRecord(state, status, reason);
if (!record) {
return null;
}
const history = await getPersistedAccountRunHistory();
history.push(record);
await chrome.storage.local.set({
[ACCOUNT_RUN_HISTORY_STORAGE_KEY]: history,
});
return record;
}
function shouldAppendAccountRunTextFile(state = {}) {
const serviceMode = String(state.hotmailServiceMode || '').trim().toLowerCase();
if (serviceMode !== HOTMAIL_SERVICE_MODE_LOCAL) {
return false;
}
const helperBaseUrl = normalizeHotmailLocalBaseUrl(state.hotmailLocalBaseUrl);
return Boolean(helperBaseUrl);
}
async function appendAccountRunHistoryTextFile(record, stateOverride = null) {
const normalizedRecord = record && typeof record === 'object'
? record
: buildAccountRunHistoryRecord(stateOverride || await getState(), '');
if (!normalizedRecord?.email || !normalizedRecord?.password || !normalizedRecord?.status) {
return null;
}
const state = stateOverride || await getState();
if (!shouldAppendAccountRunTextFile(state)) {
return null;
}
const helperBaseUrl = normalizeHotmailLocalBaseUrl(state.hotmailLocalBaseUrl);
let response;
try {
response = await fetch(buildHotmailLocalEndpoint(helperBaseUrl, '/append-account-log'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email: normalizedRecord.email,
password: normalizedRecord.password,
status: normalizedRecord.status,
recordedAt: normalizedRecord.recordedAt,
reason: normalizedRecord.reason || '',
}),
});
} catch (err) {
throw new Error(`账号文本记录写入失败:无法连接本地 helper(${getErrorMessage(err)}`);
}
let payload = null;
try {
payload = await response.json();
} catch (err) {
throw new Error(`账号文本记录写入失败:本地 helper 返回了无法解析的响应(${getErrorMessage(err)}`);
}
if (!response.ok || payload?.ok === false) {
throw new Error(`账号文本记录写入失败:${payload?.error || `HTTP ${response.status}`}`);
}
return payload?.filePath || '';
}
async function appendAccountRunRecord(status, stateOverride = null, reason = '') {
const state = stateOverride || await getState();
const record = await appendAccountRunHistoryRecord(status, state, reason);
if (!record) {
return null;
}
try {
const filePath = await appendAccountRunHistoryTextFile(record, state);
if (filePath) {
await addLog(`账号记录已追加到本地文本:${filePath}`, 'info');
}
} catch (err) {
await addLog(getErrorMessage(err), 'warn');
}
return record;
}
return {
appendAccountRunRecord,
appendAccountRunHistoryRecord,
appendAccountRunHistoryTextFile,
buildAccountRunHistoryRecord,
getPersistedAccountRunHistory,
normalizeAccountRunHistory,
shouldAppendAccountRunTextFile,
};
}
return {
createAccountRunHistoryHelpers,
};
});
+21
View File
@@ -4,6 +4,7 @@
function createAutoRunController(deps = {}) { function createAutoRunController(deps = {}) {
const { const {
addLog, addLog,
appendAccountRunRecord,
AUTO_RUN_MAX_RETRIES_PER_ROUND, AUTO_RUN_MAX_RETRIES_PER_ROUND,
AUTO_RUN_RETRY_DELAY_MS, AUTO_RUN_RETRY_DELAY_MS,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY, AUTO_RUN_TIMER_KIND_BEFORE_RETRY,
@@ -303,6 +304,7 @@
for (let targetRun = resumeCurrentRun; targetRun <= totalRuns; targetRun += 1) { for (let targetRun = resumeCurrentRun; targetRun <= totalRuns; targetRun += 1) {
const roundSummary = roundSummaries[targetRun - 1]; const roundSummary = roundSummaries[targetRun - 1];
let roundRecordAppended = false;
const resumingCurrentRound = continueCurrentOnFirstAttempt && targetRun === resumeCurrentRun; const resumingCurrentRound = continueCurrentOnFirstAttempt && targetRun === resumeCurrentRun;
let attemptRun = resumingCurrentRound ? resumeAttemptRun : 1; let attemptRun = resumingCurrentRound ? resumeAttemptRun : 1;
let reuseExistingProgress = resumingCurrentRound; let reuseExistingProgress = resumingCurrentRound;
@@ -377,6 +379,21 @@
forceFreshTabsNextRun = false; forceFreshTabsNextRun = false;
} }
const appendRoundRecordIfNeeded = async (status, reason = '') => {
if (roundRecordAppended) {
return;
}
if (typeof appendAccountRunRecord !== 'function') {
return;
}
const record = await appendAccountRunRecord(status, null, reason);
if (record) {
roundRecordAppended = true;
}
};
try { try {
deps.throwIfStopped(); deps.throwIfStopped();
await broadcastAutoRunStatus('running', { await broadcastAutoRunStatus('running', {
@@ -403,6 +420,7 @@
} catch (err) { } catch (err) {
if (isStopError(err)) { if (isStopError(err)) {
stoppedEarly = true; stoppedEarly = true;
await appendRoundRecordIfNeeded('stopped', getErrorMessage(err));
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', { await broadcastAutoRunStatus('stopped', {
currentRun: targetRun, currentRun: targetRun,
@@ -444,6 +462,7 @@
} catch (sleepError) { } catch (sleepError) {
if (isStopError(sleepError)) { if (isStopError(sleepError)) {
stoppedEarly = true; stoppedEarly = true;
await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError));
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', { await broadcastAutoRunStatus('stopped', {
currentRun: targetRun, currentRun: targetRun,
@@ -466,6 +485,7 @@
} catch (sleepError) { } catch (sleepError) {
if (isStopError(sleepError)) { if (isStopError(sleepError)) {
stoppedEarly = true; stoppedEarly = true;
await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError));
await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await addLog(`${targetRun}/${totalRuns} 轮已被用户停止`, 'warn');
await broadcastAutoRunStatus('stopped', { await broadcastAutoRunStatus('stopped', {
currentRun: targetRun, currentRun: targetRun,
@@ -486,6 +506,7 @@
await setState({ await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
}); });
await appendRoundRecordIfNeeded('failed', reason);
if (!autoRunSkipFailures) { if (!autoRunSkipFailures) {
cancelPendingCommands('当前轮执行失败。'); cancelPendingCommands('当前轮执行失败。');
await broadcastStopToContentScripts(); await broadcastStopToContentScripts();
+21
View File
@@ -4,6 +4,7 @@
function createMessageRouter(deps = {}) { function createMessageRouter(deps = {}) {
const { const {
addLog, addLog,
appendAccountRunRecord,
batchUpdateLuckmailPurchases, batchUpdateLuckmailPurchases,
buildLocalhostCleanupPrefix, buildLocalhostCleanupPrefix,
buildLuckmailSessionSettingsPayload, buildLuckmailSessionSettingsPayload,
@@ -78,6 +79,19 @@
verifyHotmailAccount, verifyHotmailAccount,
} = deps; } = deps;
async function appendManualAccountRunRecordIfNeeded(status, stateOverride = null, reason = '') {
if (typeof appendAccountRunRecord !== 'function') {
return null;
}
const state = stateOverride || await getState();
if (isAutoRunLockedState(state)) {
return null;
}
return appendAccountRunRecord(status, state, reason);
}
async function handleStepData(step, payload) { async function handleStepData(step, payload) {
switch (step) { switch (step) {
case 1: { case 1: {
@@ -187,12 +201,17 @@
case 'STEP_COMPLETE': { case 'STEP_COMPLETE': {
if (getStopRequested()) { if (getStopRequested()) {
await setStepStatus(message.step, 'stopped'); await setStepStatus(message.step, 'stopped');
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。');
notifyStepError(message.step, '流程已被用户停止。'); notifyStepError(message.step, '流程已被用户停止。');
return { ok: true }; return { ok: true };
} }
const completionState = message.step === 9 ? await getState() : null;
await setStepStatus(message.step, 'completed'); await setStepStatus(message.step, 'completed');
await addLog(`步骤 ${message.step} 已完成`, 'ok'); await addLog(`步骤 ${message.step} 已完成`, 'ok');
await handleStepData(message.step, message.payload); await handleStepData(message.step, message.payload);
if (message.step === 9 && typeof appendAccountRunRecord === 'function') {
await appendAccountRunRecord('success', completionState);
}
notifyStepComplete(message.step, message.payload); notifyStepComplete(message.step, message.payload);
return { ok: true }; return { ok: true };
} }
@@ -201,10 +220,12 @@
if (isStopError(message.error)) { if (isStopError(message.error)) {
await setStepStatus(message.step, 'stopped'); await setStepStatus(message.step, 'stopped');
await addLog(`步骤 ${message.step} 已被用户停止`, 'warn'); await addLog(`步骤 ${message.step} 已被用户停止`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error);
notifyStepError(message.step, message.error); notifyStepError(message.step, message.error);
} else { } else {
await setStepStatus(message.step, 'failed'); await setStepStatus(message.step, 'failed');
await addLog(`步骤 ${message.step} 失败:${message.error}`, 'error'); await addLog(`步骤 ${message.step} 失败:${message.error}`, 'error');
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error);
notifyStepError(message.step, message.error); notifyStepError(message.step, message.error);
} }
return { ok: true }; return { ok: true };
+33 -5
View File
@@ -9,6 +9,7 @@
confirmCustomVerificationStepBypass, confirmCustomVerificationStepBypass,
ensureStep7VerificationPageReady, ensureStep7VerificationPageReady,
executeStep6, executeStep6,
getPanelMode,
getMailConfig, getMailConfig,
getState, getState,
getTabId, getTabId,
@@ -77,18 +78,45 @@
} }
} }
const shouldRefreshOAuthBeforeSubmit = getPanelMode(state) === 'cpa';
let step6ReplayCompleted = false;
await resolveVerificationStep(7, state, mail, { await resolveVerificationStep(7, state, mail, {
filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000), filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000),
requestFreshCodeFirst: false, requestFreshCodeFirst: false,
resendIntervalMs: mail.provider === HOTMAIL_PROVIDER ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
beforeSubmit: shouldRefreshOAuthBeforeSubmit ? async (result) => {
if (step6ReplayCompleted) {
return;
}
step6ReplayCompleted = true;
await addLog(`步骤 7:已拿到登录验证码 ${result.code},先刷新 CPA OAuth 链接并重走步骤 6,再回填验证码。`, 'warn');
await rerunStep6ForStep7Recovery({
logMessage: '步骤 7:正在重新获取最新 CPA OAuth 链接,并快速重走步骤 6...',
skipPreLoginCleanup: true,
postStepDelayMs: 1200,
});
await ensureStep7VerificationPageReady();
await addLog('步骤 7:登录验证码页面已重新就绪,开始回填刚才获取到的验证码。', 'info');
} : undefined,
}); });
} }
async function rerunStep6ForStep7Recovery() { async function rerunStep6ForStep7Recovery(options = {}) {
const {
logMessage = '步骤 7:正在回到步骤 6,重新发起登录验证码流程...',
skipPreLoginCleanup = false,
postStepDelayMs = 3000,
} = options;
const currentState = await getState(); const currentState = await getState();
await addLog('步骤 7:正在回到步骤 6,重新发起登录验证码流程...', 'warn'); await addLog(logMessage, 'warn');
await executeStep6(currentState); await executeStep6(currentState, { skipPreLoginCleanup });
await sleepWithStop(3000); if (postStepDelayMs > 0) {
await sleepWithStop(postStepDelayMs);
}
} }
async function executeStep7(state) { async function executeStep7(state) {
+3 -1
View File
@@ -89,7 +89,9 @@
await resolveVerificationStep(4, state, mail, { await resolveVerificationStep(4, state, mail, {
filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
resendIntervalMs: mail.provider === HOTMAIL_PROVIDER ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
}); });
} }
+65 -48
View File
@@ -5,6 +5,7 @@
const { const {
addLog, addLog,
completeStepFromBackground, completeStepFromBackground,
getErrorMessage,
getLoginAuthStateLabel, getLoginAuthStateLabel,
getState, getState,
isStep6RecoverableResult, isStep6RecoverableResult,
@@ -15,10 +16,12 @@
sendToContentScriptResilient, sendToContentScriptResilient,
shouldSkipLoginVerificationForCpaCallback, shouldSkipLoginVerificationForCpaCallback,
skipLoginVerificationStepsForCpaCallback, skipLoginVerificationStepsForCpaCallback,
STEP6_MAX_ATTEMPTS,
throwIfStopped, throwIfStopped,
} = deps; } = deps;
async function executeStep6(state) { async function executeStep6(state, options = {}) {
const { skipPreLoginCleanup = false } = options;
if (shouldSkipLoginVerificationForCpaCallback(state)) { if (shouldSkipLoginVerificationForCpaCallback(state)) {
await skipLoginVerificationStepsForCpaCallback(); await skipLoginVerificationStepsForCpaCallback();
return; return;
@@ -27,63 +30,77 @@
throw new Error('缺少邮箱地址,请先完成步骤 3。'); throw new Error('缺少邮箱地址,请先完成步骤 3。');
} }
await runPreStep6CookieCleanup(); if (!skipPreLoginCleanup) {
await runPreStep6CookieCleanup();
}
let attempt = 0; let attempt = 0;
let lastError = null;
while (true) { while (attempt < STEP6_MAX_ATTEMPTS) {
throwIfStopped(); throwIfStopped();
attempt += 1; attempt += 1;
const currentState = attempt === 1 ? state : await getState(); try {
const password = currentState.password || currentState.customPassword || ''; const currentState = attempt === 1 ? state : await getState();
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); const password = currentState.password || currentState.customPassword || '';
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
if (attempt === 1) { if (attempt === 1) {
await addLog('步骤 6:正在打开最新 OAuth 链接并登录...'); await addLog('步骤 6:正在打开最新 OAuth 链接并登录...');
} else { } else {
await addLog(`步骤 6:上一轮登录未进入验证码页,正在重新发起第 ${attempt} 轮登录尝试...`, 'warn'); await addLog(`步骤 6:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
}
await reuseOrCreateTab('signup-page', oauthUrl);
const result = await sendToContentScriptResilient(
'signup-page',
{
type: 'EXECUTE_STEP',
step: 6,
source: 'background',
payload: {
email: currentState.email,
password,
},
},
{
timeoutMs: 180000,
retryDelayMs: 700,
logMessage: '步骤 6:认证页正在切换,等待页面重新就绪后继续登录...',
} }
);
if (result?.error) { await reuseOrCreateTab('signup-page', oauthUrl);
throw new Error(result.error);
const result = await sendToContentScriptResilient(
'signup-page',
{
type: 'EXECUTE_STEP',
step: 6,
source: 'background',
payload: {
email: currentState.email,
password,
},
},
{
timeoutMs: 180000,
retryDelayMs: 700,
logMessage: '步骤 6:认证页正在切换,等待页面重新就绪后继续登录...',
}
);
if (result?.error) {
throw new Error(result.error);
}
if (isStep6SuccessResult(result)) {
await completeStepFromBackground(6, {
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
});
return;
}
if (isStep6RecoverableResult(result)) {
const reasonMessage = result.message
|| `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 6。`;
throw new Error(reasonMessage);
}
throw new Error('步骤 6:认证页未返回可识别的登录结果。');
} catch (err) {
throwIfStopped(err);
lastError = err;
if (attempt >= STEP6_MAX_ATTEMPTS) {
break;
}
await addLog(`步骤 6:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
} }
if (isStep6SuccessResult(result)) {
await completeStepFromBackground(6, {
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
});
return;
}
if (isStep6RecoverableResult(result)) {
const reasonMessage = result.message
|| `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 6。`;
await addLog(`步骤 6${reasonMessage}`, 'warn');
continue;
}
throw new Error('步骤 6:认证页未返回可识别的登录结果。');
} }
throw new Error(`步骤 6:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
} }
return { executeStep6 }; return { executeStep6 };
+18 -4
View File
@@ -15,6 +15,8 @@
HOTMAIL_PROVIDER, HOTMAIL_PROVIDER,
isStopError, isStopError,
LUCKMAIL_PROVIDER, LUCKMAIL_PROVIDER,
MAIL_2925_VERIFICATION_INTERVAL_MS,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
pollCloudflareTempEmailVerificationCode, pollCloudflareTempEmailVerificationCode,
pollHotmailVerificationCode, pollHotmailVerificationCode,
pollLuckmailVerificationCode, pollLuckmailVerificationCode,
@@ -62,14 +64,15 @@
} }
function getVerificationPollPayload(step, state, overrides = {}) { function getVerificationPollPayload(step, state, overrides = {}) {
const is2925Provider = state?.mailProvider === '2925';
if (step === 4) { if (step === 4) {
return { return {
filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state), filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state),
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'],
targetEmail: state.email, targetEmail: state.email,
maxAttempts: 5, maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
intervalMs: 3000, intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
...overrides, ...overrides,
}; };
} }
@@ -79,8 +82,8 @@
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
targetEmail: state.email, targetEmail: state.email,
maxAttempts: 5, maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
intervalMs: 3000, intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
...overrides, ...overrides,
}; };
} }
@@ -368,6 +371,9 @@
const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER
? getHotmailVerificationPollConfig(step) ? getHotmailVerificationPollConfig(step)
: null; : null;
const beforeSubmit = typeof options.beforeSubmit === 'function'
? options.beforeSubmit
: null;
const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode); const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode);
if (state[stateKey] && !ignorePersistedLastCode) { if (state[stateKey] && !ignorePersistedLastCode) {
rejectedCodes.add(state[stateKey]); rejectedCodes.add(state[stateKey]);
@@ -431,6 +437,14 @@
throwIfStopped(); throwIfStopped();
await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`); await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`);
if (beforeSubmit) {
await beforeSubmit(result, {
attempt,
rejectedCodes: new Set(rejectedCodes),
filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined,
lastResendAt,
});
}
throwIfStopped(); throwIfStopped();
const submitResult = await submitVerificationCode(step, result.code); const submitResult = await submitVerificationCode(step, result.code);
+41
View File
@@ -2,8 +2,11 @@ import email
import html import html
import imaplib import imaplib
import json import json
import os
import re import re
import threading
import time import time
import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from email.header import decode_header from email.header import decode_header
from email.utils import parseaddr, parsedate_to_datetime from email.utils import parseaddr, parsedate_to_datetime
@@ -59,6 +62,9 @@ IMAP_HOST = "outlook.office365.com"
IMAP_PORT = 993 IMAP_PORT = 993
REQUEST_TIMEOUT_SECONDS = 45 REQUEST_TIMEOUT_SECONDS = 45
FETCH_LIMIT_DEFAULT = 5 FETCH_LIMIT_DEFAULT = 5
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
ACCOUNT_LOG_LOCK = threading.Lock()
def json_response(handler, status, payload): def json_response(handler, status, payload):
@@ -113,6 +119,24 @@ def log_info(message):
print(f"[HotmailHelper] {message}", flush=True) print(f"[HotmailHelper] {message}", flush=True)
def append_account_log(email_addr, password, status, recorded_at="", reason=""):
normalized_email = str(email_addr or "").strip()
normalized_password = str(password or "").strip()
normalized_status = str(status or "").strip().lower()
normalized_recorded_at = str(recorded_at or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
normalized_reason = str(reason or "").strip().replace("\r", " ").replace("\n", " ")
if not normalized_email or not normalized_password or not normalized_status:
raise RuntimeError("Missing email/password/status for account log append")
os.makedirs(os.path.dirname(ACCOUNT_LOG_PATH), exist_ok=True)
line = f"{normalized_recorded_at}\t{normalized_email}\t{normalized_password}\t{normalized_status}\t{normalized_reason}\n"
with ACCOUNT_LOG_LOCK:
with open(ACCOUNT_LOG_PATH, "a", encoding="utf-8") as handle:
handle.write(line)
return ACCOUNT_LOG_PATH
def try_refresh_access_token(endpoint, client_id, refresh_token): def try_refresh_access_token(endpoint, client_id, refresh_token):
request_data = { request_data = {
"client_id": client_id, "client_id": client_id,
@@ -600,6 +624,21 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
try: try:
payload = read_json_payload(self) payload = read_json_payload(self)
if self.path == "/append-account-log":
file_path = append_account_log(
payload.get("email"),
payload.get("password"),
payload.get("status"),
payload.get("recordedAt"),
payload.get("reason"),
)
json_response(self, 200, {
"ok": True,
"filePath": file_path,
})
return
email_addr = str(payload.get("email") or "").strip() email_addr = str(payload.get("email") or "").strip()
client_id = str(payload.get("clientId") or "").strip() client_id = str(payload.get("clientId") or "").strip()
refresh_token = str(payload.get("refreshToken") or "").strip() refresh_token = str(payload.get("refreshToken") or "").strip()
@@ -643,12 +682,14 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"}) json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
except Exception as exc: except Exception as exc:
traceback.print_exc()
json_response(self, 500, {"ok": False, "error": str(exc)}) json_response(self, 500, {"ok": False, "error": str(exc)})
def main(): def main():
server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler) server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True) print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
print(f"Account log file: {ACCOUNT_LOG_PATH}", flush=True)
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports account run history module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/account-run-history\.js/);
});
test('account run history module exposes a factory', () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
assert.equal(typeof api?.createAccountRunHistoryHelpers, 'function');
});
test('account run history helper normalizes records and persists without helper upload when local helper is disabled', async () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
let storedHistory = [{ email: 'old@example.com', password: 'old-pass', status: 'success', recordedAt: '2026-04-17T00:00:00.000Z' }];
let fetchCalled = false;
global.fetch = async () => {
fetchCalled = true;
throw new Error('should not call fetch');
};
const helpers = api.createAccountRunHistoryHelpers({
ACCOUNT_RUN_HISTORY_STORAGE_KEY: 'accountRunHistory',
addLog: async () => {},
buildHotmailLocalEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
chrome: {
storage: {
local: {
get: async () => ({ accountRunHistory: storedHistory }),
set: async (payload) => {
storedHistory = payload.accountRunHistory;
},
},
},
},
getErrorMessage: (error) => error?.message || String(error || ''),
getState: async () => ({
email: ' latest@example.com ',
password: ' secret ',
hotmailServiceMode: 'remote',
hotmailLocalBaseUrl: '',
}),
HOTMAIL_SERVICE_MODE_LOCAL: 'local',
normalizeHotmailLocalBaseUrl: (value) => String(value || '').trim(),
});
const record = helpers.buildAccountRunHistoryRecord(
{ email: ' latest@example.com ', password: ' secret ' },
' FAILED ',
' reason '
);
assert.deepStrictEqual(record, {
email: 'latest@example.com',
password: 'secret',
status: 'failed',
recordedAt: record.recordedAt,
reason: 'reason',
});
const appended = await helpers.appendAccountRunRecord('failed', null, 'boom');
assert.equal(appended.email, 'latest@example.com');
assert.equal(appended.status, 'failed');
assert.equal(storedHistory.length, 2);
assert.equal(storedHistory[1].reason, 'boom');
assert.equal(fetchCalled, false);
assert.equal(helpers.shouldAppendAccountRunTextFile({ hotmailServiceMode: 'remote', hotmailLocalBaseUrl: 'http://127.0.0.1:17373' }), false);
});
+103
View File
@@ -0,0 +1,103 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope);
test('step 6 retries up to configured limit and then fails', async () => {
const events = {
cleanupCalls: 0,
refreshCalls: 0,
sendCalls: 0,
completed: 0,
};
const executor = api.createStep6Executor({
addLog: async () => {},
completeStepFromBackground: async () => {
events.completed += 1;
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => {
events.refreshCalls += 1;
return `https://oauth.example/${events.refreshCalls}`;
},
reuseOrCreateTab: async () => {},
runPreStep6CookieCleanup: async () => {
events.cleanupCalls += 1;
},
sendToContentScriptResilient: async () => {
events.sendCalls += 1;
return {
step6Outcome: 'recoverable',
state: 'email_page',
message: '当前仍停留在邮箱页',
};
},
shouldSkipLoginVerificationForCpaCallback: () => false,
skipLoginVerificationStepsForCpaCallback: async () => {},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await assert.rejects(
() => executor.executeStep6({ email: 'user@example.com', password: 'secret' }),
/已重试 2 次,仍未成功/
);
assert.equal(events.cleanupCalls, 1);
assert.equal(events.refreshCalls, 3);
assert.equal(events.sendCalls, 3);
assert.equal(events.completed, 0);
});
test('step 6 can skip pre-login cleanup during step 7 recovery replay', async () => {
const events = {
cleanupCalls: 0,
completedPayloads: [],
};
const executor = api.createStep6Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
events.completedPayloads.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
isStep6RecoverableResult: () => false,
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
reuseOrCreateTab: async () => {},
runPreStep6CookieCleanup: async () => {
events.cleanupCalls += 1;
},
sendToContentScriptResilient: async () => ({
step6Outcome: 'success',
loginVerificationRequestedAt: 123,
}),
shouldSkipLoginVerificationForCpaCallback: () => false,
skipLoginVerificationStepsForCpaCallback: async () => {},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep6(
{ email: 'user@example.com', password: 'secret' },
{ skipPreLoginCleanup: true }
);
assert.equal(events.cleanupCalls, 0);
assert.deepStrictEqual(events.completedPayloads, [
{
step: 6,
payload: { loginVerificationRequestedAt: 123 },
},
]);
});
+127
View File
@@ -0,0 +1,127 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/fetch-login-code.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
test('step 7 refreshes CPA oauth via step 6 replay before submitting verification code', async () => {
const calls = {
ensureReady: 0,
executeStep6: [],
sleep: [],
resolveOptions: null,
};
const executor = api.createStep7Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
ensureStep7VerificationPageReady: async () => {
calls.ensureReady += 1;
return { state: 'verification_page' };
},
executeStep6: async (_state, options = {}) => {
calls.executeStep6.push(options);
},
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getPanelMode: () => 'cpa',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async (_step, _state, _mail, options) => {
calls.resolveOptions = options;
await options.beforeSubmit({ code: '654321' });
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldSkipLoginVerificationForCpaCallback: () => false,
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async (ms) => {
calls.sleep.push(ms);
},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep7({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(typeof calls.resolveOptions.beforeSubmit, 'function');
assert.equal(calls.ensureReady, 2);
assert.deepStrictEqual(calls.executeStep6, [{ skipPreLoginCleanup: true }]);
assert.deepStrictEqual(calls.sleep, [1200]);
assert.equal(calls.resolveOptions.resendIntervalMs, 25000);
});
test('step 7 disables resend interval for 2925 mailbox polling', async () => {
let capturedOptions = null;
const executor = api.createStep7Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
ensureStep7VerificationPageReady: async () => ({ state: 'verification_page' }),
executeStep6: async () => {},
getMailConfig: () => ({
provider: '2925',
label: '2925 邮箱',
source: 'mail-2925',
url: 'https://2925.com',
navigateOnReuse: false,
}),
getPanelMode: () => 'sub2api',
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async (_step, _state, _mail, options) => {
capturedOptions = options;
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldSkipLoginVerificationForCpaCallback: () => false,
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async () => {},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep7({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(capturedOptions.resendIntervalMs, 0);
assert.equal(capturedOptions.beforeSubmit, undefined);
});
+110
View File
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/verification-flow.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
test('verification flow extends 2925 polling window', () => {
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async () => ({}),
sendToMailContentScriptResilient: async () => ({}),
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
const step4Payload = helpers.getVerificationPollPayload(4, { email: 'user@example.com', mailProvider: '2925' });
const step7Payload = helpers.getVerificationPollPayload(7, { email: 'user@example.com', mailProvider: '2925' });
assert.equal(step4Payload.maxAttempts, 15);
assert.equal(step4Payload.intervalMs, 15000);
assert.equal(step7Payload.maxAttempts, 15);
assert.equal(step7Payload.intervalMs, 15000);
});
test('verification flow runs beforeSubmit hook before filling the code', async () => {
const events = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async (_source, message) => {
if (message.type === 'FILL_CODE') {
events.push(['submit', message.payload.code]);
return {};
}
return {};
},
sendToMailContentScriptResilient: async () => ({
code: '654321',
emailTimestamp: 123,
}),
setState: async (payload) => {
events.push(['state', payload.lastLoginCode || payload.lastSignupCode]);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.resolveVerificationStep(
7,
{ email: 'user@example.com', lastLoginCode: null },
{ provider: 'qq', label: 'QQ 邮箱' },
{
beforeSubmit: async (result) => {
events.push(['beforeSubmit', result.code]);
},
}
);
assert.deepStrictEqual(events, [
['beforeSubmit', '654321'],
['submit', '654321'],
['state', '654321'],
['complete', '654321'],
]);
});
+19 -5
View File
@@ -126,7 +126,7 @@
### 4.2 `chrome.storage.local` ### 4.2 `chrome.storage.local`
保存持久配置: 保存持久配置与账号运行历史
- CPA / SUB2API 配置 - CPA / SUB2API 配置
- 邮箱 provider 配置 - 邮箱 provider 配置
@@ -135,6 +135,9 @@
- iCloud 相关偏好 - iCloud 相关偏好
- LuckMail API 配置 - LuckMail API 配置
- 自动运行默认配置 - 自动运行默认配置
- 账号运行历史 `accountRunHistory`
当启用了本地 Hotmail helper 时,账号运行历史还会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 追加写入 `data/account-run-history.txt` 文本文件,便于离线留档。
### 4.3 状态广播 ### 4.3 状态广播
@@ -232,6 +235,11 @@
5. 回填页面 5. 回填页面
6. 若页面拒绝,则重试或回退 6. 若页面拒绝,则重试或回退
补充行为:
- `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 7 的自动重发间隔,减少因邮件延迟过早判负。
- CPA 模式下,Step 7 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 6,降低验证码等待过久导致 OAuth 过期的概率。
### Step 5 ### Step 5
文件: 文件:
@@ -257,7 +265,7 @@
3. 打开最新 OAuth 链接 3. 打开最新 OAuth 链接
4. 登录 4. 登录
5. 确保真正进入验证码页 5. 确保真正进入验证码页
6. 如果未进入验证码页,则按可恢复逻辑重试 6. 如果未进入验证码页,则按可恢复逻辑最多重试 3 次
### Step 8 ### Step 8
@@ -285,7 +293,8 @@
3. 打开相应后台 3. 打开相应后台
4. 提交回调地址 4. 提交回调地址
5. 完成平台侧验证 5. 完成平台侧验证
6. 做成功后的清理与标记 6. 追加账号运行历史成功记录
7. 做成功后的清理与标记
## 7. 邮箱与 provider 链路 ## 7. 邮箱与 provider 链路
@@ -315,6 +324,10 @@
- API 对接 - API 对接
- 本地 helper - 本地 helper
补充:
- 本地 helper 除了收信与验证码读取,还提供账号运行历史文本追加接口。
### 7.3 LuckMail ### 7.3 LuckMail
组成: 组成:
@@ -345,8 +358,9 @@
- 立即停止 - 立即停止
- 当前轮重试 - 当前轮重试
- 下一轮继续 - 下一轮继续
6. 如果配置了线程间隔,则挂计时计划 6. 当前轮最终失败或被停止时,追加账号运行历史记录
7. 所有轮次结束后输出汇总 7. 如果配置了线程间隔,则挂计时计划
8. 所有轮次结束后输出汇总
## 9. 新增功能时最容易漏掉的地方 ## 9. 新增功能时最容易漏掉的地方
+7 -2
View File
@@ -38,6 +38,7 @@
## `background/` ## `background/`
- `background/account-run-history.js`:账号运行历史模块,负责将成功/失败/停止结果持久化到 `chrome.storage.local`,并在启用本地 Hotmail helper 时追加写入文本日志。
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑。
- `background/generated-email-helpers.js`:生成邮箱辅助层,封装 Duck、Cloudflare、Cloudflare Temp Email、iCloud 隐私邮箱的获取逻辑。 - `background/generated-email-helpers.js`:生成邮箱辅助层,封装 Duck、Cloudflare、Cloudflare Temp Email、iCloud 隐私邮箱的获取逻辑。
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。
@@ -46,7 +47,7 @@
- `background/panel-bridge.js`CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 - `background/panel-bridge.js`CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱。
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列。
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退自定义邮箱跳过逻辑 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退自定义邮箱跳过以及 2925 长轮询参数
## `background/steps/` ## `background/steps/`
@@ -101,7 +102,7 @@
## `scripts/` ## `scripts/`
- `scripts/hotmail_helper.py`:本地 Hotmail helper 服务,负责通过本地接口协助获取邮件和验证码。 - `scripts/hotmail_helper.py`:本地 Hotmail helper 服务,负责通过本地接口协助获取邮件和验证码,并可追加写入账号运行历史文本日志
## `sidepanel/` ## `sidepanel/`
@@ -119,6 +120,7 @@
- `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文。 - `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文。
- `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
- `tests/background-account-run-history-module.test.js`:测试账号运行历史模块已接入、导出工厂,并能在不启用本地 helper 时正确持久化记录。
- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。 - `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。
- `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。 - `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。
- `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。 - `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。
@@ -129,6 +131,8 @@
- `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。 - `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。
- `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。 - `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。
- `tests/background-step-modules.test.js`:测试步骤模块文件都已由后台入口加载。 - `tests/background-step-modules.test.js`:测试步骤模块文件都已由后台入口加载。
- `tests/background-step6-retry-limit.test.js`:测试 Step 6 的有限重试上限,以及 Step 7 回放时可跳过登录前 Cookie 清理。
- `tests/background-step7-recovery.test.js`:测试 Step 7 在 CPA 模式下会先回放 Step 6 再提交登录验证码,并为 2925 关闭重发间隔。
- `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。 - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂。 - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂。
- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
@@ -157,3 +161,4 @@
- `tests/step9-cpa-mode.test.js`:测试本地 CPA Step 9 策略判断。 - `tests/step9-cpa-mode.test.js`:测试本地 CPA Step 9 策略判断。
- `tests/step9-localhost-cleanup-scope.test.js`:测试 Step 9 仅清理精确命中的 localhost callback 和路径前缀残留页。 - `tests/step9-localhost-cleanup-scope.test.js`:测试 Step 9 仅清理精确命中的 localhost callback 和路径前缀残留页。
- `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。 - `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。
- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数,以及验证码提交流程中的 `beforeSubmit` 钩子执行顺序。