fix(flow): harden proxy phone and checkout recovery paths
This commit is contained in:
@@ -23,8 +23,10 @@
|
||||
getState,
|
||||
hasSavedProgress,
|
||||
isAddPhoneAuthFailure,
|
||||
isPhoneSmsPlatformRateLimitFailure,
|
||||
isPlusCheckoutNonFreeTrialFailure,
|
||||
isRestartCurrentAttemptError,
|
||||
isStep4Route405RecoveryLimitFailure,
|
||||
isSignupUserAlreadyExistsFailure,
|
||||
isStopError,
|
||||
launchAutoRunTimerPlan,
|
||||
@@ -124,6 +126,18 @@
|
||||
&& state.customMailProviderPool.length > 0;
|
||||
}
|
||||
|
||||
function isPhoneNumberSupplyExhaustedFailure(error) {
|
||||
const text = String(
|
||||
typeof getErrorMessage === 'function'
|
||||
? getErrorMessage(error)
|
||||
: (error?.message || error || '')
|
||||
).trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /no\s+numbers\s+available\s+across|all provider candidates failed to acquire number|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|countries\s+are\s+empty|均无可用号码|暂无可用号码|无可用号码|接码号池暂无|\bNO_NUMBERS\b/i.test(text);
|
||||
}
|
||||
|
||||
async function logAutoRunFinalSummary(totalRuns, roundSummaries = []) {
|
||||
const summaries = buildAutoRunRoundSummaries(totalRuns, roundSummaries);
|
||||
const successRounds = summaries.filter((item) => item.status === 'success');
|
||||
@@ -502,13 +516,27 @@
|
||||
|
||||
const reason = getErrorMessage(err);
|
||||
roundSummary.failureReasons.push(reason);
|
||||
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
||||
const blockedByPhoneSmsRateLimit = typeof isPhoneSmsPlatformRateLimitFailure === 'function'
|
||||
&& isPhoneSmsPlatformRateLimitFailure(err);
|
||||
const blockedByPhoneNoSupply = !blockedByPhoneSmsRateLimit
|
||||
&& isPhoneNumberSupplyExhaustedFailure(err);
|
||||
const blockedByAddPhone = !blockedByPhoneSmsRateLimit
|
||||
&& !blockedByPhoneNoSupply
|
||||
&& typeof isAddPhoneAuthFailure === 'function'
|
||||
&& isAddPhoneAuthFailure(err);
|
||||
const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function'
|
||||
&& isPlusCheckoutNonFreeTrialFailure(err);
|
||||
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
|
||||
&& !keepSameEmailUntilAddPhone
|
||||
&& isSignupUserAlreadyExistsFailure(err);
|
||||
const canRetry = !blockedByAddPhone && !blockedByPlusNonFreeTrial && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
const blockedByStep4Route405 = typeof isStep4Route405RecoveryLimitFailure === 'function'
|
||||
&& isStep4Route405RecoveryLimitFailure(err);
|
||||
const canRetry = !blockedByAddPhone
|
||||
&& !blockedByPhoneNoSupply
|
||||
&& !blockedByPlusNonFreeTrial
|
||||
&& !blockedBySignupUserAlreadyExists
|
||||
&& autoRunSkipFailures
|
||||
&& attemptRun < maxAttemptsForRound;
|
||||
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
@@ -549,6 +577,41 @@
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockedByPhoneNoSupply) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
});
|
||||
await appendRoundRecordIfNeeded('failed', reason);
|
||||
cancelPendingCommands('当前轮因接码号池暂无可用号码已终止。');
|
||||
await broadcastStopToContentScripts();
|
||||
if (!autoRunSkipFailures) {
|
||||
await addLog(
|
||||
`第 ${targetRun}/${totalRuns} 轮接码号池暂无可用号码,自动重试未开启,当前自动运行将停止。`,
|
||||
'warn'
|
||||
);
|
||||
stoppedEarly = true;
|
||||
await broadcastAutoRunStatus('stopped', {
|
||||
currentRun: targetRun,
|
||||
totalRuns,
|
||||
attemptRun,
|
||||
sessionId: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂无可用号码,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||
await addLog(
|
||||
targetRun < totalRuns
|
||||
? `第 ${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,自动流程将继续下一轮。`
|
||||
: `第 ${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,已无后续轮次,本次自动运行结束。`,
|
||||
'warn'
|
||||
);
|
||||
forceFreshTabsNextRun = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockedByPlusNonFreeTrial) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
@@ -619,18 +682,18 @@
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockedByPhoneSupplyExhausted) {
|
||||
if (blockedByStep4Route405) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
});
|
||||
await appendRoundRecordIfNeeded('failed', reason);
|
||||
cancelPendingCommands('当前轮因接码号池不可用已终止。');
|
||||
cancelPendingCommands('当前轮因步骤 4 连续 405 错误已终止。');
|
||||
await broadcastStopToContentScripts();
|
||||
if (!autoRunSkipFailures) {
|
||||
await addLog(
|
||||
`第 ${targetRun}/${totalRuns} 轮触发接码号池不可用,自动重试未开启,当前自动运行将停止。`,
|
||||
`第 ${targetRun}/${totalRuns} 轮步骤 4 连续 405 恢复失败,自动重试未开启,当前自动运行将停止。`,
|
||||
'warn'
|
||||
);
|
||||
stoppedEarly = true;
|
||||
@@ -643,11 +706,11 @@
|
||||
break;
|
||||
}
|
||||
|
||||
await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||
await addLog(`第 ${targetRun}/${totalRuns} 轮步骤 4 连续 405 恢复失败,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||
await addLog(
|
||||
targetRun < totalRuns
|
||||
? `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。`
|
||||
: `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`,
|
||||
? `第 ${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,自动流程将继续下一轮。`
|
||||
: `第 ${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,已无后续轮次,本次自动运行结束。`,
|
||||
'warn'
|
||||
);
|
||||
forceFreshTabsNextRun = true;
|
||||
|
||||
+291
-5
@@ -48,6 +48,11 @@ const IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY = [
|
||||
// 与 curl 口径对齐,保持单端点,避免多站点探测引入额外波动与耗时。
|
||||
'https://ipinfo.io/json',
|
||||
];
|
||||
const IP_PROXY_TARGET_REACHABILITY_ENDPOINTS = [
|
||||
// Step 1 的真实目标站点;出口 IP 可用不代表该站点的 CONNECT/TLS 链路可用。
|
||||
'https://chatgpt.com/',
|
||||
];
|
||||
const IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS = 8000;
|
||||
const IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS = 4;
|
||||
const IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS = 3500;
|
||||
const IP_PROXY_PAGE_CONTEXT_PROBE_URL = 'https://example.com/';
|
||||
@@ -1270,6 +1275,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
|
||||
const exitDetecting = Boolean(status?.exitDetecting);
|
||||
const exitError = String(status?.exitError || '').trim();
|
||||
const exitSource = String(status?.exitSource || '').trim().toLowerCase();
|
||||
const exitEndpoint = String(status?.exitEndpoint || status?.endpoint || '').trim();
|
||||
return {
|
||||
ipProxyApplied: applied,
|
||||
ipProxyAppliedReason: reason,
|
||||
@@ -1286,6 +1292,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
|
||||
ipProxyAppliedExitDetecting: exitDetecting,
|
||||
ipProxyAppliedExitError: exitError,
|
||||
ipProxyAppliedExitSource: exitSource,
|
||||
ipProxyAppliedExitEndpoint: exitEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1317,6 +1324,23 @@ async function setIpProxyLeakGuardEnabled(enabled) {
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
function shouldEnableIpProxyLeakGuardForStatus(status = {}) {
|
||||
if (!status?.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (status?.applied) {
|
||||
return false;
|
||||
}
|
||||
const reason = String(status?.reason || '').trim().toLowerCase();
|
||||
// connectivity_failed 表示 PAC/代理接管已经下发,只是探测或目标站点失败。
|
||||
// 此时继续启用 DNR 会把 chatgpt.com 变成 ERR_BLOCKED_BY_CLIENT,掩盖真实代理链路结果。
|
||||
return reason !== 'connectivity_failed';
|
||||
}
|
||||
|
||||
async function syncIpProxyLeakGuardForStatus(status = {}) {
|
||||
await setIpProxyLeakGuardEnabled(shouldEnableIpProxyLeakGuardForStatus(status));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = IP_PROXY_FETCH_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || IP_PROXY_FETCH_TIMEOUT_MS));
|
||||
@@ -1495,6 +1519,15 @@ function resolveExitProbeEndpoints(options = {}) {
|
||||
return IP_PROXY_EXIT_PROBE_ENDPOINTS.slice();
|
||||
}
|
||||
|
||||
function resolveTargetReachabilityEndpoints(options = {}) {
|
||||
const configured = Array.isArray(options?.targetReachabilityEndpoints)
|
||||
? options.targetReachabilityEndpoints.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return configured.length
|
||||
? configured
|
||||
: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS.slice();
|
||||
}
|
||||
|
||||
function applyExitRegionExpectation(status = {}, expectedRegion = '') {
|
||||
const exitIp = String(status?.exitIp || '').trim();
|
||||
const exitError = String(status?.exitError || '').trim();
|
||||
@@ -1656,6 +1689,48 @@ function applyExitBaselineExpectation(status = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldVerifyIpProxyTargetReachability(status = {}) {
|
||||
if (!String(status?.exitIp || '').trim()) {
|
||||
return false;
|
||||
}
|
||||
if (status?.applied === false && String(status?.reason || '').trim().toLowerCase() === 'connectivity_failed') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTargetReachabilityFailureMessage(status = {}, reachability = {}) {
|
||||
const exitIp = String(status?.exitIp || '').trim();
|
||||
const exitRegion = String(status?.exitRegion || '').trim();
|
||||
const endpoint = String(reachability?.endpoint || reachability?.url || IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0] || '').trim();
|
||||
const targetHost = extractProbeHostFromTabUrl(endpoint) || endpoint || 'chatgpt.com';
|
||||
const diagnostic = String(reachability?.error || reachability?.diagnostics || '').trim();
|
||||
const diagnosticSuffix = diagnostic ? ` 诊断:${diagnostic}` : '';
|
||||
const exitRegionSuffix = exitRegion ? ` [${exitRegion}]` : '';
|
||||
return `已检测到出口 IP ${exitIp}${exitRegionSuffix},但真实目标 ${targetHost} 不可达。`
|
||||
+ `这说明代理只通过了 IP 探测,无法打开步骤 1 的 ChatGPT 页面;请更换支持 chatgpt.com CONNECT/TLS 的节点。`
|
||||
+ diagnosticSuffix;
|
||||
}
|
||||
|
||||
function applyTargetReachabilityExpectation(status = {}, reachability = {}) {
|
||||
if (!shouldVerifyIpProxyTargetReachability(status)) {
|
||||
return status;
|
||||
}
|
||||
if (reachability?.reachable === true) {
|
||||
return status;
|
||||
}
|
||||
if (reachability?.skipped === true) {
|
||||
return status;
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
applied: false,
|
||||
reason: 'connectivity_failed',
|
||||
warning: '',
|
||||
error: buildTargetReachabilityFailureMessage(status, reachability),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectProxyExitInfoByBackgroundFetch(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
@@ -1954,6 +2029,35 @@ async function readExitProbeFromTabDocument(tabId) {
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
function appendProbeCacheBuster(rawUrl = '', key = '_multipage_proxy_probe') {
|
||||
const text = String(rawUrl || '').trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
parsed.searchParams.set(key, String(Date.now()));
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
const separator = text.includes('?') ? '&' : '?';
|
||||
return `${text}${separator}${key}=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTargetReachabilityFromTabDocument(tabId) {
|
||||
const executionResults = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => ({
|
||||
href: String(location.href || ''),
|
||||
title: String(document.title || ''),
|
||||
readyState: String(document.readyState || ''),
|
||||
bodyLength: Number(document.body?.innerText?.length || document.documentElement?.innerText?.length || 0),
|
||||
}),
|
||||
});
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
async function probeExitInfoByTabNavigation(tabId, timeoutMs = 10000, errors = []) {
|
||||
return probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs, errors, []);
|
||||
}
|
||||
@@ -2208,6 +2312,101 @@ async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpoints
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
async function detectIpProxyTargetReachabilityByPageContext(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0
|
||||
? Number(options.timeoutMs)
|
||||
: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
const endpoints = resolveTargetReachabilityEndpoints(options);
|
||||
if (!endpoints.length) {
|
||||
return { reachable: true, skipped: true, source: 'target_page_context', endpoint: '' };
|
||||
}
|
||||
if (!chrome.tabs?.create || !chrome.tabs?.update || !chrome.scripting?.executeScript) {
|
||||
errors.push('target:page_context:unavailable');
|
||||
return {
|
||||
reachable: false,
|
||||
endpoint: endpoints[0],
|
||||
source: 'target_page_context_unavailable',
|
||||
error: 'target:page_context:unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
let tabId = null;
|
||||
let createdTabId = null;
|
||||
const navigationErrorTracker = createProbeNavigationErrorTracker(null);
|
||||
const perEndpointTimeoutMs = Math.max(2500, Math.min(IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, timeoutMs));
|
||||
|
||||
try {
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const endpoint = String(endpoints[index] || '').trim();
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
const targetUrl = appendProbeCacheBuster(endpoint, '_multipage_proxy_target');
|
||||
try {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
const tab = await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
active: false,
|
||||
});
|
||||
tabId = Number(tab?.id) || null;
|
||||
createdTabId = tabId;
|
||||
navigationErrorTracker.setTabId(tabId);
|
||||
} else {
|
||||
await chrome.tabs.update(tabId, {
|
||||
url: targetUrl,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isInteger(tabId)) {
|
||||
errors.push(`target:page_context:${endpoint}:no_tab`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs);
|
||||
if (!ready) {
|
||||
errors.push(`target:page_context:${endpoint}:not_ready`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentResult = await readTargetReachabilityFromTabDocument(tabId);
|
||||
const href = String(documentResult?.href || '').trim();
|
||||
const host = extractProbeHostFromTabUrl(href);
|
||||
const expectedHost = extractProbeHostFromTabUrl(endpoint);
|
||||
if (host && expectedHost && (host === expectedHost || host.endsWith(`.${expectedHost}`))) {
|
||||
return {
|
||||
reachable: true,
|
||||
endpoint,
|
||||
url: href,
|
||||
source: 'target_page_context',
|
||||
};
|
||||
}
|
||||
errors.push(`target:page_context:${endpoint}:unexpected_url:${href || 'unknown'}`);
|
||||
} catch (error) {
|
||||
errors.push(`target:page_context:${endpoint}:${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
navigationErrorTracker.appendDiagnostics(errors, 3);
|
||||
navigationErrorTracker.dispose();
|
||||
if (Number.isInteger(createdTabId)) {
|
||||
try {
|
||||
await chrome.tabs.remove(createdTabId);
|
||||
} catch {
|
||||
// ignore tab close failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reachable: false,
|
||||
endpoint: endpoints[0],
|
||||
source: 'target_page_context',
|
||||
error: buildProbeDiagnosticsSummary(errors, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectProxyExitInfoByPageContext(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
@@ -2675,6 +2874,16 @@ function buildIpProxyPacScript(entry) {
|
||||
}
|
||||
const targetPatterns = IP_PROXY_TARGET_HOST_PATTERNS.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
|
||||
const bypassList = IP_PROXY_BYPASS_LIST.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
|
||||
const forceDirectPatterns = (typeof IP_PROXY_FORCE_DIRECT_HOST_PATTERNS !== 'undefined' && Array.isArray(IP_PROXY_FORCE_DIRECT_HOST_PATTERNS)
|
||||
? IP_PROXY_FORCE_DIRECT_HOST_PATTERNS
|
||||
: [])
|
||||
.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`)
|
||||
.join(', ');
|
||||
const forceDirectFallback = String(
|
||||
typeof IP_PROXY_FORCE_DIRECT_FALLBACK !== 'undefined' && IP_PROXY_FORCE_DIRECT_FALLBACK
|
||||
? IP_PROXY_FORCE_DIRECT_FALLBACK
|
||||
: 'DIRECT'
|
||||
).replace(/"/g, '\\"');
|
||||
const proxyEndpoint = `${pacScheme} ${host}:${port}`;
|
||||
const routeAllLiteral = (typeof IP_PROXY_ROUTE_ALL_TRAFFIC !== 'undefined' && Boolean(IP_PROXY_ROUTE_ALL_TRAFFIC))
|
||||
? 'true'
|
||||
@@ -2690,6 +2899,22 @@ function FindProxyForURL(url, host) {
|
||||
}
|
||||
}
|
||||
|
||||
var forceDirectPatterns = [${forceDirectPatterns}];
|
||||
for (var fd = 0; fd < forceDirectPatterns.length; fd++) {
|
||||
var directPattern = forceDirectPatterns[fd];
|
||||
if (directPattern.indexOf('*.') === 0) {
|
||||
var directSuffix = directPattern.substring(1);
|
||||
var directHost = directPattern.substring(2);
|
||||
if (dnsDomainIs(host, directSuffix) || host === directHost) {
|
||||
return "${forceDirectFallback}";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (host === directPattern || dnsDomainIs(host, '.' + directPattern)) {
|
||||
return "${forceDirectFallback}";
|
||||
}
|
||||
}
|
||||
|
||||
var routeAllTraffic = ${routeAllLiteral};
|
||||
if (routeAllTraffic) {
|
||||
return "${proxyEndpoint}";
|
||||
@@ -3109,9 +3334,25 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) {
|
||||
const expectedRegion = String(entry?.region || '').trim();
|
||||
let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion);
|
||||
normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus);
|
||||
if (normalizedExitStatus.reason === 'connectivity_failed') {
|
||||
await setIpProxyLeakGuardEnabled(true);
|
||||
if (shouldVerifyIpProxyTargetReachability(normalizedExitStatus)) {
|
||||
const targetDiagnostics = [];
|
||||
const reachability = await detectIpProxyTargetReachabilityByPageContext({
|
||||
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
|
||||
errors: targetDiagnostics,
|
||||
}).catch((error) => ({
|
||||
reachable: false,
|
||||
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
|
||||
source: 'target_page_context',
|
||||
error: error?.message || String(error || 'target reachability failed'),
|
||||
}));
|
||||
normalizedExitStatus = applyTargetReachabilityExpectation(normalizedExitStatus, {
|
||||
...reachability,
|
||||
error: reachability?.reachable
|
||||
? ''
|
||||
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
|
||||
});
|
||||
}
|
||||
await syncIpProxyLeakGuardForStatus(normalizedExitStatus);
|
||||
await updateIpProxyRuntimeStatus(normalizedExitStatus);
|
||||
return normalizedExitStatus;
|
||||
}
|
||||
@@ -3425,6 +3666,32 @@ async function probeIpProxyExit(options = {}) {
|
||||
const probePromise = (async () => {
|
||||
const state = options.state || await getState();
|
||||
if (!state?.ipProxyEnabled) {
|
||||
if (options?.detectWhenDisabled) {
|
||||
const diagnostics = [];
|
||||
const exit = await detectProxyExitInfo({
|
||||
timeoutMs: Number(options?.timeoutMs) || 10000,
|
||||
errors: diagnostics,
|
||||
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
|
||||
username: String(state?.ipProxyUsername || '').trim(),
|
||||
preferPageContext: true,
|
||||
allowBackgroundFallback: true,
|
||||
}).catch(() => ({ ip: '', region: '', endpoint: '', source: '' }));
|
||||
const status = {
|
||||
enabled: false,
|
||||
applied: false,
|
||||
reason: 'disabled_probe_only',
|
||||
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
|
||||
exitDetecting: false,
|
||||
exitIp: String(exit?.ip || '').trim(),
|
||||
exitRegion: String(exit?.region || '').trim(),
|
||||
exitBaselineIp: String(exit?.baselineIp || '').trim(),
|
||||
exitEndpoint: String(exit?.endpoint || '').trim(),
|
||||
exitSource: String(exit?.source || '').trim().toLowerCase(),
|
||||
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
error: '',
|
||||
};
|
||||
return { proxyRouting: status };
|
||||
}
|
||||
const status = {
|
||||
enabled: false,
|
||||
applied: false,
|
||||
@@ -3488,6 +3755,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitDetecting: true,
|
||||
exitIp: '',
|
||||
exitRegion: '',
|
||||
exitEndpoint: '',
|
||||
exitError: '',
|
||||
exitSource: '',
|
||||
};
|
||||
@@ -3519,6 +3787,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitIp: String(exit?.ip || '').trim(),
|
||||
exitRegion: String(exit?.region || '').trim(),
|
||||
exitBaselineIp: String(exit?.baselineIp || '').trim(),
|
||||
exitEndpoint: String(exit?.endpoint || '').trim(),
|
||||
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
exitSource: String(exit?.source || '').trim().toLowerCase(),
|
||||
authDiagnostics: statusSeed?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '',
|
||||
@@ -3526,6 +3795,24 @@ async function probeIpProxyExit(options = {}) {
|
||||
const expectedRegion = String(statusSeed?.region || '').trim();
|
||||
let normalized = applyExitRegionExpectation(finalStatus, expectedRegion);
|
||||
normalized = applyExitBaselineExpectation(normalized);
|
||||
if (shouldVerifyIpProxyTargetReachability(normalized)) {
|
||||
const targetDiagnostics = [];
|
||||
const reachability = await detectIpProxyTargetReachabilityByPageContext({
|
||||
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
|
||||
errors: targetDiagnostics,
|
||||
}).catch((error) => ({
|
||||
reachable: false,
|
||||
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
|
||||
source: 'target_page_context',
|
||||
error: error?.message || String(error || 'target reachability failed'),
|
||||
}));
|
||||
normalized = applyTargetReachabilityExpectation(normalized, {
|
||||
...reachability,
|
||||
error: reachability?.reachable
|
||||
? ''
|
||||
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -3584,6 +3871,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitDetecting: true,
|
||||
exitIp: '',
|
||||
exitRegion: '',
|
||||
exitEndpoint: '',
|
||||
exitError: '',
|
||||
exitSource: '',
|
||||
};
|
||||
@@ -3612,9 +3900,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
normalizedFinalStatus = recovered.status;
|
||||
}
|
||||
}
|
||||
if (normalizedFinalStatus.reason === 'connectivity_failed') {
|
||||
await setIpProxyLeakGuardEnabled(true);
|
||||
}
|
||||
await syncIpProxyLeakGuardForStatus(normalizedFinalStatus);
|
||||
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
|
||||
return { proxyRouting: normalizedFinalStatus };
|
||||
})();
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
const HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY = 'heroSmsLastPriceCountryLabel';
|
||||
const HERO_SMS_LAST_PRICE_USER_LIMIT_KEY = 'heroSmsLastPriceUserLimit';
|
||||
const HERO_SMS_LAST_PRICE_AT_KEY = 'heroSmsLastPriceAt';
|
||||
const FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::';
|
||||
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
|
||||
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
|
||||
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
|
||||
@@ -207,11 +208,23 @@
|
||||
}
|
||||
|
||||
function resolveFiveSimCountryCandidates(state = {}) {
|
||||
const codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder);
|
||||
let codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder);
|
||||
if (!codes.length) {
|
||||
const legacyPrimary = normalizeFiveSimCountryCode(state?.fiveSimCountryId, '');
|
||||
const legacyFallback = normalizeFiveSimCountryOrder(state?.fiveSimCountryFallback);
|
||||
codes = normalizeFiveSimCountryOrder([
|
||||
...(legacyPrimary ? [legacyPrimary] : []),
|
||||
...legacyFallback,
|
||||
]);
|
||||
}
|
||||
return codes.map((code) => ({
|
||||
code,
|
||||
id: code,
|
||||
label: code,
|
||||
label: (
|
||||
code === normalizeFiveSimCountryCode(state?.fiveSimCountryId, '')
|
||||
? normalizeCountryLabel(state?.fiveSimCountryLabel, code)
|
||||
: code
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -571,10 +584,12 @@
|
||||
(Array.isArray(payloads) ? payloads : [])
|
||||
.flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, []))
|
||||
);
|
||||
const mergedCandidates = buildSortedUniquePriceCandidates([
|
||||
...inStockCandidates,
|
||||
...allCatalogCandidates,
|
||||
]);
|
||||
const mergedCandidates = inStockCandidates.length
|
||||
? buildSortedUniquePriceCandidates([
|
||||
...inStockCandidates,
|
||||
...allCatalogCandidates,
|
||||
])
|
||||
: [];
|
||||
const minCatalogPrice = allCatalogCandidates.length
|
||||
? allCatalogCandidates[0]
|
||||
: (mergedCandidates.length ? mergedCandidates[0] : null);
|
||||
@@ -1127,6 +1142,15 @@
|
||||
);
|
||||
}
|
||||
|
||||
function buildPhoneReplacementLimitError(maxNumberReplacementAttempts, reason = '') {
|
||||
const safeMax = Math.max(0, Math.floor(Number(maxNumberReplacementAttempts) || 0));
|
||||
const safeReason = String(reason || 'unknown').trim() || 'unknown';
|
||||
return new Error(
|
||||
`步骤 9:更换 ${safeMax} 次号码后手机号验证仍未成功。最后原因:${safeReason}. `
|
||||
+ `Step 9: phone verification did not succeed after ${safeMax} number replacements. Last reason: ${safeReason}.`
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizePhoneCodeTimeoutError(error) {
|
||||
const message = String(error?.message || '');
|
||||
if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) {
|
||||
@@ -1357,12 +1381,16 @@
|
||||
if (!apiKey) {
|
||||
throw new Error('5sim API key is missing. Save it in the side panel before running the phone flow.');
|
||||
}
|
||||
const configuredMaxPrice = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
|
||||
return {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl: normalizeUrl(state.fiveSimBaseUrl, DEFAULT_FIVE_SIM_BASE_URL).replace(/\/+$/, ''),
|
||||
operator: normalizeFiveSimCountryCode(state.fiveSimOperator, DEFAULT_FIVE_SIM_OPERATOR),
|
||||
product: normalizeFiveSimCountryCode(state.fiveSimProduct, DEFAULT_FIVE_SIM_PRODUCT),
|
||||
maxPriceLimit: configuredMaxPrice !== null
|
||||
? configuredMaxPrice
|
||||
: normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice),
|
||||
countryCandidates: resolveFiveSimCountryCandidates(state),
|
||||
};
|
||||
}
|
||||
@@ -1468,9 +1496,15 @@
|
||||
}
|
||||
|
||||
function resolveHeroSmsStockState(payload = {}) {
|
||||
const physicalCount = Number(payload.physicalCount);
|
||||
if (Number.isFinite(physicalCount)) {
|
||||
return {
|
||||
hasStockField: true,
|
||||
stockCount: physicalCount,
|
||||
};
|
||||
}
|
||||
const stockCandidates = [
|
||||
payload.count,
|
||||
payload.physicalCount,
|
||||
payload.stock,
|
||||
payload.available,
|
||||
payload.quantity,
|
||||
@@ -1869,6 +1903,26 @@
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function collectFiveSimProductPriceCandidates(payload, product = DEFAULT_FIVE_SIM_PRODUCT, candidates = []) {
|
||||
if (Array.isArray(payload)) {
|
||||
payload.forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
|
||||
return candidates;
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return candidates;
|
||||
}
|
||||
const productPayload = payload[product] || payload[String(product || '').toLowerCase()];
|
||||
if (productPayload && typeof productPayload === 'object') {
|
||||
const price = Number(productPayload.Price ?? productPayload.price ?? productPayload.cost);
|
||||
const qty = Number(productPayload.Qty ?? productPayload.qty ?? productPayload.count);
|
||||
if (Number.isFinite(price) && price > 0 && (!Number.isFinite(qty) || qty > 0)) {
|
||||
candidates.push(Math.round(price * 10000) / 10000);
|
||||
}
|
||||
}
|
||||
Object.values(payload).forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') {
|
||||
const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT);
|
||||
const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, '');
|
||||
@@ -1892,6 +1946,21 @@
|
||||
return /no\s+free\s+phones|no\s+phones\s+available|no\s+numbers\s+available/i.test(text);
|
||||
}
|
||||
|
||||
function isFiveSimRateLimitError(payloadOrMessage, status = 0) {
|
||||
if (Number(status) === 429) {
|
||||
return true;
|
||||
}
|
||||
const text = describeFiveSimPayload(payloadOrMessage);
|
||||
return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text);
|
||||
}
|
||||
|
||||
function buildFiveSimRateLimitError(details = []) {
|
||||
const suffix = Array.isArray(details) && details.length
|
||||
? `:${details.join(' | ')}。`
|
||||
: '。';
|
||||
return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`);
|
||||
}
|
||||
|
||||
function isFiveSimTerminalError(payloadOrMessage, status = 0) {
|
||||
if (Number(status) === 401 || Number(status) === 403) {
|
||||
return true;
|
||||
@@ -1995,7 +2064,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const maxPriceLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
|
||||
const maxPriceLimit = config.maxPriceLimit === undefined
|
||||
? normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice)
|
||||
: config.maxPriceLimit;
|
||||
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
|
||||
const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
|
||||
const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap(
|
||||
@@ -2007,6 +2078,7 @@
|
||||
const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
|
||||
|
||||
let finalNoNumbersByCountry = [];
|
||||
let finalRateLimitByCountry = [];
|
||||
let finalLastError = null;
|
||||
|
||||
for (let round = 1; round <= maxAcquireRounds; round += 1) {
|
||||
@@ -2017,6 +2089,7 @@
|
||||
);
|
||||
}
|
||||
const noNumbersByCountry = [];
|
||||
const rateLimitByCountry = [];
|
||||
const retryableNoNumberCountries = [];
|
||||
let lastError = null;
|
||||
|
||||
@@ -2068,7 +2141,20 @@
|
||||
const countryLabel = String(countryConfig.label || countryCode).trim() || countryCode;
|
||||
const countryPriceFloor = countryPriceFloorByCountryCode.get(countryCode) ?? null;
|
||||
try {
|
||||
const explicitFiveSimMaxPriceLimit = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
|
||||
let guestPricesPayload = null;
|
||||
let productPricesPayload = null;
|
||||
if (explicitFiveSimMaxPriceLimit !== null) {
|
||||
try {
|
||||
productPricesPayload = await fetchFiveSimPayload(
|
||||
config,
|
||||
`/guest/products/${countryCode}/${config.operator}`,
|
||||
'5sim guest products'
|
||||
);
|
||||
} catch (_) {
|
||||
productPricesPayload = null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
guestPricesPayload = await fetchFiveSimPayload(
|
||||
config,
|
||||
@@ -2086,16 +2172,23 @@
|
||||
}
|
||||
|
||||
const rawPriceCandidates = buildSortedUniquePriceCandidates(
|
||||
collectFiveSimPriceCandidates(
|
||||
(
|
||||
guestPricesPayload
|
||||
&& typeof guestPricesPayload === 'object'
|
||||
&& !Array.isArray(guestPricesPayload)
|
||||
? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload)
|
||||
: guestPricesPayload
|
||||
[
|
||||
...(
|
||||
normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice) !== null
|
||||
? collectFiveSimProductPriceCandidates(productPricesPayload, config.product, [])
|
||||
: []
|
||||
),
|
||||
[]
|
||||
)
|
||||
...collectFiveSimPriceCandidates(
|
||||
(
|
||||
guestPricesPayload
|
||||
&& typeof guestPricesPayload === 'object'
|
||||
&& !Array.isArray(guestPricesPayload)
|
||||
? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload)
|
||||
: guestPricesPayload
|
||||
),
|
||||
[]
|
||||
),
|
||||
]
|
||||
);
|
||||
const boundedPriceCandidates = maxPriceLimit === null
|
||||
? rawPriceCandidates
|
||||
@@ -2106,7 +2199,14 @@
|
||||
preferredPriceTier
|
||||
);
|
||||
const orderedPrices = orderedPricesFromCatalog.length
|
||||
? orderedPricesFromCatalog
|
||||
? (
|
||||
explicitFiveSimMaxPriceLimit !== null
|
||||
? [
|
||||
explicitFiveSimMaxPriceLimit,
|
||||
...orderedPricesFromCatalog.filter((price) => Number(price) !== Number(explicitFiveSimMaxPriceLimit)),
|
||||
]
|
||||
: orderedPricesFromCatalog
|
||||
)
|
||||
: (maxPriceLimit !== null ? [maxPriceLimit] : [null]);
|
||||
const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor);
|
||||
const hasCountryPriceFloor = (
|
||||
@@ -2181,6 +2281,10 @@
|
||||
break;
|
||||
}
|
||||
const payloadText = describeFiveSimPayload(payload);
|
||||
if (isFiveSimRateLimitError(payload)) {
|
||||
countryNoNumbersText = payloadText || countryNoNumbersText || 'rate limit';
|
||||
continue;
|
||||
}
|
||||
if (isFiveSimNoNumbersError(payload)) {
|
||||
countryNoNumbersText = payloadText || countryNoNumbersText || 'no free phones';
|
||||
continue;
|
||||
@@ -2190,6 +2294,10 @@
|
||||
}
|
||||
lastError = new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`);
|
||||
} catch (error) {
|
||||
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
|
||||
countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'rate limit';
|
||||
continue;
|
||||
}
|
||||
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
|
||||
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
|
||||
}
|
||||
@@ -2210,12 +2318,18 @@
|
||||
noNumbersByCountry.push(
|
||||
`${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}`
|
||||
);
|
||||
} else if (isFiveSimRateLimitError(countryNoNumbersText)) {
|
||||
rateLimitByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'rate limit'}`);
|
||||
} else {
|
||||
noNumbersByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'no free phones'}`);
|
||||
retryableNoNumberCountries.push(countryLabel);
|
||||
}
|
||||
continue;
|
||||
} catch (error) {
|
||||
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
|
||||
rateLimitByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'rate limit'}`);
|
||||
continue;
|
||||
}
|
||||
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
|
||||
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
|
||||
}
|
||||
@@ -2236,8 +2350,13 @@
|
||||
}
|
||||
|
||||
finalNoNumbersByCountry = noNumbersByCountry;
|
||||
finalRateLimitByCountry = rateLimitByCountry;
|
||||
finalLastError = lastError;
|
||||
|
||||
if (rateLimitByCountry.length) {
|
||||
throw buildFiveSimRateLimitError(rateLimitByCountry);
|
||||
}
|
||||
|
||||
if (
|
||||
noNumbersByCountry.length
|
||||
&& round < maxAcquireRounds
|
||||
@@ -2259,6 +2378,9 @@
|
||||
`5sim no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
|
||||
);
|
||||
}
|
||||
if (finalRateLimitByCountry.length) {
|
||||
throw buildFiveSimRateLimitError(finalRateLimitByCountry);
|
||||
}
|
||||
if (finalLastError) {
|
||||
throw finalLastError;
|
||||
}
|
||||
@@ -2714,6 +2836,10 @@
|
||||
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
|
||||
'info'
|
||||
);
|
||||
await addLog(
|
||||
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
|
||||
@@ -2935,6 +3061,10 @@
|
||||
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`,
|
||||
'warn'
|
||||
);
|
||||
await addLog(
|
||||
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮),${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`,
|
||||
'warn'
|
||||
);
|
||||
await sleepWithStop(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
@@ -2945,6 +3075,7 @@
|
||||
if (finalNoNumbersByCountry.length) {
|
||||
throw new Error(
|
||||
`HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}。`
|
||||
+ ` HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
|
||||
);
|
||||
}
|
||||
if (finalLastError) {
|
||||
@@ -3606,7 +3737,9 @@
|
||||
const successfulUses = normalizedActivation.successfulUses + 1;
|
||||
const nextReusableActivation = {
|
||||
...normalizedActivation,
|
||||
successfulUses,
|
||||
successfulUses: normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM
|
||||
? 1
|
||||
: successfulUses,
|
||||
};
|
||||
await upsertReusableActivationPool(nextReusableActivation, { state });
|
||||
if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) {
|
||||
@@ -3907,10 +4040,147 @@
|
||||
countryPriceFloorByKey.delete(countryKey);
|
||||
};
|
||||
|
||||
const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries())
|
||||
.filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD)
|
||||
.map(([countryId]) => countryId)
|
||||
.filter(Boolean);
|
||||
const getBlockedCountryIds = () => {
|
||||
const activeProvider = normalizePhoneSmsProvider(
|
||||
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
|
||||
);
|
||||
return Array.from(countrySmsFailureCounts.entries())
|
||||
.filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD)
|
||||
.map(([countryKey]) => splitCountryFailureKey(countryKey, activeProvider))
|
||||
.filter((entry) => entry.provider === activeProvider)
|
||||
.map((entry) => String(entry.countryKey || '').trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getCountryPriceFloorById = () => {
|
||||
const activeProvider = normalizePhoneSmsProvider(
|
||||
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
|
||||
);
|
||||
const floorById = {};
|
||||
countryPriceFloorByKey.forEach((price, compoundCountryKey) => {
|
||||
const numeric = normalizeHeroSmsPrice(price);
|
||||
if (numeric === null || numeric <= 0) {
|
||||
return;
|
||||
}
|
||||
const parsed = splitCountryFailureKey(compoundCountryKey, activeProvider);
|
||||
if (parsed.provider !== activeProvider) {
|
||||
return;
|
||||
}
|
||||
const keyPart = String(parsed.countryKey || '').trim();
|
||||
if (!keyPart) {
|
||||
return;
|
||||
}
|
||||
floorById[keyPart] = Math.round(numeric * 10000) / 10000;
|
||||
});
|
||||
return floorById;
|
||||
};
|
||||
|
||||
const setCountryPriceFloorFromActivation = async (activationCandidate, reason = '') => {
|
||||
const normalizedActivation = normalizeActivation(activationCandidate);
|
||||
if (!normalizedActivation) {
|
||||
return;
|
||||
}
|
||||
const countryKey = normalizeCountryFailureKey(
|
||||
normalizedActivation.countryId,
|
||||
normalizedActivation.provider
|
||||
);
|
||||
if (!countryKey) {
|
||||
return;
|
||||
}
|
||||
const floorPrice = normalizeHeroSmsPrice(
|
||||
normalizedActivation.price
|
||||
?? normalizedActivation.maxPrice
|
||||
?? normalizedActivation.selectedPrice
|
||||
?? getActivationAcquiredPriceHint(normalizedActivation)
|
||||
);
|
||||
if (floorPrice === null || floorPrice <= 0) {
|
||||
return;
|
||||
}
|
||||
const currentFloor = normalizeHeroSmsPrice(countryPriceFloorByKey.get(countryKey));
|
||||
if (currentFloor !== null && currentFloor >= floorPrice) {
|
||||
return;
|
||||
}
|
||||
const normalizedFloor = Math.round(floorPrice * 10000) / 10000;
|
||||
countryPriceFloorByKey.set(countryKey, normalizedFloor);
|
||||
const countryLabel = resolveCountryLabelByFailureKey(countryKey, normalizedActivation.provider);
|
||||
await addLog(
|
||||
`Step 9: ${countryLabel} will try a higher price tier (> ${normalizedFloor}) due to ${reason || 'sms timeout'}.`,
|
||||
'warn'
|
||||
);
|
||||
};
|
||||
|
||||
const isPreferredActivation = (activationCandidate, stateSnapshot = {}) => (
|
||||
isSameActivation(
|
||||
stateSnapshot?.[PREFERRED_PHONE_ACTIVATION_STATE_KEY],
|
||||
activationCandidate
|
||||
)
|
||||
);
|
||||
|
||||
const markPreferredActivationExhausted = async (reason = '') => {
|
||||
if (preferredActivationExhausted || !activation || !isPreferredActivation(activation, state)) {
|
||||
return;
|
||||
}
|
||||
preferredActivationExhausted = true;
|
||||
await addLog(
|
||||
`Step 9: preferred number ${activation.phoneNumber} failed (${reason || 'unknown reason'}), falling back to a new number.`,
|
||||
'warn'
|
||||
);
|
||||
};
|
||||
|
||||
const rotateActivationAfterAddPhoneFailure = async (failureReason, failureCode, submitState = {}) => {
|
||||
await markPreferredActivationExhausted(failureCode || failureReason);
|
||||
usedNumberReplacementAttempts += 1;
|
||||
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
|
||||
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, failureCode || 'add_phone_rejected');
|
||||
}
|
||||
await addLog(
|
||||
`Step 9: replacing number after add-phone failure (${failureReason}) (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
|
||||
'warn'
|
||||
);
|
||||
if (shouldCancelActivation && activation) {
|
||||
await cancelPhoneActivation(state, activation);
|
||||
}
|
||||
await clearCurrentActivation();
|
||||
activation = null;
|
||||
shouldCancelActivation = false;
|
||||
preferReuseExistingActivationOnAddPhone = false;
|
||||
addPhoneReentryWithSameActivation = 0;
|
||||
let addPhoneSnapshot = {
|
||||
...pageState,
|
||||
...submitState,
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
};
|
||||
try {
|
||||
const returned = await returnToAddPhone(tabId);
|
||||
addPhoneSnapshot = {
|
||||
...addPhoneSnapshot,
|
||||
...returned,
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
};
|
||||
} catch (returnError) {
|
||||
await addLog(
|
||||
`Step 9: failed to return to add-phone page after rejection, will continue with best-effort state. ${returnError.message}`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
try {
|
||||
const verified = await ensureAddPhonePageBeforeSubmit('after add-phone rejection');
|
||||
addPhoneSnapshot = {
|
||||
...addPhoneSnapshot,
|
||||
...verified,
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
};
|
||||
} catch (verifyError) {
|
||||
await addLog(
|
||||
`Step 9: failed to verify add-phone state after rejection. ${verifyError.message}`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
pageState = addPhoneSnapshot;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -3941,9 +4211,7 @@
|
||||
if (addPhoneReentryWithSameActivation > 1) {
|
||||
usedNumberReplacementAttempts += 1;
|
||||
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
|
||||
throw new Error(
|
||||
`步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('returned_to_add_phone_loop')}。`
|
||||
);
|
||||
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, 'returned_to_add_phone_loop');
|
||||
}
|
||||
await addLog(
|
||||
`步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`,
|
||||
@@ -4191,9 +4459,7 @@
|
||||
|
||||
usedNumberReplacementAttempts += 1;
|
||||
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
|
||||
throw new Error(
|
||||
`步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason(replaceReason)}。`
|
||||
);
|
||||
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, replaceReason || 'unknown');
|
||||
}
|
||||
|
||||
if (shouldCancelActivation && activation) {
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
setState,
|
||||
sleepWithStop,
|
||||
waitForTabCompleteUntilStopped,
|
||||
probeIpProxyExit = null,
|
||||
} = deps;
|
||||
|
||||
function isPlusCheckoutUrl(url = '') {
|
||||
@@ -115,11 +116,31 @@
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePostalCodeForCountry(countryCode, rawPostalCode = '', fallbackPostalCode = '') {
|
||||
const normalizedCountry = resolveMeiguodizhiCountryCode(countryCode) || normalizeText(countryCode).toUpperCase();
|
||||
const postalCode = normalizeText(rawPostalCode);
|
||||
const fallback = normalizeText(fallbackPostalCode);
|
||||
if (normalizedCountry !== 'KR') {
|
||||
return postalCode;
|
||||
}
|
||||
if (/^\d{5}$/.test(postalCode)) {
|
||||
return postalCode;
|
||||
}
|
||||
if (/^\d{5}$/.test(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return '04524';
|
||||
}
|
||||
|
||||
function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) {
|
||||
const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address);
|
||||
const city = normalizeText(apiAddress?.City);
|
||||
const region = normalizeText(apiAddress?.State_Full || apiAddress?.State);
|
||||
const postalCode = normalizeText(apiAddress?.Zip_Code);
|
||||
const postalCode = normalizePostalCodeForCountry(
|
||||
countryCode,
|
||||
apiAddress?.Zip_Code,
|
||||
fallbackSeed?.fallback?.postalCode
|
||||
);
|
||||
if (!address1 || !city || !postalCode) {
|
||||
return null;
|
||||
}
|
||||
@@ -197,11 +218,47 @@
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBillingAddressCountry(state = {}, countryOverride = '', paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const normalizedPaymentMethod = normalizePlusPaymentMethod(paymentMethod || state?.plusPaymentMethod);
|
||||
const checkoutCountry = resolveMeiguodizhiCountryCode(countryOverride);
|
||||
const savedCheckoutCountry = resolveMeiguodizhiCountryCode(state.plusCheckoutCountry);
|
||||
const exitCountry = resolveMeiguodizhiCountryCode(
|
||||
state.ipProxyAppliedExitRegion
|
||||
|| state.ipProxyExitRegion
|
||||
|| ''
|
||||
);
|
||||
|
||||
if (normalizedPaymentMethod === PLUS_PAYMENT_METHOD_GOPAY) {
|
||||
const countryCode = exitCountry || checkoutCountry || savedCheckoutCountry || 'ID';
|
||||
return {
|
||||
countryCode,
|
||||
requestedCountry: exitCountry
|
||||
|| normalizeText(countryOverride)
|
||||
|| normalizeText(state.plusCheckoutCountry)
|
||||
|| 'ID',
|
||||
source: exitCountry ? 'proxy_exit' : (checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : 'gopay_fallback')),
|
||||
};
|
||||
}
|
||||
|
||||
const countryCode = checkoutCountry || savedCheckoutCountry || exitCountry || 'DE';
|
||||
return {
|
||||
countryCode,
|
||||
requestedCountry: normalizeText(countryOverride)
|
||||
|| normalizeText(state.plusCheckoutCountry)
|
||||
|| exitCountry
|
||||
|| 'DE',
|
||||
source: checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : (exitCountry ? 'proxy_exit' : 'paypal_fallback')),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBillingAddressSeed(state = {}, countryOverride = '', options = {}) {
|
||||
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod || state?.plusPaymentMethod);
|
||||
const forcedCountry = paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'ID' : '';
|
||||
const requestedCountry = normalizeText(forcedCountry || countryOverride || state.plusCheckoutCountry || 'DE');
|
||||
const countryCode = forcedCountry || resolveMeiguodizhiCountryCode(requestedCountry) || 'DE';
|
||||
const countryResolution = resolveBillingAddressCountry(state, countryOverride, paymentMethod);
|
||||
const countryCode = countryResolution.countryCode;
|
||||
const requestedCountry = countryResolution.requestedCountry;
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && countryResolution.source === 'proxy_exit') {
|
||||
await addLog(`步骤 7:GoPay 账单地址将按当前代理出口地区 ${countryCode} 填写。`, 'info');
|
||||
}
|
||||
const localSeed = getLocalAddressSeed(countryCode);
|
||||
const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode);
|
||||
if (!lookupSeed) {
|
||||
@@ -610,7 +667,67 @@
|
||||
await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info');
|
||||
}
|
||||
|
||||
const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText, { paymentMethod });
|
||||
let billingState = state;
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && typeof probeIpProxyExit === 'function') {
|
||||
const staleExitRegion = normalizeText(
|
||||
state?.ipProxyAppliedExitRegion
|
||||
|| state?.ipProxyExitRegion
|
||||
|| ''
|
||||
);
|
||||
try {
|
||||
await addLog('步骤 7:GoPay 账单地址准备按代理出口填写,正在重新检测当前出口地区...', 'info');
|
||||
const probeResult = await probeIpProxyExit({
|
||||
state,
|
||||
timeoutMs: 12000,
|
||||
authRebindRetry: true,
|
||||
detectWhenDisabled: true,
|
||||
});
|
||||
const routing = probeResult?.proxyRouting || {};
|
||||
const probedExitRegion = normalizeText(routing.exitRegion || '');
|
||||
const probedExitIp = normalizeText(routing.exitIp || '');
|
||||
const probedExitSource = normalizeText(routing.exitSource || '');
|
||||
const probeEndpoint = normalizeText(routing.endpoint || routing.exitEndpoint || '');
|
||||
const probeReason = normalizeText(routing.reason || '');
|
||||
const probeError = normalizeText(routing.exitError || routing.error || '');
|
||||
if (probedExitRegion) {
|
||||
billingState = {
|
||||
...(state || {}),
|
||||
ipProxyAppliedExitRegion: probedExitRegion,
|
||||
ipProxyExitRegion: probedExitRegion,
|
||||
ipProxyAppliedExitIp: probedExitIp,
|
||||
ipProxyAppliedExitSource: probedExitSource,
|
||||
};
|
||||
const sourceSuffix = probedExitSource ? `,来源 ${probedExitSource}` : '';
|
||||
const endpointSuffix = probeEndpoint ? `,检测地址 ${probeEndpoint}` : '';
|
||||
await addLog(`步骤 7:当前代理出口复测结果:${probedExitRegion}${probedExitIp ? ` / ${probedExitIp}` : ''}${sourceSuffix}${endpointSuffix}。`, 'info');
|
||||
} else {
|
||||
billingState = {
|
||||
...(state || {}),
|
||||
ipProxyAppliedExitRegion: '',
|
||||
ipProxyExitRegion: '',
|
||||
ipProxyAppliedExitIp: probedExitIp,
|
||||
ipProxyAppliedExitSource: probedExitSource,
|
||||
};
|
||||
await addLog(
|
||||
`步骤 7:代理出口复测没有返回国家/地区代码,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区。${probeReason ? `状态:${probeReason}。` : ''}${probeError ? `诊断:${probeError}` : ''}`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
billingState = {
|
||||
...(state || {}),
|
||||
ipProxyAppliedExitRegion: '',
|
||||
ipProxyExitRegion: '',
|
||||
};
|
||||
await addLog(`步骤 7:代理出口复测失败,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区:${error?.message || String(error || '未知错误')}`, 'warn');
|
||||
}
|
||||
}
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY
|
||||
&& typeof probeIpProxyExit === 'function'
|
||||
&& !resolveMeiguodizhiCountryCode(billingState?.ipProxyAppliedExitRegion || billingState?.ipProxyExitRegion || '')) {
|
||||
throw new Error('步骤 7:GoPay 账单地址需要当前代理出口国家/地区,但本次复测没有拿到国家码;已停止填写,避免误用旧的 KR/ID 地区。请先点 IP 代理“检测出口”,确认显示 JP 后再继续。');
|
||||
}
|
||||
const addressSeed = await resolveBillingAddressSeed(billingState, billingFrame.countryText, { paymentMethod });
|
||||
if (!addressSeed) {
|
||||
throw new Error('步骤 7:未找到可用的本地账单地址种子。');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
const GOPAY_INJECT_FILES = ['content/utils.js', 'content/gopay-flow.js'];
|
||||
const GOPAY_WAIT_TIMEOUT_MS = 120000;
|
||||
const GOPAY_POLL_INTERVAL_MS = 1000;
|
||||
const GOPAY_LINKING_RETRY_WAIT_MS = 15000;
|
||||
const GOPAY_LINKING_STABLE_WAIT_MS = 60000;
|
||||
const GOPAY_OTP_FRAME_URL_PATTERN = /\/linking\/otp\b|gopayapi\.com\/linking\/otp/i;
|
||||
const GOPAY_PIN_FRAME_URL_PATTERN = /pin-web-client\.gopayapi\.com\/auth\/pin|\/auth\/pin\/verify|linking-validate-pin|merchants-gws-app\.gopayapi\.com\/payment\/validate-pin|\/payment\/validate-pin/i;
|
||||
const GOPAY_PAYMENT_FRAME_URL_PATTERN = /merchants-gws-app\.gopayapi\.com\/(?:payment\/details|app\/challenge)|\/gopay-tokenization\/pay/i;
|
||||
@@ -41,6 +43,37 @@
|
||||
return `${terminalMessage}${rawSuffix}`;
|
||||
}
|
||||
|
||||
function createGoPayStableStateTracker() {
|
||||
let signature = '';
|
||||
let firstSeenAt = 0;
|
||||
return {
|
||||
update(pageState = {}, currentUrl = '') {
|
||||
const nextSignature = [
|
||||
pageState.url || currentUrl || '',
|
||||
pageState.hasPhoneInput ? 'phone' : '',
|
||||
pageState.hasOtpInput ? 'otp' : '',
|
||||
pageState.hasPinInput ? 'pin' : '',
|
||||
pageState.hasPayNowButton ? 'pay' : '',
|
||||
pageState.hasContinueButton ? 'continue' : '',
|
||||
normalizeText(pageState.textPreview || '').slice(0, 700),
|
||||
].join('::');
|
||||
const now = Date.now();
|
||||
if (nextSignature !== signature) {
|
||||
signature = nextSignature;
|
||||
firstSeenAt = now;
|
||||
}
|
||||
return {
|
||||
signature,
|
||||
stableMs: firstSeenAt ? now - firstSeenAt : 0,
|
||||
};
|
||||
},
|
||||
reset() {
|
||||
signature = '';
|
||||
firstSeenAt = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function restartGoPayCheckoutFromStep6(tabId, reason = '') {
|
||||
const message = normalizeText(reason || 'GoPay 支付页已失效或点击后没有进入下一步。');
|
||||
await addLog(`步骤 8:${message} 正在关闭当前 GoPay/Checkout 页面,并回到步骤 6 重新创建 Plus Checkout。`, 'warn');
|
||||
@@ -196,14 +229,16 @@
|
||||
el.inputMode,
|
||||
].filter(Boolean).join(' ')));
|
||||
const hasTerminalError = /waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|technical\s+error|terjadi\s+kesalahan|payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|declined|failed/i.test(text);
|
||||
const hasPinInput = /pin|6\s*digit|masukkin\s+pin|ketik\s+6\s+digit/i.test(text)
|
||||
|| inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint));
|
||||
const isPinPage = /pin|6\s*digit|masukkin\s+pin|masukkan\s+pin|ketik\s+6\s+digit|enter\s+pin|支付密码/i.test(text)
|
||||
|| /pin-web-client\.gopayapi\.com|\/auth\/pin|\/payment\/validate-pin|linking-validate-pin/i.test(location.href || '');
|
||||
const hasPinInput = isPinPage
|
||||
|| inputHints.some((hint) => /pin-input|(?:^|[\s_-])pin(?:$|[\s_-])|password|numeric|支付密码/i.test(hint));
|
||||
const hasOtpInput = !hasPinInput && (/otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text)
|
||||
|| inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint)));
|
||||
const hasPayNowButton = controlTexts.some((item) => /^\s*pay\s+now\s*$/i.test(item)
|
||||
|| /^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i.test(item)
|
||||
|| /(?:^|\s)pay-button(?:\s|$)/i.test(item));
|
||||
const hasContinueButton = controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item));
|
||||
const hasContinueButton = controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item));
|
||||
return {
|
||||
url: location.href,
|
||||
hasTerminalError,
|
||||
@@ -320,10 +355,10 @@
|
||||
.filter((el) => visible(el) && enabled(el));
|
||||
const controlTexts = controls.map(getText);
|
||||
const inputHints = inputs.map(getText);
|
||||
const isPinPage = /pin|password|passcode|security|sandi|6\\s*digit|masukkin\\s+pin|ketik\\s+6\\s+digit/i.test(bodyText)
|
||||
|| /pin-web-client\\.gopayapi\\.com|\\/auth\\/pin|\\/payment\\/validate-pin/i.test(location.href || '');
|
||||
const isPinPage = /pin|password|passcode|security|sandi|6\\s*digit|masukkin\\s+pin|masukkan\\s+pin|ketik\\s+6\\s+digit|enter\\s+pin|支付密码/i.test(bodyText)
|
||||
|| /pin-web-client\\.gopayapi\\.com|\\/auth\\/pin|\\/payment\\/validate-pin|linking-validate-pin/i.test(location.href || '');
|
||||
const hasTerminalError = /waktunya\\s+habis|ulang(?:i)?\\s+prosesnya\\s+dari\\s+awal|time(?:'s|\\s+is)?\\s+(?:out|expired)|session\\s+expired|expired|kedaluwarsa|technical\\s+error|terjadi\\s+kesalahan|error\\s+teknis|kendala\\s+teknis|gak\\s+bisa\\s+diproses|coba\\s+lagi\\s+nanti|payment\\s+failed|pembayaran\\s+gagal|transaksi\\s+gagal|ditolak|declined|failed/i.test(bodyText);
|
||||
const hasPinInput = isPinPage || inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint));
|
||||
const hasPinInput = isPinPage || inputHints.some((hint) => /pin-input|(?:^|[\\s_-])pin(?:$|[\\s_-])|password|numeric|支付密码/i.test(hint));
|
||||
const hasOtpInput = !hasPinInput && (/otp|one[-\\s]*time|kode|verification|whatsapp|验证码|短信/i.test(bodyText)
|
||||
|| inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint)));
|
||||
const hasPhoneInput = !hasOtpInput && !hasPinInput && inputs.some((input) => {
|
||||
@@ -335,7 +370,7 @@
|
||||
const hasPayNowButton = controlTexts.some((item) => /^\\s*pay\\s+now\\s*$/i.test(item)
|
||||
|| /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(item)
|
||||
|| /(?:^|\\s)pay-button(?:\\s|$)/i.test(item));
|
||||
const hasContinueButton = !hasPayNowButton && !hasPhoneInput && controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item));
|
||||
const hasContinueButton = !hasPayNowButton && !hasPhoneInput && controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item));
|
||||
const completed = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText)
|
||||
&& !hasPhoneInput
|
||||
&& !hasOtpInput
|
||||
@@ -454,7 +489,7 @@
|
||||
if (/^\\s*pay\\s+now\\s*$/i.test(text) || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(text);
|
||||
return /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(text);
|
||||
});
|
||||
if (!target) {
|
||||
return { clicked: false, reason: 'target_not_found', url: location.href, textPreview: normalize(document.body?.innerText || '').slice(0, 240) };
|
||||
@@ -539,7 +574,7 @@
|
||||
&& (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0));
|
||||
};
|
||||
const inputs = Array.from(document.querySelectorAll('input, textarea')).filter((el) => visible(el) && !el.disabled);
|
||||
const target = inputs.find((el) => /otp|one[-\\s]*time|kode|verification|whatsapp|code|pin-input-field/i.test([
|
||||
const target = inputs.find((el) => /otp|one[-\\s]*time|kode|verification|whatsapp|code/i.test([
|
||||
el.getAttribute?.('data-testid'),
|
||||
el.getAttribute?.('aria-label'),
|
||||
el.getAttribute?.('placeholder'),
|
||||
@@ -1005,6 +1040,38 @@
|
||||
return { timeout: true };
|
||||
}
|
||||
|
||||
async function clickGoPayContinueBestEffort(tabId) {
|
||||
const actionFrame = await findGoPayActionFrame(tabId);
|
||||
const actionFrameId = actionFrame.frameId;
|
||||
const actionTargetId = actionFrame.targetId;
|
||||
if (Number.isInteger(actionFrameId)) {
|
||||
await ensureGoPayOtpFrameReady(tabId, actionFrameId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (actionTargetId) {
|
||||
const result = await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_CLICK_CONTINUE', {});
|
||||
if (result?.clicked) {
|
||||
return result;
|
||||
}
|
||||
} else if (Number.isInteger(actionFrameId)) {
|
||||
const result = await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_CLICK_CONTINUE', {});
|
||||
if (result?.clicked) {
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
const result = await sendGoPayCommand(tabId, 'GOPAY_CLICK_CONTINUE', {});
|
||||
if (result?.clicked) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through to a real debugger click below.
|
||||
}
|
||||
|
||||
return clickGoPayContinueWithDebugger(tabId, actionFrameId);
|
||||
}
|
||||
|
||||
function normalizeGoPayCountryCode(value = '') {
|
||||
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
@@ -1062,6 +1129,7 @@
|
||||
lastContinueClickSignature = '';
|
||||
payNowClickAttempts = 0;
|
||||
lastPayNowClickSignature = '';
|
||||
stableStateTracker?.reset?.();
|
||||
}
|
||||
|
||||
let phoneSubmitted = false;
|
||||
@@ -1072,6 +1140,7 @@
|
||||
let lastContinueClickSignature = '';
|
||||
let payNowClickAttempts = 0;
|
||||
let lastPayNowClickSignature = '';
|
||||
const stableStateTracker = createGoPayStableStateTracker();
|
||||
|
||||
while (true) {
|
||||
throwIfStopped?.();
|
||||
@@ -1094,6 +1163,7 @@
|
||||
: (Number.isInteger(actionFrameId)
|
||||
? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_GET_STATE', {})
|
||||
: await getGoPayState(tabId));
|
||||
const stableState = stableStateTracker.update(pageState, currentUrl);
|
||||
await handleGoPayTerminalError(pageState, tabId);
|
||||
|
||||
if (pageState.completed) {
|
||||
@@ -1194,27 +1264,6 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageState.hasOtpInput && !otpSubmitted) {
|
||||
const code = await requestManualGoPayOtp(credentials.otp);
|
||||
credentials.otp = code;
|
||||
await addLog('步骤 8:正在填写 GoPay 验证码...', 'info');
|
||||
if (actionTargetId) {
|
||||
await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
} else if (Number.isInteger(actionFrameId)) {
|
||||
await ensureGoPayOtpFrameReady(tabId, actionFrameId);
|
||||
await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
} else {
|
||||
await ensureGoPayReady(tabId, '步骤 8:已获取 GoPay 验证码,等待 GoPay 页面就绪...');
|
||||
await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
}
|
||||
otpSubmitted = true;
|
||||
continueClickAttempts = 0;
|
||||
lastContinueClickSignature = '';
|
||||
loggedWaiting = false;
|
||||
await sleepWithStop(1500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageState.hasPinInput && !pinSubmitted) {
|
||||
await addLog('步骤 8:正在填写 GoPay PIN...', 'info');
|
||||
if (actionTargetId) {
|
||||
@@ -1247,6 +1296,27 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageState.hasOtpInput && !pageState.hasPinInput && !otpSubmitted) {
|
||||
const code = await requestManualGoPayOtp(credentials.otp);
|
||||
credentials.otp = code;
|
||||
await addLog('步骤 8:正在填写 GoPay 验证码...', 'info');
|
||||
if (actionTargetId) {
|
||||
await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
} else if (Number.isInteger(actionFrameId)) {
|
||||
await ensureGoPayOtpFrameReady(tabId, actionFrameId);
|
||||
await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
} else {
|
||||
await ensureGoPayReady(tabId, '步骤 8:已获取 GoPay 验证码,等待 GoPay 页面就绪...');
|
||||
await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_OTP', { code });
|
||||
}
|
||||
otpSubmitted = true;
|
||||
continueClickAttempts = 0;
|
||||
lastContinueClickSignature = '';
|
||||
loggedWaiting = false;
|
||||
await sleepWithStop(1500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageState.hasContinueButton) {
|
||||
const continueSignature = `${pageState.url || currentUrl || ''}::${pageState.textPreview || ''}`.slice(0, 700);
|
||||
if (continueSignature === lastContinueClickSignature) {
|
||||
@@ -1256,7 +1326,8 @@
|
||||
continueClickAttempts = 1;
|
||||
}
|
||||
if (continueClickAttempts > 2) {
|
||||
await addLog('步骤 8:GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。', 'warn');
|
||||
const stableBeforeRetrySeconds = Math.round(stableState.stableMs / 1000);
|
||||
await addLog(`步骤 8:GoPay 确认按钮点击后页面仍未变化,先等待 linking 页面加载/跳转(已稳定 ${stableBeforeRetrySeconds}s)。`, 'warn');
|
||||
const decision = await waitForGoPayState(tabId, (nextState) => (
|
||||
nextState.hasTerminalError
|
||||
|| nextState.hasOtpInput
|
||||
@@ -1264,7 +1335,7 @@
|
||||
|| nextState.hasPayNowButton
|
||||
|| nextState.completed
|
||||
|| !nextState.hasContinueButton
|
||||
), { timeoutMs: 30000 });
|
||||
), { timeoutMs: GOPAY_LINKING_RETRY_WAIT_MS });
|
||||
await handleGoPayTerminalError(decision.pageState, tabId);
|
||||
if (decision.returned) {
|
||||
await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok');
|
||||
@@ -1273,10 +1344,46 @@
|
||||
if (!decision.timeout) {
|
||||
continueClickAttempts = 0;
|
||||
lastContinueClickSignature = '';
|
||||
stableStateTracker.reset();
|
||||
loggedWaiting = false;
|
||||
continue;
|
||||
}
|
||||
throw new Error('步骤 8:GoPay 确认按钮自动点击无效,请手动点击后重新执行或继续当前步骤。');
|
||||
const refreshedState = decision.pageState || pageState;
|
||||
const refreshedStableState = stableStateTracker.update(refreshedState, currentUrl);
|
||||
const stableSeconds = Math.round(refreshedStableState.stableMs / 1000);
|
||||
if (stableSeconds < Math.round(GOPAY_LINKING_STABLE_WAIT_MS / 1000)) {
|
||||
await addLog(`步骤 8:GoPay linking 页面还在同一状态(${stableSeconds}s),改用兜底点击 Hubungkan/确认按钮后继续等待。`, 'info');
|
||||
const retryResult = await clickGoPayContinueBestEffort(tabId);
|
||||
if (retryResult?.clickTarget) {
|
||||
await addLog(`步骤 8:已兜底点击 GoPay 控件:${retryResult.clickTarget}`, 'info');
|
||||
}
|
||||
continueClickAttempts = 2;
|
||||
await sleepWithStop(2500);
|
||||
loggedWaiting = false;
|
||||
continue;
|
||||
}
|
||||
await addLog('步骤 8:GoPay linking 页面长时间没有变化,已暂停自动重复点击。请手动点击页面上的 Hubungkan/确认按钮,插件会继续等待后续页面。', 'warn');
|
||||
const manualDecision = await waitForGoPayState(tabId, (nextState) => (
|
||||
nextState.hasTerminalError
|
||||
|| nextState.hasOtpInput
|
||||
|| nextState.hasPinInput
|
||||
|| nextState.hasPayNowButton
|
||||
|| nextState.completed
|
||||
|| !nextState.hasContinueButton
|
||||
), { timeoutMs: GOPAY_WAIT_TIMEOUT_MS });
|
||||
await handleGoPayTerminalError(manualDecision.pageState, tabId);
|
||||
if (manualDecision.returned) {
|
||||
await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok');
|
||||
break;
|
||||
}
|
||||
if (!manualDecision.timeout) {
|
||||
continueClickAttempts = 0;
|
||||
lastContinueClickSignature = '';
|
||||
stableStateTracker.reset();
|
||||
loggedWaiting = false;
|
||||
continue;
|
||||
}
|
||||
throw new Error('步骤 8:GoPay linking 页面长时间无变化,请手动点击 Hubungkan/确认按钮后重新执行或继续当前步骤。');
|
||||
}
|
||||
await addLog(`步骤 8:检测到 GoPay 继续/确认按钮,正在点击${continueClickAttempts > 1 ? `(第 ${continueClickAttempts} 次)` : ''}...`, 'info');
|
||||
const clickResult = continueClickAttempts === 1
|
||||
|
||||
@@ -306,7 +306,8 @@
|
||||
}
|
||||
|
||||
async function executeSub2ApiStep10(state) {
|
||||
const visibleStep = resolvePlatformVerifyStep(state);
|
||||
const platformVerifyStep = resolvePlatformVerifyStep(state);
|
||||
const visibleStep = platformVerifyStep;
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
@@ -351,7 +352,7 @@
|
||||
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
|
||||
const requestMessage = {
|
||||
type: 'EXECUTE_STEP',
|
||||
step: 10,
|
||||
step: platformVerifyStep,
|
||||
source: 'background',
|
||||
payload: {
|
||||
localhostUrl: state.localhostUrl,
|
||||
|
||||
Reference in New Issue
Block a user