UNPKG

article-writer-cn

Version:

AI 驱动的智能写作系统 - 专注公众号/自媒体文章创作

749 lines (737 loc) 26.4 kB
/** * 微信公众号 Markdown 格式化器 * 基于 doocs/md 的核心渲染引擎 * 将 Markdown 转换为微信公众号可用的富文本 HTML */ import { marked, Renderer } from 'marked'; import hljs from 'highlight.js/lib/core'; import path from 'path'; import os from 'os'; import fs from 'fs-extra'; import { downloadImage, imageToBase64 } from '../utils/image-downloader.js'; // 注册常用编程语言 import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import python from 'highlight.js/lib/languages/python'; import java from 'highlight.js/lib/languages/java'; import go from 'highlight.js/lib/languages/go'; import rust from 'highlight.js/lib/languages/rust'; import cpp from 'highlight.js/lib/languages/cpp'; import bash from 'highlight.js/lib/languages/bash'; import json from 'highlight.js/lib/languages/json'; import yaml from 'highlight.js/lib/languages/yaml'; import markdown from 'highlight.js/lib/languages/markdown'; import sql from 'highlight.js/lib/languages/sql'; hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('python', python); hljs.registerLanguage('java', java); hljs.registerLanguage('go', go); hljs.registerLanguage('rust', rust); hljs.registerLanguage('cpp', cpp); hljs.registerLanguage('bash', bash); hljs.registerLanguage('json', json); hljs.registerLanguage('yaml', yaml); hljs.registerLanguage('markdown', markdown); hljs.registerLanguage('sql', sql); /** * 将 CSS 对象转换为样式字符串 */ function getStyleString(styleObj) { return Object.entries(styleObj) .map(([key, value]) => { // 转义双引号为单引号,避免破坏 HTML 属性 const escapedValue = value.replace(/"/g, "'"); return `${key}:${escapedValue}`; }) .join(';'); } /** * 转义 HTML 特殊字符 */ function escapeHtml(text) { return text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/`/g, '&#96;'); } /** * 构建主题样式 */ function buildTheme(options) { const primaryColor = options.primaryColor || '#000000'; const fontSize = options.fontSize || '16px'; const fontFamily = options.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; const base = { 'font-family': fontFamily, 'font-size': fontSize, 'line-height': '1.75', 'text-align': 'left', '--md-primary-color': primaryColor, }; // 默认主题 const theme = { base, block: { container: {}, h1: { 'display': 'table', 'padding': '0 1em', 'border-bottom': `2px solid ${primaryColor}`, 'margin': '2em auto 1em', 'font-size': '1.4em', 'font-weight': 'bold', 'text-align': 'center', }, h2: { 'display': 'table', 'padding': '0.3em 1em', 'margin': '2em auto 1em', 'color': '#fff', 'background': primaryColor, 'font-size': '1.3em', 'font-weight': 'bold', 'text-align': 'center', 'border-radius': '4px', }, h3: { 'padding-left': '12px', 'border-left': `4px solid ${primaryColor}`, 'margin': '2em 0 0.75em', 'font-size': '1.2em', 'font-weight': 'bold', }, h4: { 'margin': '2em 0 0.5em', 'color': primaryColor, 'font-size': '1.1em', 'font-weight': 'bold', }, p: { 'margin': '1.5em 8px', 'letter-spacing': '0.1em', }, blockquote: { 'padding': '1em', 'border-left': `4px solid ${primaryColor}`, 'border-radius': '4px', 'color': 'rgba(0,0,0,0.6)', 'background': '#f5f5f5', 'margin': '1em 0', }, code_pre: { 'font-size': '90%', 'overflow-x': 'auto', 'border-radius': '8px', 'padding': '1em', 'line-height': '1.5', 'margin': '10px 8px', 'background': '#f6f8fa', }, image: { 'display': 'block', 'max-width': '100%', 'margin': '1em auto', 'border-radius': '4px', }, ol: { 'padding-left': '1.5em', 'margin-left': '0', }, ul: { 'list-style': 'circle', 'padding-left': '1.5em', 'margin-left': '0', }, hr: { 'border-style': 'solid', 'border-width': '1px 0 0', 'border-color': 'rgba(0,0,0,0.1)', 'margin': '2em 0', }, table: { 'border-collapse': 'collapse', 'margin': '1em 8px', 'width': '100%', }, th: { 'border': '1px solid #ddd', 'padding': '8px 12px', 'background': '#f5f5f5', 'font-weight': 'bold', }, td: { 'border': '1px solid #ddd', 'padding': '8px 12px', }, }, inline: { listitem: { 'margin': '0.5em 0', }, codespan: { 'font-size': '90%', 'color': '#d14', 'background': 'rgba(27,31,35,.05)', 'padding': '3px 5px', 'border-radius': '4px', 'font-family': 'Menlo, Monaco, Consolas, monospace', }, strong: { 'color': primaryColor, 'font-weight': 'bold', }, em: { 'font-style': 'italic', }, link: { 'color': '#576b95', }, }, }; // 首行缩进 if (options.isUseIndent) { theme.block.p = { ...theme.block.p, 'text-indent': '2em' }; } // 两端对齐 if (options.isUseJustify) { theme.block.p = { ...theme.block.p, 'text-align': 'justify' }; } return theme; } /** * Wechat Formatter 类 */ export class WechatFormatter { options; theme; footnotes = []; footnoteIndex = 0; imageBedFactory; // ImageBedFactory 实例 localImages = []; onlineImages = []; tempDir = ''; constructor(options = {}) { this.options = { theme: 'default', fontSize: '16px', primaryColor: '#3f51b5', isUseIndent: false, isUseJustify: false, isShowLineNumber: false, citeStatus: true, convertLocalImages: true, // 默认转换本地图片 convertOnlineImagesToBase64: false, // 默认不转换在线图片 ...options, }; this.imageBedFactory = options.imageBedFactory; this.theme = buildTheme(this.options); // 配置 marked marked.setOptions({ breaks: true, gfm: true, }); } /** * 获取元素样式字符串 */ getStyles(tag) { const blockStyles = this.theme.block[tag]; const inlineStyles = this.theme.inline[tag]; const styles = blockStyles || inlineStyles; if (!styles) return ''; const mergedStyles = { ...this.theme.base, ...styles }; return `style="${getStyleString(mergedStyles)}"`; } /** * 创建带样式的元素 */ styledContent(tag, content, customTag) { const actualTag = customTag || tag; return `<${actualTag} ${this.getStyles(tag)}>${content}</${actualTag}>`; } /** * 添加脚注 */ addFootnote(title, link) { const existing = this.footnotes.find(([, , l]) => l === link); if (existing) return existing[0]; this.footnotes.push([++this.footnoteIndex, title, link]); return this.footnoteIndex; } /** * 构建脚注列表 */ buildFootnotes() { if (!this.footnotes.length) return ''; const footnoteList = this.footnotes .map(([index, title, link]) => link === title ? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${link}</i><br/>` : `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`) .join('\n'); return this.styledContent('h4', '引用链接') + `<p ${this.getStyles('p')}>${footnoteList}</p>`; } /** * 判断是否为本地图片路径 */ isLocalImagePath(src) { // 不是 http/https URL 就认为是本地路径 return !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:'); } /** * 处理本地图片上传 */ async processLocalImages(html) { if (!this.imageBedFactory || this.localImages.length === 0) { return html; } let processedHtml = html; // 批量上传所有本地图片 for (let i = 0; i < this.localImages.length; i++) { const { originalSrc, placeholder } = this.localImages[i]; try { // 使用图床工厂上传图片 const result = await this.imageBedFactory.uploadWithFallback(originalSrc); if (result.success) { // 替换占位符为实际 URL processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), result.url); } else { console.error(`图片上传失败: ${originalSrc}`, result.error); // 保留原路径 processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), originalSrc); } } catch (error) { console.error(`图片处理异常: ${originalSrc}`, error); processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), originalSrc); } } return processedHtml; } /** * 处理在线图片转 Base64 */ async processOnlineImages(html) { if (!this.options.convertOnlineImagesToBase64 || this.onlineImages.length === 0) { return html; } let processedHtml = html; // 创建临时目录 if (!this.tempDir) { this.tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wechat-images-')); } // 批量处理所有在线图片 for (let i = 0; i < this.onlineImages.length; i++) { const { originalSrc, placeholder } = this.onlineImages[i]; try { // 下载图片到临时目录 const fileName = `image-${i}-${Date.now()}${path.extname(originalSrc) || '.png'}`; const savePath = path.join(this.tempDir, fileName); const result = await downloadImage({ url: originalSrc, savePath, timeout: 15000, }); if (result.success) { // 转换为 base64 const dataUri = await imageToBase64(savePath); // 替换占位符为 base64 数据 processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), dataUri); console.log(`✓ 图片转换成功: ${originalSrc.substring(0, 50)}...`); } else { console.error(`图片下载失败: ${originalSrc}`, result.error); // 保留原 URL processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), originalSrc); } } catch (error) { console.error(`图片处理异常: ${originalSrc}`, error); processedHtml = processedHtml.replace(new RegExp(placeholder, 'g'), originalSrc); } } // 清理临时目录 try { if (this.tempDir) { await fs.remove(this.tempDir); this.tempDir = ''; } } catch (error) { console.warn('清理临时目录失败:', error); } return processedHtml; } /** * 格式化 Markdown 为微信 HTML */ async format(markdown) { // 重置状态 this.footnotes = []; this.footnoteIndex = 0; this.localImages = []; this.onlineImages = []; const self = this; // 扩展 marked 的 Renderer class WechatRenderer extends Renderer { heading(token) { const text = this.parser.parseInline(token.tokens); // 跳过一级标题,避免与微信公众号标题冲突 if (token.depth === 1) { return ''; } const tag = `h${token.depth}`; return self.styledContent(tag, text); } paragraph(token) { const text = this.parser.parseInline(token.tokens); if (text.trim() === '' || text.includes('<figure') || text.includes('<img')) { return text; } return self.styledContent('p', text); } blockquote(token) { const body = this.parser.parse(token.tokens); return self.styledContent('blockquote', body); } code(token) { const language = token.lang || 'plaintext'; const isRegistered = hljs.getLanguage(language); const actualLang = isRegistered ? language : 'plaintext'; let highlighted; try { highlighted = hljs.highlight(token.text, { language: actualLang }).value; } catch { highlighted = escapeHtml(token.text); } const code = `<code ${self.getStyles('codespan')} class="language-${actualLang}">${highlighted}</code>`; return `<pre ${self.getStyles('code_pre')}>${code}</pre>`; } codespan(token) { return self.styledContent('codespan', escapeHtml(token.text), 'code'); } list(token) { const body = token.items.map(item => this.listitem(item)).join('\n'); const tag = token.ordered ? 'ol' : 'ul'; return self.styledContent(tag, body); } listitem(item) { let text = this.parser.parse(item.tokens); // 移除内部的 <p> 标签 text = text.replace(/^<p[^>]*>(.*?)<\/p>$/s, '$1'); return `<li ${self.getStyles('listitem')}>${text}</li>`; } image(token) { const caption = token.title || token.text || ''; const captionHtml = caption ? `<figcaption style="text-align:center;color:#888;font-size:0.9em;margin-top:0.5em">${caption}</figcaption>` : ''; // 图片 URL 处理:支持本地图片转换和在线图片转 base64 let imageSrc = token.href; const isLocal = self.isLocalImagePath(imageSrc); const isOnline = imageSrc.startsWith('http://') || imageSrc.startsWith('https://'); // 处理本地图片上传 if (self.imageBedFactory && self.options.convertLocalImages !== false && isLocal) { self.localImages.push({ originalSrc: imageSrc, placeholder: `__LOCAL_IMAGE_${self.localImages.length}__`, }); imageSrc = `__LOCAL_IMAGE_${self.localImages.length - 1}__`; } // 处理在线图片转 base64 else if (self.options.convertOnlineImagesToBase64 && isOnline) { self.onlineImages.push({ originalSrc: imageSrc, placeholder: `__ONLINE_IMAGE_${self.onlineImages.length}__`, }); imageSrc = `__ONLINE_IMAGE_${self.onlineImages.length - 1}__`; } return `<figure ${self.getStyles('image')}><img src="${imageSrc}" alt="${token.text || ''}" style="width:100%;height:auto;display:block;"/>${captionHtml}</figure>`; } link(token) { const text = this.parser.parseInline(token.tokens); const href = token.href; const title = token.title || text; if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) { return `<a href="${href}" title="${title}" ${self.getStyles('link')}>${text}</a>`; } if (self.options.citeStatus) { const ref = self.addFootnote(title, href); return `<span ${self.getStyles('link')}>${text}<sup>[${ref}]</sup></span>`; } return self.styledContent('link', text, 'span'); } strong(token) { const text = this.parser.parseInline(token.tokens); return self.styledContent('strong', text); } em(token) { const text = this.parser.parseInline(token.tokens); return self.styledContent('em', text, 'span'); } hr(_token) { return `<hr ${self.getStyles('hr')}/>`; } table(token) { const header = token.header.map(cell => { const content = this.parser.parseInline(cell.tokens); return `<th ${self.getStyles('th')}>${content}</th>`; }).join(''); const body = token.rows.map(row => { const cells = row.map(cell => { const content = this.parser.parseInline(cell.tokens); return `<td ${self.getStyles('td')}>${content}</td>`; }).join(''); return `<tr>${cells}</tr>`; }).join('\n'); return `<table ${self.getStyles('table')}><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`; } } const renderer = new WechatRenderer(); // 使用自定义渲染器 let content = await marked.parse(markdown, { renderer, breaks: true, gfm: true }); // 添加脚注 content += this.buildFootnotes(); // 包裹容器 let container = `<section ${this.getStyles('container')}>${content}</section>`; // 处理本地图片上传(如果配置了图床) container = await this.processLocalImages(container); // 处理在线图片转 base64(如果启用) container = await this.processOnlineImages(container); return container; } /** * 导出为完整 HTML 文件(带一键复制功能) */ async exportHtml(markdown, title = '微信文章') { const content = await this.format(markdown); return `<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title} - 微信公众号预览</title> <style> * { box-sizing: border-box; } body { max-width: 900px; margin: 0 auto; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f5f5; } .toolbar { position: sticky; top: 0; z-index: 1000; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: space-between; gap: 15px; } .toolbar h1 { margin: 0; font-size: 18px; font-weight: 600; color: white; flex: 1; } .copy-btn { background: white; color: #667eea; border: none; padding: 12px 24px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 8px; } .copy-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.15); } .copy-btn:active { transform: translateY(0); } .copy-btn.success { background: #10b981; color: white; } .status { color: white; font-size: 14px; padding: 8px 16px; border-radius: 6px; background: rgba(255,255,255,0.2); display: none; } .status.show { display: block; } .preview-container { background: white; margin: 20px; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } pre { overflow-x: auto; } .instructions { background: #fff9e6; border-left: 4px solid #fbbf24; padding: 16px 20px; margin: 20px; border-radius: 8px; color: #92400e; line-height: 1.6; } .instructions h3 { margin: 0 0 12px 0; font-size: 16px; color: #78350f; } .instructions ol { margin: 8px 0; padding-left: 20px; } .instructions li { margin: 6px 0; } .instructions code { background: rgba(251, 191, 36, 0.2); padding: 2px 6px; border-radius: 4px; font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; } </style> </head> <body> <div class="toolbar"> <h1>📱 ${title}</h1> <button class="copy-btn" id="copy-btn" onclick="copyToWechat()"> <span id="btn-icon">📋</span> <span id="btn-text">一键复制到微信</span> </button> <div class="status" id="status"></div> </div> <div class="instructions"> <h3>💡 使用说明</h3> <ol> <li>点击上方 <strong>"一键复制到微信"</strong> 按钮</li> <li>打开 <strong>微信公众号后台</strong> 编辑器</li> <li>在编辑器中按 <code>Ctrl+V</code> (Mac: <code>Cmd+V</code>) 粘贴</li> <li>检查格式,完成发布!</li> </ol> </div> <div class="preview-container" id="article-content"> ${content} </div> <script> async function copyToWechat() { const btn = document.getElementById('copy-btn'); const btnIcon = document.getElementById('btn-icon'); const btnText = document.getElementById('btn-text'); const status = document.getElementById('status'); const content = document.getElementById('article-content'); try { // 获取格式化后的 HTML const html = content.innerHTML; const text = content.innerText; // 使用 Clipboard API 复制带格式的内容 if (navigator.clipboard && window.ClipboardItem) { const blob = new Blob([html], { type: 'text/html' }); const textBlob = new Blob([text], { type: 'text/plain' }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': textBlob }); await navigator.clipboard.write([item]); } else { // 降级方案:仅复制纯文本 await navigator.clipboard.writeText(text); } // 成功提示 btn.classList.add('success'); btnIcon.textContent = '✅'; btnText.textContent = '复制成功!'; status.textContent = '已复制到剪贴板,请打开微信公众号后台粘贴'; status.classList.add('show'); // 3秒后恢复 setTimeout(() => { btn.classList.remove('success'); btnIcon.textContent = '📋'; btnText.textContent = '一键复制到微信'; status.classList.remove('show'); }, 3000); } catch (err) { console.error('复制失败:', err); btnIcon.textContent = '❌'; btnText.textContent = '复制失败'; status.textContent = '复制失败,请手动选择内容复制'; status.style.background = 'rgba(239, 68, 68, 0.9)'; status.classList.add('show'); setTimeout(() => { btnIcon.textContent = '📋'; btnText.textContent = '一键复制到微信'; status.classList.remove('show'); status.style.background = 'rgba(255,255,255,0.2)'; }, 3000); } } // 键盘快捷键: Ctrl/Cmd + Shift + C document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') { e.preventDefault(); copyToWechat(); } }); // 页面加载完成提示 window.addEventListener('load', () => { console.log('✅ 微信公众号预览已加载'); console.log('💡 快捷键: Ctrl/Cmd + Shift + C 快速复制'); }); </script> </body> </html>`; } } /** * 便捷函数:格式化 Markdown 为微信 HTML */ export async function formatMarkdownForWechat(markdown, options) { const formatter = new WechatFormatter(options); return formatter.format(markdown); } /** * 便捷函数:导出为完整 HTML 文件 */ export async function exportWechatHtml(markdown, title, options) { const formatter = new WechatFormatter(options); return formatter.exportHtml(markdown, title); } //# sourceMappingURL=wechat-formatter.js.map