import GuJumpgate snapshot from GitHub
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
(function activationUtilsModule(root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = factory();
|
||||
return;
|
||||
}
|
||||
|
||||
root.MultiPageActivationUtils = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createActivationUtils() {
|
||||
function normalizeTagName(tagName) {
|
||||
return String(tagName || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeType(type) {
|
||||
return String(type || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizePathname(pathname) {
|
||||
return String(pathname || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getActivationStrategy(target = {}) {
|
||||
const tagName = normalizeTagName(target.tagName);
|
||||
const type = normalizeType(target.type);
|
||||
const pathname = normalizePathname(target.pathname);
|
||||
const hasForm = Boolean(target.hasForm);
|
||||
const isEmailVerificationRoute = /\/email-verification(?:[/?#]|$)/i.test(pathname);
|
||||
const isSubmitButton = hasForm
|
||||
&& (
|
||||
(tagName === 'button' && (!type || type === 'submit'))
|
||||
|| (tagName === 'input' && type === 'submit')
|
||||
);
|
||||
|
||||
if (isSubmitButton && isEmailVerificationRoute) {
|
||||
return { method: 'requestSubmit' };
|
||||
}
|
||||
|
||||
return { method: 'click' };
|
||||
}
|
||||
|
||||
function isRecoverableStep9AuthFailure(statusText) {
|
||||
const text = String(statusText || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/oauth flow is not pending/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(?:认证失败|回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::]?\s*/i.test(text);
|
||||
}
|
||||
|
||||
return {
|
||||
getActivationStrategy,
|
||||
isRecoverableStep9AuthFailure,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
(function authPageRecoveryModule(root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = factory();
|
||||
return;
|
||||
}
|
||||
|
||||
root.MultiPageAuthPageRecovery = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createAuthPageRecoveryModule() {
|
||||
function createAuthPageRecovery(deps = {}) {
|
||||
const {
|
||||
detailPattern = null,
|
||||
getActionText,
|
||||
getPageTextSnapshot,
|
||||
humanPause,
|
||||
isActionEnabled,
|
||||
isVisibleElement,
|
||||
log,
|
||||
performOperationWithDelay: injectedPerformOperationWithDelay,
|
||||
routeErrorPattern = null,
|
||||
simulateClick,
|
||||
sleep,
|
||||
throwIfStopped,
|
||||
titlePattern = null,
|
||||
} = deps;
|
||||
|
||||
function matchesPathPatterns(pathname, pathPatterns = []) {
|
||||
if (!Array.isArray(pathPatterns) || !pathPatterns.length) {
|
||||
return true;
|
||||
}
|
||||
return pathPatterns.some((pattern) => pattern instanceof RegExp && pattern.test(pathname));
|
||||
}
|
||||
|
||||
function getAuthRetryButton(options = {}) {
|
||||
const { allowDisabled = false } = options;
|
||||
const direct = document.querySelector('button[data-dd-action-name="Try again"]');
|
||||
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll('button, [role="button"]');
|
||||
return Array.from(candidates).find((element) => {
|
||||
if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) {
|
||||
return false;
|
||||
}
|
||||
const text = typeof getActionText === 'function' ? getActionText(element) : '';
|
||||
return /重试|try\s+again/i.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getAuthTimeoutErrorPageState(options = {}) {
|
||||
const { pathPatterns = [] } = options;
|
||||
const pathname = location.pathname || '';
|
||||
if (!matchesPathPatterns(pathname, pathPatterns)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryButton = getAuthRetryButton({ allowDisabled: true });
|
||||
if (!retryButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = typeof getPageTextSnapshot === 'function' ? getPageTextSnapshot() : '';
|
||||
const title = typeof document !== 'undefined' ? String(document.title || '') : '';
|
||||
const titleMatched = titlePattern instanceof RegExp
|
||||
? titlePattern.test(text) || titlePattern.test(title)
|
||||
: false;
|
||||
const detailMatched = detailPattern instanceof RegExp
|
||||
? detailPattern.test(text)
|
||||
: false;
|
||||
const routeErrorMatched = routeErrorPattern instanceof RegExp
|
||||
? routeErrorPattern.test(text)
|
||||
: false;
|
||||
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
|
||||
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
|
||||
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
|
||||
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: pathname,
|
||||
url: location.href,
|
||||
retryButton,
|
||||
retryEnabled: isActionEnabled(retryButton),
|
||||
titleMatched,
|
||||
detailMatched,
|
||||
routeErrorMatched,
|
||||
fetchFailedMatched,
|
||||
maxCheckAttemptsBlocked,
|
||||
userAlreadyExistsBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRetryPageRecoveryAfterClick(options = {}) {
|
||||
const {
|
||||
pathPatterns = [],
|
||||
pollIntervalMs = 250,
|
||||
settleAfterClickMs = 3000,
|
||||
} = options;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < settleAfterClickMs) {
|
||||
if (typeof throwIfStopped === 'function') {
|
||||
throwIfStopped();
|
||||
}
|
||||
|
||||
const retryState = getAuthTimeoutErrorPageState({ pathPatterns });
|
||||
if (!retryState) {
|
||||
return {
|
||||
recovered: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: false,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverAuthRetryPage(options = {}) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
const performOperationWithDelay = injectedPerformOperationWithDelay
|
||||
|| rootScope.CodexOperationDelay?.performOperationWithDelay
|
||||
|| (async (_metadata, operation) => operation());
|
||||
const {
|
||||
logLabel = '',
|
||||
maxClickAttempts = 5,
|
||||
pathPatterns = [],
|
||||
pollIntervalMs = 250,
|
||||
step = null,
|
||||
timeoutMs = 12000,
|
||||
waitAfterClickMs = 3000,
|
||||
} = options;
|
||||
const maxIdlePolls = timeoutMs > 0
|
||||
? Math.max(1, Math.ceil(timeoutMs / Math.max(1, pollIntervalMs)))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
let clickCount = 0;
|
||||
let idlePollCount = 0;
|
||||
|
||||
while (clickCount < maxClickAttempts) {
|
||||
if (typeof throwIfStopped === 'function') {
|
||||
throwIfStopped();
|
||||
}
|
||||
|
||||
const retryState = getAuthTimeoutErrorPageState({ pathPatterns });
|
||||
if (!retryState) {
|
||||
return {
|
||||
recovered: clickCount > 0,
|
||||
clickCount,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
if (retryState.maxCheckAttemptsBlocked) {
|
||||
throw new Error(
|
||||
'CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'
|
||||
);
|
||||
}
|
||||
|
||||
if (retryState.userAlreadyExistsBlocked) {
|
||||
throw new Error(
|
||||
'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'
|
||||
);
|
||||
}
|
||||
|
||||
if (retryState.retryButton && retryState.retryEnabled) {
|
||||
idlePollCount = 0;
|
||||
clickCount += 1;
|
||||
if (typeof log === 'function') {
|
||||
const prefix = logLabel || `步骤 ${step || '?'}:检测到重试页,正在点击“重试”恢复`;
|
||||
log(`${prefix}(第 ${clickCount} 次)...`, 'warn');
|
||||
}
|
||||
if (typeof humanPause === 'function') {
|
||||
await humanPause(300, 800);
|
||||
}
|
||||
await performOperationWithDelay({ stepKey: options.stepKey || '', kind: 'click', label: 'auth-retry-click' }, async () => {
|
||||
simulateClick(retryState.retryButton);
|
||||
});
|
||||
const recoveryResult = await waitForRetryPageRecoveryAfterClick({
|
||||
pathPatterns,
|
||||
pollIntervalMs,
|
||||
settleAfterClickMs: waitAfterClickMs,
|
||||
});
|
||||
if (recoveryResult.recovered) {
|
||||
return {
|
||||
recovered: true,
|
||||
clickCount,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
idlePollCount += 1;
|
||||
if (idlePollCount >= maxIdlePolls) {
|
||||
throw new Error(
|
||||
`${logLabel || `步骤 ${step || '?'}:重试页恢复`}超时:重试按钮长时间不可点击。URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
const finalRetryState = getAuthTimeoutErrorPageState({ pathPatterns });
|
||||
if (!finalRetryState) {
|
||||
return {
|
||||
recovered: clickCount > 0,
|
||||
clickCount,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalRetryState.maxCheckAttemptsBlocked) {
|
||||
throw new Error(
|
||||
'CF_SECURITY_BLOCKED::您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器'
|
||||
);
|
||||
}
|
||||
|
||||
if (finalRetryState.userAlreadyExistsBlocked) {
|
||||
throw new Error(
|
||||
'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getAuthRetryButton,
|
||||
getAuthTimeoutErrorPageState,
|
||||
recoverAuthRetryPage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createAuthPageRecovery,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// content/duck-mail.js - Content script for DuckDuckGo Email Protection autofill settings
|
||||
|
||||
console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'FETCH_DUCK_EMAIL') return;
|
||||
|
||||
resetStopState();
|
||||
fetchDuckEmail(message.payload).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
log('Duck 邮箱:已被用户停止。', 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function fetchDuckEmail(payload = {}) {
|
||||
const {
|
||||
generateNew = true,
|
||||
baselineEmail = '',
|
||||
} = payload;
|
||||
|
||||
log(`Duck 邮箱:正在${generateNew ? '生成' : '读取'}私有地址...`);
|
||||
|
||||
await waitForElement(
|
||||
'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
|
||||
15000
|
||||
);
|
||||
|
||||
const GENERATE_BUTTON_PATTERN = /generate\s+private\s+duck\s+address|new\s+private\s+duck\s+address|generate\s+new|new\s+address|生成.*duck.*地址|生成.*私有.*地址|生成.*地址|新.*地址/i;
|
||||
const DUCK_EMAIL_PATTERN = /([a-z0-9._%+-]+@duck\.com)/i;
|
||||
const ADDRESS_VALUE_SELECTORS = [
|
||||
'input.AutofillSettingsPanel__PrivateDuckAddressValue',
|
||||
'input[class*="PrivateDuckAddressValue"]',
|
||||
'input[data-testid*="PrivateDuckAddressValue"]',
|
||||
'input[value*="@duck.com" i]',
|
||||
];
|
||||
|
||||
const normalizeDuckEmail = (value) => {
|
||||
const match = String(value || '').trim().match(DUCK_EMAIL_PATTERN);
|
||||
return match ? match[1].toLowerCase() : '';
|
||||
};
|
||||
const getAddressInputs = () => {
|
||||
const seen = new Set();
|
||||
return ADDRESS_VALUE_SELECTORS
|
||||
.flatMap((selector) => Array.from(document.querySelectorAll(selector) || []))
|
||||
.filter((element) => {
|
||||
if (!element || seen.has(element)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(element);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const getGeneratorButton = () => {
|
||||
const direct = document.querySelector('button.AutofillSettingsPanel__GeneratorButton');
|
||||
if (direct) return direct;
|
||||
|
||||
const selectors = [
|
||||
'button[data-testid*="Generator"]',
|
||||
'button[class*="Generator"]',
|
||||
'button[aria-label*="duck" i]',
|
||||
'button[title*="duck" i]',
|
||||
'[role="button"]',
|
||||
'button',
|
||||
];
|
||||
const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector) || []));
|
||||
return candidates.find((btn) => {
|
||||
const text = [
|
||||
btn.textContent,
|
||||
btn.getAttribute?.('aria-label'),
|
||||
btn.getAttribute?.('title'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return GENERATE_BUTTON_PATTERN.test(text);
|
||||
}) || null;
|
||||
};
|
||||
const readEmail = () => {
|
||||
for (const input of getAddressInputs()) {
|
||||
const candidates = [
|
||||
input?.value,
|
||||
input?.getAttribute?.('value'),
|
||||
input?.textContent,
|
||||
input?.innerText,
|
||||
input?.getAttribute?.('aria-label'),
|
||||
input?.getAttribute?.('title'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const email = normalizeDuckEmail(candidate);
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const waitForVisibleEmail = async (attemptLimit = 12) => {
|
||||
for (let i = 0; i < attemptLimit; i++) {
|
||||
const visibleEmail = readEmail();
|
||||
if (visibleEmail) {
|
||||
return visibleEmail;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const waitForEmailValue = async (previousValues = []) => {
|
||||
const blockedValues = new Set(
|
||||
(Array.isArray(previousValues) ? previousValues : [previousValues])
|
||||
.map((value) => normalizeDuckEmail(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
let stableCandidate = '';
|
||||
let stableCount = 0;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const nextValue = readEmail();
|
||||
if (nextValue && !blockedValues.has(nextValue)) {
|
||||
if (nextValue === stableCandidate) {
|
||||
stableCount += 1;
|
||||
} else {
|
||||
stableCandidate = nextValue;
|
||||
stableCount = 1;
|
||||
}
|
||||
if (stableCount >= 2) {
|
||||
return nextValue;
|
||||
}
|
||||
} else {
|
||||
stableCandidate = '';
|
||||
stableCount = 0;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('等待 Duck 地址变化超时。');
|
||||
};
|
||||
|
||||
const knownBaselineEmail = normalizeDuckEmail(baselineEmail);
|
||||
let currentEmail = readEmail();
|
||||
if (!currentEmail) {
|
||||
currentEmail = await waitForVisibleEmail(generateNew ? (knownBaselineEmail ? 12 : 30) : 20);
|
||||
}
|
||||
|
||||
if (currentEmail && !generateNew) {
|
||||
log(`Duck 邮箱:已发现现有地址 ${currentEmail}`);
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
|
||||
const generatorButton = getGeneratorButton();
|
||||
if (!generatorButton) {
|
||||
if (generateNew) {
|
||||
throw new Error('未找到“生成新 Duck 地址”按钮(可能是页面结构、文案或登录状态发生变化)。');
|
||||
}
|
||||
if (currentEmail) {
|
||||
log(`Duck 邮箱:未找到生成按钮,复用现有地址 ${currentEmail}`, 'warn');
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
throw new Error('未找到 Duck 私有地址生成按钮。');
|
||||
}
|
||||
|
||||
const comparisonEmails = [currentEmail, knownBaselineEmail].filter(Boolean);
|
||||
if (!currentEmail && knownBaselineEmail) {
|
||||
log(`Duck 邮箱:当前地址尚未显示,改用已知基线 ${knownBaselineEmail} 作为对比基线。`, 'warn');
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
await humanPause(500, 1300);
|
||||
await window.CodexOperationDelay.performOperationWithDelay(
|
||||
{
|
||||
stepKey: 'fetch-signup-code',
|
||||
kind: 'click',
|
||||
label: 'duck-generate-address',
|
||||
},
|
||||
async () => {
|
||||
if (typeof simulateClick === 'function') {
|
||||
simulateClick(generatorButton);
|
||||
} else {
|
||||
generatorButton.click();
|
||||
}
|
||||
}
|
||||
);
|
||||
log(`Duck 邮箱:已点击“生成 Duck 私有地址”按钮(${attempt}/2)`);
|
||||
|
||||
try {
|
||||
const nextEmail = await waitForEmailValue(comparisonEmails);
|
||||
log(`Duck 邮箱:地址已就绪 ${nextEmail}`, 'ok');
|
||||
return { email: nextEmail, generated: true };
|
||||
} catch (err) {
|
||||
if (attempt >= 2) {
|
||||
throw err;
|
||||
}
|
||||
log('Duck 邮箱:首次生成后地址未变化,准备再试一次...', 'warn');
|
||||
await sleep(800);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Duck 地址生成失败。');
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
// content/gmail-mail.js — Content script for Gmail polling (steps 4, 7)
|
||||
// Injected dynamically on: mail.google.com
|
||||
|
||||
const GMAIL_PREFIX = '[MultiPage:gmail-mail]';
|
||||
const GMAIL_SEEN_CODES_KEY = 'seenGmailCodes';
|
||||
const GMAIL_FALLBACK_AFTER = 3;
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(GMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(GMAIL_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
let seenCodes = new Set();
|
||||
|
||||
async function loadSeenCodes() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get(GMAIL_SEEN_CODES_KEY);
|
||||
if (Array.isArray(data[GMAIL_SEEN_CODES_KEY])) {
|
||||
seenCodes = new Set(data[GMAIL_SEEN_CODES_KEY]);
|
||||
console.log(GMAIL_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(GMAIL_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSeenCodes() {
|
||||
try {
|
||||
await chrome.storage.session.set({ [GMAIL_SEEN_CODES_KEY]: [...seenCodes] });
|
||||
} catch (err) {
|
||||
console.warn(GMAIL_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
loadSeenCodes();
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
log(`步骤 ${message.step}:Gmail 轮询失败:${err.message}`, 'warn');
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isDisplayed(element) {
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function isVisibleElement(element) {
|
||||
if (!isDisplayed(element)) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function normalizeMinuteTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
|
||||
const date = new Date(timestamp);
|
||||
date.setSeconds(0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function getTargetEmailMatchState(text, targetEmail) {
|
||||
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
|
||||
if (!normalizedTarget) {
|
||||
return { matches: true, hasExplicitEmail: false };
|
||||
}
|
||||
|
||||
const normalizedText = String(text || '').toLowerCase();
|
||||
if (normalizedText.includes(normalizedTarget)) {
|
||||
return { matches: true, hasExplicitEmail: true };
|
||||
}
|
||||
|
||||
const atIndex = normalizedTarget.indexOf('@');
|
||||
if (atIndex > 0) {
|
||||
const encodedTarget = `${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`;
|
||||
if (normalizedText.includes(encodedTarget)) {
|
||||
return { matches: true, hasExplicitEmail: true };
|
||||
}
|
||||
}
|
||||
return { matches: false, hasExplicitEmail: false };
|
||||
}
|
||||
|
||||
function normalizeRulePatternList(patterns = []) {
|
||||
return Array.isArray(patterns) ? patterns : [];
|
||||
}
|
||||
|
||||
function extractCodeByRulePatterns(text, patterns = []) {
|
||||
const normalizedText = String(text || '');
|
||||
for (const pattern of normalizeRulePatternList(patterns)) {
|
||||
try {
|
||||
const source = String(pattern?.source || '').trim();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
|
||||
const match = normalizedText.match(new RegExp(source, flags));
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
const candidate = String(match[index] || '').trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (String(match[0] || '').trim()) {
|
||||
return String(match[0] || '').trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore invalid runtime rule patterns and continue with other candidates.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MONTH_INDEX_MAP = {
|
||||
jan: 0,
|
||||
feb: 1,
|
||||
mar: 2,
|
||||
apr: 3,
|
||||
may: 4,
|
||||
jun: 5,
|
||||
jul: 6,
|
||||
aug: 7,
|
||||
sep: 8,
|
||||
oct: 9,
|
||||
nov: 10,
|
||||
dec: 11,
|
||||
};
|
||||
|
||||
function parseGmailTimestampText(rawText) {
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return null;
|
||||
|
||||
const parsedNative = Date.parse(text);
|
||||
if (Number.isFinite(parsedNative)) {
|
||||
return parsedNative;
|
||||
}
|
||||
|
||||
let match = text.match(/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})日?\s+(\d{1,2}):(\d{2})(?:\s*([AP]M))?/i);
|
||||
if (match) {
|
||||
const [, year, month, day, hourText, minute, meridiem] = match;
|
||||
let hour = Number(hourText);
|
||||
if (/pm/i.test(meridiem) && hour < 12) hour += 12;
|
||||
if (/am/i.test(meridiem) && hour === 12) hour = 0;
|
||||
return new Date(Number(year), Number(month) - 1, Number(day), hour, Number(minute), 0, 0).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/\b([A-Za-z]{3,9})\s+(\d{1,2}),\s*(\d{4}),?\s*(\d{1,2}):(\d{2})\s*([AP]M)\b/i);
|
||||
if (match) {
|
||||
const [, monthText, day, year, hourText, minute, meridiem] = match;
|
||||
const month = MONTH_INDEX_MAP[monthText.slice(0, 3).toLowerCase()];
|
||||
if (month !== undefined) {
|
||||
let hour = Number(hourText);
|
||||
if (/pm/i.test(meridiem) && hour < 12) hour += 12;
|
||||
if (/am/i.test(meridiem) && hour === 12) hour = 0;
|
||||
return new Date(Number(year), month, Number(day), hour, Number(minute), 0, 0).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
match = text.match(/今天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 1);
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (match) {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVerificationCode(text, options = {}) {
|
||||
const normalized = String(text || '');
|
||||
const matchedByRule = extractCodeByRulePatterns(normalized, options?.codePatterns);
|
||||
if (matchedByRule) {
|
||||
return matchedByRule;
|
||||
}
|
||||
|
||||
const cnMatch = normalized.match(/(?:验证码|代码)[^0-9]{0,16}(\d{6})/i);
|
||||
if (cnMatch) return cnMatch[1];
|
||||
|
||||
const enMatch = normalized.match(/(?:verification\s+code|temporary\s+verification\s+code|log-?in\s+code|enter\s+this\s+code|code(?:\s+is)?)[^0-9]{0,24}(\d{6})/i);
|
||||
if (enMatch) return enMatch[1];
|
||||
|
||||
const plainMatch = normalized.match(/\b(\d{6})\b/);
|
||||
if (plainMatch) return plainMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInboxLink() {
|
||||
const selectors = [
|
||||
'a[href*="#inbox"]',
|
||||
'a[aria-label*="收件箱"]',
|
||||
'a[aria-label*="Inbox"]',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const candidates = Array.from(document.querySelectorAll(selector));
|
||||
const visible = candidates.find(isVisibleElement);
|
||||
if (visible) return visible;
|
||||
if (candidates[0]) return candidates[0];
|
||||
}
|
||||
|
||||
return Array.from(document.querySelectorAll('a, [role="link"]')).find((element) => {
|
||||
const text = normalizeText(
|
||||
element.getAttribute('aria-label')
|
||||
|| element.getAttribute('title')
|
||||
|| element.textContent
|
||||
);
|
||||
return /收件箱|Inbox/i.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
const GMAIL_CATEGORY_LABELS = {
|
||||
primary: [/^primary$/i, /^inbox$/i, /^主要$/],
|
||||
updates: [/^updates$/i, /^更新$/],
|
||||
promotions: [/^promotions$/i, /^推广$/],
|
||||
social: [/^social$/i, /^社交$/],
|
||||
};
|
||||
|
||||
function getCategoryKeyFromText(text) {
|
||||
const normalizedText = normalizeText(text);
|
||||
if (!normalizedText) return '';
|
||||
|
||||
for (const [key, patterns] of Object.entries(GMAIL_CATEGORY_LABELS)) {
|
||||
if (patterns.some((pattern) => pattern.test(normalizedText))) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCategoryTabLabel(tab) {
|
||||
const text = normalizeText(
|
||||
tab?.getAttribute?.('aria-label')
|
||||
|| tab?.getAttribute?.('data-tooltip')
|
||||
|| tab?.getAttribute?.('title')
|
||||
|| tab?.textContent
|
||||
|| ''
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
function collectCategoryTabs() {
|
||||
const tabs = Array.from(document.querySelectorAll('[role="tab"], [data-tooltip-align][role="link"]'));
|
||||
const categoryTabs = [];
|
||||
const seenKeys = new Set();
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
if (!isVisibleElement(tab)) return;
|
||||
const label = getCategoryTabLabel(tab);
|
||||
const key = getCategoryKeyFromText(label);
|
||||
if (!key || seenKeys.has(key)) return;
|
||||
|
||||
seenKeys.add(key);
|
||||
categoryTabs.push({
|
||||
key,
|
||||
label,
|
||||
selected: tab.getAttribute('aria-selected') === 'true' || /\bTO\b/.test(tab.className || ''),
|
||||
tab,
|
||||
});
|
||||
});
|
||||
|
||||
return categoryTabs;
|
||||
}
|
||||
|
||||
function getCategoryScanOrder() {
|
||||
const categoryTabs = collectCategoryTabs();
|
||||
if (!categoryTabs.length) {
|
||||
return [{ key: 'primary', label: 'Primary', selected: true, tab: null }];
|
||||
}
|
||||
|
||||
const ordered = ['updates', 'primary']
|
||||
.map((key) => categoryTabs.find((item) => item.key === key))
|
||||
.filter(Boolean);
|
||||
|
||||
return ordered.length
|
||||
? ordered
|
||||
: [{ key: 'primary', label: 'Primary', selected: true, tab: null }];
|
||||
}
|
||||
|
||||
async function activateCategoryTab(step, categoryKey) {
|
||||
const categoryTabs = collectCategoryTabs();
|
||||
const target = categoryTabs.find((item) => item.key === categoryKey);
|
||||
if (!target?.tab) {
|
||||
return { key: categoryKey, label: categoryKey, switched: false };
|
||||
}
|
||||
|
||||
if (target.selected) {
|
||||
return { key: target.key, label: target.label, switched: false };
|
||||
}
|
||||
|
||||
simulateClick(target.tab);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(200);
|
||||
const refreshed = collectCategoryTabs().find((item) => item.key === categoryKey);
|
||||
if (refreshed?.selected) {
|
||||
await sleep(500);
|
||||
log(`步骤 ${step}:已切换到 Gmail 分类 ${refreshed.label}。`);
|
||||
return { key: refreshed.key, label: refreshed.label, switched: true };
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(600);
|
||||
log(`步骤 ${step}:已尝试切换到 Gmail 分类 ${target.label}。`, 'info');
|
||||
return { key: target.key, label: target.label, switched: true };
|
||||
}
|
||||
|
||||
function findRefreshButton() {
|
||||
const selectors = [
|
||||
'div[role="button"][data-tooltip="刷新"]',
|
||||
'div[role="button"][aria-label="刷新"]',
|
||||
'div[role="button"][data-tooltip*="刷新"]',
|
||||
'div[role="button"][aria-label*="刷新"]',
|
||||
'div[role="button"][data-tooltip="Refresh"]',
|
||||
'div[role="button"][aria-label="Refresh"]',
|
||||
'div[role="button"][data-tooltip*="Refresh"]',
|
||||
'div[role="button"][aria-label*="Refresh"]',
|
||||
'div[act="20"][role="button"]',
|
||||
'div.asf.T-I-J3.J-J5-Ji',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const matched = document.querySelector(selector);
|
||||
const button = matched?.closest?.('[role="button"]') || matched;
|
||||
if (button && isVisibleElement(button)) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(document.querySelectorAll('div[role="button"], button')).find((element) => {
|
||||
const text = normalizeText(
|
||||
element.getAttribute('aria-label')
|
||||
|| element.getAttribute('data-tooltip')
|
||||
|| element.getAttribute('title')
|
||||
|| element.textContent
|
||||
);
|
||||
return /刷新|Refresh/i.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function collectThreadRows() {
|
||||
const candidates = [
|
||||
...document.querySelectorAll('tr.zA'),
|
||||
...document.querySelectorAll('tr[role="row"]'),
|
||||
];
|
||||
|
||||
const rows = [];
|
||||
const seenRows = new Set();
|
||||
|
||||
candidates.forEach((row) => {
|
||||
if (!row || seenRows.has(row)) return;
|
||||
seenRows.add(row);
|
||||
|
||||
if (!isDisplayed(row)) return;
|
||||
|
||||
const text = normalizeText(row.textContent || row.innerText || '');
|
||||
if (!text) return;
|
||||
|
||||
if (
|
||||
row.matches('tr.zA')
|
||||
|| row.querySelector('.bog, .y6, .y2, .afn, [data-thread-id], [data-legacy-thread-id], [data-legacy-last-message-id]')
|
||||
|| /verify|verification|code|验证码|log-?in/i.test(text)
|
||||
) {
|
||||
rows.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getRowPreviewText(row) {
|
||||
const sender = normalizeText(
|
||||
row.querySelector('.zF, .yP, span[email], [email]')?.textContent
|
||||
|| row.querySelector('[email]')?.getAttribute?.('email')
|
||||
|| ''
|
||||
);
|
||||
|
||||
const subject = normalizeText(
|
||||
row.querySelector('.bog [data-thread-id], .bog [data-legacy-thread-id], .bog, .y6, .bqe')?.textContent
|
||||
|| ''
|
||||
);
|
||||
|
||||
const digest = normalizeText(
|
||||
row.querySelector('.y2, .afn, .a4W, .bog + .y2')?.textContent
|
||||
|| ''
|
||||
);
|
||||
|
||||
const timeText = normalizeText(
|
||||
row.querySelector('td.xW span')?.getAttribute?.('title')
|
||||
|| row.querySelector('td.xW span, td.xW time')?.getAttribute?.('title')
|
||||
|| row.querySelector('td.xW span, td.xW time')?.textContent
|
||||
|| ''
|
||||
);
|
||||
|
||||
const fullText = normalizeText(row.textContent || row.innerText || '');
|
||||
|
||||
return {
|
||||
sender,
|
||||
subject,
|
||||
digest,
|
||||
timeText,
|
||||
fullText,
|
||||
combinedText: normalizeText([sender, subject, digest, timeText, fullText].filter(Boolean).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function getRowTimestamp(row) {
|
||||
const timeCell = row.querySelector('td.xW span, td.xW time, td.xW [title]');
|
||||
const candidates = [
|
||||
timeCell?.getAttribute?.('title'),
|
||||
timeCell?.getAttribute?.('aria-label'),
|
||||
timeCell?.textContent,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseGmailTimestampText(candidate);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRowFingerprint(row, index = 0) {
|
||||
const marker = row.querySelector('[data-thread-id], [data-legacy-thread-id], [data-legacy-last-message-id]');
|
||||
const stableId = row.getAttribute('data-thread-id')
|
||||
|| row.getAttribute('data-legacy-thread-id')
|
||||
|| row.getAttribute('data-legacy-last-message-id')
|
||||
|| marker?.getAttribute?.('data-thread-id')
|
||||
|| marker?.getAttribute?.('data-legacy-thread-id')
|
||||
|| marker?.getAttribute?.('data-legacy-last-message-id')
|
||||
|| row.getAttribute('id')
|
||||
|| `row-${index}`;
|
||||
const preview = getRowPreviewText(row);
|
||||
return `${stableId}::${preview.subject}::${preview.timeText}`.slice(0, 300);
|
||||
}
|
||||
|
||||
function getCurrentMailIds(rows = []) {
|
||||
const ids = new Set();
|
||||
const sourceRows = rows.length ? rows : collectThreadRows();
|
||||
sourceRows.forEach((row, index) => {
|
||||
ids.add(getRowFingerprint(row, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(preview, senderFilters, subjectFilters) {
|
||||
const senderText = normalizeText(preview.sender).toLowerCase();
|
||||
const subjectText = normalizeText(preview.subject).toLowerCase();
|
||||
const combinedText = normalizeText(preview.combinedText).toLowerCase();
|
||||
|
||||
const senderMatch = senderFilters.some((filter) => {
|
||||
const value = String(filter || '').toLowerCase();
|
||||
return value && (senderText.includes(value) || combinedText.includes(value));
|
||||
});
|
||||
|
||||
const subjectMatch = subjectFilters.some((filter) => {
|
||||
const value = String(filter || '').toLowerCase();
|
||||
return value && (subjectText.includes(value) || combinedText.includes(value));
|
||||
});
|
||||
|
||||
return senderMatch || subjectMatch;
|
||||
}
|
||||
|
||||
async function ensureInboxReady(step) {
|
||||
if (!/#inbox/i.test(location.href)) {
|
||||
const inboxLink = findInboxLink();
|
||||
if (inboxLink) {
|
||||
simulateClick(inboxLink);
|
||||
await sleep(800);
|
||||
log(`步骤 ${step}:已切回 Gmail 收件箱。`);
|
||||
} else {
|
||||
location.hash = '#inbox';
|
||||
await sleep(800);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const rows = collectThreadRows();
|
||||
if (rows.length > 0) {
|
||||
return rows;
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function refreshInbox(step) {
|
||||
const refreshButton = findRefreshButton();
|
||||
if (refreshButton) {
|
||||
simulateClick(refreshButton);
|
||||
log(`步骤 ${step}:已点击 Gmail 刷新。`);
|
||||
await sleep(1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const inboxLink = findInboxLink();
|
||||
if (inboxLink) {
|
||||
simulateClick(inboxLink);
|
||||
log(`步骤 ${step}:未找到刷新按钮,已重新进入收件箱。`);
|
||||
await sleep(1200);
|
||||
return;
|
||||
}
|
||||
|
||||
location.reload();
|
||||
log(`步骤 ${step}:未找到刷新按钮,已直接刷新页面。`);
|
||||
await sleep(2500);
|
||||
}
|
||||
|
||||
async function returnToInbox() {
|
||||
if (/#inbox/i.test(location.href) && collectThreadRows().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inboxLink = findInboxLink();
|
||||
if (inboxLink) {
|
||||
simulateClick(inboxLink);
|
||||
} else {
|
||||
location.hash = '#inbox';
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (collectThreadRows().length > 0) {
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
async function openRowAndGetMessageText(row) {
|
||||
simulateClick(row);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const messageContainer = document.querySelector('div[role="main"] .a3s, div[role="main"] [data-message-id], h2[data-thread-perm-id]');
|
||||
if (messageContainer || !/#inbox/i.test(location.href)) {
|
||||
break;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
await sleep(900);
|
||||
const main = document.querySelector('div[role="main"]');
|
||||
const text = normalizeText(main?.innerText || document.body?.innerText || document.body?.textContent || '');
|
||||
await returnToInbox();
|
||||
return text;
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const {
|
||||
codePatterns = [],
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
maxAttempts = 5,
|
||||
intervalMs = 3000,
|
||||
filterAfterTimestamp = 0,
|
||||
excludeCodes = [],
|
||||
targetEmail = '',
|
||||
} = payload || {};
|
||||
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
|
||||
|
||||
log(`步骤 ${step}:开始轮询 Gmail(最多 ${maxAttempts} 次)`);
|
||||
if (filterAfterMinute) {
|
||||
log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`);
|
||||
}
|
||||
|
||||
let initialRows = await ensureInboxReady(step);
|
||||
if (!initialRows.length) {
|
||||
await refreshInbox(step);
|
||||
initialRows = await ensureInboxReady(step);
|
||||
}
|
||||
|
||||
if (!initialRows.length) {
|
||||
throw new Error('Gmail 收件箱列表未加载完成,请确认当前已打开 Gmail 收件箱。');
|
||||
}
|
||||
|
||||
const categoryOrder = getCategoryScanOrder();
|
||||
const existingMailIdsByCategory = new Map();
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
const activeCategory = await activateCategoryTab(step, category.key);
|
||||
const rows = collectThreadRows();
|
||||
existingMailIdsByCategory.set(activeCategory.key, getCurrentMailIds(rows));
|
||||
log(`步骤 ${step}:已记录 Gmail 分类 ${activeCategory.label} 的 ${rows.length} 封旧邮件快照`);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`步骤 ${step}:正在轮询 Gmail,第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshInbox(step);
|
||||
}
|
||||
|
||||
const useFallback = attempt > GMAIL_FALLBACK_AFTER;
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
const activeCategory = await activateCategoryTab(step, category.key);
|
||||
const rows = collectThreadRows();
|
||||
const existingMailIds = existingMailIdsByCategory.get(activeCategory.key) || new Set();
|
||||
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index];
|
||||
const rowId = getRowFingerprint(row, index);
|
||||
const rowTimestamp = getRowTimestamp(row);
|
||||
const rowMinute = normalizeMinuteTimestamp(rowTimestamp || 0);
|
||||
const passesTimeFilter = !filterAfterMinute || (rowMinute && rowMinute >= filterAfterMinute);
|
||||
const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && rowMinute > 0);
|
||||
|
||||
if (!passesTimeFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(rowId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preview = getRowPreviewText(row);
|
||||
if (!rowMatchesFilters(preview, senderFilters, subjectFilters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previewTargetState = getTargetEmailMatchState(preview.combinedText, targetEmail);
|
||||
const previewCode = extractVerificationCode(preview.combinedText, {
|
||||
codePatterns,
|
||||
});
|
||||
if (previewCode) {
|
||||
if (excludedCodeSet.has(previewCode)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${previewCode}`, 'info');
|
||||
continue;
|
||||
}
|
||||
if (seenCodes.has(previewCode)) {
|
||||
log(`步骤 ${step}:跳过已处理过的验证码:${previewCode}`, 'info');
|
||||
continue;
|
||||
}
|
||||
seenCodes.add(previewCode);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(rowId) ? '回退匹配邮件' : '新邮件';
|
||||
const timeLabel = rowTimestamp ? `,时间:${new Date(rowTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
|
||||
const targetLabel = previewTargetState.matches ? ',目标邮箱命中' : '';
|
||||
log(`步骤 ${step}:已在 Gmail ${activeCategory.label} 分类找到验证码:${previewCode}(来源:${source}${timeLabel}${targetLabel})`, 'ok');
|
||||
return {
|
||||
ok: true,
|
||||
code: previewCode,
|
||||
emailTimestamp: Date.now(),
|
||||
mailId: rowId,
|
||||
};
|
||||
}
|
||||
|
||||
const openedText = await openRowAndGetMessageText(row);
|
||||
const openedTargetState = getTargetEmailMatchState(openedText, targetEmail);
|
||||
const bodyCode = extractVerificationCode(openedText, {
|
||||
codePatterns,
|
||||
});
|
||||
if (!bodyCode) {
|
||||
continue;
|
||||
}
|
||||
if (excludedCodeSet.has(bodyCode)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${bodyCode}`, 'info');
|
||||
continue;
|
||||
}
|
||||
if (seenCodes.has(bodyCode)) {
|
||||
log(`步骤 ${step}:跳过已处理过的验证码:${bodyCode}`, 'info');
|
||||
continue;
|
||||
}
|
||||
seenCodes.add(bodyCode);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(rowId) ? '回退匹配邮件正文' : '新邮件正文';
|
||||
const timeLabel = rowTimestamp ? `,时间:${new Date(rowTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
|
||||
const targetLabel = openedTargetState.matches ? ',目标邮箱命中' : '';
|
||||
log(`步骤 ${step}:已在 Gmail ${activeCategory.label} 分类正文中找到验证码:${bodyCode}(来源:${source}${timeLabel}${targetLabel})`, 'ok');
|
||||
return {
|
||||
ok: true,
|
||||
code: bodyCode,
|
||||
emailTimestamp: Date.now(),
|
||||
mailId: rowId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === GMAIL_FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:连续 ${GMAIL_FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 Gmail 中找到匹配邮件。请手动检查 Gmail 收件箱。`
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
// content/gopay-flow.js — GoPay authorization helper.
|
||||
|
||||
console.log('[MultiPage:gopay-flow] Content script loaded on', location.href);
|
||||
|
||||
const GOPAY_FLOW_LISTENER_SENTINEL = 'data-multipage-gopay-flow-listener';
|
||||
|
||||
if (document.documentElement.getAttribute(GOPAY_FLOW_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(GOPAY_FLOW_LISTENER_SENTINEL, '1');
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (
|
||||
message.type === 'GOPAY_GET_STATE'
|
||||
|| message.type === 'GOPAY_SUBMIT_PHONE'
|
||||
|| message.type === 'GOPAY_SUBMIT_OTP'
|
||||
|| message.type === 'GOPAY_SUBMIT_PIN'
|
||||
|| message.type === 'GOPAY_CLICK_CONTINUE'
|
||||
|| message.type === 'GOPAY_GET_CONTINUE_TARGET'
|
||||
|| message.type === 'GOPAY_CLICK_PAY_NOW'
|
||||
|| message.type === 'GOPAY_GET_PAY_NOW_TARGET'
|
||||
) {
|
||||
resetStopState();
|
||||
handleGoPayCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[MultiPage:gopay-flow] 消息监听已存在,跳过重复注册');
|
||||
}
|
||||
|
||||
async function performGoPayOperationWithDelay(metadata, operation) {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
}
|
||||
|
||||
async function handleGoPayCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'GOPAY_GET_STATE':
|
||||
return inspectGoPayState();
|
||||
case 'GOPAY_SUBMIT_PHONE':
|
||||
return submitGoPayPhone(message.payload || {});
|
||||
case 'GOPAY_SUBMIT_OTP':
|
||||
return submitGoPayOtp(message.payload || {});
|
||||
case 'GOPAY_SUBMIT_PIN':
|
||||
return submitGoPayPin(message.payload || {});
|
||||
case 'GOPAY_CLICK_CONTINUE':
|
||||
return clickGoPayContinue();
|
||||
case 'GOPAY_GET_CONTINUE_TARGET':
|
||||
return getGoPayContinueTarget();
|
||||
case 'GOPAY_CLICK_PAY_NOW':
|
||||
return clickGoPayPayNow();
|
||||
case 'GOPAY_GET_PAY_NOW_TARGET':
|
||||
return getGoPayPayNowTarget();
|
||||
default:
|
||||
throw new Error(`gopay-flow.js 不处理消息:${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(predicate, options = {}) {
|
||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
||||
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
|
||||
const startedAt = Date.now();
|
||||
while (true) {
|
||||
throwIfStopped();
|
||||
const value = await predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
|
||||
throw new Error(options.timeoutMessage || `${options.label || 'GoPay 页面状态'}等待超时`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDocumentComplete(options = {}) {
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 8000));
|
||||
const settleMs = Math.max(0, Math.floor(Number(options.settleMs) || 500));
|
||||
try {
|
||||
await waitUntil(() => {
|
||||
const readyState = String(document.readyState || '').toLowerCase();
|
||||
return readyState === 'complete'
|
||||
|| readyState === 'interactive'
|
||||
|| Boolean(document.querySelector?.('button, input, textarea, a, [role="button"]'))
|
||||
|| Boolean(normalizeText(document.body?.innerText || document.body?.textContent || ''));
|
||||
}, {
|
||||
intervalMs: 200,
|
||||
timeoutMs,
|
||||
label: 'GoPay DOM',
|
||||
});
|
||||
} catch (_) {
|
||||
// GoPay linking 页面有时长时间保持 loading;后续定位控件本身还有等待/重试。
|
||||
}
|
||||
await sleep(settleMs);
|
||||
}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
if (!el) return false;
|
||||
let node = el;
|
||||
while (node && node.nodeType === 1) {
|
||||
if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) {
|
||||
return false;
|
||||
}
|
||||
const nodeStyle = window.getComputedStyle(node);
|
||||
if (
|
||||
nodeStyle.display === 'none'
|
||||
|| nodeStyle.visibility === 'hidden'
|
||||
|| nodeStyle.visibility === 'collapse'
|
||||
|| Number(nodeStyle.opacity) === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(rect.width) > 0
|
||||
&& Number(rect.height) > 0;
|
||||
}
|
||||
|
||||
function normalizeText(text = '') {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return normalizeText([
|
||||
el?.textContent,
|
||||
el?.value,
|
||||
el?.getAttribute?.('data-testid'),
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.getAttribute?.('placeholder'),
|
||||
el?.getAttribute?.('name'),
|
||||
el?.id,
|
||||
].filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function getVisibleControls(selector) {
|
||||
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
|
||||
}
|
||||
|
||||
function isEnabledControl(el) {
|
||||
return Boolean(el)
|
||||
&& !el.disabled
|
||||
&& el.getAttribute?.('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getVisibleTextInputs() {
|
||||
return getVisibleControls('input, textarea')
|
||||
.filter((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
|
||||
});
|
||||
}
|
||||
|
||||
function findInputByPatterns(patterns) {
|
||||
return getVisibleTextInputs().find((input) => {
|
||||
const text = getActionText(input);
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findPhoneInput() {
|
||||
const input = findInputByPatterns([
|
||||
/gopay|go\s*pay|phone|mobile|whatsapp|wa|nomor|ponsel|telepon|hp/i,
|
||||
/手机|手机号|电话号码|电话/i,
|
||||
]);
|
||||
if (input && !isCountrySearchInput(input)) {
|
||||
return input;
|
||||
}
|
||||
return getVisibleControls('input[type="tel"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate)) || null;
|
||||
}
|
||||
|
||||
function isCountrySearchInput(input) {
|
||||
const text = getActionText(input);
|
||||
return /country|country\s*name|country\s*code|国家|地区|区号/i.test(text);
|
||||
}
|
||||
|
||||
function getPageBodyText() {
|
||||
return normalizeText(document.body?.innerText || document.body?.textContent || '');
|
||||
}
|
||||
|
||||
function isGoPayOtpPageText() {
|
||||
if (isGoPayPinPageText()) {
|
||||
return false;
|
||||
}
|
||||
if (getVisibleTextInputs().some((input) => isGoPayPinInput(input))) {
|
||||
return false;
|
||||
}
|
||||
const text = getPageBodyText();
|
||||
return /otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text);
|
||||
}
|
||||
|
||||
function isGoPayPinPageText() {
|
||||
const text = getPageBodyText();
|
||||
return /pin|password|passcode|security|sandi|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 || '');
|
||||
}
|
||||
|
||||
function isGoPayPinInput(input) {
|
||||
if (!input || isCountrySearchInput(input)) {
|
||||
return false;
|
||||
}
|
||||
const text = getCombinedElementText(input);
|
||||
const testId = String(input.getAttribute?.('data-testid') || '').trim();
|
||||
const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase();
|
||||
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
|
||||
const pinPage = isGoPayPinPageText();
|
||||
const strongPinHint = /password|passcode|security|sandi|支付密码|密码/i.test(text);
|
||||
const ambiguousPinWidget = /^pin-input/i.test(testId)
|
||||
|| /pin-input(?:-field)?|(?:^|[\s_-])pin(?:$|[\s_-])/i.test(text);
|
||||
return strongPinHint
|
||||
|| (pinPage && (ambiguousPinWidget || type === 'password' || maxLength === 1));
|
||||
}
|
||||
|
||||
function detectGoPayTerminalError(text = getPageBodyText()) {
|
||||
const normalizedText = normalizeText(text);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
if (/waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|kedaluwarsa/i.test(normalizedText)) {
|
||||
return {
|
||||
code: 'expired',
|
||||
message: 'GoPay 支付会话已超时,需要重新创建 Plus Checkout。',
|
||||
rawText: normalizedText.slice(0, 240),
|
||||
};
|
||||
}
|
||||
|
||||
if (/technical\s+error|don[’']t\s+worry|try\s+again|terjadi\s+kesalahan|error\s+teknis/i.test(normalizedText)) {
|
||||
return {
|
||||
code: 'technical-error',
|
||||
message: 'GoPay 页面显示技术错误,需要重新发起支付授权。',
|
||||
rawText: normalizedText.slice(0, 240),
|
||||
};
|
||||
}
|
||||
|
||||
if (/payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|ditolak|declined|failed/i.test(normalizedText)) {
|
||||
return {
|
||||
code: 'failed',
|
||||
message: 'GoPay 页面显示支付失败,需要重新发起支付授权。',
|
||||
rawText: normalizedText.slice(0, 240),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findOtpInput() {
|
||||
if (isGoPayPinPageText()) {
|
||||
return null;
|
||||
}
|
||||
const input = findInputByPatterns([
|
||||
/otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa/i,
|
||||
/验证码|短信|代码/i,
|
||||
]);
|
||||
if (input && !isCountrySearchInput(input) && !isGoPayPinInput(input)) {
|
||||
return input;
|
||||
}
|
||||
if (isGoPayOtpPageText()) {
|
||||
return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate) && !isGoPayPinInput(candidate)) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGoPayPinInputs() {
|
||||
return getVisibleTextInputs().filter((candidate) => {
|
||||
return isGoPayPinInput(candidate);
|
||||
});
|
||||
}
|
||||
|
||||
function findPinInput() {
|
||||
const pinInputs = getGoPayPinInputs();
|
||||
if (pinInputs[0]) {
|
||||
return pinInputs[0];
|
||||
}
|
||||
if (isGoPayOtpPageText()) {
|
||||
return null;
|
||||
}
|
||||
const input = findInputByPatterns([
|
||||
/pin|password|passcode|security|sandi|pin-input/i,
|
||||
/密码|支付密码/i,
|
||||
]);
|
||||
if (input && !isCountrySearchInput(input)) {
|
||||
return input;
|
||||
}
|
||||
return getVisibleControls('input[type="password"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function findClickableByText(patterns) {
|
||||
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
|
||||
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"]');
|
||||
return candidates.find((el) => {
|
||||
if (!isEnabledControl(el)) return false;
|
||||
const text = getActionText(el);
|
||||
return normalizedPatterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findPayNowButton() {
|
||||
return findClickableByText([
|
||||
/^\s*pay\s+now\s*$/i,
|
||||
/^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i,
|
||||
/^\s*支付\s*$/i,
|
||||
/^\s*立即支付\s*$/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function findContinueButton() {
|
||||
return findClickableByText([
|
||||
/continue|next|submit|verify|confirm|pay|authorize|allow|lanjut|lanjutkan|berikut|kirim|bayar|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link/i,
|
||||
/继续|下一步|提交|验证|确认|支付|授权|绑定|关联/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function describeElement(el) {
|
||||
if (!el) return '';
|
||||
const rect = el.getBoundingClientRect?.();
|
||||
const parts = [String(el.tagName || '').toUpperCase()];
|
||||
if (el.id) parts.push(`#${el.id}`);
|
||||
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class');
|
||||
if (className) parts.push(`.${String(className).trim().replace(/\s+/g, '.')}`);
|
||||
const text = getActionText(el) || normalizeText(el.innerText || el.textContent || '');
|
||||
if (text) parts.push(`"${text.slice(0, 60)}"`);
|
||||
if (rect) parts.push(`@${Math.round(rect.left)},${Math.round(rect.top)} ${Math.round(rect.width)}x${Math.round(rect.height)}`);
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function dispatchPointerMouseSequence(target) {
|
||||
const rect = target.getBoundingClientRect?.();
|
||||
const clientX = rect ? Math.round(rect.left + rect.width / 2) : 0;
|
||||
const clientY = rect ? Math.round(rect.top + rect.height / 2) : 0;
|
||||
const eventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
detail: 1,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
screenX: window.screenX + clientX,
|
||||
screenY: window.screenY + clientY,
|
||||
};
|
||||
if (typeof PointerEvent === 'function') {
|
||||
target.dispatchEvent(new PointerEvent('pointerover', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
target.dispatchEvent(new PointerEvent('pointerenter', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: false }));
|
||||
target.dispatchEvent(new PointerEvent('pointermove', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mouseover', eventInit));
|
||||
target.dispatchEvent(new MouseEvent('mouseenter', { ...eventInit, bubbles: false }));
|
||||
target.dispatchEvent(new MouseEvent('mousemove', eventInit));
|
||||
target.dispatchEvent(new MouseEvent('mousedown', eventInit));
|
||||
if (typeof PointerEvent === 'function') {
|
||||
target.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 }));
|
||||
}
|
||||
target.dispatchEvent(new MouseEvent('mouseup', { ...eventInit, buttons: 0 }));
|
||||
target.dispatchEvent(new MouseEvent('click', { ...eventInit, buttons: 0 }));
|
||||
}
|
||||
|
||||
async function humanClickElement(el, options = {}) {
|
||||
if (!el) {
|
||||
throw new Error('GoPay 页面未找到可点击元素。');
|
||||
}
|
||||
el.scrollIntoView?.({ block: 'center', inline: 'center' });
|
||||
await sleep(Math.max(0, Number(options.beforeMs) || 120));
|
||||
try {
|
||||
el.focus?.({ preventScroll: true });
|
||||
} catch (_) {
|
||||
try { el.focus?.(); } catch (__) {}
|
||||
}
|
||||
dispatchPointerMouseSequence(el);
|
||||
await sleep(Math.max(0, Number(options.afterDispatchMs) || 120));
|
||||
if (typeof el.click === 'function') {
|
||||
el.click();
|
||||
}
|
||||
await sleep(Math.max(0, Number(options.afterMs) || 1000));
|
||||
}
|
||||
|
||||
async function clickContinueIfPresent(options = {}) {
|
||||
const button = findContinueButton();
|
||||
if (!button) {
|
||||
return { clicked: false, target: '' };
|
||||
}
|
||||
await humanClickElement(button, options);
|
||||
return { clicked: true, target: describeElement(button) };
|
||||
}
|
||||
|
||||
function normalizePhoneNumber(value = '') {
|
||||
return String(value || '').trim().replace(/[^\d+]/g, '');
|
||||
}
|
||||
|
||||
function normalizeGoPayCountryCode(value = '') {
|
||||
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
return digits ? `+${digits}` : '+86';
|
||||
}
|
||||
|
||||
function getCountryCodeDigits(value = '') {
|
||||
return normalizeGoPayCountryCode(value).replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function normalizeGoPayNationalPhone(value = '', countryCode = '+86') {
|
||||
const countryDigits = getCountryCodeDigits(countryCode);
|
||||
let digits = normalizePhoneNumber(value).replace(/\D/g, '');
|
||||
if (countryDigits && digits.startsWith(countryDigits)) {
|
||||
digits = digits.slice(countryDigits.length);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function getCombinedElementText(el) {
|
||||
return normalizeText([
|
||||
getActionText(el),
|
||||
el?.innerText,
|
||||
el?.textContent,
|
||||
typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'),
|
||||
].filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function robustClick(el) {
|
||||
if (!el) return;
|
||||
el.scrollIntoView?.({ block: 'center', inline: 'center' });
|
||||
try {
|
||||
el.focus?.();
|
||||
} catch (_) {}
|
||||
const eventInit = { bubbles: true, cancelable: true, view: window };
|
||||
if (typeof PointerEvent === 'function') {
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
}
|
||||
el.dispatchEvent(new MouseEvent('mousedown', eventInit));
|
||||
if (typeof PointerEvent === 'function') {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
||||
}
|
||||
el.dispatchEvent(new MouseEvent('mouseup', eventInit));
|
||||
el.dispatchEvent(new MouseEvent('click', eventInit));
|
||||
if (typeof el.click === 'function') {
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
|
||||
function readSelectedCountryCodeText() {
|
||||
const candidates = getVisibleControls('.phone-code, .phone-code-wrapper, [class*="phone-code"], button, [role="button"], [tabindex]');
|
||||
for (const candidate of candidates) {
|
||||
const match = getCombinedElementText(candidate).match(/\+\d{1,4}/);
|
||||
if (match) return normalizeGoPayCountryCode(match[0]);
|
||||
}
|
||||
const bodyMatch = normalizeText(document.body?.innerText || document.body?.textContent || '').match(/Phone number:\s*(\+\d{1,4})/i);
|
||||
return bodyMatch ? normalizeGoPayCountryCode(bodyMatch[1]) : '';
|
||||
}
|
||||
|
||||
function findCountryCodeToggle() {
|
||||
const preferred = getVisibleControls('.phone-code-wrapper, [class*="phone-code-wrapper"], .phone-code, [class*="phone-code"]')
|
||||
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)));
|
||||
if (preferred) return preferred;
|
||||
return getVisibleControls('button, [role="button"], [tabindex], div, span')
|
||||
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)) && /phone|code|country|\+\d{1,4}/i.test(getCombinedElementText(el))) || null;
|
||||
}
|
||||
|
||||
function findCountryCodeOption(countryCode = '+86') {
|
||||
const normalized = normalizeGoPayCountryCode(countryCode);
|
||||
const digits = getCountryCodeDigits(normalized);
|
||||
const countryAliases = {
|
||||
'1': [/United States|USA|Canada|美国|加拿大/i],
|
||||
'60': [/Malaysia|马来西亚/i],
|
||||
'62': [/Indonesia|印尼|印度尼西亚/i],
|
||||
'63': [/Philippines|菲律宾/i],
|
||||
'65': [/Singapore|新加坡/i],
|
||||
'66': [/Thailand|泰国/i],
|
||||
'84': [/Vietnam|越南/i],
|
||||
'86': [/China|中国|Mainland/i],
|
||||
'91': [/India|印度/i],
|
||||
'852': [/Hong Kong|香港/i],
|
||||
'853': [/Macau|Macao|澳门/i],
|
||||
'886': [/Taiwan|台湾/i],
|
||||
}[digits] || [];
|
||||
const controls = getVisibleControls('li.country-item, .country-item, [class*="country-item"], [role="option"], li, button, [role="button"], a, [tabindex], div, span')
|
||||
.map((el) => el.closest?.('.country-item') || el)
|
||||
.filter((el, index, list) => el && list.indexOf(el) === index)
|
||||
.filter((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const text = getCombinedElementText(el);
|
||||
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
|
||||
return text.includes(normalized)
|
||||
&& rect.width > 20
|
||||
&& rect.height > 8
|
||||
&& rect.width < Math.max(480, window.innerWidth * 0.8)
|
||||
&& rect.height < Math.max(120, window.innerHeight * 0.35)
|
||||
&& !/phone-number-input-wrapper|gopay-tokenization-content|asphalt-theme|search-country|country-list/i.test(className);
|
||||
});
|
||||
const matchesAlias = (el) => {
|
||||
const text = getCombinedElementText(el);
|
||||
return countryAliases.some((pattern) => pattern.test(text));
|
||||
};
|
||||
return controls.find((el) => /country-item/i.test(typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '') && matchesAlias(el))
|
||||
|| controls.find((el) => String(el.tagName || '').toUpperCase() === 'LI' && matchesAlias(el))
|
||||
|| controls.find((el) => el.getAttribute?.('role') === 'option' && matchesAlias(el))
|
||||
|| controls.find(matchesAlias)
|
||||
|| null;
|
||||
}
|
||||
|
||||
async function ensureGoPayCountryCode(countryCode = '+86') {
|
||||
const normalized = normalizeGoPayCountryCode(countryCode);
|
||||
const selected = readSelectedCountryCodeText();
|
||||
if (selected === normalized) {
|
||||
return { changed: false, countryCode: normalized, selected };
|
||||
}
|
||||
|
||||
const toggle = findCountryCodeToggle();
|
||||
if (!toggle) {
|
||||
throw new Error(`GoPay 页面未找到国家区号切换控件,当前识别区号:${selected || '未知'},目标区号:${normalized}`);
|
||||
}
|
||||
robustClick(toggle);
|
||||
await sleep(500);
|
||||
|
||||
const countryDropdown = document.querySelector('.search-country');
|
||||
if (countryDropdown && window.getComputedStyle(countryDropdown).display === 'none') {
|
||||
countryDropdown.style.display = 'block';
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
const countrySearchInput = getVisibleTextInputs().find(isCountrySearchInput);
|
||||
if (countrySearchInput) {
|
||||
fillInput(countrySearchInput, normalized);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
const option = await waitUntil(() => findCountryCodeOption(normalized), {
|
||||
label: `GoPay 国家区号 ${normalized}`,
|
||||
intervalMs: 250,
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
robustClick(option);
|
||||
await sleep(500);
|
||||
|
||||
const nextSelected = readSelectedCountryCodeText();
|
||||
if (nextSelected === normalized && countryDropdown) {
|
||||
countryDropdown.style.display = 'none';
|
||||
}
|
||||
if (nextSelected !== normalized) {
|
||||
throw new Error(`GoPay 国家区号切换失败:目标 ${normalized},当前 ${nextSelected || '未知'}`);
|
||||
}
|
||||
return {
|
||||
changed: true,
|
||||
countryCode: normalized,
|
||||
selected: nextSelected,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOtp(value = '') {
|
||||
return String(value || '').trim().replace(/[^\d]/g, '');
|
||||
}
|
||||
|
||||
|
||||
function fillDigitInputs(inputs = [], code = '') {
|
||||
const normalizedCode = normalizeOtp(code);
|
||||
if (!normalizedCode || !inputs.length) return false;
|
||||
normalizedCode.split('').forEach((digit, index) => {
|
||||
const input = inputs[index];
|
||||
if (!input) return;
|
||||
try {
|
||||
input.focus?.();
|
||||
} catch (_) {}
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
|
||||
fillInput(input, digit);
|
||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function fillVisiblePinInputs(pin = '') {
|
||||
const normalizedPin = normalizeOtp(pin);
|
||||
if (!normalizedPin) return false;
|
||||
const pinInputs = getGoPayPinInputs();
|
||||
const digitInputs = pinInputs.filter((input) => {
|
||||
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
|
||||
return maxLength > 0 && maxLength <= 1;
|
||||
});
|
||||
if (digitInputs.length >= Math.min(4, normalizedPin.length)) {
|
||||
return fillDigitInputs(digitInputs, normalizedPin);
|
||||
}
|
||||
const input = findPinInput() || pinInputs[0];
|
||||
if (!input) return false;
|
||||
fillInput(input, normalizedPin);
|
||||
return true;
|
||||
}
|
||||
|
||||
function fillVisibleOtpInputs(code = '') {
|
||||
const normalizedCode = normalizeOtp(code);
|
||||
if (!normalizedCode) return false;
|
||||
if (isGoPayPinPageText() || getVisibleTextInputs().some((input) => isGoPayPinInput(input))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const otpInputs = getVisibleTextInputs()
|
||||
.filter((input) => {
|
||||
if (isGoPayPinInput(input)) return false;
|
||||
const text = getActionText(input);
|
||||
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
|
||||
return /otp|code|kode|verification|验证码|短信/i.test(text)
|
||||
|| (maxLength > 0 && maxLength <= 1)
|
||||
|| (maxLength > 1 && maxLength <= 8);
|
||||
});
|
||||
|
||||
const digitInputs = otpInputs.filter((input) => {
|
||||
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
|
||||
return maxLength > 0 && maxLength <= 1;
|
||||
});
|
||||
if (digitInputs.length >= Math.min(4, normalizedCode.length)) {
|
||||
return fillDigitInputs(digitInputs, normalizedCode);
|
||||
}
|
||||
|
||||
const input = findOtpInput() || otpInputs[0];
|
||||
if (!input) return false;
|
||||
fillInput(input, normalizedCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitGoPayPhone(payload = {}) {
|
||||
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
|
||||
? performGoPayOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const countryCode = normalizeGoPayCountryCode(payload.countryCode || payload.gopayCountryCode || '+86');
|
||||
const phone = normalizeGoPayNationalPhone(payload.phone || payload.gopayPhone || '', countryCode);
|
||||
if (!phone) {
|
||||
throw new Error('GoPay 手机号为空,请先在侧边栏配置。');
|
||||
}
|
||||
const input = await waitUntil(() => findPhoneInput(), {
|
||||
label: 'GoPay 手机号输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const { countryResult, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-phone' }, async () => {
|
||||
const nextCountryResult = await ensureGoPayCountryCode(countryCode);
|
||||
fillInput(input, phone);
|
||||
const nextClickResult = await clickContinueIfPresent();
|
||||
return {
|
||||
countryResult: nextCountryResult,
|
||||
clickResult: nextClickResult,
|
||||
};
|
||||
});
|
||||
return {
|
||||
phoneSubmitted: true,
|
||||
countryCode,
|
||||
countryChanged: Boolean(countryResult.changed),
|
||||
clicked: Boolean(clickResult.clicked),
|
||||
clickTarget: clickResult.target || '',
|
||||
phase: 'phone_submitted',
|
||||
};
|
||||
}
|
||||
|
||||
async function submitGoPayOtp(payload = {}) {
|
||||
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
|
||||
? performGoPayOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const code = normalizeOtp(payload.code || payload.otp || '');
|
||||
if (!code) {
|
||||
throw new Error('GoPay WhatsApp 验证码为空。');
|
||||
}
|
||||
const { filled, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-otp' }, async () => {
|
||||
const filledOtp = await waitUntil(() => fillVisibleOtpInputs(code), {
|
||||
label: 'GoPay 验证码输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const continueResult = await clickContinueIfPresent();
|
||||
return { filled: filledOtp, clickResult: continueResult };
|
||||
});
|
||||
return {
|
||||
otpSubmitted: Boolean(filled),
|
||||
clicked: Boolean(clickResult.clicked),
|
||||
clickTarget: clickResult.target || '',
|
||||
phase: 'otp_submitted',
|
||||
};
|
||||
}
|
||||
|
||||
async function submitGoPayPin(payload = {}) {
|
||||
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
|
||||
? performGoPayOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const pin = normalizeOtp(payload.pin || payload.gopayPin || '');
|
||||
if (!pin) {
|
||||
throw new Error('GoPay PIN 为空,请先在侧边栏配置。');
|
||||
}
|
||||
const { filled, clickResult } = await delayOperation({ stepKey: 'gopay-approve', kind: 'submit', label: 'submit-pin' }, async () => {
|
||||
const filledPin = await waitUntil(() => fillVisiblePinInputs(pin), {
|
||||
label: 'GoPay PIN 输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const continueResult = await clickContinueIfPresent();
|
||||
return { filled: filledPin, clickResult: continueResult };
|
||||
});
|
||||
return {
|
||||
pinSubmitted: Boolean(filled),
|
||||
clicked: Boolean(clickResult.clicked),
|
||||
clickTarget: clickResult.target || '',
|
||||
phase: 'pin_submitted',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function getElementClickRect(el) {
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect?.();
|
||||
if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
centerX: rect.left + rect.width / 2,
|
||||
centerY: rect.top + rect.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getGoPayContinueTarget() {
|
||||
const button = findContinueButton();
|
||||
return {
|
||||
found: Boolean(button),
|
||||
target: describeElement(button),
|
||||
rect: getElementClickRect(button),
|
||||
};
|
||||
}
|
||||
|
||||
function getGoPayPayNowTarget() {
|
||||
const button = findPayNowButton();
|
||||
return {
|
||||
found: Boolean(button),
|
||||
target: describeElement(button),
|
||||
rect: getElementClickRect(button),
|
||||
};
|
||||
}
|
||||
|
||||
async function clickGoPayContinue() {
|
||||
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
|
||||
? performGoPayOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const button = findContinueButton();
|
||||
if (!button) {
|
||||
return { clicked: false, clickTarget: '' };
|
||||
}
|
||||
const clickResult = await delayOperation({ stepKey: 'gopay-approve', kind: 'click', label: 'click-continue' }, async () => {
|
||||
await humanClickElement(button, { afterMs: 1200 });
|
||||
return { clicked: true, target: describeElement(button) };
|
||||
});
|
||||
return { clicked: Boolean(clickResult.clicked), clickTarget: clickResult.target || '' };
|
||||
}
|
||||
|
||||
async function clickGoPayPayNow() {
|
||||
const delayOperation = typeof performGoPayOperationWithDelay === 'function'
|
||||
? performGoPayOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const button = findPayNowButton();
|
||||
if (!button) {
|
||||
return { clicked: false, clickTarget: '' };
|
||||
}
|
||||
await delayOperation({ stepKey: 'gopay-approve', kind: 'click', label: 'click-pay-now' }, async () => {
|
||||
await humanClickElement(button, { afterMs: 1500 });
|
||||
});
|
||||
return { clicked: true, clickTarget: describeElement(button) };
|
||||
}
|
||||
|
||||
function inspectGoPayState() {
|
||||
const bodyText = normalizeText(document.body?.innerText || document.body?.textContent || '');
|
||||
const phoneInput = findPhoneInput();
|
||||
const otpInput = findOtpInput();
|
||||
const pinInput = findPinInput();
|
||||
const payNowButton = findPayNowButton();
|
||||
const continueButton = findContinueButton();
|
||||
const terminalError = detectGoPayTerminalError(bodyText);
|
||||
const successTextMatched = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText);
|
||||
const completed = !phoneInput && !otpInput && !pinInput && successTextMatched;
|
||||
const selectedCountryCode = readSelectedCountryCodeText();
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
selectedCountryCode,
|
||||
hasPhoneInput: Boolean(phoneInput),
|
||||
hasOtpInput: Boolean(otpInput),
|
||||
hasPinInput: Boolean(pinInput),
|
||||
hasPayNowButton: Boolean(payNowButton),
|
||||
hasContinueButton: Boolean(continueButton),
|
||||
hasTerminalError: Boolean(terminalError),
|
||||
terminalError,
|
||||
completed,
|
||||
textPreview: bodyText.slice(0, 500),
|
||||
inputHints: getVisibleTextInputs().map((input) => getActionText(input).slice(0, 120)).filter(Boolean).slice(0, 12),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
const ICLOUD_MAIL_PREFIX = '[MultiPage:icloud-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
const ICLOUD_POLL_SESSION_CACHE = new Map();
|
||||
|
||||
console.log(ICLOUD_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
function isMailApplicationFrame() {
|
||||
if (/\/applications\/mail2\//.test(location.pathname)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(document.querySelector('.content-container, .mail-message-defaults, .thread-participants'));
|
||||
}
|
||||
|
||||
if (isTopFrame) {
|
||||
console.log(ICLOUD_MAIL_PREFIX, 'Top frame detected; waiting for mail iframe.');
|
||||
}
|
||||
|
||||
const shouldHandlePollEmailInCurrentFrame = !isTopFrame || isMailApplicationFrame();
|
||||
if (shouldHandlePollEmailInCurrentFrame) {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
if (!isMailApplicationFrame()) {
|
||||
sendResponse({ ok: false, reason: 'wrong-frame' });
|
||||
return;
|
||||
}
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
log(`步骤 ${message.step}:iCloud 邮箱轮询失败:${err.message}`, 'warn');
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeRulePatternList(patterns = []) {
|
||||
return Array.isArray(patterns) ? patterns : [];
|
||||
}
|
||||
|
||||
function extractCodeByRulePatterns(text, patterns = []) {
|
||||
const normalizedText = String(text || '');
|
||||
for (const pattern of normalizeRulePatternList(patterns)) {
|
||||
try {
|
||||
const source = String(pattern?.source || '').trim();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
|
||||
const match = normalizedText.match(new RegExp(source, flags));
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
const candidate = String(match[index] || '').trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (String(match[0] || '').trim()) {
|
||||
return String(match[0] || '').trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore invalid runtime rule patterns and continue with other candidates.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVisibleElement(node) {
|
||||
return Boolean(node instanceof HTMLElement)
|
||||
&& (Boolean(node.offsetParent) || getComputedStyle(node).position === 'fixed');
|
||||
}
|
||||
|
||||
function collectThreadItems() {
|
||||
return Array.from(document.querySelectorAll('.content-container')).filter((item) => {
|
||||
if (!isVisibleElement(item)) return false;
|
||||
return item.querySelector('.thread-participants')
|
||||
&& item.querySelector('.thread-subject')
|
||||
&& item.querySelector('.thread-preview');
|
||||
});
|
||||
}
|
||||
|
||||
function getThreadItemMetadata(item) {
|
||||
const sender = normalizeText(item.querySelector('.thread-participants')?.textContent || '');
|
||||
const subject = normalizeText(item.querySelector('.thread-subject')?.textContent || '');
|
||||
const preview = normalizeText(item.querySelector('.thread-preview')?.textContent || '');
|
||||
const timestamp = normalizeText(item.querySelector('.thread-timestamp')?.textContent || '');
|
||||
return {
|
||||
sender,
|
||||
subject,
|
||||
preview,
|
||||
timestamp,
|
||||
combinedText: normalizeText([sender, subject, preview, timestamp].filter(Boolean).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function buildItemSignature(item) {
|
||||
const meta = getThreadItemMetadata(item);
|
||||
return normalizeText([
|
||||
item.getAttribute('aria-label') || '',
|
||||
meta.sender,
|
||||
meta.subject,
|
||||
meta.preview,
|
||||
meta.timestamp,
|
||||
].join('::')).slice(0, 240);
|
||||
}
|
||||
|
||||
function extractVerificationCode(text, options = {}) {
|
||||
const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns);
|
||||
if (matchedByRule) return matchedByRule;
|
||||
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i);
|
||||
if (matchLoginCode) return matchLoginCode[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOpenedMailHeader() {
|
||||
const headerRoot = document.querySelector('.ic-efwqa7');
|
||||
if (!headerRoot) {
|
||||
return { sender: '', recipients: '', timestamp: '' };
|
||||
}
|
||||
|
||||
const contactValues = Array.from(headerRoot.querySelectorAll('.contact-token .ic-x1z554'))
|
||||
.map((node) => normalizeText(node.textContent))
|
||||
.filter(Boolean);
|
||||
const sender = contactValues[0] || '';
|
||||
const recipients = contactValues.slice(1).join(' ');
|
||||
const timestamp = normalizeText(headerRoot.querySelector('.ic-rffsj8')?.textContent || '');
|
||||
return { sender, recipients, timestamp };
|
||||
}
|
||||
|
||||
function getOpenedMailBodyRoot() {
|
||||
return document.querySelector('.mail-message-defaults, .pane.thread-detail-pane');
|
||||
}
|
||||
|
||||
function readOpenedMailBody() {
|
||||
const bodyRoot = getOpenedMailBodyRoot();
|
||||
return normalizeText(bodyRoot?.innerText || bodyRoot?.textContent || '');
|
||||
}
|
||||
|
||||
function getThreadListItemRoot(item) {
|
||||
return item?.closest?.('.thread-list-item, [role="treeitem"]') || null;
|
||||
}
|
||||
|
||||
function isThreadItemSelected(item, expectedSignature = '') {
|
||||
const expected = normalizeText(expectedSignature);
|
||||
const candidates = collectThreadItems();
|
||||
const matchedItem = expected
|
||||
? candidates.find((candidate) => buildItemSignature(candidate) === expected)
|
||||
: item;
|
||||
const root = getThreadListItemRoot(matchedItem || item);
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
if (root.getAttribute('aria-selected') === 'true') {
|
||||
return true;
|
||||
}
|
||||
const className = String(root.className || '').toLowerCase();
|
||||
return /\b(selected|current|active)\b/.test(className);
|
||||
}
|
||||
|
||||
function openedMailMatchesExpectedContent(expectedMeta = {}, header = null, bodyText = '') {
|
||||
const expectedSender = normalizeText(expectedMeta.sender || '').toLowerCase();
|
||||
const expectedSubject = normalizeText(expectedMeta.subject || '').toLowerCase();
|
||||
const combined = normalizeText([
|
||||
header?.sender || '',
|
||||
header?.recipients || '',
|
||||
header?.timestamp || '',
|
||||
bodyText || '',
|
||||
].join(' ')).toLowerCase();
|
||||
|
||||
if (expectedSender && combined.includes(expectedSender)) {
|
||||
return true;
|
||||
}
|
||||
if (expectedSubject && combined.includes(expectedSubject)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForOpenedMailContent(item, expectedMeta = {}, timeout = 10000) {
|
||||
const expectedSignature = buildItemSignature(item);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
const headerRoot = document.querySelector('.ic-efwqa7');
|
||||
const bodyRoot = getOpenedMailBodyRoot();
|
||||
const selected = isThreadItemSelected(item, expectedSignature);
|
||||
if (selected && (headerRoot || bodyRoot)) {
|
||||
const header = readOpenedMailHeader();
|
||||
const bodyText = normalizeText(bodyRoot?.innerText || bodyRoot?.textContent || '');
|
||||
if (openedMailMatchesExpectedContent(expectedMeta, header, bodyText)) {
|
||||
return { headerRoot, bodyRoot };
|
||||
}
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('打开邮件后未找到详情区域,请确认邮件内容已加载。');
|
||||
}
|
||||
|
||||
async function openMailItemAndRead(item) {
|
||||
const expectedMeta = getThreadItemMetadata(item);
|
||||
simulateClick(item);
|
||||
|
||||
const { bodyRoot } = await waitForOpenedMailContent(item, expectedMeta, 10000);
|
||||
await sleep(300);
|
||||
|
||||
const header = readOpenedMailHeader();
|
||||
const bodyText = normalizeText(
|
||||
bodyRoot?.innerText || bodyRoot?.textContent || readOpenedMailBody()
|
||||
);
|
||||
return {
|
||||
...header,
|
||||
bodyText,
|
||||
combinedText: normalizeText([header.sender, header.recipients, header.timestamp, bodyText].filter(Boolean).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshInbox() {
|
||||
const refreshPatterns = [/刷新/i, /refresh/i, /重新载入/i];
|
||||
const candidates = document.querySelectorAll('button, [role="button"], a');
|
||||
for (const node of candidates) {
|
||||
const text = normalizeText(node.innerText || node.textContent || '');
|
||||
const label = normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || '');
|
||||
if (refreshPatterns.some((pattern) => pattern.test(text) || pattern.test(label))) {
|
||||
simulateClick(node);
|
||||
await sleep(1000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const inboxPatterns = [/收件箱/, /inbox/i];
|
||||
for (const node of candidates) {
|
||||
const text = normalizeText(node.innerText || node.textContent || '');
|
||||
const label = normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || '');
|
||||
if (inboxPatterns.some((pattern) => pattern.test(text) || pattern.test(label))) {
|
||||
simulateClick(node);
|
||||
await sleep(1000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePollSessionKey(payload = {}) {
|
||||
const raw = String(payload?.sessionKey || '').trim();
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getOrCreatePollSessionBaseline(sessionKey, currentItems = []) {
|
||||
if (!sessionKey) {
|
||||
return {
|
||||
signatures: new Set(currentItems.map(buildItemSignature)),
|
||||
fallbackCarry: 0,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cached = ICLOUD_POLL_SESSION_CACHE.get(sessionKey);
|
||||
if (cached && cached.signatures instanceof Set) {
|
||||
return {
|
||||
signatures: new Set(cached.signatures),
|
||||
fallbackCarry: Math.max(0, Number(cached.fallbackCarry) || 0),
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
signatures: new Set(currentItems.map(buildItemSignature)),
|
||||
fallbackCarry: 0,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
function persistPollSessionBaseline(sessionKey, signatures, fallbackCarry = 0) {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ICLOUD_POLL_SESSION_CACHE.set(sessionKey, {
|
||||
signatures: new Set(signatures || []),
|
||||
fallbackCarry: Math.max(0, Number(fallbackCarry) || 0),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (ICLOUD_POLL_SESSION_CACHE.size > 12) {
|
||||
const oldest = Array.from(ICLOUD_POLL_SESSION_CACHE.entries())
|
||||
.sort((left, right) => Number(left?.[1]?.updatedAt || 0) - Number(right?.[1]?.updatedAt || 0))
|
||||
.slice(0, ICLOUD_POLL_SESSION_CACHE.size - 12);
|
||||
oldest.forEach(([key]) => ICLOUD_POLL_SESSION_CACHE.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const {
|
||||
codePatterns = [],
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
maxAttempts,
|
||||
intervalMs,
|
||||
excludeCodes = [],
|
||||
} = payload;
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
const FALLBACK_AFTER = 3;
|
||||
const pollSessionKey = normalizePollSessionKey(payload);
|
||||
const normalizedSenderFilters = senderFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
|
||||
const normalizedSubjectFilters = subjectFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
|
||||
|
||||
log(`步骤 ${step}:开始轮询 iCloud 邮箱(最多 ${maxAttempts} 次)`);
|
||||
await waitForElement('.content-container', 10000);
|
||||
await sleep(1500);
|
||||
const currentItems = collectThreadItems();
|
||||
const sessionBaseline = getOrCreatePollSessionBaseline(pollSessionKey, currentItems);
|
||||
const existingSignatures = sessionBaseline.signatures;
|
||||
let fallbackCarry = sessionBaseline.fallbackCarry;
|
||||
if (sessionBaseline.fromCache) {
|
||||
log(`步骤 ${step}:已复用当前会话旧邮件快照(${existingSignatures.size} 封)。`);
|
||||
} else {
|
||||
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
log(`步骤 ${step}:正在轮询 iCloud 邮箱,第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
const items = collectThreadItems();
|
||||
const useFallback = (fallbackCarry + attempt) > FALLBACK_AFTER;
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const signature = buildItemSignature(item);
|
||||
const allowInitialStep8VisibleCode = Number(step) === 8
|
||||
&& attempt === 1
|
||||
&& index === 0
|
||||
&& !sessionBaseline.fromCache;
|
||||
if (!useFallback && existingSignatures.has(signature) && !allowInitialStep8VisibleCode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = getThreadItemMetadata(item);
|
||||
const lowerSender = meta.sender.toLowerCase();
|
||||
const lowerSubject = normalizeText([meta.subject, meta.preview].join(' ')).toLowerCase();
|
||||
const senderMatch = normalizedSenderFilters.some((filter) => lowerSender.includes(filter));
|
||||
const subjectMatch = normalizedSubjectFilters.some((filter) => lowerSubject.includes(filter));
|
||||
|
||||
if (!senderMatch && !subjectMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let code = extractVerificationCode(meta.combinedText, { codePatterns });
|
||||
let opened = null;
|
||||
|
||||
if (!code) {
|
||||
opened = await openMailItemAndRead(item);
|
||||
const openedSender = opened.sender.toLowerCase();
|
||||
const openedBody = opened.bodyText.toLowerCase();
|
||||
const openedSenderMatch = normalizedSenderFilters.some((filter) => openedSender.includes(filter));
|
||||
const openedSubjectMatch = normalizedSubjectFilters.some((filter) => openedBody.includes(filter));
|
||||
if (!openedSenderMatch && !openedSubjectMatch && !senderMatch && !subjectMatch) {
|
||||
continue;
|
||||
}
|
||||
code = extractVerificationCode(opened.combinedText, { codePatterns });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
if (excludedCodeSet.has(code)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = useFallback && existingSignatures.has(signature) ? '回退匹配邮件' : '新邮件';
|
||||
log(`步骤 ${step}:已找到验证码:${code}(来源:${source})`, 'ok');
|
||||
persistPollSessionBaseline(
|
||||
pollSessionKey,
|
||||
new Set(collectThreadItems().map(buildItemSignature)),
|
||||
0
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
code,
|
||||
emailTimestamp: Date.now(),
|
||||
preview: (opened?.combinedText || meta.combinedText).slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCarry += maxAttempts;
|
||||
persistPollSessionBaseline(
|
||||
pollSessionKey,
|
||||
new Set(collectThreadItems().map(buildItemSignature)),
|
||||
fallbackCarry
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`${Math.round((maxAttempts * intervalMs) / 1000)} 秒后仍未在 iCloud 邮箱中找到新的匹配邮件。请手动检查收件箱。`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
|
||||
// Injected dynamically on the configured Inbucket host
|
||||
//
|
||||
// Supported page:
|
||||
// - /m/<mailbox>/
|
||||
|
||||
const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
|
||||
|
||||
console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(INBUCKET_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
let seenMailIds = new Set();
|
||||
|
||||
async function loadSeenMailIds() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
|
||||
if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
|
||||
seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
|
||||
console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSeenMailIds() {
|
||||
try {
|
||||
await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
loadSeenMailIds();
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn');
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRulePatternList(patterns = []) {
|
||||
return Array.isArray(patterns) ? patterns : [];
|
||||
}
|
||||
|
||||
function extractCodeByRulePatterns(text, patterns = []) {
|
||||
const normalizedText = String(text || '');
|
||||
for (const pattern of normalizeRulePatternList(patterns)) {
|
||||
try {
|
||||
const source = String(pattern?.source || '').trim();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
|
||||
const match = normalizedText.match(new RegExp(source, flags));
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
const candidate = String(match[index] || '').trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (String(match[0] || '').trim()) {
|
||||
return String(match[0] || '').trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore invalid runtime rule patterns and continue with other candidates.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesKeywordHints(text, keywords = []) {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedKeywords = Array.isArray(keywords) ? keywords : [];
|
||||
return normalizedKeywords.some((keyword) => normalizedText.includes(normalizeText(keyword)));
|
||||
}
|
||||
|
||||
function extractVerificationCode(text, options = {}) {
|
||||
const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns);
|
||||
if (matchedByRule) {
|
||||
return matchedByRule;
|
||||
}
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i);
|
||||
if (matchLoginCode) return matchLoginCode[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail, options = {}) {
|
||||
const sender = normalizeText(mail.sender);
|
||||
const subject = normalizeText(mail.subject);
|
||||
const mailbox = normalizeText(mail.mailbox);
|
||||
const combined = normalizeText(mail.combinedText);
|
||||
const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
|
||||
const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
|
||||
const code = extractVerificationCode(mail.combinedText, {
|
||||
codePatterns: options?.codePatterns,
|
||||
});
|
||||
const keywordMatch = options?.requiredKeywords?.length
|
||||
? matchesKeywordHints(combined, options.requiredKeywords)
|
||||
: /verify|verification|confirm|log-?in|验证码|代码/.test(combined);
|
||||
|
||||
if (mailboxMatch) return { matched: true, mailboxMatch, code };
|
||||
if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
|
||||
if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
|
||||
|
||||
return { matched: false, mailboxMatch: false, code };
|
||||
}
|
||||
|
||||
function findMailboxEntries() {
|
||||
return document.querySelectorAll('.message-list-entry');
|
||||
}
|
||||
|
||||
function getMailboxEntryId(entry, index = 0) {
|
||||
const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
|
||||
if (explicitId) return explicitId;
|
||||
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
|
||||
return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
|
||||
}
|
||||
|
||||
function parseMailboxEntry(entry, index = 0) {
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
|
||||
|
||||
return {
|
||||
entry,
|
||||
dateText,
|
||||
sender,
|
||||
mailbox: '',
|
||||
subject,
|
||||
unread: entry.classList.contains('unseen'),
|
||||
combinedText,
|
||||
mailId: getMailboxEntryId(entry, index),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentMailboxIds() {
|
||||
const ids = new Set();
|
||||
Array.from(findMailboxEntries()).forEach((entry, index) => {
|
||||
ids.add(getMailboxEntryId(entry, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function refreshMailbox() {
|
||||
const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
|
||||
if (!refreshButton) return;
|
||||
|
||||
simulateClick(refreshButton);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function openMailboxEntry(entry) {
|
||||
simulateClick(entry);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
|
||||
return;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentMailboxMessage(step) {
|
||||
try {
|
||||
const deleteButton = await waitForElement('.button-bar button.danger', 5000);
|
||||
simulateClick(deleteButton);
|
||||
log(`步骤 ${step}:已删除邮箱消息`, 'ok');
|
||||
await sleep(1200);
|
||||
} catch (err) {
|
||||
log(`步骤 ${step}:删除邮箱消息失败:${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMailboxPollEmail(step, payload) {
|
||||
const {
|
||||
codePatterns = [],
|
||||
requiredKeywords = [],
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
maxAttempts = 20,
|
||||
intervalMs = 3000,
|
||||
excludeCodes = [],
|
||||
} = payload || {};
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
|
||||
log(`步骤 ${step}:开始轮询 Inbucket 邮箱页面(最多 ${maxAttempts} 次)`);
|
||||
|
||||
try {
|
||||
await waitForElement('.message-list, .message-list-entry', 15000);
|
||||
log(`步骤 ${step}:邮箱页面已加载`);
|
||||
} catch {
|
||||
throw new Error('Inbucket 邮箱页面未加载完成,请确认已打开 /m/<mailbox>/ 页面。');
|
||||
}
|
||||
|
||||
const existingMailIds = getCurrentMailboxIds();
|
||||
log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧消息快照`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`步骤 ${step}:正在轮询 Inbucket 邮箱,第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshMailbox();
|
||||
}
|
||||
|
||||
const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
const candidates = [];
|
||||
|
||||
for (const mail of entries) {
|
||||
if (!mail.unread) continue;
|
||||
if (seenMailIds.has(mail.mailId)) continue;
|
||||
if (!useFallback && existingMailIds.has(mail.mailId)) continue;
|
||||
|
||||
const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '', {
|
||||
codePatterns,
|
||||
requiredKeywords,
|
||||
});
|
||||
if (!match.matched) continue;
|
||||
|
||||
candidates.push({ ...mail, code: match.code });
|
||||
}
|
||||
|
||||
for (const mail of candidates) {
|
||||
const code = mail.code || extractVerificationCode(mail.combinedText, {
|
||||
codePatterns,
|
||||
});
|
||||
if (!code) continue;
|
||||
if (excludedCodeSet.has(code)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
|
||||
continue;
|
||||
}
|
||||
|
||||
await openMailboxEntry(mail.entry);
|
||||
await deleteCurrentMailboxMessage(step);
|
||||
|
||||
seenMailIds.add(mail.mailId);
|
||||
await persistSeenMailIds();
|
||||
|
||||
const source = existingMailIds.has(mail.mailId) ? '回退匹配邮件' : '新邮件';
|
||||
log(
|
||||
`步骤 ${step}:已找到验证码:${code}(来源:${source},发件人:${mail.sender || '未知'},主题:${(mail.subject || '').slice(0, 60)})`,
|
||||
'ok'
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
code,
|
||||
emailTimestamp: Date.now(),
|
||||
mailId: mail.mailId,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:暂未发现新消息,开始回退到较早的匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 Inbucket 邮箱中找到匹配的验证码邮件。` +
|
||||
'请手动检查邮箱页面。'
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
if (!location.pathname.startsWith('/m/')) {
|
||||
throw new Error('当前 Inbucket 仅支持 /m/<mailbox>/ 这种邮箱页面。');
|
||||
}
|
||||
return handleMailboxPollEmail(step, payload);
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
@@ -0,0 +1,783 @@
|
||||
// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
|
||||
// Injected on: mail.163.com
|
||||
//
|
||||
// DOM structure:
|
||||
// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 : OpenAI ..."
|
||||
// Sender: .nui-user (e.g., "OpenAI")
|
||||
// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
|
||||
// Delete actions: hover trash icon on the row, or checkbox + toolbar delete button
|
||||
|
||||
const MAIL163_PREFIX = '[MultiPage:mail-163]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// Only operate in the top frame
|
||||
if (!isTopFrame) {
|
||||
console.log(MAIL163_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
|
||||
let seenCodes = new Set();
|
||||
|
||||
async function loadSeenCodes() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get('seenCodes');
|
||||
if (data.seenCodes && Array.isArray(data.seenCodes)) {
|
||||
seenCodes = new Set(data.seenCodes);
|
||||
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load previously seen codes on startup
|
||||
loadSeenCodes();
|
||||
|
||||
async function persistSeenCodes() {
|
||||
try {
|
||||
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Handler (top frame only)
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn');
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Find mail items
|
||||
// ============================================================
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeRulePatternList(patterns = []) {
|
||||
return Array.isArray(patterns) ? patterns : [];
|
||||
}
|
||||
|
||||
function extractCodeByRulePatterns(text, patterns = []) {
|
||||
const normalizedText = String(text || '');
|
||||
for (const pattern of normalizeRulePatternList(patterns)) {
|
||||
try {
|
||||
const source = String(pattern?.source || '').trim();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
|
||||
const match = normalizedText.match(new RegExp(source, flags));
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
const candidate = String(match[index] || '').trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (String(match[0] || '').trim()) {
|
||||
return String(match[0] || '').trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore invalid runtime rule patterns and continue with other candidates.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNetEaseMailLabel(hostname) {
|
||||
const currentHostname = String(
|
||||
hostname || (typeof location !== 'undefined' ? location.hostname : '') || ''
|
||||
).toLowerCase();
|
||||
|
||||
if (currentHostname === 'mail.126.com' || currentHostname.endsWith('.mail.126.com')) {
|
||||
return '126 邮箱';
|
||||
}
|
||||
if (currentHostname === 'webmail.vip.163.com') {
|
||||
return '163 VIP 邮箱';
|
||||
}
|
||||
|
||||
return '163 邮箱';
|
||||
}
|
||||
|
||||
function isVisibleNode(node) {
|
||||
if (!node) return false;
|
||||
if (node.hidden) return false;
|
||||
|
||||
const style = typeof window.getComputedStyle === 'function'
|
||||
? window.getComputedStyle(node)
|
||||
: null;
|
||||
if (style && (style.display === 'none' || style.visibility === 'hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = typeof node.getBoundingClientRect === 'function'
|
||||
? node.getBoundingClientRect()
|
||||
: null;
|
||||
if (rect && rect.width <= 0 && rect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLikelyMailItemNode(node) {
|
||||
if (!isVisibleNode(node)) {
|
||||
return false;
|
||||
}
|
||||
if (node.matches?.('div[sign="letter"]')) {
|
||||
return true;
|
||||
}
|
||||
if (node.querySelector?.('.nui-user, span.da0, [sign="trash"], [title="删除邮件"], [class*="subject"], [class*="sender"]')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const summaryText = normalizeText(
|
||||
node.getAttribute?.('aria-label')
|
||||
|| node.getAttribute?.('title')
|
||||
|| node.textContent
|
||||
);
|
||||
if (!summaryText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /发件人|验证码|verification|code|log-?in/i.test(summaryText);
|
||||
}
|
||||
|
||||
function findMailItems() {
|
||||
const selectorGroups = [
|
||||
'div[sign="letter"]',
|
||||
'[role="option"][aria-label]',
|
||||
'[role="listitem"][aria-label]',
|
||||
'tr[aria-label]',
|
||||
'li[aria-label]',
|
||||
'div[aria-label]',
|
||||
];
|
||||
|
||||
for (const selector of selectorGroups) {
|
||||
const matches = Array.from(document.querySelectorAll(selector)).filter(isLikelyMailItemNode);
|
||||
if (matches.length > 0) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getMailTextBySelectors(item, selectors = []) {
|
||||
for (const selector of selectors) {
|
||||
const candidates = item.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
const texts = [
|
||||
candidate.getAttribute?.('title'),
|
||||
candidate.getAttribute?.('aria-label'),
|
||||
candidate.textContent,
|
||||
];
|
||||
for (const text of texts) {
|
||||
const normalized = normalizeText(text);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailSenderText(item) {
|
||||
return getMailTextBySelectors(item, [
|
||||
'.nui-user',
|
||||
'[class*="sender"]',
|
||||
'[class*="from"]',
|
||||
'[data-sender]',
|
||||
]);
|
||||
}
|
||||
|
||||
function getMailSubjectText(item) {
|
||||
return getMailTextBySelectors(item, [
|
||||
'span.da0',
|
||||
'[class*="subject"]',
|
||||
'[data-subject]',
|
||||
'strong',
|
||||
]);
|
||||
}
|
||||
|
||||
function getMailRowText(item) {
|
||||
const ariaLabel = normalizeText(item.getAttribute('aria-label'));
|
||||
const sender = getMailSenderText(item);
|
||||
const subject = getMailSubjectText(item);
|
||||
const fullText = normalizeText(item.textContent || '');
|
||||
return normalizeText([ariaLabel, sender, subject, fullText].filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function getMailItemId(item, index = 0) {
|
||||
const candidates = [
|
||||
item.getAttribute('id'),
|
||||
item.getAttribute('data-id'),
|
||||
item.dataset?.id,
|
||||
item.getAttribute('data-key'),
|
||||
item.getAttribute('key'),
|
||||
].filter(Boolean);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return String(candidates[0]);
|
||||
}
|
||||
|
||||
return `${index}|${getMailRowText(item).slice(0, 240)}`;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
const ids = new Set();
|
||||
const sourceItems = items.length > 0 ? items : findMailItems();
|
||||
sourceItems.forEach((item, index) => {
|
||||
ids.add(getMailItemId(item, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizeMinuteTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
|
||||
const date = new Date(timestamp);
|
||||
date.setSeconds(0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function parseMail163Timestamp(rawText) {
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return null;
|
||||
|
||||
let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/今天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 1);
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/\b(\d{1,2}):(\d{2})\b/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyMailTimestampText(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2})|今天\s*\d{1,2}:\d{2}|昨天\s*\d{1,2}:\d{2}|\b\d{1,2}:\d{2}\b/.test(text);
|
||||
}
|
||||
|
||||
function collectMailTimestampCandidates(item) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const pushCandidate = (value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || !isLikelyMailTimestampText(normalized) || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
candidates.push(normalized);
|
||||
};
|
||||
|
||||
const priorityNodes = item.querySelectorAll('.e00, [title], [aria-label], time, [class*="time"], [class*="date"]');
|
||||
priorityNodes.forEach((node) => {
|
||||
pushCandidate(node.getAttribute?.('title'));
|
||||
pushCandidate(node.getAttribute?.('aria-label'));
|
||||
pushCandidate(node.textContent);
|
||||
});
|
||||
|
||||
const textNodes = item.querySelectorAll('span, div, td, strong, b');
|
||||
textNodes.forEach((node) => {
|
||||
const text = normalizeText(node.textContent);
|
||||
if (text && text.length <= 24) {
|
||||
pushCandidate(text);
|
||||
}
|
||||
});
|
||||
|
||||
pushCandidate(item.getAttribute('aria-label'));
|
||||
pushCandidate(item.getAttribute('title'));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function getMailTimestamp(item) {
|
||||
const candidates = collectMailTimestampCandidates(item);
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseMail163Timestamp(candidate);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectOpenedMailTextCandidates() {
|
||||
const texts = [];
|
||||
const seen = new Set();
|
||||
const pushText = (value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
texts.push(normalized);
|
||||
};
|
||||
|
||||
const selectors = [
|
||||
'.readHtml',
|
||||
'[class*="readmail"]',
|
||||
'[class*="mailread"]',
|
||||
'[class*="mailBody"]',
|
||||
'[class*="mailbody"]',
|
||||
'[class*="mail-content"]',
|
||||
'[class*="mailContent"]',
|
||||
'[class*="mail-detail"]',
|
||||
'[class*="mailDetail"]',
|
||||
'[class*="detail"] [class*="content"]',
|
||||
'[class*="read"] [class*="content"]',
|
||||
'[role="main"]',
|
||||
];
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
pushText(node.innerText || node.textContent);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('iframe').forEach((frame) => {
|
||||
try {
|
||||
pushText(frame.contentDocument?.body?.innerText || frame.contentDocument?.body?.textContent);
|
||||
} catch {
|
||||
// Ignore cross-frame access errors and keep trying other candidates.
|
||||
}
|
||||
});
|
||||
|
||||
pushText(document.body?.innerText || document.body?.textContent);
|
||||
return texts.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function selectOpenedMailTextCandidate(item, candidates = [], options = {}) {
|
||||
const subject = normalizeText(getMailSubjectText(item)).toLowerCase();
|
||||
const sender = normalizeText(getMailSenderText(item)).toLowerCase();
|
||||
const excludedSet = new Set((options.excludedTexts || []).map((value) => normalizeText(value)));
|
||||
const allowExcludedFallback = options.allowExcludedFallback !== false;
|
||||
|
||||
const pickCandidate = (source) => source.find((candidate) => {
|
||||
const lower = candidate.toLowerCase();
|
||||
if (subject && lower.includes(subject)) {
|
||||
return true;
|
||||
}
|
||||
if (sender && lower.includes(sender)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(extractVerificationCode(candidate, { codePatterns: options.codePatterns }) && /verification|验证码|log-?in\s+code|enter\s+this\s+code|code/i.test(lower));
|
||||
}) || source[0] || '';
|
||||
|
||||
const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate)));
|
||||
const preferred = pickCandidate(filteredCandidates);
|
||||
if (preferred || !allowExcludedFallback) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
return pickCandidate(candidates);
|
||||
}
|
||||
|
||||
function readOpenedMailText(item, options = {}) {
|
||||
const candidates = collectOpenedMailTextCandidates();
|
||||
return selectOpenedMailTextCandidate(item, candidates, options);
|
||||
}
|
||||
|
||||
async function returnToInbox() {
|
||||
const inboxLink = document.querySelector('.nui-tree-item-text[title="收件箱"], [title="收件箱"]');
|
||||
if (inboxLink) {
|
||||
if (typeof simulateClick === 'function') {
|
||||
simulateClick(inboxLink);
|
||||
} else {
|
||||
inboxLink.click();
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (findMailItems().length > 0) {
|
||||
return true;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function openMailAndGetMessageText(item, options = {}) {
|
||||
const beforeCandidates = collectOpenedMailTextCandidates();
|
||||
const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates, {
|
||||
codePatterns: options.codePatterns,
|
||||
});
|
||||
if (typeof simulateClick === 'function') {
|
||||
simulateClick(item);
|
||||
} else {
|
||||
item.click();
|
||||
}
|
||||
|
||||
let openedText = '';
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
await sleep(250);
|
||||
const candidate = readOpenedMailText(item, {
|
||||
codePatterns: options.codePatterns,
|
||||
excludedTexts: beforeCandidates,
|
||||
allowExcludedFallback: false,
|
||||
});
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
openedText = candidate;
|
||||
if (extractVerificationCode(candidate, { codePatterns: options.codePatterns })) {
|
||||
break;
|
||||
}
|
||||
if (candidate !== beforeText && candidate.length > beforeText.length + 24) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await returnToInbox();
|
||||
return openedText;
|
||||
}
|
||||
|
||||
function scheduleEmailCleanup(item, step) {
|
||||
setTimeout(() => {
|
||||
Promise.resolve(deleteEmail(item, step)).catch(() => {
|
||||
// Cleanup is best effort only and must never affect the main verification flow.
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const {
|
||||
codePatterns = [],
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
maxAttempts,
|
||||
intervalMs,
|
||||
excludeCodes = [],
|
||||
filterAfterTimestamp = 0,
|
||||
} = payload;
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
|
||||
const mailLabel = getNetEaseMailLabel();
|
||||
|
||||
log(`步骤 ${step}:开始轮询 ${mailLabel}(最多 ${maxAttempts} 次)`);
|
||||
if (filterAfterMinute) {
|
||||
log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`);
|
||||
}
|
||||
|
||||
// Click inbox in sidebar to ensure we're in inbox view
|
||||
log(`步骤 ${step}:正在等待侧边栏加载...`);
|
||||
try {
|
||||
const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
|
||||
inboxLink.click();
|
||||
log(`步骤 ${step}:已点击收件箱`);
|
||||
} catch {
|
||||
log(`步骤 ${step}:未找到收件箱入口,继续尝试后续流程...`, 'warn');
|
||||
}
|
||||
|
||||
// Wait for mail list to appear
|
||||
log(`步骤 ${step}:正在等待邮件列表加载...`);
|
||||
let items = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
items = findMailItems();
|
||||
if (items.length > 0) break;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
await refreshInbox();
|
||||
await sleep(2000);
|
||||
items = findMailItems();
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error(`${mailLabel}列表未加载完成,请确认当前已打开收件箱。`);
|
||||
}
|
||||
|
||||
log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`);
|
||||
|
||||
// Snapshot existing mail IDs
|
||||
const existingMailIds = getCurrentMailIds(items);
|
||||
log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`步骤 ${step}:正在轮询 ${mailLabel},第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
const allItems = findMailItems();
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
|
||||
for (let index = 0; index < allItems.length; index++) {
|
||||
const item = allItems[index];
|
||||
const id = getMailItemId(item, index);
|
||||
const mailTimestamp = getMailTimestamp(item);
|
||||
const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0);
|
||||
const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute);
|
||||
|
||||
if (!passesTimeFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!useFallback && existingMailIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sender = getMailSenderText(item).toLowerCase();
|
||||
const subject = getMailSubjectText(item);
|
||||
const rowText = getMailRowText(item);
|
||||
const ariaLabel = normalizeText(item.getAttribute('aria-label')).toLowerCase();
|
||||
const combinedText = normalizeText([subject, ariaLabel, rowText].filter(Boolean).join(' '));
|
||||
|
||||
if (!mailTimestamp) {
|
||||
log(`步骤 ${step}:邮件 ${id.slice(0, 60)} 未读取到时间,已跳过时间窗口校验后的文本匹配阶段。`, 'info');
|
||||
}
|
||||
|
||||
const senderMatch = senderFilters.some((filter) => {
|
||||
const normalizedFilter = String(filter || '').toLowerCase();
|
||||
return normalizedFilter && (sender.includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
|
||||
});
|
||||
const subjectMatch = subjectFilters.some((filter) => {
|
||||
const normalizedFilter = String(filter || '').toLowerCase();
|
||||
return normalizedFilter && (subject.toLowerCase().includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
|
||||
});
|
||||
|
||||
if (senderMatch || subjectMatch) {
|
||||
let code = extractVerificationCode(combinedText, { codePatterns });
|
||||
let codeSource = '邮件列表';
|
||||
|
||||
if (!code) {
|
||||
const openedText = await openMailAndGetMessageText(item, { codePatterns });
|
||||
code = extractVerificationCode(openedText, { codePatterns });
|
||||
if (code) {
|
||||
codeSource = '邮件正文';
|
||||
}
|
||||
}
|
||||
|
||||
if (code && excludedCodeSet.has(code)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
|
||||
} else if (code && !seenCodes.has(code)) {
|
||||
seenCodes.add(code);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(id) ? `回退匹配${codeSource}` : `新邮件${codeSource}`;
|
||||
const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
|
||||
log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok');
|
||||
|
||||
// Trigger cleanup only as a best-effort side effect.
|
||||
scheduleEmailCleanup(item, step);
|
||||
|
||||
return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
|
||||
} else if (code && seenCodes.has(code)) {
|
||||
log(`步骤 ${step}:跳过已处理过的验证码:${code}`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 ${mailLabel}中找到新的匹配邮件。` +
|
||||
'请手动检查收件箱。'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Delete Email via Hover Trash / Toolbar Fallback
|
||||
// ============================================================
|
||||
|
||||
async function deleteEmail(item, step) {
|
||||
try {
|
||||
log(`步骤 ${step}:正在删除邮件...`);
|
||||
|
||||
// Strategy 1: Click the trash icon inside the mail item
|
||||
// Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
|
||||
// These icons appear on hover, so we trigger mouseover first
|
||||
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await sleep(300);
|
||||
|
||||
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
|
||||
if (trashIcon) {
|
||||
trashIcon.click();
|
||||
log(`步骤 ${step}:已点击删除图标`, 'ok');
|
||||
await sleep(1500);
|
||||
|
||||
// Check if item disappeared (confirm deletion)
|
||||
const stillExists = document.getElementById(item.id);
|
||||
if (!stillExists || stillExists.style.display === 'none') {
|
||||
log(`步骤 ${step}:邮件已成功删除`);
|
||||
} else {
|
||||
log(`步骤 ${step}:邮件可能尚未删除,列表中仍可见`, 'warn');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Select checkbox then click toolbar delete button
|
||||
log(`步骤 ${step}:未找到删除图标,尝试使用复选框加工具栏删除...`);
|
||||
const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
|
||||
if (checkbox) {
|
||||
checkbox.click();
|
||||
await sleep(300);
|
||||
|
||||
// Click toolbar delete button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('删除')) {
|
||||
btn.closest('.nui-btn').click();
|
||||
log(`步骤 ${step}:已点击工具栏删除`, 'ok');
|
||||
await sleep(1500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`步骤 ${step}:无法删除邮件(未找到删除按钮)`, 'warn');
|
||||
} catch (err) {
|
||||
log(`步骤 ${step}:删除邮件失败:${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try toolbar "刷 新" button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '') === '刷新') {
|
||||
btn.closest('.nui-btn').click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: click sidebar "收 信"
|
||||
const shouXinBtns = document.querySelectorAll('.ra0');
|
||||
for (const btn of shouXinBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
|
||||
btn.click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Could not find refresh button');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Verification Code Extraction
|
||||
// ============================================================
|
||||
|
||||
function extractVerificationCode(text, options = {}) {
|
||||
const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns);
|
||||
if (matchedByRule) {
|
||||
return matchedByRule;
|
||||
}
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i);
|
||||
if (matchLoginCode) return matchLoginCode[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
(function attachOperationDelay(root) {
|
||||
const OPERATION_DELAY_MS = 2000;
|
||||
const SETTING_RESTORE_FALLBACK_MS = 50;
|
||||
const EXCLUDED_STEP_KEYS = new Set(['confirm-oauth', 'platform-verify']);
|
||||
let operationDelayEnabled = true;
|
||||
let operationDelaySettingReady = null;
|
||||
let operationDelaySettingRevision = 0;
|
||||
|
||||
function normalizeOperationDelayEnabled(value) {
|
||||
return typeof value === 'boolean' ? value : true;
|
||||
}
|
||||
|
||||
function getOperationDelayEnabled() {
|
||||
return operationDelayEnabled;
|
||||
}
|
||||
|
||||
async function refreshOperationDelaySetting() {
|
||||
const restoreRevision = ++operationDelaySettingRevision;
|
||||
const ready = (async () => {
|
||||
let nextEnabled = true;
|
||||
try {
|
||||
const data = await root.chrome?.storage?.local?.get?.(['operationDelayEnabled']);
|
||||
nextEnabled = normalizeOperationDelayEnabled(data?.operationDelayEnabled);
|
||||
} catch {
|
||||
nextEnabled = true;
|
||||
}
|
||||
if (operationDelaySettingRevision === restoreRevision) {
|
||||
operationDelayEnabled = nextEnabled;
|
||||
}
|
||||
return operationDelayEnabled;
|
||||
})();
|
||||
operationDelaySettingReady = ready;
|
||||
try {
|
||||
return await ready;
|
||||
} finally {
|
||||
if (operationDelaySettingReady === ready) {
|
||||
operationDelaySettingReady = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDelayOperation(metadata = {}) {
|
||||
if (metadata.skipOperationDelay === true) return false;
|
||||
if (EXCLUDED_STEP_KEYS.has(String(metadata.stepKey || '').trim())) return false;
|
||||
const enabled = Object.prototype.hasOwnProperty.call(metadata, 'enabled')
|
||||
? normalizeOperationDelayEnabled(metadata.enabled)
|
||||
: getOperationDelayEnabled();
|
||||
if (enabled === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForOperationDelaySettingFallback() {
|
||||
return new Promise((resolve) => {
|
||||
const schedule = root.setTimeout || (typeof setTimeout === 'function' ? setTimeout : null);
|
||||
if (typeof schedule === 'function') {
|
||||
schedule(() => resolve(getOperationDelayEnabled()), SETTING_RESTORE_FALLBACK_MS);
|
||||
return;
|
||||
}
|
||||
resolve(getOperationDelayEnabled());
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveOperationDelayEnabled(metadata = {}, options = {}) {
|
||||
if (typeof options.getEnabled === 'function') {
|
||||
return normalizeOperationDelayEnabled(options.getEnabled());
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(metadata, 'enabled')) {
|
||||
return normalizeOperationDelayEnabled(metadata.enabled);
|
||||
}
|
||||
if (operationDelaySettingReady) {
|
||||
return normalizeOperationDelayEnabled(await Promise.race([
|
||||
operationDelaySettingReady,
|
||||
waitForOperationDelaySettingFallback(),
|
||||
]));
|
||||
}
|
||||
return getOperationDelayEnabled();
|
||||
}
|
||||
|
||||
async function performOperationWithDelay(metadata = {}, operation, options = {}) {
|
||||
const result = await operation();
|
||||
const enabled = await resolveOperationDelayEnabled(metadata, options);
|
||||
if (shouldDelayOperation({ ...metadata, enabled })) {
|
||||
const wait = options.sleep || root.sleep;
|
||||
await wait(OPERATION_DELAY_MS);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
root.chrome?.storage?.onChanged?.addListener?.((changes, areaName) => {
|
||||
if (areaName === 'local' && Object.prototype.hasOwnProperty.call(changes, 'operationDelayEnabled')) {
|
||||
operationDelaySettingRevision += 1;
|
||||
operationDelayEnabled = normalizeOperationDelayEnabled(changes.operationDelayEnabled?.newValue);
|
||||
}
|
||||
});
|
||||
|
||||
refreshOperationDelaySetting().catch(() => { operationDelayEnabled = true; });
|
||||
root.CodexOperationDelay = { OPERATION_DELAY_MS, normalizeOperationDelayEnabled, refreshOperationDelaySetting, getOperationDelayEnabled, shouldDelayOperation, performOperationWithDelay };
|
||||
})(typeof self !== 'undefined' ? self : globalThis);
|
||||
@@ -0,0 +1,957 @@
|
||||
// content/paypal-flow.js — PayPal login and approval helper.
|
||||
|
||||
console.log('[MultiPage:paypal-flow] Content script loaded on', location.href);
|
||||
|
||||
const PAYPAL_FLOW_LISTENER_SENTINEL = 'data-multipage-paypal-flow-listener';
|
||||
const PAYPAL_HOSTED_DEFAULT_PHONE = '1234567890';
|
||||
const PAYPAL_HOSTED_STAGE_OUTSIDE = 'outside_paypal';
|
||||
const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login';
|
||||
const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout';
|
||||
const PAYPAL_HOSTED_STAGE_VERIFICATION = 'verification';
|
||||
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
|
||||
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
|
||||
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
|
||||
const PAYPAL_HOSTED_HERMES_AUTORUN_SENTINEL = '__MULTIPAGE_PAYPAL_HOSTED_HERMES_AUTORUN__';
|
||||
const PAYPAL_HOSTED_GUEST_SUBMIT_SENTINEL = '__MULTIPAGE_PAYPAL_HOSTED_GUEST_SUBMIT__';
|
||||
|
||||
if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1');
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (
|
||||
message.type === 'PAYPAL_GET_STATE'
|
||||
|| message.type === 'PAYPAL_SUBMIT_LOGIN'
|
||||
|| message.type === 'PAYPAL_DISMISS_PROMPTS'
|
||||
|| message.type === 'PAYPAL_CLICK_APPROVE'
|
||||
|| message.type === 'PAYPAL_HOSTED_GET_STATE'
|
||||
|| message.type === 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP'
|
||||
) {
|
||||
resetStopState();
|
||||
handlePayPalCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[MultiPage:paypal-flow] 消息监听已存在,跳过重复注册');
|
||||
}
|
||||
|
||||
async function performPayPalOperationWithDelay(metadata, operation) {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
}
|
||||
|
||||
async function handlePayPalCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'PAYPAL_GET_STATE':
|
||||
return inspectPayPalState();
|
||||
case 'PAYPAL_SUBMIT_LOGIN':
|
||||
return submitPayPalLogin(message.payload || {});
|
||||
case 'PAYPAL_DISMISS_PROMPTS':
|
||||
return dismissPayPalPrompts();
|
||||
case 'PAYPAL_CLICK_APPROVE':
|
||||
return clickPayPalApprove();
|
||||
case 'PAYPAL_HOSTED_GET_STATE':
|
||||
return inspectPayPalState();
|
||||
case 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP':
|
||||
return runHostedCheckoutStep(message.payload || {});
|
||||
default:
|
||||
throw new Error(`paypal-flow.js 不处理消息:${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(predicate, options = {}) {
|
||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
||||
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
|
||||
const startedAt = Date.now();
|
||||
while (true) {
|
||||
throwIfStopped();
|
||||
const value = await predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
|
||||
throw new Error(options.timeoutMessage || 'PayPal page timed out waiting for target state.');
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDocumentComplete() {
|
||||
await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 });
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
if (!el) return false;
|
||||
let node = el;
|
||||
while (node && node.nodeType === 1) {
|
||||
if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) {
|
||||
return false;
|
||||
}
|
||||
const nodeStyle = window.getComputedStyle(node);
|
||||
if (
|
||||
nodeStyle.display === 'none'
|
||||
|| nodeStyle.visibility === 'hidden'
|
||||
|| nodeStyle.visibility === 'collapse'
|
||||
|| Number(nodeStyle.opacity) === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(rect.width) > 0
|
||||
&& Number(rect.height) > 0;
|
||||
}
|
||||
|
||||
function normalizeText(text = '') {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return normalizeText([
|
||||
el?.textContent,
|
||||
el?.value,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.getAttribute?.('placeholder'),
|
||||
el?.getAttribute?.('name'),
|
||||
el?.id,
|
||||
].filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function getVisibleControls(selector) {
|
||||
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
|
||||
}
|
||||
|
||||
function isEnabledControl(el) {
|
||||
return Boolean(el)
|
||||
&& !el.disabled
|
||||
&& el.getAttribute?.('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function findClickableByText(patterns) {
|
||||
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
|
||||
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"]');
|
||||
return candidates.find((el) => {
|
||||
const text = getActionText(el);
|
||||
return normalizedPatterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findInputByPatterns(patterns) {
|
||||
const inputs = getVisibleControls('input')
|
||||
.filter((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
|
||||
});
|
||||
return inputs.find((input) => {
|
||||
const text = getActionText(input);
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findEmailInput() {
|
||||
const isPasswordCandidate = (input) => {
|
||||
const type = String(input?.getAttribute?.('type') || input?.type || '').trim().toLowerCase();
|
||||
const metadataText = normalizeText([
|
||||
input?.textContent,
|
||||
input?.getAttribute?.('aria-label'),
|
||||
input?.getAttribute?.('title'),
|
||||
input?.getAttribute?.('placeholder'),
|
||||
input?.getAttribute?.('name'),
|
||||
input?.id,
|
||||
].filter(Boolean).join(' '));
|
||||
return type === 'password' || /password|pass|密码/i.test(metadataText);
|
||||
};
|
||||
const inputs = getVisibleControls('input')
|
||||
.filter((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
return isEnabledControl(input)
|
||||
&& !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type)
|
||||
&& !isPasswordCandidate(input);
|
||||
});
|
||||
return inputs.find((input) => [
|
||||
/email|login|user|账号|邮箱/i,
|
||||
].some((pattern) => pattern.test(getActionText(input))))
|
||||
|| getVisibleControls('input[type="email"]').find((input) => isVisibleElement(input) && !isPasswordCandidate(input))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function findPasswordInput() {
|
||||
const inputs = getVisibleControls('input')
|
||||
.filter((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
|
||||
});
|
||||
return inputs.find((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
const metadataText = normalizeText([
|
||||
input?.textContent,
|
||||
input?.getAttribute?.('aria-label'),
|
||||
input?.getAttribute?.('title'),
|
||||
input?.getAttribute?.('placeholder'),
|
||||
input?.getAttribute?.('name'),
|
||||
input?.id,
|
||||
].filter(Boolean).join(' '));
|
||||
return type === 'password' || /password|pass|密码/i.test(metadataText);
|
||||
}) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null;
|
||||
}
|
||||
|
||||
function findLoginNextButton() {
|
||||
return findClickableByText([
|
||||
/next|continue|login|log\s*in|sign\s*in/i,
|
||||
/下一步|继续|登录|登入/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function findEmailNextButton() {
|
||||
return findClickableByText([
|
||||
/next|btn\s*next|btnnext/i,
|
||||
/下一页|下一步/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function findPasswordLoginButton() {
|
||||
const button = findClickableByText([
|
||||
/login|log\s*in|sign\s*in/i,
|
||||
/登录|登入/i,
|
||||
]);
|
||||
return button && button !== findEmailNextButton() ? button : null;
|
||||
}
|
||||
|
||||
function findApproveButton() {
|
||||
return findClickableByText([
|
||||
/同意并继续|同意|继续|授权|确认并继续/i,
|
||||
/agree\s*(?:and)?\s*continue|continue|accept|authorize|agree|pay\s*now/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function getPayPalHostedPathname() {
|
||||
return String(location?.pathname || '').trim();
|
||||
}
|
||||
|
||||
function isPayPalHostedLoginPage() {
|
||||
const pathname = getPayPalHostedPathname();
|
||||
return pathname === '/pay'
|
||||
|| Boolean(document.getElementById('email'));
|
||||
}
|
||||
|
||||
function isPayPalHostedGuestCheckoutPage() {
|
||||
const pathname = getPayPalHostedPathname();
|
||||
return /\/checkoutweb\//i.test(pathname)
|
||||
|| Boolean(document.getElementById('cardNumber'))
|
||||
|| Boolean(document.getElementById('billingLine1'));
|
||||
}
|
||||
|
||||
function isPayPalHostedReviewPage() {
|
||||
return /\/webapps\/hermes/i.test(getPayPalHostedPathname());
|
||||
}
|
||||
|
||||
function findHostedVerificationInputs() {
|
||||
return Array.from({ length: 6 }, (_, index) => document.getElementById(`ci-ciBasic-${index}`))
|
||||
.filter((input) => isVisibleElement(input));
|
||||
}
|
||||
|
||||
function hasHostedVerificationInputs() {
|
||||
return findHostedVerificationInputs().length >= 6;
|
||||
}
|
||||
|
||||
function findHostedReviewConsentButton() {
|
||||
const direct = document.getElementById('consentButton')
|
||||
|| document.querySelector('button[data-testid="consentButton"]');
|
||||
if (direct && isVisibleElement(direct) && isEnabledControl(direct)) {
|
||||
return direct;
|
||||
}
|
||||
return findClickableByText([
|
||||
/agree\s*(?:and)?\s*continue|accept|continue/i,
|
||||
/同意并继续|同意|继续/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function detectPayPalHostedCheckoutStage() {
|
||||
if (!/paypal\./i.test(String(location?.host || ''))) {
|
||||
return PAYPAL_HOSTED_STAGE_OUTSIDE;
|
||||
}
|
||||
if (hasHostedVerificationInputs()) {
|
||||
return PAYPAL_HOSTED_STAGE_VERIFICATION;
|
||||
}
|
||||
if (isPayPalHostedGuestCheckoutPage()) {
|
||||
return PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT;
|
||||
}
|
||||
if (isPayPalHostedReviewPage() && findHostedReviewConsentButton()) {
|
||||
return PAYPAL_HOSTED_STAGE_REVIEW;
|
||||
}
|
||||
if (isPayPalHostedLoginPage()) {
|
||||
return PAYPAL_HOSTED_STAGE_LOGIN;
|
||||
}
|
||||
if (Boolean(findApproveButton())) {
|
||||
return PAYPAL_HOSTED_STAGE_APPROVAL;
|
||||
}
|
||||
return PAYPAL_HOSTED_STAGE_UNKNOWN;
|
||||
}
|
||||
|
||||
function fillHostedInputById(id, value) {
|
||||
const input = document.getElementById(String(id || '').trim());
|
||||
if (!input || !isVisibleElement(input) || !isEnabledControl(input)) {
|
||||
return false;
|
||||
}
|
||||
fillInput(input, String(value || ''));
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectHostedOptionByIdText(id, text) {
|
||||
const select = document.getElementById(String(id || '').trim());
|
||||
const expectedText = normalizeText(text);
|
||||
if (!select || !expectedText || !Array.isArray(Array.from(select.options || []))) {
|
||||
return false;
|
||||
}
|
||||
const match = Array.from(select.options || []).find((option) => {
|
||||
const label = normalizeText(option?.textContent || option?.label || '');
|
||||
const value = normalizeText(option?.value || '');
|
||||
return label.toLowerCase().includes(expectedText.toLowerCase())
|
||||
|| value.toLowerCase().includes(expectedText.toLowerCase());
|
||||
});
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
select.value = match.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeHostedCaptchaArtifacts() {
|
||||
let removed = false;
|
||||
const selectors = [
|
||||
'#captcha-standalone',
|
||||
'.captcha-overlay',
|
||||
'.captcha-container',
|
||||
];
|
||||
selectors.forEach((selector) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
try {
|
||||
node.remove();
|
||||
removed = true;
|
||||
} catch {
|
||||
// Ignore non-removable overlays.
|
||||
}
|
||||
});
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
function startHostedCaptchaCleanupObserver(timeoutMs = 15000) {
|
||||
const observer = new MutationObserver(() => {
|
||||
removeHostedCaptchaArtifacts();
|
||||
});
|
||||
observer.observe(document.documentElement || document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
setTimeout(() => observer.disconnect(), Math.max(1000, Number(timeoutMs) || 15000));
|
||||
return observer;
|
||||
}
|
||||
|
||||
function findHostedGuestSubmitButton() {
|
||||
return document.querySelector('button[data-testid="submit-button"]')
|
||||
|| document.querySelector('button[data-testid="hosted-payment-submit-button"]')
|
||||
|| document.querySelector('button[data-atomic-wait-intent="Submit_Email"]')
|
||||
|| document.querySelector('button.SubmitButton--complete')
|
||||
|| findClickableByText([
|
||||
/pay|continue|next|agree|subscribe/i,
|
||||
/支付|继续|下一步|同意|订阅/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildHostedRandomEmail() {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let value = '';
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
value += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
return `${value}@gmail.com`;
|
||||
}
|
||||
|
||||
function buildHostedRandomPassword() {
|
||||
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const digits = '0123456789';
|
||||
const symbols = '!@#$%^';
|
||||
const alphabet = `${lowercase}${uppercase}${digits}${symbols}`;
|
||||
const value = [
|
||||
lowercase[Math.floor(Math.random() * lowercase.length)],
|
||||
uppercase[Math.floor(Math.random() * uppercase.length)],
|
||||
digits[Math.floor(Math.random() * digits.length)],
|
||||
symbols[Math.floor(Math.random() * symbols.length)],
|
||||
];
|
||||
while (value.length < 14) {
|
||||
value.push(alphabet[Math.floor(Math.random() * alphabet.length)]);
|
||||
}
|
||||
return value.sort(() => Math.random() - 0.5).join('');
|
||||
}
|
||||
|
||||
function buildHostedVisaCard() {
|
||||
const prefixes = [
|
||||
[4, 1, 4, 7],
|
||||
[4, 1, 0, 0],
|
||||
];
|
||||
const digits = prefixes[Math.floor(Math.random() * prefixes.length)].slice();
|
||||
while (digits.length < 15) {
|
||||
digits.push(Math.floor(Math.random() * 10));
|
||||
}
|
||||
const reversed = digits.slice().reverse();
|
||||
let sum = 0;
|
||||
for (let index = 0; index < reversed.length; index += 1) {
|
||||
let digit = reversed[index];
|
||||
if (index % 2 === 0) {
|
||||
digit *= 2;
|
||||
if (digit > 9) {
|
||||
digit -= 9;
|
||||
}
|
||||
}
|
||||
sum += digit;
|
||||
}
|
||||
const checkDigit = (10 - (sum % 10)) % 10;
|
||||
digits.push(checkDigit);
|
||||
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||
const currentYear = new Date().getFullYear() % 100;
|
||||
const year = currentYear + Math.floor(Math.random() * 4) + 2;
|
||||
const cvv = String(Math.floor(100 + Math.random() * 900));
|
||||
return {
|
||||
number: digits.join(''),
|
||||
expiry: `${month} / ${year}`,
|
||||
cvv,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchHostedGenericClick(button) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
const eventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX,
|
||||
clientY,
|
||||
};
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', eventInit));
|
||||
button.dispatchEvent(new MouseEvent('mousedown', eventInit));
|
||||
button.dispatchEvent(new PointerEvent('pointerup', eventInit));
|
||||
button.dispatchEvent(new MouseEvent('mouseup', eventInit));
|
||||
button.dispatchEvent(new MouseEvent('click', eventInit));
|
||||
}
|
||||
|
||||
async function clickHostedGenericSubmitButton(retries = 0) {
|
||||
removeHostedCaptchaArtifacts();
|
||||
const button = findHostedGuestSubmitButton() || findEmailNextButton() || findLoginNextButton();
|
||||
if (!button) {
|
||||
if (retries >= 10) {
|
||||
throw new Error('PayPal hosted checkout 未找到可点击的继续/提交按钮。');
|
||||
}
|
||||
await sleep(1000);
|
||||
return clickHostedGenericSubmitButton(retries + 1);
|
||||
}
|
||||
|
||||
const buttonText = normalizeText(button.textContent || '');
|
||||
if (button.disabled) {
|
||||
if (retries >= 10) {
|
||||
throw new Error('PayPal hosted checkout 按钮长时间处于 disabled 状态。');
|
||||
}
|
||||
await sleep(1000);
|
||||
return clickHostedGenericSubmitButton(retries + 1);
|
||||
}
|
||||
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (rect.height === 0) {
|
||||
if (retries >= 10) {
|
||||
throw new Error('PayPal hosted checkout 按钮长时间不可见。');
|
||||
}
|
||||
await sleep(1000);
|
||||
return clickHostedGenericSubmitButton(retries + 1);
|
||||
}
|
||||
|
||||
dispatchHostedGenericClick(button);
|
||||
await sleep(1000);
|
||||
removeHostedCaptchaArtifacts();
|
||||
|
||||
if (hasHostedVerificationInputs()) {
|
||||
return {
|
||||
clicked: true,
|
||||
verificationRequired: true,
|
||||
buttonText,
|
||||
};
|
||||
}
|
||||
|
||||
const currentText = normalizeText(button.textContent || '');
|
||||
if (!/processing/i.test(currentText) && currentText === buttonText) {
|
||||
if (retries >= 10) {
|
||||
return {
|
||||
clicked: true,
|
||||
verificationRequired: false,
|
||||
buttonText,
|
||||
retried: true,
|
||||
};
|
||||
}
|
||||
await sleep(2000);
|
||||
return clickHostedGenericSubmitButton(retries + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
clicked: true,
|
||||
verificationRequired: false,
|
||||
buttonText,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostedVerificationCode(value = '') {
|
||||
const digits = String(value || '').replace(/\D+/g, '');
|
||||
return digits.slice(0, 6);
|
||||
}
|
||||
|
||||
async function submitHostedPayLogin(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
removeHostedCaptchaArtifacts();
|
||||
const email = normalizeText(payload.email || buildHostedRandomEmail());
|
||||
if (!email) {
|
||||
throw new Error('PayPal hosted checkout 缺少邮箱。');
|
||||
}
|
||||
const emailInput = document.getElementById('email') || findEmailInput();
|
||||
if (!emailInput) {
|
||||
throw new Error('PayPal hosted checkout 未找到邮箱输入框。');
|
||||
}
|
||||
await sleep(2000);
|
||||
refillPayPalEmailInput(emailInput, email);
|
||||
await sleep(1000);
|
||||
const clickResult = await clickHostedGenericSubmitButton(0);
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_LOGIN,
|
||||
submitted: true,
|
||||
generatedEmail: email,
|
||||
verificationRequired: Boolean(clickResult?.verificationRequired),
|
||||
nextExpected: 'guest_checkout_or_verification',
|
||||
};
|
||||
}
|
||||
|
||||
async function fillHostedVerificationCode(payload = {}) {
|
||||
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
|
||||
? performPayPalOperationWithDelay
|
||||
: async (_metadata, operation) => operation();
|
||||
await waitForDocumentComplete();
|
||||
const code = normalizeHostedVerificationCode(payload.verificationCode || payload.code || '');
|
||||
if (code.length !== 6) {
|
||||
throw new Error('PayPal hosted checkout 验证码无效。');
|
||||
}
|
||||
const inputs = findHostedVerificationInputs();
|
||||
if (inputs.length < 6) {
|
||||
throw new Error('PayPal hosted checkout 当前页面未显示验证码输入框。');
|
||||
}
|
||||
await delayOperation({ stepKey: 'plus-checkout-create', kind: 'fill', label: 'hosted-paypal-verification-code' }, async () => {
|
||||
inputs.forEach((input, index) => {
|
||||
fillInput(input, code[index] || '');
|
||||
});
|
||||
});
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_VERIFICATION,
|
||||
codeSubmitted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function fillHostedGuestCheckout(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
startHostedCaptchaCleanupObserver();
|
||||
removeHostedCaptchaArtifacts();
|
||||
log(`PayPal guest checkout:收到 payload.phone=${String(payload?.phone || '').trim() || '(空)'},payload.address=${JSON.stringify(payload?.address || {})}`, 'info');
|
||||
|
||||
await sleep(2000);
|
||||
const countrySelect = document.getElementById('country');
|
||||
if (countrySelect && String(countrySelect.value || '').trim().toUpperCase() !== 'US') {
|
||||
countrySelect.value = 'US';
|
||||
countrySelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
const card = buildHostedVisaCard();
|
||||
const email = normalizeText(payload.email || buildHostedRandomEmail());
|
||||
const phone = normalizeText(payload.phone || PAYPAL_HOSTED_DEFAULT_PHONE);
|
||||
const password = String(payload.password || buildHostedRandomPassword());
|
||||
const firstName = normalizeText(payload.firstName || 'James');
|
||||
const lastName = normalizeText(payload.lastName || 'Smith');
|
||||
const cardNumber = String(payload.cardNumber || card.number).replace(/\s+/g, '');
|
||||
const cardExpiry = normalizeText(payload.cardExpiry || card.expiry);
|
||||
const cardCvv = normalizeText(payload.cardCvv || card.cvv);
|
||||
const address = payload.address && typeof payload.address === 'object' ? payload.address : {};
|
||||
|
||||
if (!email || !password || !cardNumber || !cardExpiry || !cardCvv) {
|
||||
throw new Error('PayPal hosted checkout 缺少卡支付所需资料。');
|
||||
}
|
||||
|
||||
fillHostedInputById('email', email);
|
||||
fillHostedInputById('phone', phone);
|
||||
fillHostedInputById('cardNumber', cardNumber);
|
||||
fillHostedInputById('cardExpiry', cardExpiry);
|
||||
fillHostedInputById('cardCvv', cardCvv);
|
||||
fillHostedInputById('password', password);
|
||||
fillHostedInputById('firstName', firstName);
|
||||
fillHostedInputById('lastName', lastName);
|
||||
fillHostedInputById('billingLine1', address.street || '');
|
||||
fillHostedInputById('billingCity', address.city || '');
|
||||
fillHostedInputById('billingPostalCode', address.zip || '');
|
||||
fillHostedInputById('billingLine1', address.street || '');
|
||||
selectHostedOptionByIdText('billingState', address.state || '');
|
||||
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
if (!rootScope[PAYPAL_HOSTED_GUEST_SUBMIT_SENTINEL]) {
|
||||
rootScope[PAYPAL_HOSTED_GUEST_SUBMIT_SENTINEL] = true;
|
||||
setTimeout(() => {
|
||||
clickHostedGenericSubmitButton(0).catch((error) => {
|
||||
log(`PayPal hosted checkout guest submit 失败:${error?.message || error}`, 'warn');
|
||||
}).finally(() => {
|
||||
rootScope[PAYPAL_HOSTED_GUEST_SUBMIT_SENTINEL] = false;
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
submitted: true,
|
||||
verificationRequired: Boolean(hasHostedVerificationInputs()),
|
||||
submitScheduled: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function clickHostedReviewConsent() {
|
||||
await waitForDocumentComplete();
|
||||
log(`PayPal Hermes:开始等待账单确认文案。当前 URL:${location.href}`, 'info');
|
||||
let waited = 0;
|
||||
while (waited < 30) {
|
||||
waited += 1;
|
||||
const pageText = document.body ? document.body.innerText : '';
|
||||
if (String(pageText || '').includes('Set up once. Pay faster next time')) {
|
||||
log(`PayPal Hermes:第 ${waited}/30 秒命中目标文案,开始寻找 consentButton。`, 'info');
|
||||
let button = document.getElementById('consentButton')
|
||||
|| document.querySelector('button[data-testid="consentButton"]');
|
||||
if (button) {
|
||||
log('PayPal Hermes:已找到 consentButton,准备点击 Agree and Continue。', 'info');
|
||||
button.click();
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_REVIEW,
|
||||
submitted: true,
|
||||
};
|
||||
}
|
||||
log('PayPal Hermes:首次未找到 consentButton,2 秒后重试一次。', 'warn');
|
||||
await sleep(2000);
|
||||
button = document.getElementById('consentButton');
|
||||
if (button) {
|
||||
log('PayPal Hermes:重试后找到 consentButton,准备点击 Agree and Continue。', 'info');
|
||||
button.click();
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_REVIEW,
|
||||
submitted: true,
|
||||
};
|
||||
}
|
||||
log('PayPal Hermes:重试后仍未找到 consentButton。', 'warn');
|
||||
throw new Error('PayPal hosted checkout 未找到 consentButton。');
|
||||
}
|
||||
if (waited === 1 || waited % 5 === 0) {
|
||||
log(`PayPal Hermes:尚未命中目标文案,继续等待(${waited}/30)。`, 'info');
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
log('PayPal Hermes:等待 30 秒后仍未命中目标文案。', 'warn');
|
||||
throw new Error('PayPal hosted checkout 账单确认页超时,未检测到目标文案。');
|
||||
}
|
||||
|
||||
async function runHostedCheckoutStep(payload = {}) {
|
||||
if (isPayPalHostedReviewPage()) {
|
||||
return clickHostedReviewConsent();
|
||||
}
|
||||
const stage = detectPayPalHostedCheckoutStage();
|
||||
if (stage === PAYPAL_HOSTED_STAGE_VERIFICATION) {
|
||||
if (!payload.verificationCode && !payload.code) {
|
||||
return {
|
||||
stage,
|
||||
requiresVerificationCode: true,
|
||||
};
|
||||
}
|
||||
return fillHostedVerificationCode(payload);
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_LOGIN) {
|
||||
return submitHostedPayLogin(payload);
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) {
|
||||
return fillHostedGuestCheckout(payload);
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_REVIEW) {
|
||||
return clickHostedReviewConsent();
|
||||
}
|
||||
return {
|
||||
stage,
|
||||
submitted: false,
|
||||
approveReady: Boolean(findApproveButton()),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldAutoRunHostedHermesReview() {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
if (!isPayPalHostedReviewPage()) {
|
||||
return false;
|
||||
}
|
||||
if (rootScope[PAYPAL_HOSTED_HERMES_AUTORUN_SENTINEL]) {
|
||||
return false;
|
||||
}
|
||||
rootScope[PAYPAL_HOSTED_HERMES_AUTORUN_SENTINEL] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleHostedHermesAutoRun() {
|
||||
if (!shouldAutoRunHostedHermesReview()) {
|
||||
return;
|
||||
}
|
||||
log(`PayPal Hermes 页面已命中,按油猴脚本方式自动等待并点击 Agree and Continue。当前 URL:${location.href}`, 'info');
|
||||
setTimeout(() => {
|
||||
clickHostedReviewConsent().then(() => {
|
||||
log('PayPal Hermes:已按油猴脚本方式执行 Agree and Continue。', 'ok');
|
||||
}).catch((error) => {
|
||||
log(`PayPal Hermes:自动点击 Agree and Continue 失败:${error?.message || error}`, 'warn');
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function findPasskeyPromptButtons() {
|
||||
const promptPatterns = [
|
||||
/passkey|通行密钥|安全密钥|下次登录|faster|save/i,
|
||||
];
|
||||
const bodyText = normalizeText(document.body?.innerText || '');
|
||||
const likelyPrompt = promptPatterns.some((pattern) => pattern.test(bodyText));
|
||||
if (!likelyPrompt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cancelOrClose = getVisibleControls('button, a, [role="button"]')
|
||||
.filter((el) => {
|
||||
const text = getActionText(el);
|
||||
return /取消|稍后|不保存|不用|关闭|cancel|not now|maybe later|skip|close|x/i.test(text)
|
||||
|| el.getAttribute?.('aria-label')?.match(/close|关闭/i);
|
||||
});
|
||||
|
||||
const iconCloseButtons = getVisibleControls('button, [role="button"]')
|
||||
.filter((el) => {
|
||||
const text = getActionText(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (/^×$|^x$/i.test(text) || /close|关闭/i.test(text))
|
||||
&& rect.width <= 64
|
||||
&& rect.height <= 64;
|
||||
});
|
||||
|
||||
return [...cancelOrClose, ...iconCloseButtons];
|
||||
}
|
||||
|
||||
function hasPasskeyPrompt() {
|
||||
return findPasskeyPromptButtons().length > 0;
|
||||
}
|
||||
|
||||
function getPayPalLoginPhase(emailInput, passwordInput) {
|
||||
const emailNextButton = findEmailNextButton();
|
||||
const passwordLoginButton = findPasswordLoginButton();
|
||||
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !passwordLoginButton)) {
|
||||
return 'email';
|
||||
}
|
||||
if (emailInput && passwordInput) return 'login_combined';
|
||||
if (passwordInput) return 'password';
|
||||
if (emailInput) return 'email';
|
||||
return '';
|
||||
}
|
||||
|
||||
function refillPayPalEmailInput(emailInput, email) {
|
||||
if (!emailInput) return;
|
||||
if (typeof emailInput.focus === 'function') {
|
||||
emailInput.focus();
|
||||
}
|
||||
fillInput(emailInput, '');
|
||||
fillInput(emailInput, email);
|
||||
if (typeof emailInput.blur === 'function') {
|
||||
emailInput.blur();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayPalLogin(payload = {}) {
|
||||
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
|
||||
? performPayPalOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
|
||||
const email = normalizeText(payload.email || '');
|
||||
const password = String(payload.password || '');
|
||||
if (!password) {
|
||||
throw new Error('PayPal 密码为空,请先在侧边栏配置。');
|
||||
}
|
||||
|
||||
let passwordInput = findPasswordInput();
|
||||
const emailInput = findEmailInput();
|
||||
const emailNextButton = findEmailNextButton();
|
||||
|
||||
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) {
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-email' }, async () => {
|
||||
refillPayPalEmailInput(emailInput, email);
|
||||
simulateClick(emailNextButton);
|
||||
});
|
||||
return {
|
||||
submitted: false,
|
||||
phase: 'email_submitted',
|
||||
awaiting: 'password_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (!passwordInput && emailInput && email) {
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-email' }, async () => {
|
||||
refillPayPalEmailInput(emailInput, email);
|
||||
const nextButton = await waitUntil(() => {
|
||||
const button = findEmailNextButton() || findLoginNextButton();
|
||||
return button && isEnabledControl(button) ? button : null;
|
||||
}, {
|
||||
intervalMs: 250,
|
||||
timeoutMs: 8000,
|
||||
timeoutMessage: 'PayPal email page did not expose a clickable next/continue button.',
|
||||
});
|
||||
simulateClick(nextButton);
|
||||
});
|
||||
return {
|
||||
submitted: false,
|
||||
phase: 'email_submitted',
|
||||
awaiting: 'password_page',
|
||||
};
|
||||
} else if (!passwordInput && emailInput && !email) {
|
||||
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
|
||||
} else if (emailInput && email) {
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'fill', label: 'paypal-email' }, async () => {
|
||||
refillPayPalEmailInput(emailInput, email);
|
||||
});
|
||||
}
|
||||
|
||||
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), {
|
||||
intervalMs: 250,
|
||||
timeoutMs: 8000,
|
||||
timeoutMessage: 'PayPal password page did not expose a password input.',
|
||||
});
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'submit', label: 'paypal-password' }, async () => {
|
||||
fillInput(passwordInput, password);
|
||||
await sleep(1000);
|
||||
|
||||
const loginButton = await waitUntil(() => {
|
||||
const button = findClickableByText([
|
||||
/login|log\s*in|sign\s*in|continue/i,
|
||||
/登录|登入|继续/i,
|
||||
]);
|
||||
return button && isEnabledControl(button) ? button : null;
|
||||
}, {
|
||||
intervalMs: 250,
|
||||
timeoutMs: 8000,
|
||||
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
|
||||
});
|
||||
|
||||
simulateClick(loginButton);
|
||||
});
|
||||
return {
|
||||
submitted: true,
|
||||
phase: 'password_submitted',
|
||||
awaiting: 'redirect_or_approval',
|
||||
};
|
||||
}
|
||||
|
||||
async function dismissPayPalPrompts() {
|
||||
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
|
||||
? performPayPalOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
const buttons = findPasskeyPromptButtons();
|
||||
let clicked = 0;
|
||||
for (const button of buttons) {
|
||||
if (!isVisibleElement(button) || !isEnabledControl(button)) {
|
||||
continue;
|
||||
}
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'click', label: 'paypal-dismiss-prompt' }, async () => {
|
||||
simulateClick(button);
|
||||
});
|
||||
clicked += 1;
|
||||
await sleep(500);
|
||||
}
|
||||
return {
|
||||
clicked,
|
||||
hasPromptAfterClick: hasPasskeyPrompt(),
|
||||
};
|
||||
}
|
||||
|
||||
async function clickPayPalApprove() {
|
||||
const delayOperation = typeof performPayPalOperationWithDelay === 'function'
|
||||
? performPayPalOperationWithDelay
|
||||
: async (metadata, operation) => {
|
||||
const rootScope = typeof window !== 'undefined' ? window : globalThis;
|
||||
const gate = rootScope?.CodexOperationDelay?.performOperationWithDelay;
|
||||
return typeof gate === 'function' ? gate(metadata, operation) : operation();
|
||||
};
|
||||
await waitForDocumentComplete();
|
||||
await dismissPayPalPrompts().catch(() => ({ clicked: 0 }));
|
||||
|
||||
const button = findApproveButton();
|
||||
if (!button || !isEnabledControl(button)) {
|
||||
return {
|
||||
clicked: false,
|
||||
state: inspectPayPalState(),
|
||||
};
|
||||
}
|
||||
|
||||
await delayOperation({ stepKey: 'paypal-approve', kind: 'click', label: 'paypal-approve' }, async () => {
|
||||
simulateClick(button);
|
||||
});
|
||||
return {
|
||||
clicked: true,
|
||||
buttonText: getActionText(button),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPayPalState() {
|
||||
const emailInput = findEmailInput();
|
||||
const passwordInput = findPasswordInput();
|
||||
const approveButton = findApproveButton();
|
||||
const loginPhase = getPayPalLoginPhase(emailInput, passwordInput);
|
||||
const hostedStage = detectPayPalHostedCheckoutStage();
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
hostedStage,
|
||||
needsLogin: Boolean(loginPhase),
|
||||
loginPhase,
|
||||
hasEmailInput: Boolean(emailInput),
|
||||
hasPasswordInput: Boolean(passwordInput),
|
||||
hasHostedGuestCheckout: hostedStage === PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
verificationInputsVisible: hasHostedVerificationInputs(),
|
||||
reviewConsentReady: Boolean(findHostedReviewConsentButton()),
|
||||
approveReady: Boolean(approveButton && isEnabledControl(approveButton)),
|
||||
approveButtonText: approveButton ? getActionText(approveButton) : '',
|
||||
hasPasskeyPrompt: hasPasskeyPrompt(),
|
||||
bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240),
|
||||
};
|
||||
}
|
||||
|
||||
scheduleHostedHermesAutoRun();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
(function attachPhoneCountryUtils(root, factory) {
|
||||
root.MultiPagePhoneCountryUtils = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createPhoneCountryUtils() {
|
||||
const KNOWN_DIAL_CODES = Object.freeze([
|
||||
'1246', '1264', '1268', '1284', '1340', '1345', '1441', '1473', '1649', '1664', '1670', '1671', '1684',
|
||||
'1721', '1758', '1767', '1784', '1809', '1829', '1849', '1868', '1869', '1876',
|
||||
'971', '962', '886', '880', '856', '855', '852', '853', '673', '672', '670', '599', '598', '597', '596',
|
||||
'595', '594', '593', '592', '591', '590', '509', '508', '507', '506', '505', '504', '503', '502', '501',
|
||||
'423', '421', '420', '389', '387', '386', '385', '383', '382', '381', '380', '379', '378', '377', '376',
|
||||
'375', '374', '373', '372', '371', '370', '359', '358', '357', '356', '355', '354', '353', '352', '351',
|
||||
'350', '299', '298', '297', '291', '290', '269', '268', '267', '266', '265', '264', '263', '262', '261',
|
||||
'260', '258', '257', '256', '255', '254', '253', '252', '251', '250', '249', '248', '247', '246', '245',
|
||||
'244', '243', '242', '241', '240', '239', '238', '237', '236', '235', '234', '233', '232', '231', '230',
|
||||
'229', '228', '227', '226', '225', '224', '223', '222', '221', '220', '218', '216', '213', '212', '211',
|
||||
'98', '95', '94', '93', '92', '91', '90', '89', '88', '86', '84', '82', '81', '66', '65', '64', '63',
|
||||
'62', '61', '60', '58', '57', '56', '55', '54', '53', '52', '51', '49', '48', '47', '46', '45', '44',
|
||||
'43', '41', '40', '39', '36', '34', '33', '32', '31', '30', '27', '20', '7', '1',
|
||||
]);
|
||||
|
||||
function normalizePhoneDigits(value) {
|
||||
let digits = String(value || '').replace(/\D+/g, '');
|
||||
if (digits.startsWith('00')) {
|
||||
digits = digits.slice(2);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function extractDialCodeFromText(value) {
|
||||
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
|
||||
return String(match?.[1] || match?.[2] || match?.[3] || '').trim();
|
||||
}
|
||||
|
||||
function normalizeCountryLabel(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCountryOptionValue(value) {
|
||||
return String(value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function getCountryLabelAliases(value) {
|
||||
const aliases = new Set();
|
||||
const addAlias = (alias) => {
|
||||
const normalized = normalizeCountryLabel(alias);
|
||||
if (normalized) {
|
||||
aliases.add(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const raw = String(value || '').trim();
|
||||
addAlias(raw);
|
||||
|
||||
const normalized = normalizeCountryLabel(raw);
|
||||
const compact = normalized.replace(/\s+/g, '');
|
||||
if (
|
||||
/(?:^|\s)(?:gb|uk)(?:\s|$)/i.test(raw)
|
||||
|| /england|united\s*kingdom|great\s*britain|\bbritain\b/i.test(raw)
|
||||
|| /英国|英格兰|大不列颠/.test(raw)
|
||||
|| ['gb', 'uk', 'england', 'unitedkingdom', 'greatbritain', 'britain'].includes(compact)
|
||||
) {
|
||||
[
|
||||
'GB',
|
||||
'UK',
|
||||
'United Kingdom',
|
||||
'Great Britain',
|
||||
'Britain',
|
||||
'England',
|
||||
'英国',
|
||||
'英格兰',
|
||||
'大不列颠',
|
||||
].forEach(addAlias);
|
||||
}
|
||||
|
||||
return Array.from(aliases);
|
||||
}
|
||||
|
||||
function getRegionDisplayName(regionCode, locale) {
|
||||
const normalizedRegionCode = normalizeCountryOptionValue(regionCode);
|
||||
const normalizedLocale = String(locale || '').trim();
|
||||
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return String(
|
||||
new Intl.DisplayNames([normalizedLocale], { type: 'region' }).of(normalizedRegionCode) || ''
|
||||
).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return String(option?.textContent || option?.label || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getOptionMatchLabels(option, options = {}) {
|
||||
const labels = new Set();
|
||||
const pushLabel = (value) => {
|
||||
const label = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (label) {
|
||||
labels.add(label);
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = typeof options.getOptionLabel === 'function'
|
||||
? options.getOptionLabel
|
||||
: getOptionLabel;
|
||||
pushLabel(getLabel(option));
|
||||
|
||||
const regionCode = normalizeCountryOptionValue(option?.value);
|
||||
if (/^[A-Z]{2}$/.test(regionCode)) {
|
||||
pushLabel(regionCode);
|
||||
pushLabel(getRegionDisplayName(regionCode, 'en'));
|
||||
|
||||
const pageLocale = String(
|
||||
options.pageLocale
|
||||
|| options.document?.documentElement?.lang
|
||||
|| options.document?.documentElement?.getAttribute?.('lang')
|
||||
|| options.navigator?.language
|
||||
|| ''
|
||||
).trim();
|
||||
if (pageLocale && !/^en(?:[-_]|$)/i.test(pageLocale)) {
|
||||
pushLabel(getRegionDisplayName(regionCode, pageLocale));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(labels);
|
||||
}
|
||||
|
||||
function resolveDialCodeFromPhoneNumber(phoneNumber = '', texts = []) {
|
||||
const digits = normalizePhoneDigits(phoneNumber);
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const textDialCodes = texts
|
||||
.map((text) => normalizePhoneDigits(extractDialCodeFromText(text)))
|
||||
.filter((dialCode) => dialCode && digits.startsWith(dialCode) && digits.length > dialCode.length)
|
||||
.sort((left, right) => right.length - left.length);
|
||||
if (textDialCodes[0]) {
|
||||
return textDialCodes[0];
|
||||
}
|
||||
|
||||
return KNOWN_DIAL_CODES.find((code) => digits.startsWith(code) && digits.length > code.length) || '';
|
||||
}
|
||||
|
||||
function findOptionByCountryLabel(options, countryLabel, config = {}) {
|
||||
const source = Array.from(options || []);
|
||||
const normalizedTargets = getCountryLabelAliases(countryLabel);
|
||||
if (source.length === 0 || normalizedTargets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return source.find((option) => (
|
||||
getOptionMatchLabels(option, config).some((label) => normalizedTargets.includes(normalizeCountryLabel(label)))
|
||||
))
|
||||
|| source.find((option) => {
|
||||
const normalizedLabels = getOptionMatchLabels(option, config)
|
||||
.map((label) => normalizeCountryLabel(label))
|
||||
.filter(Boolean);
|
||||
return normalizedLabels.some((optionLabel) => normalizedTargets.some((normalizedTarget) => (
|
||||
optionLabel.length > 2
|
||||
&& normalizedTarget.length > 2
|
||||
&& (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel))
|
||||
)));
|
||||
})
|
||||
|| null;
|
||||
}
|
||||
|
||||
function findOptionByPhoneNumber(options, phoneNumber, config = {}) {
|
||||
const source = Array.from(options || []);
|
||||
const digits = normalizePhoneDigits(phoneNumber);
|
||||
if (source.length === 0 || !digits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getLabel = typeof config.getOptionLabel === 'function'
|
||||
? config.getOptionLabel
|
||||
: getOptionLabel;
|
||||
let bestMatch = null;
|
||||
let bestDialCodeLength = 0;
|
||||
for (const option of source) {
|
||||
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getLabel(option)));
|
||||
if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) {
|
||||
continue;
|
||||
}
|
||||
bestMatch = option;
|
||||
bestDialCodeLength = dialCode.length;
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function findElementByDialCode(elements, phoneNumber, config = {}) {
|
||||
const source = Array.from(elements || []);
|
||||
const digits = normalizePhoneDigits(phoneNumber);
|
||||
if (source.length === 0 || !digits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getText = typeof config.getText === 'function' ? config.getText : getOptionLabel;
|
||||
let bestMatch = null;
|
||||
let bestDialCodeLength = 0;
|
||||
for (const element of source) {
|
||||
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getText(element)));
|
||||
if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) {
|
||||
continue;
|
||||
}
|
||||
bestMatch = element;
|
||||
bestDialCodeLength = dialCode.length;
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return {
|
||||
extractDialCodeFromText,
|
||||
findElementByDialCode,
|
||||
findOptionByCountryLabel,
|
||||
findOptionByPhoneNumber,
|
||||
getCountryLabelAliases,
|
||||
getOptionLabel,
|
||||
getOptionMatchLabels,
|
||||
getRegionDisplayName,
|
||||
normalizeCountryLabel,
|
||||
normalizeCountryOptionValue,
|
||||
normalizePhoneDigits,
|
||||
resolveDialCodeFromPhoneNumber,
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
|
||||
// Injected on: mail.qq.com, wx.mail.qq.com
|
||||
// NOTE: all_frames: true
|
||||
//
|
||||
// Strategy for avoiding stale codes:
|
||||
// 1. On poll start, snapshot all existing mail IDs as "old"
|
||||
// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
|
||||
// 3. Only extract codes from NEW items that match sender/subject filters
|
||||
|
||||
const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// ============================================================
|
||||
// Message Handler
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
if (!isTopFrame) {
|
||||
sendResponse({ ok: false, reason: 'wrong-frame' });
|
||||
return;
|
||||
}
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn');
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true; // async response
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Get all current mail IDs from the list
|
||||
// ============================================================
|
||||
|
||||
function getCurrentMailIds() {
|
||||
const ids = new Set();
|
||||
document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
|
||||
ids.add(item.getAttribute('data-mailid'));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function normalizeRulePatternList(patterns = []) {
|
||||
return Array.isArray(patterns) ? patterns : [];
|
||||
}
|
||||
|
||||
function extractCodeByRulePatterns(text, patterns = []) {
|
||||
const normalizedText = String(text || '');
|
||||
for (const pattern of normalizeRulePatternList(patterns)) {
|
||||
try {
|
||||
const source = String(pattern?.source || '').trim();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
|
||||
const match = normalizedText.match(new RegExp(source, flags));
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (let index = 1; index < match.length; index += 1) {
|
||||
const candidate = String(match[index] || '').trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (String(match[0] || '').trim()) {
|
||||
return String(match[0] || '').trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore invalid runtime rule patterns and continue with other candidates.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const {
|
||||
codePatterns = [],
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
maxAttempts,
|
||||
intervalMs,
|
||||
excludeCodes = [],
|
||||
} = payload;
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
|
||||
log(`步骤 ${step}:开始轮询邮箱(最多 ${maxAttempts} 次,每 ${intervalMs / 1000} 秒一次)`);
|
||||
|
||||
// Wait for mail list to load
|
||||
try {
|
||||
await waitForElement('.mail-list-page-item', 10000);
|
||||
log(`步骤 ${step}:邮件列表已加载`);
|
||||
} catch {
|
||||
throw new Error('邮件列表未加载完成,请确认 QQ 邮箱已打开收件箱。');
|
||||
}
|
||||
|
||||
// Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
log(`步骤 ${step}:已将当前 ${existingMailIds.size} 封邮件标记为旧邮件快照`);
|
||||
|
||||
// Fallback after just 3 attempts (~10s). In practice, the email is usually
|
||||
// already in the list but has the same mailid (page was already open).
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`步骤 ${step}:正在轮询 QQ 邮箱,第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
// Refresh inbox (skip on first attempt, list is fresh)
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
const allItems = document.querySelectorAll('.mail-list-page-item[data-mailid]');
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
|
||||
// Phase 1 (attempt 1~3): only look at NEW emails (not in snapshot)
|
||||
// Phase 2 (attempt 4+): fallback to first matching email in list
|
||||
for (const item of allItems) {
|
||||
const mailId = item.getAttribute('data-mailid');
|
||||
|
||||
if (!useFallback && existingMailIds.has(mailId)) continue;
|
||||
|
||||
const sender = (item.querySelector('.cmp-account-nick')?.textContent || '').toLowerCase();
|
||||
const subject = (item.querySelector('.mail-subject')?.textContent || '').toLowerCase();
|
||||
const digest = item.querySelector('.mail-digest')?.textContent || '';
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()));
|
||||
|
||||
if (senderMatch || subjectMatch) {
|
||||
const code = extractVerificationCode(subject + ' ' + digest, {
|
||||
codePatterns,
|
||||
});
|
||||
if (code) {
|
||||
if (excludedCodeSet.has(code)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
|
||||
continue;
|
||||
}
|
||||
const source = useFallback && existingMailIds.has(mailId) ? '回退首封匹配邮件' : '新邮件';
|
||||
log(`步骤 ${step}:已找到验证码:${code}(来源:${source},主题:${subject.slice(0, 40)})`, 'ok');
|
||||
return { ok: true, code, emailTimestamp: Date.now(), mailId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未找到新的匹配邮件。` +
|
||||
'请手动检查 QQ 邮箱,邮件可能延迟到达或进入垃圾箱。'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try multiple strategies to refresh the mail list
|
||||
|
||||
// Strategy 1: Click any visible refresh button
|
||||
const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
|
||||
if (refreshBtn) {
|
||||
simulateClick(refreshBtn);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Click inbox in sidebar to reload list
|
||||
const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
|
||||
if (sidebarInbox) {
|
||||
simulateClick(sidebarInbox);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Click the folder name in toolbar
|
||||
const folderName = document.querySelector('.toolbar-folder-name');
|
||||
if (folderName) {
|
||||
simulateClick(folderName);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Verification Code Extraction
|
||||
// ============================================================
|
||||
|
||||
function extractVerificationCode(text, options = {}) {
|
||||
const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns);
|
||||
if (matchedByRule) return matchedByRule;
|
||||
|
||||
// Pattern 1: Chinese format "代码为 370794" or "验证码...370794"
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i);
|
||||
if (matchLoginCode) return matchLoginCode[1];
|
||||
|
||||
// Pattern 2: English format "code is 370794" or "code: 370794"
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
// Pattern 3: standalone 6-digit number (first occurrence)
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
// content/sub2api-panel.js — 页内脚本:SUB2API 后台(OAuth 生成与回调提交)
|
||||
|
||||
console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href);
|
||||
|
||||
const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener';
|
||||
const SUB2API_DEFAULT_GROUP_NAME = 'codex';
|
||||
const SUB2API_DEFAULT_PROXY_NAME = '';
|
||||
const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||
const SUB2API_DEFAULT_CONCURRENCY = 10;
|
||||
const SUB2API_DEFAULT_PRIORITY = 1;
|
||||
const SUB2API_DEFAULT_RATE_MULTIPLIER = 1;
|
||||
|
||||
if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1');
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'EXECUTE_NODE' || message.type === 'REQUEST_OAUTH_URL') {
|
||||
resetStopState();
|
||||
const handler = message.type === 'REQUEST_OAUTH_URL'
|
||||
? requestOAuthUrl(message.payload)
|
||||
: handleNode(message.nodeId || message.payload?.nodeId, message.payload);
|
||||
handler.then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
if (message.payload?.visibleStep || message.step) {
|
||||
log('已被用户停止。', 'warn', { step: message.payload?.visibleStep || message.step });
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
if (message.nodeId || message.payload?.nodeId) {
|
||||
reportError(message.nodeId || message.payload?.nodeId, err.message);
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[MultiPage:sub2api-panel] 消息监听已存在,跳过重复注册');
|
||||
}
|
||||
|
||||
function getSub2ApiOrigin(payload = {}) {
|
||||
const rawUrl = payload.sub2apiUrl || location.href;
|
||||
try {
|
||||
return new URL(rawUrl).origin;
|
||||
} catch {
|
||||
return location.origin;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRedirectUri() {
|
||||
const input = SUB2API_DEFAULT_REDIRECT_URI;
|
||||
const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
parsed.pathname = '/auth/callback';
|
||||
}
|
||||
if (parsed.pathname !== '/auth/callback') {
|
||||
throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback');
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function handleStep(step, payload = {}) {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return step1_generateOpenAiAuthUrl(payload);
|
||||
case 10:
|
||||
case 12:
|
||||
case 13:
|
||||
return step9_submitOpenAiCallback({ ...(payload || {}), visibleStep: step });
|
||||
default:
|
||||
throw new Error(`sub2api-panel.js 不处理步骤 ${step}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNode(nodeId, payload = {}) {
|
||||
const normalizedNodeId = String(nodeId || '').trim();
|
||||
switch (normalizedNodeId) {
|
||||
case 'platform-verify':
|
||||
return step9_submitOpenAiCallback(payload);
|
||||
default:
|
||||
throw new Error(`sub2api-panel.js 不处理节点 ${normalizedNodeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOAuthUrl(payload = {}) {
|
||||
return step1_generateOpenAiAuthUrl(payload, { report: false });
|
||||
}
|
||||
|
||||
async function requestJson(origin, path, options = {}) {
|
||||
throwIfStopped();
|
||||
const {
|
||||
method = 'GET',
|
||||
token = '',
|
||||
body = undefined,
|
||||
} = options;
|
||||
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
|
||||
if (json && typeof json === 'object' && 'code' in json) {
|
||||
if (json.code === 0) {
|
||||
return json.data;
|
||||
}
|
||||
throw new Error(json.message || json.detail || `请求失败(${path})`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error((json && (json.message || json.detail)) || `请求失败(HTTP ${response.status}):${path}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
function storeAuthSession(loginData) {
|
||||
if (!loginData?.access_token) {
|
||||
throw new Error('SUB2API 登录返回缺少 access_token。');
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', loginData.access_token);
|
||||
if (loginData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', loginData.refresh_token);
|
||||
} else {
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
if (loginData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + Number(loginData.expires_in) * 1000));
|
||||
}
|
||||
if (loginData.user) {
|
||||
localStorage.setItem('auth_user', JSON.stringify(loginData.user));
|
||||
}
|
||||
sessionStorage.removeItem('auth_expired');
|
||||
}
|
||||
|
||||
async function loginSub2Api(payload = {}) {
|
||||
const email = (payload.sub2apiEmail || '').trim();
|
||||
const password = payload.sub2apiPassword || '';
|
||||
const origin = getSub2ApiOrigin(payload);
|
||||
|
||||
if (!email) {
|
||||
throw new Error('缺少 SUB2API 登录邮箱,请先在侧边栏填写。');
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('缺少 SUB2API 登录密码,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
log('步骤:正在登录 SUB2API 后台...');
|
||||
const loginData = await requestJson(origin, '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
storeAuthSession(loginData);
|
||||
|
||||
return {
|
||||
origin,
|
||||
token: loginData.access_token,
|
||||
user: loginData.user || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroupByName(origin, token, groupName) {
|
||||
const targetName = (groupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
|
||||
const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
|
||||
const normalized = targetName.toLowerCase();
|
||||
const group = (groups || []).find((item) => {
|
||||
const itemName = String(item?.name || '').trim().toLowerCase();
|
||||
if (!itemName) return false;
|
||||
if (itemName !== normalized) return false;
|
||||
return !item.platform || item.platform === 'openai';
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new Error(`SUB2API 中未找到名为“${targetName}”的 openai 分组。`);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function normalizeSub2ApiGroupNames(value) {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '').split(/[\r\n,,;;]+/);
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
for (const item of source) {
|
||||
const name = String(item || '').trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
return names.length ? names : [SUB2API_DEFAULT_GROUP_NAME];
|
||||
}
|
||||
|
||||
async function getGroupsByNames(origin, token, groupNames) {
|
||||
const targetNames = normalizeSub2ApiGroupNames(groupNames);
|
||||
const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
const matched = [];
|
||||
const missing = [];
|
||||
|
||||
for (const targetName of targetNames) {
|
||||
const normalized = targetName.toLowerCase();
|
||||
const group = (groups || []).find((item) => {
|
||||
const itemName = String(item?.name || '').trim().toLowerCase();
|
||||
if (!itemName) return false;
|
||||
if (itemName !== normalized) return false;
|
||||
return !item.platform || item.platform === 'openai';
|
||||
});
|
||||
if (group) {
|
||||
matched.push(group);
|
||||
} else {
|
||||
missing.push(targetName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`SUB2API 中未找到以下 openai 分组:${missing.join('、')}。`);
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
function normalizeSub2ApiProxyPreference(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) {
|
||||
if (payload.sub2apiDefaultProxyName !== undefined) {
|
||||
return normalizeSub2ApiProxyPreference(payload.sub2apiDefaultProxyName);
|
||||
}
|
||||
if (backgroundState.sub2apiDefaultProxyName !== undefined) {
|
||||
return normalizeSub2ApiProxyPreference(backgroundState.sub2apiDefaultProxyName);
|
||||
}
|
||||
return SUB2API_DEFAULT_PROXY_NAME;
|
||||
}
|
||||
|
||||
function resolveSub2ApiAccountPriority(payload = {}, backgroundState = {}) {
|
||||
const candidate = payload.sub2apiAccountPriority !== undefined
|
||||
? payload.sub2apiAccountPriority
|
||||
: backgroundState.sub2apiAccountPriority;
|
||||
const rawValue = String(candidate ?? '').trim();
|
||||
if (!rawValue) {
|
||||
return SUB2API_DEFAULT_PRIORITY;
|
||||
}
|
||||
const numeric = Number(rawValue);
|
||||
if (!Number.isSafeInteger(numeric) || numeric < 1) {
|
||||
throw new Error('SUB2API 账号优先级必须是大于等于 1 的整数。');
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function normalizeProxyId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildProxyDisplayName(proxy = {}) {
|
||||
const id = normalizeProxyId(proxy.id);
|
||||
const name = String(proxy.name || '').trim();
|
||||
const protocol = String(proxy.protocol || '').trim();
|
||||
const host = String(proxy.host || '').trim();
|
||||
const port = proxy.port === undefined || proxy.port === null ? '' : String(proxy.port).trim();
|
||||
const address = protocol && host && port ? `${protocol}://${host}:${port}` : '';
|
||||
const parts = [
|
||||
name || '(未命名代理)',
|
||||
id ? `#${id}` : '',
|
||||
address,
|
||||
].filter(Boolean);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function buildProxySearchText(proxy = {}) {
|
||||
return [
|
||||
proxy.id,
|
||||
proxy.name,
|
||||
proxy.protocol,
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
buildProxyDisplayName(proxy),
|
||||
]
|
||||
.filter((value) => value !== undefined && value !== null && value !== '')
|
||||
.map((value) => String(value).trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isActiveProxy(proxy = {}) {
|
||||
const status = String(proxy.status || '').trim().toLowerCase();
|
||||
return !status || status === 'active';
|
||||
}
|
||||
|
||||
function findSub2ApiProxy(proxies = [], preference = '') {
|
||||
const activeProxies = (Array.isArray(proxies) ? proxies : [])
|
||||
.filter(isActiveProxy)
|
||||
.filter((proxy) => normalizeProxyId(proxy.id));
|
||||
const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase();
|
||||
const preferredId = normalizeProxyId(normalizedPreference);
|
||||
|
||||
if (preferredId) {
|
||||
const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId);
|
||||
return {
|
||||
proxy: matchedById || null,
|
||||
reason: matchedById ? 'id' : 'missing-id',
|
||||
candidates: activeProxies,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPreference) {
|
||||
const exactMatches = activeProxies.filter((proxy) => {
|
||||
const name = String(proxy.name || '').trim().toLowerCase();
|
||||
return name === normalizedPreference;
|
||||
});
|
||||
if (exactMatches.length === 1) {
|
||||
return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies };
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches };
|
||||
}
|
||||
|
||||
const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference));
|
||||
if (fuzzyMatches.length === 1) {
|
||||
return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies };
|
||||
}
|
||||
if (fuzzyMatches.length > 1) {
|
||||
return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches };
|
||||
}
|
||||
|
||||
return { proxy: null, reason: 'missing-name', candidates: activeProxies };
|
||||
}
|
||||
|
||||
if (activeProxies.length === 1) {
|
||||
return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies };
|
||||
}
|
||||
return {
|
||||
proxy: null,
|
||||
reason: activeProxies.length ? 'no-preference' : 'none-active',
|
||||
candidates: activeProxies,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSub2ApiProxy(origin, token, preference = '') {
|
||||
const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
if (!Array.isArray(proxies)) {
|
||||
throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。');
|
||||
}
|
||||
|
||||
const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference);
|
||||
if (proxy) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)';
|
||||
const available = (candidates || [])
|
||||
.slice(0, 8)
|
||||
.map(buildProxyDisplayName)
|
||||
.join(',') || '无可用代理';
|
||||
if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') {
|
||||
throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`);
|
||||
}
|
||||
if (reason === 'missing-id') {
|
||||
throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`);
|
||||
}
|
||||
if (reason === 'missing-name') {
|
||||
throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`);
|
||||
}
|
||||
if (reason === 'no-preference') {
|
||||
throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID;留空则不使用代理。可用代理:${available}`);
|
||||
}
|
||||
throw new Error('SUB2API 没有可用代理;请检查默认代理配置,或将其留空以禁用代理。');
|
||||
}
|
||||
|
||||
function buildDraftAccountName(groupName) {
|
||||
const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME)
|
||||
.trim()
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || SUB2API_DEFAULT_GROUP_NAME;
|
||||
const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14);
|
||||
const random = Math.floor(Math.random() * 9000 + 1000);
|
||||
return `${prefix}-${stamp}-${random}`;
|
||||
}
|
||||
|
||||
function extractStateFromAuthUrl(authUrl) {
|
||||
try {
|
||||
return new URL(authUrl).searchParams.get('state') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocalhostCallback(rawUrl, visibleStep = 10) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('提供的回调 URL 不是合法链接。');
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('回调 URL 协议不正确。');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
|
||||
throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`);
|
||||
}
|
||||
if (parsed.pathname !== '/auth/callback') {
|
||||
throw new Error('回调 URL 路径必须是 /auth/callback。');
|
||||
}
|
||||
|
||||
const code = (parsed.searchParams.get('code') || '').trim();
|
||||
const state = (parsed.searchParams.get('state') || '').trim();
|
||||
if (!code || !state) {
|
||||
throw new Error('回调 URL 中缺少 code 或 state。');
|
||||
}
|
||||
|
||||
return {
|
||||
url: parsed.toString(),
|
||||
code,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenAiCredentials(exchangeData) {
|
||||
const credentials = {};
|
||||
const allowedKeys = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'expires_at',
|
||||
'email',
|
||||
'chatgpt_account_id',
|
||||
'chatgpt_user_id',
|
||||
'organization_id',
|
||||
'plan_type',
|
||||
'client_id',
|
||||
];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
|
||||
credentials[key] = exchangeData[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentials.access_token) {
|
||||
throw new Error('SUB2API 交换授权码后未返回 access_token。');
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function buildOpenAiExtra(exchangeData) {
|
||||
const extra = {};
|
||||
const allowedKeys = ['email', 'name', 'privacy_mode'];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
|
||||
extra[key] = exchangeData[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(extra).length ? extra : undefined;
|
||||
}
|
||||
|
||||
async function getBackgroundState() {
|
||||
try {
|
||||
return await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sub2api-panel' });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function openAccountsPageSoon(origin) {
|
||||
const accountsUrl = `${origin}/admin/accounts`;
|
||||
if (location.href === accountsUrl || location.pathname.startsWith('/admin/accounts')) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
location.replace(accountsUrl);
|
||||
} catch { }
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
const { report = true } = options;
|
||||
const logStep = Number.isInteger(payload?.logStep) ? payload.logStep : 1;
|
||||
const redirectUri = normalizeRedirectUri();
|
||||
const groupNames = normalizeSub2ApiGroupNames(payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
const groupName = groupNames[0] || SUB2API_DEFAULT_GROUP_NAME;
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const groups = await getGroupsByNames(origin, token, groupNames);
|
||||
const group = groups[0];
|
||||
const proxyPreference = resolveSub2ApiProxyPreference(payload);
|
||||
const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference) : null;
|
||||
const proxyId = normalizeProxyId(proxy?.id);
|
||||
const draftName = buildDraftAccountName(group.name || groupName);
|
||||
const groupLabel = groups.map((item) => `${item.name}(#${item.id})`).join('、');
|
||||
|
||||
log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${groupLabel}。`);
|
||||
if (proxy) {
|
||||
log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
} else {
|
||||
log(`步骤 ${logStep}:未配置 SUB2API 默认代理,本次将不使用代理。`);
|
||||
}
|
||||
log(`步骤 ${logStep}:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`);
|
||||
|
||||
const authRequestBody = {
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
if (proxyId) {
|
||||
authRequestBody.proxy_id = proxyId;
|
||||
}
|
||||
|
||||
const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: authRequestBody,
|
||||
});
|
||||
|
||||
const oauthUrl = String(authData?.auth_url || '').trim();
|
||||
const sessionId = String(authData?.session_id || '').trim();
|
||||
const oauthState = String(authData?.state || extractStateFromAuthUrl(oauthUrl)).trim();
|
||||
|
||||
if (!oauthUrl || !sessionId) {
|
||||
throw new Error('SUB2API 未返回完整的 auth_url / session_id。');
|
||||
}
|
||||
|
||||
log(`步骤 ${logStep}:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok');
|
||||
const result = {
|
||||
oauthUrl,
|
||||
sub2apiSessionId: sessionId,
|
||||
sub2apiOAuthState: oauthState,
|
||||
sub2apiGroupId: group.id,
|
||||
sub2apiGroupIds: groups.map((item) => item.id),
|
||||
sub2apiDraftName: draftName,
|
||||
sub2apiProxyId: proxyId,
|
||||
};
|
||||
if (report) {
|
||||
reportComplete(1, result);
|
||||
}
|
||||
openAccountsPageSoon(origin);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function step9_submitOpenAiCallback(payload = {}) {
|
||||
const visibleStep = Number(payload?.visibleStep) || 10;
|
||||
const callback = parseLocalhostCallback(payload.localhostUrl || '', visibleStep);
|
||||
const backgroundState = await getBackgroundState();
|
||||
const flowEmail = String(backgroundState.email || '').trim();
|
||||
|
||||
const sessionId = String(payload.sub2apiSessionId || backgroundState.sub2apiSessionId || '').trim();
|
||||
const expectedState = String(payload.sub2apiOAuthState || backgroundState.sub2apiOAuthState || '').trim();
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState);
|
||||
const preferredProxyId = normalizeProxyId(payload.sub2apiProxyId || backgroundState.sub2apiProxyId);
|
||||
const proxySelector = preferredProxyId || proxyPreference;
|
||||
const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector) : null;
|
||||
const proxyId = normalizeProxyId(proxy?.id);
|
||||
const accountPriority = resolveSub2ApiAccountPriority(payload, backgroundState);
|
||||
const storedGroupIds = Array.isArray(payload.sub2apiGroupIds)
|
||||
? payload.sub2apiGroupIds
|
||||
: (Array.isArray(backgroundState.sub2apiGroupIds) ? backgroundState.sub2apiGroupIds : []);
|
||||
const groupIdsFromState = storedGroupIds
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
const groups = groupIdsFromState.length
|
||||
? groupIdsFromState.map((id) => ({ id }))
|
||||
: (payload.sub2apiGroupId
|
||||
? [{ id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }]
|
||||
: await getGroupsByNames(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME));
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。');
|
||||
}
|
||||
if (expectedState && expectedState !== callback.state) {
|
||||
throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
|
||||
}
|
||||
|
||||
log('正在向 SUB2API 交换 OpenAI 授权码...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
if (proxy) {
|
||||
log(`使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
} else {
|
||||
log('未配置 SUB2API 默认代理,本次将不使用代理。', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
}
|
||||
const exchangeRequestBody = {
|
||||
session_id: sessionId,
|
||||
code: callback.code,
|
||||
state: callback.state,
|
||||
};
|
||||
if (proxyId) {
|
||||
exchangeRequestBody.proxy_id = proxyId;
|
||||
}
|
||||
const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: exchangeRequestBody,
|
||||
});
|
||||
|
||||
const credentials = buildOpenAiCredentials(exchangeData);
|
||||
const extra = buildOpenAiExtra(exchangeData);
|
||||
const resolvedEmail = String(exchangeData?.email || credentials?.email || '').trim();
|
||||
const groupIds = groups
|
||||
.map((group) => Number(group.id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
if (!groupIds.length) {
|
||||
throw new Error('SUB2API 返回的目标分组 ID 无效。');
|
||||
}
|
||||
const accountName = resolvedEmail
|
||||
|| flowEmail
|
||||
|| String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim()
|
||||
|| buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
const createPayload = {
|
||||
name: accountName,
|
||||
notes: '',
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
concurrency: SUB2API_DEFAULT_CONCURRENCY,
|
||||
priority: accountPriority,
|
||||
rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
|
||||
group_ids: groupIds,
|
||||
auto_pause_on_expired: true,
|
||||
};
|
||||
if (proxyId) {
|
||||
createPayload.proxy_id = proxyId;
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
createPayload.extra = extra;
|
||||
}
|
||||
|
||||
log(`授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: createPayload,
|
||||
});
|
||||
|
||||
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
|
||||
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
reportComplete('platform-verify', {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
visibleStep,
|
||||
});
|
||||
openAccountsPageSoon(origin);
|
||||
}
|
||||
|
||||
reportReady();
|
||||
@@ -0,0 +1,574 @@
|
||||
// content/utils.js — Shared utilities for all content scripts
|
||||
|
||||
const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy;
|
||||
|
||||
function detectScriptSource({
|
||||
injectedSource,
|
||||
url = '',
|
||||
hostname = '',
|
||||
} = {}) {
|
||||
const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.();
|
||||
if (sourceRegistry?.detectSourceFromLocation) {
|
||||
return sourceRegistry.detectSourceFromLocation({
|
||||
injectedSource,
|
||||
url,
|
||||
hostname,
|
||||
});
|
||||
}
|
||||
if (injectedSource) return injectedSource;
|
||||
if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'openai-auth';
|
||||
if (hostname === 'mail.qq.com' || hostname === 'wx.mail.qq.com') return 'qq-mail';
|
||||
if (
|
||||
hostname === 'mail.163.com'
|
||||
|| hostname.endsWith('.mail.163.com')
|
||||
|| hostname === 'webmail.vip.163.com'
|
||||
|| hostname === 'mail.126.com'
|
||||
|| hostname.endsWith('.mail.126.com')
|
||||
) return 'mail-163';
|
||||
if (hostname === 'mail.google.com') return 'gmail-mail';
|
||||
if (hostname === 'www.icloud.com' || hostname === 'www.icloud.com.cn') return 'icloud-mail';
|
||||
if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
|
||||
if (url.includes('chatgpt.com')) return 'chatgpt';
|
||||
if (url.includes("2925.com")) return "mail-2925";
|
||||
return 'unknown-source';
|
||||
}
|
||||
|
||||
const SCRIPT_SOURCE = (() => {
|
||||
return detectScriptSource({
|
||||
injectedSource: window.__MULTIPAGE_SOURCE,
|
||||
url: location.href,
|
||||
hostname: location.hostname,
|
||||
});
|
||||
})();
|
||||
|
||||
function getRuntimeScriptSource() {
|
||||
return window.__MULTIPAGE_SOURCE || SCRIPT_SOURCE;
|
||||
}
|
||||
|
||||
const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
let flowStopped = false;
|
||||
|
||||
if (!window.__MULTIPAGE_UTILS_LISTENER_READY__) {
|
||||
window.__MULTIPAGE_UTILS_LISTENER_READY__ = true;
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'STOP_FLOW') {
|
||||
flowStopped = true;
|
||||
console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'PING') {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
source: getRuntimeScriptSource(),
|
||||
plusCheckoutReady: Boolean(window.__MULTIPAGE_PLUS_CHECKOUT_READY__),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetStopState() {
|
||||
flowStopped = false;
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
const message = typeof error === 'string' ? error : error?.message;
|
||||
return message === STOP_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
function throwIfStopped() {
|
||||
if (flowStopped) {
|
||||
throw new Error(STOP_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a DOM element to appear.
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {number} timeout - Max wait time in ms (default 10000)
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElement(selector, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `立即找到元素: ${selector}`);
|
||||
log(`已找到元素:${selector}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `等待元素: ${selector}(超时 ${timeout}ms)`);
|
||||
log(`正在等待选择器:${selector}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `等待后找到元素: ${selector}`);
|
||||
log(`已找到元素:${selector}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `在 ${location.href} 等待 ${selector} 超时,已超过 ${timeout}ms`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element matching a text pattern among multiple candidates.
|
||||
* @param {string} containerSelector - Selector for candidate elements
|
||||
* @param {RegExp} textPattern - Regex to match against textContent
|
||||
* @param {number} timeout - Max wait time in ms
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
function search() {
|
||||
const candidates = document.querySelectorAll(containerSelector);
|
||||
for (const el of candidates) {
|
||||
if (textPattern.test(el.textContent)) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = search();
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `立即按文本找到元素: ${containerSelector} 匹配 ${textPattern}`);
|
||||
log(`已按文本找到元素:${textPattern}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `等待文本匹配: ${containerSelector} / ${textPattern}`);
|
||||
log(`正在等待包含文本的元素:${textPattern}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = search();
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `等待后按文本找到元素: ${textPattern}`);
|
||||
log(`已按文本找到元素:${textPattern}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `在 ${location.href} 的 ${containerSelector} 中等待文本 "${textPattern}" 超时,已超过 ${timeout}ms`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React-compatible form filling.
|
||||
* Sets value via native setter and dispatches input + change events.
|
||||
* @param {HTMLInputElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillInput(el, value) {
|
||||
throwIfStopped();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `已填写输入框 ${el.name || el.id || el.type}: ${value}`);
|
||||
log(`已填写输入框 [${el.name || el.id || el.type || '未知'}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a select element by setting its value and triggering change.
|
||||
* @param {HTMLSelectElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillSelect(el, value) {
|
||||
throwIfStopped();
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `已在 ${el.name || el.id} 中选择值: ${value}`);
|
||||
log(`已选择 [${el.name || el.id || '未知'}] = ${value}`);
|
||||
}
|
||||
|
||||
function normalizeLogStep(value) {
|
||||
const step = Math.floor(Number(value) || 0);
|
||||
return step > 0 ? step : null;
|
||||
}
|
||||
|
||||
const DEFAULT_OPENAI_NODE_BY_STEP = Object.freeze({
|
||||
1: 'open-chatgpt',
|
||||
2: 'submit-signup-email',
|
||||
3: 'fill-password',
|
||||
4: 'fetch-signup-code',
|
||||
5: 'fill-profile',
|
||||
6: 'wait-registration-success',
|
||||
7: 'oauth-login',
|
||||
8: 'fetch-login-code',
|
||||
9: 'post-login-phone-verification',
|
||||
10: 'confirm-oauth',
|
||||
11: 'fetch-login-code',
|
||||
12: 'post-login-phone-verification',
|
||||
13: 'confirm-oauth',
|
||||
14: 'platform-verify',
|
||||
15: 'platform-verify',
|
||||
16: 'confirm-oauth',
|
||||
17: 'platform-verify',
|
||||
});
|
||||
|
||||
function resolveReportNodeId(stepOrNodeId, data = {}) {
|
||||
const explicitNodeId = String(data?.nodeId || data?.nodeKey || '').trim();
|
||||
if (explicitNodeId) {
|
||||
return explicitNodeId;
|
||||
}
|
||||
const directNodeId = String(stepOrNodeId || '').trim();
|
||||
if (directNodeId && !/^\d+$/.test(directNodeId)) {
|
||||
return directNodeId;
|
||||
}
|
||||
const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep);
|
||||
return step ? DEFAULT_OPENAI_NODE_BY_STEP[step] || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log message to Side Panel via Background.
|
||||
* @param {string} message
|
||||
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
|
||||
* @param {{ step?: number, stepKey?: string }} options
|
||||
*/
|
||||
function log(message, level = 'info', options = {}) {
|
||||
const step = normalizeLogStep(options?.step);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'LOG',
|
||||
source: getRuntimeScriptSource(),
|
||||
step,
|
||||
payload: {
|
||||
message: String(message || ''),
|
||||
level,
|
||||
timestamp: Date.now(),
|
||||
step,
|
||||
stepKey: String(options?.stepKey || '').trim(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that this content script is loaded and ready.
|
||||
*/
|
||||
function reportReady() {
|
||||
if (getRuntimeScriptSource() === 'unknown-source') {
|
||||
console.warn(LOG_PREFIX, 'skip CONTENT_SCRIPT_READY for unknown source');
|
||||
return;
|
||||
}
|
||||
console.log(LOG_PREFIX, '内容脚本已就绪');
|
||||
const message = {
|
||||
type: 'CONTENT_SCRIPT_READY',
|
||||
source: getRuntimeScriptSource(),
|
||||
step: null,
|
||||
payload: {},
|
||||
error: null,
|
||||
};
|
||||
Promise.resolve(chrome.runtime.sendMessage(message))
|
||||
.then((response) => {
|
||||
console.log(LOG_PREFIX, 'CONTENT_SCRIPT_READY sent successfully', { response, url: location.href });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(LOG_PREFIX, 'CONTENT_SCRIPT_READY send failed', err?.message || err, { url: location.href });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report node completion.
|
||||
* @param {string|number} stepOrNodeId
|
||||
* @param {Object} data - Node output data
|
||||
*/
|
||||
function reportComplete(stepOrNodeId, data = {}) {
|
||||
const nodeId = resolveReportNodeId(stepOrNodeId, data);
|
||||
const step = normalizeLogStep(stepOrNodeId || data?.step || data?.visibleStep);
|
||||
console.log(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 已完成`, data);
|
||||
log('已成功完成', 'ok', { step, stepKey: nodeId });
|
||||
const message = {
|
||||
type: 'NODE_COMPLETE',
|
||||
source: getRuntimeScriptSource(),
|
||||
nodeId,
|
||||
payload: {
|
||||
...(data || {}),
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
...(step ? { step } : {}),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
Promise.resolve(chrome.runtime.sendMessage(message))
|
||||
.then((response) => {
|
||||
console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${nodeId || stepOrNodeId}`, {
|
||||
response,
|
||||
url: location.href,
|
||||
payloadKeys: Object.keys(data || {}),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, {
|
||||
url: location.href,
|
||||
payloadKeys: Object.keys(data || {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reportNodeComplete(nodeId, data = {}) {
|
||||
const normalizedNodeId = String(nodeId || '').trim();
|
||||
console.log(LOG_PREFIX, `节点 ${normalizedNodeId} 已完成`, data);
|
||||
const message = {
|
||||
type: 'NODE_COMPLETE',
|
||||
source: getRuntimeScriptSource(),
|
||||
nodeId: normalizedNodeId,
|
||||
payload: {
|
||||
...(data || {}),
|
||||
nodeId: normalizedNodeId,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
Promise.resolve(chrome.runtime.sendMessage(message))
|
||||
.then((response) => {
|
||||
console.log(LOG_PREFIX, `NODE_COMPLETE sent successfully for node ${normalizedNodeId}`, {
|
||||
response,
|
||||
url: location.href,
|
||||
payloadKeys: Object.keys(data || {}),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(LOG_PREFIX, `NODE_COMPLETE send failed for node ${normalizedNodeId}`, err?.message || err, {
|
||||
url: location.href,
|
||||
payloadKeys: Object.keys(data || {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report node error.
|
||||
* @param {string|number} stepOrNodeId
|
||||
* @param {string} errorMessage
|
||||
*/
|
||||
function reportError(stepOrNodeId, errorMessage) {
|
||||
const nodeId = resolveReportNodeId(stepOrNodeId);
|
||||
const step = normalizeLogStep(stepOrNodeId);
|
||||
console.error(LOG_PREFIX, `节点 ${nodeId || stepOrNodeId} 失败: ${errorMessage}`);
|
||||
const message = {
|
||||
type: 'NODE_ERROR',
|
||||
source: getRuntimeScriptSource(),
|
||||
nodeId,
|
||||
payload: {
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
...(step ? { step } : {}),
|
||||
},
|
||||
error: errorMessage,
|
||||
};
|
||||
Promise.resolve(chrome.runtime.sendMessage(message))
|
||||
.then((response) => {
|
||||
console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${nodeId || stepOrNodeId}`, {
|
||||
response,
|
||||
url: location.href,
|
||||
errorMessage,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${nodeId || stepOrNodeId}`, err?.message || err, {
|
||||
url: location.href,
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reportNodeError(nodeId, errorMessage) {
|
||||
const normalizedNodeId = String(nodeId || '').trim();
|
||||
console.error(LOG_PREFIX, `节点 ${normalizedNodeId} 失败: ${errorMessage}`);
|
||||
const message = {
|
||||
type: 'NODE_ERROR',
|
||||
source: getRuntimeScriptSource(),
|
||||
nodeId: normalizedNodeId,
|
||||
payload: {
|
||||
nodeId: normalizedNodeId,
|
||||
},
|
||||
error: errorMessage,
|
||||
};
|
||||
Promise.resolve(chrome.runtime.sendMessage(message))
|
||||
.then((response) => {
|
||||
console.log(LOG_PREFIX, `NODE_ERROR sent successfully for node ${normalizedNodeId}`, {
|
||||
response,
|
||||
url: location.href,
|
||||
errorMessage,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(LOG_PREFIX, `NODE_ERROR send failed for node ${normalizedNodeId}`, err?.message || err, {
|
||||
url: location.href,
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a click with proper event dispatching.
|
||||
* @param {Element} el
|
||||
*/
|
||||
function simulateClick(el) {
|
||||
throwIfStopped();
|
||||
if (!el) {
|
||||
throw new Error('无法点击空元素。');
|
||||
}
|
||||
|
||||
const form = el.form || el.closest?.('form') || null;
|
||||
const strategy = typeof getActivationStrategy === 'function'
|
||||
? getActivationStrategy({
|
||||
tagName: el.tagName,
|
||||
type: el.getAttribute?.('type') || el.type || '',
|
||||
hasForm: Boolean(form),
|
||||
pathname: location.pathname || '',
|
||||
})
|
||||
: { method: 'click' };
|
||||
|
||||
let method = strategy.method || 'click';
|
||||
|
||||
if (method === 'requestSubmit' && form && typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit(el);
|
||||
} else if (typeof el.click === 'function') {
|
||||
method = 'click';
|
||||
el.click();
|
||||
} else {
|
||||
method = 'dispatchEvent';
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `已点击(${method}): ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
|
||||
log(`已点击(${method}) [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait a specified number of milliseconds.
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
function tick() {
|
||||
if (flowStopped) {
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start >= ms) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
|
||||
}
|
||||
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function humanPause(min = 250, max = 850) {
|
||||
const duration = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
await sleep(duration);
|
||||
}
|
||||
|
||||
function shouldReportReadyForFrame(source, isChildFrame) {
|
||||
const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.();
|
||||
if (sourceRegistry?.shouldReportReadyForFrame) {
|
||||
return sourceRegistry.shouldReportReadyForFrame(source, isChildFrame);
|
||||
}
|
||||
if (!isChildFrame) return true;
|
||||
return ![
|
||||
'qq-mail',
|
||||
'mail-163',
|
||||
'gmail-mail',
|
||||
'mail-2925',
|
||||
'inbucket-mail',
|
||||
'plus-checkout',
|
||||
'unknown-source',
|
||||
].includes(source);
|
||||
}
|
||||
|
||||
// Auto-report ready on load. Child frames are probed explicitly by frameId, so
|
||||
// they should not overwrite the tab-level registration or spam the side panel.
|
||||
if (shouldReportReadyForFrame(getRuntimeScriptSource(), window !== window.top)) {
|
||||
reportReady();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// content/whatsapp-flow.js — WhatsApp Web code reader for GoPay.
|
||||
|
||||
console.log('[MultiPage:whatsapp-flow] Content script loaded on', location.href);
|
||||
|
||||
const WHATSAPP_FLOW_LISTENER_SENTINEL = 'data-multipage-whatsapp-flow-listener';
|
||||
|
||||
if (document.documentElement.getAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL, '1');
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (
|
||||
message.type === 'WHATSAPP_GET_STATE'
|
||||
|| message.type === 'WHATSAPP_FIND_CODE'
|
||||
) {
|
||||
resetStopState();
|
||||
handleWhatsAppCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[MultiPage:whatsapp-flow] 消息监听已存在,跳过重复注册');
|
||||
}
|
||||
|
||||
async function handleWhatsAppCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'WHATSAPP_GET_STATE':
|
||||
return inspectWhatsAppState();
|
||||
case 'WHATSAPP_FIND_CODE':
|
||||
return findWhatsAppCode(message.payload || {});
|
||||
default:
|
||||
throw new Error(`whatsapp-flow.js 不处理消息:${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(text = '') {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getBodyText() {
|
||||
return normalizeText(document.body?.innerText || document.body?.textContent || '');
|
||||
}
|
||||
|
||||
function extractVerificationCode(text = '') {
|
||||
const normalized = normalizeText(text);
|
||||
const preferredPatterns = [
|
||||
/(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|whatsapp|验证码|驗證碼|代码|代碼)[^\d]{0,40}(\d{4,8})/i,
|
||||
/(\d{4,8})[^\d]{0,40}(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|验证码|驗證碼|代码|代碼)/i,
|
||||
];
|
||||
for (const pattern of preferredPatterns) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
const genericMatch = normalized.match(/(?<!\d)(\d{4,8})(?!\d)/);
|
||||
return genericMatch?.[1] || '';
|
||||
}
|
||||
|
||||
function getMessageCandidates() {
|
||||
const selectors = [
|
||||
'[data-testid*="msg" i]',
|
||||
'[data-pre-plain-text]',
|
||||
'.message-in',
|
||||
'[role="row"]',
|
||||
'div',
|
||||
'span',
|
||||
];
|
||||
const seen = new Set();
|
||||
const messages = [];
|
||||
for (const selector of selectors) {
|
||||
for (const el of Array.from(document.querySelectorAll(selector))) {
|
||||
const text = normalizeText(el.innerText || el.textContent || '');
|
||||
if (!text || text.length < 4 || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
messages.push(text);
|
||||
}
|
||||
}
|
||||
return messages.slice(-80);
|
||||
}
|
||||
|
||||
async function waitUntil(predicate, options = {}) {
|
||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 1000));
|
||||
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
|
||||
const startedAt = Date.now();
|
||||
while (true) {
|
||||
throwIfStopped();
|
||||
const value = await predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
|
||||
return null;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function findWhatsAppCode(payload = {}) {
|
||||
const timeoutMs = Math.max(0, Math.floor(Number(payload.timeoutMs) || 0));
|
||||
const result = await waitUntil(() => {
|
||||
const messages = getMessageCandidates();
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const text = messages[index];
|
||||
const code = extractVerificationCode(text);
|
||||
if (code) {
|
||||
return {
|
||||
code,
|
||||
messageText: text.slice(0, 500),
|
||||
};
|
||||
}
|
||||
}
|
||||
const code = extractVerificationCode(getBodyText());
|
||||
return code ? { code, messageText: getBodyText().slice(0, 500) } : null;
|
||||
}, {
|
||||
intervalMs: 1000,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
return result || {
|
||||
code: '',
|
||||
messageText: '',
|
||||
};
|
||||
}
|
||||
|
||||
function inspectWhatsAppState() {
|
||||
const bodyText = getBodyText();
|
||||
const code = extractVerificationCode(bodyText);
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
loggedIn: !/use whatsapp on your computer|link a device|scan|qr|使用 WhatsApp|扫码|掃碼/i.test(bodyText),
|
||||
code,
|
||||
textPreview: bodyText.slice(0, 500),
|
||||
messagePreview: getMessageCandidates().slice(-8),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user