Files
FlowPilot/content/mail-163.js
T
2026-05-13 06:35:43 +08:00

784 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
const MAIL163_PREFIX = '[MultiPage:mail-163]';
const isTopFrame = window === window.top;
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
// Only operate in the top frame
if (!isTopFrame) {
console.log(MAIL163_PREFIX, 'Skipping child frame');
} else {
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
let seenCodes = new Set();
async function loadSeenCodes() {
try {
const data = await chrome.storage.session.get('seenCodes');
if (data.seenCodes && Array.isArray(data.seenCodes)) {
seenCodes = new Set(data.seenCodes);
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
}
} catch (err) {
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
}
}
// Load previously seen codes on startup
loadSeenCodes();
async function persistSeenCodes() {
try {
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
} catch (err) {
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
}
}
// ============================================================
// Message Handler (top frame only)
// ============================================================
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`步骤 ${message.step}:已被用户停止。`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn');
sendResponse({ error: err.message });
});
return true;
}
});
// ============================================================
// Find mail items
// ============================================================
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function normalizeRulePatternList(patterns = []) {
return Array.isArray(patterns) ? patterns : [];
}
function extractCodeByRulePatterns(text, patterns = []) {
const normalizedText = String(text || '');
for (const pattern of normalizeRulePatternList(patterns)) {
try {
const source = String(pattern?.source || '').trim();
if (!source) {
continue;
}
const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, '');
const match = normalizedText.match(new RegExp(source, flags));
if (!match) {
continue;
}
for (let index = 1; index < match.length; index += 1) {
const candidate = String(match[index] || '').trim();
if (candidate) {
return candidate;
}
}
if (String(match[0] || '').trim()) {
return String(match[0] || '').trim();
}
} catch (_) {
// Ignore invalid runtime rule patterns and continue with other candidates.
}
}
return null;
}
function getNetEaseMailLabel(hostname) {
const currentHostname = String(
hostname || (typeof location !== 'undefined' ? location.hostname : '') || ''
).toLowerCase();
if (currentHostname === 'mail.126.com' || currentHostname.endsWith('.mail.126.com')) {
return '126 邮箱';
}
if (currentHostname === 'webmail.vip.163.com') {
return '163 VIP 邮箱';
}
return '163 邮箱';
}
function isVisibleNode(node) {
if (!node) return false;
if (node.hidden) return false;
const style = typeof window.getComputedStyle === 'function'
? window.getComputedStyle(node)
: null;
if (style && (style.display === 'none' || style.visibility === 'hidden')) {
return false;
}
const rect = typeof node.getBoundingClientRect === 'function'
? node.getBoundingClientRect()
: null;
if (rect && rect.width <= 0 && rect.height <= 0) {
return false;
}
return true;
}
function isLikelyMailItemNode(node) {
if (!isVisibleNode(node)) {
return false;
}
if (node.matches?.('div[sign="letter"]')) {
return true;
}
if (node.querySelector?.('.nui-user, span.da0, [sign="trash"], [title="删除邮件"], [class*="subject"], [class*="sender"]')) {
return true;
}
const summaryText = normalizeText(
node.getAttribute?.('aria-label')
|| node.getAttribute?.('title')
|| node.textContent
);
if (!summaryText) {
return false;
}
return /发件人|验证码|verification|code|log-?in/i.test(summaryText);
}
function findMailItems() {
const selectorGroups = [
'div[sign="letter"]',
'[role="option"][aria-label]',
'[role="listitem"][aria-label]',
'tr[aria-label]',
'li[aria-label]',
'div[aria-label]',
];
for (const selector of selectorGroups) {
const matches = Array.from(document.querySelectorAll(selector)).filter(isLikelyMailItemNode);
if (matches.length > 0) {
return matches;
}
}
return [];
}
function getMailTextBySelectors(item, selectors = []) {
for (const selector of selectors) {
const candidates = item.querySelectorAll(selector);
for (const candidate of candidates) {
const texts = [
candidate.getAttribute?.('title'),
candidate.getAttribute?.('aria-label'),
candidate.textContent,
];
for (const text of texts) {
const normalized = normalizeText(text);
if (normalized) {
return normalized;
}
}
}
}
return '';
}
function getMailSenderText(item) {
return getMailTextBySelectors(item, [
'.nui-user',
'[class*="sender"]',
'[class*="from"]',
'[data-sender]',
]);
}
function getMailSubjectText(item) {
return getMailTextBySelectors(item, [
'span.da0',
'[class*="subject"]',
'[data-subject]',
'strong',
]);
}
function getMailRowText(item) {
const ariaLabel = normalizeText(item.getAttribute('aria-label'));
const sender = getMailSenderText(item);
const subject = getMailSubjectText(item);
const fullText = normalizeText(item.textContent || '');
return normalizeText([ariaLabel, sender, subject, fullText].filter(Boolean).join(' '));
}
function getMailItemId(item, index = 0) {
const candidates = [
item.getAttribute('id'),
item.getAttribute('data-id'),
item.dataset?.id,
item.getAttribute('data-key'),
item.getAttribute('key'),
].filter(Boolean);
if (candidates.length > 0) {
return String(candidates[0]);
}
return `${index}|${getMailRowText(item).slice(0, 240)}`;
}
function getCurrentMailIds(items = []) {
const ids = new Set();
const sourceItems = items.length > 0 ? items : findMailItems();
sourceItems.forEach((item, index) => {
ids.add(getMailItemId(item, index));
});
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(/今天\s*(\d{1,2}):(\d{2})/);
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();
}
match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
if (match) {
const [, hour, minute] = match;
const now = new Date();
now.setDate(now.getDate() - 1);
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
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 isLikelyMailTimestampText(value) {
const text = normalizeText(value);
if (!text) {
return false;
}
return /(\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2})|今天\s*\d{1,2}:\d{2}|昨天\s*\d{1,2}:\d{2}|\b\d{1,2}:\d{2}\b/.test(text);
}
function collectMailTimestampCandidates(item) {
const candidates = [];
const seen = new Set();
const pushCandidate = (value) => {
const normalized = normalizeText(value);
if (!normalized || !isLikelyMailTimestampText(normalized) || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
};
const priorityNodes = item.querySelectorAll('.e00, [title], [aria-label], time, [class*="time"], [class*="date"]');
priorityNodes.forEach((node) => {
pushCandidate(node.getAttribute?.('title'));
pushCandidate(node.getAttribute?.('aria-label'));
pushCandidate(node.textContent);
});
const textNodes = item.querySelectorAll('span, div, td, strong, b');
textNodes.forEach((node) => {
const text = normalizeText(node.textContent);
if (text && text.length <= 24) {
pushCandidate(text);
}
});
pushCandidate(item.getAttribute('aria-label'));
pushCandidate(item.getAttribute('title'));
return candidates;
}
function getMailTimestamp(item) {
const candidates = collectMailTimestampCandidates(item);
for (const candidate of candidates) {
const parsed = parseMail163Timestamp(candidate);
if (parsed) return parsed;
}
return null;
}
function collectOpenedMailTextCandidates() {
const texts = [];
const seen = new Set();
const pushText = (value) => {
const normalized = normalizeText(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
texts.push(normalized);
};
const selectors = [
'.readHtml',
'[class*="readmail"]',
'[class*="mailread"]',
'[class*="mailBody"]',
'[class*="mailbody"]',
'[class*="mail-content"]',
'[class*="mailContent"]',
'[class*="mail-detail"]',
'[class*="mailDetail"]',
'[class*="detail"] [class*="content"]',
'[class*="read"] [class*="content"]',
'[role="main"]',
];
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((node) => {
pushText(node.innerText || node.textContent);
});
});
document.querySelectorAll('iframe').forEach((frame) => {
try {
pushText(frame.contentDocument?.body?.innerText || frame.contentDocument?.body?.textContent);
} catch {
// Ignore cross-frame access errors and keep trying other candidates.
}
});
pushText(document.body?.innerText || document.body?.textContent);
return texts.sort((a, b) => b.length - a.length);
}
function selectOpenedMailTextCandidate(item, candidates = [], options = {}) {
const subject = normalizeText(getMailSubjectText(item)).toLowerCase();
const sender = normalizeText(getMailSenderText(item)).toLowerCase();
const excludedSet = new Set((options.excludedTexts || []).map((value) => normalizeText(value)));
const allowExcludedFallback = options.allowExcludedFallback !== false;
const pickCandidate = (source) => source.find((candidate) => {
const lower = candidate.toLowerCase();
if (subject && lower.includes(subject)) {
return true;
}
if (sender && lower.includes(sender)) {
return true;
}
return Boolean(extractVerificationCode(candidate, { codePatterns: options.codePatterns }) && /verification|验证码|log-?in\s+code|enter\s+this\s+code|code/i.test(lower));
}) || source[0] || '';
const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate)));
const preferred = pickCandidate(filteredCandidates);
if (preferred || !allowExcludedFallback) {
return preferred;
}
return pickCandidate(candidates);
}
function readOpenedMailText(item, options = {}) {
const candidates = collectOpenedMailTextCandidates();
return selectOpenedMailTextCandidate(item, candidates, options);
}
async function returnToInbox() {
const inboxLink = document.querySelector('.nui-tree-item-text[title="收件箱"], [title="收件箱"]');
if (inboxLink) {
if (typeof simulateClick === 'function') {
simulateClick(inboxLink);
} else {
inboxLink.click();
}
}
for (let i = 0; i < 20; i += 1) {
if (findMailItems().length > 0) {
return true;
}
await sleep(250);
}
return false;
}
async function openMailAndGetMessageText(item, options = {}) {
const beforeCandidates = collectOpenedMailTextCandidates();
const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates, {
codePatterns: options.codePatterns,
});
if (typeof simulateClick === 'function') {
simulateClick(item);
} else {
item.click();
}
let openedText = '';
for (let i = 0; i < 24; i += 1) {
await sleep(250);
const candidate = readOpenedMailText(item, {
codePatterns: options.codePatterns,
excludedTexts: beforeCandidates,
allowExcludedFallback: false,
});
if (!candidate) {
continue;
}
openedText = candidate;
if (extractVerificationCode(candidate, { codePatterns: options.codePatterns })) {
break;
}
if (candidate !== beforeText && candidate.length > beforeText.length + 24) {
break;
}
}
await returnToInbox();
return openedText;
}
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);
}
// ============================================================
// Email Polling
// ============================================================
async function handlePollEmail(step, payload) {
const {
codePatterns = [],
senderFilters,
subjectFilters,
maxAttempts,
intervalMs,
excludeCodes = [],
filterAfterTimestamp = 0,
} = payload;
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
const mailLabel = getNetEaseMailLabel();
log(`步骤 ${step}:开始轮询 ${mailLabel}(最多 ${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(`${mailLabel}列表未加载完成,请确认当前已打开收件箱。`);
}
log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`);
// Snapshot existing mail IDs
const existingMailIds = getCurrentMailIds(items);
log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`);
const FALLBACK_AFTER = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`步骤 ${step}:正在轮询 ${mailLabel},第 ${attempt}/${maxAttempts}`);
if (attempt > 1) {
await refreshInbox();
await sleep(1000);
}
const allItems = findMailItems();
const useFallback = attempt > FALLBACK_AFTER;
for (let index = 0; index < allItems.length; index++) {
const item = allItems[index];
const id = getMailItemId(item, index);
const mailTimestamp = getMailTimestamp(item);
const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0);
const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute);
if (!passesTimeFilter) {
continue;
}
if (!useFallback && existingMailIds.has(id)) {
continue;
}
const sender = getMailSenderText(item).toLowerCase();
const subject = getMailSubjectText(item);
const rowText = getMailRowText(item);
const ariaLabel = normalizeText(item.getAttribute('aria-label')).toLowerCase();
const combinedText = normalizeText([subject, ariaLabel, rowText].filter(Boolean).join(' '));
if (!mailTimestamp) {
log(`步骤 ${step}:邮件 ${id.slice(0, 60)} 未读取到时间,已跳过时间窗口校验后的文本匹配阶段。`, 'info');
}
const senderMatch = senderFilters.some((filter) => {
const normalizedFilter = String(filter || '').toLowerCase();
return normalizedFilter && (sender.includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
});
const subjectMatch = subjectFilters.some((filter) => {
const normalizedFilter = String(filter || '').toLowerCase();
return normalizedFilter && (subject.toLowerCase().includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
});
if (senderMatch || subjectMatch) {
let code = extractVerificationCode(combinedText, { codePatterns });
let codeSource = '邮件列表';
if (!code) {
const openedText = await openMailAndGetMessageText(item, { codePatterns });
code = extractVerificationCode(openedText, { codePatterns });
if (code) {
codeSource = '邮件正文';
}
}
if (code && excludedCodeSet.has(code)) {
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
} else if (code && !seenCodes.has(code)) {
seenCodes.add(code);
persistSeenCodes();
const source = useFallback && existingMailIds.has(id) ? `回退匹配${codeSource}` : `新邮件${codeSource}`;
const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
log(`步骤 ${step}:已找到验证码:${code}(来源:${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');
}
}
}
if (attempt === FALLBACK_AFTER + 1) {
log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`${(maxAttempts * intervalMs / 1000).toFixed(0)} 秒后仍未在 ${mailLabel}中找到新的匹配邮件。` +
'请手动检查收件箱。'
);
}
// ============================================================
// 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: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
// These icons appear on hover, so we trigger mouseover first
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await sleep(300);
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
if (trashIcon) {
trashIcon.click();
log(`步骤 ${step}:已点击删除图标`, '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();
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;
}
}
}
log(`步骤 ${step}:无法删除邮件(未找到删除按钮)`, 'warn');
} catch (err) {
log(`步骤 ${step}:删除邮件失败:${err.message}`, 'warn');
}
}
// ============================================================
// Inbox Refresh
// ============================================================
async function refreshInbox() {
// Try toolbar "刷 新" button
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
for (const btn of toolbarBtns) {
if (btn.textContent.replace(/\s/g, '') === '刷新') {
btn.closest('.nui-btn').click();
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
await sleep(800);
return;
}
}
// Fallback: click sidebar "收 信"
const shouXinBtns = document.querySelectorAll('.ra0');
for (const btn of shouXinBtns) {
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
btn.click();
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
await sleep(800);
return;
}
}
console.log(MAIL163_PREFIX, 'Could not find refresh button');
}
// ============================================================
// Verification Code Extraction
// ============================================================
function extractVerificationCode(text, options = {}) {
const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns);
if (matchedByRule) {
return matchedByRule;
}
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i);
if (matchLoginCode) return matchLoginCode[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