#!/usr/bin/env node /** * 小红书卡片渲染脚本 - Node.js 增强版 * 支持多种排版样式和智能分页策略 * * 使用方法: * node render_xhs.js [options] * * 选项: * --output-dir, -o 输出目录(默认为当前工作目录) * --theme, -t 排版主题:default, playful-geometric, neo-brutalism, 等 * --mode, -m 分页模式:separator, auto-fit, auto-split, dynamic * --width, -w 图片宽度(默认 1080) * --height, -h 图片高度(默认 1440) * --dpr 设备像素比(默认 2) * * 依赖安装: * npm install marked yaml playwright * npx playwright install chromium */ const fs = require('fs'); const path = require('path'); const { marked } = require('marked'); const yaml = require('yaml'); const { chromium } = require('playwright'); // 获取脚本所在目录 const SCRIPT_DIR = path.dirname(__dirname); const ASSETS_DIR = path.join(SCRIPT_DIR, 'assets'); const THEMES_DIR = path.join(ASSETS_DIR, 'themes'); // 默认卡片尺寸配置 (3:4 比例) const DEFAULT_WIDTH = 1080; const DEFAULT_HEIGHT = 1440; const MAX_HEIGHT = 2160; // 可用主题列表 const AVAILABLE_THEMES = [ 'default', 'playful-geometric', 'neo-brutalism', 'botanical', 'professional', 'retro', 'terminal', 'sketch' ]; // 分页模式 const PAGING_MODES = ['separator', 'auto-fit', 'auto-split', 'dynamic']; // 主题背景色 const THEME_BACKGROUNDS = { 'default': 'linear-gradient(180deg, #f3f3f3 0%, #f9f9f9 100%)', 'playful-geometric': 'linear-gradient(135deg, #8B5CF6 0%, #F472B6 100%)', 'neo-brutalism': 'linear-gradient(135deg, #FF4757 0%, #FECA57 100%)', 'botanical': 'linear-gradient(135deg, #4A7C59 0%, #8FBC8F 100%)', 'professional': 'linear-gradient(135deg, #2563EB 0%, #3B82F6 100%)', 'retro': 'linear-gradient(135deg, #D35400 0%, #F39C12 100%)', 'terminal': 'linear-gradient(135deg, #0D1117 0%, #161B22 100%)', 'sketch': 'linear-gradient(135deg, #555555 0%, #888888 100%)' }; // 封面标题文字渐变(随主题变化) const THEME_TITLE_GRADIENTS = { 'default': 'linear-gradient(180deg, #111827 0%, #4B5563 100%)', 'playful-geometric': 'linear-gradient(180deg, #7C3AED 0%, #F472B6 100%)', 'neo-brutalism': 'linear-gradient(180deg, #000000 0%, #FF4757 100%)', 'botanical': 'linear-gradient(180deg, #1F2937 0%, #4A7C59 100%)', 'professional': 'linear-gradient(180deg, #1E3A8A 0%, #2563EB 100%)', 'retro': 'linear-gradient(180deg, #8B4513 0%, #D35400 100%)', 'terminal': 'linear-gradient(180deg, #39D353 0%, #58A6FF 100%)', 'sketch': 'linear-gradient(180deg, #111827 0%, #6B7280 100%)', }; /** * 解析命令行参数 */ function parseArgs() { const args = process.argv.slice(2); const options = { markdownFile: null, outputDir: process.cwd(), theme: 'default', mode: 'separator', width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, maxHeight: MAX_HEIGHT, dpr: 2 }; for (let i = 0; i < args.length; i++) { const arg = args[i]; const nextArg = args[i + 1]; switch (arg) { case '--output-dir': case '-o': options.outputDir = nextArg; i++; break; case '--theme': case '-t': options.theme = nextArg; i++; break; case '--mode': case '-m': options.mode = nextArg; i++; break; case '--width': case '-w': options.width = parseInt(nextArg); i++; break; case '--height': options.height = parseInt(nextArg); i++; break; case '--max-height': options.maxHeight = parseInt(nextArg); i++; break; case '--dpr': options.dpr = parseInt(nextArg); i++; break; case '--help': printHelp(); process.exit(0); default: if (!arg.startsWith('-')) { options.markdownFile = arg; } } } return options; } /** * 打印帮助信息 */ function printHelp() { console.log(` 小红书卡片渲染脚本 - Node.js 版本 使用方法: node render_xhs.js [options] 选项: --output-dir, -o 输出目录(默认为当前工作目录) --theme, -t 排版主题 --mode, -m 分页模式 --width, -w 图片宽度(默认 1080) --height 图片高度(默认 1440) --max-height 最大高度(默认 2160) --dpr 设备像素比(默认 2) 可用主题: ${AVAILABLE_THEMES.join(', ')} 分页模式: ${PAGING_MODES.join(', ')} `); } /** * 解析 Markdown 文件 */ function parseMarkdownFile(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); // 解析 YAML 头部 const yamlMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); let metadata = {}; let body = content; if (yamlMatch) { try { metadata = yaml.parse(yamlMatch[1]) || {}; } catch (e) { metadata = {}; } body = content.slice(yamlMatch[0].length); } return { metadata, body: body.trim() }; } /** * 按分隔符拆分内容 */ function splitContentBySeparator(body) { const parts = body.split(/\n---+\n/); return parts.map(p => p.trim()).filter(p => p); } /** * 加载主题 CSS */ function loadThemeCss(theme) { const themeFile = path.join(THEMES_DIR, `${theme}.css`); if (fs.existsSync(themeFile)) { return fs.readFileSync(themeFile, 'utf-8'); } const defaultFile = path.join(THEMES_DIR, 'default.css'); if (fs.existsSync(defaultFile)) { return fs.readFileSync(defaultFile, 'utf-8'); } return ''; } /** * 生成封面 HTML */ function generateCoverHtml(metadata, theme, width, height) { const emoji = metadata.emoji || '📝'; let title = metadata.title || '标题'; let subtitle = metadata.subtitle || ''; if (title.length > 15) title = title.slice(0, 15); if (subtitle.length > 15) subtitle = subtitle.slice(0, 15); const bg = THEME_BACKGROUNDS[theme] || THEME_BACKGROUNDS['default']; const titleBg = THEME_TITLE_GRADIENTS[theme] || THEME_TITLE_GRADIENTS['default']; return ` 小红书封面
${emoji}
${title}
${subtitle}
`; } /** * 生成正文卡片 HTML */ function generateCardHtml(content, theme, pageNumber, totalPages, width, height, mode) { const htmlContent = marked.parse(content); const themeCss = loadThemeCss(theme); const pageText = totalPages > 1 ? `${pageNumber}/${totalPages}` : ''; const bg = THEME_BACKGROUNDS[theme] || THEME_BACKGROUNDS['default']; let containerStyle, innerStyle, contentStyle; if (mode === 'auto-fit') { containerStyle = ` width: ${width}px; height: ${height}px; background: ${bg}; position: relative; padding: 50px; overflow: hidden; `; innerStyle = ` background: rgba(255, 255, 255, 0.95); border-radius: 20px; padding: 60px; height: calc(${height}px - 100px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); backdrop-filter: blur(10px); overflow: hidden; display: flex; flex-direction: column; `; contentStyle = 'flex: 1; overflow: hidden;'; } else if (mode === 'dynamic') { containerStyle = ` width: ${width}px; min-height: ${height}px; background: ${bg}; position: relative; padding: 50px; `; innerStyle = ` background: rgba(255, 255, 255, 0.95); border-radius: 20px; padding: 60px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); backdrop-filter: blur(10px); `; contentStyle = ''; } else { containerStyle = ` width: ${width}px; min-height: ${height}px; background: ${bg}; position: relative; padding: 50px; overflow: hidden; `; innerStyle = ` background: rgba(255, 255, 255, 0.95); border-radius: 20px; padding: 60px; min-height: calc(${height}px - 100px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); backdrop-filter: blur(10px); `; contentStyle = ''; } return ` 小红书卡片
${htmlContent}
${pageText}
`; } /** * 渲染 HTML 为图片 */ async function renderHtmlToImage(htmlContent, outputPath, width, height, mode, maxHeight, dpr) { const browser = await chromium.launch(); const viewportHeight = mode !== 'dynamic' ? height : maxHeight; const page = await browser.newPage({ viewport: { width, height: viewportHeight }, deviceScaleFactor: dpr }); await page.setContent(htmlContent); await page.waitForLoadState('networkidle'); await page.waitForTimeout(500); let actualHeight; if (mode === 'auto-fit') { await page.evaluate(() => { const viewportContent = document.querySelector('.card-content'); const scaleEl = document.querySelector('.card-content-scale'); if (!viewportContent || !scaleEl) return; // reset scaleEl.style.transform = 'none'; scaleEl.style.width = ''; scaleEl.style.height = ''; const availableWidth = viewportContent.clientWidth; const availableHeight = viewportContent.clientHeight; const rect = scaleEl.getBoundingClientRect(); const contentWidth = Math.max(scaleEl.scrollWidth, rect.width); const contentHeight = Math.max(scaleEl.scrollHeight, rect.height); if (!contentWidth || !contentHeight || !availableWidth || !availableHeight) return; const scale = Math.min(1, availableWidth / contentWidth, availableHeight / contentHeight); // expand layout box to avoid clip scaleEl.style.width = (availableWidth / scale) + 'px'; scaleEl.style.transformOrigin = 'top left'; scaleEl.style.transform = `translate(0px, 0px) scale(${scale})`; }); await page.waitForTimeout(100); actualHeight = height; } else if (mode === 'dynamic') { const contentHeight = await page.evaluate(() => { const container = document.querySelector('.card-container'); return container ? container.scrollHeight : document.body.scrollHeight; }); actualHeight = Math.max(height, Math.min(contentHeight, maxHeight)); } else { const contentHeight = await page.evaluate(() => { const container = document.querySelector('.card-container'); return container ? container.scrollHeight : document.body.scrollHeight; }); actualHeight = Math.max(height, contentHeight); } await page.screenshot({ path: outputPath, clip: { x: 0, y: 0, width, height: actualHeight }, type: 'png' }); await browser.close(); console.log(` ✅ 已生成: ${outputPath} (${width}x${actualHeight})`); return actualHeight; } /** * 主渲染函数 */ async function renderMarkdownToCards(options) { const { markdownFile, outputDir, theme, mode, width, height, maxHeight, dpr } = options; console.log(`\n🎨 开始渲染: ${markdownFile}`); console.log(` 📐 主题: ${theme}`); console.log(` 📏 模式: ${mode}`); console.log(` 📐 尺寸: ${width}x${height}`); // 确保输出目录存在 if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // 解析 Markdown 文件 const { metadata, body } = parseMarkdownFile(markdownFile); // 分割内容 const cardContents = splitContentBySeparator(body); const totalCards = cardContents.length; console.log(` 📄 检测到 ${totalCards} 张正文卡片`); // 生成封面 if (metadata.emoji || metadata.title) { console.log(' 📷 生成封面...'); const coverHtml = generateCoverHtml(metadata, theme, width, height); const coverPath = path.join(outputDir, 'cover.png'); await renderHtmlToImage(coverHtml, coverPath, width, height, 'separator', maxHeight, dpr); } // 生成正文卡片 for (let i = 0; i < cardContents.length; i++) { const content = cardContents[i]; console.log(` 📷 生成卡片 ${i + 1}/${totalCards}...`); const cardHtml = generateCardHtml(content, theme, i + 1, totalCards, width, height, mode); const cardPath = path.join(outputDir, `card_${i + 1}.png`); await renderHtmlToImage(cardHtml, cardPath, width, height, mode, maxHeight, dpr); } console.log(`\n✨ 渲染完成!图片已保存到: ${outputDir}`); } /** * 主函数 */ async function main() { const options = parseArgs(); if (!options.markdownFile) { console.error('❌ 错误: 请提供 Markdown 文件路径'); printHelp(); process.exit(1); } if (!fs.existsSync(options.markdownFile)) { console.error(`❌ 错误: 文件不存在 - ${options.markdownFile}`); process.exit(1); } if (!AVAILABLE_THEMES.includes(options.theme)) { console.error(`❌ 错误: 不支持的主题 - ${options.theme}`); console.error(`可用主题: ${AVAILABLE_THEMES.join(', ')}`); process.exit(1); } if (!PAGING_MODES.includes(options.mode)) { console.error(`❌ 错误: 不支持的分页模式 - ${options.mode}`); console.error(`可用模式: ${PAGING_MODES.join(', ')}`); process.exit(1); } await renderMarkdownToCards(options); } main().catch(console.error);