diff --git a/README.md b/README.md index dba0351..c025153 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ 2. 在 `2925 账号池` 中添加 `邮箱 / 密码` 3. 在“2925 基邮箱”右侧打开 `号池` 开关后,基邮箱输入框会切成下拉框,只能从 2925 账号池中选择邮箱;关闭开关则回退到原来的手填基邮箱路线 4. 可先点 `使用此账号` 让当前别名基邮箱切到这条 2925 账号,再点 `登录` 手动验证网页邮箱登录态 -5. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会直接使用当前账号自动登录 +5. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会直接使用当前账号自动登录;点击登录后若 `20 秒`内仍未进入收件箱,则会判定当前登录失败 6. 当 Step 4 / Step 8 轮询邮箱时遇到“子邮箱已达上限邮箱”,扩展会记录当前时间;如果还有下一个可用账号,就禁用当前账号 24 小时并自动切换登录;如果没有下一个可用账号,或当前未启用号池模式,则会直接复用现有“手动暂停 / 停止”逻辑终止自动流程 7. 如果你同时开启了 `Auto` 的自动重试,当前尝试结束后会按现有逻辑自动进入下一次尝试,不需要再手动介入 8. `Mail = 2925` 仍然走 Gmail / 2925 共用的别名邮箱链路;实际注册邮箱会基于当前 2925 账号邮箱生成,例如 `name@2925.com -> name123456@2925.com` diff --git a/background.js b/background.js index 38a4150..266dcf7 100644 --- a/background.js +++ b/background.js @@ -5387,6 +5387,7 @@ const mail2925SessionManager = self.MultiPageBackgroundMail2925Session?.createMa findMail2925Account, getMail2925AccountStatus, getState, + isAutoRunLockedState, isMail2925AccountAvailable: self.Mail2925Utils?.isMail2925AccountAvailable, MAIL2925_LIMIT_COOLDOWN_MS, normalizeMail2925Account, diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js index db484a6..2677035 100644 --- a/background/mail-2925-session.js +++ b/background/mail-2925-session.js @@ -14,6 +14,7 @@ normalizeMail2925Accounts, pickMail2925AccountForRun, getState, + isAutoRunLockedState, requestStop, reuseOrCreateTab, sendToMailContentScriptResilient, @@ -60,6 +61,25 @@ return new Error(`${MAIL2925_THREAD_TERMINATED_ERROR_PREFIX}${String(message || '').trim()}`); } + async function stopAutoRunForMail2925LoginFailure(errorMessage = '') { + if (typeof requestStop !== 'function') { + return false; + } + + const state = await getState(); + const autoRunning = typeof isAutoRunLockedState === 'function' + ? isAutoRunLockedState(state) + : Boolean(state?.autoRunning); + if (!autoRunning) { + return false; + } + + await requestStop({ + logMessage: errorMessage || '2925 登录失败,已按手动停止逻辑暂停自动流程。', + }); + return true; + } + function isMail2925LimitReachedError(error) { const message = getErrorMessage(error); return message.startsWith(MAIL2925_LIMIT_ERROR_PREFIX) @@ -352,7 +372,9 @@ injectSource: MAIL2925_INJECT_SOURCE, }); - const result = await sendToMailContentScriptResilient( + let result; + try { + result = await sendToMailContentScriptResilient( getMail2925MailConfig(), { type: 'ENSURE_MAIL2925_SESSION', @@ -365,13 +387,38 @@ }, }, { - timeoutMs: forceRelogin ? 90000 : 45000, - responseTimeoutMs: forceRelogin ? 90000 : 45000, + timeoutMs: forceRelogin ? 30000 : 25000, + responseTimeoutMs: forceRelogin ? 30000 : 25000, maxRecoveryAttempts: 2, } ); + } catch (err) { + const stopped = await stopAutoRunForMail2925LoginFailure( + `2925:${actionLabel}失败(${getErrorMessage(err) || '20 秒内未进入收件箱'}),已按手动停止逻辑暂停自动流程。` + ); + if (stopped) { + throw new Error('流程已被用户停止。'); + } + throw err; + } + + if (!result?.loggedIn) { + const stopped = await stopAutoRunForMail2925LoginFailure( + `2925:${actionLabel}失败(20 秒内未进入收件箱),已按手动停止逻辑暂停自动流程。` + ); + if (stopped) { + throw new Error('流程已被用户停止。'); + } + throw new Error(`2925:${actionLabel}失败,当前页面仍未进入收件箱。`); + } if (result?.error) { + const stopped = await stopAutoRunForMail2925LoginFailure( + `2925:${actionLabel}失败(${result.error}),已按手动停止逻辑暂停自动流程。` + ); + if (stopped) { + throw new Error('流程已被用户停止。'); + } throw new Error(result.error); } if (result?.limitReached) { diff --git a/content/mail-2925.js b/content/mail-2925.js index aa522b8..ffd8993 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -193,6 +193,12 @@ const MAIL2925_AGREEMENT_PATTERNS = [ /服务协议/, /隐私政策/, ]; +const MAIL2925_REMEMBER_LOGIN_PATTERNS = [ + /30天内免登录/, + /免登录/, + /记住登录/, + /保持登录/, +]; function normalizeNodeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); @@ -405,46 +411,102 @@ function findAgreementContainer() { return null; } -function findAgreementCheckbox() { - const agreementContainer = findAgreementContainer(); - if (agreementContainer) { - const checkbox = agreementContainer.querySelector('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'); - if (checkbox) { - return resolveActionTarget(checkbox); - } - const nearbyCheckbox = agreementContainer.parentElement?.querySelector?.('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'); - if (nearbyCheckbox) { - return resolveActionTarget(nearbyCheckbox); +function isAgreementText(text = '') { + const normalizedText = normalizeNodeText(text); + if (!normalizedText || MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(normalizedText))) { + return false; + } + + return /我已阅读并同意/.test(normalizedText) + || (/服务协议/.test(normalizedText) && /隐私政策/.test(normalizedText)); +} + +function getCheckboxContextText(target) { + if (!target) { + return ''; + } + + const textParts = []; + const candidates = [ + target, + target.closest?.('label'), + target.parentElement, + target.parentElement?.parentElement, + target.nextElementSibling, + target.previousElementSibling, + target.closest?.('label, div, span, p, li, form'), + ].filter(Boolean); + + for (const candidate of candidates) { + const text = normalizeNodeText(candidate.innerText || candidate.textContent || ''); + if (text) { + textParts.push(text); } } + return normalizeNodeText(textParts.join(' ')); +} + +function findAgreementCheckbox() { const genericCheckboxes = document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'); for (const checkbox of genericCheckboxes) { const target = resolveActionTarget(checkbox); if (!isVisibleNode(target)) { continue; } - const wrapper = target.closest('label, div, span') || target.parentElement || target; - const text = normalizeNodeText(wrapper?.innerText || wrapper?.textContent || ''); - if (/我已阅读并同意/.test(text) || /服务协议/.test(text) || /隐私政策/.test(text)) { + + const contextText = getCheckboxContextText(target); + if (MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) { + continue; + } + if (isAgreementText(contextText)) { return target; } } + const agreementContainer = findAgreementContainer(); + if (agreementContainer) { + const checkbox = agreementContainer.querySelector('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'); + if (checkbox) { + const target = resolveActionTarget(checkbox); + const contextText = getCheckboxContextText(target); + if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) { + return target; + } + } + const nearbyCheckbox = agreementContainer.parentElement?.querySelector?.('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'); + if (nearbyCheckbox) { + const target = resolveActionTarget(nearbyCheckbox); + const contextText = getCheckboxContextText(target); + if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) { + return target; + } + } + } + return null; } async function ensureAgreementChecked() { - const agreementCheckbox = findAgreementCheckbox(); - if (!agreementCheckbox) { + const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox')) + .map((checkbox) => resolveActionTarget(checkbox)) + .filter((target, index, list) => target && isVisibleNode(target) && list.indexOf(target) === index); + + if (!checkboxes.length) { return false; } - if (isCheckboxChecked(agreementCheckbox)) { - return true; + + let changed = false; + for (const checkbox of checkboxes) { + if (isCheckboxChecked(checkbox)) { + continue; + } + simulateClick(checkbox); + changed = true; + await sleep(120); } - simulateClick(agreementCheckbox); - await sleep(150); - return isCheckboxChecked(agreementCheckbox); + + return changed || checkboxes.every((checkbox) => isCheckboxChecked(checkbox)); } function detectMail2925ViewState() { @@ -832,7 +894,7 @@ async function ensureMail2925Session(payload = {}) { await sleep(200); simulateClick(loginButton); - const finalState = await waitForMail2925View('mailbox', 60000); + const finalState = await waitForMail2925View('mailbox', 20000); if (finalState.view !== 'mailbox') { throw new Error('2925:提交账号密码后未进入收件箱。'); } diff --git a/tests/background-mail2925-session-module.test.js b/tests/background-mail2925-session-module.test.js index a00319d..c0322fa 100644 --- a/tests/background-mail2925-session-module.test.js +++ b/tests/background-mail2925-session-module.test.js @@ -159,3 +159,69 @@ test('handleMail2925LimitReachedError requests stop when no next mail2925 accoun assert.equal(events.stopCalls.length, 1); assert.match(events.stopCalls[0].logMessage, /没有可切换的下一个账号/); }); + +test('ensureMail2925MailboxSession requests stop when auto run is active and login does not reach mailbox', async () => { + const source = fs.readFileSync('background/mail-2925-session.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope); + + let currentState = { + autoRunning: true, + autoRunPhase: 'running', + mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([ + { id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 }, + ]), + currentMail2925AccountId: 'acc-1', + }; + const events = { + stopCalls: [], + }; + + const manager = api.createMail2925SessionManager({ + addLog: async () => {}, + broadcastDataUpdate: () => {}, + chrome: { + cookies: { + getAll: async () => [], + remove: async () => ({ ok: true }), + }, + browsingData: { + removeCookies: async () => {}, + }, + }, + findMail2925Account: mail2925Utils.findMail2925Account, + getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus, + getState: async () => currentState, + isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', + isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable, + MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS, + normalizeMail2925Account: mail2925Utils.normalizeMail2925Account, + normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts, + pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun, + requestStop: async (options = {}) => { + events.stopCalls.push(options); + }, + reuseOrCreateTab: async () => 1, + sendToMailContentScriptResilient: async () => ({ loggedIn: false }), + setPersistentSettings: async (payload) => { + currentState = { ...currentState, ...payload }; + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + throwIfStopped: () => {}, + upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList, + }); + + await assert.rejects( + () => manager.ensureMail2925MailboxSession({ + accountId: 'acc-1', + forceRelogin: true, + actionLabel: '步骤 4:确认 2925 邮箱登录态', + }), + /流程已被用户停止。/ + ); + + assert.equal(events.stopCalls.length, 1); + assert.match(events.stopCalls[0].logMessage, /20 秒内未进入收件箱/); +}); diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index bbc9900..098fab8 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -4,6 +4,10 @@ const fs = require('node:fs'); const source = fs.readFileSync('content/mail-2925.js', 'utf8'); +test('ensureMail2925Session waits at most 20 seconds for mailbox after clicking login', () => { + assert.match(source, /waitForMail2925View\('mailbox',\s*20000\)/); +}); + function extractFunction(name) { const markers = [`async function ${name}(`, `function ${name}(`]; const start = markers @@ -488,3 +492,187 @@ return { assert.equal(result, true); assert.deepEqual(api.getCalls(), ['inbox', 'select-all', 'delete']); }); + +test('findAgreementCheckbox skips 30-day login checkbox and picks agreement checkbox', async () => { + const bundle = [ + extractFunction('normalizeNodeText'), + extractFunction('isVisibleNode'), + extractFunction('resolveActionTarget'), + extractFunction('findAgreementContainer'), + extractFunction('isAgreementText'), + extractFunction('getCheckboxContextText'), + extractFunction('findAgreementCheckbox'), + ].join('\n'); + + const api = new Function(` +const MAIL2925_REMEMBER_LOGIN_PATTERNS = [ + /30天内免登录/, + /免登录/, + /记住登录/, + /保持登录/, +]; +const MAIL2925_AGREEMENT_PATTERNS = [ + /我已阅读并同意/, + /服务协议/, + /隐私政策/, +]; + +const rememberCheckbox = { + kind: 'remember-checkbox', + disabled: false, + readOnly: false, + hidden: false, + classList: { contains() { return false; } }, + getBoundingClientRect() { return { width: 14, height: 14 }; }, + closest(selector) { + if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this; + if (selector === 'label') return rememberLabel; + if (selector === 'label, div, span, p, li, form') return rememberLabel; + return null; + }, +}; +const agreementCheckbox = { + kind: 'agreement-checkbox', + disabled: false, + readOnly: false, + hidden: false, + classList: { contains() { return false; } }, + getBoundingClientRect() { return { width: 14, height: 14 }; }, + closest(selector) { + if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this; + if (selector === 'label') return agreementLabel; + if (selector === 'label, div, span, p, li, form') return agreementLabel; + return null; + }, +}; +const rememberLabel = { + innerText: '30天内免登录', + textContent: '30天内免登录', + hidden: false, + getBoundingClientRect() { return { width: 100, height: 20 }; }, + parentElement: null, +}; +const agreementLabel = { + innerText: '我已阅读并同意 《服务协议》 和 《隐私政策》', + textContent: '我已阅读并同意 《服务协议》 和 《隐私政策》', + hidden: false, + getBoundingClientRect() { return { width: 220, height: 24 }; }, + parentElement: null, + querySelector(selector) { + return selector.includes('checkbox') ? agreementCheckbox : null; + }, +}; +rememberCheckbox.parentElement = rememberLabel; +agreementCheckbox.parentElement = agreementLabel; + +const document = { + querySelectorAll(selector) { + if (selector === 'label, div, span, p, form') { + return [rememberLabel, agreementLabel]; + } + if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') { + return [rememberCheckbox, agreementCheckbox]; + } + return []; + }, +}; + +const window = { + getComputedStyle() { + return { display: 'block', visibility: 'visible' }; + }, +}; + +${bundle} + +return { + findAgreementCheckbox, + rememberCheckbox, + agreementCheckbox, +}; +`)(); + + assert.equal(api.findAgreementCheckbox(), api.agreementCheckbox); +}); + +test('ensureAgreementChecked clicks all visible login checkboxes', async () => { + const bundle = [ + extractFunction('isVisibleNode'), + extractFunction('resolveActionTarget'), + extractFunction('isCheckboxChecked'), + extractFunction('ensureAgreementChecked'), + ].join('\n'); + + const api = new Function(` +const rememberCheckbox = { + disabled: false, + readOnly: false, + hidden: false, + checked: false, + classList: { contains() { return false; } }, + getBoundingClientRect() { return { width: 14, height: 14 }; }, + click() { this.checked = true; }, + closest(selector) { + if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this; + return null; + }, + querySelector() { return null; }, + getAttribute(name) { + if (name === 'aria-checked') return this.checked ? 'true' : 'false'; + return ''; + }, +}; +const agreementCheckbox = { + disabled: false, + readOnly: false, + hidden: false, + checked: false, + classList: { contains() { return false; } }, + getBoundingClientRect() { return { width: 14, height: 14 }; }, + click() { this.checked = true; }, + closest(selector) { + if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this; + return null; + }, + querySelector() { return null; }, + getAttribute(name) { + if (name === 'aria-checked') return this.checked ? 'true' : 'false'; + return ''; + }, +}; + +const document = { + querySelectorAll(selector) { + if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') { + return [rememberCheckbox, agreementCheckbox]; + } + return []; + }, +}; + +const window = { + getComputedStyle() { + return { display: 'block', visibility: 'visible' }; + }, +}; + +async function sleep() {} +function simulateClick(node) { + node.click(); +} + +${bundle} + +return { + rememberCheckbox, + agreementCheckbox, + ensureAgreementChecked, +}; +`)(); + + const result = await api.ensureAgreementChecked(); + + assert.equal(result, true); + assert.equal(api.rememberCheckbox.checked, true); + assert.equal(api.agreementCheckbox.checked, true); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 3800b8e..2e9062d 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -526,9 +526,10 @@ 1. 用户在 sidepanel 的 2925 账号池中保存 `email / password` 2. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail` -3. 手动点击 `登录` 或自动流程进入 Step 4 / Step 8 前,只有在号池模式开启时,后台才会确保当前 2925 账号已登录网页邮箱 +3. 手动点击 `登录` 或自动流程进入 Step 4 / Step 8 前,只有在号池模式开启时,后台才会确保当前 2925 账号已登录网页邮箱;登录动作提交后,内容脚本会在 20 秒内轮询“是否已进入收件箱” 4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先记录当前时间;若当前处于号池模式且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录 -5. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态 +5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页 +6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态 ### 7.5 iCloud