From 1b0f4bb17489ec5c010f03075ab55c5137a01800 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 11 May 2026 16:41:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9=20Duck=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E9=82=AE=E7=AE=B1=E7=9A=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=9F=BA=E7=BA=BF=E6=94=AF=E6=8C=81=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=82=AE=E7=AE=B1=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/generated-email-helpers.js | 21 +- content/duck-mail.js | 114 +++++++++-- .../background-generated-email-module.test.js | 58 ++++++ tests/duck-mail-content.test.js | 187 ++++++++++++++++++ 4 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 tests/duck-mail-content.test.js diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js index 55e5566..c57f114 100644 --- a/background/generated-email-helpers.js +++ b/background/generated-email-helpers.js @@ -166,9 +166,16 @@ return address; } + function normalizeEmailForComparison(value) { + return String(value || '').trim().toLowerCase(); + } + async function fetchDuckEmail(options = {}) { throwIfStopped(); - const { generateNew = true } = options; + const { + generateNew = true, + previousEmail = '', + } = options; await addLog(`Duck 邮箱:正在打开自动填充设置(${generateNew ? '生成新地址' : '复用当前地址'})...`); await reuseOrCreateTab('duck-mail', DUCK_AUTOFILL_URL); @@ -176,7 +183,10 @@ const result = await sendToContentScript('duck-mail', { type: 'FETCH_DUCK_EMAIL', source: 'background', - payload: { generateNew }, + payload: { + generateNew, + previousEmail: normalizeEmailForComparison(previousEmail), + }, }); if (result?.error) { @@ -301,7 +311,12 @@ if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) { return fetchCloudflareTempEmailAddress(mergedState, options); } - return fetchDuckEmail(options); + return fetchDuckEmail({ + ...options, + previousEmail: options.previousEmail !== undefined + ? options.previousEmail + : mergedState.email, + }); } return { diff --git a/content/duck-mail.js b/content/duck-mail.js index 3562bad..8972be4 100644 --- a/content/duck-mail.js +++ b/content/duck-mail.js @@ -6,9 +6,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type !== 'FETCH_DUCK_EMAIL') return; resetStopState(); - fetchDuckEmail(message.payload).then(result => { + fetchDuckEmail(message.payload).then((result) => { sendResponse(result); - }).catch(err => { + }).catch((err) => { if (isStopError(err)) { log('Duck 邮箱:已被用户停止。', 'warn'); sendResponse({ stopped: true, error: err.message }); @@ -21,7 +21,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); async function fetchDuckEmail(payload = {}) { - const { generateNew = true } = payload; + const { + generateNew = true, + previousEmail = '', + } = payload; log(`Duck 邮箱:正在${generateNew ? '生成' : '读取'}私有地址...`); @@ -31,8 +34,30 @@ async function fetchDuckEmail(payload = {}) { ); const GENERATE_BUTTON_PATTERN = /generate\s+private\s+duck\s+address|new\s+private\s+duck\s+address|generate\s+new|new\s+address|生成.*duck.*地址|生成.*私有.*地址|生成.*地址|新.*地址/i; + const DUCK_EMAIL_PATTERN = /([a-z0-9._%+-]+@duck\.com)/i; + const ADDRESS_VALUE_SELECTORS = [ + 'input.AutofillSettingsPanel__PrivateDuckAddressValue', + 'input[class*="PrivateDuckAddressValue"]', + 'input[data-testid*="PrivateDuckAddressValue"]', + 'input[value*="@duck.com" i]', + ]; - const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue'); + const normalizeDuckEmail = (value) => { + const match = String(value || '').trim().match(DUCK_EMAIL_PATTERN); + return match ? match[1].toLowerCase() : ''; + }; + const getAddressInputs = () => { + const seen = new Set(); + return ADDRESS_VALUE_SELECTORS + .flatMap((selector) => Array.from(document.querySelectorAll(selector) || [])) + .filter((element) => { + if (!element || seen.has(element)) { + return false; + } + seen.add(element); + return true; + }); + }; const getGeneratorButton = () => { const direct = document.querySelector('button.AutofillSettingsPanel__GeneratorButton'); if (direct) return direct; @@ -45,7 +70,7 @@ async function fetchDuckEmail(payload = {}) { '[role="button"]', 'button', ]; - const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))); + const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector) || [])); return candidates.find((btn) => { const text = [ btn.textContent, @@ -60,22 +85,57 @@ async function fetchDuckEmail(payload = {}) { }) || null; }; const readEmail = () => { - const value = getAddressInput()?.value?.trim() || ''; - return value.includes('@duck.com') ? value : ''; + for (const input of getAddressInputs()) { + const candidates = [ + input?.value, + input?.getAttribute?.('value'), + input?.textContent, + input?.innerText, + input?.getAttribute?.('aria-label'), + input?.getAttribute?.('title'), + ]; + for (const candidate of candidates) { + const email = normalizeDuckEmail(candidate); + if (email) { + return email; + } + } + } + return ''; }; + const waitForVisibleEmail = async (attemptLimit = 12) => { + for (let i = 0; i < attemptLimit; i++) { + const visibleEmail = readEmail(); + if (visibleEmail) { + return visibleEmail; + } + await sleep(150); + } + return ''; + }; + const waitForEmailValue = async (previousValues = []) => { + const blockedValues = new Set( + (Array.isArray(previousValues) ? previousValues : [previousValues]) + .map((value) => normalizeDuckEmail(value)) + .filter(Boolean) + ); - const waitForEmailValue = async (previousValue = '') => { for (let i = 0; i < 100; i++) { const nextValue = readEmail(); - if (nextValue && nextValue !== previousValue) { + if (nextValue && !blockedValues.has(nextValue)) { return nextValue; } await sleep(150); } - throw new Error('等待 Duck 地址出现超时。'); + throw new Error('等待 Duck 地址变化超时。'); }; - const currentEmail = readEmail(); + const fallbackPreviousEmail = normalizeDuckEmail(previousEmail); + let currentEmail = readEmail(); + if (!currentEmail) { + currentEmail = await waitForVisibleEmail(generateNew ? 12 : 20); + } + if (currentEmail && !generateNew) { log(`Duck 邮箱:已发现现有地址 ${currentEmail}`); return { email: currentEmail, generated: false }; @@ -84,35 +144,47 @@ async function fetchDuckEmail(payload = {}) { const generatorButton = getGeneratorButton(); if (!generatorButton) { if (generateNew) { - throw new Error('未找到“生成新 Duck 地址”按钮(可能是页面文案/语言变化、未登录或页面结构更新)。'); + throw new Error('未找到“生成新 Duck 地址”按钮(可能是页面结构、文案或登录状态发生变化)。'); } if (currentEmail) { log(`Duck 邮箱:未找到生成按钮,复用现有地址 ${currentEmail}`, 'warn'); return { email: currentEmail, generated: false }; } - throw new Error('未找到“生成 Duck 私有地址”按钮。'); + throw new Error('未找到 Duck 私有地址生成按钮。'); + } + + const comparisonEmails = [currentEmail, fallbackPreviousEmail].filter(Boolean); + if (!currentEmail && fallbackPreviousEmail) { + log(`Duck 邮箱:当前地址尚未显示,改用上次地址 ${fallbackPreviousEmail} 作为对比基线。`, 'warn'); } for (let attempt = 1; attempt <= 2; attempt++) { await humanPause(500, 1300); - await window.CodexOperationDelay.performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'click', label: 'duck-generate-address' }, async () => { - if (typeof simulateClick === 'function') { - simulateClick(generatorButton); - } else { - generatorButton.click(); + await window.CodexOperationDelay.performOperationWithDelay( + { + stepKey: 'fetch-signup-code', + kind: 'click', + label: 'duck-generate-address', + }, + async () => { + if (typeof simulateClick === 'function') { + simulateClick(generatorButton); + } else { + generatorButton.click(); + } } - }); + ); log(`Duck 邮箱:已点击“生成 Duck 私有地址”按钮(${attempt}/2)`); try { - const nextEmail = await waitForEmailValue(currentEmail); + const nextEmail = await waitForEmailValue(comparisonEmails); log(`Duck 邮箱:地址已就绪 ${nextEmail}`, 'ok'); return { email: nextEmail, generated: true }; } catch (err) { if (attempt >= 2) { throw err; } - log('Duck 邮箱:首次生成后地址未变化,准备重试一次...', 'warn'); + log('Duck 邮箱:首次生成后地址未变化,准备再试一次...', 'warn'); await sleep(800); } } diff --git a/tests/background-generated-email-module.test.js b/tests/background-generated-email-module.test.js index 0934923..ef7f59d 100644 --- a/tests/background-generated-email-module.test.js +++ b/tests/background-generated-email-module.test.js @@ -78,6 +78,64 @@ test('generated email helper falls back to normal generator when 2925 is in rece ]); }); +test('generated email helper forwards the previous email to Duck generation as a comparison baseline', async () => { + const api = loadGeneratedEmailHelpersApi(); + const requests = []; + + const helpers = api.createGeneratedEmailHelpers({ + addLog: async () => {}, + buildGeneratedAliasEmail: () => { + throw new Error('should not build alias'); + }, + buildCloudflareTempEmailHeaders: () => ({}), + CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email', + DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email', + fetch: async () => ({ ok: true, text: async () => '{}' }), + fetchIcloudHideMyEmail: async () => { + throw new Error('should not use icloud generator'); + }, + getCloudflareTempEmailAddressFromResponse: () => '', + getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }), + getState: async () => ({ + email: 'Previous@Duck.com', + emailGenerator: 'duck', + mailProvider: 'gmail', + }), + ensureMail2925AccountForFlow: async () => { + throw new Error('should not allocate 2925 account'); + }, + joinCloudflareTempEmailUrl: () => '', + normalizeCloudflareDomain: () => '', + normalizeCloudflareTempEmailAddress: () => '', + normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(), + isGeneratedAliasProvider: () => false, + reuseOrCreateTab: async () => {}, + sendToContentScript: async (_source, message) => { + requests.push(message); + return { email: 'fresh@duck.com', generated: true }; + }, + setEmailState: async () => {}, + throwIfStopped: () => {}, + }); + + const email = await helpers.fetchGeneratedEmail({ + email: 'Previous@Duck.com', + emailGenerator: 'duck', + mailProvider: 'gmail', + }, { + generator: 'duck', + generateNew: true, + }); + + assert.equal(email, 'fresh@duck.com'); + assert.equal(requests.length, 1); + assert.equal(requests[0].type, 'FETCH_DUCK_EMAIL'); + assert.deepEqual(requests[0].payload, { + generateNew: true, + previousEmail: 'previous@duck.com', + }); +}); + test('generated email helper can read the requested address from custom email pool', async () => { const api = loadGeneratedEmailHelpersApi(); const events = []; diff --git a/tests/duck-mail-content.test.js b/tests/duck-mail-content.test.js new file mode 100644 index 0000000..aadd14a --- /dev/null +++ b/tests/duck-mail-content.test.js @@ -0,0 +1,187 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/duck-mail.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createInput(initialValue = '') { + return { + value: initialValue, + textContent: '', + innerText: '', + getAttribute(name) { + if (name === 'value') { + return this.value; + } + return ''; + }, + }; +} + +function createButton(onClick) { + return { + clickCount: 0, + textContent: 'Generate Private Duck Address', + getAttribute() { + return ''; + }, + click() { + this.clickCount += 1; + if (typeof onClick === 'function') { + onClick(); + } + }, + }; +} + +function createDocument(input, button) { + return { + querySelector(selector) { + if (selector === 'input.AutofillSettingsPanel__PrivateDuckAddressValue') { + return input; + } + if (selector === 'button.AutofillSettingsPanel__GeneratorButton') { + return button; + } + return null; + }, + querySelectorAll(selector) { + switch (selector) { + case 'input.AutofillSettingsPanel__PrivateDuckAddressValue': + case 'input[class*="PrivateDuckAddressValue"]': + return input ? [input] : []; + case 'input[data-testid*="PrivateDuckAddressValue"]': + return []; + case 'input[value*="@duck.com" i]': + return input?.value?.includes('@duck.com') ? [input] : []; + case 'button.AutofillSettingsPanel__GeneratorButton': + return button ? [button] : []; + default: + return []; + } + }, + }; +} + +function createApi({ document, sleep, log = () => {} }) { + const bundle = [extractFunction('fetchDuckEmail')].join('\n'); + return new Function('window', 'document', 'waitForElement', 'humanPause', 'sleep', 'log', ` +${bundle} +return { fetchDuckEmail }; +`)( + { + CodexOperationDelay: { + async performOperationWithDelay(_meta, fn) { + return fn(); + }, + }, + }, + document, + async () => true, + async () => {}, + sleep, + log + ); +} + +test('fetchDuckEmail waits for the existing page address to hydrate before accepting a new Duck email', async () => { + const input = createInput(''); + const button = createButton(() => {}); + let sleepCount = 0; + + const api = createApi({ + document: createDocument(input, button), + sleep: async () => { + sleepCount += 1; + if (sleepCount === 1) { + input.value = 'existing@duck.com'; + } else if (sleepCount === 2) { + input.value = 'fresh@duck.com'; + } + }, + }); + + const result = await api.fetchDuckEmail({ generateNew: true }); + + assert.deepEqual(result, { + email: 'fresh@duck.com', + generated: true, + }); + assert.equal(button.clickCount, 1); +}); + +test('fetchDuckEmail falls back to the previous Duck email when the page baseline never becomes visible', async () => { + const input = createInput(''); + const button = createButton(() => { + input.value = 'previous@duck.com'; + }); + let sleepCount = 0; + + const api = createApi({ + document: createDocument(input, button), + sleep: async () => { + sleepCount += 1; + if (sleepCount === 13) { + input.value = 'fresh@duck.com'; + } + }, + }); + + const result = await api.fetchDuckEmail({ + generateNew: true, + previousEmail: 'previous@duck.com', + }); + + assert.deepEqual(result, { + email: 'fresh@duck.com', + generated: true, + }); + assert.equal(button.clickCount, 1); +});