mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-05 15:38:58 +08:00
Import sanitized project structure and GitHub docs
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
// content/cloudflare-temp-email.js — Content script for Cloudflare Temp Email admin page
|
||||
|
||||
const CLOUDFLARE_TEMP_EMAIL_PREFIX = '[MultiPage:cloudflare-temp-email]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
const {
|
||||
combineDistinctTextParts = (parts = []) => parts
|
||||
.map((part) => String(part || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.filter((part, index, values) => values.indexOf(part) === index)
|
||||
.join(' '),
|
||||
extractVerificationCode = () => null,
|
||||
generateReadableLocalPart = () => `mp${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 18),
|
||||
normalizeDomainSuffix = (value) => String(value || '').trim().replace(/^@+/, '').toLowerCase(),
|
||||
parseCloudflareMailboxCredential = () => null,
|
||||
pickRandomSuffix = (options = []) => normalizeDomainSuffix(options[0] || ''),
|
||||
selectVerificationMessage = () => null,
|
||||
} = globalThis.MultiPageCloudflareTempEmail || {};
|
||||
|
||||
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'CREATE_CLOUDFLARE_TEMP_EMAIL' && message.type !== 'POLL_EMAIL') {
|
||||
return;
|
||||
}
|
||||
|
||||
resetStopState();
|
||||
|
||||
const handler = message.type === 'CREATE_CLOUDFLARE_TEMP_EMAIL'
|
||||
? createCloudflareTempEmail
|
||||
: pollCloudflareTempEmail;
|
||||
|
||||
handler(message.step, message.payload || {}).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
if (isStopError(err)) {
|
||||
if (message.step) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
} else {
|
||||
log('Cloudflare Temp Email: Stopped by user.', 'warn');
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.step) {
|
||||
reportError(message.step, err.message);
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function getElementText(el) {
|
||||
return combineDistinctTextParts([
|
||||
el?.innerText,
|
||||
el?.textContent,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.value,
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function findVisibleButton(pattern) {
|
||||
return Array.from(document.querySelectorAll('button'))
|
||||
.filter(isVisible)
|
||||
.find((button) => pattern.test(normalizeText(getElementText(button))));
|
||||
}
|
||||
|
||||
function findVisibleTab(pattern, occurrence = 'first') {
|
||||
const tabs = Array.from(document.querySelectorAll('.n-tabs-tab'))
|
||||
.filter(isVisible)
|
||||
.filter((tab) => pattern.test(normalizeText(getElementText(tab))));
|
||||
|
||||
return occurrence === 'last' ? tabs[tabs.length - 1] || null : tabs[0] || null;
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, timeout, message) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const value = predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function clickTab(pattern, occurrence = 'first', timeout = 10000) {
|
||||
await waitForCondition(
|
||||
() => findVisibleTab(pattern, occurrence),
|
||||
timeout,
|
||||
`Timed out waiting for tab ${pattern}`
|
||||
);
|
||||
|
||||
const tab = findVisibleTab(pattern, occurrence);
|
||||
if (!tab) {
|
||||
throw new Error(`Could not find tab ${pattern}`);
|
||||
}
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(tab);
|
||||
await sleep(400);
|
||||
return tab;
|
||||
}
|
||||
|
||||
function getCreateAddressInput() {
|
||||
return Array.from(document.querySelectorAll('input[placeholder="请输入"]')).find(isVisible) || null;
|
||||
}
|
||||
|
||||
function getPrefixSwitch() {
|
||||
return Array.from(document.querySelectorAll('[role="switch"], .n-switch')).find(isVisible) || null;
|
||||
}
|
||||
|
||||
function getCreateInputGroup() {
|
||||
return getCreateAddressInput()?.closest('.n-input-group') || null;
|
||||
}
|
||||
|
||||
function getCreateDomainSelect() {
|
||||
const group = getCreateInputGroup();
|
||||
|
||||
return Array.from(group?.querySelectorAll('.n-base-selection') || []).find(isVisible)
|
||||
|| Array.from(group?.querySelectorAll('.n-select') || []).find(isVisible)
|
||||
|| null;
|
||||
}
|
||||
|
||||
function getCurrentDomain() {
|
||||
const groupText = normalizeText(getCreateInputGroup()?.textContent || '');
|
||||
const match = groupText.match(/@([a-z0-9.-]+\.[a-z]{2,})/i);
|
||||
return normalizeDomainSuffix(match ? match[1] : '');
|
||||
}
|
||||
|
||||
function getVisibleDomainOptionEntries() {
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
|
||||
for (const option of Array.from(document.querySelectorAll('.n-base-select-option, [role="option"]'))) {
|
||||
if (!isVisible(option)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = normalizeDomainSuffix(getElementText(option));
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
entries.push({ option, value });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function ensureCreateAccountPage() {
|
||||
await clickTab(/^账号$/, 'first');
|
||||
await clickTab(/^创建账号$/);
|
||||
await waitForCondition(
|
||||
() => getCreateAddressInput(),
|
||||
10000,
|
||||
'Cloudflare Temp Email create form did not load.'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureMailPage() {
|
||||
await clickTab(/^邮件$/, 'first');
|
||||
await clickTab(/^邮件$/, 'last');
|
||||
await waitForCondition(
|
||||
() => document.querySelector('input[placeholder="留空查询所有地址"]'),
|
||||
10000,
|
||||
'Cloudflare Temp Email mail page did not load.'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePrefixDisabled() {
|
||||
const prefixSwitch = getPrefixSwitch();
|
||||
if (!prefixSwitch) {
|
||||
log('Cloudflare Temp Email: Prefix switch not present, assuming prefix is already disabled', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefixSwitch.getAttribute('aria-checked') === 'true') {
|
||||
await humanPause(120, 280);
|
||||
simulateClick(prefixSwitch);
|
||||
await waitForCondition(
|
||||
() => prefixSwitch.getAttribute('aria-checked') === 'false',
|
||||
5000,
|
||||
'Cloudflare Temp Email prefix switch did not turn off.'
|
||||
);
|
||||
log('Cloudflare Temp Email: Prefix disabled', 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRandomDomainSuffix() {
|
||||
const domainSelect = await waitForCondition(
|
||||
() => getCreateDomainSelect(),
|
||||
5000,
|
||||
'Could not find the Cloudflare Temp Email suffix selector.'
|
||||
);
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(domainSelect);
|
||||
await sleep(300);
|
||||
|
||||
const optionEntries = await waitForCondition(
|
||||
() => {
|
||||
const entries = getVisibleDomainOptionEntries();
|
||||
return entries.length > 0 ? entries : null;
|
||||
},
|
||||
5000,
|
||||
'Could not find any available Cloudflare Temp Email suffix options.'
|
||||
);
|
||||
|
||||
const suffix = pickRandomSuffix(optionEntries.map((entry) => entry.value));
|
||||
const selectedEntry = optionEntries.find((entry) => entry.value === suffix);
|
||||
|
||||
if (!suffix || !selectedEntry?.option) {
|
||||
throw new Error('Could not choose a Cloudflare Temp Email suffix from the available options.');
|
||||
}
|
||||
|
||||
await humanPause(120, 260);
|
||||
simulateClick(selectedEntry.option);
|
||||
await waitForCondition(
|
||||
() => getCurrentDomain() === suffix,
|
||||
5000,
|
||||
`Cloudflare Temp Email suffix did not switch to ${suffix}.`
|
||||
);
|
||||
|
||||
log(`Cloudflare Temp Email: Selected suffix ${suffix}`, 'info');
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function generateLocalPart() {
|
||||
return generateReadableLocalPart();
|
||||
}
|
||||
|
||||
function findCredentialDialog() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[role="dialog"], .n-dialog, .n-modal, .n-base-modal, .n-card')
|
||||
).find((el) => isVisible(el) && /邮箱地址凭证/.test(getElementText(el)));
|
||||
}
|
||||
|
||||
function extractCredentialToken(root) {
|
||||
if (!root) return '';
|
||||
|
||||
const candidates = [
|
||||
getElementText(root),
|
||||
...Array.from(root.querySelectorAll('textarea, input, pre, code')).map((el) => el.value || el.textContent || ''),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const match = String(candidate || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function waitForCredentialDetails(timeout = 15000) {
|
||||
return waitForCondition(() => {
|
||||
const dialog = findCredentialDialog();
|
||||
const token = extractCredentialToken(dialog);
|
||||
const credential = parseCloudflareMailboxCredential(token);
|
||||
if (!credential?.email) {
|
||||
return null;
|
||||
}
|
||||
return credential;
|
||||
}, timeout, 'Timed out waiting for Cloudflare Temp Email credential dialog.');
|
||||
}
|
||||
|
||||
async function dismissCredentialDialog() {
|
||||
const dialog = findCredentialDialog();
|
||||
if (!dialog) return;
|
||||
|
||||
const closeButton = Array.from(dialog.querySelectorAll('button, .n-base-close'))
|
||||
.find((el) => isVisible(el) && (/关闭|确定|取消/.test(getElementText(el)) || el.classList?.contains('n-base-close')));
|
||||
|
||||
if (closeButton) {
|
||||
simulateClick(closeButton);
|
||||
await sleep(300);
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
async function createCloudflareTempEmail(step, payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
await ensureCreateAccountPage();
|
||||
await ensurePrefixDisabled();
|
||||
await selectRandomDomainSuffix();
|
||||
|
||||
const input = getCreateAddressInput();
|
||||
if (!input) {
|
||||
throw new Error('Could not find the Cloudflare Temp Email address input.');
|
||||
}
|
||||
|
||||
const createButton = findVisibleButton(/^创建新邮箱$/);
|
||||
if (!createButton) {
|
||||
throw new Error('Could not find the "创建新邮箱" button.');
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const localPart = generateLocalPart();
|
||||
fillInput(input, localPart);
|
||||
await humanPause(120, 260);
|
||||
simulateClick(createButton);
|
||||
log(`Cloudflare Temp Email: Creating mailbox attempt ${attempt}`, 'info');
|
||||
|
||||
try {
|
||||
const credential = await waitForCredentialDetails(10000);
|
||||
await dismissCredentialDialog();
|
||||
|
||||
return {
|
||||
...credential,
|
||||
domain: credential.domain || getCurrentDomain(),
|
||||
generated: Boolean(generateNew),
|
||||
};
|
||||
} catch (err) {
|
||||
if (attempt === 3) {
|
||||
throw err;
|
||||
}
|
||||
log(`Cloudflare Temp Email: Mailbox attempt ${attempt} did not complete, retrying`, 'warn');
|
||||
await dismissCredentialDialog().catch(() => {});
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cloudflare Temp Email mailbox creation did not succeed.');
|
||||
}
|
||||
|
||||
function getMessageRows() {
|
||||
return Array.from(document.querySelectorAll('.n-thing')).filter(isVisible);
|
||||
}
|
||||
|
||||
function parseMessageRow(row) {
|
||||
const rowText = row?.innerText || getElementText(row);
|
||||
const subject = normalizeText(
|
||||
row.querySelector('.n-thing-header__title')?.textContent
|
||||
|| row.querySelector('h3, h4, h2')?.textContent
|
||||
|| ''
|
||||
);
|
||||
const messageId = rowText.match(/ID:\s*([^\s]+)/i)?.[1] || null;
|
||||
const timestampText = rowText.match(/\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/)?.[0] || '';
|
||||
const sender = normalizeText(rowText.match(/FROM:\s*([^\n]+)/i)?.[1] || '');
|
||||
const matchedEmail = normalizeEmail(rowText.match(/TO:\s*([^\n]+)/i)?.[1] || '');
|
||||
|
||||
return {
|
||||
combinedText: normalizeText(rowText),
|
||||
emailTimestamp: null,
|
||||
matchedEmail,
|
||||
messageId,
|
||||
row,
|
||||
sender,
|
||||
subject,
|
||||
timestampText,
|
||||
};
|
||||
}
|
||||
|
||||
function findMessageDetailRoot() {
|
||||
const deleteButton = Array.from(document.querySelectorAll('button'))
|
||||
.find((button) => isVisible(button) && /^删除$/.test(normalizeText(getElementText(button))));
|
||||
|
||||
let current = deleteButton?.parentElement || null;
|
||||
while (current && current !== document.body) {
|
||||
const text = getElementText(current);
|
||||
if (/FROM:/i.test(text) && /TO:/i.test(text)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openMessageRow(row, subject) {
|
||||
await humanPause(80, 180);
|
||||
simulateClick(row);
|
||||
await waitForCondition(() => {
|
||||
const detailRoot = findMessageDetailRoot();
|
||||
const detailText = normalizeText(getElementText(detailRoot));
|
||||
if (!detailText) return null;
|
||||
if (!subject || detailText.includes(subject)) {
|
||||
return detailRoot;
|
||||
}
|
||||
return null;
|
||||
}, 4000, `Timed out opening message ${subject || ''}`.trim());
|
||||
}
|
||||
|
||||
function buildMessageDetailText() {
|
||||
return normalizeText(getElementText(findMessageDetailRoot()));
|
||||
}
|
||||
|
||||
async function collectMessagesForTarget(targetEmail) {
|
||||
const normalizedTargetEmail = normalizeEmail(targetEmail);
|
||||
const rows = getMessageRows();
|
||||
const messages = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const message = parseMessageRow(row);
|
||||
if (!message.matchedEmail || message.matchedEmail !== normalizedTargetEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!extractVerificationCode(`${message.subject} ${message.combinedText}`)) {
|
||||
await openMessageRow(row, message.subject);
|
||||
message.combinedText = `${message.combinedText} ${buildMessageDetailText()}`.trim();
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function runMailQuery(targetEmail) {
|
||||
const queryInput = document.querySelector('input[placeholder="留空查询所有地址"]');
|
||||
if (!queryInput) {
|
||||
throw new Error('Could not find the admin mail query input.');
|
||||
}
|
||||
|
||||
const queryButton = findVisibleButton(/^查询$/);
|
||||
if (!queryButton) {
|
||||
throw new Error('Could not find the admin mail query button.');
|
||||
}
|
||||
|
||||
fillInput(queryInput, targetEmail);
|
||||
await humanPause(80, 180);
|
||||
simulateClick(queryButton);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function refreshMailList() {
|
||||
const refreshButton = findVisibleButton(/^刷新$/);
|
||||
if (!refreshButton) {
|
||||
throw new Error('Could not find the admin mail refresh button.');
|
||||
}
|
||||
|
||||
simulateClick(refreshButton);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function pollCloudflareTempEmail(step, payload = {}) {
|
||||
const {
|
||||
filterAfterTimestamp = 0,
|
||||
intervalMs = 3000,
|
||||
maxAttempts = 20,
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
targetEmail = '',
|
||||
} = payload;
|
||||
|
||||
if (!targetEmail) {
|
||||
throw new Error('No target email provided for Cloudflare Temp Email polling.');
|
||||
}
|
||||
|
||||
await ensureMailPage();
|
||||
await runMailQuery(targetEmail);
|
||||
|
||||
log(`Step ${step}: Starting Cloudflare Temp Email poll for ${targetEmail}`, 'info');
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Step ${step}: Polling Cloudflare Temp Email... attempt ${attempt}/${maxAttempts}`, 'info');
|
||||
await refreshMailList();
|
||||
|
||||
const messages = await collectMessagesForTarget(targetEmail);
|
||||
const match = selectVerificationMessage(messages, {
|
||||
filterAfterTimestamp,
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
targetEmail,
|
||||
});
|
||||
|
||||
if (match?.code) {
|
||||
log(
|
||||
`Step ${step}: Code found: ${match.code} (subject: ${(match.subject || '').slice(0, 60)})`,
|
||||
'ok'
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
code: match.code,
|
||||
emailTimestamp: match.emailTimestamp,
|
||||
matchedEmail: match.matchedEmail,
|
||||
messageId: match.messageId,
|
||||
subject: match.subject,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const newerSuffix = Number(filterAfterTimestamp) > 0
|
||||
? ' newer than the previous verification message'
|
||||
: '';
|
||||
|
||||
throw new Error(
|
||||
`No matching verification email${newerSuffix} was found for ${targetEmail} after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings
|
||||
|
||||
console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'FETCH_DUCK_EMAIL') return;
|
||||
|
||||
resetStopState();
|
||||
fetchDuckEmail(message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log('Duck Mail: Stopped by user.', 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function fetchDuckEmail(payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`);
|
||||
|
||||
await waitForElement(
|
||||
'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
|
||||
15000
|
||||
);
|
||||
|
||||
const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue');
|
||||
const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton')
|
||||
|| Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || ''));
|
||||
const readEmail = () => {
|
||||
const value = getAddressInput()?.value?.trim() || '';
|
||||
return value.includes('@duck.com') ? value : '';
|
||||
};
|
||||
|
||||
const waitForEmailValue = async (previousValue = '') => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const nextValue = readEmail();
|
||||
if (nextValue && nextValue !== previousValue) {
|
||||
return nextValue;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('Timed out waiting for Duck address to appear.');
|
||||
};
|
||||
|
||||
const currentEmail = readEmail();
|
||||
if (currentEmail && !generateNew) {
|
||||
log(`Duck Mail: Found existing address ${currentEmail}`);
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
|
||||
await humanPause(500, 1300);
|
||||
const generatorButton = getGeneratorButton();
|
||||
if (!generatorButton) {
|
||||
if (currentEmail) {
|
||||
log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn');
|
||||
return { email: currentEmail, generated: false };
|
||||
}
|
||||
throw new Error('Could not find "Generate Private Duck Address" button.');
|
||||
}
|
||||
|
||||
generatorButton.click();
|
||||
log('Duck Mail: Clicked "Generate Private Duck Address"');
|
||||
|
||||
const nextEmail = await waitForEmailValue(currentEmail);
|
||||
log(`Duck Mail: Ready address ${nextEmail}`, 'ok');
|
||||
return { email: nextEmail, generated: true };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
|
||||
// Injected dynamically on the configured Inbucket host
|
||||
//
|
||||
// Supported page:
|
||||
// - /m/<mailbox>/
|
||||
|
||||
const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
|
||||
|
||||
console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
if (!isTopFrame) {
|
||||
console.log(INBUCKET_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
let seenMailIds = new Set();
|
||||
|
||||
async function loadSeenMailIds() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
|
||||
if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
|
||||
seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
|
||||
console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSeenMailIds() {
|
||||
try {
|
||||
await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
|
||||
} catch (err) {
|
||||
console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
loadSeenMailIds();
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
|
||||
const sender = normalizeText(mail.sender);
|
||||
const subject = normalizeText(mail.subject);
|
||||
const mailbox = normalizeText(mail.mailbox);
|
||||
const combined = normalizeText(mail.combinedText);
|
||||
const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
|
||||
const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
|
||||
const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
|
||||
const code = extractVerificationCode(mail.combinedText);
|
||||
const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
|
||||
|
||||
if (mailboxMatch) return { matched: true, mailboxMatch, code };
|
||||
if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
|
||||
if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
|
||||
|
||||
return { matched: false, mailboxMatch: false, code };
|
||||
}
|
||||
|
||||
function findMailboxEntries() {
|
||||
return document.querySelectorAll('.message-list-entry');
|
||||
}
|
||||
|
||||
function getMailboxEntryId(entry, index = 0) {
|
||||
const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
|
||||
if (explicitId) return explicitId;
|
||||
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
|
||||
return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
|
||||
}
|
||||
|
||||
function parseMailboxEntry(entry, index = 0) {
|
||||
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
|
||||
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
|
||||
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
|
||||
const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
|
||||
|
||||
return {
|
||||
entry,
|
||||
dateText,
|
||||
sender,
|
||||
mailbox: '',
|
||||
subject,
|
||||
unread: entry.classList.contains('unseen'),
|
||||
combinedText,
|
||||
mailId: getMailboxEntryId(entry, index),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentMailboxIds() {
|
||||
const ids = new Set();
|
||||
Array.from(findMailboxEntries()).forEach((entry, index) => {
|
||||
ids.add(getMailboxEntryId(entry, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function refreshMailbox() {
|
||||
const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
|
||||
if (!refreshButton) return;
|
||||
|
||||
simulateClick(refreshButton);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
async function openMailboxEntry(entry) {
|
||||
simulateClick(entry);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
|
||||
return;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentMailboxMessage(step) {
|
||||
try {
|
||||
const deleteButton = await waitForElement('.button-bar button.danger', 5000);
|
||||
simulateClick(deleteButton);
|
||||
log(`Step ${step}: Deleted mailbox message`, 'ok');
|
||||
await sleep(1200);
|
||||
} catch (err) {
|
||||
log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMailboxPollEmail(step, payload) {
|
||||
const {
|
||||
senderFilters = [],
|
||||
subjectFilters = [],
|
||||
maxAttempts = 20,
|
||||
intervalMs = 3000,
|
||||
} = payload || {};
|
||||
|
||||
log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
|
||||
|
||||
try {
|
||||
await waitForElement('.message-list, .message-list-entry', 15000);
|
||||
log(`Step ${step}: Mailbox page loaded`);
|
||||
} catch {
|
||||
throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
|
||||
}
|
||||
|
||||
const existingMailIds = getCurrentMailboxIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshMailbox();
|
||||
}
|
||||
|
||||
const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
const candidates = [];
|
||||
|
||||
for (const mail of entries) {
|
||||
if (!mail.unread) continue;
|
||||
if (seenMailIds.has(mail.mailId)) continue;
|
||||
if (!useFallback && existingMailIds.has(mail.mailId)) continue;
|
||||
|
||||
const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
|
||||
if (!match.matched) continue;
|
||||
|
||||
candidates.push({ ...mail, code: match.code });
|
||||
}
|
||||
|
||||
for (const mail of candidates) {
|
||||
const code = mail.code || extractVerificationCode(mail.combinedText);
|
||||
if (!code) continue;
|
||||
|
||||
await openMailboxEntry(mail.entry);
|
||||
await deleteCurrentMailboxMessage(step);
|
||||
|
||||
seenMailIds.add(mail.mailId);
|
||||
await persistSeenMailIds();
|
||||
|
||||
const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
|
||||
log(
|
||||
`Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
|
||||
'ok'
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
code,
|
||||
emailTimestamp: Date.now(),
|
||||
mailId: mail.mailId,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check the mailbox page manually.'
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
if (!location.pathname.startsWith('/m/')) {
|
||||
throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
|
||||
}
|
||||
return handleMailboxPollEmail(step, payload);
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
@@ -0,0 +1,296 @@
|
||||
// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
|
||||
// Injected on: mail.163.com
|
||||
//
|
||||
// DOM structure:
|
||||
// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 : OpenAI ..."
|
||||
// Sender: .nui-user (e.g., "OpenAI")
|
||||
// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
|
||||
// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
|
||||
|
||||
const MAIL163_PREFIX = '[MultiPage:mail-163]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// Only operate in the top frame
|
||||
if (!isTopFrame) {
|
||||
console.log(MAIL163_PREFIX, 'Skipping child frame');
|
||||
} else {
|
||||
|
||||
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
|
||||
let seenCodes = new Set();
|
||||
|
||||
async function loadSeenCodes() {
|
||||
try {
|
||||
const data = await chrome.storage.session.get('seenCodes');
|
||||
if (data.seenCodes && Array.isArray(data.seenCodes)) {
|
||||
seenCodes = new Set(data.seenCodes);
|
||||
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load previously seen codes on startup
|
||||
loadSeenCodes();
|
||||
|
||||
async function persistSeenCodes() {
|
||||
try {
|
||||
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
|
||||
} catch (err) {
|
||||
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Handler (top frame only)
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Find mail items
|
||||
// ============================================================
|
||||
|
||||
function findMailItems() {
|
||||
return document.querySelectorAll('div[sign="letter"]');
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
const ids = new Set();
|
||||
findMailItems().forEach(item => {
|
||||
const id = item.getAttribute('id') || '';
|
||||
if (id) ids.add(id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
|
||||
|
||||
log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
|
||||
|
||||
// Click inbox in sidebar to ensure we're in inbox view
|
||||
log(`Step ${step}: Waiting for sidebar...`);
|
||||
try {
|
||||
const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
|
||||
inboxLink.click();
|
||||
log(`Step ${step}: Clicked inbox`);
|
||||
} catch {
|
||||
log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
|
||||
}
|
||||
|
||||
// Wait for mail list to appear
|
||||
log(`Step ${step}: Waiting for mail list...`);
|
||||
let items = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
items = findMailItems();
|
||||
if (items.length > 0) break;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
await refreshInbox();
|
||||
await sleep(2000);
|
||||
items = findMailItems();
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('163 Mail list did not load. Make sure inbox is open.');
|
||||
}
|
||||
|
||||
log(`Step ${step}: Mail list loaded, ${items.length} items`);
|
||||
|
||||
// Snapshot existing mail IDs
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
const allItems = findMailItems();
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
|
||||
for (const item of allItems) {
|
||||
const id = item.getAttribute('id') || '';
|
||||
|
||||
if (!useFallback && existingMailIds.has(id)) continue;
|
||||
|
||||
const senderEl = item.querySelector('.nui-user');
|
||||
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
|
||||
|
||||
const subjectEl = item.querySelector('span.da0');
|
||||
const subject = subjectEl ? subjectEl.textContent : '';
|
||||
|
||||
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
|
||||
if (senderMatch || subjectMatch) {
|
||||
const code = extractVerificationCode(subject + ' ' + ariaLabel);
|
||||
if (code && !seenCodes.has(code)) {
|
||||
seenCodes.add(code);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
|
||||
log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
|
||||
|
||||
// Delete this email via right-click menu, WAIT for it to finish before returning
|
||||
await deleteEmail(item, step);
|
||||
// Extra wait to ensure deletion is processed
|
||||
await sleep(1000);
|
||||
|
||||
return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
|
||||
} else if (code && seenCodes.has(code)) {
|
||||
log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check inbox manually.'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Delete Email via Right-Click Menu
|
||||
// ============================================================
|
||||
|
||||
async function deleteEmail(item, step) {
|
||||
try {
|
||||
log(`Step ${step}: Deleting email...`);
|
||||
|
||||
// Strategy 1: Click the trash icon inside the mail item
|
||||
// Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
|
||||
// These icons appear on hover, so we trigger mouseover first
|
||||
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await sleep(300);
|
||||
|
||||
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
|
||||
if (trashIcon) {
|
||||
trashIcon.click();
|
||||
log(`Step ${step}: Clicked trash icon`, 'ok');
|
||||
await sleep(1500);
|
||||
|
||||
// Check if item disappeared (confirm deletion)
|
||||
const stillExists = document.getElementById(item.id);
|
||||
if (!stillExists || stillExists.style.display === 'none') {
|
||||
log(`Step ${step}: Email deleted successfully`);
|
||||
} else {
|
||||
log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Select checkbox then click toolbar delete button
|
||||
log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
|
||||
const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
|
||||
if (checkbox) {
|
||||
checkbox.click();
|
||||
await sleep(300);
|
||||
|
||||
// Click toolbar delete button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('删除')) {
|
||||
btn.closest('.nui-btn').click();
|
||||
log(`Step ${step}: Clicked toolbar delete`, 'ok');
|
||||
await sleep(1500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
|
||||
} catch (err) {
|
||||
log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try toolbar "刷 新" button
|
||||
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
|
||||
for (const btn of toolbarBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '') === '刷新') {
|
||||
btn.closest('.nui-btn').click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: click sidebar "收 信"
|
||||
const shouXinBtns = document.querySelectorAll('.ra0');
|
||||
for (const btn of shouXinBtns) {
|
||||
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
|
||||
btn.click();
|
||||
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(MAIL163_PREFIX, 'Could not find refresh button');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Verification Code Extraction
|
||||
// ============================================================
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
|
||||
if (matchCn) return matchCn[1];
|
||||
|
||||
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
|
||||
if (matchEn) return matchEn[1] || matchEn[2];
|
||||
|
||||
const match6 = text.match(/\b(\d{6})\b/);
|
||||
if (match6) return match6[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
} // end of isTopFrame else block
|
||||
@@ -0,0 +1,147 @@
|
||||
// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
|
||||
// Injected on: mail.qq.com, wx.mail.qq.com
|
||||
// NOTE: all_frames: true
|
||||
//
|
||||
// Strategy for avoiding stale codes:
|
||||
// 1. On poll start, snapshot all existing mail IDs as "old"
|
||||
// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
|
||||
// 3. Only extract codes from NEW items that match sender/subject filters
|
||||
// 4. Never fall back to older matching emails
|
||||
|
||||
const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
|
||||
const isTopFrame = window === window.top;
|
||||
|
||||
console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
|
||||
|
||||
// ============================================================
|
||||
// Message Handler
|
||||
// ============================================================
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
if (!isTopFrame) {
|
||||
sendResponse({ ok: false, reason: 'wrong-frame' });
|
||||
return;
|
||||
}
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true; // async response
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Get all current mail IDs from the list
|
||||
// ============================================================
|
||||
|
||||
function getCurrentMailIds() {
|
||||
const ids = new Set();
|
||||
document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
|
||||
ids.add(item.getAttribute('data-mailid'));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectMailItems() {
|
||||
return Array.from(document.querySelectorAll('.mail-list-page-item[data-mailid]')).map((item) => ({
|
||||
mailId: item.getAttribute('data-mailid') || '',
|
||||
sender: item.querySelector('.cmp-account-nick')?.textContent || '',
|
||||
subject: item.querySelector('.mail-subject')?.textContent || '',
|
||||
digest: item.querySelector('.mail-digest')?.textContent || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Polling
|
||||
// ============================================================
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
|
||||
|
||||
log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
|
||||
|
||||
// Wait for mail list to load
|
||||
try {
|
||||
await waitForElement('.mail-list-page-item', 10000);
|
||||
log(`Step ${step}: Mail list loaded`);
|
||||
} catch {
|
||||
throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
|
||||
}
|
||||
|
||||
// Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
|
||||
|
||||
// Refresh inbox (skip on first attempt, list is fresh)
|
||||
if (attempt > 1) {
|
||||
await refreshInbox();
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
const result = MultiPageQQMail.findNewQQVerificationCode(collectMailItems(), {
|
||||
existingMailIds: [...existingMailIds],
|
||||
senderFilters,
|
||||
subjectFilters,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
log(`Step ${step}: Code found: ${result.code} (${result.source}, subject: ${result.subject.slice(0, 40)})`, 'ok');
|
||||
return { ok: true, code: result.code, emailTimestamp: Date.now(), mailId: result.mailId };
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
|
||||
'Check QQ Mail manually. Email may be delayed or in spam folder.'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Inbox Refresh
|
||||
// ============================================================
|
||||
|
||||
async function refreshInbox() {
|
||||
// Try multiple strategies to refresh the mail list
|
||||
|
||||
// Strategy 1: Click any visible refresh button
|
||||
const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
|
||||
if (refreshBtn) {
|
||||
simulateClick(refreshBtn);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Click inbox in sidebar to reload list
|
||||
const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
|
||||
if (sidebarInbox) {
|
||||
simulateClick(sidebarInbox);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
|
||||
await sleep(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Click the folder name in toolbar
|
||||
const folderName = document.querySelector('.toolbar-folder-name');
|
||||
if (folderName) {
|
||||
simulateClick(folderName);
|
||||
console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// content/relay-firefox.js — Content script for Firefox Relay profile page
|
||||
|
||||
console.log('[MultiPage:relay-firefox] Content script loaded on', location.href);
|
||||
|
||||
const {
|
||||
getNextRelayMaskLabel = (labels = []) => `t${labels.length + 1}`,
|
||||
} = globalThis.MultiPageEmailProvider || {};
|
||||
|
||||
const LABEL_INPUT_SELECTOR = 'input[placeholder="Add account name"], input[aria-label="Edit the label for this mask"]';
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type !== 'CREATE_RELAY_MASK' && message.type !== 'DELETE_RELAY_MASK') return;
|
||||
|
||||
resetStopState();
|
||||
|
||||
const handler = message.type === 'CREATE_RELAY_MASK'
|
||||
? createRelayMask
|
||||
: deleteRelayMask;
|
||||
|
||||
handler(message.payload || {}).then(result => {
|
||||
sendResponse(result);
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log('Relay: Stopped by user.', 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function getElementText(el) {
|
||||
return [
|
||||
el?.innerText,
|
||||
el?.textContent,
|
||||
el?.getAttribute?.('aria-label'),
|
||||
el?.getAttribute?.('title'),
|
||||
el?.getAttribute?.('description'),
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function extractMozmail(text) {
|
||||
const match = String(text || '').match(/[A-Z0-9._%+-]+@mozmail\.com/i);
|
||||
return match ? match[0].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getMaskButtons(root = document) {
|
||||
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
|
||||
}
|
||||
|
||||
function getMaskEmails(root = document) {
|
||||
return Array.from(new Set(
|
||||
getMaskButtons(root)
|
||||
.map((button) => extractMozmail(getElementText(button)))
|
||||
.filter(Boolean)
|
||||
));
|
||||
}
|
||||
|
||||
function getVisibleLabelInputs(root = document) {
|
||||
return Array.from(root.querySelectorAll(LABEL_INPUT_SELECTOR)).filter(isVisible);
|
||||
}
|
||||
|
||||
function getExistingLabels() {
|
||||
return Array.from(document.querySelectorAll(LABEL_INPUT_SELECTOR))
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function findGenerateButton() {
|
||||
return document.querySelector('button[title="Generate new mask"]')
|
||||
|| Array.from(document.querySelectorAll('button')).find((button) => /generate new mask/i.test(getElementText(button)));
|
||||
}
|
||||
|
||||
function findDeleteButton(root) {
|
||||
return Array.from(root.querySelectorAll('button')).find((button) => /^delete$/i.test(getElementText(button).trim()));
|
||||
}
|
||||
|
||||
function getMaskButtonsIn(root) {
|
||||
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
|
||||
}
|
||||
|
||||
function findMaskContainerForButton(button) {
|
||||
let current = button?.parentElement || null;
|
||||
|
||||
while (current && current !== document.body) {
|
||||
const maskButtons = getMaskButtonsIn(current);
|
||||
if (maskButtons.length === 1 && (current.querySelector(LABEL_INPUT_SELECTOR) || findDeleteButton(current))) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return button?.closest('li') || button?.parentElement || null;
|
||||
}
|
||||
|
||||
function findMaskRowByEmail(email) {
|
||||
const normalizedEmail = String(email || '').toLowerCase();
|
||||
const button = getMaskButtons().find((candidate) => extractMozmail(getElementText(candidate)) === normalizedEmail);
|
||||
if (!button) return null;
|
||||
return findMaskContainerForButton(button);
|
||||
}
|
||||
|
||||
async function waitForNewMaskEmail(previousEmails = new Set(), timeout = 15000) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const currentEmails = getMaskEmails();
|
||||
const nextEmail = currentEmails.find((email) => !previousEmails.has(email));
|
||||
if (nextEmail) {
|
||||
return nextEmail;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for a new Relay mask to appear.');
|
||||
}
|
||||
|
||||
async function waitForMaskRow(email, timeout = 10000) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const row = findMaskRowByEmail(email);
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Relay mask row: ${email}`);
|
||||
}
|
||||
|
||||
async function assignRelayLabel(maskRow) {
|
||||
const labelInput = Array.from(maskRow.querySelectorAll(LABEL_INPUT_SELECTOR)).find(isVisible)
|
||||
|| maskRow.querySelector(LABEL_INPUT_SELECTOR);
|
||||
|
||||
if (!labelInput) {
|
||||
throw new Error('Could not find Relay label input for the new mask.');
|
||||
}
|
||||
|
||||
const currentValue = labelInput.value.trim();
|
||||
if (currentValue) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
const nextLabel = getNextRelayMaskLabel(getExistingLabels());
|
||||
|
||||
await humanPause(200, 450);
|
||||
fillInput(labelInput, nextLabel);
|
||||
labelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
labelInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
|
||||
labelInput.blur();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
throwIfStopped();
|
||||
const labels = getExistingLabels();
|
||||
if (labels.includes(nextLabel) || labelInput.value.trim() === nextLabel) {
|
||||
log(`Relay: Assigned label ${nextLabel}`, 'ok');
|
||||
return nextLabel;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(`Relay label ${nextLabel} was not saved.`);
|
||||
}
|
||||
|
||||
async function createRelayMask(payload = {}) {
|
||||
const { generateNew = true } = payload;
|
||||
|
||||
log(`Relay: ${generateNew ? 'Creating' : 'Reading'} mask...`);
|
||||
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
|
||||
|
||||
const previousEmails = new Set(getMaskEmails());
|
||||
if (!generateNew && previousEmails.size > 0) {
|
||||
const email = Array.from(previousEmails)[0];
|
||||
return { email, label: null, generated: false };
|
||||
}
|
||||
|
||||
const generatorButton = findGenerateButton();
|
||||
if (!generatorButton) {
|
||||
throw new Error('Could not find "Generate new mask" button on Firefox Relay.');
|
||||
}
|
||||
|
||||
await humanPause(500, 1200);
|
||||
simulateClick(generatorButton);
|
||||
log('Relay: Clicked "Generate new mask"');
|
||||
|
||||
const email = await waitForNewMaskEmail(previousEmails);
|
||||
const maskRow = await waitForMaskRow(email);
|
||||
const label = await assignRelayLabel(maskRow);
|
||||
|
||||
log(`Relay: Ready mask ${email}`, 'ok');
|
||||
return { email, label, generated: true };
|
||||
}
|
||||
|
||||
function findVisibleDialogDeleteButton() {
|
||||
const dialogButtons = Array.from(document.querySelectorAll('[role="dialog"] button, dialog button, [aria-modal="true"] button'));
|
||||
return dialogButtons.find((button) => isVisible(button) && /delete|confirm|remove/i.test(getElementText(button)));
|
||||
}
|
||||
|
||||
async function waitForMaskRemoval(email, timeout = 15000) {
|
||||
const startedAt = Date.now();
|
||||
const normalizedEmail = String(email || '').toLowerCase();
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
throwIfStopped();
|
||||
const exists = getMaskEmails().includes(normalizedEmail);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Relay mask deletion: ${email}`);
|
||||
}
|
||||
|
||||
async function deleteRelayMask(payload = {}) {
|
||||
const email = String(payload.email || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
throw new Error('No Relay mask email provided for deletion.');
|
||||
}
|
||||
|
||||
log(`Relay: Deleting ${email}...`);
|
||||
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
|
||||
|
||||
const maskRow = await waitForMaskRow(email);
|
||||
const detailsButton = Array.from(maskRow.querySelectorAll('button')).find((button) => /show mask details/i.test(getElementText(button)));
|
||||
if (detailsButton && isVisible(detailsButton)) {
|
||||
await humanPause(150, 300);
|
||||
simulateClick(detailsButton);
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
const deleteButton = findDeleteButton(maskRow);
|
||||
if (!deleteButton) {
|
||||
throw new Error(`Could not find Delete button for Relay mask ${email}.`);
|
||||
}
|
||||
|
||||
await humanPause(200, 400);
|
||||
simulateClick(deleteButton);
|
||||
|
||||
await sleep(300);
|
||||
const confirmButton = findVisibleDialogDeleteButton();
|
||||
if (confirmButton) {
|
||||
await humanPause(150, 300);
|
||||
simulateClick(confirmButton);
|
||||
}
|
||||
|
||||
await waitForMaskRemoval(email);
|
||||
log(`Relay: Deleted ${email}`, 'ok');
|
||||
return { deleted: true, email };
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
|
||||
// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
|
||||
|
||||
console.log('[MultiPage:signup-page] Content script loaded on', location.href);
|
||||
|
||||
// Listen for commands from Background
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'GET_PAGE_STATE') {
|
||||
handleCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch(err => {
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK') {
|
||||
resetStopState();
|
||||
handleCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'STEP8_FIND_AND_CLICK') {
|
||||
log(`Step 8: ${err.message}`, 'error');
|
||||
sendResponse({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'GET_PAGE_STATE':
|
||||
return getCurrentPageState();
|
||||
case 'EXECUTE_STEP':
|
||||
switch (message.step) {
|
||||
case 2: return await step2_clickRegister();
|
||||
case 3: return await step3_fillEmailPassword(message.payload);
|
||||
case 5: return await step5_fillNameBirthday(message.payload);
|
||||
case 6: return await step6_login(message.payload);
|
||||
case 8: return await step8_findAndClick();
|
||||
default: throw new Error(`signup-page.js does not handle step ${message.step}`);
|
||||
}
|
||||
case 'FILL_CODE':
|
||||
// Step 4 = signup code, Step 7 = login code (same handler)
|
||||
return await fillVerificationCode(message.step, message.payload);
|
||||
case 'STEP8_FIND_AND_CLICK':
|
||||
return await step8_findAndClick();
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageState() {
|
||||
const consentButton = findVisibleConsentButton();
|
||||
const hasVisibleContinueButton = Boolean(consentButton);
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
hasVisibleContinueButton,
|
||||
isConsentPage: MultiPageOAuthFlow.isConsentPageState({
|
||||
url: location.href,
|
||||
hasVisibleContinueButton,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 2: Click Register
|
||||
// ============================================================
|
||||
|
||||
async function step2_clickRegister() {
|
||||
log('Step 2: Looking for Register/Sign up button...');
|
||||
|
||||
let registerBtn = null;
|
||||
try {
|
||||
registerBtn = await waitForElementByText(
|
||||
'a, button, [role="button"], [role="link"]',
|
||||
/sign\s*up|register|create\s*account|注册/i,
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
// Some pages may have a direct link
|
||||
try {
|
||||
registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not find Register/Sign up button. ' +
|
||||
'Check auth page DOM in DevTools. URL: ' + location.href
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(450, 1200);
|
||||
reportComplete(2);
|
||||
simulateClick(registerBtn);
|
||||
log('Step 2: Clicked Register button');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 3: Fill Email & Password
|
||||
// ============================================================
|
||||
|
||||
async function step3_fillEmailPassword(payload) {
|
||||
const { email } = payload;
|
||||
if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
|
||||
|
||||
log(`Step 3: Filling email: ${email}`);
|
||||
|
||||
// Find email input
|
||||
let emailInput = null;
|
||||
try {
|
||||
emailInput = await waitForElement(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find email input field on signup page. URL: ' + location.href);
|
||||
}
|
||||
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, email);
|
||||
log('Step 3: Email filled');
|
||||
|
||||
// Check if password field is on the same page
|
||||
let passwordInput = document.querySelector('input[type="password"]');
|
||||
|
||||
if (!passwordInput) {
|
||||
// Need to submit email first to get to password page
|
||||
log('Step 3: No password field yet, submitting email first...');
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(400, 1100);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 3: Submitted email, waiting for password field...');
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
try {
|
||||
passwordInput = await waitForElement('input[type="password"]', 10000);
|
||||
} catch {
|
||||
throw new Error('Could not find password input after submitting email. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
|
||||
await humanPause(600, 1500);
|
||||
fillInput(passwordInput, payload.password);
|
||||
log('Step 3: Password filled');
|
||||
|
||||
// Report complete BEFORE submit, because submit causes page navigation
|
||||
// which kills the content script connection
|
||||
reportComplete(3, { email });
|
||||
|
||||
// Submit the form (page will navigate away after this)
|
||||
await sleep(500);
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(500, 1300);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 3: Form submitted');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fill Verification Code (used by step 4 and step 7)
|
||||
// ============================================================
|
||||
|
||||
async function fillVerificationCode(step, payload) {
|
||||
const { code } = payload;
|
||||
if (!code) throw new Error('No verification code provided.');
|
||||
|
||||
log(`Step ${step}: Filling verification code: ${code}`);
|
||||
|
||||
// Find code input — could be a single input or multiple separate inputs
|
||||
let codeInput = null;
|
||||
try {
|
||||
codeInput = await waitForElement(
|
||||
'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
// Check for multiple single-digit inputs (common pattern)
|
||||
const singleInputs = document.querySelectorAll('input[maxlength="1"]');
|
||||
if (singleInputs.length >= 6) {
|
||||
log(`Step ${step}: Found single-digit code inputs, filling individually...`);
|
||||
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
|
||||
fillInput(singleInputs[i], code[i]);
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(1000);
|
||||
reportComplete(step);
|
||||
return;
|
||||
}
|
||||
throw new Error('Could not find verification code input. URL: ' + location.href);
|
||||
}
|
||||
|
||||
fillInput(codeInput, code);
|
||||
log(`Step ${step}: Code filled`);
|
||||
|
||||
// Report complete BEFORE submit (page may navigate away)
|
||||
reportComplete(step);
|
||||
|
||||
// Submit
|
||||
await sleep(500);
|
||||
const submitBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
|
||||
|
||||
if (submitBtn) {
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log(`Step ${step}: Verification submitted`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 6: Login with registered account (on OAuth auth page)
|
||||
// ============================================================
|
||||
|
||||
async function step6_login(payload) {
|
||||
const { email, password } = payload;
|
||||
if (!email) throw new Error('No email provided for login.');
|
||||
|
||||
log(`Step 6: Logging in with ${email}...`);
|
||||
|
||||
// Wait for email input on the auth page
|
||||
let emailInput = null;
|
||||
try {
|
||||
emailInput = await waitForElement(
|
||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
|
||||
15000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find email input on login page. URL: ' + location.href);
|
||||
}
|
||||
|
||||
await humanPause(500, 1400);
|
||||
fillInput(emailInput, email);
|
||||
log('Step 6: Email filled');
|
||||
|
||||
// Submit email
|
||||
await sleep(500);
|
||||
const submitBtn1 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
||||
if (submitBtn1) {
|
||||
await humanPause(400, 1100);
|
||||
simulateClick(submitBtn1);
|
||||
log('Step 6: Submitted email');
|
||||
}
|
||||
|
||||
const passwordInput = await waitForLoginPasswordField();
|
||||
if (passwordInput) {
|
||||
log('Step 6: Password field found, filling password...');
|
||||
await humanPause(550, 1450);
|
||||
fillInput(passwordInput, password);
|
||||
|
||||
await sleep(500);
|
||||
const submitBtn2 = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
|
||||
// Report complete BEFORE submit in case page navigates
|
||||
reportComplete(6, { needsOTP: true });
|
||||
|
||||
if (submitBtn2) {
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn2);
|
||||
log('Step 6: Submitted password, may need verification code (step 7)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No password field — OTP flow
|
||||
log('Step 6: No password field. OTP flow or auto-redirect.');
|
||||
reportComplete(6, { needsOTP: true });
|
||||
}
|
||||
|
||||
async function waitForLoginPasswordField(timeout = 25000) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
|
||||
const passwordInput = findVisiblePasswordInput();
|
||||
if (passwordInput) {
|
||||
return passwordInput;
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
|
||||
return null;
|
||||
}
|
||||
|
||||
function findVisiblePasswordInput() {
|
||||
const inputs = document.querySelectorAll('input[type="password"]');
|
||||
for (const input of inputs) {
|
||||
if (isElementVisible(input)) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isElementVisible(el) {
|
||||
if (!el) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 8: Find "继续" on OAuth consent page for debugger click
|
||||
// ============================================================
|
||||
// After login + verification, page shows:
|
||||
// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
|
||||
// Background performs the actual click through the debugger Input API.
|
||||
|
||||
async function step8_findAndClick() {
|
||||
log('Step 8: Looking for OAuth consent "继续" button...');
|
||||
|
||||
const continueBtn = await findContinueButton();
|
||||
await waitForButtonEnabled(continueBtn);
|
||||
|
||||
await humanPause(350, 900);
|
||||
continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
continueBtn.focus();
|
||||
await sleep(250);
|
||||
|
||||
const rect = getSerializableRect(continueBtn);
|
||||
log('Step 8: Found "继续" button and prepared debugger click coordinates.');
|
||||
return {
|
||||
rect,
|
||||
buttonText: (continueBtn.textContent || '').trim(),
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function findContinueButton() {
|
||||
const visibleButton = findVisibleConsentButton();
|
||||
if (visibleButton) {
|
||||
return visibleButton;
|
||||
}
|
||||
|
||||
try {
|
||||
return await waitForElement(
|
||||
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
return await waitForElementByText('button', /继续|Continue/, 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findVisibleConsentButton() {
|
||||
const selectorMatches = document.querySelectorAll(
|
||||
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107'
|
||||
);
|
||||
|
||||
for (const button of selectorMatches) {
|
||||
if (isElementVisible(button)) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const button of buttons) {
|
||||
if (!isElementVisible(button)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/继续|Continue/i.test(button.textContent || '')) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForButtonEnabled(button, timeout = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
throwIfStopped();
|
||||
if (isButtonEnabled(button)) return;
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
|
||||
}
|
||||
|
||||
function isButtonEnabled(button) {
|
||||
return Boolean(button)
|
||||
&& !button.disabled
|
||||
&& button.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getSerializableRect(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) {
|
||||
throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 5: Fill Name & Birthday / Age
|
||||
// ============================================================
|
||||
|
||||
async function step5_fillNameBirthday(payload) {
|
||||
const { firstName, lastName, age, year, month, day } = payload;
|
||||
if (!firstName || !lastName) throw new Error('No name data provided.');
|
||||
|
||||
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
|
||||
const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
|
||||
if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
|
||||
throw new Error('No birthday or age data provided.');
|
||||
}
|
||||
|
||||
const fullName = `${firstName} ${lastName}`;
|
||||
log(`Step 5: Filling name: ${fullName}`);
|
||||
|
||||
// Actual DOM structure:
|
||||
// - Full name: <input name="name" placeholder="全名" type="text">
|
||||
// - Birthday: React Aria DateField or hidden input[name="birthday"]
|
||||
// - Age: <input name="age" type="text|number">
|
||||
|
||||
// --- Full Name (single field, not first+last) ---
|
||||
let nameInput = null;
|
||||
try {
|
||||
nameInput = await waitForElement(
|
||||
'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
|
||||
10000
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Could not find name input. URL: ' + location.href);
|
||||
}
|
||||
await humanPause(500, 1300);
|
||||
fillInput(nameInput, fullName);
|
||||
log(`Step 5: Name filled: ${fullName}`);
|
||||
|
||||
let birthdayMode = false;
|
||||
let ageInput = null;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
|
||||
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
|
||||
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
ageInput = document.querySelector('input[name="age"]');
|
||||
|
||||
// Some pages include a hidden birthday input even though the real UI is "age".
|
||||
// In that case we must prioritize filling age to satisfy required validation.
|
||||
if (ageInput) break;
|
||||
|
||||
if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) {
|
||||
birthdayMode = true;
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (birthdayMode) {
|
||||
if (!hasBirthdayData) {
|
||||
throw new Error('Birthday field detected, but no birthday data provided.');
|
||||
}
|
||||
|
||||
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
|
||||
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
|
||||
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
|
||||
|
||||
if (yearSpinner && monthSpinner && daySpinner) {
|
||||
log('Step 5: Birthday fields detected, filling birthday...');
|
||||
|
||||
async function setSpinButton(el, value) {
|
||||
el.focus();
|
||||
await sleep(100);
|
||||
document.execCommand('selectAll', false, null);
|
||||
await sleep(50);
|
||||
|
||||
const valueStr = String(value);
|
||||
for (const char of valueStr) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
|
||||
el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
|
||||
el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
|
||||
el.blur();
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
await humanPause(450, 1100);
|
||||
await setSpinButton(yearSpinner, year);
|
||||
await humanPause(250, 650);
|
||||
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
|
||||
await humanPause(250, 650);
|
||||
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
|
||||
log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
if (hiddenBirthday) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
hiddenBirthday.value = dateStr;
|
||||
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
log(`Step 5: Hidden birthday input set: ${dateStr}`);
|
||||
}
|
||||
} else if (ageInput) {
|
||||
if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
|
||||
throw new Error('Age field detected, but no age data provided.');
|
||||
}
|
||||
await humanPause(500, 1300);
|
||||
fillInput(ageInput, String(resolvedAge));
|
||||
log(`Step 5: Age filled: ${resolvedAge}`);
|
||||
|
||||
// Some age-mode pages still submit a hidden birthday field.
|
||||
// Keep it aligned with generated data so backend validation won't reject.
|
||||
const hiddenBirthday = document.querySelector('input[name="birthday"]');
|
||||
if (hiddenBirthday && hasBirthdayData) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
hiddenBirthday.value = dateStr;
|
||||
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Could not find birthday or age input. URL: ' + location.href);
|
||||
}
|
||||
|
||||
// Click "完成帐户创建" button
|
||||
await sleep(500);
|
||||
const completeBtn = document.querySelector('button[type="submit"]')
|
||||
|| await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
|
||||
|
||||
// Report complete BEFORE submit (page navigates to add-phone after this)
|
||||
reportComplete(5);
|
||||
|
||||
if (completeBtn) {
|
||||
await humanPause(500, 1300);
|
||||
simulateClick(completeBtn);
|
||||
log('Step 5: Clicked "完成帐户创建"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// content/utils.js — Shared utilities for all content scripts
|
||||
|
||||
const SCRIPT_SOURCE = (() => {
|
||||
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
|
||||
const url = location.href;
|
||||
if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page';
|
||||
if (url.includes('mail.qq.com')) return 'qq-mail';
|
||||
if (url.includes('mail.163.com')) return 'mail-163';
|
||||
if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
|
||||
if (url.includes('relay.firefox.com/accounts/profile')) return 'relay-firefox';
|
||||
if (url.includes('mail.cloudflare.com/admin')) return 'cloudflare-temp-email';
|
||||
if (url.includes('chatgpt.com')) return 'chatgpt';
|
||||
// VPS panel — detected dynamically since URL is configurable
|
||||
return 'vps-panel';
|
||||
})();
|
||||
|
||||
const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
|
||||
const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
|
||||
let flowStopped = false;
|
||||
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === 'STOP_FLOW') {
|
||||
flowStopped = true;
|
||||
console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
function resetStopState() {
|
||||
flowStopped = false;
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
const message = typeof error === 'string' ? error : error?.message;
|
||||
return message === STOP_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
function throwIfStopped() {
|
||||
if (flowStopped) {
|
||||
throw new Error(STOP_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a DOM element to appear.
|
||||
* @param {string} selector - CSS selector
|
||||
* @param {number} timeout - Max wait time in ms (default 10000)
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElement(selector, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `Found immediately: ${selector}`);
|
||||
log(`Found element: ${selector}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
|
||||
log(`Waiting for selector: ${selector}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `Found after wait: ${selector}`);
|
||||
log(`Found element: ${selector}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element matching a text pattern among multiple candidates.
|
||||
* @param {string} containerSelector - Selector for candidate elements
|
||||
* @param {RegExp} textPattern - Regex to match against textContent
|
||||
* @param {number} timeout - Max wait time in ms
|
||||
* @returns {Promise<Element>}
|
||||
*/
|
||||
function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfStopped();
|
||||
|
||||
function search() {
|
||||
const candidates = document.querySelectorAll(containerSelector);
|
||||
for (const el of candidates) {
|
||||
if (textPattern.test(el.textContent)) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = search();
|
||||
if (existing) {
|
||||
console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
|
||||
log(`Found element by text: ${textPattern}`);
|
||||
resolve(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
|
||||
log(`Waiting for element with text: ${textPattern}...`);
|
||||
|
||||
let settled = false;
|
||||
let stopTimer = null;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
clearTimeout(stopTimer);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
const el = search();
|
||||
if (el) {
|
||||
cleanup();
|
||||
console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
|
||||
log(`Found element by text: ${textPattern}`);
|
||||
resolve(el);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body || document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
|
||||
console.error(LOG_PREFIX, msg);
|
||||
reject(new Error(msg));
|
||||
}, timeout);
|
||||
|
||||
const pollStop = () => {
|
||||
if (settled) return;
|
||||
if (flowStopped) {
|
||||
cleanup();
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
stopTimer = setTimeout(pollStop, 100);
|
||||
};
|
||||
pollStop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React-compatible form filling.
|
||||
* Sets value via native setter and dispatches input + change events.
|
||||
* @param {HTMLInputElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillInput(el, value) {
|
||||
throwIfStopped();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
|
||||
log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a select element by setting its value and triggering change.
|
||||
* @param {HTMLSelectElement} el
|
||||
* @param {string} value
|
||||
*/
|
||||
function fillSelect(el, value) {
|
||||
throwIfStopped();
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
|
||||
log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log message to Side Panel via Background.
|
||||
* @param {string} message
|
||||
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
|
||||
*/
|
||||
function log(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'LOG',
|
||||
source: SCRIPT_SOURCE,
|
||||
step: null,
|
||||
payload: { message, level, timestamp: Date.now() },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that this content script is loaded and ready.
|
||||
*/
|
||||
function reportReady() {
|
||||
console.log(LOG_PREFIX, 'Content script ready');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_SCRIPT_READY',
|
||||
source: SCRIPT_SOURCE,
|
||||
step: null,
|
||||
payload: {},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report step completion.
|
||||
* @param {number} step
|
||||
* @param {Object} data - Step output data
|
||||
*/
|
||||
function reportComplete(step, data = {}) {
|
||||
console.log(LOG_PREFIX, `Step ${step} completed`, data);
|
||||
log(`Step ${step} completed successfully`, 'ok');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STEP_COMPLETE',
|
||||
source: SCRIPT_SOURCE,
|
||||
step,
|
||||
payload: data,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report step error.
|
||||
* @param {number} step
|
||||
* @param {string} errorMessage
|
||||
*/
|
||||
function reportError(step, errorMessage) {
|
||||
console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
|
||||
log(`Step ${step} failed: ${errorMessage}`, 'error');
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STEP_ERROR',
|
||||
source: SCRIPT_SOURCE,
|
||||
step,
|
||||
payload: {},
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a click with proper event dispatching.
|
||||
* @param {Element} el
|
||||
*/
|
||||
function simulateClick(el) {
|
||||
throwIfStopped();
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
|
||||
log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait a specified number of milliseconds.
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
|
||||
function tick() {
|
||||
if (flowStopped) {
|
||||
reject(new Error(STOP_ERROR_MESSAGE));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start >= ms) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
|
||||
}
|
||||
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
async function humanPause(min = 250, max = 850) {
|
||||
const duration = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
await sleep(duration);
|
||||
}
|
||||
|
||||
// Auto-report ready on load
|
||||
// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
|
||||
const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
|
||||
if (!_isMailChildFrame) {
|
||||
reportReady();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
|
||||
// Injected on: VPS panel (user-configured URL)
|
||||
//
|
||||
// Actual DOM structure (after login click):
|
||||
// <div class="card">
|
||||
// <div class="card-header">
|
||||
// <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
|
||||
// <button class="btn btn-primary"><span>登录</span></button>
|
||||
// </div>
|
||||
// <div class="OAuthPage-module__cardContent___1sXLA">
|
||||
// <div class="OAuthPage-module__authUrlBox___Iu1d4">
|
||||
// <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
|
||||
// <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
|
||||
// <div class="OAuthPage-module__authUrlActions___venPj">
|
||||
// <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
|
||||
// <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div class="OAuthPage-module__callbackSection___8kA31">
|
||||
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
|
||||
|
||||
// Listen for commands from Background
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'EXECUTE_STEP') {
|
||||
resetStopState();
|
||||
handleStep(message.step, message.payload).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
}).catch(err => {
|
||||
if (isStopError(err)) {
|
||||
log(`Step ${message.step}: Stopped by user.`, 'warn');
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
}
|
||||
reportError(message.step, err.message);
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleStep(step, payload) {
|
||||
switch (step) {
|
||||
case 1: return await step1_getOAuthLink();
|
||||
case 9: return await step9_vpsVerify(payload);
|
||||
default:
|
||||
throw new Error(`vps-panel.js does not handle step ${step}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 1: Get OAuth Link
|
||||
// ============================================================
|
||||
|
||||
async function step1_getOAuthLink() {
|
||||
log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
|
||||
|
||||
// The page may start at #/login and auto-redirect to #/oauth.
|
||||
// Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
|
||||
let loginBtn = null;
|
||||
try {
|
||||
// Wait for any card-header containing "Codex" to appear
|
||||
const header = await waitForElementByText('.card-header', /codex/i, 30000);
|
||||
loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
|
||||
log('Step 1: Found Codex OAuth card');
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
|
||||
'Current URL: ' + location.href
|
||||
);
|
||||
}
|
||||
|
||||
if (!loginBtn) {
|
||||
throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
|
||||
}
|
||||
|
||||
// Check if button is disabled (already clicked / loading)
|
||||
if (loginBtn.disabled) {
|
||||
log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
|
||||
} else {
|
||||
await humanPause(500, 1400);
|
||||
simulateClick(loginBtn);
|
||||
log('Step 1: Clicked login button, waiting for auth URL...');
|
||||
}
|
||||
|
||||
// Wait for the auth URL to appear in the specific div
|
||||
let authUrlEl = null;
|
||||
try {
|
||||
authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Auth URL did not appear after clicking login. ' +
|
||||
'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
|
||||
);
|
||||
}
|
||||
|
||||
const oauthUrl = (authUrlEl.textContent || '').trim();
|
||||
if (!oauthUrl || !oauthUrl.startsWith('http')) {
|
||||
throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
|
||||
}
|
||||
|
||||
log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
|
||||
reportComplete(1, { oauthUrl });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Step 9: VPS Verify — paste localhost URL and submit
|
||||
// ============================================================
|
||||
|
||||
async function step9_vpsVerify(payload) {
|
||||
// Get localhostUrl from payload (passed directly by background) or fallback to state
|
||||
let localhostUrl = payload?.localhostUrl;
|
||||
if (!localhostUrl) {
|
||||
log('Step 9: localhostUrl not in payload, fetching from state...');
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
|
||||
localhostUrl = state.localhostUrl;
|
||||
}
|
||||
if (!localhostUrl) {
|
||||
throw new Error('No localhost URL found. Complete step 8 first.');
|
||||
}
|
||||
log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
|
||||
|
||||
log('Step 9: Looking for callback URL input...');
|
||||
|
||||
// Find the callback URL input
|
||||
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
let urlInput = null;
|
||||
try {
|
||||
urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
|
||||
} catch {
|
||||
try {
|
||||
urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(600, 1500);
|
||||
fillInput(urlInput, localhostUrl);
|
||||
log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
|
||||
|
||||
// Find and click "提交回调 URL" button
|
||||
let submitBtn = null;
|
||||
try {
|
||||
submitBtn = await waitForElementByText(
|
||||
'[class*="callbackActions"] button, [class*="callbackSection"] button',
|
||||
/提交/,
|
||||
5000
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
|
||||
} catch {
|
||||
throw new Error('Could not find "提交回调 URL" button. URL: ' + location.href);
|
||||
}
|
||||
}
|
||||
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log('Step 9: Clicked "提交回调 URL", waiting for authentication result...');
|
||||
|
||||
// Wait for "认证成功!" status badge to appear
|
||||
try {
|
||||
await waitForElementByText('.status-badge, [class*="status"]', /认证成功/, 30000);
|
||||
log('Step 9: Authentication successful!', 'ok');
|
||||
} catch {
|
||||
// Check if there's an error message instead
|
||||
const statusEl = document.querySelector('.status-badge, [class*="status"]');
|
||||
const statusText = statusEl ? statusEl.textContent : 'unknown';
|
||||
if (/成功|success/i.test(statusText)) {
|
||||
log('Step 9: Authentication successful!', 'ok');
|
||||
} else {
|
||||
log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
reportComplete(9);
|
||||
}
|
||||
Reference in New Issue
Block a user