feat(gopay): support GoPay Plus checkout flow

This commit is contained in:
朴圣佑
2026-05-01 02:02:33 +08:00
committed by QLHazyCoder
parent 2eb913e00b
commit d851cc4d36
24 changed files with 3798 additions and 162 deletions
+725
View File
@@ -0,0 +1,725 @@
// 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() {
await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 });
await sleep(800);
}
function isVisibleElement(el) {
if (!el) return false;
let node = el;
while (node && node.nodeType === 1) {
if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) {
return false;
}
const nodeStyle = window.getComputedStyle(node);
if (
nodeStyle.display === 'none'
|| nodeStyle.visibility === 'hidden'
|| nodeStyle.visibility === 'collapse'
|| Number(nodeStyle.opacity) === 0
) {
return false;
}
node = node.parentElement;
}
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(rect.width) > 0
&& Number(rect.height) > 0;
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getActionText(el) {
return normalizeText([
el?.textContent,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.id,
].filter(Boolean).join(' '));
}
function getVisibleControls(selector) {
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
}
function isEnabledControl(el) {
return Boolean(el)
&& !el.disabled
&& el.getAttribute?.('aria-disabled') !== 'true';
}
function 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;
}
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/i.test(text)
|| /pin-web-client\.gopayapi\.com|\/auth\/pin/i.test(location.href || '');
}
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() {
const input = findInputByPatterns([
/otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa|pin-input-field/i,
/验证码|短信|代码/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
if (isGoPayOtpPageText()) {
return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate)) || null;
}
return null;
}
function getGoPayPinInputs() {
return getVisibleTextInputs().filter((candidate) => {
if (isCountrySearchInput(candidate)) return false;
const text = getCombinedElementText(candidate);
const maxLength = Number(candidate.getAttribute?.('maxlength') || candidate.maxLength || 0);
return /pin|password|passcode|security|sandi|pin-input/i.test(text)
|| candidate.getAttribute?.('data-testid')?.startsWith?.('pin-input')
|| (isGoPayPinPageText() && maxLength === 1);
});
}
function findPinInput() {
if (isGoPayOtpPageText()) {
return null;
}
const input = findInputByPatterns([
/pin|password|passcode|security|sandi|pin-input/i,
/密码|支付密码/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
return getGoPayPinInputs()[0]
|| 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|berikut|kirim|bayar|konfirmasi|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;
const otpInputs = getVisibleTextInputs()
.filter((input) => {
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),
};
}
+211 -51
View File
@@ -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':
@@ -291,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;
}
@@ -367,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;
}
}
@@ -387,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;
@@ -409,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]',
@@ -428,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();
@@ -436,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;
}
@@ -464,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();
@@ -476,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))
@@ -499,26 +569,67 @@ 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 登录会话...');
@@ -533,6 +644,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',
@@ -540,7 +653,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(() => ({}));
@@ -550,17 +663,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;
}
@@ -568,31 +681,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,
};
}
@@ -660,14 +791,14 @@ function hasBillingAddressFields() {
});
}
function hasSelectedPayPalControl() {
const paypalPattern = /paypal/i;
const candidates = getPayPalSearchCandidates();
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;
if (!config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current)))) continue;
const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || '';
if (
current.checked === true
@@ -684,15 +815,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);
@@ -700,6 +847,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([
@@ -820,6 +971,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', '韩国'],
@@ -834,6 +986,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;
@@ -1230,7 +1386,8 @@ async function humanLikeClick(el) {
async function fillPlusBillingAndSubmit(payload = {}) {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
await selectPaymentMethod(paymentMethod);
const billingResult = await fillPlusBillingAddress(payload);
if (payload.skipSubmit) {
@@ -1311,8 +1468,9 @@ async function ensurePlusStructuredBillingAddress(payload = {}) {
}
async function clickPlusSubscribe(payload = {}) {
if (payload.ensurePayPalActive && !isPayPalPaymentMethodActive()) {
await selectPayPalPaymentMethod();
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
if ((payload.ensurePayPalActive || payload.ensurePaymentActive) && !isPaymentMethodActive(paymentMethod)) {
await selectPaymentMethod(paymentMethod);
}
const subscribeButton = await waitUntil(() => {
@@ -1338,7 +1496,9 @@ function inspectPlusCheckoutState() {
readyState: document.readyState,
countryText: readCountryText(),
hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
hasGoPay: Boolean(findGoPayPaymentMethodTarget()),
paypalCandidates: getPayPalCandidateSummaries(),
gopayCandidates: getGoPayCandidateSummaries(),
paymentTextPreview: getPaymentTextPreview(),
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
+144
View File
@@ -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),
};
}