From a92455bb5f098fcd255a8ec3564f58deb5e38793 Mon Sep 17 00:00:00 2001 From: dada2321 Date: Sun, 19 Apr 2026 03:44:38 +0800 Subject: [PATCH 1/3] it ok --- content/mail-163.js | 763 ++++++++++-------- .../2026-04-19-163-mail-body-fallback.md | 87 ++ ...026-04-19-163-mail-body-fallback-design.md | 57 ++ tests/mail-163-content.test.js | 148 ++++ 项目完整链路说明.md | 14 + 项目文件结构说明.md | 5 +- 6 files changed, 753 insertions(+), 321 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-19-163-mail-body-fallback.md create mode 100644 docs/superpowers/specs/2026-04-19-163-mail-body-fallback-design.md create mode 100644 tests/mail-163-content.test.js diff --git a/content/mail-163.js b/content/mail-163.js index dd7ba4f..1ab01e3 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -1,381 +1,504 @@ -// 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") -// Delete actions: hover trash icon on the row, or checkbox + toolbar delete button +// content/mail-163.js - Content script for 163 Mail (steps 4, 7) +// Injected on: mail.163.com / webmail.vip.163.com 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 { + let seenCodes = new Set(); -// 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`); + 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); } - } catch (err) { - console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err); } -} -// Load previously seen codes on startup -loadSeenCodes(); + 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); + 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') { + return undefined; + } -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(`步骤 ${message.step}:已被用户停止。`, 'warn'); - sendResponse({ stopped: true, error: err.message }); + handlePollEmail(message.step, message.payload) + .then((result) => { + sendResponse(result); + }) + .catch((err) => { + if (isStopError(err)) { + log(`步骤 ${message.step}:已被用户停止。`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } + + log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn'); + sendResponse({ error: err.message }); + }); + + return true; + }); + + function normalizeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function findMailItems() { + return document.querySelectorAll('div[sign="letter"]'); + } + + function getCurrentMailIds(items = findMailItems()) { + const ids = new Set(); + Array.from(items).forEach((item) => { + const id = item.getAttribute('id') || ''; + if (id) ids.add(id); + }); + return ids; + } + + function normalizeMinuteTimestamp(timestamp) { + if (!Number.isFinite(timestamp) || timestamp <= 0) return 0; + const date = new Date(timestamp); + date.setSeconds(0, 0); + return date.getTime(); + } + + function parseMail163Timestamp(rawText) { + const text = normalizeText(rawText); + if (!text) return null; + + let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/); + if (match) { + const [, year, month, day, hour, minute] = match; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + 0, + 0 + ).getTime(); + } + + match = text.match(/\b(\d{1,2}):(\d{2})\b/); + if (match) { + const [, hour, minute] = match; + const now = new Date(); + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + Number(hour), + Number(minute), + 0, + 0 + ).getTime(); + } + + return null; + } + + function getMailTimestamp(item) { + const candidates = []; + const timeCell = item.querySelector('.e00[title], [title*="年"][title*=":"]'); + if (timeCell?.getAttribute('title')) candidates.push(timeCell.getAttribute('title')); + if (timeCell?.textContent) candidates.push(timeCell.textContent); + + item.querySelectorAll('[title]').forEach((node) => { + const title = node.getAttribute('title'); + if (title) candidates.push(title); + }); + + for (const candidate of candidates) { + const parsed = parseMail163Timestamp(candidate); + if (parsed) return parsed; + } + + return null; + } + + function scheduleEmailCleanup(item, step) { + setTimeout(() => { + Promise.resolve(deleteEmail(item, step)).catch(() => { + // Cleanup is best effort only and must never affect the main verification flow. + }); + }, 0); + } + + function findInboxLink() { + const direct = document.querySelector('.nui-tree-item-text[title="收件箱"]'); + if (direct) return direct; + + const inboxPattern = /(?:Inbox|\u6536\u4ef6\u7bb1)/i; + const candidates = document.querySelectorAll('.nui-tree-item-text, .nui-tree-item, a, span, div'); + for (const node of candidates) { + const text = normalizeText([ + node.getAttribute?.('title') || '', + node.getAttribute?.('aria-label') || '', + node.textContent || '', + ].join(' ')); + if (inboxPattern.test(text)) { + return node; + } + } + + return null; + } + + function getMailPreviewText(item) { + return normalizeText([ + item.querySelector('.nui-user')?.textContent || '', + item.querySelector('span.da0')?.textContent || '', + item.getAttribute('aria-label') || '', + ].join(' ')); + } + + function readOpenedMailText() { + const texts = []; + const seenTexts = new Set(); + + const pushText = (value) => { + const normalized = normalizeText(value); + if (!normalized || normalized.length < 20 || seenTexts.has(normalized)) { return; } - log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn'); - sendResponse({ error: err.message }); + seenTexts.add(normalized); + texts.push(normalized); + }; + + const detailSelectors = [ + '.nui-iframe-body', + '.nui-msgbox', + '.mail-view', + '.mail-detail', + '.mail-reader', + '.reader-main', + '.reader-body', + '.readMainWrap', + '.mD0', + '.oD0', + '.nui-scroll', + ]; + + detailSelectors.forEach((selector) => { + document.querySelectorAll(selector).forEach((node) => { + pushText(node.innerText || node.textContent || ''); + }); }); - 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; -} - -function normalizeMinuteTimestamp(timestamp) { - if (!Number.isFinite(timestamp) || timestamp <= 0) return 0; - const date = new Date(timestamp); - date.setSeconds(0, 0); - return date.getTime(); -} - -function parseMail163Timestamp(rawText) { - const text = (rawText || '').replace(/\s+/g, ' ').trim(); - if (!text) return null; - - let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/); - if (match) { - const [, year, month, day, hour, minute] = match; - return new Date( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - 0, - 0 - ).getTime(); - } - - match = text.match(/\b(\d{1,2}):(\d{2})\b/); - if (match) { - const [, hour, minute] = match; - const now = new Date(); - return new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - Number(hour), - Number(minute), - 0, - 0 - ).getTime(); - } - - return null; -} - -function getMailTimestamp(item) { - const candidates = []; - const timeCell = item.querySelector('.e00[title], [title*="年"][title*=":"]'); - if (timeCell?.getAttribute('title')) candidates.push(timeCell.getAttribute('title')); - if (timeCell?.textContent) candidates.push(timeCell.textContent); - - const titledNodes = item.querySelectorAll('[title]'); - titledNodes.forEach((node) => { - const title = node.getAttribute('title'); - if (title) candidates.push(title); - }); - - for (const candidate of candidates) { - const parsed = parseMail163Timestamp(candidate); - if (parsed) return parsed; - } - - return null; -} - -function scheduleEmailCleanup(item, step) { - setTimeout(() => { - Promise.resolve(deleteEmail(item, step)).catch(() => { - // Cleanup is best effort only and must never affect the main verification flow. + document.querySelectorAll('iframe, frame').forEach((frame) => { + try { + const frameDoc = frame.contentDocument || frame.contentWindow?.document; + if (!frameDoc) return; + pushText(frameDoc.body?.innerText || frameDoc.body?.textContent || ''); + } catch (_) { + // Ignore inaccessible frames. + } }); - }, 0); -} -// ============================================================ -// Email Polling -// ============================================================ - -async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [], filterAfterTimestamp = 0 } = payload; - const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); - const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0); - - log(`步骤 ${step}:开始轮询 163 邮箱(最多 ${maxAttempts} 次)`); - if (filterAfterMinute) { - log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`); - } - - // Click inbox in sidebar to ensure we're in inbox view - log(`步骤 ${step}:正在等待侧边栏加载...`); - try { - const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000); - inboxLink.click(); - log(`步骤 ${step}:已点击收件箱`); - } catch { - log(`步骤 ${step}:未找到收件箱入口,继续尝试后续流程...`, 'warn'); - } - - // Wait for mail list to appear - log(`步骤 ${step}:正在等待邮件列表加载...`); - 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 邮箱列表未加载完成,请确认当前已打开收件箱。'); - } - - log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`); - - // Snapshot existing mail IDs - const existingMailIds = getCurrentMailIds(); - log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`); - - const FALLBACK_AFTER = 3; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - log(`步骤 ${step}:正在轮询 163 邮箱,第 ${attempt}/${maxAttempts} 次`); - - if (attempt > 1) { - await refreshInbox(); - await sleep(1000); + if (!texts.length) { + pushText(document.body?.innerText || document.body?.textContent || ''); } - const allItems = findMailItems(); - const useFallback = attempt > FALLBACK_AFTER; + texts.sort((left, right) => right.length - left.length); + return texts[0] || ''; + } - for (const item of allItems) { - const id = item.getAttribute('id') || ''; - const mailTimestamp = getMailTimestamp(item); - const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0); - const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute); - const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && mailMinute > 0); + async function returnToInbox() { + if (findMailItems().length > 0) { + return true; + } - if (!passesTimeFilter) { - continue; + const inboxLink = findInboxLink(); + if (inboxLink) { + simulateClick(inboxLink); + } + + for (let i = 0; i < 20; i += 1) { + if (findMailItems().length > 0) { + return true; + } + await sleep(250); + } + + return false; + } + + async function openMailAndGetMessageText(item) { + const previewText = getMailPreviewText(item); + + simulateClick(item); + + let openedText = ''; + for (let i = 0; i < 24; i += 1) { + const candidateText = readOpenedMailText(); + if (candidateText && candidateText !== previewText && candidateText.length >= previewText.length) { + openedText = candidateText; + if (extractVerificationCode(candidateText) || candidateText.length > previewText.length + 20) { + break; + } + } + await sleep(250); + } + + await returnToInbox(); + return openedText; + } + + async function handlePollEmail(step, payload) { + const { + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + filterAfterTimestamp = 0, + } = payload; + const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); + const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0); + + log(`步骤 ${step}:开始轮询 163 邮箱(最多 ${maxAttempts} 次)`); + if (filterAfterMinute) { + log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`); + } + + log(`步骤 ${step}:正在等待侧边栏加载...`); + try { + const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000); + inboxLink.click(); + log(`步骤 ${step}:已点击收件箱`); + } catch { + log(`步骤 ${step}:未找到收件箱入口,继续尝试后续流程...`, 'warn'); + } + + log(`步骤 ${step}:正在等待邮件列表加载...`); + let items = []; + for (let i = 0; i < 20; i += 1) { + 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 邮箱列表未加载完成,请确认当前已打开收件箱。'); + } + + log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`); + + const existingMailIds = getCurrentMailIds(items); + log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`); + + const FALLBACK_AFTER = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + log(`步骤 ${step}:正在轮询 163 邮箱,第 ${attempt}/${maxAttempts} 次`); + + if (attempt > 1) { + await refreshInbox(); + await sleep(1000); } - if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(id)) continue; + const allItems = findMailItems(); + const useFallback = attempt > FALLBACK_AFTER; - const senderEl = item.querySelector('.nui-user'); - const sender = senderEl ? senderEl.textContent.toLowerCase() : ''; + for (const item of allItems) { + const id = item.getAttribute('id') || ''; + const mailTimestamp = getMailTimestamp(item); + const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0); + const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute); + const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && mailMinute > 0); - const subjectEl = item.querySelector('span.da0'); - const subject = subjectEl ? subjectEl.textContent : ''; + if (!passesTimeFilter) { + continue; + } - const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase(); + if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(id)) { + continue; + } - 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())); + const sender = normalizeText(item.querySelector('.nui-user')?.textContent || '').toLowerCase(); + const subject = normalizeText(item.querySelector('span.da0')?.textContent || ''); + const ariaLabel = normalizeText(item.getAttribute('aria-label') || '').toLowerCase(); - if (senderMatch || subjectMatch) { - const code = extractVerificationCode(subject + ' ' + ariaLabel); - if (code && excludedCodeSet.has(code)) { - log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); - } else if (code && !seenCodes.has(code)) { - seenCodes.add(code); + const senderMatch = senderFilters.some((filter) => sender.includes(String(filter || '').toLowerCase()) || ariaLabel.includes(String(filter || '').toLowerCase())); + const subjectMatch = subjectFilters.some((filter) => subject.toLowerCase().includes(String(filter || '').toLowerCase()) || ariaLabel.includes(String(filter || '').toLowerCase())); + + if (!senderMatch && !subjectMatch) { + continue; + } + + const previewCode = extractVerificationCode(`${subject} ${ariaLabel}`); + if (previewCode) { + if (excludedCodeSet.has(previewCode)) { + log(`步骤 ${step}:跳过排除的验证码:${previewCode}`, 'info'); + continue; + } + if (seenCodes.has(previewCode)) { + log(`步骤 ${step}:跳过已处理过的验证码:${previewCode}`, 'info'); + continue; + } + + seenCodes.add(previewCode); persistSeenCodes(); const source = useFallback && existingMailIds.has(id) ? '回退匹配邮件' : '新邮件'; const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; - log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok'); + log(`步骤 ${step}:已找到验证码:${previewCode}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok'); - // Trigger cleanup only as a best-effort side effect. scheduleEmailCleanup(item, step); - - return { ok: true, code, emailTimestamp: Date.now(), mailId: id }; - } else if (code && seenCodes.has(code)) { - log(`步骤 ${step}:跳过已处理过的验证码:${code}`, 'info'); + return { ok: true, code: previewCode, emailTimestamp: Date.now(), mailId: id }; } + + const openedText = await openMailAndGetMessageText(item); + const bodyCode = extractVerificationCode(openedText); + if (!bodyCode) { + continue; + } + if (excludedCodeSet.has(bodyCode)) { + log(`步骤 ${step}:跳过排除的验证码:${bodyCode}`, 'info'); + continue; + } + if (seenCodes.has(bodyCode)) { + log(`步骤 ${step}:跳过已处理过的验证码:${bodyCode}`, 'info'); + continue; + } + + seenCodes.add(bodyCode); + persistSeenCodes(); + const source = useFallback && existingMailIds.has(id) ? '回退匹配邮件正文' : '新邮件正文'; + const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; + log(`步骤 ${step}:已从 163 邮件正文中找到验证码:${bodyCode}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok'); + + scheduleEmailCleanup(item, step); + return { ok: true, code: bodyCode, emailTimestamp: Date.now(), mailId: id }; + } + + if (attempt === FALLBACK_AFTER + 1) { + log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件。`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleep(intervalMs); } } - if (attempt === FALLBACK_AFTER + 1) { - log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn'); - } - - if (attempt < maxAttempts) { - await sleep(intervalMs); - } + throw new Error( + `${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 163 邮箱中找到新的匹配邮件。` + + '请手动检查收件箱。' + ); } - throw new Error( - `${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 163 邮箱中找到新的匹配邮件。` + - '请手动检查收件箱。' - ); -} + async function deleteEmail(item, step) { + try { + log(`步骤 ${step}:正在删除邮件...`); -// ============================================================ -// Delete Email via Hover Trash / Toolbar Fallback -// ============================================================ - -async function deleteEmail(item, step) { - try { - log(`步骤 ${step}:正在删除邮件...`); - - // Strategy 1: Click the trash icon inside the mail item - // Each mail item has: - // 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}:已点击删除图标`, 'ok'); - await sleep(1500); - - // Check if item disappeared (confirm deletion) - const stillExists = document.getElementById(item.id); - if (!stillExists || stillExists.style.display === 'none') { - log(`步骤 ${step}:邮件已成功删除`); - } else { - log(`步骤 ${step}:邮件可能尚未删除,列表中仍可见`, 'warn'); - } - return; - } - - // Strategy 2: Select checkbox then click toolbar delete button - log(`步骤 ${step}:未找到删除图标,尝试使用复选框加工具栏删除...`); - const checkbox = item.querySelector('[sign="checkbox"], .nui-chk'); - if (checkbox) { - checkbox.click(); + item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); 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}:已点击工具栏删除`, 'ok'); - await sleep(1500); - return; + const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]'); + if (trashIcon) { + simulateClick(trashIcon); + log(`步骤 ${step}:已点击删除图标`, 'ok'); + await sleep(1500); + + const stillExists = item.id ? document.getElementById(item.id) : item; + if (!stillExists || stillExists.style?.display === 'none') { + log(`步骤 ${step}:邮件已成功删除`); + } else { + log(`步骤 ${step}:邮件可能尚未删除,列表中仍可见`, 'warn'); } + return; + } + + log(`步骤 ${step}:未找到删除图标,尝试使用复选框加工具栏删除...`); + const checkbox = item.querySelector('[sign="checkbox"], .nui-chk'); + if (checkbox) { + simulateClick(checkbox); + await sleep(300); + + const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text'); + for (const btn of toolbarBtns) { + if (normalizeText(btn.textContent || '').includes('删除')) { + simulateClick(btn.closest('.nui-btn') || btn); + log(`步骤 ${step}:已点击工具栏删除`, 'ok'); + await sleep(1500); + return; + } + } + } + + log(`步骤 ${step}:无法删除邮件(未找到删除按钮)`, 'warn'); + } catch (err) { + log(`步骤 ${step}:删除邮件失败:${err.message}`, 'warn'); + } + } + + async function refreshInbox() { + const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text'); + for (const btn of toolbarBtns) { + if (normalizeText(btn.textContent || '') === '刷新') { + simulateClick(btn.closest('.nui-btn') || btn); + console.log(MAIL163_PREFIX, 'Clicked refresh button'); + await sleep(800); + return; } } - log(`步骤 ${step}:无法删除邮件(未找到删除按钮)`, 'warn'); - } catch (err) { - log(`步骤 ${step}:删除邮件失败:${err.message}`, 'warn'); - } -} + const receiveButtons = document.querySelectorAll('.ra0'); + for (const btn of receiveButtons) { + if (normalizeText(btn.textContent || '').includes('收信')) { + simulateClick(btn); + console.log(MAIL163_PREFIX, 'Clicked receive button'); + await sleep(800); + return; + } + } -// ============================================================ -// 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'); + const inboxLink = findInboxLink(); + if (inboxLink) { + simulateClick(inboxLink); await sleep(800); return; } + + console.log(MAIL163_PREFIX, 'Could not find refresh button'); } - // 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; - } + function extractVerificationCode(text) { + const matchCn = String(text || '').match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); + if (matchCn) return matchCn[1]; + + const matchEn = String(text || '').match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); + if (matchEn) return matchEn[1] || matchEn[2]; + + const match6 = String(text || '').match(/\b(\d{6})\b/); + if (match6) return match6[1]; + + return null; } - - 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 diff --git a/docs/superpowers/plans/2026-04-19-163-mail-body-fallback.md b/docs/superpowers/plans/2026-04-19-163-mail-body-fallback.md new file mode 100644 index 0000000..7c0a6c6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-163-mail-body-fallback.md @@ -0,0 +1,87 @@ +# 163 Mail Body Fallback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the 163 mailbox provider read verification codes from opened email bodies when the inbox list does not show the code inline. + +**Architecture:** Keep the existing list-first polling flow in `content/mail-163.js`, then add a provider-local fallback that opens candidate messages, reads body text, and returns to the inbox before continuing. Do not add `targetEmail` filtering in this change. + +**Tech Stack:** Manifest V3 Chrome extension, plain JavaScript content scripts, Node built-in test runner + +--- + +### Task 1: Lock the regression with a failing test + +**Files:** +- Create: `D:\github\codex-oauth-automation-extension-Pro2.0\tests\mail-163-content.test.js` +- Test: `D:\github\codex-oauth-automation-extension-Pro2.0\tests\mail-163-content.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +test('handlePollEmail opens a matching 163 message and reads the body when the list row has no inline code', async () => { + const bundle = extractFunction('handlePollEmail'); + const api = new Function(`...`)(); + const result = await api.handlePollEmail(8, { + senderFilters: ['openai'], + subjectFilters: ['chatgpt'], + maxAttempts: 1, + intervalMs: 1, + }); + assert.equal(result.code, '480382'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/mail-163-content.test.js` +Expected: FAIL because `handlePollEmail` never opens the message body. + +### Task 2: Add the 163 opened-mail fallback + +**Files:** +- Modify: `D:\github\codex-oauth-automation-extension-Pro2.0\content\mail-163.js` +- Test: `D:\github\codex-oauth-automation-extension-Pro2.0\tests\mail-163-content.test.js` + +- [ ] **Step 1: Add minimal helpers** + +```js +function findInboxLink() { /* find the 163 inbox entry */ } +async function returnToInbox() { /* restore inbox list view */ } +function readOpenedMailText() { /* read visible detail text and iframe text */ } +async function openMailAndGetMessageText(item) { /* open, read, return */ } +``` + +- [ ] **Step 2: Update `handlePollEmail`** + +```js +if (!code) { + const openedText = await openMailAndGetMessageText(item); + const bodyCode = extractVerificationCode(openedText); + if (bodyCode) { + return { ok: true, code: bodyCode, emailTimestamp: Date.now(), mailId: id }; + } +} +``` + +- [ ] **Step 3: Run the targeted test** + +Run: `node --test tests/mail-163-content.test.js` +Expected: PASS + +### Task 3: Verify no regressions in adjacent polling logic + +**Files:** +- Test: `D:\github\codex-oauth-automation-extension-Pro2.0\tests\mail-163-content.test.js` +- Test: `D:\github\codex-oauth-automation-extension-Pro2.0\tests\verification-flow-polling.test.js` + +- [ ] **Step 1: Run the local regression slice** + +Run: `node --test tests/mail-163-content.test.js tests/verification-flow-polling.test.js` +Expected: PASS + +- [ ] **Step 2: Update docs only if behavior scope changed** + +```md +No root docs update is required if the change stays inside existing 163 polling behavior and does not alter the documented top-level flow. +``` diff --git a/docs/superpowers/specs/2026-04-19-163-mail-body-fallback-design.md b/docs/superpowers/specs/2026-04-19-163-mail-body-fallback-design.md new file mode 100644 index 0000000..91c163f --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-163-mail-body-fallback-design.md @@ -0,0 +1,57 @@ +# 163 Mail Body Fallback Design + +## Goal + +Make the `163` and `163-vip` mailbox polling flow able to read verification codes from the opened email body when the inbox list does not expose the six-digit code. + +## Existing Problem + +- `content/mail-163.js` currently matches candidate emails from the inbox list only. +- It extracts the code from the row subject text and the row `aria-label`. +- If the subject line only says something like "你的临时 ChatGPT 登录代码" and the row metadata does not include the code, polling fails even when the opened email body clearly shows the code. + +## Approved Scope + +- Add a body-reading fallback for `163` and `163-vip`. +- Keep the current list-based fast path. +- Do not add `targetEmail` filtering in this change. +- Keep cleanup as best effort only. + +## Design Summary + +`content/mail-163.js` will move from a single-stage detector to a two-stage detector: + +1. Scan inbox rows exactly as today. +2. If a matching row does not contain a code in its subject or `aria-label`, open that email. +3. Read visible detail text from the opened mail view, including same-origin iframe content when available. +4. Extract the six-digit code from the opened content. +5. Return to the inbox before continuing. + +## Architecture Notes + +### Row-first detection stays in place + +The existing sender, subject, time-window, and seen-code checks stay as the first filter because they are cheap and already fit the current provider flow. + +### Opened-mail fallback is local to `content/mail-163.js` + +The background verification flow does not need to change. The mailbox content script already owns the provider-specific polling behavior, so the new logic should stay inside the 163 content script. + +### No target-email filtering in this revision + +Step 8 already passes `targetEmail` through the background payload, but this revision intentionally leaves that field unused for 163. The goal is to fix the missing body fallback without widening the scope of behavior changes. + +## Error Handling + +- If opening a candidate mail does not reveal readable detail text, polling should continue to the next candidate or next round. +- If the helper opens a mail successfully, it should try to return to the inbox before continuing. +- Cleanup and deletion remain best effort and must not block successful code extraction. + +## Testing Strategy + +Add a focused regression test for `content/mail-163.js` that proves: + +- a matching 163 inbox row without an inline code does not succeed from list text alone +- the script opens the row +- the script reads the opened body text +- the code extracted from the body is returned to the background flow diff --git a/tests/mail-163-content.test.js b/tests/mail-163-content.test.js new file mode 100644 index 0000000..9f70a04 --- /dev/null +++ b/tests/mail-163-content.test.js @@ -0,0 +1,148 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/mail-163.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); +} + +test('handlePollEmail opens a matching 163 message and reads the body when the list row has no inline code', async () => { + const bundle = [ + extractFunction('normalizeText'), + extractFunction('handlePollEmail'), + ].join('\n'); + + const api = new Function(` +const seenCodes = new Set(); +const openedMailIds = []; +let state = 'empty'; +const now = Date.now(); + +const inboxLink = { + click() { + state = 'ready'; + }, +}; + +const mailItem = { + getAttribute(name) { + if (name === 'id') return 'mail-1'; + if (name === 'aria-label') return 'OpenAI 发件人 你的临时 ChatGPT 登录代码'; + return ''; + }, + querySelector(selector) { + if (selector === '.nui-user') return { textContent: 'OpenAI' }; + if (selector === 'span.da0') return { textContent: '你的临时 ChatGPT 登录代码' }; + return null; + }, +}; + +function findMailItems() { + return state === 'ready' ? [mailItem] : []; +} + +function getCurrentMailIds() { + return new Set(findMailItems().map((item) => item.getAttribute('id'))); +} + +function normalizeMinuteTimestamp(timestamp) { + if (!Number.isFinite(timestamp) || timestamp <= 0) return 0; + const date = new Date(timestamp); + date.setSeconds(0, 0); + return date.getTime(); +} + +function getMailTimestamp() { + return now; +} + +async function waitForElement() { + return inboxLink; +} + +async function refreshInbox() { + state = 'ready'; +} + +async function sleep() {} + +function extractVerificationCode(text) { + const match = String(text || '').match(/(\\d{6})/); + return match ? match[1] : null; +} + +async function openMailAndGetMessageText(item) { + openedMailIds.push(item.getAttribute('id')); + return '输入此临时验证码以继续:480382'; +} + +function persistSeenCodes() {} +function scheduleEmailCleanup() {} +function log() {} + +${bundle} + +return { + handlePollEmail, + getOpenedMailIds() { + return openedMailIds.slice(); + }, +}; +`)(); + + const result = await api.handlePollEmail(8, { + senderFilters: ['openai'], + subjectFilters: ['chatgpt'], + maxAttempts: 1, + intervalMs: 1, + filterAfterTimestamp: Date.now(), + }); + + assert.equal(result.code, '480382'); + assert.deepEqual(api.getOpenedMailIds(), ['mail-1']); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 140c3e9..a506ac9 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -471,6 +471,20 @@ - [icloud-utils.js](c:/Users/projectf/Downloads/codex注册扩展/icloud-utils.js) - [content/icloud-mail.js](c:/Users/projectf/Downloads/codex注册扩展/content/icloud-mail.js) +### 7.5 163 / 163 VIP + +组成: + +- [content/mail-163.js](c:/Users/projectf/Downloads/codex注册扩展/content/mail-163.js) + +当前行为: + +1. 先在 163 收件箱列表中按发件人、主题、`aria-label` 和时间窗口筛选候选邮件 +2. 如果列表文本已经直接包含 6 位验证码,则直接返回验证码 +3. 如果列表文本未直接包含验证码,则自动打开候选邮件正文 +4. 从正文可见文本与同源 iframe 文本中提取验证码 +5. 读取后回到收件箱列表,继续后续轮询或清理流程 + ## 8. 自动运行完整链路 文件: diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 4d90214..2e52113 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -73,7 +73,7 @@ - `content/gmail-mail.js`:Gmail 邮箱轮询脚本,负责在 Gmail 页面中匹配验证码邮件。 - `content/icloud-mail.js`:iCloud 邮箱页面脚本,负责在 iCloud Mail 页面中读取邮件详情和验证码。 - `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。 -- `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 +- `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理;当前会先扫描收件箱列表中的发件人/主题/`aria-label`,若列表未直接暴露验证码,则会打开候选邮件正文读取验证码后再回到列表继续轮询。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询、按步骤会话隔离“已试验证码”、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 - `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。 @@ -95,7 +95,9 @@ - `docs/images/支付宝.jpg`:README 中展示的支付宝收款码图片。 - `docs/refactor/2026-04-16-architecture-refactor-plan.md`:本轮重构阶段的结构设计与迁移计划记录。 - `docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md`:Hotmail OAuth 邮箱池实现计划文档。 +- `docs/superpowers/plans/2026-04-19-163-mail-body-fallback.md`:163 邮箱“列表无验证码时回退读取正文”的实现计划文档。 - `docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md`:Hotmail OAuth + Graph Mail 的设计规格文档。 +- `docs/superpowers/specs/2026-04-19-163-mail-body-fallback-design.md`:163 邮箱正文兜底读取验证码的设计说明文档。 ## `icons/` @@ -155,6 +157,7 @@ - `tests/hotmail-cors-headers.test.js`:测试 Microsoft token 请求相关 DNR 配置。 - `tests/hotmail-utils.test.js`:测试 Hotmail 工具层的账号筛选、验证码提取和消息匹配逻辑。 - `tests/icloud-mail-content.test.js`:测试 iCloud Mail 内容脚本读取邮件正文和选中状态逻辑。 +- `tests/mail-163-content.test.js`:测试 163 邮箱内容脚本在列表无验证码时会打开邮件正文读取验证码。 - `tests/icloud-utils.test.js`:测试 iCloud 工具层的 host、别名列表、保留状态与筛选逻辑。 - `tests/luckmail-utils.test.js`:测试 LuckMail 工具层的购买记录、邮件、游标和验证码匹配逻辑。 - `tests/mail-2925-content.test.js`:测试 2925 邮箱内容脚本的轮询快照、忽略邮箱对比、按步骤会话隔离已试验证码、单封邮件删除与整箱删除逻辑。 From e5ae540a3c21112bb57080c61f45955201525843 Mon Sep 17 00:00:00 2001 From: dada2321 Date: Sun, 19 Apr 2026 04:57:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Process.md | 20 +++++++++++ content/mail-163.js | 15 +++++++-- tests/mail-163-content.test.js | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 Process.md diff --git a/Process.md b/Process.md new file mode 100644 index 0000000..51e0468 --- /dev/null +++ b/Process.md @@ -0,0 +1,20 @@ +# Process + +## 2026-04-19 + +### 今日战利品 +- 修复了 163 邮箱顶部“刷新”按钮的识别问题。 +- 根因不是插件不知道顶部按钮在哪里,而是它用 DOM 文本做识别时,代码只认“刷新”,但 163 实际渲染出来可能是“刷 新”。 +- 给 `163` 邮箱补上了回归测试,确保以后再次遇到这种带空格的按钮文案时,脚本仍然会优先点击顶部工具栏刷新,而不是误退回到“收件箱”。 +- 额外补了更直白的刷新分支日志,后续排查时可以直接看出本次命中的是“顶部刷新”“左侧收信”还是“收件箱兜底”。 + +### 知识地图 +- 这一块属于“自动化脚本稳定性”里的 DOM 识别层。 +- 老板可以把它理解成:脚本不是靠“看见页面上方那个位置”去点按钮,而是靠“读页面结构里的标签文字和属性”去认目标。 +- 所以这里学到的核心不是某一个按钮怎么点,而是“页面自动化里,视觉上像同一个按钮,DOM 文本却可能有空格、换行、图标包裹等变体”,识别逻辑必须做抗噪处理。 +- 这部分在大神知识体系里,位置属于:前端页面结构理解 -> 浏览器自动化 -> 选择器与文本匹配鲁棒性。 + +### 下一步导航 +- 可以继续把 163 的删除兜底逻辑也做一次同类检查,因为顶部“删 除”从 DOM 结构看也可能存在同样的空格拆字风险。 +- 如果老板接下来还想继续提稳收信成功率,可以再查一层:163 顶部“刷新”点击之后,页面到底有没有真正触发新的列表请求,以及请求完成信号该监听什么。 +- 如果要把这一块做成长期稳定方案,下一步适合补“刷新动作观测点”日志,比如记录刷新前后列表首封邮件 ID、请求时间、是否出现 loading。 diff --git a/content/mail-163.js b/content/mail-163.js index 1ab01e3..424fdb0 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -461,9 +461,18 @@ if (!isTopFrame) { async function refreshInbox() { const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text'); for (const btn of toolbarBtns) { - if (normalizeText(btn.textContent || '') === '刷新') { + // 163 会把“刷 新”拆成带空格的文字,所以这里要先压平空白, + // 不然脚本明明看到了顶部刷新按钮,也会误以为没找到。 + const compactLabel = String([ + btn.textContent || '', + btn.getAttribute?.('aria-label') || '', + btn.getAttribute?.('title') || '', + ].join('')).replace(/\s+/g, ''); + + if (compactLabel === '刷新') { simulateClick(btn.closest('.nui-btn') || btn); - console.log(MAIL163_PREFIX, 'Clicked refresh button'); + console.log(MAIL163_PREFIX, 'Clicked top toolbar refresh button'); + log('[163 邮箱] 已命中顶部工具栏“刷新”按钮'); await sleep(800); return; } @@ -474,6 +483,7 @@ if (!isTopFrame) { if (normalizeText(btn.textContent || '').includes('收信')) { simulateClick(btn); console.log(MAIL163_PREFIX, 'Clicked receive button'); + log('[163 邮箱] 未找到顶部刷新,改用左侧“收信”按钮'); await sleep(800); return; } @@ -482,6 +492,7 @@ if (!isTopFrame) { const inboxLink = findInboxLink(); if (inboxLink) { simulateClick(inboxLink); + log('[163 邮箱] 未找到顶部刷新或左侧“收信”,回退到点击“收件箱”'); await sleep(800); return; } diff --git a/tests/mail-163-content.test.js b/tests/mail-163-content.test.js index 9f70a04..0f901a6 100644 --- a/tests/mail-163-content.test.js +++ b/tests/mail-163-content.test.js @@ -146,3 +146,64 @@ return { assert.equal(result.code, '480382'); assert.deepEqual(api.getOpenedMailIds(), ['mail-1']); }); + +test('refreshInbox prefers the top toolbar refresh button even when 163 renders the label as 刷 新', async () => { + const bundle = [ + extractFunction('normalizeText'), + extractFunction('refreshInbox'), + ].join('\n'); + + const api = new Function(` +const MAIL163_PREFIX = '[MultiPage:mail-163]'; +const clickOrder = []; + +const refreshButton = { + tagName: 'DIV', + textContent: '刷 新', +}; + +const refreshLabel = { + textContent: '刷 新', + closest(selector) { + return selector === '.nui-btn' ? refreshButton : null; + }, +}; + +const inboxLink = { + tagName: 'SPAN', + textContent: '收件箱', +}; + +const document = { + querySelectorAll(selector) { + if (selector === '.nui-btn .nui-btn-text') return [refreshLabel]; + if (selector === '.ra0') return []; + return []; + }, +}; + +function simulateClick(node) { + clickOrder.push(node.textContent); +} + +function findInboxLink() { + return inboxLink; +} + +async function sleep() {} +function log() {} + +${bundle} + +return { + refreshInbox, + getClickOrder() { + return clickOrder.slice(); + }, +}; +`)(); + + await api.refreshInbox(); + + assert.deepEqual(api.getClickOrder(), ['刷 新']); +}); From 01477d6bd03c12b2c3ae4fcd58360c8ce8f2956e Mon Sep 17 00:00:00 2001 From: dada2321 Date: Sun, 19 Apr 2026 12:05:24 +0800 Subject: [PATCH 3/3] fix: improve step 2 email switch handling --- Process.md | 17 + background.js | 5 +- background/logging-status.js | 5 +- background/steps/oauth-login.js | 5 +- content/signup-page.js | 132 +++++- .../background-logging-status-module.test.js | 25 + tests/signup-entry-diagnostics.test.js | 8 + tests/signup-step2-email-switch.test.js | 431 ++++++++++++++++++ 项目完整链路说明.md | 7 + 项目文件结构说明.md | 2 +- 10 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 tests/signup-step2-email-switch.test.js diff --git a/Process.md b/Process.md index 51e0468..e8426fb 100644 --- a/Process.md +++ b/Process.md @@ -18,3 +18,20 @@ - 可以继续把 163 的删除兜底逻辑也做一次同类检查,因为顶部“删 除”从 DOM 结构看也可能存在同样的空格拆字风险。 - 如果老板接下来还想继续提稳收信成功率,可以再查一层:163 顶部“刷新”点击之后,页面到底有没有真正触发新的列表请求,以及请求完成信号该监听什么。 - 如果要把这一块做成长期稳定方案,下一步适合补“刷新动作观测点”日志,比如记录刷新前后列表首封邮件 ID、请求时间、是否出现 loading。 + + +## 2026-04-19 Step 2 纠偏补充 + +### 今日战利品 +- 修复了中文按钮 继续使用电子邮件地址登录 无法被 Step 2 识别的问题。 +- 修复了后台把 步骤 2:当前页面仍停留在手机号输入模式... 这类错误误判成 auth dd-phone 致命页的问题。 +- 现在自动运行只会在真正命中认证流程的手机号页面时才按 fatal 停止,不会因为注册弹窗还停留在手机号输入态就整轮停机。 + +### 知识地图 +- 这一块属于“错误分类系统”和“页面状态系统”的交叉地带。 +- 老板可以把它理解成:同样都带“手机号”三个字,但“注册弹窗默认展示手机号输入框”和“认证流程真的跳进 add-phone 页面”在业务意义上完全不是一回事。 +- 大神体系里,这属于自动化系统里很重要的一层:不仅要会识别页面,还要会给错误正确分级。 + +### 下一步导航 +- 如果老板接下来还想继续提稳 Step 2,可以把“手机号态切邮箱”的点击前后 DOM 快照也打进日志里,这样下次能直接看出是“按钮没识别到”还是“按钮点了但页面没切”。 +- 如果后面再遇到多语言 UI 变体,优先考虑把按钮识别继续升级成“关键语义词 + 排除词 + 结构位置”三段式判断,抗漂移会更强。 diff --git a/background.js b/background.js index 03e822f..0523bd9 100644 --- a/background.js +++ b/background.js @@ -3902,7 +3902,10 @@ function isAddPhoneAuthFailure(error) { return loggingStatus.isAddPhoneAuthFailure(error); } const message = getErrorMessage(error); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); } function getLoginAuthStateLabel(state) { diff --git a/background/logging-status.js b/background/logging-status.js index f685fd3..0eb9081 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -63,7 +63,10 @@ function isAddPhoneAuthFailure(error) { const message = getErrorMessage(error); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); } function getLoginAuthStateLabel(state) { diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index facf21e..60ad2d8 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -11,7 +11,10 @@ getState, isAddPhoneAuthFailure = (error) => { const message = String(typeof error === 'string' ? error : error?.message || ''); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + if (/\u624b\u673a\u53f7\u8f93\u5165\u6a21\u5f0f|phone\s+entry/i.test(message)) { + return false; + } + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|\u6dfb\u52a0\u624b\u673a\u53f7|\u624b\u673a\u53f7\u7801|\u8fdb\u5165\u624b\u673a\u53f7\u9875\u9762|\u624b\u673a\u53f7\u9875|\u624b\u673a\u53f7\u9875\u9762|phone\s+number|telephone/i.test(message); }, isStep6RecoverableResult, isStep6SuccessResult, diff --git a/content/signup-page.js b/content/signup-page.js index 4ef7f78..6cbbf07 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -293,11 +293,99 @@ async function handle405ResendError(step, remainingTimeout = 30000) { // ============================================================ const SIGNUP_ENTRY_TRIGGER_PATTERN = /免费注册|立即注册|注册|sign\s*up|register|create\s*account|create\s+account/i; -const SIGNUP_EMAIL_INPUT_SELECTOR = 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i]'; +const SIGNUP_EMAIL_INPUT_SELECTOR = [ + 'input[type="email"]', + 'input[autocomplete="email"]', + 'input[autocomplete="username"]', + 'input[name="email"]', + 'input[name="username"]', + 'input[id*="email"]', + 'input[placeholder*="email" i]', + 'input[placeholder*="电子邮件"]', + 'input[placeholder*="邮箱"]', + 'input[aria-label*="email" i]', + 'input[aria-label*="电子邮件"]', + 'input[aria-label*="邮箱"]', +].join(', '); +const SIGNUP_PHONE_INPUT_SELECTOR = [ + 'input[type="tel"]:not([maxlength="6"])', + 'input[name*="phone" i]', + 'input[id*="phone" i]', + 'input[autocomplete="tel"]', + 'input[placeholder*="手机"]', + 'input[aria-label*="手机"]', +].join(', '); +const SIGNUP_SWITCH_TO_EMAIL_PATTERN = new RegExp([ + String.raw`\u7ee7\u7eed\u4f7f\u7528(?:\u7535\u5b50\u90ae\u4ef6\u5730\u5740|\u90ae\u7bb1)\u767b\u5f55`, + String.raw`\u6539\u7528(?:\u7535\u5b50\u90ae\u4ef6\u5730\u5740|\u90ae\u7bb1)\u767b\u5f55`, + String.raw`continue\s+using\s+(?:an?\s+)?email(?:\s+address)?(?:\s+(?:to\s+)?(?:log\s*in|sign\s*in|sign\s*up))?`, + String.raw`continue\s+with\s+email(?:\s+address)?`, + String.raw`use\s+(?:an?\s+)?email(?:\s+address)?(?:\s+instead)?`, + String.raw`sign\s*(?:in|up)\s+with\s+email`, +].join('|'), 'i'); +const SIGNUP_SWITCH_ACTION_PATTERN = /\u7ee7\u7eed\u4f7f\u7528|\u6539\u7528|continue|use|sign\s*(?:in|up)/i; +const SIGNUP_EMAIL_ACTION_PATTERN = /\u7535\u5b50\u90ae\u4ef6|\u90ae\u7bb1|email/i; +const SIGNUP_WORK_EMAIL_PATTERN = /\u5de5\u4f5c|business|work\s+email/i; function getSignupEmailInput() { const input = document.querySelector(SIGNUP_EMAIL_INPUT_SELECTOR); - return input && isVisibleElement(input) ? input : null; + if (input && isVisibleElement(input)) { + return input; + } + + const fallback = Array.from(document.querySelectorAll('input')).find((el) => { + if (!isVisibleElement(el)) return false; + const type = String(el.getAttribute?.('type') || '').trim().toLowerCase(); + const name = String(el.getAttribute?.('name') || '').trim().toLowerCase(); + const id = String(el.getAttribute?.('id') || '').trim().toLowerCase(); + const placeholder = String(el.getAttribute?.('placeholder') || '').trim(); + const ariaLabel = String(el.getAttribute?.('aria-label') || '').trim(); + const autocomplete = String(el.getAttribute?.('autocomplete') || '').trim().toLowerCase(); + const combinedText = `${placeholder} ${ariaLabel}`; + return type === 'email' + || autocomplete === 'email' + || autocomplete === 'username' + || /email|username/i.test(`${name} ${id}`) + || /email|电子邮件|邮箱/i.test(combinedText); + }); + + return fallback || null; +} + +function getSignupPhoneInput() { + const input = document.querySelector(SIGNUP_PHONE_INPUT_SELECTOR); + if (input && isVisibleElement(input)) { + return input; + } + + const fallback = Array.from(document.querySelectorAll('input')).find((el) => { + if (!isVisibleElement(el)) return false; + const type = String(el.getAttribute?.('type') || '').trim().toLowerCase(); + const name = String(el.getAttribute?.('name') || '').trim().toLowerCase(); + const id = String(el.getAttribute?.('id') || '').trim().toLowerCase(); + const placeholder = String(el.getAttribute?.('placeholder') || '').trim(); + const ariaLabel = String(el.getAttribute?.('aria-label') || '').trim(); + const autocomplete = String(el.getAttribute?.('autocomplete') || '').trim().toLowerCase(); + const combinedText = `${placeholder} ${ariaLabel}`; + return type === 'tel' + || autocomplete === 'tel' + || /phone|tel/i.test(`${name} ${id}`) + || /手机|电话|手机号/.test(combinedText); + }); + + return fallback || null; +} + +function findSignupUseEmailTrigger() { + const candidates = document.querySelectorAll('button, a, [role="button"], [role="link"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + const text = getActionText(el); + if (!text) return false; + if (SIGNUP_WORK_EMAIL_PATTERN.test(text)) return false; + return SIGNUP_SWITCH_TO_EMAIL_PATTERN.test(text) + || (SIGNUP_SWITCH_ACTION_PATTERN.test(text) && SIGNUP_EMAIL_ACTION_PATTERN.test(text)); + }) || null; } function getSignupEmailContinueButton({ allowDisabled = false } = {}) { @@ -353,6 +441,16 @@ function inspectSignupEntryState() { }; } + const phoneInput = getSignupPhoneInput(); + if (phoneInput) { + return { + state: 'phone_entry', + phoneInput, + switchToEmailTrigger: findSignupUseEmailTrigger(), + url: location.href, + }; + } + const signupTrigger = findSignupEntryTrigger(); if (signupTrigger) { return { @@ -410,7 +508,9 @@ function getSignupEntryDiagnostics() { title: document.title || '', readyState: document.readyState || '', hasEmailInput: Boolean(getSignupEmailInput()), + hasPhoneInput: Boolean(getSignupPhoneInput()), hasPasswordInput: Boolean(getSignupPasswordInput()), + hasSwitchToEmailAction: Boolean(findSignupUseEmailTrigger()), bodyContainsSignupText: SIGNUP_ENTRY_TRIGGER_PATTERN.test(getPageTextSnapshot()), signupLikeActions, visibleActions, @@ -425,6 +525,8 @@ async function waitForSignupEntryState(options = {}) { } = options; const start = Date.now(); let lastTriggerClickAt = 0; + let lastSwitchToEmailAt = 0; + let loggedMissingSwitchToEmail = false; while (Date.now() - start < timeout) { throwIfStopped(); @@ -434,6 +536,26 @@ async function waitForSignupEntryState(options = {}) { return snapshot; } + if (snapshot.state === 'phone_entry') { + if (!autoOpenEntry) { + return snapshot; + } + + if (snapshot.switchToEmailTrigger && Date.now() - lastSwitchToEmailAt >= 1500) { + lastSwitchToEmailAt = Date.now(); + loggedMissingSwitchToEmail = false; + log('步骤 2:检测到手机号输入模式,正在切换到邮箱输入模式...'); + await humanPause(350, 900); + simulateClick(snapshot.switchToEmailTrigger); + } else if (!snapshot.switchToEmailTrigger && !loggedMissingSwitchToEmail) { + loggedMissingSwitchToEmail = true; + log('步骤 2:检测到手机号输入模式,但暂未识别到“改用邮箱/继续使用电子邮件地址登录”按钮,继续等待界面稳定...', 'warn'); + } + + await sleep(250); + continue; + } + if (snapshot.state === 'entry_home') { if (!autoOpenEntry) { return snapshot; @@ -455,7 +577,7 @@ async function waitForSignupEntryState(options = {}) { async function ensureSignupEntryReady(timeout = 15000) { const snapshot = await waitForSignupEntryState({ timeout, autoOpenEntry: false }); - if (snapshot.state === 'entry_home' || snapshot.state === 'email_entry' || snapshot.state === 'password_page') { + if (snapshot.state === 'entry_home' || snapshot.state === 'phone_entry' || snapshot.state === 'email_entry' || snapshot.state === 'password_page') { return { ready: true, state: snapshot.state, @@ -506,6 +628,10 @@ async function fillSignupEmailAndContinue(email, step) { }; } + if (snapshot.state === 'phone_entry') { + throw new Error(`步骤 ${step}:当前页面仍停留在手机号输入模式,未成功切换到邮箱输入模式。URL: ${location.href}`); + } + if (snapshot.state !== 'email_entry' || !snapshot.emailInput) { throw new Error(`步骤 ${step}:未找到可用的邮箱输入入口。URL: ${location.href}`); } diff --git a/tests/background-logging-status-module.test.js b/tests/background-logging-status-module.test.js index 9abedb6..6997090 100644 --- a/tests/background-logging-status-module.test.js +++ b/tests/background-logging-status-module.test.js @@ -15,3 +15,28 @@ test('logging/status module exposes a factory', () => { assert.equal(typeof api?.createLoggingStatus, 'function'); }); + +test('logging/status add-phone detection ignores step 2 phone-entry switch failures', () => { + const source = fs.readFileSync('background/logging-status.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundLoggingStatus;`)(globalScope); + + const loggingStatus = api.createLoggingStatus({ + chrome: { runtime: { sendMessage() { return Promise.resolve(); } } }, + DEFAULT_STATE: { stepStatuses: {} }, + getState: async () => ({ stepStatuses: {} }), + isRecoverableStep9AuthFailure: () => false, + LOG_PREFIX: '[test]', + setState: async () => {}, + STOP_ERROR_MESSAGE: 'stopped', + }); + + assert.equal( + loggingStatus.isAddPhoneAuthFailure('姝ラ 2锛氬綋鍓嶉〉闈粛鍋滅暀鍦ㄦ墜鏈哄彿杈撳叆妯″紡锛屾湭鎴愬姛鍒囨崲鍒伴偖绠辫緭鍏ユā寮忋€俇RL: https://chatgpt.com/'), + false + ); + assert.equal( + loggingStatus.isAddPhoneAuthFailure('姝ラ 8锛氶獙璇佺爜鎻愪氦鍚庨〉闈㈣繘鍏ユ墜鏈哄彿椤甸潰锛屽綋鍓嶆祦绋嬫棤娉曠户缁嚜鍔ㄦ巿鏉冦€?URL: https://auth.openai.com/add-phone'), + true + ); +}); diff --git a/tests/signup-entry-diagnostics.test.js b/tests/signup-entry-diagnostics.test.js index 2e99d06..c5ad8b9 100644 --- a/tests/signup-entry-diagnostics.test.js +++ b/tests/signup-entry-diagnostics.test.js @@ -112,10 +112,18 @@ function getSignupEmailInput() { return null; } +function getSignupPhoneInput() { + return null; +} + function getSignupPasswordInput() { return null; } +function findSignupUseEmailTrigger() { + return null; +} + function getPageTextSnapshot() { return 'Welcome to ChatGPT. Try our latest models.'; } diff --git a/tests/signup-step2-email-switch.test.js b/tests/signup-step2-email-switch.test.js new file mode 100644 index 0000000..d8bbfb5 --- /dev/null +++ b/tests/signup-step2-email-switch.test.js @@ -0,0 +1,431 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.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 extractConst(name) { + const pattern = new RegExp(`const\\s+${name}\\s*=\\s*[\\s\\S]*?;`); + const match = source.match(pattern); + if (!match) { + throw new Error(`missing const ${name}`); + } + return match[0]; +} + +test('waitForSignupEntryState switches from phone mode to email mode before step 2 fills the address', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +let phase = 'phone'; +let now = 0; + +const phoneInput = { + kind: 'phone', + getAttribute(name) { + if (name === 'type') return 'tel'; + return ''; + }, +}; + +const switchButton = { + textContent: 'Continue using email address', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const emailInput = { + kind: 'email', + getAttribute(name) { + if (name === 'type') return 'email'; + return ''; + }, +}; + +const document = { + querySelector(selector) { + if (selector === SIGNUP_EMAIL_INPUT_SELECTOR) { + return phase === 'email' ? emailInput : null; + } + if (selector === SIGNUP_PHONE_INPUT_SELECTOR) { + return phase === 'phone' ? phoneInput : null; + } + return null; + }, + querySelectorAll(selector) { + if (selector === 'button, a, [role="button"], [role="link"]') { + return phase === 'phone' ? [switchButton] : []; + } + if (selector === 'a, button, [role="button"], [role="link"]') { + return []; + } + if (selector === 'input') { + return phase === 'phone' ? [phoneInput] : [emailInput]; + } + return []; + }, +}; + +const location = { + href: 'https://chatgpt.com/', +}; + +const Date = { + now() { + return now; + }, +}; + +${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')} +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} +${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')} +${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')} +${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')} +${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')} +${extractConst('SIGNUP_WORK_EMAIL_PATTERN')} + +function isVisibleElement(el) { + return Boolean(el); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getSignupPasswordInput() { + return null; +} + +function isSignupPasswordPage() { + return false; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +function findSignupEntryTrigger() { + return null; +} + +function getSignupPasswordDisplayedEmail() { + return ''; +} + +function throwIfStopped() {} + +function log(message, level = 'info') { + logs.push({ message, level }); +} + +async function humanPause() {} + +function simulateClick(target) { + clicks.push(getActionText(target)); + if (target === switchButton) { + phase = 'email'; + } +} + +async function sleep(ms) { + now += ms; +} + +${extractFunction('getSignupEmailInput')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('findSignupUseEmailTrigger')} +${extractFunction('getSignupEmailContinueButton')} +${extractFunction('inspectSignupEntryState')} +${extractFunction('waitForSignupEntryState')} + +return { + async run() { + return waitForSignupEntryState({ timeout: 5000, autoOpenEntry: true }); + }, + getClicks() { + return clicks.slice(); + }, + getLogs() { + return logs.slice(); + }, +}; +`)(); + + const snapshot = await api.run(); + + assert.equal(snapshot.state, 'email_entry'); + assert.deepEqual(api.getClicks(), ['Continue using email address']); + assert.equal(api.getLogs().length > 0, true); +}); + +test('waitForSignupEntryState also recognizes the Chinese switch-to-email button text', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +let phase = 'phone'; +let now = 0; + +const phoneInput = { + kind: 'phone', + getAttribute(name) { + if (name === 'type') return 'tel'; + return ''; + }, +}; + +const switchButton = { + textContent: '\\u7ee7\\u7eed\\u4f7f\\u7528\\u7535\\u5b50\\u90ae\\u4ef6\\u5730\\u5740\\u767b\\u5f55', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const workEmailButton = { + textContent: '\\u7ee7\\u7eed\\u4f7f\\u7528\\u5de5\\u4f5c\\u7535\\u5b50\\u90ae\\u4ef6\\u5730\\u5740\\u767b\\u5f55', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'button'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const emailInput = { + kind: 'email', + getAttribute(name) { + if (name === 'type') return 'email'; + return ''; + }, +}; + +const document = { + querySelector(selector) { + if (selector === SIGNUP_EMAIL_INPUT_SELECTOR) { + return phase === 'email' ? emailInput : null; + } + if (selector === SIGNUP_PHONE_INPUT_SELECTOR) { + return phase === 'phone' ? phoneInput : null; + } + return null; + }, + querySelectorAll(selector) { + if (selector === 'button, a, [role="button"], [role="link"]') { + return phase === 'phone' ? [switchButton, workEmailButton] : []; + } + if (selector === 'a, button, [role="button"], [role="link"]') { + return []; + } + if (selector === 'input') { + return phase === 'phone' ? [phoneInput] : [emailInput]; + } + return []; + }, +}; + +const location = { + href: 'https://chatgpt.com/', +}; + +const Date = { + now() { + return now; + }, +}; + +${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')} +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} +${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')} +${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')} +${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')} +${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')} +${extractConst('SIGNUP_WORK_EMAIL_PATTERN')} + +function isVisibleElement(el) { + return Boolean(el); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getSignupPasswordInput() { + return null; +} + +function isSignupPasswordPage() { + return false; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +function findSignupEntryTrigger() { + return null; +} + +function getSignupPasswordDisplayedEmail() { + return ''; +} + +function throwIfStopped() {} + +function log(message, level = 'info') { + logs.push({ message, level }); +} + +async function humanPause() {} + +function simulateClick(target) { + clicks.push(getActionText(target)); + if (target === switchButton) { + phase = 'email'; + } +} + +async function sleep(ms) { + now += ms; +} + +${extractFunction('getSignupEmailInput')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('findSignupUseEmailTrigger')} +${extractFunction('getSignupEmailContinueButton')} +${extractFunction('inspectSignupEntryState')} +${extractFunction('waitForSignupEntryState')} + +return { + async run() { + return waitForSignupEntryState({ timeout: 5000, autoOpenEntry: true }); + }, + getClicks() { + return clicks.slice(); + }, +}; +`)(); + + const snapshot = await api.run(); + + assert.equal(snapshot.state, 'email_entry'); + assert.deepEqual(api.getClicks(), ['继续使用电子邮件地址登录']); +}); + +test('getSignupEmailInput recognizes localized email placeholders in text inputs', () => { + const api = new Function(` +const localizedEmailInput = { + kind: 'localized-email', + getAttribute(name) { + if (name === 'placeholder') return '电子邮件地址'; + if (name === 'type') return 'text'; + return ''; + }, +}; + +const document = { + querySelector() { + return null; + }, + querySelectorAll(selector) { + if (selector === 'input') { + return [localizedEmailInput]; + } + return []; + }, +}; + +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} + +function isVisibleElement(el) { + return Boolean(el); +} + +${extractFunction('getSignupEmailInput')} + +return { + run() { + return getSignupEmailInput(); + }, +}; +`)(); + + assert.equal(api.run()?.kind, 'localized-email'); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index a506ac9..bd23819 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -566,3 +566,10 @@ - Step 8 如果发现认证页已经进入登录超时报错/重试页,会直接报错并回到 Step 7 重新开始,而不是在 Step 8 内部点击 `重试`。 - 任意认证页重试页如果正文中出现 `max_check_attempts`,会被视为 Cloudflare 风控触发:后台立刻完全停止流程,侧边栏会复用现有确认弹窗提示等待 15~30 分钟后再试,避免继续刷新或反复重试加重风控,确认按钮显示为“我知道了”。 - Step 9 在点击 OAuth 同意页 `继续` 后,会额外检查是否进入认证页重试页;若命中则先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再重新执行当前轮的 `继续` 点击。 + + +## 2026-04 Step 2 手机号态说明 + +- Step 2 如果发现注册弹窗默认处于手机号输入模式,会先尝试点击 继续使用电子邮件地址登录 / Continue using email address 这类按钮,切回邮箱输入模式后再继续填写邮箱。 +- Step 2 对邮箱输入框的识别不再只依赖英文 email,也会接受 电子邮件地址、邮箱 等本地化占位或 ria-label。 +- Step 2 日志里的“手机号输入模式”只表示注册弹窗当前默认展示了手机号输入框,本身不等同于 auth dd-phone 致命页;只有明确进入认证流程的手机号页面时,自动运行才会按 fatal 停止。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 2e52113..a66e1b1 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -43,7 +43,7 @@ - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。 -- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 +- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。 - `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。