fix: resolve step 9 profile fallback with latest dev
This commit is contained in:
@@ -0,0 +1,766 @@
|
||||
// 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 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 = {}) {
|
||||
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 countryResult = await ensureGoPayCountryCode(countryCode);
|
||||
const input = await waitUntil(() => findPhoneInput(), {
|
||||
label: 'GoPay 手机号输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
fillInput(input, phone);
|
||||
const clickResult = await clickContinueIfPresent();
|
||||
return {
|
||||
phoneSubmitted: true,
|
||||
countryCode,
|
||||
countryChanged: Boolean(countryResult.changed),
|
||||
clicked: Boolean(clickResult.clicked),
|
||||
clickTarget: clickResult.target || '',
|
||||
phase: 'phone_submitted',
|
||||
};
|
||||
}
|
||||
|
||||
async function submitGoPayOtp(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
const code = normalizeOtp(payload.code || payload.otp || '');
|
||||
if (!code) {
|
||||
throw new Error('GoPay WhatsApp 验证码为空。');
|
||||
}
|
||||
const filled = await waitUntil(() => fillVisibleOtpInputs(code), {
|
||||
label: 'GoPay 验证码输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const clickResult = await clickContinueIfPresent();
|
||||
return {
|
||||
otpSubmitted: Boolean(filled),
|
||||
clicked: Boolean(clickResult.clicked),
|
||||
clickTarget: clickResult.target || '',
|
||||
phase: 'otp_submitted',
|
||||
};
|
||||
}
|
||||
|
||||
async function submitGoPayPin(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
const pin = normalizeOtp(payload.pin || payload.gopayPin || '');
|
||||
if (!pin) {
|
||||
throw new Error('GoPay PIN 为空,请先在侧边栏配置。');
|
||||
}
|
||||
const filled = await waitUntil(() => fillVisiblePinInputs(pin), {
|
||||
label: 'GoPay PIN 输入框',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
const clickResult = await clickContinueIfPresent();
|
||||
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() {
|
||||
await waitForDocumentComplete();
|
||||
const clickResult = await clickContinueIfPresent({ afterMs: 1200 });
|
||||
return { clicked: Boolean(clickResult.clicked), clickTarget: clickResult.target || '' };
|
||||
}
|
||||
|
||||
async function clickGoPayPayNow() {
|
||||
await waitForDocumentComplete();
|
||||
const button = findPayNowButton();
|
||||
if (!button) {
|
||||
return { clicked: false, clickTarget: '' };
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
+41
-3
@@ -26,6 +26,8 @@
|
||||
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
|
||||
const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i;
|
||||
const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3;
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
const phoneCountryUtils = rootScope?.MultiPagePhoneCountryUtils || globalThis?.MultiPagePhoneCountryUtils || {};
|
||||
let lastPhoneRoute405RecoveryFailedAt = 0;
|
||||
let activePhoneResendPromise = null;
|
||||
|
||||
@@ -36,6 +38,9 @@
|
||||
}
|
||||
|
||||
function normalizePhoneDigits(value) {
|
||||
if (typeof phoneCountryUtils.normalizePhoneDigits === 'function') {
|
||||
return phoneCountryUtils.normalizePhoneDigits(value);
|
||||
}
|
||||
let digits = String(value || '').replace(/\D+/g, '');
|
||||
if (digits.startsWith('00')) {
|
||||
digits = digits.slice(2);
|
||||
@@ -48,27 +53,39 @@
|
||||
}
|
||||
|
||||
function normalizeCountryLabel(value) {
|
||||
if (typeof phoneCountryUtils.normalizeCountryLabel === 'function') {
|
||||
return phoneCountryUtils.normalizeCountryLabel(value);
|
||||
}
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getOptionLabel(option) {
|
||||
if (typeof phoneCountryUtils.getOptionLabel === 'function') {
|
||||
return phoneCountryUtils.getOptionLabel(option);
|
||||
}
|
||||
return String(option?.textContent || option?.label || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeCountryOptionValue(value) {
|
||||
if (typeof phoneCountryUtils.normalizeCountryOptionValue === 'function') {
|
||||
return phoneCountryUtils.normalizeCountryOptionValue(value);
|
||||
}
|
||||
return String(value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function getRegionDisplayName(regionCode, locale) {
|
||||
if (typeof phoneCountryUtils.getRegionDisplayName === 'function') {
|
||||
return phoneCountryUtils.getRegionDisplayName(regionCode, locale);
|
||||
}
|
||||
const normalizedRegionCode = normalizeCountryOptionValue(regionCode);
|
||||
const normalizedLocale = String(locale || '').trim();
|
||||
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
|
||||
@@ -84,6 +101,14 @@
|
||||
}
|
||||
|
||||
function getCountryOptionMatchLabels(option) {
|
||||
if (typeof phoneCountryUtils.getOptionMatchLabels === 'function') {
|
||||
return phoneCountryUtils.getOptionMatchLabels(option, {
|
||||
document: typeof document !== 'undefined' ? document : null,
|
||||
navigator: rootScope?.navigator || globalThis?.navigator || null,
|
||||
getOptionLabel,
|
||||
});
|
||||
}
|
||||
|
||||
const labels = new Set();
|
||||
const pushLabel = (value) => {
|
||||
const label = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -128,8 +153,11 @@
|
||||
}
|
||||
|
||||
function extractDialCodeFromText(value) {
|
||||
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
|
||||
return String(match?.[1] || match?.[2] || '').trim();
|
||||
if (typeof phoneCountryUtils.extractDialCodeFromText === 'function') {
|
||||
return phoneCountryUtils.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 getCountryButtonText() {
|
||||
@@ -240,6 +268,13 @@
|
||||
if (!select) {
|
||||
return null;
|
||||
}
|
||||
if (typeof phoneCountryUtils.findOptionByCountryLabel === 'function') {
|
||||
return phoneCountryUtils.findOptionByCountryLabel(select.options, countryLabel, {
|
||||
document: typeof document !== 'undefined' ? document : null,
|
||||
navigator: rootScope?.navigator || globalThis?.navigator || null,
|
||||
getOptionLabel,
|
||||
});
|
||||
}
|
||||
const normalizedTarget = normalizeCountryLabel(countryLabel);
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
@@ -267,6 +302,9 @@
|
||||
if (!select) {
|
||||
return null;
|
||||
}
|
||||
if (typeof phoneCountryUtils.findOptionByPhoneNumber === 'function') {
|
||||
return phoneCountryUtils.findOptionByPhoneNumber(select.options, phoneNumber, { getOptionLabel });
|
||||
}
|
||||
const digits = normalizePhoneDigits(phoneNumber);
|
||||
if (!digits) {
|
||||
return null;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
+471
-90
@@ -5,7 +5,7 @@ console.log('[MultiPage:plus-checkout] Content script loaded on', location.href)
|
||||
window.__MULTIPAGE_PLUS_CHECKOUT_READY__ = true;
|
||||
|
||||
const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener';
|
||||
const PLUS_CHECKOUT_BASE_PAYLOAD = {
|
||||
const PLUS_CHECKOUT_PAYLOAD_BASE = {
|
||||
entry_point: 'all_plans_pricing_modal',
|
||||
plan_name: 'chatgptplusplan',
|
||||
checkout_ui_mode: 'custom',
|
||||
@@ -33,6 +33,32 @@ const PLUS_CHECKOUT_CONFIGS = {
|
||||
},
|
||||
};
|
||||
const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000;
|
||||
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
|
||||
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
|
||||
const PAYMENT_METHOD_CONFIGS = {
|
||||
[PLUS_PAYMENT_METHOD_PAYPAL]: {
|
||||
id: PLUS_PAYMENT_METHOD_PAYPAL,
|
||||
label: 'PayPal',
|
||||
diagnosticLabel: 'PayPal',
|
||||
checkoutMerchantPath: 'openai_ie',
|
||||
billingDetails: {
|
||||
country: 'DE',
|
||||
currency: 'EUR',
|
||||
},
|
||||
patterns: [/paypal/i],
|
||||
},
|
||||
[PLUS_PAYMENT_METHOD_GOPAY]: {
|
||||
id: PLUS_PAYMENT_METHOD_GOPAY,
|
||||
label: 'GoPay',
|
||||
diagnosticLabel: 'GoPay',
|
||||
checkoutMerchantPath: 'openai_llc',
|
||||
billingDetails: {
|
||||
country: 'ID',
|
||||
currency: 'IDR',
|
||||
},
|
||||
patterns: [/gopay|go\s*pay/i],
|
||||
},
|
||||
};
|
||||
|
||||
if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1');
|
||||
@@ -42,6 +68,7 @@ if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '
|
||||
message.type === 'CREATE_PLUS_CHECKOUT'
|
||||
|| message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'
|
||||
|| message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'
|
||||
|| message.type === 'PLUS_CHECKOUT_SELECT_GOPAY'
|
||||
|| message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'
|
||||
|| message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'
|
||||
|| message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'
|
||||
@@ -73,7 +100,9 @@ async function handlePlusCheckoutCommand(message) {
|
||||
case 'FILL_PLUS_BILLING_AND_SUBMIT':
|
||||
return fillPlusBillingAndSubmit(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_SELECT_PAYPAL':
|
||||
return selectPlusPayPalPaymentMethod();
|
||||
return selectPlusPayPalPaymentMethod(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_SELECT_GOPAY':
|
||||
return selectPlusGoPayPaymentMethod(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS':
|
||||
return fillPlusBillingAddress(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY':
|
||||
@@ -83,9 +112,9 @@ async function handlePlusCheckoutCommand(message) {
|
||||
case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS':
|
||||
return ensurePlusStructuredBillingAddress(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE':
|
||||
return clickPlusSubscribe();
|
||||
return clickPlusSubscribe(message.payload || {});
|
||||
case 'PLUS_CHECKOUT_GET_STATE':
|
||||
return inspectPlusCheckoutState();
|
||||
return inspectPlusCheckoutState(message.payload || {});
|
||||
default:
|
||||
throw new Error(`plus-checkout.js 不处理消息:${message.type}`);
|
||||
}
|
||||
@@ -94,12 +123,17 @@ async function handlePlusCheckoutCommand(message) {
|
||||
async function waitUntil(predicate, options = {}) {
|
||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
||||
const label = String(options.label || '条件').trim() || '条件';
|
||||
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(`${label}等待超时`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
@@ -126,24 +160,97 @@ function normalizeText(text = '') {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
|
||||
function parseLocalizedAmount(rawValue = '') {
|
||||
const raw = normalizeText(rawValue);
|
||||
const match = raw.match(/(?:[$€£¥]\s*)?([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)(?:\s*[$€£¥])?/);
|
||||
if (!match) return null;
|
||||
let numericText = String(match[1] || '').trim();
|
||||
const lastComma = numericText.lastIndexOf(',');
|
||||
const lastDot = numericText.lastIndexOf('.');
|
||||
if (lastComma > -1 && lastDot > -1) {
|
||||
const decimalSeparator = lastComma > lastDot ? ',' : '.';
|
||||
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
|
||||
numericText = numericText
|
||||
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
|
||||
.replace(decimalSeparator, '.');
|
||||
} else if (lastComma > -1) {
|
||||
numericText = numericText.replace(',', '.');
|
||||
}
|
||||
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
|
||||
return Number.isFinite(amount)
|
||||
? {
|
||||
amount,
|
||||
raw: match[0],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildPlusCheckoutConfig(payload = {}) {
|
||||
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
|
||||
const config = PLUS_CHECKOUT_CONFIGS[paymentMethod] || PLUS_CHECKOUT_CONFIGS.paypal;
|
||||
function getTextAfterTodayDueLabel(text = '') {
|
||||
const normalized = normalizeText(text);
|
||||
const match = normalized.match(/(?:今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today)/i);
|
||||
if (!match) return '';
|
||||
return normalized.slice((match.index || 0) + match[0].length).trim();
|
||||
}
|
||||
|
||||
function getCheckoutAmountSummary() {
|
||||
const elements = getVisibleControls('div, span, p, strong, b');
|
||||
const labelPattern = /今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today/i;
|
||||
const amountPattern = /[$€£¥]\s*[+-]?\d|[+-]?\d+(?:[.,]\d{1,2})?\s*[$€£¥]/;
|
||||
|
||||
for (const element of elements) {
|
||||
const text = normalizeText(element.innerText || element.textContent || '');
|
||||
if (!labelPattern.test(text)) continue;
|
||||
|
||||
const candidates = [];
|
||||
const afterLabelText = getTextAfterTodayDueLabel(text);
|
||||
if (afterLabelText) candidates.push(afterLabelText);
|
||||
|
||||
const parent = element.parentElement;
|
||||
if (parent) {
|
||||
for (const child of Array.from(parent.children || [])) {
|
||||
if (child === element) continue;
|
||||
const childText = normalizeText(child.innerText || child.textContent || '');
|
||||
if (amountPattern.test(childText)) {
|
||||
candidates.push(childText);
|
||||
}
|
||||
}
|
||||
const parentAfterLabelText = getTextAfterTodayDueLabel(parent.innerText || parent.textContent || '');
|
||||
if (parentAfterLabelText) candidates.push(parentAfterLabelText);
|
||||
}
|
||||
|
||||
const grandparent = parent?.parentElement;
|
||||
if (grandparent) {
|
||||
const grandparentAfterLabelText = getTextAfterTodayDueLabel(grandparent.innerText || grandparent.textContent || '');
|
||||
if (grandparentAfterLabelText) candidates.push(grandparentAfterLabelText);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseLocalizedAmount(candidate);
|
||||
if (!parsed) continue;
|
||||
return {
|
||||
hasTodayDue: true,
|
||||
amount: parsed.amount,
|
||||
isZero: Math.abs(parsed.amount) < 0.005,
|
||||
rawAmount: parsed.raw,
|
||||
labelText: text.slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasTodayDue: true,
|
||||
amount: null,
|
||||
isZero: false,
|
||||
rawAmount: '',
|
||||
labelText: text.slice(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
paymentMethod,
|
||||
paymentLabel: config.paymentLabel,
|
||||
checkoutUrlPrefix: config.checkoutUrlPrefix,
|
||||
checkoutPayload: {
|
||||
...PLUS_CHECKOUT_BASE_PAYLOAD,
|
||||
billing_details: {
|
||||
country: config.billing_details.country,
|
||||
currency: config.billing_details.currency,
|
||||
},
|
||||
},
|
||||
hasTodayDue: false,
|
||||
amount: null,
|
||||
isZero: false,
|
||||
rawAmount: '',
|
||||
labelText: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,9 +259,14 @@ function getActionText(el) {
|
||||
el?.textContent,
|
||||
el?.value,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('aria-labelledby'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.getAttribute?.('placeholder'),
|
||||
el?.getAttribute?.('name'),
|
||||
el?.getAttribute?.('autocomplete'),
|
||||
el?.getAttribute?.('data-elements-stable-field-name'),
|
||||
el?.getAttribute?.('data-field'),
|
||||
el?.getAttribute?.('data-field-name'),
|
||||
el?.id,
|
||||
].filter(Boolean).join(' '));
|
||||
}
|
||||
@@ -208,9 +320,9 @@ function getVisibleControls(selector) {
|
||||
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"], [tabindex]');
|
||||
const candidates = getVisibleControls('button, a, [role="button"], [role="tab"], input[type="button"], input[type="submit"], [tabindex]');
|
||||
return candidates.find((el) => {
|
||||
const text = getActionText(el);
|
||||
const text = getCombinedSearchText(el);
|
||||
return normalizedPatterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
@@ -284,7 +396,7 @@ function findInteractiveAncestor(el) {
|
||||
let current = el;
|
||||
for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
|
||||
if (!isVisibleElement(current) || isDocumentLevelContainer(current)) continue;
|
||||
if (current.matches?.('button, a, label, [role="button"], [role="radio"], input[type="radio"], [tabindex]')) {
|
||||
if (current.matches?.('button, a, label, [role="button"], [role="radio"], [role="tab"], input[type="radio"], [tabindex]')) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -304,6 +416,16 @@ function findPaymentCardAncestor(el, pattern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
|
||||
? PLUS_PAYMENT_METHOD_GOPAY
|
||||
: PLUS_PAYMENT_METHOD_PAYPAL;
|
||||
}
|
||||
|
||||
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
|
||||
}
|
||||
|
||||
function getAncestorChainSummary(el, limit = 6) {
|
||||
const chain = [];
|
||||
let current = el;
|
||||
@@ -326,13 +448,15 @@ function getAncestorChainSummary(el, limit = 6) {
|
||||
return chain;
|
||||
}
|
||||
|
||||
function getPayPalSearchCandidates() {
|
||||
function getPaymentMethodSearchCandidates(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
const selector = [
|
||||
'button',
|
||||
'a',
|
||||
'label',
|
||||
'[role="button"]',
|
||||
'[role="radio"]',
|
||||
'[role="tab"]',
|
||||
'input[type="radio"]',
|
||||
'[tabindex]',
|
||||
'[data-testid]',
|
||||
@@ -345,7 +469,10 @@ function getPayPalSearchCandidates() {
|
||||
].join(', ');
|
||||
|
||||
return getVisibleControls(selector)
|
||||
.filter((el) => /paypal/i.test(getCombinedSearchText(el)))
|
||||
.filter((el) => {
|
||||
const text = getCombinedSearchText(el);
|
||||
return config.patterns.some((pattern) => pattern.test(text));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftRect = left.getBoundingClientRect();
|
||||
const rightRect = right.getBoundingClientRect();
|
||||
@@ -353,26 +480,36 @@ function getPayPalSearchCandidates() {
|
||||
});
|
||||
}
|
||||
|
||||
function findPayPalPaymentMethodTarget() {
|
||||
const paypalPattern = /paypal/i;
|
||||
const directClickable = findClickableByText([paypalPattern]);
|
||||
function getPayPalSearchCandidates() {
|
||||
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
}
|
||||
|
||||
function getGoPaySearchCandidates() {
|
||||
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
}
|
||||
|
||||
function findPaymentMethodTarget(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
const directClickable = findClickableByText(config.patterns);
|
||||
if (directClickable) {
|
||||
return directClickable;
|
||||
}
|
||||
|
||||
const radios = getVisibleControls('input[type="radio"], [role="radio"]');
|
||||
const paypalRadio = radios.find((el) => paypalPattern.test(getCombinedSearchText(el)));
|
||||
if (paypalRadio) {
|
||||
return paypalRadio;
|
||||
const matchedRadio = radios.find((el) => config.patterns.some((pattern) => pattern.test(getCombinedSearchText(el))));
|
||||
if (matchedRadio) {
|
||||
return matchedRadio;
|
||||
}
|
||||
|
||||
const candidates = getPayPalSearchCandidates();
|
||||
const candidates = getPaymentMethodSearchCandidates(method);
|
||||
for (const candidate of candidates) {
|
||||
const interactive = findInteractiveAncestor(candidate);
|
||||
if (interactive && paypalPattern.test(getCombinedSearchText(interactive))) {
|
||||
if (interactive && config.patterns.some((pattern) => pattern.test(getCombinedSearchText(interactive)))) {
|
||||
return interactive;
|
||||
}
|
||||
const card = findPaymentCardAncestor(candidate, paypalPattern);
|
||||
const card = config.patterns
|
||||
.map((pattern) => findPaymentCardAncestor(candidate, pattern))
|
||||
.find(Boolean);
|
||||
if (card) {
|
||||
return card;
|
||||
}
|
||||
@@ -381,6 +518,14 @@ function findPayPalPaymentMethodTarget() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPayPalPaymentMethodTarget() {
|
||||
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
}
|
||||
|
||||
function findGoPayPaymentMethodTarget() {
|
||||
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
}
|
||||
|
||||
function summarizeElementForDebug(el) {
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -393,16 +538,24 @@ function summarizeElementForDebug(el) {
|
||||
};
|
||||
}
|
||||
|
||||
function getPayPalCandidateSummaries(limit = 6) {
|
||||
return getPayPalSearchCandidates()
|
||||
function getPaymentMethodCandidateSummaries(method = PLUS_PAYMENT_METHOD_PAYPAL, limit = 6) {
|
||||
return getPaymentMethodSearchCandidates(method)
|
||||
.map(summarizeElementForDebug)
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function getPayPalCandidateSummaries(limit = 6) {
|
||||
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_PAYPAL, limit);
|
||||
}
|
||||
|
||||
function getGoPayCandidateSummaries(limit = 6) {
|
||||
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_GOPAY, limit);
|
||||
}
|
||||
|
||||
function getPaymentTextPreview(limit = 10) {
|
||||
const seen = new Set();
|
||||
const pattern = /paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
|
||||
const pattern = /gopay|go\s*pay|paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
|
||||
return getVisibleControls('button, a, label, [role="button"], [role="radio"], input[type="radio"], input[type="button"], input[type="submit"], [data-testid]')
|
||||
.map((el) => getCombinedSearchText(el))
|
||||
.filter((text) => text && pattern.test(text))
|
||||
@@ -416,28 +569,68 @@ function getPaymentTextPreview(limit = 10) {
|
||||
}
|
||||
|
||||
function getPayPalDiagnostics(reason = '') {
|
||||
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason);
|
||||
}
|
||||
|
||||
function getGoPayDiagnostics(reason = '') {
|
||||
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason);
|
||||
}
|
||||
|
||||
function getPaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '') {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
return {
|
||||
reason,
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
paymentMethod: config.id,
|
||||
paymentMethodLabel: config.label,
|
||||
paymentCandidates: getPaymentMethodCandidateSummaries(config.id),
|
||||
paypalCandidates: getPayPalCandidateSummaries(),
|
||||
gopayCandidates: getGoPayCandidateSummaries(),
|
||||
paymentTextPreview: getPaymentTextPreview(),
|
||||
cardFieldsVisible: hasCreditCardFields(),
|
||||
billingFieldsVisible: hasBillingAddressFields(),
|
||||
};
|
||||
}
|
||||
|
||||
function writePayPalDiagnostics(reason, level = 'info') {
|
||||
const diagnostics = getPayPalDiagnostics(reason);
|
||||
function writePaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '', level = 'info') {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
const diagnostics = getPaymentMethodDiagnostics(config.id, reason);
|
||||
const writer = typeof console[level] === 'function' ? console[level] : console.info;
|
||||
writer.call(console, '[MultiPage:plus-checkout] PayPal diagnostics', diagnostics);
|
||||
log(`Plus Checkout:${reason}。PayPal 候选 ${diagnostics.paypalCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}。`, level === 'error' ? 'error' : 'warn');
|
||||
writer.call(console, `[MultiPage:plus-checkout] ${config.diagnosticLabel} diagnostics`, diagnostics);
|
||||
log(`Plus Checkout:${reason}。${config.label} 候选 ${diagnostics.paymentCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}。`, level === 'error' ? 'error' : 'warn');
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function createPlusCheckoutSession(payload = {}) {
|
||||
function writePayPalDiagnostics(reason, level = 'info') {
|
||||
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason, level);
|
||||
}
|
||||
|
||||
function writeGoPayDiagnostics(reason, level = 'info') {
|
||||
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason, level);
|
||||
}
|
||||
|
||||
function buildPlusCheckoutPayload(paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const config = getPaymentMethodConfig(paymentMethod);
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(PLUS_CHECKOUT_PAYLOAD_BASE)),
|
||||
billing_details: {
|
||||
...config.billingDetails,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlusCheckoutUrl(checkoutSessionId, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const sessionId = String(checkoutSessionId || '').trim();
|
||||
if (!sessionId) {
|
||||
throw new Error('创建 Plus Checkout 失败:未返回 checkout_session_id。');
|
||||
}
|
||||
const config = getPaymentMethodConfig(paymentMethod);
|
||||
return `https://chatgpt.com/checkout/${config.checkoutMerchantPath}/${sessionId}`;
|
||||
}
|
||||
|
||||
async function createPlusCheckoutSession(options = {}) {
|
||||
await waitForDocumentComplete();
|
||||
const checkoutConfig = buildPlusCheckoutConfig(payload);
|
||||
log('Plus:正在读取 ChatGPT 登录会话...');
|
||||
|
||||
const sessionResponse = await fetch('/api/auth/session', {
|
||||
@@ -450,6 +643,8 @@ async function createPlusCheckoutSession(payload = {}) {
|
||||
}
|
||||
|
||||
log('Plus:正在创建 checkout 会话...');
|
||||
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod);
|
||||
const checkoutPayload = buildPlusCheckoutPayload(paymentMethod);
|
||||
const response = await fetch('https://chatgpt.com/backend-api/payments/checkout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -457,7 +652,7 @@ async function createPlusCheckoutSession(payload = {}) {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(checkoutConfig.checkoutPayload),
|
||||
body: JSON.stringify(checkoutPayload),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -467,17 +662,17 @@ async function createPlusCheckoutSession(payload = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutUrl: `${checkoutConfig.checkoutUrlPrefix}${data.checkout_session_id}`,
|
||||
country: checkoutConfig.checkoutPayload.billing_details.country,
|
||||
currency: checkoutConfig.checkoutPayload.billing_details.currency,
|
||||
paymentMethod: checkoutConfig.paymentMethod,
|
||||
checkoutUrl: buildPlusCheckoutUrl(data.checkout_session_id, paymentMethod),
|
||||
country: checkoutPayload.billing_details.country,
|
||||
currency: checkoutPayload.billing_details.currency,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectPayPalPaymentMethod() {
|
||||
async function selectPaymentMethod(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
let lastDiagnosticsAt = 0;
|
||||
const target = await waitUntil(() => {
|
||||
const currentTarget = findPayPalPaymentMethodTarget();
|
||||
const currentTarget = findPaymentMethodTarget(config.id);
|
||||
if (currentTarget) {
|
||||
return currentTarget;
|
||||
}
|
||||
@@ -485,31 +680,49 @@ async function selectPayPalPaymentMethod() {
|
||||
const now = Date.now();
|
||||
if (!lastDiagnosticsAt || now - lastDiagnosticsAt >= PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS) {
|
||||
lastDiagnosticsAt = now;
|
||||
writePayPalDiagnostics('正在等待可点击的 PayPal 付款方式', 'warn');
|
||||
writePaymentMethodDiagnostics(config.id, `正在等待可点击的 ${config.label} 付款方式`, 'warn');
|
||||
}
|
||||
return null;
|
||||
}, {
|
||||
label: 'PayPal 付款方式',
|
||||
label: `${config.label} 付款方式`,
|
||||
intervalMs: 250,
|
||||
});
|
||||
console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target));
|
||||
console.info(`[MultiPage:plus-checkout] ${config.label} target selected`, summarizeElementForDebug(target));
|
||||
simulateClick(target);
|
||||
log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。');
|
||||
log(`Plus Checkout:已点击 ${config.label} 付款方式,正在确认选中状态。`);
|
||||
|
||||
if (!await waitForPayPalPaymentMethodActive()) {
|
||||
const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error');
|
||||
throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`);
|
||||
if (!await waitForPaymentMethodActive(config.id)) {
|
||||
const diagnostics = writePaymentMethodDiagnostics(config.id, `点击 ${config.label} 后页面仍未进入 ${config.label} 账单表单`, 'error');
|
||||
throw new Error(`Plus Checkout:已尝试点击 ${config.label},但页面未切换到 ${config.label} 表单。请提供控制台 ${config.label} diagnostics 结构。候选数量:${diagnostics.paymentCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`);
|
||||
}
|
||||
|
||||
log('Plus Checkout:已确认 PayPal 付款方式生效。');
|
||||
log(`Plus Checkout:已确认 ${config.label} 付款方式生效。`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function selectPayPalPaymentMethod() {
|
||||
return selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
}
|
||||
|
||||
async function selectGoPayPaymentMethod() {
|
||||
return selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
}
|
||||
|
||||
async function selectPlusPayPalPaymentMethod() {
|
||||
await waitForDocumentComplete();
|
||||
await selectPayPalPaymentMethod();
|
||||
await selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
return {
|
||||
paymentSelected: true,
|
||||
paymentMethod: PLUS_PAYMENT_METHOD_PAYPAL,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectPlusGoPayPaymentMethod() {
|
||||
await waitForDocumentComplete();
|
||||
await selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
return {
|
||||
paymentSelected: true,
|
||||
paymentMethod: PLUS_PAYMENT_METHOD_GOPAY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -577,22 +790,34 @@ function hasBillingAddressFields() {
|
||||
});
|
||||
}
|
||||
|
||||
function hasSelectedPayPalControl() {
|
||||
const paypalPattern = /paypal/i;
|
||||
const candidates = getPayPalSearchCandidates();
|
||||
function hasPaymentMethodSelectionMarker(el) {
|
||||
if (!el) return false;
|
||||
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
|
||||
return el.checked === true
|
||||
|| el.getAttribute?.('aria-checked') === 'true'
|
||||
|| el.getAttribute?.('aria-selected') === 'true'
|
||||
|| el.getAttribute?.('data-state') === 'checked'
|
||||
|| el.getAttribute?.('data-selected') === 'true'
|
||||
|| /\b(selected|checked|active)\b/i.test(className);
|
||||
}
|
||||
|
||||
function hasSelectedPaymentMethodControl(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const config = getPaymentMethodConfig(method);
|
||||
const candidates = getPaymentMethodSearchCandidates(config.id);
|
||||
return candidates.some((candidate) => {
|
||||
let current = candidate;
|
||||
for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) {
|
||||
if (isDocumentLevelContainer(current)) break;
|
||||
if (!paypalPattern.test(getCombinedSearchText(current))) continue;
|
||||
const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || '';
|
||||
const currentMatchesPayment = config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current)));
|
||||
if (currentMatchesPayment && hasPaymentMethodSelectionMarker(current)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const radio = current.querySelector?.('input[type="radio"], [role="radio"]');
|
||||
if (
|
||||
current.checked === true
|
||||
|| current.getAttribute?.('aria-checked') === 'true'
|
||||
|| current.getAttribute?.('aria-selected') === 'true'
|
||||
|| current.getAttribute?.('data-state') === 'checked'
|
||||
|| current.getAttribute?.('data-selected') === 'true'
|
||||
|| /\b(selected|checked|active)\b/i.test(className)
|
||||
radio
|
||||
&& config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current) || getCombinedSearchText(radio)))
|
||||
&& hasPaymentMethodSelectionMarker(radio)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -601,15 +826,31 @@ function hasSelectedPayPalControl() {
|
||||
});
|
||||
}
|
||||
|
||||
function isPayPalPaymentMethodActive() {
|
||||
return hasSelectedPayPalControl();
|
||||
function hasSelectedPayPalControl() {
|
||||
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
}
|
||||
|
||||
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
|
||||
function hasSelectedGoPayControl() {
|
||||
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
}
|
||||
|
||||
function isPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
return hasSelectedPaymentMethodControl(method);
|
||||
}
|
||||
|
||||
function isPayPalPaymentMethodActive() {
|
||||
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL);
|
||||
}
|
||||
|
||||
function isGoPayPaymentMethodActive() {
|
||||
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_GOPAY);
|
||||
}
|
||||
|
||||
async function waitForPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs = 5000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
throwIfStopped();
|
||||
if (isPayPalPaymentMethodActive()) {
|
||||
if (isPaymentMethodActive(method)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(250);
|
||||
@@ -617,6 +858,10 @@ async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
|
||||
return waitForPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs);
|
||||
}
|
||||
|
||||
async function findAddressSearchInput() {
|
||||
return waitUntil(() => {
|
||||
const direct = findInputByFieldText([
|
||||
@@ -670,6 +915,7 @@ async function clickAddressSuggestion(seed = {}) {
|
||||
}, {
|
||||
label: '地址推荐列表',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
|
||||
const suggestionIndex = Math.max(0, Math.min(
|
||||
@@ -736,6 +982,7 @@ function getCountryCandidates(value = '') {
|
||||
FR: ['France', '法国'],
|
||||
GB: ['United Kingdom', 'UK', 'Britain', 'England', '英国'],
|
||||
HK: ['Hong Kong', '香港'],
|
||||
ID: ['Indonesia', '印度尼西亚', '印尼'],
|
||||
IT: ['Italy', '意大利'],
|
||||
JP: ['Japan', '日本', '日本国'],
|
||||
KR: ['Korea', 'South Korea', '韩国'],
|
||||
@@ -750,6 +997,10 @@ function getCountryCandidates(value = '') {
|
||||
US: ['United States', 'United States of America', 'USA', '美国'],
|
||||
VN: ['Vietnam', '越南'],
|
||||
};
|
||||
const indonesiaCandidates = aliases.ID || [];
|
||||
if (compact === 'id' || compact === 'indonesia' || compact === '印度尼西亚' || compact === '印尼') {
|
||||
return Array.from(new Set([raw, 'ID', ...indonesiaCandidates].filter(Boolean)));
|
||||
}
|
||||
const direct = aliases[String(raw || '').trim().toUpperCase()] || [];
|
||||
const matched = Object.entries(aliases).find(([code, names]) => {
|
||||
if (String(code).toLowerCase() === compact) return true;
|
||||
@@ -820,7 +1071,7 @@ function findRegionDropdown() {
|
||||
if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false;
|
||||
const text = getFieldText(control);
|
||||
if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false;
|
||||
return /state|province|county/i.test(text)
|
||||
return /state|province|county|prefecture|administrative|administrative[_-]?area/i.test(text)
|
||||
|| /(?:^|\s)region(?:\s|$)/i.test(text)
|
||||
|| /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text);
|
||||
}) || null;
|
||||
@@ -956,24 +1207,24 @@ async function selectCountryDropdown(countryDropdown, value) {
|
||||
|
||||
function getStructuredAddressFields() {
|
||||
const address1 = findInputByFieldText([
|
||||
/address\s*(?:line)?\s*1|street/i,
|
||||
/地址\s*1|街道|详细地址/i,
|
||||
/address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i,
|
||||
/地址\s*1|街道|详细地址|住所/i,
|
||||
]);
|
||||
const address2 = findInputByFieldText([
|
||||
/address\s*(?:line)?\s*2|apt|suite|unit/i,
|
||||
/address\s*(?:line)?\s*2|address[_-]?line[_-]?2|address\[(?:address_)?line2\]|line\s*2|apt|suite|unit/i,
|
||||
/地址\s*2|公寓|单元|门牌/i,
|
||||
]);
|
||||
const city = findInputByFieldText([
|
||||
/city|town|suburb/i,
|
||||
/城市|市区/i,
|
||||
/city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i,
|
||||
/城市|市区|区市町村|市区町村|市町村/i,
|
||||
]);
|
||||
const region = findInputByFieldText([
|
||||
/state|province|region|county/i,
|
||||
/省|州|地区/i,
|
||||
/state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i,
|
||||
/省|州|地区|辖区|都道府县|都道府県/i,
|
||||
]);
|
||||
const postalCode = findInputByFieldText([
|
||||
/postal|zip|postcode/i,
|
||||
/邮编|邮政/i,
|
||||
/postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i,
|
||||
/邮编|邮政|郵便番号/i,
|
||||
]);
|
||||
return { address1, address2, city, region, postalCode };
|
||||
}
|
||||
@@ -1012,6 +1263,7 @@ async function ensureStructuredAddress(seed, options = {}) {
|
||||
}, {
|
||||
label: '结构化账单地址字段',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
|
||||
fillIfEmpty(fields.address1, fallback.address1, { overwrite });
|
||||
@@ -1038,15 +1290,115 @@ async function ensureStructuredAddress(seed, options = {}) {
|
||||
}
|
||||
|
||||
function findSubscribeButton() {
|
||||
const submitButtons = getVisibleControls('button[type="submit"], input[type="submit"]');
|
||||
const exactSubmit = submitButtons.find((button) => (
|
||||
isEnabledControl(button)
|
||||
&& /订阅|subscribe|购买\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i.test(getCombinedSearchText(button))
|
||||
));
|
||||
if (exactSubmit) {
|
||||
return exactSubmit;
|
||||
}
|
||||
|
||||
return findClickableByText([
|
||||
/订阅|继续|确认|支付/i,
|
||||
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function isBusySubscribeButton(button) {
|
||||
if (!button) return true;
|
||||
const text = getActionText(button);
|
||||
return button.disabled
|
||||
|| button.getAttribute?.('aria-disabled') === 'true'
|
||||
|| button.getAttribute?.('aria-busy') === 'true'
|
||||
|| button.closest?.('[aria-busy="true"], [data-loading="true"], [data-state="loading"]')
|
||||
|| /loading|processing|submitting|请稍候|处理中|加载中/i.test(text);
|
||||
}
|
||||
|
||||
function getAssociatedForm(button) {
|
||||
if (!button) return null;
|
||||
if (button.form) return button.form;
|
||||
const formId = String(button.getAttribute?.('form') || '').trim();
|
||||
if (formId) {
|
||||
return document.getElementById(formId) || null;
|
||||
}
|
||||
return button.closest?.('form') || null;
|
||||
}
|
||||
|
||||
async function humanLikeClick(el) {
|
||||
throwIfStopped();
|
||||
if (!el) {
|
||||
throw new Error('无法点击空元素。');
|
||||
}
|
||||
|
||||
el.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
await sleep(300);
|
||||
if (typeof el.focus === 'function') {
|
||||
el.focus({ preventScroll: true });
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const clientX = Math.floor(rect.left + rect.width / 2);
|
||||
const clientY = Math.floor(rect.top + rect.height / 2);
|
||||
const eventInit = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
};
|
||||
const pointerCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
|
||||
const events = [
|
||||
['pointerover', pointerCtor],
|
||||
['pointerenter', pointerCtor],
|
||||
['mouseover', MouseEvent],
|
||||
['mouseenter', MouseEvent],
|
||||
['pointermove', pointerCtor],
|
||||
['mousemove', MouseEvent],
|
||||
['pointerdown', pointerCtor],
|
||||
['mousedown', MouseEvent],
|
||||
['pointerup', pointerCtor],
|
||||
['mouseup', MouseEvent],
|
||||
['click', MouseEvent],
|
||||
];
|
||||
|
||||
for (const [type, EventCtor] of events) {
|
||||
throwIfStopped();
|
||||
el.dispatchEvent(new EventCtor(type, eventInit));
|
||||
await sleep(type === 'mousedown' || type === 'pointerdown' ? 120 : 30);
|
||||
}
|
||||
|
||||
if (typeof el.click === 'function') {
|
||||
await sleep(120);
|
||||
el.click();
|
||||
}
|
||||
|
||||
const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase();
|
||||
const form = getAssociatedForm(el);
|
||||
if (
|
||||
form
|
||||
&& typeof form.requestSubmit === 'function'
|
||||
&& (
|
||||
(String(el.tagName || '').toUpperCase() === 'BUTTON' && (!type || type === 'submit'))
|
||||
|| (String(el.tagName || '').toUpperCase() === 'INPUT' && type === 'submit')
|
||||
)
|
||||
) {
|
||||
await sleep(250);
|
||||
form.requestSubmit(el);
|
||||
}
|
||||
|
||||
console.log('[MultiPage:plus-checkout] 已执行拟人工点击', summarizeElementForDebug(el));
|
||||
log(`已拟人工点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||
}
|
||||
|
||||
async function fillPlusBillingAndSubmit(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
await selectPayPalPaymentMethod();
|
||||
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
|
||||
await selectPaymentMethod(paymentMethod);
|
||||
const billingResult = await fillPlusBillingAddress(payload);
|
||||
|
||||
if (payload.skipSubmit) {
|
||||
@@ -1117,40 +1469,63 @@ async function selectPlusAddressSuggestion(payload = {}) {
|
||||
|
||||
async function ensurePlusStructuredBillingAddress(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {});
|
||||
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, {
|
||||
overwrite: Boolean(payload.overwriteStructuredAddress),
|
||||
});
|
||||
return {
|
||||
countryText: readCountryText(),
|
||||
structuredAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function clickPlusSubscribe() {
|
||||
async function clickPlusSubscribe(payload = {}) {
|
||||
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
|
||||
if ((payload.ensurePayPalActive || payload.ensurePaymentActive) && !isPaymentMethodActive(paymentMethod)) {
|
||||
await selectPaymentMethod(paymentMethod);
|
||||
}
|
||||
|
||||
const subscribeButton = await waitUntil(() => {
|
||||
const button = findSubscribeButton();
|
||||
return button && isEnabledControl(button) ? button : null;
|
||||
return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null;
|
||||
}, {
|
||||
label: '订阅按钮',
|
||||
intervalMs: 250,
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
|
||||
simulateClick(subscribeButton);
|
||||
await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
|
||||
await humanLikeClick(subscribeButton);
|
||||
return {
|
||||
clicked: true,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPlusCheckoutState() {
|
||||
const structuredAddress = getStructuredAddressFields();
|
||||
async function readChatGptSessionAccessToken() {
|
||||
const sessionResponse = await fetch('/api/auth/session', {
|
||||
credentials: 'include',
|
||||
});
|
||||
const session = await sessionResponse.json().catch(() => ({}));
|
||||
return {
|
||||
session,
|
||||
accessToken: String(session?.accessToken || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectPlusCheckoutState(options = {}) {
|
||||
const structuredAddress = getStructuredAddressFields();
|
||||
const state = {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
countryText: readCountryText(),
|
||||
hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
|
||||
hasGoPay: Boolean(findGoPayPaymentMethodTarget()),
|
||||
paypalCandidates: getPayPalCandidateSummaries(),
|
||||
gopayCandidates: getGoPayCandidateSummaries(),
|
||||
paymentTextPreview: getPaymentTextPreview(),
|
||||
cardFieldsVisible: hasCreditCardFields(),
|
||||
billingFieldsVisible: hasBillingAddressFields(),
|
||||
hasSubscribeButton: Boolean(findSubscribeButton()),
|
||||
checkoutAmountSummary: getCheckoutAmountSummary(),
|
||||
addressFieldValues: {
|
||||
address1: structuredAddress.address1?.value || '',
|
||||
city: structuredAddress.city?.value || '',
|
||||
@@ -1158,5 +1533,11 @@ function inspectPlusCheckoutState() {
|
||||
postalCode: structuredAddress.postalCode?.value || '',
|
||||
},
|
||||
};
|
||||
if (options.includeSession || options.includeAccessToken) {
|
||||
const sessionState = await readChatGptSessionAccessToken();
|
||||
state.session = sessionState.session;
|
||||
state.accessToken = sessionState.accessToken;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
})();
|
||||
|
||||
+2611
-93
File diff suppressed because it is too large
Load Diff
+79
-18
@@ -24,7 +24,7 @@ if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
if (message.step) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
log('已被用户停止。', 'warn', { step: message.step });
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
@@ -191,6 +191,53 @@ async function getGroupByName(origin, token, groupName) {
|
||||
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();
|
||||
}
|
||||
@@ -352,7 +399,7 @@ function extractStateFromAuthUrl(authUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocalhostCallback(rawUrl) {
|
||||
function parseLocalhostCallback(rawUrl, visibleStep = 10) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
@@ -364,7 +411,7 @@ function parseLocalhostCallback(rawUrl) {
|
||||
throw new Error('回调 URL 协议不正确。');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
|
||||
throw new Error('步骤 10 只接受 localhost / 127.0.0.1 回调地址。');
|
||||
throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`);
|
||||
}
|
||||
if (parsed.pathname !== '/auth/callback') {
|
||||
throw new Error('回调 URL 路径必须是 /auth/callback。');
|
||||
@@ -448,16 +495,19 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
const { report = true } = options;
|
||||
const logStep = Number.isInteger(payload?.logStep) ? payload.logStep : 1;
|
||||
const redirectUri = normalizeRedirectUri();
|
||||
const groupName = (payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
|
||||
const groupNames = normalizeSub2ApiGroupNames(payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
const groupName = groupNames[0] || SUB2API_DEFAULT_GROUP_NAME;
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const group = await getGroupByName(origin, token, groupName);
|
||||
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,使用分组 ${group.name}(#${group.id})。`);
|
||||
log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${groupLabel}。`);
|
||||
if (proxy) {
|
||||
log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
} else {
|
||||
@@ -492,6 +542,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
sub2apiSessionId: sessionId,
|
||||
sub2apiOAuthState: oauthState,
|
||||
sub2apiGroupId: group.id,
|
||||
sub2apiGroupIds: groups.map((item) => item.id),
|
||||
sub2apiDraftName: draftName,
|
||||
sub2apiProxyId: proxyId,
|
||||
};
|
||||
@@ -504,7 +555,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
|
||||
async function step9_submitOpenAiCallback(payload = {}) {
|
||||
const visibleStep = Number(payload?.visibleStep) || 10;
|
||||
const callback = parseLocalhostCallback(payload.localhostUrl || '');
|
||||
const callback = parseLocalhostCallback(payload.localhostUrl || '', visibleStep);
|
||||
const backgroundState = await getBackgroundState();
|
||||
const flowEmail = String(backgroundState.email || '').trim();
|
||||
|
||||
@@ -517,9 +568,17 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
const proxySelector = preferredProxyId || proxyPreference;
|
||||
const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector) : null;
|
||||
const proxyId = normalizeProxyId(proxy?.id);
|
||||
const group = payload.sub2apiGroupId
|
||||
? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
|
||||
: await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
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。');
|
||||
@@ -528,11 +587,11 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
|
||||
}
|
||||
|
||||
log(`步骤 ${visibleStep}:正在向 SUB2API 交换 OpenAI 授权码...`);
|
||||
log('正在向 SUB2API 交换 OpenAI 授权码...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
if (proxy) {
|
||||
log(`步骤 ${visibleStep}:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
log(`使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
} else {
|
||||
log(`步骤 ${visibleStep}:未配置 SUB2API 默认代理,本次将不使用代理。`);
|
||||
log('未配置 SUB2API 默认代理,本次将不使用代理。', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
}
|
||||
const exchangeRequestBody = {
|
||||
session_id: sessionId,
|
||||
@@ -551,8 +610,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
const credentials = buildOpenAiCredentials(exchangeData);
|
||||
const extra = buildOpenAiExtra(exchangeData);
|
||||
const resolvedEmail = String(exchangeData?.email || credentials?.email || '').trim();
|
||||
const groupId = Number(group.id);
|
||||
if (!Number.isFinite(groupId) || groupId <= 0) {
|
||||
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
|
||||
@@ -568,7 +629,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
concurrency: SUB2API_DEFAULT_CONCURRENCY,
|
||||
priority: SUB2API_DEFAULT_PRIORITY,
|
||||
rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
|
||||
group_ids: [groupId],
|
||||
group_ids: groupIds,
|
||||
auto_pause_on_expired: true,
|
||||
};
|
||||
if (proxyId) {
|
||||
@@ -579,7 +640,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
createPayload.extra = extra;
|
||||
}
|
||||
|
||||
log(`步骤 ${visibleStep}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`);
|
||||
log(`授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
|
||||
method: 'POST',
|
||||
token,
|
||||
@@ -587,7 +648,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
});
|
||||
|
||||
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
|
||||
log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok');
|
||||
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
reportComplete(visibleStep, {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
|
||||
+18
-5
@@ -262,17 +262,30 @@ function fillSelect(el, value) {
|
||||
log(`已选择 [${el.name || el.id || '未知'}] = ${value}`);
|
||||
}
|
||||
|
||||
function normalizeLogStep(value) {
|
||||
const step = Math.floor(Number(value) || 0);
|
||||
return step > 0 ? step : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
function log(message, level = 'info', options = {}) {
|
||||
const step = normalizeLogStep(options?.step);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'LOG',
|
||||
source: getRuntimeScriptSource(),
|
||||
step: null,
|
||||
payload: { message, level, timestamp: Date.now() },
|
||||
step,
|
||||
payload: {
|
||||
message: String(message || ''),
|
||||
level,
|
||||
timestamp: Date.now(),
|
||||
step,
|
||||
stepKey: String(options?.stepKey || '').trim(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
@@ -305,7 +318,7 @@ function reportReady() {
|
||||
*/
|
||||
function reportComplete(step, data = {}) {
|
||||
console.log(LOG_PREFIX, `步骤 ${step} 已完成`, data);
|
||||
log(`步骤 ${step} 已成功完成`, 'ok');
|
||||
log('已成功完成', 'ok', { step });
|
||||
const message = {
|
||||
type: 'STEP_COMPLETE',
|
||||
source: getRuntimeScriptSource(),
|
||||
@@ -336,7 +349,7 @@ function reportComplete(step, data = {}) {
|
||||
*/
|
||||
function reportError(step, errorMessage) {
|
||||
console.error(LOG_PREFIX, `步骤 ${step} 失败: ${errorMessage}`);
|
||||
log(`步骤 ${step} 失败:${errorMessage}`, 'error');
|
||||
log(`失败:${errorMessage}`, 'error', { step });
|
||||
const message = {
|
||||
type: 'STEP_ERROR',
|
||||
source: getRuntimeScriptSource(),
|
||||
|
||||
+20
-17
@@ -66,7 +66,7 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
|
||||
});
|
||||
if (isStopError(err)) {
|
||||
if (message.step) {
|
||||
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
|
||||
log('已被用户停止。', 'warn', { step: message.step });
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
@@ -88,6 +88,7 @@ async function handleStep(step, payload) {
|
||||
case 1: return await step1_getOAuthLink(payload);
|
||||
case 10:
|
||||
case 12:
|
||||
case 13:
|
||||
return await step9_vpsVerify({ ...(payload || {}), visibleStep: step });
|
||||
default:
|
||||
throw new Error(`vps-panel.js 不处理步骤 ${step}`);
|
||||
@@ -618,7 +619,7 @@ function explainStep10Failure(statusText, sourceKind = 'unknown') {
|
||||
{
|
||||
code: 'oauth_state_mismatch',
|
||||
pattern: /state code error/i,
|
||||
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是步骤 1 已刷新过新的授权链接,但步骤 10 仍提交旧回调。',
|
||||
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是授权链接已刷新,但平台回调验证仍提交旧回调。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_code_exchange_failed',
|
||||
@@ -666,7 +667,7 @@ function explainStep10Failure(statusText, sourceKind = 'unknown') {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
|
||||
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep = 10) {
|
||||
const start = Date.now();
|
||||
let lastDiagnosticsSignature = '';
|
||||
let lastHeartbeatLoggedAt = 0;
|
||||
@@ -681,11 +682,11 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
if (diagnostics.signature !== lastDiagnosticsSignature) {
|
||||
lastDiagnosticsSignature = diagnostics.signature;
|
||||
lastHeartbeatLoggedAt = elapsed;
|
||||
log(`步骤 10:认证状态检测中,${diagnostics.summary}`);
|
||||
log(`认证状态检测中,${diagnostics.summary}`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
console.log(LOG_PREFIX, '[Step 9] status badge diagnostics changed', diagnostics);
|
||||
} else if (elapsed - lastHeartbeatLoggedAt >= 10000) {
|
||||
lastHeartbeatLoggedAt = elapsed;
|
||||
log(`步骤 10:仍在等待认证成功,${diagnostics.summary}`);
|
||||
log(`仍在等待认证成功,${diagnostics.summary}`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
console.log(LOG_PREFIX, '[Step 9] still waiting for success badge', diagnostics);
|
||||
}
|
||||
|
||||
@@ -697,8 +698,9 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
if (callbackSubmittedSignature !== lastCallbackSubmittedSignature) {
|
||||
lastCallbackSubmittedSignature = callbackSubmittedSignature;
|
||||
log(
|
||||
`步骤 10:CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
|
||||
'info'
|
||||
`CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
|
||||
'info',
|
||||
{ step: visibleStep, stepKey: 'platform-verify' }
|
||||
);
|
||||
console.info(LOG_PREFIX, '[Step 9] callback accepted and waiting for auth completion', diagnostics);
|
||||
}
|
||||
@@ -706,7 +708,7 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
|
||||
if (isStep10BrowserSwitchRequiredConflict(diagnostics)) {
|
||||
const browserSwitchMessage = getStep10BrowserSwitchRequiredMessage(diagnostics);
|
||||
log(`步骤 10:${browserSwitchMessage}`, 'error');
|
||||
log(browserSwitchMessage, 'error', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
console.error(LOG_PREFIX, '[Step 9] browser-switch conflict detected', diagnostics);
|
||||
throw new Error(`BROWSER_SWITCH_REQUIRED::${browserSwitchMessage}`);
|
||||
}
|
||||
@@ -723,8 +725,9 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
? diagnostics.pageErrorSummary
|
||||
: diagnostics.failureSummary;
|
||||
log(
|
||||
`步骤 10:同时检测到成功徽标和失败提示,本轮不判定成功。成功徽标:${diagnostics.exactSuccessSummary};失败提示:${failureSummary}`,
|
||||
'warn'
|
||||
`同时检测到成功徽标和失败提示,本轮不判定成功。成功徽标:${diagnostics.exactSuccessSummary};失败提示:${failureSummary}`,
|
||||
'warn',
|
||||
{ step: visibleStep, stepKey: 'platform-verify' }
|
||||
);
|
||||
console.warn(LOG_PREFIX, '[Step 9] success badge is blocked by visible failure', diagnostics);
|
||||
}
|
||||
@@ -1021,7 +1024,7 @@ async function step9_vpsVerify(payload) {
|
||||
throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}。`);
|
||||
}
|
||||
if (!localhostUrl) {
|
||||
log(`步骤 ${visibleStep}:payload 中没有 localhostUrl,正在从状态中读取...`);
|
||||
log('payload 中没有 localhostUrl,正在从状态中读取...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
|
||||
localhostUrl = state.localhostUrl;
|
||||
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
|
||||
@@ -1031,9 +1034,9 @@ async function step9_vpsVerify(payload) {
|
||||
if (!localhostUrl) {
|
||||
throw new Error(`未找到 localhost 回调地址,请先完成步骤 ${confirmStep}。`);
|
||||
}
|
||||
log(`步骤 ${visibleStep}:已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`);
|
||||
log(`已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
|
||||
log(`步骤 ${visibleStep}:正在查找回调地址输入框...`);
|
||||
log('正在查找回调地址输入框...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
|
||||
// Find the callback URL input
|
||||
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
@@ -1050,7 +1053,7 @@ async function step9_vpsVerify(payload) {
|
||||
|
||||
await humanPause(600, 1500);
|
||||
fillInput(urlInput, localhostUrl);
|
||||
log(`步骤 ${visibleStep}:已填写回调地址:${localhostUrl.slice(0, 80)}...`);
|
||||
log(`已填写回调地址:${localhostUrl.slice(0, 80)}...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
|
||||
// Find and click the callback submit button in supported UI languages.
|
||||
const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i;
|
||||
@@ -1071,9 +1074,9 @@ async function step9_vpsVerify(payload) {
|
||||
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log(`步骤 ${visibleStep}:已点击回调提交按钮,正在等待认证结果...`);
|
||||
log('已点击回调提交按钮,正在等待认证结果...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
|
||||
const verifiedStatus = await waitForExactSuccessBadge();
|
||||
log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok');
|
||||
const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep);
|
||||
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
|
||||
reportComplete(visibleStep, { localhostUrl, verifiedStatus });
|
||||
}
|
||||
|
||||
@@ -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