diff --git a/background.js b/background.js index b56bbbe..efa7aa6 100644 --- a/background.js +++ b/background.js @@ -456,6 +456,11 @@ function normalizePlusPaymentMethod(value = '') { return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL; } +function normalizeGpcHelperPhoneMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'auto' || normalized === 'builtin' ? 'auto' : 'manual'; +} + function normalizeContributionModeSource(value = '') { const normalized = String(value || '').trim().toLowerCase(); return normalized === CONTRIBUTION_SOURCE_SUB2API @@ -634,6 +639,7 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperApiUrl: DEFAULT_GPC_HELPER_API_URL, gopayHelperApiKey: '', gopayHelperCardKey: '', + gopayHelperPhoneMode: 'manual', gopayHelperPhoneNumber: '', gopayHelperCountryCode: '+86', gopayHelperPin: '', @@ -665,6 +671,9 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperBalancePayload: null, gopayHelperBalanceUpdatedAt: 0, gopayHelperBalanceError: '', + gopayHelperRemainingUses: 0, + gopayHelperAutoModeEnabled: false, + gopayHelperApiKeyStatus: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, oauthFlowTimeoutEnabled: true, @@ -2331,6 +2340,10 @@ function normalizePersistentSettingValue(key, value) { return self.GoPayUtils?.normalizeGoPayPin ? self.GoPayUtils.normalizeGoPayPin(value) : String(value || ''); + case 'gopayHelperPhoneMode': + return self.GoPayUtils?.normalizeGpcHelperPhoneMode + ? self.GoPayUtils.normalizeGpcHelperPhoneMode(value) + : (String(value || '').trim().toLowerCase() === 'auto' || String(value || '').trim().toLowerCase() === 'builtin' ? 'auto' : 'manual'); case 'gopayHelperPhoneNumber': return self.GoPayUtils?.normalizeGoPayPhone ? self.GoPayUtils.normalizeGoPayPhone(value) @@ -2405,6 +2418,7 @@ function normalizePersistentSettingValue(key, value) { case 'gopayHelperFailureDetail': case 'gopayHelperBalance': case 'gopayHelperBalanceError': + case 'gopayHelperApiKeyStatus': return String(value || '').trim(); case 'gopayHelperBalancePayload': case 'gopayHelperStartPayload': @@ -2413,10 +2427,12 @@ function normalizePersistentSettingValue(key, value) { case 'gopayHelperBalanceUpdatedAt': case 'gopayHelperApiInputWaitSeconds': case 'gopayHelperOtpInvalidCount': + case 'gopayHelperRemainingUses': return Math.max(0, Number(value) || 0); case 'autoRunSkipFailures': case 'oauthFlowTimeoutEnabled': case 'gopayHelperLocalSmsHelperEnabled': + case 'gopayHelperAutoModeEnabled': case 'autoRunDelayEnabled': return Boolean(value); case 'operationDelayEnabled': @@ -7731,6 +7747,17 @@ function isGpcTaskEndedFailure(error) { return /GPC_TASK_ENDED::/i.test(message); } +function isGpcCheckoutRestartRequiredFailure(error) { + const message = getErrorMessage(error); + if (/PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message)) { + return false; + } + if (/GPC_TASK_ENDED::/i.test(message)) { + return true; + } + return /GPC\s*API\s*请求超时|步骤\s*[67][\s\S]*GPC[\s\S]*(?:任务轮询超时|请求超时|超时|timeout|timed\s*out|卡死|无响应)|account\s+already\s+linked|GOPAY已经绑了订阅|(?:账号|账户|GoPay|GOPAY)[\s\S]*(?:已绑定|已经绑定|已绑|绑了订阅|绑定了订阅)|创建\s*GPC\s*订单失败[\s\S]*(?:任务已结束|任务结束|failed|expired|discarded|请求超时|timeout|timed\s*out)/i.test(message); +} + function isGoPayCheckoutRestartRequiredFailure(error) { const message = getErrorMessage(error); return /GOPAY_RESTART_FROM_STEP6::|GOPAY_RETRY_REQUIRED::/i.test(message); @@ -9957,12 +9984,27 @@ async function refreshGpcApiKeyBalance(state = {}, options = {}) { const balancePayload = self.GoPayUtils?.unwrapGpcResponse ? self.GoPayUtils.unwrapGpcResponse(payload) : (payload?.data && typeof payload === 'object' ? payload.data : payload); + const balanceData = balancePayload && typeof balancePayload === 'object' && !Array.isArray(balancePayload) + ? balancePayload + : {}; + const remainingUses = self.GoPayUtils?.getGpcBalanceRemainingUses + ? self.GoPayUtils.getGpcBalanceRemainingUses(balanceData) + : Math.max(0, Number(balanceData.remaining_uses ?? balanceData.remainingUses ?? balanceData.balance ?? balanceData.remaining) || 0); + const autoModeEnabled = self.GoPayUtils?.isGpcAutoModeEnabled + ? self.GoPayUtils.isGpcAutoModeEnabled(balanceData) + : Boolean(balanceData.auto_mode_enabled ?? balanceData.autoModeEnabled); + const apiKeyStatus = self.GoPayUtils?.getGpcApiKeyStatus + ? self.GoPayUtils.getGpcApiKeyStatus(balanceData) + : String(balanceData.status || balanceData.card_status || balanceData.cardStatus || '').trim(); const balanceText = formatGpcApiKeyBalancePayload(payload) || rawText || '未知'; const updates = { gopayHelperBalance: balanceText, - gopayHelperBalancePayload: balancePayload && typeof balancePayload === 'object' && !Array.isArray(balancePayload) ? balancePayload : { raw: String(balancePayload || '') }, + gopayHelperBalancePayload: Object.keys(balanceData).length > 0 ? balanceData : { raw: String(balancePayload || '') }, gopayHelperBalanceUpdatedAt: Date.now(), gopayHelperBalanceError: '', + gopayHelperRemainingUses: Math.max(0, Number(remainingUses) || 0), + gopayHelperAutoModeEnabled: Boolean(autoModeEnabled), + gopayHelperApiKeyStatus: apiKeyStatus, }; const flowId = String(balancePayload?.flow_id || balancePayload?.flowId || '').trim(); if (flowId) { @@ -9991,7 +10033,15 @@ async function refreshGpcApiKeyBalance(state = {}, options = {}) { : `GPC 余额查询成功:${balanceText}`, 'info' ); - return { balance: balanceText, payload, updatedAt: updates.gopayHelperBalanceUpdatedAt }; + return { + balance: balanceText, + payload, + data: updates.gopayHelperBalancePayload, + remainingUses: updates.gopayHelperRemainingUses, + autoModeEnabled: updates.gopayHelperAutoModeEnabled, + apiKeyStatus: updates.gopayHelperApiKeyStatus, + updatedAt: updates.gopayHelperBalanceUpdatedAt, + }; } const refreshGpcCardBalance = refreshGpcApiKeyBalance; @@ -10397,10 +10447,33 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const { targetRun, totalRuns, attemptRuns, continued = false } = context; let postStep7RestartCount = 0; let goPayCheckoutRestartCount = 0; + let gpcCheckoutRestartCount = 0; let step4RestartCount = 0; let currentStartStep = startStep; let continueCurrentAttempt = continued; const resolvedSignupMethod = await ensureResolvedSignupMethodForRun(); + const normalizePlusPaymentMethodForRun = typeof normalizePlusPaymentMethod === 'function' + ? normalizePlusPaymentMethod + : (value) => (String(value || '').trim().toLowerCase() === 'gpc-helper' ? 'gpc-helper' : String(value || '').trim().toLowerCase()); + const plusPaymentMethodGpcHelper = typeof PLUS_PAYMENT_METHOD_GPC_HELPER === 'string' + ? PLUS_PAYMENT_METHOD_GPC_HELPER + : 'gpc-helper'; + const attachFailedStep = (error, step) => { + const failedStep = Math.floor(Number(step) || 0); + if (!error || typeof error !== 'object' || failedStep <= 0) { + return error; + } + + if (!Number.isInteger(Number(error.failedStep)) || Number(error.failedStep) <= 0) { + try { + error.failedStep = failedStep; + } catch (_err) { + // Some host errors may be non-extensible; state-based inference still covers normal paths. + } + } + + return error; + }; while (true) { @@ -10440,6 +10513,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { try { await executeStepAndWait(3, AUTO_STEP_DELAYS[3]); } catch (err) { + attachFailedStep(err, 3); if (isStopError(err)) { throw err; } @@ -10486,10 +10560,31 @@ async function runAutoSequenceFromStep(startStep, context = {}) { await executeStepAndWait(step, AUTO_STEP_DELAYS[step]); step += 1; } catch (err) { + attachFailedStep(err, step); if (isStopError(err)) { throw err; } + const stepExecutionKey = typeof getStepExecutionKeyForState === 'function' + ? getStepExecutionKeyForState(step, latestState) + : ''; + const isGpcCheckoutStep = normalizePlusPaymentMethodForRun(latestState?.plusPaymentMethod) === plusPaymentMethodGpcHelper + || String(latestState?.plusCheckoutSource || '').trim() === plusPaymentMethodGpcHelper; + if (isGpcCheckoutStep + && (stepExecutionKey === 'plus-checkout-create' || stepExecutionKey === 'plus-checkout-billing') + && isGpcCheckoutRestartRequiredFailure(err)) { + gpcCheckoutRestartCount += 1; + await addLog( + `步骤 ${step}:检测到 GPC 任务失败/卡住,准备回到步骤 6 重新创建 GPC 任务(第 ${gpcCheckoutRestartCount} 次)。原因:${getErrorMessage(err)}`, + 'warn' + ); + await invalidateDownstreamAfterStepRestart(5, { + logLabel: `步骤 ${step} GPC 任务失败后准备回到步骤 6 重试(第 ${gpcCheckoutRestartCount} 次)`, + }); + step = 6; + continue; + } + if (step === 8 && isGoPayCheckoutRestartRequiredFailure(err)) { goPayCheckoutRestartCount += 1; if (goPayCheckoutRestartCount > 3) { @@ -10733,7 +10828,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe }, isSignupProfilePageUrl: (rawUrl) => { const parsed = parseUrlSafely(rawUrl); - return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || '')); + return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(parsed.pathname || '')); }, isRetryableContentScriptTransportError, isHotmailProvider, @@ -10861,6 +10956,7 @@ const step2Executor = self.MultiPageBackgroundStep2?.createStep2Executor({ resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, + waitForTabStableComplete, }); const step3Executor = self.MultiPageBackgroundStep3?.createStep3Executor({ addLog, @@ -12070,6 +12166,29 @@ function throwIfStep8SettledOrStopped(isSettled = false) { } } +function isStep9AuthCallbackWaitPageUrl(rawUrl) { + if (!rawUrl) return false; + try { + const parsed = new URL(rawUrl); + const hostname = String(parsed.hostname || '').toLowerCase(); + if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(hostname)) { + return false; + } + const pathname = String(parsed.pathname || ''); + return /\/api\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname) + || /\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname); + } catch { + return false; + } +} + +async function shouldDeferStep9CallbackTimeout(details = {}) { + const tabId = details?.tabId; + if (!Number.isInteger(tabId)) return false; + const tab = await chrome.tabs.get(tabId).catch(() => null); + return isStep9AuthCallbackWaitPageUrl(tab?.url || ''); +} + async function ensureStep8SignupPageReady(tabId, options = {}) { const visibleStep = Math.floor(Number(options.visibleStep || options.logStep || options.step) || 0); await ensureContentScriptReadyOnTab('signup-page', tabId, { @@ -12524,6 +12643,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ setStep8TabUpdatedListener, setWebNavCommittedListener, setWebNavListener, + shouldDeferStep9CallbackTimeout, sleepWithStop, STEP8_CLICK_RETRY_DELAY_MS, STEP8_MAX_ROUNDS, diff --git a/background/account-run-history.js b/background/account-run-history.js index 0a73f29..e80908a 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -39,7 +39,7 @@ return ''; } - function extractRecordStep(status = '', detail = '') { + function extractRecordStepFromStatus(status = '') { const normalizedStatus = String(status || '').trim().toLowerCase(); const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/); if (statusMatch) { @@ -47,6 +47,21 @@ return Number.isInteger(step) && step > 0 ? step : null; } + return null; + } + + function extractRecordStepFromDetailPrefix(detail = '') { + const text = String(detail || '').trim(); + const detailMatch = text.match(/^(?:Step\s+(\d+)|步骤\s*(\d+))\s*(?::|:|失败|停止|已|\b)/i); + if (!detailMatch) { + return null; + } + + const step = Number(detailMatch[1] || detailMatch[2]); + return Number.isInteger(step) && step > 0 ? step : null; + } + + function extractAnyRecordStepFromDetail(detail = '') { const text = String(detail || '').trim(); const detailMatch = text.match(/(?:Step\s+(\d+)|步骤\s*(\d+))/i); if (!detailMatch) { @@ -57,6 +72,76 @@ return Number.isInteger(step) && step > 0 ? step : null; } + function extractRecordStep(status = '', detail = '') { + return extractRecordStepFromStatus(status) || extractRecordStepFromDetailPrefix(detail); + } + + function parseFailureLabelStep(label = '') { + const match = String(label || '').trim().match(/^步骤\s*(\d+)\s*(?:失败|停止)$/); + if (!match) { + return null; + } + const step = Number(match[1]); + return Number.isInteger(step) && step > 0 ? step : null; + } + + function shouldIgnorePersistedFailedStepCandidate(candidate, failureDetail = '') { + if (!Number.isInteger(candidate) || candidate <= 0) { + return true; + } + + const text = String(failureDetail || '').trim(); + if (!text) { + return false; + } + + const leadingStep = extractRecordStepFromDetailPrefix(text); + if (Number.isInteger(leadingStep) && leadingStep > 0) { + return leadingStep !== candidate; + } + + const incidentalStep = extractAnyRecordStepFromDetail(text); + return incidentalStep === candidate; + } + + function resolveNormalizedFailedStep(record = {}, failureDetail = '') { + const explicitStatusStep = extractRecordStepFromStatus(record.finalStatus || record.status || ''); + if (Number.isInteger(explicitStatusStep) && explicitStatusStep > 0) { + return explicitStatusStep; + } + + const detailStep = extractRecordStepFromDetailPrefix(failureDetail); + if (Number.isInteger(detailStep) && detailStep > 0) { + return detailStep; + } + + const failedStepCandidate = Number(record.failedStep); + if (Number.isInteger(failedStepCandidate) + && failedStepCandidate > 0 + && !shouldIgnorePersistedFailedStepCandidate(failedStepCandidate, failureDetail)) { + return failedStepCandidate; + } + + return null; + } + + function resolveFailureLabel(finalStatus, rawFailureLabel = '', computedFailureLabel = '', failedStep = null) { + const rawLabel = String(rawFailureLabel || '').trim(); + if (finalStatus === 'stopped') { + return computedFailureLabel; + } + if (finalStatus !== 'failed') { + return rawLabel || computedFailureLabel; + } + + const rawStep = parseFailureLabelStep(rawLabel); + if (Number.isInteger(rawStep) && rawStep > 0) { + return rawStep === failedStep ? rawLabel : computedFailureLabel; + } + + return rawLabel || computedFailureLabel; + } + function isPhoneVerificationFailure(detail = '') { const text = String(detail || '').trim(); if (!text) { @@ -247,10 +332,9 @@ const failureDetail = finalStatus === 'failed' || finalStatus === 'stopped' ? String(record.failureDetail || record.reason || '').trim() : ''; - const failedStepCandidate = Number(record.failedStep); - const failedStep = Number.isInteger(failedStepCandidate) && failedStepCandidate > 0 - ? failedStepCandidate - : extractRecordStep(record.finalStatus || record.status || '', failureDetail); + const failedStep = finalStatus === 'failed' || finalStatus === 'stopped' + ? resolveNormalizedFailedStep(record, failureDetail) + : null; const autoRunContext = normalizeAutoRunContext(record.autoRunContext); const retryCount = normalizeRetryCount( record.retryCount !== undefined @@ -271,9 +355,7 @@ finalStatus, finishedAt, retryCount, - failureLabel: finalStatus === 'stopped' - ? computedFailureLabel - : (rawFailureLabel || computedFailureLabel), + failureLabel: resolveFailureLabel(finalStatus, rawFailureLabel, computedFailureLabel, failedStep), failureDetail, failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 7c9e063..89a8b1f 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -85,6 +85,95 @@ return Math.max(0, Number(summary?.attempts || 0) - 1); } + function normalizeRecordStep(value) { + const step = Math.floor(Number(value) || 0); + return step > 0 ? step : null; + } + + function extractStepFromRecordStatus(status = '') { + const match = String(status || '').trim().toLowerCase().match(/^step(\d+)_(?:failed|stopped)$/); + if (!match) { + return null; + } + return normalizeRecordStep(match[1]); + } + + function getKnownStepIdsFromState(state = {}) { + const ids = new Set(); + for (const key of Object.keys(state?.stepStatuses || {})) { + const step = normalizeRecordStep(key); + if (step) { + ids.add(step); + } + } + + const currentStep = normalizeRecordStep(state?.currentStep); + if (currentStep) { + ids.add(currentStep); + } + + return Array.from(ids).sort((left, right) => left - right); + } + + function inferRecordStepFromState(state = {}, preferredStatuses = []) { + const statuses = state?.stepStatuses || {}; + const preferredStatusSet = new Set(preferredStatuses.map((item) => String(item || '').trim()).filter(Boolean)); + const stepIds = getKnownStepIdsFromState(state); + const currentStep = normalizeRecordStep(state?.currentStep); + + if (currentStep && preferredStatusSet.has(String(statuses[currentStep] || '').trim())) { + return currentStep; + } + + const matchingSteps = stepIds + .filter((step) => preferredStatusSet.has(String(statuses[step] || '').trim())) + .sort((left, right) => right - left); + if (matchingSteps.length) { + return matchingSteps[0]; + } + + if (currentStep) { + const currentStatus = String(statuses[currentStep] || '').trim(); + if (!['', 'pending', 'completed', 'manual_completed', 'skipped'].includes(currentStatus)) { + return currentStep; + } + } + + return null; + } + + function inferRecordStepFromError(errorLike = null) { + if (!errorLike || typeof errorLike !== 'object') { + return null; + } + + return normalizeRecordStep(errorLike.failedStep) + || normalizeRecordStep(errorLike.step) + || normalizeRecordStep(errorLike.currentStep); + } + + function resolveAutoRunAccountRecordStatus(status, state = {}, errorLike = null) { + const normalizedStatus = String(status || '').trim().toLowerCase(); + const explicitStep = extractStepFromRecordStatus(normalizedStatus); + if (explicitStep) { + return normalizedStatus; + } + + if (normalizedStatus === 'failed') { + const failedStep = inferRecordStepFromError(errorLike) + || inferRecordStepFromState(state, ['failed', 'running']); + return failedStep ? `step${failedStep}_failed` : status; + } + + if (normalizedStatus === 'stopped') { + const stoppedStep = inferRecordStepFromError(errorLike) + || inferRecordStepFromState(state, ['stopped', 'running']); + return stoppedStep ? `step${stoppedStep}_stopped` : status; + } + + return status; + } + function formatAutoRunFailureReasons(reasons = []) { if (!Array.isArray(reasons) || !reasons.length) { return '未知错误'; @@ -456,7 +545,7 @@ forceFreshTabsNextRun = false; } - const appendRoundRecordIfNeeded = async (status, reason = '') => { + const appendRoundRecordIfNeeded = async (status, reason = '', errorLike = null) => { if (roundRecordAppended) { return; } @@ -465,7 +554,9 @@ return; } - const record = await appendAccountRunRecord(status, null, reason); + const recordState = await getState(); + const recordStatus = resolveAutoRunAccountRecordStatus(status, recordState, errorLike); + const record = await appendAccountRunRecord(recordStatus, recordState, reason); if (record) { roundRecordAppended = true; } @@ -507,7 +598,7 @@ } catch (err) { if (isStopError(err)) { stoppedEarly = true; - await appendRoundRecordIfNeeded('stopped', getErrorMessage(err)); + await appendRoundRecordIfNeeded('stopped', getErrorMessage(err), err); await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await broadcastAutoRunStatus('stopped', { currentRun: targetRun, @@ -556,7 +647,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -591,7 +682,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因接码号池暂无可用号码已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -626,7 +717,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因 Plus 免费试用资格不可用已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -661,7 +752,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因 GPC 任务已结束。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -696,7 +787,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因 user_already_exists 已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -731,7 +822,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); cancelPendingCommands('当前轮因步骤 4 连续 405 错误已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { @@ -787,7 +878,7 @@ } catch (sleepError) { if (isStopError(sleepError)) { stoppedEarly = true; - await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError)); + await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError), sleepError); await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await broadcastAutoRunStatus('stopped', { currentRun: targetRun, @@ -811,7 +902,7 @@ } catch (sleepError) { if (isStopError(sleepError)) { stoppedEarly = true; - await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError)); + await appendRoundRecordIfNeeded('stopped', getErrorMessage(sleepError), sleepError); await addLog(`第 ${targetRun}/${totalRuns} 轮已被用户停止`, 'warn'); await broadcastAutoRunStatus('stopped', { currentRun: targetRun, @@ -833,7 +924,7 @@ await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); - await appendRoundRecordIfNeeded('failed', reason); + await appendRoundRecordIfNeeded('failed', reason, err); if (!autoRunSkipFailures) { cancelPendingCommands('当前轮执行失败。'); await broadcastStopToContentScripts(); @@ -948,6 +1039,7 @@ handleAutoRunLoopUnhandledError, logAutoRunFinalSummary, normalizeAutoRunRoundSummary, + resolveAutoRunAccountRecordStatus, serializeAutoRunRoundSummaries, skipAutoRunCountdown, startAutoRunLoop, diff --git a/background/message-router.js b/background/message-router.js index ca8b3fb..01c305c 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -167,6 +167,25 @@ return ''; } + function isStaleAutoRunStepMessage(step, state = {}) { + if (typeof isAutoRunLockedState !== 'function' || !isAutoRunLockedState(state)) { + return false; + } + const normalizedStep = Number(step); + if (!Number.isInteger(normalizedStep) || normalizedStep <= 0) { + return false; + } + const currentStatus = String(state?.stepStatuses?.[normalizedStep] || '').trim(); + if (currentStatus === 'running') { + return false; + } + const currentStep = Number(state?.currentStep) || 0; + if (currentStep > 0 && normalizedStep !== currentStep) { + return true; + } + return ['completed', 'manual_completed', 'skipped', 'failed', 'stopped'].includes(currentStatus); + } + function resolveSignupPhonePayload(payload = {}) { const directPhone = String( payload?.signupPhoneNumber @@ -540,6 +559,11 @@ } case 'STEP_COMPLETE': { + const currentState = await getState(); + if (isStaleAutoRunStepMessage(message.step, currentState)) { + await addLog(`自动运行:忽略过期的步骤 ${message.step} 完成消息,当前流程已在步骤 ${currentState.currentStep || '未知'}。`, 'warn', { step: message.step }); + return { ok: true, ignored: true }; + } if (getStopRequested()) { await setStepStatus(message.step, 'stopped'); await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, '流程已被用户停止。'); @@ -582,6 +606,11 @@ } case 'STEP_ERROR': { + const staleCheckState = await getState(); + if (isStaleAutoRunStepMessage(message.step, staleCheckState)) { + await addLog(`自动运行:忽略过期的步骤 ${message.step} 失败消息,当前流程已在步骤 ${staleCheckState.currentStep || '未知'}。原始错误:${message.error || '未知错误'}`, 'warn', { step: message.step }); + return { ok: true, ignored: true }; + } if (typeof isCloudflareSecurityBlockedError === 'function' && isCloudflareSecurityBlockedError(message.error)) { const userMessage = typeof handleCloudflareSecurityBlocked === 'function' ? await handleCloudflareSecurityBlocked(message.error) diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index f4a3b19..f3deb95 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -94,6 +94,7 @@ const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; const PHONE_RESEND_BANNED_NUMBER_ERROR_PREFIX = 'PHONE_RESEND_BANNED_NUMBER::'; + const PHONE_RESEND_SERVER_ERROR_PREFIX = 'PHONE_RESEND_SERVER_ERROR::'; const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::'; const PHONE_MANUAL_FREE_REUSE_ERROR_PREFIX = 'PHONE_MANUAL_FREE_REUSE::'; const PHONE_AUTO_FREE_REUSE_PREPARE_ERROR_PREFIX = 'PHONE_AUTO_FREE_REUSE_PREPARE::'; @@ -1387,6 +1388,25 @@ return /无法向此电话号码发送短信|无法向此手机号发送短信|无法发送短信到此电话号码|无法发送短信到此手机号|can(?:not|'t)\s+send\s+(?:an?\s+)?(?:sms|text(?:\s+message)?)\s+to\s+(?:this|that)\s+(?:phone\s+)?number|unable\s+to\s+send\s+(?:an?\s+)?(?:sms|text(?:\s+message)?)\s+to\s+(?:this|that)\s+(?:phone\s+)?number/i.test(message); } + function isPhoneResendServerError(error) { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + if (message.startsWith(PHONE_RESEND_SERVER_ERROR_PREFIX)) { + return true; + } + return /this\s+page\s+isn['’]?t\s+working|currently\s+unable\s+to\s+handle\s+this\s+request|http\s+error\s+500|500\s+internal\s+server\s+error/i.test(message); + } + + function buildPhoneResendServerError(error) { + const message = String(error?.message || error || '').trim(); + if (message.startsWith(PHONE_RESEND_SERVER_ERROR_PREFIX)) { + return new Error(message); + } + return new Error(`${PHONE_RESEND_SERVER_ERROR_PREFIX}${message || 'OpenAI contact-verification page returned HTTP ERROR 500 after resend.'}`); + } + function shouldTreatResendThrottledAsBanned(state = {}) { return Boolean(state?.phoneResendThrottledAsBannedEnabled); } @@ -4300,6 +4320,13 @@ message: error.message, }; } + if (isPhoneResendServerError(error)) { + return { + hasError: true, + reason: 'resend_server_error', + message: error.message, + }; + } if (isPhoneMaxUsageExceededFlowError(error)) { return { hasError: true, @@ -4971,6 +4998,9 @@ if (pageError?.reason === 'phone_max_usage_exceeded') { throw buildPhoneMaxUsageExceededError(pageError.message); } + if (pageError?.reason === 'resend_server_error') { + throw buildPhoneResendServerError(pageError.message); + } if (pageError?.reason === 'resend_throttled') { if (shouldTreatResendThrottledAsBanned(state)) { throw buildHighRiskResendThrottledError(pageError.message); @@ -5028,6 +5058,18 @@ reason: 'phone_max_usage_exceeded', }; } + if (isPhoneResendServerError(error)) { + await addLog( + `步骤 9:重发短信后进入 contact-verification 500 页面,立即更换号码。${error.message}`, + 'warn' + ); + await clearPhoneRuntimeCountdown(); + return { + code: '', + replaceNumber: true, + reason: 'resend_server_error', + }; + } if (isPhoneResendThrottledError(error)) { if (shouldTreatResendThrottledAsBanned(state)) { await addLog( @@ -5144,6 +5186,18 @@ : 'resend_throttled', }; } + if (isPhoneResendServerError(resendError)) { + await addLog( + `步骤 9:重发短信后进入 contact-verification 500 页面,立即更换号码。${resendError.message}`, + 'warn' + ); + await clearPhoneRuntimeCountdown(); + return { + code: '', + replaceNumber: true, + reason: 'resend_server_error', + }; + } await addLog(`步骤 9:点击手机验证码页面重发按钮失败。${resendError.message}`, 'warn'); } continue; @@ -5382,6 +5436,9 @@ if (isStopRequestedError(resendError)) { throw resendError; } + if (isPhoneResendServerError(resendError)) { + throw buildPhoneResendServerError(resendError); + } await addLog(`步骤 4:注册手机验证码页面重发失败,将继续轮询短信。${resendError.message}`, 'warn', { step: 4, stepKey: 'fetch-signup-code', @@ -5419,6 +5476,9 @@ if (isStopRequestedError(resendError)) { throw resendError; } + if (isPhoneResendServerError(resendError)) { + throw buildPhoneResendServerError(resendError); + } await addLog(`步骤 4:验证码被拒后点击重发失败。${resendError.message}`, 'warn', { step: 4, stepKey: 'fetch-signup-code', @@ -5621,6 +5681,9 @@ if (isStopRequestedError(resendError)) { throw resendError; } + if (isPhoneResendServerError(resendError)) { + throw buildPhoneResendServerError(resendError); + } await addLog(`步骤 ${visibleStep}:登录手机验证码页面重发失败,将继续轮询短信。${resendError.message}`, 'warn', { step: visibleStep, stepKey: 'fetch-login-code', @@ -5656,6 +5719,9 @@ if (isStopRequestedError(resendError)) { throw resendError; } + if (isPhoneResendServerError(resendError)) { + throw buildPhoneResendServerError(resendError); + } await addLog(`步骤 ${visibleStep}:登录手机验证码被拒后点击重发失败。${resendError.message}`, 'warn', { step: visibleStep, stepKey: 'fetch-login-code', diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index b85736e..d3c890e 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -30,12 +30,35 @@ waitForTabUrlMatch, } = deps; + async function waitForSignupEntryTabToSettle(tabId, step = 1) { + if (step !== 2 || !Number.isInteger(tabId) || typeof waitForTabStableComplete !== 'function') { + return null; + } + + if (typeof addLog === 'function') { + await addLog( + `步骤 ${step}:注册页已打开,正在等待页面加载完成并额外稳定 3 秒...`, + 'info', + { step, stepKey: 'signup-entry' } + ); + } + + return waitForTabStableComplete(tabId, { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }); + } + async function openSignupEntryTab(step = 1) { const tabId = await reuseOrCreateTab('signup-page', SIGNUP_ENTRY_URL, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', }); + await waitForSignupEntryTabToSettle(tabId, step); + await ensureContentScriptReadyOnTab('signup-page', tabId, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', @@ -85,7 +108,7 @@ function fallbackSignupProfilePageUrl(rawUrl) { const parsed = parseUrlSafely(rawUrl); if (!parsed) return false; - return /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || ''); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(parsed.pathname || ''); } function resolveSignupPostIdentityState(rawUrl) { diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index a2cff0c..52fe3ce 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -33,6 +33,7 @@ setWebNavCommittedListener, setStep8PendingReject, setStep8TabUpdatedListener, + shouldDeferStep9CallbackTimeout, } = deps; const LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS = 240000; @@ -96,6 +97,7 @@ let signupTabId = null; const callbackWaitStartedAt = Date.now(); let timeoutCheckTimer = null; + let timeoutDeferredLogged = false; const cleanupListener = () => { if (timeoutCheckTimer) { @@ -128,11 +130,46 @@ }); }; + const isCallbackTimeoutDeferred = async (elapsedMs) => { + if (typeof shouldDeferStep9CallbackTimeout !== 'function') { + return false; + } + try { + const deferred = await shouldDeferStep9CallbackTimeout({ + tabId: signupTabId, + visibleStep, + elapsedMs, + oauthUrl: activeState?.oauthUrl || '', + }); + if (deferred && !timeoutDeferredLogged) { + timeoutDeferredLogged = true; + await addStepLog( + visibleStep, + '检测到认证页仍在安全验证/授权跳转中,暂停本地回调超时判定,继续等待 localhost 回调...', + 'info' + ); + } + return Boolean(deferred); + } catch (error) { + await addStepLog( + visibleStep, + `复核认证页跳转状态失败(${error?.message || error}),继续按原超时规则等待回调。`, + 'warn' + ); + return false; + } + }; + const checkCallbackTimeout = async () => { if (resolved) { return; } const elapsedMs = Date.now() - callbackWaitStartedAt; + if (await isCallbackTimeoutDeferred(elapsedMs)) { + timeoutCheckTimer = setTimeout(checkCallbackTimeout, CALLBACK_TIMEOUT_CHECK_INTERVAL_MS); + return; + } + if (elapsedMs >= LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS) { rejectStep9(new Error(`${Math.round(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); return; diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 7ce89e1..a89dfc1 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -8,6 +8,8 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -86,6 +88,17 @@ return cleaned; } + function normalizeGpcHelperPhoneMode(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcHelperPhoneMode) { + return rootScope.GoPayUtils.normalizeGpcHelperPhoneMode(value); + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + function normalizeGpcOtpChannel(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) { @@ -143,6 +156,17 @@ return buildGpcHelperApiUrl(apiUrl, '/api/gp/tasks'); } + function buildGpcBalanceUrl(apiUrl = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcApiKeyBalanceUrl) { + return rootScope.GoPayUtils.buildGpcApiKeyBalanceUrl(apiUrl); + } + if (rootScope.GoPayUtils?.buildGpcCardBalanceUrl) { + return rootScope.GoPayUtils.buildGpcCardBalanceUrl(apiUrl); + } + return buildGpcHelperApiUrl(apiUrl, '/api/gp/balance'); + } + function unwrapGpcResponse(payload = {}) { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.unwrapGpcResponse) { @@ -176,6 +200,55 @@ return payload?.data?.detail || payload?.detail || payload?.message || payload?.error || `HTTP ${status || 0}`; } + function getGpcRemainingUses(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.getGpcBalanceRemainingUses) { + return rootScope.GoPayUtils.getGpcBalanceRemainingUses(payload); + } + const data = unwrapGpcResponse(payload); + const numeric = Number(data?.remaining_uses ?? data?.remainingUses ?? data?.balance ?? data?.remaining); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null; + } + + function isGpcAutoModeEnabled(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.isGpcAutoModeEnabled) { + return rootScope.GoPayUtils.isGpcAutoModeEnabled(payload); + } + const data = unwrapGpcResponse(payload); + return data?.auto_mode_enabled === true || data?.autoModeEnabled === true; + } + + async function assertGpcApiKeyReadyForCreate(state = {}, phoneMode = GPC_HELPER_PHONE_MODE_MANUAL, apiKey = '') { + const apiUrl = buildGpcBalanceUrl(state?.gopayHelperApiUrl); + if (!apiUrl) { + throw new Error('创建 GPC 订单失败:缺少 API 地址。'); + } + const { response, data } = await fetchJsonWithTimeout(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-API-Key': apiKey, + }, + }, 30000); + if (!response?.ok || !isGpcUnifiedResponseOk(data)) { + const detail = getGpcResponseErrorDetail(data, response?.status || 0); + throw new Error(`创建 GPC 订单失败:API Key 校验失败:${detail}`); + } + const balanceData = unwrapGpcResponse(data); + const remainingUses = getGpcRemainingUses(balanceData); + const status = String(balanceData?.status || balanceData?.card_status || balanceData?.cardStatus || '').trim().toLowerCase(); + if (status && status !== 'active') { + throw new Error(`创建 GPC 订单失败:API Key 状态不可用(${status})。`); + } + if (remainingUses !== null && remainingUses <= 0) { + throw new Error('创建 GPC 订单失败:API Key 剩余次数不足。'); + } + if (phoneMode === GPC_HELPER_PHONE_MODE_AUTO && !isGpcAutoModeEnabled(balanceData)) { + throw new Error('创建 GPC 订单失败:当前 GPC API Key 未开通自动模式。'); + } + } + async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) { const fetcher = typeof fetchImpl === 'function' ? fetchImpl @@ -184,11 +257,34 @@ throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。'); } const controller = typeof AbortController === 'function' ? new AbortController() : null; - const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null; + const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000); + let didTimeout = false; + let timer = null; + const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + didTimeout = true; + reject(buildTimeoutError()); + if (controller) { + controller.abort(); + } + }, effectiveTimeoutMs); + }); try { - const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }); - const data = await response.json().catch(() => ({})); + const response = await Promise.race([ + fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }), + timeoutPromise, + ]); + const data = await Promise.race([ + response.json().catch(() => ({})), + timeoutPromise, + ]); return { response, data }; + } catch (error) { + if (didTimeout || error?.name === 'AbortError') { + throw buildTimeoutError(); + } + throw error; } finally { if (timer) clearTimeout(timer); } @@ -226,25 +322,31 @@ if (!apiUrl) { throw new Error('创建 GPC 订单失败:缺少 API 地址。'); } + const phoneMode = normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode); + const isAutoMode = phoneMode === GPC_HELPER_PHONE_MODE_AUTO; const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); const countryCode = normalizeHelperCountryCode(state?.gopayHelperCountryCode || '86'); const pin = String(state?.gopayHelperPin || '').trim(); const apiKey = resolveGpcHelperApiKey(state); - if (!phoneNumber) { - throw new Error('创建 GPC 订单失败:缺少手机号。'); + if (!isAutoMode && !phoneNumber) { + throw new Error('创建 GPC 订单失败:手动模式缺少手机号。'); } - if (!pin) { - throw new Error('创建 GPC 订单失败:缺少 PIN。'); + if (!isAutoMode && !pin) { + throw new Error('创建 GPC 订单失败:手动模式缺少 PIN。'); } + throwIfStopped(); + await assertGpcApiKeyReadyForCreate(state, phoneMode, apiKey); throwIfStopped(); const payload = { access_token: token, - phone_mode: 'manual', - country_code: countryCode, - phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), - otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel), + phone_mode: phoneMode, }; + if (!isAutoMode) { + payload.country_code = countryCode; + payload.phone_number = normalizeHelperPhoneNumber(phoneNumber, countryCode); + payload.otp_channel = normalizeGpcOtpChannel(state?.gopayHelperOtpChannel); + } const orderCreatedAt = Date.now(); const { response, data } = await fetchJsonWithTimeout(apiUrl, { @@ -273,6 +375,7 @@ remoteStage: String(taskData?.remote_stage || taskData?.remoteStage || '').trim(), orderCreatedAt, responsePayload: taskData && typeof taskData === 'object' && !Array.isArray(taskData) ? taskData : null, + phoneMode: normalizeGpcHelperPhoneMode(taskData?.phone_mode || taskData?.phoneMode || phoneMode), country: 'ID', currency: 'IDR', checkoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, @@ -308,6 +411,7 @@ gopayHelperTaskStatus: result.taskStatus, gopayHelperStatusText: result.statusText, gopayHelperRemoteStage: result.remoteStage, + gopayHelperPhoneMode: result.phoneMode || normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode), gopayHelperTaskPayload: result.responsePayload, gopayHelperReferenceId: '', gopayHelperGoPayGuid: '', @@ -318,7 +422,7 @@ gopayHelperStartPayload: null, gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), }); - await addLog(`步骤 6:GPC 任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); + await addLog(`步骤 6:GPC ${result.phoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'ID', plusCheckoutCurrency: result.currency || 'IDR', diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 6f03bf7..b69ef78 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -11,7 +11,28 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const GPC_TASK_POLL_INTERVAL_MS = 3000; + const GPC_TASK_STALE_STATUS_TIMEOUT_MS = 60000; + const GPC_REMOTE_STAGE_LABELS = { + auto_otp_wait: '等待自动 OTP', + checkout_order_start: '创建订单', + checkout_start: '创建订单', + completed: '充值完成', + gopay_validate_pin: '校验 PIN', + otp_ready: '等待 PIN', + otp_submitted_local: 'OTP 已提交', + payment_processing: '支付处理中', + pin_submitted_local: 'PIN 已提交', + sms_otp_wait: '等待短信 OTP', + whatsapp_otp_wait: '等待 WhatsApp OTP', + }; + const GPC_WAITING_FOR_LABELS = { + auto_otp: '自动 OTP', + otp: 'OTP', + pin: 'PIN', + }; const PAYMENT_METHOD_CONFIGS = { [PLUS_PAYMENT_METHOD_PAYPAL]: { id: PLUS_PAYMENT_METHOD_PAYPAL, @@ -112,6 +133,56 @@ return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL; } + function normalizeGpcHelperPhoneMode(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcHelperPhoneMode) { + return rootScope.GoPayUtils.normalizeGpcHelperPhoneMode(value); + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + + function formatGpcRemoteStageLabel(stage = '') { + const normalized = String(stage || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + return GPC_REMOTE_STAGE_LABELS[normalized] || normalized; + } + + function formatGpcWaitingForLabel(waitingFor = '') { + const normalized = String(waitingFor || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + return GPC_WAITING_FOR_LABELS[normalized] || normalized.toUpperCase(); + } + + function formatGpcTaskStatusLog(task = {}) { + const statusText = String(task?.status_text || task?.statusText || '').trim(); + const status = String(task?.status || '').trim(); + const remoteStage = String(task?.remote_stage || task?.remoteStage || '').trim(); + const stageText = formatGpcRemoteStageLabel(remoteStage); + const waitingForText = formatGpcWaitingForLabel(task?.api_waiting_for || task?.apiWaitingFor || ''); + const mainText = stageText || statusText || status || '处理中'; + const parts = [`步骤 7:GPC 任务状态:${mainText}`]; + if (waitingForText && !mainText.includes(waitingForText)) { + parts.push(`,等待 ${waitingForText}`); + } + return parts.join(''); + } + + function getGpcHelperPhoneMode(state = {}, task = null) { + return normalizeGpcHelperPhoneMode( + task?.phone_mode + || task?.phoneMode + || state?.gopayHelperPhoneMode + || state?.phoneMode + ); + } + function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) { return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL]; } @@ -139,11 +210,34 @@ throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。'); } const controller = typeof AbortController === 'function' ? new AbortController() : null; - const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null; + const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000); + let didTimeout = false; + let timer = null; + const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + didTimeout = true; + reject(buildTimeoutError()); + if (controller) { + controller.abort(); + } + }, effectiveTimeoutMs); + }); try { - const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }); - const data = await response.json().catch(() => ({})); + const response = await Promise.race([ + fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }), + timeoutPromise, + ]); + const data = await Promise.race([ + response.json().catch(() => ({})), + timeoutPromise, + ]); return { response, data }; + } catch (error) { + if (didTimeout || error?.name === 'AbortError') { + throw buildTimeoutError(); + } + throw error; } finally { if (timer) clearTimeout(timer); } @@ -299,7 +393,7 @@ task.task_id = String(task.task_id || task.taskId || '').trim(); task.status = String(task.status || '').trim().toLowerCase(); task.status_text = String(task.status_text || task.statusText || '').trim(); - task.phone_mode = String(task.phone_mode || task.phoneMode || '').trim().toLowerCase(); + task.phone_mode = normalizeGpcHelperPhoneMode(task.phone_mode || task.phoneMode || ''); task.remote_stage = String(task.remote_stage || task.remoteStage || '').trim().toLowerCase(); task.api_waiting_for = String(task.api_waiting_for || task.apiWaitingFor || '').trim().toLowerCase(); task.api_input_deadline_at = String(task.api_input_deadline_at || task.apiInputDeadlineAt || '').trim(); @@ -318,6 +412,7 @@ gopayHelperTaskId: task.task_id, gopayHelperTaskStatus: task.status, gopayHelperStatusText: task.status_text, + gopayHelperPhoneMode: task.phone_mode, gopayHelperRemoteStage: task.remote_stage, gopayHelperApiWaitingFor: task.api_waiting_for, gopayHelperApiInputDeadlineAt: task.api_input_deadline_at, @@ -675,12 +770,16 @@ }); } - function isGpcTaskOtpWait(task = {}) { - return task?.phone_mode === 'manual' && task?.api_waiting_for === 'otp'; + function isGpcTaskManualMode(task = {}, state = {}) { + return getGpcHelperPhoneMode(state, task) === GPC_HELPER_PHONE_MODE_MANUAL; } - function isGpcTaskPinWait(task = {}) { - return task?.phone_mode === 'manual' + function isGpcTaskOtpWait(task = {}, state = {}) { + return isGpcTaskManualMode(task, state) && task?.api_waiting_for === 'otp'; + } + + function isGpcTaskPinWait(task = {}, state = {}) { + return isGpcTaskManualMode(task, state) && (task?.api_waiting_for === 'pin' || task?.status === 'otp_ready'); } @@ -688,6 +787,49 @@ return ['completed', 'failed', 'expired', 'discarded'].includes(String(status || '').trim().toLowerCase()); } + function buildGpcTaskProgressSignature(task = {}) { + return [ + task?.status, + task?.status_text, + task?.remote_stage, + task?.api_waiting_for, + task?.last_input_error, + task?.otp_invalid_count, + task?.reference_id || task?.referenceId, + task?.redirect_url || task?.redirectUrl, + task?.flow_id || task?.flowId, + task?.challenge_id || task?.challengeId, + task?.gopay_guid || task?.gopayGuid, + ].map((value) => String(value ?? '').trim()).join('|'); + } + + function shouldWatchGpcTaskProgress(task = {}, state = {}) { + if (!task || isGpcTaskTerminal(task.status)) { + return false; + } + if (isGpcTaskOtpWait(task, state) || isGpcTaskPinWait(task, state)) { + return false; + } + return true; + } + + function getGpcTaskStaleStatusTimeoutMs(state = {}) { + const configuredSeconds = Number(state?.gopayHelperTaskStaleSeconds); + if (Number.isFinite(configuredSeconds) && configuredSeconds > 0) { + return Math.max(15000, Math.min(600000, Math.floor(configuredSeconds * 1000))); + } + return GPC_TASK_STALE_STATUS_TIMEOUT_MS; + } + + function buildGpcTaskStaleStatusError(task = {}, staleTimeoutMs = GPC_TASK_STALE_STATUS_TIMEOUT_MS) { + const seconds = Math.max(1, Math.round(staleTimeoutMs / 1000)); + const label = formatGpcRemoteStageLabel(task?.remote_stage) + || task?.status_text + || task?.status + || '未知状态'; + return new Error(`GPC_TASK_ENDED::GPC 任务状态超过 ${seconds} 秒无进展(${label}),请重新创建任务。`); + } + function buildGpcTaskTerminalError(task = {}) { const status = String(task?.status || '').trim().toLowerCase(); const remoteStage = String(task?.remote_stage || task?.remoteStage || '').trim(); @@ -829,6 +971,9 @@ let lastSubmittedOtp = ''; let pinSubmitted = false; let terminalReached = false; + let lastProgressSignature = ''; + let lastProgressAt = Date.now(); + const staleStatusTimeoutMs = getGpcTaskStaleStatusTimeoutMs(state); if (!taskId) { throw new Error('步骤 7:GPC 模式缺少 task_id,请先执行步骤 6。'); @@ -840,27 +985,25 @@ throw new Error('步骤 7:GPC 模式缺少 API Key。'); } + const configuredPhoneMode = normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode || GPC_HELPER_PHONE_MODE_MANUAL); const rawPin = String(state?.gopayHelperPin || '').trim(); const pinDigits = rawPin.replace(/[^\d]/g, ''); const pin = normalizeSixDigitPin(rawPin); - if (!pin) { + if (configuredPhoneMode === GPC_HELPER_PHONE_MODE_MANUAL && !pin) { if (taskId && apiUrl && apiKey) { await stopGpcTaskBestEffort(apiUrl, taskId, apiKey, 'PIN 配置错误'); } throw new Error(pinDigits ? '步骤 7:GPC PIN 必须是 6 位数字,请检查侧边栏配置。' - : '步骤 7:GPC 模式缺少 PIN 配置。'); + : '步骤 7:GPC 手动模式缺少 PIN 配置。'); } - await addLog(`步骤 7:GPC 模式开始轮询任务(task_id: ${taskId})...`, 'info'); + await addLog(`步骤 7:GPC ${configuredPhoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式开始轮询任务(task_id: ${taskId})...`, 'info'); try { while (Date.now() <= deadline) { throwIfStopped(); const task = await fetchGpcTaskStatus(apiUrl, taskId, apiKey); - const statusText = task?.status_text || task?.status || '处理中'; - const remoteStage = task?.remote_stage || ''; - const waitingFor = task?.api_waiting_for || ''; - await addLog(`步骤 7:GPC 任务状态:${statusText}${remoteStage ? `(${remoteStage})` : ''}${waitingFor ? `,等待 ${waitingFor.toUpperCase()}` : ''}`, 'info'); + await addLog(formatGpcTaskStatusLog(task), 'info'); if (task.status === 'completed') { terminalReached = true; @@ -879,7 +1022,21 @@ throw buildGpcTaskEndedError(task, 'GPC 任务已结束,请重新创建任务。'); } - if (isGpcTaskOtpWait(task)) { + if (shouldWatchGpcTaskProgress(task, state)) { + const progressSignature = buildGpcTaskProgressSignature(task); + const now = Date.now(); + if (progressSignature && progressSignature !== lastProgressSignature) { + lastProgressSignature = progressSignature; + lastProgressAt = now; + } else if (progressSignature && now - lastProgressAt >= staleStatusTimeoutMs) { + throw buildGpcTaskStaleStatusError(task, staleStatusTimeoutMs); + } + } else { + lastProgressSignature = ''; + lastProgressAt = Date.now(); + } + + if (isGpcTaskOtpWait(task, state)) { if (isGpcTaskInputDeadlineExpired(task)) { throw buildGpcInputDeadlineError(task, 'OTP'); } @@ -940,7 +1097,7 @@ otpLastSubmittedAt = Date.now(); lastSubmittedOtp = normalizedOtp; await addLog('步骤 7:OTP 已提交,继续等待 GPC 任务状态更新。', 'ok'); - } else if (isGpcTaskPinWait(task) && !pinSubmitted) { + } else if (isGpcTaskPinWait(task, state) && !pinSubmitted) { if (isGpcTaskInputDeadlineExpired(task)) { throw buildGpcInputDeadlineError(task, 'PIN'); } diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index c8f0b02..ba39ca8 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -58,7 +58,30 @@ && !Boolean(state?.contributionMode); } + function hasStep7PhoneSignupIdentity(state = {}) { + return Boolean( + String(state?.signupPhoneNumber || '').trim() + || String(state?.signupPhoneCompletedActivation?.phoneNumber || '').trim() + || String(state?.signupPhoneActivation?.phoneNumber || '').trim() + || ( + normalizeStep7IdentifierType(state?.accountIdentifierType) === 'phone' + && String(state?.accountIdentifier || '').trim() + ) + ); + } + + function shouldPreferStep7PhoneSignupIdentity(state = {}) { + const frozenSignupMethod = normalizeStep7IdentifierType(state?.resolvedSignupMethod); + return canUseConfiguredPhoneSignup(state) + && frozenSignupMethod !== 'email' + && hasStep7PhoneSignupIdentity(state); + } + function resolveStep7LoginIdentifierType(state = {}, fallbackType = '') { + if (shouldPreferStep7PhoneSignupIdentity(state)) { + return 'phone'; + } + const explicitIdentifierType = normalizeStep7IdentifierType(state?.accountIdentifierType); if (explicitIdentifierType) { return explicitIdentifierType; diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index 5a917f8..44b0a1c 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -18,6 +18,7 @@ resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, + waitForTabStableComplete = null, } = deps; function getErrorMessage(error) { @@ -165,6 +166,25 @@ } } + async function waitForStep2SignupTabToSettle(tabId, logMessage) { + if (!Number.isInteger(tabId) || typeof waitForTabStableComplete !== 'function') { + return null; + } + + await addLog( + logMessage || '步骤 2:注册页标签已切换,正在等待页面加载完成并额外稳定 3 秒...', + 'info', + { step: 2, stepKey: 'signup-entry' } + ); + + return waitForTabStableComplete(tabId, { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }); + } + async function ensureSignupPhoneEntryReady(tabId) { if (!Number.isInteger(tabId)) { throw new Error('步骤 2:未找到可用的注册页标签,无法切换到手机号注册入口。'); @@ -210,6 +230,10 @@ signupTabId = (await ensureSignupEntryPageReady(2)).tabId; } else { await chrome.tabs.update(signupTabId, { active: true }); + await waitForStep2SignupTabToSettle( + signupTabId, + '步骤 2:已切换到注册页标签,正在等待页面加载完成并额外稳定 3 秒...' + ); await ensureContentScriptReadyOnTab('signup-page', signupTabId, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', diff --git a/background/verification-flow.js b/background/verification-flow.js index ffb5158..ad820e6 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -159,7 +159,7 @@ if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { return false; } - return /\/create-account\/profile(?:[/?#]|$)/i.test(String(parsed.pathname || '')); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(String(parsed.pathname || '')); } catch { return false; } @@ -249,11 +249,21 @@ const authState = String(result?.state || '').trim(); const authUrl = String(result?.url || '').trim(); + const verificationErrorText = String(result?.verificationErrorText || '').trim(); lastSnapshot = { state: authState || 'unknown', url: authUrl, }; + if (authState === 'verification_page' && verificationErrorText) { + return { + success: false, + reason: 'invalid_code', + invalidCode: true, + errorText: verificationErrorText, + url: authUrl, + }; + } if (authState === 'oauth_consent_page') { return { success: true, @@ -1080,7 +1090,8 @@ }, }; let result; - if (typeof sendToContentScriptResilient === 'function') { + const shouldAvoidReplaySubmit = step === 8; + if (typeof sendToContentScriptResilient === 'function' && !shouldAvoidReplaySubmit) { try { result = await sendToContentScriptResilient('signup-page', message, { timeoutMs: Math.max(baseResponseTimeoutMs + 15000, 30000), @@ -1143,6 +1154,56 @@ } throw err; } + } else if (shouldAvoidReplaySubmit) { + try { + result = await sendToContentScript('signup-page', message, { + responseTimeoutMs: baseResponseTimeoutMs, + }); + } catch (err) { + if (isRetryableVerificationTransportError(err)) { + await addLog('认证页正在切换,等待页面重新就绪后继续确认验证码提交结果...', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + const fallback = await detectStep8PostSubmitFallback({ + step, + timeoutMs: 9000, + pollIntervalMs: 300, + }); + if (fallback.invalidCode) { + return { + invalidCode: true, + errorText: fallback.errorText || '验证码被拒绝。', + url: fallback.url || '', + }; + } + if (fallback.success) { + if (fallback.addPhonePage) { + await addLog('验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + } else { + await addLog('验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + } + return { + success: true, + assumed: true, + transportRecovered: true, + addPhonePage: Boolean(fallback.addPhonePage), + url: fallback.url || '', + }; + } + if (fallback.restartStep7) { + const urlPart = fallback.url ? ` URL: ${fallback.url}` : ''; + throw new Error(`STEP8_RESTART_STEP7::步骤 ${completionStep}:验证码提交后认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim()); + } + } + throw err; + } } else { result = await sendToContentScript('signup-page', message, { responseTimeoutMs: baseResponseTimeoutMs, @@ -1287,20 +1348,8 @@ continue; } - const remainingBeforeResendMs = resendIntervalMs > 0 && lastResendAt > 0 - ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)) - : 0; - if (remainingBeforeResendMs > 0) { - await addLog( - `步骤 ${step}:提交失败后距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,先继续刷新邮箱(${attempt + 1}/${maxSubmitAttempts})...`, - 'warn' - ); - await sleepWithStop(Math.min(remainingBeforeResendMs, 2000)); - continue; - } - if (remainingAutomaticResendCount <= 0) { - await addLog(`步骤 ${step}:已达到自动重新发送验证码次数上限,将直接使用当前时间窗口继续重试。`, 'warn'); + await addLog(`步骤 ${step}:已达到自动重新发送验证码次数上限,将排除已拒绝验证码并继续轮询新邮件。`, 'warn'); continue; } diff --git a/content/phone-auth.js b/content/phone-auth.js index 02ec249..268924d 100644 --- a/content/phone-auth.js +++ b/content/phone-auth.js @@ -21,6 +21,7 @@ } = deps; const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; const PHONE_RESEND_BANNED_NUMBER_ERROR_PREFIX = 'PHONE_RESEND_BANNED_NUMBER::'; + const PHONE_RESEND_SERVER_ERROR_PREFIX = 'PHONE_RESEND_SERVER_ERROR::'; const PHONE_MAX_USAGE_EXCEEDED_PATTERN = /phone_max_usage_exceeded/i; const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::'; const PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS = 6000; @@ -28,6 +29,7 @@ const PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS = 12000; const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i; const PHONE_RESEND_BANNED_NUMBER_PATTERN = /无法向此电话号码发送短信|无法向此手机号发送短信|无法发送短信到此电话号码|无法发送短信到此手机号|can(?:not|'t)\s+send\s+(?:an?\s+)?(?:sms|text(?:\s+message)?)\s+to\s+(?:this|that)\s+(?:phone\s+)?number|unable\s+to\s+send\s+(?:an?\s+)?(?:sms|text(?:\s+message)?)\s+to\s+(?:this|that)\s+(?:phone\s+)?number/i; + const PHONE_RESEND_SERVER_ERROR_PATTERN = /this\s+page\s+isn['’]?t\s+working|currently\s+unable\s+to\s+handle\s+this\s+request|http\s+error\s+500|500\s+internal\s+server\s+error/i; const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i; const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3; const rootScope = typeof self !== 'undefined' ? self : globalThis; @@ -550,6 +552,20 @@ return ''; } + function getPhoneResendServerErrorText() { + const path = String(location?.pathname || ''); + if (!/\/contact-verification(?:[/?#]|$)/i.test(path)) { + return ''; + } + const text = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim(); + const title = String(document?.title || '').replace(/\s+/g, ' ').trim(); + const combined = `${title} ${text}`.trim(); + if (!PHONE_RESEND_SERVER_ERROR_PATTERN.test(combined)) { + return ''; + } + return combined || 'OpenAI contact-verification page returned HTTP ERROR 500 after resend.'; + } + function checkPhoneResendError() { const maxUsageText = getAddPhoneErrorText(); if (maxUsageText && PHONE_MAX_USAGE_EXCEEDED_PATTERN.test(maxUsageText)) { @@ -583,6 +599,17 @@ }; } + const serverErrorText = getPhoneResendServerErrorText(); + if (serverErrorText) { + return { + hasError: true, + reason: 'resend_server_error', + prefix: PHONE_RESEND_SERVER_ERROR_PREFIX, + message: serverErrorText, + url: location.href, + }; + } + return { hasError: false, reason: '', @@ -897,6 +924,10 @@ if (throttledText) { throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`); } + const serverErrorText = getPhoneResendServerErrorText(); + if (serverErrorText) { + throw new Error(`${PHONE_RESEND_SERVER_ERROR_PREFIX}${serverErrorText}`); + } const resendButton = getPhoneVerificationResendButton({ allowDisabled: true }); if (resendButton && isActionEnabled(resendButton)) { const resendInfo = getPhoneVerificationResendActionInfo(resendButton); @@ -936,6 +967,10 @@ if (afterClickThrottleText) { throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`); } + const afterClickServerErrorText = getPhoneResendServerErrorText(); + if (afterClickServerErrorText) { + throw new Error(`${PHONE_RESEND_SERVER_ERROR_PREFIX}${afterClickServerErrorText}`); + } return { resent: true, channel: resendInfo.channel || 'sms', @@ -957,6 +992,11 @@ throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`); } + const timeoutServerErrorText = getPhoneResendServerErrorText(); + if (timeoutServerErrorText) { + throw new Error(`${PHONE_RESEND_SERVER_ERROR_PREFIX}${timeoutServerErrorText}`); + } + throw new Error('Timed out waiting for the phone verification resend button.'); })().finally(() => { activePhoneResendPromise = null; diff --git a/content/signup-page.js b/content/signup-page.js index 7bbb7b2..ced1e82 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -155,6 +155,8 @@ const LOGIN_EXTERNAL_IDP_PATTERN = /google|microsoft|apple|sso|single\s+sign[-\s const LOGIN_CODE_ONLY_ACTION_PATTERN = /one[-\s]*time|passcode|use\s+(?:a\s+)?code|验证码|一次性/i; const RESEND_VERIFICATION_CODE_PATTERN = /重新发送(?:验证码)?|再次发送(?:验证码)?|重发(?:验证码)?|未收到(?:验证码|邮件)|resend(?:\s+code)?|send\s+(?:a\s+)?new\s+code|send\s+(?:it\s+)?again|request\s+(?:a\s+)?new\s+code|didn'?t\s+receive/i; +const PHONE_RESEND_SERVER_ERROR_PREFIX = 'PHONE_RESEND_SERVER_ERROR::'; +const CONTACT_VERIFICATION_SERVER_ERROR_PATTERN = /this\s+page\s+isn['’]?t\s+working|currently\s+unable\s+to\s+handle\s+this\s+request|http\s+error\s+500|500\s+internal\s+server\s+error/i; function isVisibleElement(el) { if (!el) return false; @@ -258,6 +260,27 @@ function isEmailVerificationPage() { return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || ''); } +function getContactVerificationServerErrorText() { + const path = String(location?.pathname || ''); + if (!/\/contact-verification(?:[/?#]|$)/i.test(path)) { + return ''; + } + const text = String(getPageTextSnapshot?.() || document?.body?.textContent || '').replace(/\s+/g, ' ').trim(); + const title = String(document?.title || '').replace(/\s+/g, ' ').trim(); + const combined = `${title} ${text}`.trim(); + if (!CONTACT_VERIFICATION_SERVER_ERROR_PATTERN.test(combined)) { + return ''; + } + return combined || 'OpenAI contact-verification page returned HTTP ERROR 500 after resend.'; +} + +function throwIfContactVerificationServerError() { + const serverErrorText = getContactVerificationServerErrorText(); + if (serverErrorText) { + throw new Error(`${PHONE_RESEND_SERVER_ERROR_PREFIX}${serverErrorText}`); + } +} + async function resendVerificationCode(step, timeout = 45000) { const performOperationWithDelay = typeof getOperationDelayRunner === 'function' ? getOperationDelayRunner() @@ -276,6 +299,7 @@ async function resendVerificationCode(step, timeout = 45000) { while (Date.now() - start < timeout) { throwIfStopped(); + throwIfContactVerificationServerError(); // Check for 405 error page and recover by clicking "Try again" if (is405MethodNotAllowedPage()) { @@ -302,6 +326,7 @@ async function resendVerificationCode(step, timeout = 45000) { loggedWaiting = false; continue; } + throwIfContactVerificationServerError(); return { resent: true, @@ -317,6 +342,7 @@ async function resendVerificationCode(step, timeout = 45000) { await sleep(250); } + throwIfContactVerificationServerError(); throw new Error('无法点击重新发送验证码按钮。URL: ' + location.href); } @@ -986,6 +1012,8 @@ async function waitForSignupEntryState(options = {}) { logDiagnostics = false, } = options; const start = Date.now(); + const maxSignupEntryClickRetries = 5; + const maxSignupEntryClickAttempts = maxSignupEntryClickRetries + 1; let lastTriggerClickAt = 0; let clickAttempts = 0; let lastState = ''; @@ -1042,12 +1070,21 @@ async function waitForSignupEntryState(options = {}) { } if (Date.now() - lastTriggerClickAt >= 1500) { + if (clickAttempts >= maxSignupEntryClickAttempts) { + log(`步骤 ${step}:官网注册入口已完成 ${maxSignupEntryClickRetries} 次重试,页面仍未进入邮箱输入页,停止重试。`, 'warn'); + return snapshot; + } lastTriggerClickAt = Date.now(); clickAttempts += 1; + const retryAttempt = clickAttempts - 1; if (logDiagnostics) { - log(`步骤 ${step}:正在点击官网注册入口(第 ${clickAttempts} 次):"${getActionText(snapshot.signupTrigger).slice(0, 80)}"`); + log(`步骤 ${step}:正在点击官网注册入口(第 ${clickAttempts}/${maxSignupEntryClickAttempts} 次):"${getActionText(snapshot.signupTrigger).slice(0, 80)}"`); } - log('步骤 2:正在点击官网注册入口...'); + log(retryAttempt > 0 + ? `步骤 ${step}:上次点击后仍未进入邮箱输入页,等待 3 秒后重试点击官网注册入口(重试 ${retryAttempt}/${maxSignupEntryClickRetries})...` + : `步骤 ${step}:已找到官网注册入口,等待 3 秒后点击...`); + await sleep(3000); + throwIfStopped(); await humanPause(350, 900); await performOperationWithDelay({ stepKey: 'signup-entry', kind: 'click', label: 'open-signup-entry' }, async () => { simulateClick(snapshot.signupTrigger); @@ -2282,7 +2319,10 @@ async function waitForSignupPhoneEntryState(options = {}) { step = 2, } = options; const start = Date.now(); + const maxSignupEntryClickRetries = 5; + const maxSignupEntryClickAttempts = maxSignupEntryClickRetries + 1; let lastTriggerClickAt = 0; + let clickAttempts = 0; let lastSwitchToPhoneAt = 0; let lastMoreOptionsClickAt = 0; let slowSnapshotLogged = false; @@ -2328,8 +2368,18 @@ async function waitForSignupPhoneEntryState(options = {}) { if (snapshot.state === 'entry_home' && snapshot.signupTrigger) { if (Date.now() - lastTriggerClickAt >= 1500) { + if (clickAttempts >= maxSignupEntryClickAttempts) { + log(`步骤 ${step}:官网注册入口已完成 ${maxSignupEntryClickRetries} 次重试,页面仍未进入手机号输入页,停止重试。`, 'warn'); + return snapshot; + } lastTriggerClickAt = Date.now(); - log(`步骤 ${step}:正在点击官网注册入口...`); + clickAttempts += 1; + const retryAttempt = clickAttempts - 1; + log(retryAttempt > 0 + ? `步骤 ${step}:上次点击后仍未进入手机号输入页,等待 3 秒后重试点击官网注册入口(重试 ${retryAttempt}/${maxSignupEntryClickRetries})...` + : `步骤 ${step}:已找到官网注册入口,等待 3 秒后点击...`); + await sleep(3000); + throwIfStopped(); await humanPause(350, 900); await performOperationWithDelay({ stepKey: 'signup-phone-entry', kind: 'click', label: 'open-signup-entry' }, async () => { simulateClick(snapshot.signupTrigger); @@ -2735,7 +2785,7 @@ function isSignupProfilePageUrl(rawUrl = location.href) { if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { return false; } - return /\/create-account\/profile(?:[/?#]|$)/i.test(String(parsed.pathname || '')); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(String(parsed.pathname || '')); } catch { return false; } @@ -4109,6 +4159,7 @@ function serializeLoginAuthState(snapshot) { url: snapshot?.url || location.href, path: snapshot?.path || location.pathname || '', displayedEmail: snapshot?.displayedEmail || '', + verificationErrorText: getVerificationErrorText(), retryEnabled: Boolean(snapshot?.retryEnabled), titleMatched: Boolean(snapshot?.titleMatched), detailMatched: Boolean(snapshot?.detailMatched), @@ -6266,14 +6317,23 @@ function getSerializableRect(el) { // Step 5: Fill Name & Birthday / Age // ============================================================ -function getStep5DirectCompletionPayload({ isAgeMode = false } = {}) { +function getStep5DirectCompletionPayload({ isAgeMode = false, navigationStarted = false, outcome = null } = {}) { const payload = { - skippedPostSubmitCheck: true, - directProceedToStep6: true, + profileSubmitted: true, + postSubmitChecked: true, }; if (isAgeMode) { payload.ageMode = true; } + if (navigationStarted) { + payload.navigationStarted = true; + } + if (outcome?.state) { + payload.outcome = outcome.state; + } + if (outcome?.url) { + payload.url = outcome.url; + } return payload; } @@ -6322,6 +6382,252 @@ async function waitForCombinedSignupVerificationProfilePage(timeout = 2500) { return isCombinedSignupVerificationProfilePage(); } +function getStep5ProfilePathPatterns() { + return [ + /\/create-account\/profile(?:[/?#]|$)/i, + /\/u\/signup\/profile(?:[/?#]|$)/i, + /\/signup\/profile(?:[/?#]|$)/i, + ]; +} + +function getStep5AuthRetryPathPatterns() { + const signupPatterns = typeof getSignupAuthRetryPathPatterns === 'function' + ? getSignupAuthRetryPathPatterns() + : []; + return [ + ...signupPatterns, + ...getStep5ProfilePathPatterns(), + ]; +} + +function isStep5ProfilePageUrl(rawUrl = location.href) { + return isSignupProfilePageUrl(rawUrl); +} + +function getStep5AuthRetryPageState() { + if (typeof getAuthTimeoutErrorPageState === 'function') { + return getAuthTimeoutErrorPageState({ + pathPatterns: getStep5AuthRetryPathPatterns(), + }); + } + + if (typeof getCurrentAuthRetryPageState === 'function') { + return getCurrentAuthRetryPageState('signup'); + } + + return null; +} + +function getStep5SubmitButton() { + const direct = document.querySelector('button[type="submit"], input[type="submit"]'); + if (direct && isVisibleElement(direct)) { + return direct; + } + + const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el)) return false; + const text = typeof getActionText === 'function' + ? getActionText(el) + : [ + el?.textContent, + el?.value, + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + ] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return /完成|创建|create|continue|finish|done|agree/i.test(text); + }) || null; +} + +async function waitForStep5SubmitButton(timeout = 5000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + const button = getStep5SubmitButton(); + if (button) { + return button; + } + await sleep(150); + } + + return null; +} + +function isStep5SubmitButtonClickable(button) { + return Boolean(button) + && isVisibleElement(button) + && !button.disabled + && button.getAttribute?.('aria-disabled') !== 'true'; +} + +function isStep5ProfileStillVisible() { + if (isStep5ProfilePageUrl()) { + return true; + } + + return typeof isStep5Ready === 'function' ? isStep5Ready() : false; +} + +function getStep5PostSubmitSuccessState() { + if (getStep5AuthRetryPageState()) { + return null; + } + + if (isLikelyLoggedInChatgptHomeUrl()) { + return { + state: 'logged_in_home', + url: location.href, + }; + } + + if (typeof isOAuthConsentPage === 'function' && isOAuthConsentPage()) { + return { + state: 'oauth_consent', + url: location.href, + }; + } + + if (typeof isAddPhonePageReady === 'function' && isAddPhonePageReady()) { + return { + state: 'add_phone', + url: location.href, + }; + } + + if (!isStep5ProfileStillVisible()) { + return { + state: 'left_profile', + url: location.href, + }; + } + + return null; +} + +function installStep5NavigationCompletionReporter(completeOnce) { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') { + return () => {}; + } + + const onNavigationStarted = () => { + completeOnce({ + navigationStarted: true, + outcome: { + state: 'navigation_started', + url: location.href, + }, + }); + }; + + window.addEventListener('pagehide', onNavigationStarted, { once: true }); + window.addEventListener('beforeunload', onNavigationStarted, { once: true }); + + return () => { + window.removeEventListener('pagehide', onNavigationStarted); + window.removeEventListener('beforeunload', onNavigationStarted); + }; +} + +async function waitForStep5SubmitOutcome(options = {}) { + const { + timeoutMs = 45000, + maxAuthRetryRecoveries = 2, + maxSubmitClicks = 3, + retryClickIntervalMs = 3500, + } = options; + const start = Date.now(); + let authRetryRecoveryCount = 0; + let submitClickCount = 1; + let lastSubmitClickAt = Date.now(); + let lastStep5Error = ''; + + while (Date.now() - start < timeoutMs) { + throwIfStopped(); + + const retryState = getStep5AuthRetryPageState(); + if (retryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + if (retryState?.maxCheckAttemptsBlocked) { + throw createAuthMaxCheckAttemptsError(); + } + if (retryState) { + if (authRetryRecoveryCount >= maxAuthRetryRecoveries) { + throw new Error(`步骤 5:资料提交后连续进入认证重试页 ${maxAuthRetryRecoveries} 次,页面仍未恢复。URL: ${location.href}`); + } + authRetryRecoveryCount += 1; + log(`步骤 5:资料提交后进入认证重试页,正在自动恢复(${authRetryRecoveryCount}/${maxAuthRetryRecoveries})...`, 'warn'); + await recoverCurrentAuthRetryPage({ + flow: 'signup', + logLabel: '步骤 5:资料提交后检测到认证重试页,正在点击“重试”恢复', + maxClickAttempts: 2, + pathPatterns: getStep5AuthRetryPathPatterns(), + step: 5, + timeoutMs: 12000, + }); + lastSubmitClickAt = Date.now(); + continue; + } + + const successState = getStep5PostSubmitSuccessState(); + if (successState) { + return successState; + } + + const step5Error = typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : ''; + if (step5Error) { + lastStep5Error = step5Error; + } + + if ( + isStep5ProfileStillVisible() + && submitClickCount < maxSubmitClicks + && Date.now() - lastSubmitClickAt >= retryClickIntervalMs + ) { + const submitButton = getStep5SubmitButton(); + if (isStep5SubmitButtonClickable(submitButton)) { + submitClickCount += 1; + log(`步骤 5:资料提交后仍停留在资料页,正在重新点击“完成帐户创建”(第 ${submitClickCount}/${maxSubmitClicks} 次)...`, 'warn'); + await humanPause(350, 900); + simulateClick(submitButton); + lastSubmitClickAt = Date.now(); + await sleep(1000); + continue; + } + } + + await sleep(250); + } + + const finalRetryState = getStep5AuthRetryPageState(); + if (finalRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + if (finalRetryState?.maxCheckAttemptsBlocked) { + throw createAuthMaxCheckAttemptsError(); + } + if (finalRetryState) { + throw new Error(`步骤 5:资料提交后仍停留在认证重试页,自动恢复未完成。URL: ${location.href}`); + } + + const finalSuccessState = getStep5PostSubmitSuccessState(); + if (finalSuccessState) { + return finalSuccessState; + } + + const finalStep5Error = (typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : '') || lastStep5Error; + if (finalStep5Error) { + throw new Error(`步骤 5:资料提交后页面返回错误:${finalStep5Error}。URL: ${location.href}`); + } + + throw new Error(`步骤 5:资料提交后未检测到页面跳转或恢复成功(已点击提交 ${submitClickCount}/${maxSubmitClicks} 次)。URL: ${location.href}`); +} + async function step5_fillNameBirthday(payload) { const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload; if (!firstName || !lastName) throw new Error('未提供姓名数据。'); @@ -6558,7 +6864,7 @@ async function step5_fillNameBirthday(payload) { // Click "完成帐户创建" button await sleep(500); - const completeBtn = document.querySelector('button[type="submit"]') + const completeBtn = await waitForStep5SubmitButton(5000) || await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null); if (!completeBtn) { throw new Error('未找到“完成帐户创建”按钮。URL: ' + location.href); @@ -6566,22 +6872,42 @@ async function step5_fillNameBirthday(payload) { const isAgeMode = !birthdayMode && Boolean(ageInput); if (isAgeMode) { - log('步骤 5:当前为年龄输入模式,点击“完成帐户创建”后将直接完成当前步骤。', 'warn'); + log('步骤 5:当前为年龄输入模式,点击“完成帐户创建”后将等待页面结果。', 'info'); } + let reportedCompletionPayload = null; + function completeStep5Once(extra = {}) { + if (reportedCompletionPayload) { + return reportedCompletionPayload; + } + + const completionPayload = getStep5DirectCompletionPayload({ + isAgeMode, + navigationStarted: Boolean(extra.navigationStarted), + outcome: extra.outcome || null, + }); + reportedCompletionPayload = completionPayload; + reportComplete(5, completionPayload); + return completionPayload; + } + + const cleanupNavigationReporter = installStep5NavigationCompletionReporter(completeStep5Once); + await humanPause(500, 1300); await performOperationWithDelay({ stepKey: 'fill-profile', kind: 'submit', label: 'submit-profile' }, async () => { simulateClick(completeBtn); }); + log('步骤 5:已点击“完成帐户创建”,正在等待页面跳转、重试页或提交结果。'); - const completionPayload = getStep5DirectCompletionPayload({ isAgeMode }); - reportComplete(5, completionPayload); + try { + const outcome = await waitForStep5SubmitOutcome(); + cleanupNavigationReporter(); - if (isAgeMode) { - log('步骤 5:年龄模式已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。', 'warn'); + const completionPayload = completeStep5Once({ outcome }); + log(`步骤 5:资料提交结果已确认(${outcome.state || 'success'}),准备继续后续步骤。`, 'ok'); return completionPayload; + } catch (error) { + cleanupNavigationReporter(); + throw error; } - - log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。'); - return completionPayload; } diff --git a/data/step-definitions.js b/data/step-definitions.js index 6cc1cae..dd87867 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -58,7 +58,7 @@ { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单' }, - { id: 7, order: 70, key: 'plus-checkout-billing', title: 'GPC OTP/PIN 验证' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成' }, { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, diff --git a/gopay-utils.js b/gopay-utils.js index b2c2eee..0c25a0a 100644 --- a/gopay-utils.js +++ b/gopay-utils.js @@ -5,6 +5,8 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const ALLOWED_GPC_HELPER_REMOTE_HOST = 'gpc.qlhazycoder.top'; function normalizePlusPaymentMethod(value = '') { @@ -47,6 +49,85 @@ return String(value || '').trim().replace(/[^\d]/g, ''); } + function normalizeGpcHelperPhoneMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + + function normalizeGpcRemainingUses(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null; + } + + function unwrapGpcBalancePayload(payload = {}) { + const data = unwrapGpcResponse(payload); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const hasBalanceFields = [ + 'remaining_uses', + 'remainingUses', + 'balance', + 'remaining', + 'uses', + 'available_uses', + 'availableUses', + 'auto_mode_enabled', + 'autoModeEnabled', + 'auto_enabled', + 'autoEnabled', + 'status', + 'card_status', + 'cardStatus', + ].some((key) => Object.prototype.hasOwnProperty.call(data, key)); + if (!hasBalanceFields && data.data && typeof data.data === 'object' && !Array.isArray(data.data)) { + return data.data; + } + return data; + } + + function getGpcBalanceRemainingUses(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return null; + } + return normalizeGpcRemainingUses( + data.remaining_uses + ?? data.remainingUses + ?? data.balance + ?? data.remaining + ?? data.uses + ?? data.available_uses + ?? data.availableUses + ); + } + + function isGpcAutoModeEnabled(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return false; + } + const raw = data.auto_mode_enabled ?? data.autoModeEnabled ?? data.auto_enabled ?? data.autoEnabled; + if (typeof raw === 'boolean') { + return raw; + } + const normalized = String(raw ?? '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'enabled'; + } + + function getGpcApiKeyStatus(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return ''; + } + return String(data.status || data.card_status || data.cardStatus || '').trim(); + } + function normalizeGpcOtpChannel(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'sms') { @@ -291,7 +372,7 @@ } function formatGpcBalancePayload(payload = {}) { - const data = unwrapGpcResponse(payload); + const data = unwrapGpcBalancePayload(payload); if (!data || typeof data !== 'object') { return ''; } @@ -330,6 +411,8 @@ return { DEFAULT_GOPAY_COUNTRY_CODE, DEFAULT_GPC_HELPER_API_URL, + GPC_HELPER_PHONE_MODE_AUTO, + GPC_HELPER_PHONE_MODE_MANUAL, PLUS_PAYMENT_METHOD_GPC_HELPER, PLUS_PAYMENT_METHOD_GOPAY, PLUS_PAYMENT_METHOD_PAYPAL, @@ -348,8 +431,13 @@ buildGpcTaskQueryUrl, extractGpcResponseErrorDetail, formatGpcBalancePayload, + getGpcApiKeyStatus, + getGpcBalanceRemainingUses, isGpcUnifiedResponseOk, + isGpcAutoModeEnabled, normalizeGpcHelperBaseUrl, + normalizeGpcHelperPhoneMode, + normalizeGpcRemainingUses, normalizeGpcTaskId, normalizeGoPayCountryCode, normalizeGoPayPhone, @@ -358,6 +446,7 @@ normalizeGoPayPin, normalizeGpcOtpChannel, normalizePlusPaymentMethod, + unwrapGpcBalancePayload, unwrapGpcResponse, }; }); diff --git a/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc b/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc new file mode 100644 index 0000000..7f85f82 Binary files /dev/null and b/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc differ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 50310bb..5337d0f 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -305,6 +305,13 @@ 余额未获取 +