diff --git a/README.md b/README.md index d52dff2..d4adf4d 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 使用第 2 步已经确定好的邮箱 - 使用自定义密码或自动生成密码 - 在密码页填写密码并提交注册表单 -- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 +- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 实际使用的密码会写入会话状态,并同步到侧边栏显示。 @@ -499,9 +499,10 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。 -进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,回到密码页重新提交,再继续等待验证码页面。 +进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果注册认证流程出现 `糟糕,出错了 / 操作超时(Operation timed out)`,或 `/email-verification` 上的 `405 / Route Error` 且带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,必要时回到密码页重新提交,再继续等待验证码页面。 在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。 +但如果 Step 4 的认证重试页正文里出现 `user_already_exists`,则会直接判定“当前用户已存在”,不会点击 `重试`,而是立即结束当前轮;开启自动重试时会直接继续下一轮。 支持: diff --git a/background.js b/background.js index 0523bd9..ecd7085 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,7 @@ importScripts( 'managed-alias-utils.js', 'background/account-run-history.js', + 'background/contribution-oauth.js', 'background/panel-bridge.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', @@ -175,6 +176,25 @@ const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; +const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { + contributionMode: false, + contributionModeExpected: false, + contributionNickname: '', + contributionQq: '', + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, +}; +const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS + || Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -276,6 +296,7 @@ const PRE_LOGIN_COOKIE_CLEAR_ORIGINS = [ const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 @@ -321,6 +342,7 @@ const DEFAULT_STATE = { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, currentHotmailAccountId: null, preferredIcloudHost: '', }; @@ -1117,6 +1139,67 @@ async function setPasswordState(password) { broadcastDataUpdate({ password }); } +function buildContributionModeState(enabled, persistedSettings = {}, currentState = {}) { + const currentContributionState = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + currentContributionState[key] = currentState[key] !== undefined + ? currentState[key] + : CONTRIBUTION_RUNTIME_DEFAULTS[key]; + } + + if (enabled) { + return { + ...currentContributionState, + contributionMode: true, + contributionModeExpected: true, + panelMode: 'cpa', + customPassword: '', + accountRunHistoryTextEnabled: false, + }; + } + + return { + ...CONTRIBUTION_RUNTIME_DEFAULTS, + contributionMode: false, + contributionModeExpected: false, + panelMode: persistedSettings.panelMode || DEFAULT_STATE.panelMode, + customPassword: persistedSettings.customPassword || '', + accountRunHistoryTextEnabled: Boolean(persistedSettings.accountRunHistoryTextEnabled), + }; +} + +async function setContributionMode(enabled) { + const normalizedEnabled = Boolean(enabled); + const [persistedSettings, currentState] = await Promise.all([ + getPersistedSettings(), + getState(), + ]); + + if (normalizedEnabled) { + await setPersistentSettings({ panelMode: 'cpa' }); + } + + const updates = buildContributionModeState(normalizedEnabled, { + ...persistedSettings, + ...(normalizedEnabled ? { panelMode: 'cpa' } : {}), + }, currentState); + + await setState(updates); + const nextState = await getState(); + const contributionBroadcast = {}; + for (const key of CONTRIBUTION_RUNTIME_KEYS) { + contributionBroadcast[key] = nextState[key]; + } + broadcastDataUpdate({ + ...contributionBroadcast, + panelMode: nextState.panelMode, + customPassword: nextState.customPassword, + accountRunHistoryTextEnabled: nextState.accountRunHistoryTextEnabled, + accountRunHistoryHelperBaseUrl: nextState.accountRunHistoryHelperBaseUrl, + }); + return nextState; +} + function getLuckmailUsedPurchases(state = {}) { return normalizeLuckmailUsedPurchases(state?.luckmailUsedPurchases); } @@ -1267,15 +1350,21 @@ async function resetState() { 'luckmailPreserveTagId', 'luckmailPreserveTagName', 'preferredIcloudHost', + ...CONTRIBUTION_RUNTIME_KEYS, ]), getPersistedSettings(), getPersistedAliasState(), ]); + const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), { + ...persistedSettings, + ...(prev.contributionMode ? { panelMode: 'cpa' } : {}), + }, prev); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, + ...contributionModeState, seenCodes: prev.seenCodes || [], seenInbucketMailIds: prev.seenInbucketMailIds || [], accounts: prev.accounts || [], @@ -3932,6 +4021,14 @@ function isRestartCurrentAttemptError(error) { return /当前邮箱已存在,需要重新开始新一轮/.test(message); } +function isSignupUserAlreadyExistsFailure(error) { + if (typeof loggingStatus !== 'undefined' && loggingStatus?.isSignupUserAlreadyExistsFailure) { + return loggingStatus.isSignupUserAlreadyExistsFailure(error); + } + const message = getErrorMessage(error); + return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); +} + function isStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_RETRY::/i.test(message) @@ -3978,6 +4075,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -3990,6 +4088,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4001,6 +4100,7 @@ function getDownstreamStateResets(step) { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -4011,6 +4111,7 @@ function getDownstreamStateResets(step) { lastLoginCode: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, localhostUrl: null, }; } @@ -4067,6 +4168,79 @@ function getRunningSteps(statuses = {}) { .sort((a, b) => a - b); } +function inferStoppedRecordStep(state = {}) { + const statuses = { ...DEFAULT_STATE.stepStatuses, ...(state?.stepStatuses || {}) }; + const stepIds = Object.keys(statuses) + .map((step) => Number(step)) + .filter(Number.isFinite) + .sort((left, right) => left - right); + + const runningSteps = stepIds.filter((step) => statuses[step] === 'running'); + if (runningSteps.length) { + return runningSteps[0]; + } + + const hasProgress = stepIds.some((step) => statuses[step] !== 'pending'); + if (!hasProgress) { + return null; + } + + for (const step of stepIds) { + const status = statuses[step] || 'pending'; + if (!(status === 'completed' || status === 'manual_completed' || status === 'skipped')) { + return step; + } + } + + return null; +} + +function resolveAccountRunRecordStatusForStop(status, state = {}) { + const normalizedStatus = String(status || '').trim().toLowerCase(); + if (normalizedStatus === 'stopped') { + const inferredStep = inferStoppedRecordStep(state); + if (Number.isInteger(inferredStep) && inferredStep > 0) { + return `step${inferredStep}_stopped`; + } + } + return status; +} + +function extractStoppedStepFromRecordStatus(status = '') { + const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_stopped$/); + if (!match) { + return null; + } + const step = Number(match[1]); + return Number.isInteger(step) && step > 0 ? step : null; +} + +function resolveAccountRunRecordReasonForStop(status, reason = '') { + const text = String(reason || '').trim(); + const stoppedStep = extractStoppedStepFromRecordStatus(status); + + if (!stoppedStep) { + if (!text || text === STOP_ERROR_MESSAGE || /^流程已被用户停止。?$/.test(text)) { + return '流程已停止。'; + } + return text; + } + + if (!text || text === STOP_ERROR_MESSAGE || /^流程已被用户停止。?$/.test(text)) { + return `步骤 ${stoppedStep} 已被用户停止。`; + } + + if (/流程尚未完成/.test(text) || /已使用邮箱/.test(text)) { + return `步骤 ${stoppedStep} 已停止:邮箱已设置,流程尚未完成。`; + } + + if (/步骤\s*\d+\s*已(?:被用户)?停止/.test(text)) { + return text.replace(/步骤\s*\d+/, `步骤 ${stoppedStep}`); + } + + return text; +} + function getAutoRunStatusPayload(phase, payload = {}) { const normalizedPayload = { ...payload, @@ -4542,7 +4716,11 @@ async function skipStep(step) { return { ok: true, step, status: 'skipped' }; } -function throwIfStopped() { +function throwIfStopped(error = null) { + const errorMessage = typeof error === 'string' ? error : error?.message; + if (errorMessage === STOP_ERROR_MESSAGE) { + throw error instanceof Error ? error : new Error(STOP_ERROR_MESSAGE); + } if (stopRequested) { throw new Error(STOP_ERROR_MESSAGE); } @@ -4726,6 +4904,7 @@ async function handleStepData(step, payload) { await setState({ localhostUrl: payload.localhostUrl, oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, }); broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); } @@ -4923,6 +5102,59 @@ async function waitForRunningStepsToFinish(payload = {}) { return currentState; } +const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]); +let activeTopLevelAuthChainExecution = null; + +function isAuthChainStep(step) { + return AUTH_CHAIN_STEP_IDS.has(Number(step)); +} + +async function acquireTopLevelAuthChainExecution(step) { + const normalizedStep = Number(step); + if (!isAuthChainStep(normalizedStep)) { + return { + joined: false, + release() {}, + }; + } + + if (activeTopLevelAuthChainExecution) { + const activeExecution = activeTopLevelAuthChainExecution; + await addLog( + `步骤 ${normalizedStep}:检测到步骤 ${activeExecution.step} 正在运行,本次请求将复用当前授权链,不再重复启动。`, + 'warn' + ); + const result = await activeExecution.promise; + if (result?.error) { + throw result.error; + } + return { + joined: true, + release() {}, + }; + } + + let settleExecution = () => {}; + const promise = new Promise((resolve) => { + settleExecution = (error = null) => resolve({ error }); + }); + const execution = { + step: normalizedStep, + promise, + }; + activeTopLevelAuthChainExecution = execution; + + return { + joined: false, + release(error = null) { + if (activeTopLevelAuthChainExecution === execution) { + activeTopLevelAuthChainExecution = null; + } + settleExecution(error); + }, + }; +} + async function markRunningStepsStopped() { const state = await getState(); const runningSteps = getRunningSteps(state.stepStatuses); @@ -4935,6 +5167,8 @@ async function markRunningStepsStopped() { async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; const state = await getState(); + const runningSteps = getRunningSteps(state.stepStatuses); + const inferredStopStep = inferStoppedRecordStep(state); const timerPlan = getPendingAutoRunTimerPlan(state); if (timerPlan?.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START && !autoRunActive) { @@ -4982,6 +5216,10 @@ async function requestStop(options = {}) { await addLog(logMessage, 'warn'); await broadcastStopToContentScripts(); + if (!runningSteps.length && Number.isInteger(inferredStopStep) && inferredStopStep > 0) { + await appendAndBroadcastAccountRunRecord('stopped', state, STOP_ERROR_MESSAGE); + } + for (const waiter of stepWaiters.values()) { waiter.reject(new Error(STOP_ERROR_MESSAGE)); } @@ -5013,21 +5251,29 @@ async function requestStop(options = {}) { async function executeStep(step, options = {}) { const { deferRetryableTransportError = false } = options; console.log(LOG_PREFIX, `Executing step ${step}`); - throwIfStopped(); - await setStepStatus(step, 'running'); - await addLog(`步骤 ${step} 开始执行`); - await humanStepDelay(); - - const state = await getState(); - - // Set flow start time on first step - if (step === 1 && !state.flowStartTime) { - await setState({ flowStartTime: Date.now() }); + const authChainClaim = await acquireTopLevelAuthChainExecution(step); + if (authChainClaim.joined) { + return; } + let executionError = null; + throwIfStopped(); try { + await setStepStatus(step, 'running'); + await addLog(`步骤 ${step} 开始执行`); + await humanStepDelay(); + + const state = await getState(); + + // Set flow start time on first step + if (step === 1 && !state.flowStartTime) { + await setState({ flowStartTime: Date.now() }); + } + await stepRegistry.executeStep(step, state); } catch (err) { + executionError = err; + const state = await getState(); if (isStopError(err)) { await setStepStatus(step, 'stopped'); await addLog(`步骤 ${step} 已被用户停止`, 'warn'); @@ -5049,6 +5295,8 @@ async function executeStep(step, options = {}) { ); } throw err; + } finally { + authChainClaim.release(executionError); } } @@ -5195,6 +5443,15 @@ const accountRunHistoryHelpers = self.MultiPageBackgroundAccountRunHistory?.crea getState, normalizeAccountRunHistoryHelperBaseUrl, }); +const contributionOAuthManager = self.MultiPageBackgroundContributionOAuth?.createContributionOAuthManager({ + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, +}); +contributionOAuthManager?.ensureCallbackListeners?.(); async function broadcastAccountRunHistoryUpdate() { if (!accountRunHistoryHelpers?.getPersistedAccountRunHistory) { @@ -5211,7 +5468,10 @@ async function appendAndBroadcastAccountRunRecord(status, stateOverride = null, return null; } - const record = await accountRunHistoryHelpers.appendAccountRunRecord(status, stateOverride, reason); + const state = stateOverride || await getState(); + const resolvedStatus = resolveAccountRunRecordStatusForStop(status, state); + const resolvedReason = resolveAccountRunRecordReasonForStop(resolvedStatus, reason); + const record = await accountRunHistoryHelpers.appendAccountRunRecord(resolvedStatus, state, resolvedReason); if (!record) { return null; } @@ -5262,6 +5522,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR hasSavedProgress, isAddPhoneAuthFailure, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, @@ -5569,6 +5830,9 @@ async function runAutoSequenceFromStep(startStep, context = {}) { } if (step === 4) { + if (isSignupUserAlreadyExistsFailure(err)) { + throw err; + } step4RestartCount += 1; const preservedState = await getState(); const preservedEmail = String(preservedState.email || '').trim(); @@ -5884,7 +6148,6 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ CLOUDFLARE_TEMP_EMAIL_PROVIDER, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, - executeStep7: (...args) => executeStep7(...args), getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getPanelMode, @@ -5896,9 +6159,9 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep: verificationFlowHelpers.resolveVerificationStep, + rerunStep7ForStep8Recovery: (...args) => rerunStep7ForStep8Recovery(...args), reuseOrCreateTab, setState, - setStepStatus, shouldUseCustomRegistrationEmail, sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, @@ -5934,7 +6197,7 @@ const stepExecutorsByKey = { 'oauth-login': (state) => step7Executor.executeStep7(state), 'fetch-login-code': (state) => step8Executor.executeStep8(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), - 'platform-verify': (state) => step10Executor.executeStep10(state), + 'platform-verify': (state) => executeStep10(state), }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, @@ -6007,6 +6270,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -6019,7 +6283,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow: (...args) => contributionOAuthManager?.startContributionFlow?.(...args), startAutoRunLoop, + pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, testHotmailAccountMailAccess, upsertHotmailAccount, @@ -6350,7 +6616,24 @@ async function runPreStep6CookieCleanup() { // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { - await addLog(`步骤 7:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); + if (state?.contributionModeExpected && !state?.contributionMode) { + throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 链路。请重新进入贡献模式后再点击自动。'); + } + if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { + await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); + const contributionState = await contributionOAuthManager.startContributionFlow({ + nickname: state.email, + openAuthTab: false, + stateOverride: state, + }); + const oauthUrl = String(contributionState?.contributionAuthUrl || '').trim(); + if (!oauthUrl) { + throw new Error('贡献模式未返回可用的登录地址,请稍后重试。'); + } + await handleStepData(1, { oauthUrl }); + return oauthUrl; + } + await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); await handleStepData(1, refreshResult); @@ -6376,10 +6659,18 @@ function normalizeOAuthFlowDeadlineAt(value) { return Math.floor(numeric); } +function normalizeOAuthFlowSourceUrl(value) { + const normalized = String(value || '').trim(); + return normalized || null; +} + async function startOAuthFlowTimeoutWindow(options = {}) { const step = Number(options.step) || 7; const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS; - await setState({ oauthFlowDeadlineAt: deadlineAt }); + await setState({ + oauthFlowDeadlineAt: deadlineAt, + oauthFlowDeadlineSourceUrl: normalizeOAuthFlowSourceUrl(options.oauthUrl), + }); await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 6 分钟倒计时。`, 'info'); return deadlineAt; } @@ -6389,10 +6680,22 @@ async function getOAuthFlowRemainingMs(options = {}) { const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程'; const state = options.state || await getState(); const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt); + const deadlineSourceUrl = normalizeOAuthFlowSourceUrl(state?.oauthFlowDeadlineSourceUrl); + const currentOauthUrl = normalizeOAuthFlowSourceUrl(options.oauthUrl !== undefined ? options.oauthUrl : state?.oauthUrl); if (!deadlineAt) { return null; } + if (deadlineSourceUrl && currentOauthUrl && deadlineSourceUrl !== currentOauthUrl) { + console.warn(LOG_PREFIX, '[oauth-flow] ignoring stale deadline due to oauth url mismatch', { + step, + actionLabel, + deadlineSourceUrl, + currentOauthUrl, + }); + return null; + } + const remainingMs = deadlineAt - Date.now(); if (remainingMs <= 0) { throw buildOAuthFlowTimeoutError(step, actionLabel); @@ -6538,6 +6841,43 @@ async function ensureStep8VerificationPageReady(options = {}) { throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); } +async function rerunStep7ForStep8Recovery(options = {}) { + const { + logMessage = '步骤 8:正在回到步骤 7,重新发起登录验证码流程...', + postStepDelayMs = 3000, + } = options; + + throwIfStopped(); + const initialState = await getState(); + await addLog(logMessage, 'warn'); + await setStepStatus(7, 'running'); + await addLog('步骤 7 开始执行'); + + try { + await step7Executor.executeStep7(initialState); + } catch (err) { + const latestState = await getState(); + if (isStopError(err)) { + await setStepStatus(7, 'stopped'); + await addLog('步骤 7 已被用户停止', 'warn'); + await appendManualAccountRunRecordIfNeeded('step7_stopped', latestState, getErrorMessage(err)); + throw err; + } + if (isTerminalSecurityBlockedError(err)) { + await handleCloudflareSecurityBlocked(err); + throw new Error(STOP_ERROR_MESSAGE); + } + await setStepStatus(7, 'failed'); + await addLog(`步骤 7 失败:${getErrorMessage(err)}`, 'error'); + await appendManualAccountRunRecordIfNeeded('step7_failed', latestState, getErrorMessage(err)); + throw err; + } + + if (postStepDelayMs > 0) { + await sleepWithStop(postStepDelayMs); + } +} + async function executeStep6() { return step6Executor.executeStep6(); } @@ -6954,7 +7294,76 @@ async function executeStep9(state) { // Step 10: 平台回调验证 // ============================================================ +async function executeContributionStep10(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + } + if (!state.contributionSessionId) { + throw new Error('缺少贡献会话信息,请重新从步骤 7 开始。'); + } + if (!contributionOAuthManager?.pollContributionStatus) { + throw new Error('贡献 OAuth 流程尚未接入,无法完成贡献模式的步骤 10。'); + } + + await addLog('步骤 10:贡献模式正在提交回调并等待最终结果...'); + + let latestState = await getState(); + const callbackUrl = latestState.localhostUrl || state.localhostUrl; + + if (!latestState.contributionCallbackUrl && contributionOAuthManager?.handleCapturedCallback) { + latestState = await contributionOAuthManager.handleCapturedCallback(callbackUrl, { + source: 'step10', + }); + } else { + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_initial', + stateOverride: latestState, + }); + } + + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(120000, { + step: 10, + actionLabel: '贡献流程最终结果', + }) + : 120000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const status = String(latestState.contributionStatus || '').trim().toLowerCase(); + if (contributionOAuthManager?.isContributionFinalStatus?.(status)) { + if (status === 'auto_approved' || status === 'manual_review_required') { + await addLog(`步骤 10:贡献流程已结束,最终状态:${latestState.contributionStatusMessage || status}`, status === 'auto_approved' ? 'ok' : 'warn'); + await completeStepFromBackground(10, { + contributionStatus: status, + contributionStatusMessage: latestState.contributionStatusMessage || '', + localhostUrl: callbackUrl, + }); + return; + } + throw new Error(latestState.contributionStatusMessage || '贡献流程失败。'); + } + + await sleepWithStop(2500); + latestState = await contributionOAuthManager.pollContributionStatus({ + reason: 'step10_wait_final', + stateOverride: latestState, + }); + } + + throw new Error('步骤 10:等待贡献流程最终结果超时。'); +} + async function executeStep10(state) { + if (state?.contributionModeExpected && !state?.contributionMode) { + throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。'); + } + if (state?.contributionMode) { + return executeContributionStep10(state); + } return step10Executor.executeStep10(state); } diff --git a/background/account-run-history.js b/background/account-run-history.js index a855907..49b4103 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -39,9 +39,9 @@ return ''; } - function extractFailedStep(status = '', detail = '') { + function extractRecordStep(status = '', detail = '') { const normalizedStatus = String(status || '').trim().toLowerCase(); - const statusMatch = normalizedStatus.match(/^step(\d+)_failed$/); + const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/); if (statusMatch) { const step = Number(statusMatch[1]); return Number.isInteger(step) && step > 0 ? step : null; @@ -73,6 +73,9 @@ return '流程完成'; } if (finalStatus === 'stopped') { + if (Number.isInteger(failedStep) && failedStep > 0) { + return `步骤 ${failedStep} 停止`; + } return '流程已停止'; } if (finalStatus !== 'failed') { @@ -152,7 +155,7 @@ const failedStepCandidate = Number(record.failedStep); const failedStep = Number.isInteger(failedStepCandidate) && failedStepCandidate > 0 ? failedStepCandidate - : extractFailedStep(record.finalStatus || record.status || '', failureDetail); + : extractRecordStep(record.finalStatus || record.status || '', failureDetail); const autoRunContext = normalizeAutoRunContext(record.autoRunContext); const retryCount = normalizeRetryCount( record.retryCount !== undefined @@ -160,6 +163,8 @@ : ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0) ); const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual')); + const computedFailureLabel = buildFailureLabel(finalStatus, failedStep, failureDetail); + const rawFailureLabel = String(record.failureLabel || '').trim(); return { recordId: String(record.recordId || '').trim() || buildRecordId(email), @@ -168,7 +173,9 @@ finalStatus, finishedAt, retryCount, - failureLabel: String(record.failureLabel || '').trim() || buildFailureLabel(finalStatus, failedStep, failureDetail), + failureLabel: finalStatus === 'stopped' + ? computedFailureLabel + : (rawFailureLabel || computedFailureLabel), failureDetail, failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, @@ -215,7 +222,9 @@ } const failureDetail = finalStatus === 'failed' || finalStatus === 'stopped' ? String(reason || '').trim() : ''; - const failedStep = finalStatus === 'failed' ? extractFailedStep(status, failureDetail) : null; + const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' + ? extractRecordStep(status, failureDetail) + : null; const source = Boolean(state.autoRunning) ? 'auto' : 'manual'; const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null; const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0; @@ -322,6 +331,9 @@ } function shouldSyncAccountRunHistorySnapshot(state = {}) { + if (Boolean(state.contributionMode)) { + return false; + } if (!Boolean(state.accountRunHistoryTextEnabled)) { return false; } diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 36cc6d9..1f4d58c 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -23,6 +23,7 @@ hasSavedProgress, isAddPhoneAuthFailure, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, normalizeAutoRunFallbackThreadIntervalMinutes, @@ -458,7 +459,9 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); - const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' + && isSignupUserAlreadyExistsFailure(err); + const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), @@ -499,6 +502,41 @@ break; } + if (blockedBySignupUserAlreadyExists) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因 user_already_exists 已终止。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (canRetry) { const retryIndex = attemptRun; if (isRestartCurrentAttemptError(err)) { diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js new file mode 100644 index 0000000..d5fc80d --- /dev/null +++ b/background/contribution-oauth.js @@ -0,0 +1,689 @@ +(function attachBackgroundContributionOAuth(root, factory) { + root.MultiPageBackgroundContributionOAuth = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() { + const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const CALLBACK_FINAL_STATUSES = new Set(['submitted']); + const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); + + const RUNTIME_DEFAULTS = { + contributionMode: false, + contributionModeExpected: false, + contributionNickname: '', + contributionQq: '', + contributionSessionId: '', + contributionAuthUrl: '', + contributionAuthState: '', + contributionCallbackUrl: '', + contributionStatus: '', + contributionStatusMessage: '', + contributionLastPollAt: 0, + contributionCallbackStatus: 'idle', + contributionCallbackMessage: '', + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }; + + const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS); + + function createContributionOAuthManager(deps = {}) { + const { + addLog, + broadcastDataUpdate, + chrome, + closeLocalhostCallbackTabs, + getState, + setState, + } = deps; + + let listenersBound = false; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizePositiveInteger(value, fallback = 0) { + const numeric = Math.floor(Number(value) || 0); + return numeric > 0 ? numeric : fallback; + } + + function normalizeContributionStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'started': + return 'started'; + case 'waiting': + case 'wait': + return 'waiting'; + case 'processing': + return 'processing'; + case 'auto_approved': + case 'approved': + return 'auto_approved'; + case 'auto_rejected': + case 'rejected': + return 'auto_rejected'; + case 'manual_review_required': + case 'manual_review': + return 'manual_review_required'; + case 'expired': + case 'timeout': + return 'expired'; + case 'error': + case 'failed': + return 'error'; + default: + return ''; + } + } + + function normalizeContributionCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'idle': + return 'idle'; + case 'waiting': + case 'pending': + return 'waiting'; + case 'captured': + return 'captured'; + case 'submitting': + case 'processing': + return 'submitting'; + case 'submitted': + case 'success': + case 'done': + return 'submitted'; + case 'failed': + case 'error': + return 'failed'; + default: + return ''; + } + } + + function isContributionFinalStatus(status = '') { + return FINAL_STATUSES.has(normalizeContributionStatus(status)); + } + + function getStatusLabel(status = '') { + switch (normalizeContributionStatus(status)) { + case 'started': + return '已生成登录地址'; + case 'waiting': + return '等待提交回调'; + case 'processing': + return '已提交回调,等待 CPA 确认'; + case 'auto_approved': + return '贡献成功,CPA 已确认'; + case 'auto_rejected': + return '贡献未通过确认'; + case 'manual_review_required': + return '已提交,等待人工处理'; + case 'expired': + return '贡献会话已超时'; + case 'error': + return '贡献流程失败'; + default: + return '等待开始贡献'; + } + } + + function getCallbackLabel(status = '') { + switch (normalizeContributionCallbackStatus(status)) { + case 'waiting': + case 'idle': + return '等待回调'; + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'failed': + return '回调提交失败'; + default: + return '等待回调'; + } + } + + function unwrapPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return {}; + } + + if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) { + return { ...payload.data, ...payload }; + } + + return payload; + } + + function getErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.message, + payload?.detail, + payload?.error, + payload?.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (details) { + return details; + } + + return `贡献服务请求失败(HTTP ${responseStatus})。`; + } + + async function fetchContributionJson(endpoint, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + let rawPayload = {}; + try { + rawPayload = await response.json(); + } catch { + rawPayload = {}; + } + + const payload = unwrapPayload(rawPayload); + if (!response.ok || payload.ok === false) { + const error = new Error(getErrorMessage(payload, response.status)); + error.payload = payload; + error.responseStatus = response.status; + throw error; + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('贡献服务请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + function pickContributionState(state = {}) { + const picked = {}; + for (const key of RUNTIME_KEYS) { + picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key]; + } + return picked; + } + + async function applyRuntimeUpdates(updates = {}) { + if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) { + return getState(); + } + + await setState(updates); + broadcastDataUpdate(updates); + return getState(); + } + + function extractAuthStateFromUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function buildNickname(state = {}, preferredNickname = '') { + const nickname = normalizeString(preferredNickname) + || normalizeString(state.email) + || normalizeString(state.contributionNickname); + return nickname || 'codex-extension-user'; + } + + function buildContributionQq(state = {}, preferredQq = '') { + const qq = normalizeString(preferredQq) || normalizeString(state.contributionQq); + return qq; + } + + function buildStatusMessage(status, payload = {}) { + const label = getStatusLabel(status); + const details = [ + payload.status_message, + payload.statusMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function buildCallbackMessage(status, payload = {}) { + const label = getCallbackLabel(status); + const details = [ + payload.callback_message, + payload.callbackMessage, + payload.message, + payload.detail, + payload.reason, + ] + .map((item) => normalizeString(item)) + .find(Boolean); + + if (!details || details === label) { + return label; + } + + return `${label}:${details}`; + } + + function deriveCallbackState(payload = {}, state = {}) { + const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus); + const callbackUrl = normalizeString( + payload.callback_url + || payload.callbackUrl + || state.contributionCallbackUrl + ); + const explicitStatus = normalizeContributionCallbackStatus( + payload.callback_status + || payload.callbackStatus + ); + + if (explicitStatus) { + return { + status: explicitStatus, + message: buildCallbackMessage(explicitStatus, payload), + callbackUrl, + }; + } + + if (payload.callback_submitted === true || payload.callbackSubmitted === true) { + return { + status: 'submitted', + message: buildCallbackMessage('submitted', payload), + callbackUrl, + }; + } + + if (callbackUrl) { + return { + status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', + message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload), + callbackUrl, + }; + } + + if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') { + return { + status: existingStatus, + message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus), + callbackUrl: normalizeString(state.contributionCallbackUrl), + }; + } + + return { + status: 'waiting', + message: buildCallbackMessage('waiting', payload), + callbackUrl: '', + }; + } + + function isContributionCallbackUrl(rawUrl, state = {}) { + const urlText = normalizeString(rawUrl); + if (!urlText) { + return false; + } + + let parsed; + try { + parsed = new URL(urlText); + } catch { + return false; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + + const code = normalizeString(parsed.searchParams.get('code')); + const errorText = normalizeString(parsed.searchParams.get('error')) + || normalizeString(parsed.searchParams.get('error_description')); + const authState = normalizeString(parsed.searchParams.get('state')); + if ((!code && !errorText) || !authState) { + return false; + } + + const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname); + const pathLooksLikeCallback = /callback/i.test(parsed.pathname || ''); + if (!hostLooksLocal && !pathLooksLikeCallback) { + return false; + } + + const expectedState = normalizeString(state.contributionAuthState); + return !expectedState || expectedState === authState; + } + + async function openContributionAuthUrl(authUrl, options = {}) { + const normalizedUrl = normalizeString(authUrl); + if (!normalizedUrl) { + throw new Error('贡献服务未返回有效的登录地址。'); + } + + const currentState = options.stateOverride || await getState(); + const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0); + let tab = null; + + if (preferredTabId) { + tab = await chrome.tabs.update(preferredTabId, { + url: normalizedUrl, + active: true, + }).catch(() => null); + } + + if (!tab) { + tab = await chrome.tabs.create({ url: normalizedUrl, active: true }); + } + + await applyRuntimeUpdates({ + contributionAuthUrl: normalizedUrl, + contributionAuthOpenedAt: Date.now(), + contributionAuthTabId: normalizePositiveInteger(tab?.id, 0), + }); + + return tab; + } + + async function fetchContributionResult(sessionId) { + try { + return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`); + } catch (error) { + if (typeof addLog === 'function') { + await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn'); + } + return null; + } + } + + async function submitContributionCallback(callbackUrl, options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + const normalizedUrl = normalizeString(callbackUrl); + + if (!sessionId || !normalizedUrl) { + return currentState; + } + + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'submitting', + contributionCallbackMessage: buildCallbackMessage('submitting'), + }); + + try { + const payload = await fetchContributionJson('/submit-callback', { + method: 'POST', + body: { + session_id: sessionId, + callback_url: normalizedUrl, + }, + }); + + const nextStatus = 'submitted'; + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: nextStatus, + contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); + } catch (error) { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'failed', + contributionCallbackMessage: `回调提交失败:${error.message}`, + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); + } + + throw error; + } + } + + async function handleCapturedCallback(rawUrl, metadata = {}) { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(rawUrl, currentState)) { + return currentState; + } + + const normalizedUrl = normalizeString(rawUrl); + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } + + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'captured', + contributionCallbackMessage: buildCallbackMessage('captured'), + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info'); + } + + try { + return await submitContributionCallback(normalizedUrl, { + reason: metadata.source || 'navigation', + stateOverride: await getState(), + }); + } catch { + return getState(); + } + } + + async function pollContributionStatus(options = {}) { + const currentState = options.stateOverride || await getState(); + const sessionId = normalizeString(currentState.contributionSessionId); + if (!sessionId) { + return currentState; + } + + const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`); + const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting'; + let finalPayload = null; + + if (isContributionFinalStatus(nextStatus)) { + finalPayload = await fetchContributionResult(sessionId); + } + + const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload; + const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus; + const callbackState = deriveCallbackState(mergedPayload, currentState); + const updates = { + contributionLastPollAt: Date.now(), + contributionStatus: normalizedStatus, + contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload), + contributionCallbackUrl: callbackState.callbackUrl, + contributionCallbackStatus: callbackState.status, + contributionCallbackMessage: callbackState.message, + }; + + const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl); + if (authUrl) { + updates.contributionAuthUrl = authUrl; + } + + const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState) + || (authUrl ? extractAuthStateFromUrl(authUrl) : ''); + if (authState) { + updates.contributionAuthState = authState; + } + + await applyRuntimeUpdates(updates); + const nextState = await getState(); + + if ( + normalizeString(nextState.contributionCallbackUrl) + && CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus)) + ) { + try { + return await submitContributionCallback(nextState.contributionCallbackUrl, { + reason: options.reason || 'status_poll', + stateOverride: nextState, + }); + } catch { + return getState(); + } + } + + return nextState; + } + + async function startContributionFlow(options = {}) { + const currentState = options.stateOverride || await getState(); + const shouldOpenAuthTab = options.openAuthTab !== false; + if (!currentState.contributionMode) { + throw new Error('请先进入贡献模式。'); + } + + const currentSessionId = normalizeString(currentState.contributionSessionId); + const currentStatus = normalizeContributionStatus(currentState.contributionStatus); + if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) { + if (normalizeString(currentState.contributionAuthUrl)) { + if (shouldOpenAuthTab) { + await openContributionAuthUrl(currentState.contributionAuthUrl, { + stateOverride: currentState, + }).catch(() => null); + } + } + return pollContributionStatus({ reason: 'resume_existing' }); + } + + const payload = await fetchContributionJson('/start', { + method: 'POST', + body: { + nickname: buildNickname(currentState, options.nickname), + qq: buildContributionQq(currentState, options.qq), + source: 'cpa', + channel: 'codex-extension', + }, + }); + + const sessionId = normalizeString(payload.session_id || payload.sessionId); + const authUrl = normalizeString(payload.auth_url || payload.authUrl); + const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl); + if (!sessionId || !authUrl) { + throw new Error('贡献服务未返回有效的 session_id 或 auth_url。'); + } + + await applyRuntimeUpdates({ + contributionSessionId: sessionId, + contributionAuthUrl: authUrl, + contributionAuthState: authState, + contributionCallbackUrl: '', + contributionStatus: normalizeContributionStatus(payload.status) || 'started', + contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload), + contributionLastPollAt: 0, + contributionCallbackStatus: 'waiting', + contributionCallbackMessage: buildCallbackMessage('waiting'), + contributionAuthOpenedAt: 0, + contributionAuthTabId: 0, + }); + + if (shouldOpenAuthTab) { + await openContributionAuthUrl(authUrl); + } + return pollContributionStatus({ reason: 'after_start' }); + } + + function onNavigationEvent(details = {}, source) { + if (details?.frameId !== undefined && Number(details.frameId) !== 0) { + return; + } + handleCapturedCallback(details?.url || '', { + source, + tabId: normalizePositiveInteger(details?.tabId, 0), + }).catch(() => {}); + } + + function onTabUpdated(tabId, changeInfo, tab) { + const candidateUrl = normalizeString(changeInfo?.url || tab?.url); + if (!candidateUrl) { + return; + } + handleCapturedCallback(candidateUrl, { + source: 'tabs.onUpdated', + tabId: normalizePositiveInteger(tabId, 0), + }).catch(() => {}); + } + + function ensureCallbackListeners() { + if (listenersBound) { + return; + } + + chrome.webNavigation.onCommitted.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onCommitted'); + }); + chrome.webNavigation.onHistoryStateUpdated.addListener((details) => { + onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated'); + }); + chrome.tabs.onUpdated.addListener(onTabUpdated); + listenersBound = true; + } + + return { + ensureCallbackListeners, + handleCapturedCallback, + isContributionCallbackUrl, + isContributionFinalStatus, + pollContributionStatus, + startContributionFlow, + submitContributionCallback, + }; + } + + return { + ACTIVE_STATUSES, + FINAL_STATUSES, + RUNTIME_DEFAULTS, + RUNTIME_KEYS, + createContributionOAuthManager, + }; +}); diff --git a/background/logging-status.js b/background/logging-status.js index 0eb9081..47692c1 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -94,6 +94,11 @@ return /当前邮箱已存在,需要重新开始新一轮/.test(message); } + function isSignupUserAlreadyExistsFailure(error) { + const message = getErrorMessage(error); + return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); + } + function isStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_RETRY::/i.test(message) @@ -165,6 +170,7 @@ hasSavedProgress, isLegacyStep9RecoverableAuthError, isRestartCurrentAttemptError, + isSignupUserAlreadyExistsFailure, isStep9RecoverableAuthError, isStepDoneStatus, isVerificationMailPollingError, diff --git a/background/message-router.js b/background/message-router.js index bdcd0be..af4c479 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -57,6 +57,7 @@ notifyStepComplete, notifyStepError, patchHotmailAccount, + pollContributionStatus, registerTab, requestStop, handleCloudflareSecurityBlocked, @@ -65,6 +66,7 @@ scheduleAutoRun, selectLuckmailPurchase, setCurrentHotmailAccount, + setContributionMode, setEmailState, setEmailStateSilently, setIcloudAliasPreservedState, @@ -77,6 +79,7 @@ setStepStatus, skipAutoRunCountdown, skipStep, + startContributionFlow, startAutoRunLoop, syncHotmailAccounts, testHotmailAccountMailAccess, @@ -289,6 +292,70 @@ return { ok: true }; } + case 'SET_CONTRIBUTION_MODE': { + const enabled = Boolean(message.payload?.enabled); + const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。'); + } + if (typeof setContributionMode !== 'function') { + throw new Error('贡献模式切换能力未接入。'); + } + return { + ok: true, + state: await setContributionMode(enabled), + }; + } + + case 'START_CONTRIBUTION_FLOW': { + const state = await ensureManualInteractionAllowed('开始贡献'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error('当前有步骤正在执行,无法开始贡献流程。'); + } + if (typeof startContributionFlow !== 'function') { + throw new Error('贡献 OAuth 流程尚未接入。'); + } + return { + ok: true, + state: await startContributionFlow({ + nickname: message.payload?.nickname, + qq: message.payload?.qq, + }), + }; + } + + case 'SET_CONTRIBUTION_PROFILE': { + const state = await getState(); + if (!state?.contributionMode) { + throw new Error('请先进入贡献模式。'); + } + const nickname = String(message.payload?.nickname || '').trim(); + const qq = String(message.payload?.qq || '').trim(); + if (qq && !/^\d{1,20}$/.test(qq)) { + throw new Error('QQ 只能填写数字,且长度不能超过 20 位。'); + } + await setState({ + contributionNickname: nickname, + contributionQq: qq, + }); + return { + ok: true, + state: await getState(), + }; + } + + case 'POLL_CONTRIBUTION_STATUS': { + if (typeof pollContributionStatus !== 'function') { + throw new Error('贡献状态轮询能力尚未接入。'); + } + return { + ok: true, + state: await pollContributionStatus({ + reason: message.payload?.reason || 'sidepanel_poll', + }), + }; + } + case 'CLEAR_ACCOUNT_RUN_HISTORY': { const state = await getState(); if (isAutoRunLockedState(state)) { @@ -340,6 +407,17 @@ case 'AUTO_RUN': { clearStopRequest(); + if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { + await setContributionMode(true); + if (typeof setState === 'function') { + const contributionNickname = String(message.payload?.contributionNickname || '').trim(); + const contributionQq = String(message.payload?.contributionQq || '').trim(); + await setState({ + contributionNickname, + contributionQq, + }); + } + } const state = await getState(); if (getPendingAutoRunTimerPlan(state)) { throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); @@ -354,6 +432,17 @@ case 'SCHEDULE_AUTO_RUN': { clearStopRequest(); + if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') { + await setContributionMode(true); + if (typeof setState === 'function') { + const contributionNickname = String(message.payload?.contributionNickname || '').trim(); + const contributionQq = String(message.payload?.contributionQq || '').trim(); + await setState({ + contributionNickname, + contributionQq, + }); + } + } const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); return await scheduleAutoRun(totalRuns, { delayMinutes: message.payload?.delayMinutes, diff --git a/background/panel-bridge.js b/background/panel-bridge.js index b25af20..ad97e6c 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -60,7 +60,7 @@ source: 'background', payload: { vpsPassword: state.vpsPassword, - logStep: 6, + logStep: 7, }, }, { timeoutMs: 30000, @@ -121,7 +121,7 @@ sub2apiPassword: state.sub2apiPassword, sub2apiGroupName: groupName, sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - logStep: 6, + logStep: 7, }, }, { responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 0046dd7..fb7c78c 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -8,7 +8,6 @@ CLOUDFLARE_TEMP_EMAIL_PROVIDER, confirmCustomVerificationStepBypass, ensureStep8VerificationPageReady, - executeStep7, getOAuthFlowRemainingMs, getOAuthFlowStepTimeoutMs, getMailConfig, @@ -19,17 +18,16 @@ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep, + rerunStep7ForStep8Recovery, reuseOrCreateTab, setState, - setStepStatus, shouldUseCustomRegistrationEmail, - sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, } = deps; - async function getStep8ReadyTimeoutMs(actionLabel) { + async function getStep8ReadyTimeoutMs(actionLabel, expectedOauthUrl = '') { if (typeof getOAuthFlowStepTimeoutMs !== 'function') { return 15000; } @@ -37,10 +35,11 @@ return getOAuthFlowStepTimeoutMs(15000, { step: 8, actionLabel, + oauthUrl: expectedOauthUrl, }); } - function getStep8RemainingTimeResolver() { + function getStep8RemainingTimeResolver(expectedOauthUrl = '') { if (typeof getOAuthFlowRemainingMs !== 'function') { return undefined; } @@ -48,6 +47,7 @@ return async (details = {}) => getOAuthFlowRemainingMs({ step: 8, actionLabel: details.actionLabel || '登录验证码流程', + oauthUrl: expectedOauthUrl, }); } @@ -60,6 +60,7 @@ if (mail.error) throw new Error(mail.error); const stepStartedAt = Date.now(); + const verificationSessionKey = `8:${stepStartedAt}`; const authTabId = await getTabId('signup-page'); if (authTabId) { @@ -73,7 +74,7 @@ throwIfStopped(); const pageState = await ensureStep8VerificationPageReady({ - timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'), + timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || ''), }); const shouldCompareVerificationEmail = mail.provider !== '2925'; const displayedVerificationEmail = shouldCompareVerificationEmail @@ -130,8 +131,10 @@ ...state, step8VerificationTargetEmail: displayedVerificationEmail || '', }, mail, { - filterAfterTimestamp: stepStartedAt, - getRemainingTimeMs: getStep8RemainingTimeResolver(), + filterAfterTimestamp: mail.provider === '2925' ? 0 : stepStartedAt, + sessionKey: verificationSessionKey, + disableTimeBudgetCap: mail.provider === '2925', + getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || ''), requestFreshCodeFirst: false, targetEmail: fixedTargetEmail, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') @@ -140,19 +143,6 @@ }); } - async function rerunStep7ForStep8Recovery(options = {}) { - const { - logMessage = '步骤 8:正在回到步骤 7,重新发起登录验证码流程...', - postStepDelayMs = 3000, - } = options; - const currentState = await getState(); - await addLog(logMessage, 'warn'); - await executeStep7(currentState); - if (postStepDelayMs > 0) { - await sleepWithStop(postStepDelayMs); - } - } - function isStep8RestartStep7Error(error) { const message = String(error?.message || error || ''); return /STEP8_RESTART_STEP7::/i.test(message); diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index e7fe682..a729946 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -25,6 +25,7 @@ const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); const stepStartedAt = Date.now(); + const verificationSessionKey = `4:${stepStartedAt}`; const signupTabId = await getTabId('signup-page'); if (!signupTabId) { throw new Error('认证页面标签页已关闭,无法继续步骤 4。'); @@ -91,7 +92,9 @@ } await resolveVerificationStep(4, state, mail, { - filterAfterTimestamp: stepStartedAt, + filterAfterTimestamp: mail.provider === '2925' ? 0 : stepStartedAt, + sessionKey: verificationSessionKey, + disableTimeBudgetCap: mail.provider === '2925', requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 60ad2d8..f96caa0 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -48,6 +48,7 @@ ? await getOAuthFlowStepTimeoutMs(180000, { step: 7, actionLabel: 'OAuth 登录并进入验证码页', + oauthUrl, }) : 180000; diff --git a/background/verification-flow.js b/background/verification-flow.js index 76e18b2..f21b143 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -164,9 +164,10 @@ const nextPayload = { ...payload }; const intervalMs = Math.max(1, Number(nextPayload.intervalMs) || 3000); const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1); + const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap); const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel); - if (remainingMs !== null) { + if (!disableTimeBudgetCap && remainingMs !== null) { nextPayload.maxAttempts = Math.max( 1, Math.min(baseMaxAttempts, Math.floor(Math.max(0, remainingMs - 1000) / intervalMs) + 1) @@ -174,7 +175,7 @@ } const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000); - const responseTimeoutMs = remainingMs === null + const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null ? defaultResponseTimeoutMs : Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs)); @@ -236,6 +237,58 @@ return requestedAt; } + function shouldPreclear2925Mailbox(step, mail) { + return mail?.provider === '2925' && (step === 4 || step === 8); + } + + async function clear2925MailboxBeforePolling(step, mail, options = {}) { + if (!shouldPreclear2925Mailbox(step, mail)) { + return; + } + + throwIfStopped(); + await addLog(`步骤 ${step}:开始刷新 2925 邮箱前先清空全部邮件,避免读取旧验证码邮件。`, 'warn'); + + try { + const responseTimeoutMs = await getResponseTimeoutMsForStep( + step, + options, + 15000, + '清空 2925 邮箱历史邮件' + ); + const result = await sendToMailContentScriptResilient( + mail, + { + type: 'DELETE_ALL_EMAILS', + step, + source: 'background', + payload: {}, + }, + { + timeoutMs: responseTimeoutMs, + responseTimeoutMs, + maxRecoveryAttempts: 2, + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (result?.deleted === false) { + await addLog(`步骤 ${step}:未能确认 2925 邮箱已清空,将继续刷新等待新邮件。`, 'warn'); + return; + } + + await addLog(`步骤 ${step}:2925 邮箱已预先清空,开始刷新等待新邮件。`, 'info'); + } catch (err) { + if (isStopError(err)) { + throw err; + } + await addLog(`步骤 ${step}:预清空 2925 邮箱失败,将继续刷新等待新邮件:${err.message}`, 'warn'); + } + } + function triggerPostSuccessMailboxCleanup(step, mail) { if (mail?.provider !== '2925') { return; @@ -564,7 +617,7 @@ getLegacyVerificationResendCountDefault(step, { requestFreshCodeFirst }) ) : getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst }); - const maxSubmitAttempts = 7; + const maxSubmitAttempts = 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); let lastResendAt = Number(options.lastResendAt) || 0; @@ -572,6 +625,8 @@ return nextFilterAfterTimestamp; }; + await clear2925MailboxBeforePolling(step, mail, options); + if (requestFreshCodeFirst) { if (remainingAutomaticResendCount <= 0) { await addLog(`步骤 ${step}:当前自动重新发送验证码次数为 0,将直接使用当前时间窗口轮询邮箱。`, 'info'); @@ -609,6 +664,7 @@ for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) { const pollOptions = { excludeCodes: [...rejectedCodes], + disableTimeBudgetCap: Boolean(options.disableTimeBudgetCap), getRemainingTimeMs: options.getRemainingTimeMs, maxResendRequests: remainingAutomaticResendCount, resendIntervalMs, diff --git a/content/auth-page-recovery.js b/content/auth-page-recovery.js index e046396..63417f8 100644 --- a/content/auth-page-recovery.js +++ b/content/auth-page-recovery.js @@ -15,6 +15,7 @@ isActionEnabled, isVisibleElement, log, + routeErrorPattern = null, simulateClick, sleep, throwIfStopped, @@ -65,9 +66,13 @@ const detailMatched = detailPattern instanceof RegExp ? detailPattern.test(text) : false; + const routeErrorMatched = routeErrorPattern instanceof RegExp + ? routeErrorPattern.test(text) + : false; const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); + const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -78,7 +83,9 @@ retryEnabled: isActionEnabled(retryButton), titleMatched, detailMatched, + routeErrorMatched, maxCheckAttemptsBlocked, + userAlreadyExistsBlocked, }; } @@ -148,6 +155,12 @@ ); } + if (retryState.userAlreadyExistsBlocked) { + throw new Error( + 'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。' + ); + } + if (retryState.retryButton && retryState.retryEnabled) { idlePollCount = 0; clickCount += 1; @@ -199,6 +212,12 @@ ); } + if (finalRetryState.userAlreadyExistsBlocked) { + throw new Error( + 'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。' + ); + } + throw new Error( `${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}` ); diff --git a/content/mail-2925.js b/content/mail-2925.js index 404477c..92ccb2b 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -53,6 +53,10 @@ async function persistSeenCodes() { } function buildSeenCodeSessionKey(step, payload = {}) { + const explicitSessionKey = String(payload?.sessionKey || '').trim(); + if (explicitSessionKey) { + return explicitSessionKey; + } const timestamp = Number(payload?.filterAfterTimestamp); if (Number.isFinite(timestamp) && timestamp > 0) { return `${step}:${timestamp}`; @@ -95,8 +99,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message.type === 'DELETE_ALL_EMAILS') { - Promise.resolve(deleteAllMailboxEmails(message.step)).catch(() => {}); - sendResponse({ ok: true }); + Promise.resolve(deleteAllMailboxEmails(message.step)).then((deleted) => { + sendResponse({ ok: true, deleted }); + }).catch((err) => { + sendResponse({ ok: false, error: err?.message || String(err || '删除邮件失败') }); + }); return true; } @@ -474,6 +481,10 @@ async function openMailAndDeleteAfterRead(item, step) { async function deleteAllMailboxEmails(step) { try { await returnToInbox(); + const initialItems = findMailItems(); + if (initialItems.length === 0) { + return true; + } const selectAllControl = findSelectAllControl(); if (!selectAllControl) { @@ -491,8 +502,15 @@ async function deleteAllMailboxEmails(step) { } simulateClick(deleteButton); + for (let attempt = 0; attempt < 20; attempt += 1) { + await sleep(250); + if (findMailItems().length === 0) { + return true; + } + } + await sleepRandom(200, 500); - return true; + return findMailItems().length === 0; } catch (err) { console.warn(MAIL2925_PREFIX, `Step ${step}: delete-all cleanup failed:`, err?.message || err); return false; @@ -551,11 +569,7 @@ async function handlePollEmail(step, payload) { throw new Error('2925 邮箱列表未加载完成,请确认当前已打开收件箱。'); } - const knownMailIds = getCurrentMailIds(initialItems); log(`步骤 ${step}:邮件列表已加载,共 ${initialItems.length} 封邮件`); - log(`步骤 ${step}:已记录当前 ${knownMailIds.size} 封旧邮件快照`); - - const FALLBACK_AFTER = 3; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(`步骤 ${step}:正在轮询 2925 邮箱,第 ${attempt}/${maxAttempts} 次`); @@ -568,26 +582,10 @@ async function handlePollEmail(step, payload) { const items = findMailItems(); if (items.length > 0) { - const useFallback = attempt > FALLBACK_AFTER; - const newMailIds = new Set(); - - items.forEach((item, index) => { - const itemId = getMailItemId(item, index); - if (!knownMailIds.has(itemId)) { - newMailIds.add(itemId); - } - }); - for (let index = 0; index < items.length; index += 1) { const item = items[index]; - const itemId = getMailItemId(item, index); - const isNewMail = newMailIds.has(itemId); const itemTimestamp = parseMailItemTimestamp(item); - if (!useFallback && !isNewMail) { - continue; - } - const previewText = getMailItemText(item); if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) { continue; @@ -613,21 +611,11 @@ async function handlePollEmail(step, payload) { seenCodes.add(candidateCode); persistSeenCodes(); - const source = useFallback && !isNewMail - ? (bodyCode ? '回退匹配邮件正文' : '回退匹配邮件') - : (bodyCode ? '新邮件正文' : '新邮件'); + const source = bodyCode ? '邮件正文' : '邮件预览'; const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; log(`步骤 ${step}:已找到验证码:${candidateCode}(来源:${source}${timeLabel})`, 'ok'); return { ok: true, code: candidateCode, emailTimestamp: Date.now() }; } - - items.forEach((item, index) => { - knownMailIds.add(getMailItemId(item, index)); - }); - } - - if (attempt === FALLBACK_AFTER + 1) { - log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn'); } if (attempt < maxAttempts) { diff --git a/content/signup-page.js b/content/signup-page.js index 6cbbf07..15d258d 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -253,39 +253,17 @@ async function resendVerificationCode(step, timeout = 45000) { function is405MethodNotAllowedPage() { const pageText = document.body?.textContent || ''; - return /405\s+Method\s+Not\s+Allowed/i.test(pageText) - || /Route\s+Error.*405/i.test(pageText); + return AUTH_ROUTE_ERROR_PATTERN.test(pageText); } async function handle405ResendError(step, remainingTimeout = 30000) { - const start = Date.now(); - let retryCount = 0; - - while (Date.now() - start < remainingTimeout) { - throwIfStopped(); - - if (!is405MethodNotAllowedPage()) { - // Page recovered — back to verification page - log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`); - return; - } - - const retryBtn = getAuthRetryButton(); - if (retryBtn) { - retryCount++; - log(`步骤 ${step}:检测到 405 错误页面,正在点击"Try again"(第 ${retryCount} 次)...`, 'warn'); - await humanPause(300, 800); - simulateClick(retryBtn); - - // Wait 3 seconds before checking again - await sleep(3000); - continue; - } - - await sleep(500); - } - - throw new Error(`步骤 ${step}:405 错误恢复超时,无法返回验证码页面。URL: ${location.href}`); + await recoverCurrentAuthRetryPage({ + logLabel: `步骤 ${step}:检测到 405 错误页面,正在点击“重试”恢复`, + pathPatterns: [], + step, + timeoutMs: Math.max(1000, remainingTimeout), + }); + log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`); } // ============================================================ @@ -759,6 +737,8 @@ const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手 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 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; const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery?.({ @@ -769,6 +749,7 @@ const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery? isActionEnabled, isVisibleElement, log, + routeErrorPattern: AUTH_ROUTE_ERROR_PATTERN, simulateClick, sleep, throwIfStopped, @@ -809,6 +790,12 @@ function getVerificationErrorText() { return messages.find((text) => INVALID_VERIFICATION_CODE_PATTERN.test(text)) || ''; } +function createSignupUserAlreadyExistsError() { + return new Error( + `${SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX}步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。` + ); +} + function isStep5Ready() { return Boolean( document.querySelector('input[name="name"], input[autocomplete="name"], input[name="birthday"], input[name="age"], [role="spinbutton"][data-type="year"]') @@ -1125,9 +1112,11 @@ function getAuthTimeoutErrorPageState(options = {}) { const titleMatched = AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(text) || 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 maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); + const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -1138,16 +1127,33 @@ function getAuthTimeoutErrorPageState(options = {}) { retryEnabled: isActionEnabled(retryButton), titleMatched, detailMatched, + routeErrorMatched, maxCheckAttemptsBlocked, + userAlreadyExistsBlocked, }; } +function getSignupAuthRetryPathPatterns() { + return [ + /\/create-account\/password(?:[/?#]|$)/i, + /\/email-verification(?:[/?#]|$)/i, + ]; +} + +function getLoginAuthRetryPathPatterns() { + return [ + /\/log-in(?:[/?#]|$)/i, + /\/email-verification(?:[/?#]|$)/i, + ]; +} + function getAuthRetryPathPatternsForFlow(flow = 'auth') { switch (flow) { + case 'signup': case 'signup_password': - return [/\/create-account\/password(?:[/?#]|$)/i]; + return getSignupAuthRetryPathPatterns(); case 'login': - return [/\/log-in(?:[/?#]|$)/i]; + return getLoginAuthRetryPathPatterns(); default: return []; } @@ -1164,16 +1170,19 @@ async function recoverCurrentAuthRetryPage(payload = {}) { flow = 'auth', logLabel = '', maxClickAttempts = 5, + pathPatterns = null, step = null, timeoutMs = 12000, waitAfterClickMs = 3000, } = payload; - const pathPatterns = getAuthRetryPathPatternsForFlow(flow); + const resolvedPathPatterns = Array.isArray(pathPatterns) + ? pathPatterns + : getAuthRetryPathPatternsForFlow(flow); if (authPageRecovery?.recoverAuthRetryPage) { return authPageRecovery.recoverAuthRetryPage({ logLabel, maxClickAttempts, - pathPatterns, + pathPatterns: resolvedPathPatterns, step, timeoutMs, waitAfterClickMs, @@ -1187,7 +1196,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { let idlePollCount = 0; while (clickCount < maxClickAttempts) { throwIfStopped(); - const retryState = getCurrentAuthRetryPageState(flow); + const retryState = getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns }); if (!retryState) { return { recovered: clickCount > 0, @@ -1199,6 +1208,9 @@ async function recoverCurrentAuthRetryPage(payload = {}) { if (retryState.maxCheckAttemptsBlocked) { throw new Error('CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'); } + if (retryState.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } if (retryState.retryButton && retryState.retryEnabled) { idlePollCount = 0; clickCount += 1; @@ -1208,7 +1220,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { const settleStart = Date.now(); while (Date.now() - settleStart < waitAfterClickMs) { throwIfStopped(); - if (!getCurrentAuthRetryPageState(flow)) { + if (!getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns })) { return { recovered: true, clickCount, @@ -1228,7 +1240,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { await sleep(250); } - const finalRetryState = getCurrentAuthRetryPageState(flow); + const finalRetryState = getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns }); if (!finalRetryState) { return { recovered: clickCount > 0, @@ -1239,13 +1251,16 @@ async function recoverCurrentAuthRetryPage(payload = {}) { if (finalRetryState.maxCheckAttemptsBlocked) { throw new Error('CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'); } + if (finalRetryState.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } throw new Error(`${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}`); } function getSignupPasswordTimeoutErrorPageState() { return getAuthTimeoutErrorPageState({ - pathPatterns: [/\/create-account\/password(?:[/?#]|$)/i], + pathPatterns: getSignupAuthRetryPathPatterns(), }); } @@ -1556,6 +1571,7 @@ function inspectSignupVerificationState() { return { state: 'error', retryButton: timeoutPage?.retryButton || null, + userAlreadyExistsBlocked: Boolean(timeoutPage?.userAlreadyExistsBlocked), }; } @@ -1629,9 +1645,12 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { recoveryRound += 1; if (snapshot.state === 'error') { + if (snapshot.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } await recoverCurrentAuthRetryPage({ - flow: 'signup_password', - logLabel: `${prepareLogLabel}:检测到密码页超时报错,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`, + flow: 'signup', + logLabel: `${prepareLogLabel}:检测到注册认证重试页,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`, step: 4, timeoutMs: 12000, }); @@ -1675,6 +1694,13 @@ async function waitForVerificationSubmitOutcome(step, timeout) { while (Date.now() - start < resolvedTimeout) { throwIfStopped(); + if (step === 4) { + const signupRetryState = getCurrentAuthRetryPageState('signup'); + if (signupRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + } + const errorText = getVerificationErrorText(); if (errorText) { return { invalidCode: true, errorText }; @@ -1695,6 +1721,13 @@ async function waitForVerificationSubmitOutcome(step, timeout) { await sleep(150); } + if (step === 4) { + const signupRetryState = getCurrentAuthRetryPageState('signup'); + if (signupRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + } + if (isVerificationPageStillVisible()) { return { invalidCode: true, @@ -1797,7 +1830,7 @@ async function fillVerificationCode(step, payload) { } // ============================================================ -// Step 6: Login with registered account (on OAuth auth page) +// Step 7: Login with registered account (on OAuth auth page) // ============================================================ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { @@ -2039,10 +2072,18 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) { async function step6LoginFromPasswordPage(payload, snapshot) { const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + const hasPassword = Boolean(String(payload?.password || '').trim()); if (currentSnapshot.passwordInput) { - if (!payload.password) { - throw new Error('登录时缺少密码,步骤 7 无法继续。'); + if (!hasPassword) { + if (currentSnapshot.switchTrigger) { + log('步骤 7:当前未提供密码,改走一次性验证码登录。', 'warn'); + return step6SwitchToOneTimeCodeLogin(currentSnapshot); + } + + return createStep6RecoverableResult('missing_password_and_one_time_code_trigger', currentSnapshot, { + message: '登录时未提供密码,且当前页面没有可用的一次性验证码登录入口。', + }); } log('步骤 7:已进入密码页,准备填写密码...'); diff --git a/content/vps-panel.js b/content/vps-panel.js index 28b3c4f..4e84d49 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -1,4 +1,4 @@ -// content/vps-panel.js — Content script for CPA panel (steps 1, 9) +// content/vps-panel.js — Content script for CPA panel (steps 7, 10 / OAuth URL request) // Injected on: CPA panel (user-configured URL) // // Actual DOM structure (after login click): diff --git a/manifest.json b/manifest.json index dd2a526..ccbeb41 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "多页面自动化", - "version": "3.3", - "version_name": "Pro3.3", + "version": "4.3", + "version_name": "Pro4.3", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js new file mode 100644 index 0000000..0655a00 --- /dev/null +++ b/sidepanel/contribution-mode.js @@ -0,0 +1,453 @@ +(function attachSidepanelContributionMode(globalScope) { + const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); + const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,并继续等待 CPA 最终确认。'; + + function createContributionModeManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + constants = {}, + } = context; + + const contributionUploadUrl = constants.contributionUploadUrl || 'https://apikey.qzz.io/'; + const pollIntervalMs = Math.max(1500, Math.floor(Number(constants.pollIntervalMs) || 2500)); + + const hiddenRows = [ + dom.rowVpsUrl, + dom.rowVpsPassword, + dom.rowLocalCpaStep9Mode, + dom.rowSub2ApiUrl, + dom.rowSub2ApiEmail, + dom.rowSub2ApiPassword, + dom.rowSub2ApiGroup, + dom.rowSub2ApiDefaultProxy, + dom.rowCustomPassword, + dom.rowAccountRunHistoryTextEnabled, + dom.rowAccountRunHistoryHelperBaseUrl, + ].filter(Boolean); + + let actionInFlight = false; + let pollInFlight = false; + let pollTimer = null; + + function getLatestState() { + return state.getLatestState?.() || {}; + } + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function normalizeStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + if (ACTIVE_STATUSES.has(normalized) || FINAL_STATUSES.has(normalized)) { + return normalized; + } + return ''; + } + + function normalizeCallbackStatus(value = '') { + const normalized = normalizeString(value).toLowerCase(); + switch (normalized) { + case 'waiting': + case 'captured': + case 'submitting': + case 'submitted': + case 'failed': + case 'idle': + return normalized; + default: + return ''; + } + } + + function isContributionModeEnabled(currentState = getLatestState()) { + return Boolean(currentState.contributionMode); + } + + function hasActiveContributionSession(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + return Boolean(normalizeString(currentState.contributionSessionId) && status && !FINAL_STATUSES.has(status)); + } + + function isModeSwitchBlocked() { + return Boolean(helpers.isModeSwitchBlocked?.(getLatestState())); + } + + function setContributionHidden(element, hidden) { + element?.classList.toggle('is-contribution-hidden', hidden); + } + + function syncContributionRows(enabled) { + hiddenRows.forEach((row) => { + setContributionHidden(row, enabled); + }); + } + + function syncContributionButton(enabled, blocked) { + if (!dom.btnContributionMode) { + return; + } + + dom.btnContributionMode.classList.toggle('is-active', enabled); + dom.btnContributionMode.setAttribute('aria-pressed', String(enabled)); + dom.btnContributionMode.disabled = enabled || blocked; + dom.btnContributionMode.title = blocked + ? '当前流程运行中,暂时不能切换贡献模式' + : (enabled ? '当前已在贡献模式' : '进入贡献模式'); + } + + function stopPolling() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function schedulePolling(delayMs = pollIntervalMs) { + stopPolling(); + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + return; + } + + pollTimer = setTimeout(() => { + pollOnce({ silentError: true }).catch(() => {}); + }, delayMs); + } + + function ensurePolling() { + if (!isContributionModeEnabled() || !hasActiveContributionSession()) { + stopPolling(); + return; + } + + if (!pollTimer && !pollInFlight) { + schedulePolling(1200); + } + } + + function getOauthStatusText(currentState = getLatestState()) { + const status = normalizeStatus(currentState.contributionStatus); + const hasAuthUrl = Boolean(normalizeString(currentState.contributionAuthUrl)); + if (!normalizeString(currentState.contributionSessionId) || !hasAuthUrl) { + return '未生成登录地址'; + } + if (status === 'waiting') { + return '等待提交回调'; + } + if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') { + return status === 'processing' ? '已提交回调' : '授权已结束'; + } + if (status === 'expired' || status === 'error') { + return '授权失败'; + } + if (Number(currentState.contributionAuthOpenedAt) > 0) { + return '已打开授权页'; + } + return '登录地址已生成'; + } + + function getCallbackStatusText(currentState = getLatestState()) { + const status = normalizeCallbackStatus(currentState.contributionCallbackStatus); + switch (status) { + case 'captured': + return '已捕获回调地址'; + case 'submitting': + return '正在提交回调'; + case 'submitted': + return '已提交回调'; + case 'failed': + return '回调提交失败'; + case 'waiting': + case 'idle': + default: + return normalizeString(currentState.contributionCallbackUrl) + ? '已捕获回调地址' + : '等待回调'; + } + } + + function getSummaryText(currentState = getLatestState()) { + return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY; + } + + async function syncContributionProfile(partial = {}) { + const payload = { + nickname: normalizeString(partial.nickname), + qq: normalizeString(partial.qq), + }; + const response = await runtime.sendMessage({ + type: 'SET_CONTRIBUTION_PROFILE', + source: 'sidepanel', + payload, + }); + if (response?.error) { + throw new Error(response.error); + } + if (response?.state) { + helpers.applySettingsState?.(response.state); + } + } + + async function requestContributionMode(enabled) { + const response = await runtime.sendMessage({ + type: 'SET_CONTRIBUTION_MODE', + source: 'sidepanel', + payload: { enabled: Boolean(enabled) }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.state) { + throw new Error('贡献模式切换后未返回最新状态。'); + } + + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + render(); + } + + async function pollOnce(options = {}) { + if (pollInFlight || !isContributionModeEnabled() || !hasActiveContributionSession()) { + if (!hasActiveContributionSession()) { + stopPolling(); + } + return; + } + + pollInFlight = true; + try { + const response = await runtime.sendMessage({ + type: 'POLL_CONTRIBUTION_STATUS', + source: 'sidepanel', + payload: { + reason: options.reason || 'sidepanel_poll', + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (response?.state) { + helpers.applySettingsState?.(response.state); + helpers.updateStatusDisplay?.(response.state); + } + } finally { + pollInFlight = false; + render(); + if (hasActiveContributionSession()) { + schedulePolling(); + } else { + stopPolling(); + } + } + } + + async function startContributionFlow() { + if (typeof helpers.startContributionAutoRun !== 'function') { + throw new Error('贡献模式尚未接入主自动流程启动能力。'); + } + + const profile = helpers.getContributionProfile?.() || {}; + const qq = normalizeString(profile.qq); + if (qq && !/^\d{1,20}$/.test(qq)) { + throw new Error('QQ 只能填写数字,且长度不能超过 20 位。'); + } + await syncContributionProfile(profile); + + const started = await helpers.startContributionAutoRun(); + if (!started) { + return; + } + + helpers.showToast?.('贡献自动流程已启动。', 'info', 1800); + render(); + } + + async function enterContributionMode() { + await requestContributionMode(true); + helpers.showToast?.('已进入贡献模式。', 'success', 1800); + } + + async function exitContributionMode() { + stopPolling(); + await requestContributionMode(false); + helpers.showToast?.('已退出贡献模式。', 'info', 1800); + } + + function render() { + const currentState = getLatestState(); + const enabled = isContributionModeEnabled(currentState); + const blocked = isModeSwitchBlocked(); + const activeElement = typeof document !== 'undefined' ? document.activeElement : null; + + if (enabled && dom.selectPanelMode) { + dom.selectPanelMode.value = 'cpa'; + } + + helpers.updatePanelModeUI?.(); + helpers.updateAccountRunHistorySettingsUI?.(); + + if (dom.contributionModePanel) { + dom.contributionModePanel.hidden = !enabled; + } + if (dom.contributionModeText) { + dom.contributionModeText.textContent = DEFAULT_COPY; + } + if (dom.inputContributionNickname && activeElement !== dom.inputContributionNickname) { + const nextNickname = normalizeString(currentState.contributionNickname); + if (nextNickname || !normalizeString(dom.inputContributionNickname.value)) { + dom.inputContributionNickname.value = nextNickname; + } + } + if (dom.inputContributionQq && activeElement !== dom.inputContributionQq) { + const nextQq = normalizeString(currentState.contributionQq); + if (nextQq || !normalizeString(dom.inputContributionQq.value)) { + dom.inputContributionQq.value = nextQq; + } + } + if (dom.contributionOauthStatus) { + dom.contributionOauthStatus.textContent = getOauthStatusText(currentState); + } + if (dom.contributionCallbackStatus) { + dom.contributionCallbackStatus.textContent = getCallbackStatusText(currentState); + } + if (dom.contributionModeSummary) { + dom.contributionModeSummary.textContent = getSummaryText(currentState); + } + + syncContributionRows(enabled); + syncContributionButton(enabled, blocked); + + if (dom.selectPanelMode) { + dom.selectPanelMode.disabled = enabled; + } + + if (dom.btnStartContribution) { + dom.btnStartContribution.disabled = actionInFlight || blocked; + } + + if (dom.btnOpenContributionUpload) { + dom.btnOpenContributionUpload.disabled = false; + } + + if (dom.btnExitContributionMode) { + dom.btnExitContributionMode.disabled = actionInFlight || blocked; + dom.btnExitContributionMode.title = blocked ? '当前流程运行中,暂时不能退出贡献模式' : '退出贡献模式'; + } + + if (dom.btnOpenAccountRecords) { + dom.btnOpenAccountRecords.disabled = enabled; + } + + if (enabled) { + helpers.closeConfigMenu?.(); + helpers.closeAccountRecordsPanel?.(); + ensurePolling(); + } else { + stopPolling(); + } + + helpers.updateConfigMenuControls?.(); + } + + function bindEvents() { + dom.btnContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await enterContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.btnStartContribution?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await startContributionFlow(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + + dom.inputContributionNickname?.addEventListener('change', async () => { + try { + await syncContributionProfile({ + nickname: dom.inputContributionNickname?.value, + qq: dom.inputContributionQq?.value, + }); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + render(); + } + }); + + dom.inputContributionQq?.addEventListener('change', async () => { + try { + await syncContributionProfile({ + nickname: dom.inputContributionNickname?.value, + qq: dom.inputContributionQq?.value, + }); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + render(); + } + }); + + dom.btnOpenContributionUpload?.addEventListener('click', () => { + try { + helpers.openExternalUrl?.(contributionUploadUrl); + } catch (error) { + helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error'); + } + }); + + dom.btnExitContributionMode?.addEventListener('click', async () => { + if (actionInFlight) { + return; + } + actionInFlight = true; + render(); + try { + await exitContributionMode(); + } catch (error) { + helpers.showToast?.(error.message, 'error'); + } finally { + actionInFlight = false; + render(); + } + }); + } + + return { + bindEvents, + pollOnce, + render, + stopPolling, + }; + } + + globalScope.SidepanelContributionMode = { + createContributionModeManager, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 7dd6bf8..938b08e 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -100,7 +100,9 @@ body { header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + flex-wrap: wrap; + gap: 10px; position: sticky; top: 0; z-index: 200; @@ -228,12 +230,19 @@ header { gap: 4px; flex-shrink: 0; flex-wrap: wrap; + justify-content: flex-end; } .btn-contribution-mode { padding-inline: 10px; } +.btn-contribution-mode.is-active { + border-color: color-mix(in srgb, var(--orange) 58%, var(--border)); + background: color-mix(in srgb, var(--orange) 14%, var(--bg-base)); + color: var(--orange); +} + /* ============================================================ Theme Toggle ============================================================ */ @@ -541,6 +550,108 @@ header { pointer-events: none; } +.is-contribution-hidden { + display: none !important; +} + +.contribution-mode-panel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--orange) 28%, var(--border)); + border-radius: var(--radius-md); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--orange) 10%, var(--bg-base)), + color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base)) + ); +} + +.contribution-mode-panel[hidden] { + display: none !important; +} + +.contribution-mode-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.contribution-mode-badge { + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--orange) 16%, var(--bg-base)); + color: var(--orange); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.contribution-mode-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); +} + +.contribution-mode-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.contribution-mode-status-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 92%, var(--orange-soft)); +} + +.contribution-mode-status-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.contribution-mode-status-value { + font-size: 13px; + font-weight: 600; + line-height: 1.5; + color: var(--text-primary); +} + +.contribution-mode-summary { + padding: 10px 12px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 88%, var(--orange-soft)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.contribution-mode-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.contribution-mode-actions .btn { + width: 100%; + justify-content: center; +} + +@media (max-width: 540px) { + .contribution-mode-status-grid { + grid-template-columns: 1fr; + } +} + .section-mini-header { display: flex; align-items: center; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 3dc8cec..5f73343 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -35,7 +35,7 @@
当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。
+