// ==UserScript== // @name Gmail 批量辅助 (富文本版) // @namespace http://tampermonkey.net/ // @version 2.0 // @description 使用 ExcelJS 读取单元格样式,发送富文本邮件。全程 DOM API,避开 TrustedHTML。 // @author YourName // @match https://mail.google.com/* // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.4.0/exceljs.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // ==/UserScript== (function () { 'use strict'; let excelData = []; // 每行: { raw: {key:val}, cells: {key: {text, fragments}} } let isRunning = false; let isMinimized = false; let currentIndex = 0; let previewCount = 8; let sendDelay = 100; const mapping = { to: '', sub: '', body: '' }; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const isAnyWindowOpen = () => { return document.querySelectorAll('div[role="dialog"]').length > 0; }; const waitForElement = (parent, selector, timeout = 8000) => { return new Promise((resolve, reject) => { const start = Date.now(); const timer = setInterval(() => { const el = parent.querySelector(selector); if (el) { clearInterval(timer); resolve(el); } else if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error(`等待元素超时: ${selector}`)); } }, 100); }); }; // --- ExcelJS 单元格样式 → DOM 节点 --- // 主题颜色表 (从 xlsx theme1.xml 中动态提取,以下为 Office 默认值兜底) let THEME_COLORS = [ 'FFFFFF', '000000', 'E7E6E6', '44546A', '4472C4', 'ED7D31', 'A5A5A5', 'FFC000', '5B9BD5', '70AD47', ]; /** * 从 xlsx 的 ArrayBuffer 中解析 theme1.xml,提取实际主题色 * 主题色在 节点下,顺序: dk1, lt1, dk2, lt2, accent1-6 * ExcelJS 的 theme 索引映射: 0=lt1, 1=dk1, 2=lt2, 3=dk2, 4-9=accent1-6 */ const extractThemeColors = async (arrayBuffer) => { try { const zip = await JSZip.loadAsync(arrayBuffer); const themeFile = zip.file('xl/theme/theme1.xml'); if (!themeFile) return; const xml = await themeFile.async('string'); // 解析 XML const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'application/xml'); // 提取颜色值 (兼容带命名空间和不带的情况) const getColor = (el) => { // 查找 sysClr 或 srgbClr 子元素 const srgb = el.querySelector('srgbClr') || el.getElementsByTagNameNS('*', 'srgbClr')[0]; if (srgb) return srgb.getAttribute('val'); const sys = el.querySelector('sysClr') || el.getElementsByTagNameNS('*', 'sysClr')[0]; if (sys) return sys.getAttribute('lastClr') || sys.getAttribute('val'); return null; }; // clrScheme 子元素顺序: dk1, lt1, dk2, lt2, accent1-6, hlink, folHlink const schemeNames = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']; const schemeColors = {}; for (const name of schemeNames) { const el = doc.querySelector(name) || doc.getElementsByTagNameNS('*', name)[0]; if (el) { const c = getColor(el); if (c) schemeColors[name] = c.toUpperCase(); } } // ExcelJS theme 索引: 0=lt1, 1=dk1, 2=lt2, 3=dk2, 4-9=accent1-6 const mapping = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']; const colors = mapping.map(name => schemeColors[name]); // 只在全部提取成功时替换 if (colors.every(c => c)) { THEME_COLORS = colors; console.log('[Gmail助手] 已从文件中提取主题色:', THEME_COLORS); } } catch (err) { console.warn('[Gmail助手] 提取主题色失败,使用默认值:', err); } }; /** * 对 RGB 分量应用 tint 调整 * tint > 0: 向白色靠近; tint < 0: 向黑色靠近 */ const applyTint = (r, g, b, tint) => { if (tint > 0) { r = r + (255 - r) * tint; g = g + (255 - g) * tint; b = b + (255 - b) * tint; } else if (tint < 0) { r = r * (1 + tint); g = g * (1 + tint); b = b * (1 + tint); } return [Math.round(r), Math.round(g), Math.round(b)]; }; /** * 将 ExcelJS 颜色对象转换为 CSS 颜色 * 支持 argb / theme+tint / indexed 格式 */ const argbToCss = (color) => { if (!color) return ''; // 直接 ARGB if (color.argb) { const hex = color.argb; if (hex.length === 8) return '#' + hex.substring(2); if (hex.length === 6) return '#' + hex; } // Theme 颜色 if (color.theme !== undefined && color.theme !== null) { const base = THEME_COLORS[color.theme]; if (base) { let r = parseInt(base.substring(0, 2), 16); let g = parseInt(base.substring(2, 4), 16); let b = parseInt(base.substring(4, 6), 16); if (color.tint) { [r, g, b] = applyTint(r, g, b, color.tint); } return `rgb(${r},${g},${b})`; } } return ''; }; /** * 将 ExcelJS font 对象的样式应用到一个 DOM 元素上 */ const applyFontStyle = (el, font) => { if (!font) return; if (font.bold) el.style.fontWeight = 'bold'; if (font.italic) el.style.fontStyle = 'italic'; if (font.underline) el.style.textDecoration = 'underline'; if (font.strike) { el.style.textDecoration = el.style.textDecoration ? el.style.textDecoration + ' line-through' : 'line-through'; } if (font.size) el.style.fontSize = font.size + 'pt'; if (font.name) el.style.fontFamily = font.name; const color = argbToCss(font.color); if (color) el.style.color = color; }; /** * 解析 ExcelJS 单元格,返回结构化数据: * { text: string, fragments: [{text, font}] } */ const parseCellValue = (cell) => { if (!cell || cell.value === null || cell.value === undefined) { return { text: '', fragments: [{ text: '', font: null }] }; } // 富文本单元格 if (cell.type === ExcelJS.ValueType.RichText && cell.value.richText) { const fragments = cell.value.richText.map(rt => ({ text: rt.text || '', font: rt.font || null })); const text = fragments.map(f => f.text).join(''); return { text, fragments }; } // 普通单元格 (可能有整体 font 样式) const text = cell.text || String(cell.value); return { text, fragments: [{ text, font: cell.font || null }] }; }; /** * 将 fragments 数组渲染为 DOM 节点列表 * 如果需要模板变量替换,会在 text 中查找 ${var} 并用 row 数据替换 */ const fragmentsToDom = (fragments, row = null) => { const nodes = []; for (const frag of fragments) { let textParts; if (row) { // 模板变量替换: 将 ${var} 替换为 row 中的值 textParts = splitByTemplate(frag.text, row); } else { textParts = [{ text: frag.text, isVar: false }]; } for (const part of textParts) { // 按换行符拆分,插入
实现换行 const lines = part.text.split(/\r?\n/); for (let li = 0; li < lines.length; li++) { if (li > 0) nodes.push(document.createElement('br')); if (lines[li]) { const span = document.createElement('span'); applyFontStyle(span, frag.font); span.textContent = lines[li]; nodes.push(span); } } } } return nodes; }; /** * 将文本按 ${var} 模板分割,返回 [{text, isVar}] */ const splitByTemplate = (str, row) => { if (!str) return [{ text: '', isVar: false }]; const parts = []; const regex = /\${(.*?)}/g; let lastIndex = 0; let match; while ((match = regex.exec(str)) !== null) { if (match.index > lastIndex) { parts.push({ text: str.substring(lastIndex, match.index), isVar: false }); } const key = match[1].trim(); const val = row[key] !== undefined ? String(row[key]) : match[0]; parts.push({ text: val, isVar: true }); lastIndex = regex.lastIndex; } if (lastIndex < str.length) { parts.push({ text: str.substring(lastIndex), isVar: false }); } if (parts.length === 0) { parts.push({ text: str, isVar: false }); } return parts; }; /** * 将 fragments 渲染为纯文本 (用于主题等不支持富文本的场景) */ const fragmentsToText = (fragments, row = null) => { return fragments.map(f => { if (row) { return splitByTemplate(f.text, row).map(p => p.text).join(''); } return f.text; }).join(''); }; // --- UI 构建 --- const createUI = () => { const panel = document.createElement('div'); panel.id = 'gmail-helper-ui'; panel.style = 'position:fixed; top:70px; left:20px; z-index:9999; background:white; border:1px solid #dadce0; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.15); width:400px; transition: width 0.3s; overflow: hidden; font-family: sans-serif;'; const header = document.createElement('div'); header.style = 'background: #f1f3f4; padding: 10px 15px; cursor: move; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dadce0; user-select: none;'; const titleSpan = document.createElement('span'); titleSpan.textContent = 'Gmail 发送助手 v2.0'; titleSpan.style.fontWeight = 'bold'; header.appendChild(titleSpan); const toggleBtn = document.createElement('button'); toggleBtn.textContent = '-'; toggleBtn.style = 'border:none; background:none; cursor:pointer; font-size:16px;'; const bodyContainer = document.createElement('div'); bodyContainer.style = 'padding: 15px; max-height: 75vh; overflow-y: auto;'; toggleBtn.onclick = () => { isMinimized = !isMinimized; bodyContainer.style.display = isMinimized ? 'none' : 'block'; panel.style.width = isMinimized ? '200px' : '400px'; toggleBtn.textContent = isMinimized ? '+' : '-'; }; header.appendChild(toggleBtn); panel.appendChild(header); const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.xlsx, .xls'; fileInput.onchange = handleFile; bodyContainer.appendChild(fileInput); const configArea = document.createElement('div'); configArea.id = 'config-area'; configArea.style.display = 'none'; bodyContainer.appendChild(configArea); panel.appendChild(bodyContainer); document.body.appendChild(panel); makeDraggable(panel, header); }; async function handleFile(e) { const file = e.target.files[0]; if (!file) return; const arrayBuffer = await file.arrayBuffer(); // 先从 xlsx 中提取实际主题色 await extractThemeColors(arrayBuffer); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(arrayBuffer); const worksheet = workbook.worksheets[0]; if (!worksheet) return; excelData = []; const headers = []; // 读取表头 (第一行) const headerRow = worksheet.getRow(1); headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => { headers[colNumber] = cell.text || String(cell.value || ''); }); // 读取数据行 for (let rowNum = 2; rowNum <= worksheet.rowCount; rowNum++) { const row = worksheet.getRow(rowNum); const raw = {}; const cells = {}; let hasData = false; headers.forEach((h, colNum) => { if (!h) return; const cell = row.getCell(colNum); const parsed = parseCellValue(cell); raw[h] = parsed.text; cells[h] = parsed; if (parsed.text.trim()) hasData = true; }); if (hasData) { excelData.push({ raw, cells }); } } if (excelData.length > 0) { renderConfig(headers.filter(Boolean)); } } function renderConfig(headers) { const area = document.getElementById('config-area'); area.replaceChildren(); area.style.display = 'block'; const createSelect = (label, key) => { const p = document.createElement('p'); p.textContent = label; p.style.margin = '10px 0 2px 0'; const sel = document.createElement('select'); sel.style.width = '100%'; headers.forEach(h => { const opt = document.createElement('option'); opt.value = h; opt.textContent = h; sel.appendChild(opt); }); sel.onchange = () => { mapping[key] = sel.value; updatePreview(); }; mapping[key] = sel.value; area.appendChild(p); area.appendChild(sel); }; createSelect('收件人列:', 'to'); createSelect('主题模板:', 'sub'); createSelect('正文模板:', 'body'); const ctrlDiv = document.createElement('div'); ctrlDiv.style = 'margin-top: 15px; display: flex; align-items: center; gap: 10px; border-top: 1px solid #eee; padding-top: 10px;'; ctrlDiv.appendChild(Object.assign(document.createElement('span'), { textContent: '预览条数:' })); const ctrlInput = Object.assign(document.createElement('input'), { type: 'number', value: previewCount, min: 1, style: 'width:50px' }); ctrlInput.onchange = (e) => { previewCount = parseInt(e.target.value) || 8; updatePreview(); }; ctrlDiv.appendChild(ctrlInput); ctrlDiv.appendChild(Object.assign(document.createElement('span'), { textContent: '发送间隔(ms):' })); const delayInput = Object.assign(document.createElement('input'), { type: 'number', value: sendDelay, min: 0, step: 100, style: 'width:70px' }); delayInput.onchange = (e) => { sendDelay = Math.max(0, parseInt(e.target.value) || 100); }; ctrlDiv.appendChild(delayInput); area.appendChild(ctrlDiv); const previewBox = Object.assign(document.createElement('div'), { id: 'data-preview-container' }); previewBox.style = 'background: #f8f9fa; border: 1px solid #eee; padding: 0 10px; border-radius: 4px; font-size: 11px; max-height: 200px; overflow-y: auto; margin-top: 10px;'; area.appendChild(previewBox); const statusLabel = Object.assign(document.createElement('div'), { id: 'test-status', textContent: '准备就绪' }); statusLabel.style = 'margin-top:10px; font-weight:bold; color:#1a73e8;'; area.appendChild(statusLabel); const runBtn = Object.assign(document.createElement('button'), { id: 'run-btn', textContent: '确认并开始批量发送' }); runBtn.style = 'margin-top:10px; width:100%; height:40px; background:#d93025; color:white; border:none; border-radius:4px; font-weight:bold; cursor:pointer;'; runBtn.onclick = toggleTask; area.appendChild(runBtn); updatePreview(); } function updatePreview() { const container = document.getElementById('data-preview-container'); if (!container || !excelData.length) return; container.replaceChildren(); const count = Math.min(excelData.length, previewCount); for (let i = 0; i < count; i++) { const { raw, cells } = excelData[i]; const itemDiv = document.createElement('div'); itemDiv.style = 'padding: 8px 0; border-bottom: 1px dashed #ccc;'; // 收件人 (纯文本) const toDiv = document.createElement('div'); toDiv.appendChild(Object.assign(document.createElement('strong'), { textContent: `[${i + 1}] 收件: ` })); toDiv.appendChild(document.createTextNode(raw[mapping.to] || '')); itemDiv.appendChild(toDiv); // 主题 (带模板替换,但无富文本样式展示) const subDiv = document.createElement('div'); subDiv.appendChild(Object.assign(document.createElement('strong'), { textContent: '主: ' })); const subCell = cells[mapping.sub]; if (subCell) { subDiv.appendChild(document.createTextNode(fragmentsToText(subCell.fragments, raw))); } itemDiv.appendChild(subDiv); // 正文 (富文本样式预览) const bodyDiv = document.createElement('div'); bodyDiv.appendChild(Object.assign(document.createElement('strong'), { textContent: '正: ' })); const bodyCell = cells[mapping.body]; if (bodyCell) { const domNodes = fragmentsToDom(bodyCell.fragments, raw); domNodes.forEach(n => bodyDiv.appendChild(n)); } itemDiv.appendChild(bodyDiv); container.appendChild(itemDiv); } } async function toggleTask() { if (isRunning) { isRunning = false; return; } isRunning = true; document.getElementById('run-btn').textContent = '停止批量发送'; document.getElementById('run-btn').style.background = '#333'; await runMainLoop(); } async function runMainLoop() { while (isRunning && currentIndex < excelData.length) { const status = document.getElementById('test-status'); if (isAnyWindowOpen()) { status.textContent = `等待窗口空闲... (${currentIndex + 1}/${excelData.length})`; await sleep(100); continue; } const { raw, cells } = excelData[currentIndex]; status.textContent = `正在准备发送: ${currentIndex + 1} / ${excelData.length}`; try { // 1. 点击写信 document.querySelector('div[gh="cm"]')?.click(); // 2. 定位 Dialog let dialog = null; for (let i = 0; i < 20; i++) { dialog = document.querySelector('div[role="dialog"]'); if (dialog) break; await sleep(100); } if (!dialog) throw new Error("无法开启写信窗口"); // 3. 获取元素 const toInput = await waitForElement(dialog, 'input[peoplekit-id="BbVjBd"]'); const subInput = await waitForElement(dialog, 'input[name="subjectbox"]'); const bodyDiv = await waitForElement(dialog, 'div[role="textbox"][contenteditable]'); // 4. 清空收件人 let checkCount = 0; while (toInput.value !== "" && checkCount < 10) { toInput.value = ""; toInput.dispatchEvent(new Event('input', { bubbles: true })); await sleep(100); checkCount++; } // 5. 填写收件人 toInput.value = raw[mapping.to] || ""; toInput.dispatchEvent(new Event('input', { bubbles: true })); // 6. 填写主题 (纯文本,支持模板变量) const subCell = cells[mapping.sub]; subInput.value = subCell ? fragmentsToText(subCell.fragments, raw) : ''; // 7. 填写正文 (富文本 DOM) bodyDiv.replaceChildren(); const bodyCell = cells[mapping.body]; if (bodyCell) { const p = document.createElement('p'); const domNodes = fragmentsToDom(bodyCell.fragments, raw); domNodes.forEach(n => p.appendChild(n)); bodyDiv.appendChild(p); } // 填写后的缓冲 await sleep(100); // 8. 点击发送按钮 const sendBtn = dialog.querySelector('div[class="T-I J-J5-Ji aoO v7 T-I-atl L3"]'); if (sendBtn) { sendBtn.click(); } else { // 兜底 Ctrl+Enter bodyDiv.focus(); bodyDiv.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', ctrlKey: true, bubbles: true })); } // 9. 等待窗口彻底消失 while (isAnyWindowOpen()) { await sleep(100); } // 10. 发送间隔 if (sendDelay > 0) await sleep(sendDelay); currentIndex++; } catch (err) { console.error(err); status.textContent = `错误: ${err.message}`; isRunning = false; break; } } stopUI(); } function stopUI() { isRunning = false; const btn = document.getElementById('run-btn'); if (btn) { btn.textContent = '确认并开始批量发送'; btn.style.background = '#d93025'; } const st = document.getElementById('test-status'); if (st) st.textContent = currentIndex >= excelData.length ? '全部发送任务已完成' : '任务已停止'; } function makeDraggable(el, handle) { let p1 = 0, p2 = 0, p3 = 0, p4 = 0; handle.onmousedown = (e) => { p3 = e.clientX; p4 = e.clientY; document.onmouseup = () => { document.onmouseup = null; document.onmousemove = null; }; document.onmousemove = (e) => { p1 = p3 - e.clientX; p2 = p4 - e.clientY; p3 = e.clientX; p4 = e.clientY; el.style.top = (el.offsetTop - p2) + "px"; el.style.left = (el.offsetLeft - p1) + "px"; }; }; } window.addEventListener("load", (e) => { createUI() }) })();