diff --git a/README.md b/README.md index 362d20f..9ec27bc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,56 @@ [![Node.js](https://img.shields.io/badge/Node.js-16+-green.svg)](https://nodejs.org/) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +--- + +## 🆕 v2.0 版本更新 + +### ✨ 新增功能 + +- **🚀 智能分页渲染** - 自动检测内容高度,超出时自动拆分到多张卡片,彻底解决文字溢出问题 +- **🎨 7种可选样式** - 新增多种主题风格,一键切换不同视觉效果 +- **⚡ V2 渲染脚本** - 全新 `render_xhs_v2.py` / `render_xhs_v2.js`,推荐升级使用 + +### 📋 可用样式列表 + +| 样式 | 名称 | 预览 | +|------|------|------| +| `purple` | 紫韵(默认)| 蓝紫色渐变 | +| `xiaohongshu` | 小红书红 | 品牌红色系 | +| `mint` | 清新薄荷 | 绿色自然调 | +| `sunset` | 日落橙 | 粉橙浪漫调 | +| `ocean` | 深海蓝 | 海洋蓝色调 | +| `elegant` | 优雅白 | 灰白简约调 | +| `dark` | 暗黑模式 | 深色高对比 | + +### 🎯 使用 V2 脚本 + +```bash +# Python 版本 +python scripts/render_xhs_v2.py note.md --style sunset + +# Node.js 版本 +node scripts/render_xhs_v2.js note.md --style ocean + +# 查看所有样式 +python scripts/render_xhs_v2.py --list-styles +``` + +### 📁 v2.0 新增文件 + +``` +scripts/ +├── render_xhs_v2.py # 新增:Python 智能分页版(推荐) +├── render_xhs_v2.js # 新增:Node.js 智能分页版(推荐) +├── render_xhs.py # 旧版:保留兼容 +└── render_xhs.js # 旧版:保留兼容 +STYLES.md # 新增:样式选择指南 +``` + +**注:V1 旧版脚本保留兼容,V2 版本完全向下兼容 Markdown 格式。** + +--- + ## ✨ 功能特性 - 📝 **撰写笔记** - 根据既定主题,撰写小红书笔记(提示词自己调整,在 `SKILL.md`里) @@ -22,7 +72,7 @@ Clone 项目到本地 ```bash -git clone https://github.com/comeonzhj/Auto-Redbook-Skills.git                    +git clone https://github.com/comeonzhj/Auto-Redbook-Skills.git ``` @@ -49,6 +99,41 @@ npm install npx playwright install chromium ``` +## 🎨 渲染图片 + +### V2 渲染(推荐) + +```bash +# 使用默认样式 +python scripts/render_xhs_v2.py note.md + +# 指定样式主题 +python scripts/render_xhs_v2.py note.md --style sunset + +# 指定输出目录 +python scripts/render_xhs_v2.py note.md -o ./output --style xiaohongshu + +# 查看所有样式 +python scripts/render_xhs_v2.py --list-styles +``` + +**V2 特性:** +- 智能分页:自动检测内容高度,自动拆分卡片 +- 固定尺寸:所有卡片固定 1080×1440px +- 多种样式:7种预设主题风格 + +### V1 渲染(旧版) + +```bash +# Python 版本 +python scripts/render_xhs.py note.md + +# Node.js 版本 +node scripts/render_xhs.js note.md +``` + +**注意:** V1 版本当内容过长时可能出现溢出,建议手动使用 `---` 分隔内容。 + ## 📤 发布到小红书 ### 1. 配置 Cookie @@ -93,7 +178,7 @@ python scripts/publish_xhs.py \ ## 🎨 自定义样式 -### 修改背景渐变 +### 修改背景渐变(V1) 编辑 `assets/card.html` 中的 `.card-container`: @@ -113,9 +198,16 @@ python scripts/publish_xhs.py \ | 绿色 | `#43e97b → #38f9d7` | | 橙黄 | `#fa709a → #fee140` | -### 修改封面样式 +### 更多样式选择(V2) -编辑 `assets/cover.html` 中的样式。 +V2 版本提供 7 种内置样式,通过 `--style` 参数快速切换: + +```bash +python scripts/render_xhs_v2.py note.md --style dark # 暗黑模式 +python scripts/render_xhs_v2.py note.md --style mint # 清新薄荷 +``` + +详见 [STYLES.md](./STYLES.md) ## 📁 项目结构 @@ -123,6 +215,7 @@ python scripts/publish_xhs.py \ md2Redbook/ ├── SKILL.md # 技能描述(AI Agent 使用) ├── README.md # 项目文档 +├── STYLES.md # 样式选择指南 ├── requirements.txt # Python 依赖 ├── package.json # Node.js 依赖 ├── env.example.txt # Cookie 配置示例 @@ -132,8 +225,10 @@ md2Redbook/ │ ├── styles.css # 共用样式表 │ └── example.md # 示例 Markdown └── scripts/ - ├── render_xhs.py # Python 渲染脚本 - ├── render_xhs.js # Node.js 渲染脚本 + ├── render_xhs_v2.py # Python 渲染脚本 V2(推荐) + ├── render_xhs_v2.js # Node.js 渲染脚本 V2(推荐) + ├── render_xhs.py # Python 渲染脚本 V1 + ├── render_xhs.js # Node.js 渲染脚本 V1 └── publish_xhs.py # 小红书发布脚本 ``` @@ -149,7 +244,7 @@ md2Redbook/ - [Playwright](https://playwright.dev/) - 浏览器自动化渲染 - [Marked](https://marked.js.org/) - Markdown 解析 -- [Madopic](https://github.com/xiaolinbaba/Madopic) - Markdown 渲染  +- [Madopic](https://github.com/xiaolinbaba/Madopic) - Markdown 渲染 - [xhs](https://github.com/ReaJason/xhs) - 小红书 API 客户端 ## 📄 License diff --git a/SKILL.md b/SKILL.md index 253a087..5ca82a0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,17 +1,18 @@ --- -name: xhs-note-creator -description: 小红书笔记素材创作技能。当用户需要创建小红书笔记素材时使用这个技能。技能包含:根据用户的需求和提供的资料,撰写小红书笔记内容(标题+正文),生成图片卡片(封面+正文卡片),以及发布小红书笔记。 +name: Auto-Redbook +description: 小红书笔记素材创作技能。当用户需要创建小红书笔记素材时使用这个技能。技能包含:根据用户的需求和提供的资料,撰写小红书笔记内容(标题+正文),生成图片卡片(封面+正文卡片,支持多种样式主题)。 --- # 小红书笔记创作技能 -这个技能用于创建专业的小红书笔记素材,包括内容撰写、图片卡片生成和笔记发布。 +这个技能用于创建专业的小红书笔记素材,包括内容撰写、图片卡片生成(支持7种样式主题)和智能分页渲染。 ## 使用场景 - 用户需要创建小红书笔记时 - 用户提供资料需要转化为小红书风格内容时 - 用户需要生成精美的图片卡片用于发布时 +- 用户需要多种风格样式选择时 ## 工作流程 @@ -49,7 +50,7 @@ subtitle: "副标题文案" # 封面副标题(不超过15字) 2. 用于渲染卡片的 Markdown 文本内容: - 使用 `---` 分割线将正文分隔为多个卡片段落 - 每个分段的文字控制在 200 字左右 - - 后续会将每个卡片段落渲染为一张图片 + - 脚本会自动检测内容高度并智能分页 完整示例: ```markdown @@ -72,9 +73,7 @@ subtitle: "对着抄作业就好了,一起变高效" # 神器二:Raycast ⚡ -\`\`\` 可使用代码块来增加渲染后图片的视觉丰富度 -\`\`\` ## 推荐原因 @@ -90,28 +89,74 @@ subtitle: "对着抄作业就好了,一起变高效" ... +#效率工具 #生产力 #Mac软件 ``` ### 第三步:渲染图片卡片 -将 Markdown 文档渲染为图片卡片。提供两种渲染脚本: +将 Markdown 文档渲染为图片卡片。**推荐使用 V2 版本脚本**,支持智能分页和多种样式。 -#### Python 渲染脚本 +#### V2 渲染脚本(推荐) + +V2 版本新增特性: +- ✅ **智能分页**:自动检测内容高度,超出时自动拆分到多张卡片 +- ✅ **多种样式**:支持 7 种预设样式主题 +- ✅ **字数预估**:基于字数预分配内容,减少渲染次数 + +**Python 版本:** ```bash -python scripts/render_xhs.py [--output-dir ] +# 基本用法 +python scripts/render_xhs_v2.py + +# 指定输出目录 +python scripts/render_xhs_v2.py -o + +# 指定样式主题 +python scripts/render_xhs_v2.py --style xiaohongshu + +# 查看所有可用样式 +python scripts/render_xhs_v2.py --list-styles ``` -- 默认输出目录为当前工作目录 -- 生成的图片包括:封面(cover.png)和正文卡片(card_1.png, card_2.png, ...) - -#### Node.js 渲染脚本 +**Node.js 版本:** ```bash +# 基本用法 +node scripts/render_xhs_v2.js + +# 指定输出目录和样式 +node scripts/render_xhs_v2.js -o ./output --style mint + +# 查看所有可用样式 +node scripts/render_xhs_v2.js --list-styles +``` + +#### 可用样式主题 + +| 样式键 | 名称 | 描述 | +|--------|------|------| +| `purple` | 紫韵 | 默认样式,紫蓝色渐变 | +| `xiaohongshu` | 小红书红 | 小红书品牌色系 | +| `mint` | 清新薄荷 | 绿色/自然调 | +| `sunset` | 日落橙 | 粉色/日落渐变 | +| `ocean` | 深海蓝 | 蓝绿色海洋调 | +| `elegant` | 优雅白 | 简约灰白调 | +| `dark` | 暗黑模式 | 深色背景,高对比度 | + +#### 旧版渲染脚本(保留) + +如需使用旧版(不支持自动分页): + +```bash +# Python 版本 +python scripts/render_xhs.py [--output-dir ] + +# Node.js 版本 node scripts/render_xhs.js [--output-dir ] ``` -功能与 Python 版本相同。 +**旧版已知问题**:单张卡片内容过多时可能出现文字溢出,需手动用 `---` 分隔。 ### 第四步:发布小红书笔记(可选) @@ -139,30 +184,46 @@ XHS_COOKIE=your_cookie_string_here - 尺寸比例:3:4(小红书推荐比例) - 基准尺寸:1080×1440px - 包含:Emoji 装饰、大标题、副标题 -- 样式:渐变背景 + 圆角内容区 +- 样式:渐变背景 + 圆角内容区(根据所选主题变化) ### 正文卡片 - 尺寸比例:3:4 - 基准尺寸:1080×1440px - 支持:标题、段落、列表、引用、代码块、图片 -- 样式:白色卡片 + 渐变背景边框 +- 样式:白色卡片 + 渐变背景边框(根据所选主题变化) +- V2 版本:自动分页,单张卡片内容不会溢出 ## 技能资源 ### 脚本文件 -- `scripts/render_xhs.py` - Python 渲染脚本 -- `scripts/render_xhs.js` - Node.js 渲染脚本 +- `scripts/render_xhs.py` - Python V1 渲染脚本(旧版) +- `scripts/render_xhs.js` - Node.js V1 渲染脚本(旧版) +- `scripts/render_xhs_v2.py` - Python V2 渲染脚本(推荐 ✅) +- `scripts/render_xhs_v2.js` - Node.js V2 渲染脚本(推荐 ✅) - `scripts/publish_xhs.py` - 小红书发布脚本 ### 资源文件 -- `assets/cover.html` - 封面 HTML 模板 -- `assets/card.html` - 正文卡片 HTML 模板 -- `assets/styles.css` - 共用样式表 +- `assets/cover.html` - 封面 HTML 模板(旧版) +- `assets/card.html` - 正文卡片 HTML 模板(旧版) +- `assets/styles.css` - 共用样式表(旧版) +- `assets/example.md` - 示例 Markdown 文件 ## 注意事项 -1. Markdown 文件应保存在工作目录,渲染后的图片也保存在工作目录 -2. 技能目录 (`md2Redbook/`) 仅存放脚本和模板,不存放用户数据 -3. 图片尺寸会根据内容自动调整,但保持 3:4 比例 -4. Cookie 有有效期限制,过期后需要重新获取 -5. 发布功能依赖 xhs 库,需要安装:`pip install xhs` +1. **V2 版本推荐**:V2 版本支持智能分页,可自动处理内容溢出问题 +2. **样式选择**:根据内容风格选择合适的样式主题 +3. **Markdown 位置**:Markdown 文件应保存在工作目录,渲染后的图片也保存在工作目录 +4. **内容长度**:建议每个 `---` 分隔的内容块控制在 200 字以内 +5. **Cookie 有效期**:发布功能的 Cookie 有过期限制,过期后需要重新获取 +6. **发布依赖**:发布功能依赖 xhs 库,需要安装:`pip install xhs` + +## 智能分页说明 + +V2 版本的智能分页机制: + +1. **预估阶段**:基于字数、元素类型预估内容高度 +2. **预渲染阶段**:使用 Playwright 预渲染并测量实际高度 +3. **拆分阶段**:如果内容超出,按段落/行智能拆分内容 +4. **固定输出**:每张卡片固定为 1080×1440px,确保一致性 + +这种机制确保无论内容多长,都不会出现文字溢出问题。 diff --git a/STYLES.md b/STYLES.md new file mode 100644 index 0000000..9afcec1 --- /dev/null +++ b/STYLES.md @@ -0,0 +1,98 @@ +# 小红书笔记样式预览 + +本文档展示所有可用的样式主题,方便用户选择合适的设计风格。 + +## 样式列表 + +### 1. purple(紫韵)- 默认 +- **封面背景**:蓝紫色渐变 +- **卡片背景**:紫蓝渐变 +- **强调色**:#6366f1 +- **适用场景**:通用、科技感、创意设计 + +```bash +python scripts/render_xhs_v2.py note.md --style purple +``` + +### 2. xiaohongshu(小红书红) +- **封面背景**:小红书品牌红渐变 +- **卡片背景**:粉红渐变 +- **强调色**:#FF2442 +- **适用场景**:小红书原生风格、时尚、生活方式 + +```bash +python scripts/render_xhs_v2.py note.md --style xiaohongshu +``` + +### 3. mint(清新薄荷) +- **封面背景**:绿色渐变 +- **卡片背景**:薄荷绿渐变 +- **强调色**:#43e97b +- **适用场景**:健康、环保、自然、春季主题 + +```bash +python scripts/render_xhs_v2.py note.md --style mint +``` + +### 4. sunset(日落橙) +- **封面背景**:粉橙渐变 +- **卡片背景**:日落渐变 +- **强调色**:#fa709a +- **适用场景**:温暖、浪漫、傍晚、秋季主题 + +```bash +python scripts/render_xhs_v2.py note.md --style sunset +``` + +### 5. ocean(深海蓝) +- **封面背景**:天蓝渐变 +- **卡片背景**:海洋蓝渐变 +- **强调色**:#4facfe +- **适用场景**:清新、水、夏季主题、专业商务 + +```bash +python scripts/render_xhs_v2.py note.md --style ocean +``` + +### 6. elegant(优雅白) +- **封面背景**:灰白渐变 +- **卡片背景**:浅灰渐变 +- **强调色**:#333333 +- **适用场景**:极简、商务、正式、高级 + +```bash +python scripts/render_xhs_v2.py note.md --style elegant +``` + +### 7. dark(暗黑模式) +- **封面背景**:深蓝黑色渐变 +- **卡片背景**:深色渐变 +- **强调色**:#e94560 +- **适用场景**:夜间阅读、科技、编程、游戏 + +```bash +python scripts/render_xhs_v2.py note.md --style dark +``` + +## 样式选择建议 + +| 内容类型 | 推荐样式 | +|----------|----------| +| 科技/编程 | dark, purple | +| 时尚/美妆 | xiaohongshu, sunset | +| 健康/自然 | mint, ocean | +| 商务/职场 | ocean, elegant | +| 生活/情感 | sunset, xiaohongshu | +| 创意/设计 | purple, dark | + +## 查看所有样式 + +运行以下命令列出所有可用样式: + +```bash +# Python 版本 +python scripts/render_xhs_v2.py --list-styles + +# Node.js 版本 +node scripts/render_xhs_v2.js --list-styles +``` diff --git a/assets/example.md b/assets/example.md index f2098be..db836b5 100644 --- a/assets/example.md +++ b/assets/example.md @@ -4,7 +4,7 @@ title: "5个效率神器" subtitle: "让工作效率翻倍" --- -## 神器一:Notion 📝 +# 神器一:Notion 📝 全能型笔记工具,支持数据库、看板、日历等多种视图。 @@ -18,33 +18,60 @@ subtitle: "让工作效率翻倍" --- -## 神器二:Raycast ⚡ +# 神器二:Raycast ⚡ Mac 上的效率启动器,比 Spotlight 强大 100 倍! +```bash +# 快捷命令示例 +raycast://extensions/raycast/clipboard/clipboard-history +``` + **必装插件推荐:** - 剪贴板历史 - 窗口管理 - 快捷短语 - API 调试工具 -一键搜索、快速启动,让你的 Mac 飞起来 ✈️ - --- -## 神器三:Arc 浏览器 🌈 +# 神器三:Arc 浏览器 🌈 -重新定义浏览器体验: +全新理念的浏览器体验: - 侧边栏标签管理 - 空间分组功能 - 内置笔记和画板 - 极简无干扰模式 -告别标签栏焦虑,专注当下任务! +--- + +# 神器四:Warp 终端 🖥️ + +基于 Rust 的现代化终端: + +```python +# 支持 AI 智能补全 +def example(): + print("Hello Warp!") +``` + +- ⚡ 极速性能 +- 🤖 AI 智能提示 +- 📋 自动补全 +- 🎯 分组工作区 --- -## 总结 🎯 +# 神器五:Fig 自动补全 🔮 + +终端命令自动补全神器: +- 数百种 CLI 工具支持 +- 可视化参数提示 +- 团队协作分享 + +--- + +# 总结 🎯 效率提升不在于工具多少,而在于是否**真正用起来**。 @@ -54,4 +81,4 @@ Mac 上的效率启动器,比 Spotlight 强大 100 倍! ✅ 减少 80% 的焦虑 ✅ 提升 100% 的专注力 -#效率工具 #生产力 #Mac软件 #工作技巧 #神器推荐 #Notion #Raycast #Arc浏览器 +#效率工具 #生产力 #Mac软件 #工作技巧 #神器推荐 #Notion #Raycast #Arc浏览器 #Warp #Fig diff --git a/scripts/render_xhs_v2.js b/scripts/render_xhs_v2.js new file mode 100644 index 0000000..be534ff --- /dev/null +++ b/scripts/render_xhs_v2.js @@ -0,0 +1,723 @@ +#!/usr/bin/env node +/** + * 小红书卡片渲染脚本 V2 - Node.js 智能分页版 + * 将 Markdown 文件渲染为小红书风格的图片卡片 + * + * 新特性: + * 1. 智能分页:自动检测内容高度,超出时自动拆分到多张卡片 + * 2. 多种样式:支持多种预设样式主题 + * + * 使用方法: + * node render_xhs_v2.js [options] + * + * 依赖安装: + * npm install marked js-yaml playwright + * npx playwright install chromium + */ + +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); +const { marked } = require('marked'); +const yaml = require('js-yaml'); + +// 获取脚本所在目录 +const SCRIPT_DIR = path.dirname(__dirname); +const ASSETS_DIR = path.join(SCRIPT_DIR, 'assets'); + +// 卡片尺寸配置 (3:4 比例) +const CARD_WIDTH = 1080; +const CARD_HEIGHT = 1440; + +// 内容区域安全高度 +const SAFE_HEIGHT = CARD_HEIGHT - 120 - 100 - 80 - 40; // ~1100px + +// 样式配置 +const STYLES = { + purple: { + name: "紫韵", + cover_bg: "linear-gradient(180deg, #3450E4 0%, #D266DA 100%)", + card_bg: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + accent_color: "#6366f1", + }, + xiaohongshu: { + name: "小红书红", + cover_bg: "linear-gradient(180deg, #FF2442 0%, #FF6B81 100%)", + card_bg: "linear-gradient(135deg, #FF2442 0%, #FF6B81 100%)", + accent_color: "#FF2442", + }, + mint: { + name: "清新薄荷", + cover_bg: "linear-gradient(180deg, #43e97b 0%, #38f9d7 100%)", + card_bg: "linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)", + accent_color: "#43e97b", + }, + sunset: { + name: "日落橙", + cover_bg: "linear-gradient(180deg, #fa709a 0%, #fee140 100%)", + card_bg: "linear-gradient(135deg, #fa709a 0%, #fee140 100%)", + accent_color: "#fa709a", + }, + ocean: { + name: "深海蓝", + cover_bg: "linear-gradient(180deg, #4facfe 0%, #00f2fe 100%)", + card_bg: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)", + accent_color: "#4facfe", + }, + elegant: { + name: "优雅白", + cover_bg: "linear-gradient(180deg, #f5f5f5 0%, #e0e0e0 100%)", + card_bg: "linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%)", + accent_color: "#333333", + }, + dark: { + name: "暗黑模式", + cover_bg: "linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)", + card_bg: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)", + accent_color: "#e94560", + }, +}; + +/** + * 解析 Markdown 文件,提取 YAML 头部和正文内容 + */ +function parseMarkdownFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + + const yamlPattern = /^---\s*\n([\s\S]*?)\n---\s*\n/; + const yamlMatch = content.match(yamlPattern); + + let metadata = {}; + let body = content; + + if (yamlMatch) { + try { + metadata = yaml.load(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.filter(part => part.trim()).map(part => part.trim()); +} + +/** + * 预估内容高度 + */ +function estimateContentHeight(content) { + const lines = content.split('\n'); + let totalHeight = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + totalHeight += 20; + continue; + } + + if (trimmed.startsWith('# ')) { + totalHeight += 130; + } else if (trimmed.startsWith('## ')) { + totalHeight += 110; + } else if (trimmed.startsWith('### ')) { + totalHeight += 90; + } else if (trimmed.startsWith('```')) { + totalHeight += 80; + } else if (trimmed.match(/^[-*+]\s/)) { + totalHeight += 85; + } else if (trimmed.startsWith('>')) { + totalHeight += 100; + } else if (trimmed.startsWith('![')) { + totalHeight += 300; + } else { + const charCount = trimmed.length; + const linesNeeded = Math.max(1, charCount / 28); + totalHeight += Math.floor(linesNeeded * 42 * 1.7) + 35; + } + } + + return totalHeight; +} + +/** + * 智能拆分内容 + */ +function smartSplitContent(content, maxHeight = SAFE_HEIGHT) { + const blocks = []; + let currentBlock = []; + + const lines = content.split('\n'); + + for (const line of lines) { + if (line.trim().startsWith('#') && currentBlock.length > 0) { + blocks.push(currentBlock.join('\n')); + currentBlock = [line]; + } else if (line.trim() === '---') { + if (currentBlock.length > 0) { + blocks.push(currentBlock.join('\n')); + currentBlock = []; + } + } else { + currentBlock.push(line); + } + } + + if (currentBlock.length > 0) { + blocks.push(currentBlock.join('\n')); + } + + if (blocks.length <= 1) { + const paragraphs = content.split('\n\n').filter(b => b.trim()); + blocks.length = 0; + blocks.push(...paragraphs); + } + + const cards = []; + let currentCard = []; + let currentHeight = 0; + + for (const block of blocks) { + const blockHeight = estimateContentHeight(block); + + if (blockHeight > maxHeight) { + if (currentCard.length > 0) { + cards.push(currentCard.join('\n\n')); + currentCard = []; + currentHeight = 0; + } + + const blockLines = block.split('\n'); + let subBlock = []; + let subHeight = 0; + + for (const line of blockLines) { + const lineHeight = estimateContentHeight(line); + + if (subHeight + lineHeight > maxHeight && subBlock.length > 0) { + cards.push(subBlock.join('\n')); + subBlock = [line]; + subHeight = lineHeight; + } else { + subBlock.push(line); + subHeight += lineHeight; + } + } + + if (subBlock.length > 0) { + cards.push(subBlock.join('\n')); + } + } else if (currentHeight + blockHeight > maxHeight && currentCard.length > 0) { + cards.push(currentCard.join('\n\n')); + currentCard = [block]; + currentHeight = blockHeight; + } else { + currentCard.push(block); + currentHeight += blockHeight; + } + } + + if (currentCard.length > 0) { + cards.push(currentCard.join('\n\n')); + } + + return cards.length > 0 ? cards : [content]; +} + +/** + * 将 Markdown 转换为 HTML + */ +function convertMarkdownToHtml(mdContent, style = STYLES.purple) { + const tagsPattern = /((?:#[\w\u4e00-\u9fa5]+\s*)+)$/m; + const tagsMatch = mdContent.match(tagsPattern); + let tagsHtml = ""; + + if (tagsMatch) { + const tagsStr = tagsMatch[1]; + mdContent = mdContent.slice(0, tagsMatch.index).trim(); + const tags = tagsStr.match(/#([\w\u4e00-\u9fa5]+)/g); + if (tags) { + const accent = style.accent_color; + tagsHtml = '
'; + for (const tag of tags) { + tagsHtml += `${tag}`; + } + tagsHtml += '
'; + } + } + + const html = marked.parse(mdContent, { breaks: true, gfm: true }); + return html + tagsHtml; +} + +/** + * 生成封面 HTML + */ +function generateCoverHtml(metadata, styleKey = 'purple') { + const style = STYLES[styleKey] || STYLES.purple; + + 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 isDark = styleKey === 'dark'; + const textColor = isDark ? '#ffffff' : '#000000'; + const titleGradient = isDark + ? 'linear-gradient(180deg, #ffffff 0%, #cccccc 100%)' + : 'linear-gradient(180deg, #2E67B1 0%, #4C4C4C 100%)'; + const innerBg = isDark ? '#1a1a2e' : '#F3F3F3'; + + return ` + + + + + 小红书封面 + + + +
+
+
${emoji}
+
${title}
+
${subtitle}
+
+
+ +`; +} + +/** + * 生成正文卡片 HTML + */ +function generateCardHtml(content, pageNumber = 1, totalPages = 1, styleKey = 'purple') { + const style = STYLES[styleKey] || STYLES.purple; + const htmlContent = convertMarkdownToHtml(content, style); + const pageText = totalPages > 1 ? `${pageNumber}/${totalPages}` : ''; + + const isDark = styleKey === 'dark'; + const cardBg = isDark ? 'rgba(30, 30, 46, 0.95)' : 'rgba(255, 255, 255, 0.95)'; + const textColor = isDark ? '#e0e0e0' : '#475569'; + const headingColor = isDark ? '#ffffff' : '#1e293b'; + const h2Color = isDark ? '#e0e0e0' : '#334155'; + const h3Color = isDark ? '#c0c0c0' : '#475569'; + const codeBg = isDark ? '#252540' : '#f1f5f9'; + const preBg = isDark ? '#0f0f23' : '#1e293b'; + const blockquoteBg = isDark ? '#252540' : '#f1f5f9'; + const blockquoteColor = isDark ? '#a0a0a0' : '#64748b'; + const hrBg = isDark ? '#333355' : '#e2e8f0'; + + return ` + + + + + 小红书卡片 + + + +
+
+
+ ${htmlContent} +
+
+
${pageText}
+
+ +`; +} + +/** + * 测量内容高度 + */ +async function measureContentHeight(page, htmlContent) { + await page.setContent(htmlContent, { waitUntil: 'networkidle' }); + await page.waitForTimeout(300); + + return await page.evaluate(() => { + const inner = document.querySelector('.card-inner'); + if (inner) return inner.scrollHeight; + const container = document.querySelector('.card-container'); + return container ? container.scrollHeight : document.body.scrollHeight; + }); +} + +/** + * 处理和渲染卡片 + */ +async function processAndRenderCards(cardContents, outputDir, styleKey) { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: CARD_WIDTH, height: CARD_HEIGHT } }); + + const allCards = []; + + try { + for (const content of cardContents) { + const estimatedHeight = estimateContentHeight(content); + + let splitContents; + if (estimatedHeight > SAFE_HEIGHT) { + splitContents = smartSplitContent(content, SAFE_HEIGHT); + } else { + splitContents = [content]; + } + + for (const splitContent of splitContents) { + const tempHtml = generateCardHtml(splitContent, 1, 1, styleKey); + const actualHeight = await measureContentHeight(page, tempHtml); + + if (actualHeight > CARD_HEIGHT - 100) { + const lines = splitContent.split('\n'); + const subContents = []; + let subLines = []; + + for (const line of lines) { + const testLines = [...subLines, line]; + const testHtml = generateCardHtml(testLines.join('\n'), 1, 1, styleKey); + const testHeight = await measureContentHeight(page, testHtml); + + if (testHeight > CARD_HEIGHT - 100 && subLines.length > 0) { + subContents.push(subLines.join('\n')); + subLines = [line]; + } else { + subLines = testLines; + } + } + + if (subLines.length > 0) { + subContents.push(subLines.join('\n')); + } + + allCards.push(...subContents); + } else { + allCards.push(splitContent); + } + } + } + } finally { + await browser.close(); + } + + return allCards; +} + +/** + * 渲染 HTML 到图片 + */ +async function renderHtmlToImage(page, htmlContent, outputPath) { + await page.setContent(htmlContent, { waitUntil: 'networkidle' }); + await page.waitForTimeout(300); + + await page.screenshot({ + path: outputPath, + clip: { x: 0, y: 0, width: CARD_WIDTH, height: CARD_HEIGHT }, + type: 'png' + }); + + console.log(` ✅ 已生成: ${outputPath}`); +} + +/** + * 主渲染函数 + */ +async function renderMarkdownToCards(mdFile, outputDir, styleKey = 'purple') { + console.log(`\n🎨 开始渲染: ${mdFile}`); + console.log(`🎨 使用样式: ${STYLES[styleKey].name}`); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const data = parseMarkdownFile(mdFile); + const { metadata, body } = data; + + const cardContents = splitContentBySeparator(body); + console.log(` 📄 检测到 ${cardContents.length} 个内容块`); + + console.log(' 🔍 分析内容高度并智能分页...'); + const processedCards = await processAndRenderCards(cardContents, outputDir, styleKey); + const totalCards = processedCards.length; + console.log(` 📄 将生成 ${totalCards} 张卡片`); + + if (metadata.emoji || metadata.title) { + console.log(' 📷 生成封面...'); + const coverHtml = generateCoverHtml(metadata, styleKey); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: CARD_WIDTH, height: CARD_HEIGHT } }); + + try { + await renderHtmlToImage(page, coverHtml, path.join(outputDir, 'cover.png')); + } finally { + await browser.close(); + } + } + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: CARD_WIDTH, height: CARD_HEIGHT } }); + + try { + for (let i = 0; i < processedCards.length; i++) { + const pageNum = i + 1; + console.log(` 📷 生成卡片 ${pageNum}/${totalCards}...`); + + const cardHtml = generateCardHtml(processedCards[i], pageNum, totalCards, styleKey); + const cardPath = path.join(outputDir, `card_${pageNum}.png`); + + await renderHtmlToImage(page, cardHtml, cardPath); + } + } finally { + await browser.close(); + } + + console.log(`\n✨ 渲染完成!共生成 ${totalCards} 张卡片,保存到: ${outputDir}`); + return totalCards; +} + +/** + * 列出所有样式 + */ +function listStyles() { + console.log('\n📋 可用样式列表:'); + console.log('-'.repeat(40)); + for (const [key, style] of Object.entries(STYLES)) { + console.log(` ${key.padEnd(12)} - ${style.name}`); + } + console.log('-'.repeat(40)); +} + +/** + * 解析命令行参数 + */ +function parseArgs() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('--help')) { + console.log(` +使用方法: node render_xhs_v2.js [options] + +选项: + -o, --output-dir 输出目录(默认为当前工作目录) + -s, --style + + +
+
+
{emoji}
+
{title}
+
{subtitle}
+
+
+ +''' + + +def generate_card_html(content: str, page_number: int = 1, total_pages: int = 1, + style_key: str = "purple") -> str: + """生成正文卡片 HTML""" + style = STYLES.get(style_key, STYLES["purple"]) + html_content = convert_markdown_to_html(content, style) + page_text = f"{page_number}/{total_pages}" if total_pages > 1 else "" + + # 暗黑模式特殊处理 + is_dark = style_key == "dark" + card_bg = "rgba(30, 30, 46, 0.95)" if is_dark else "rgba(255, 255, 255, 0.95)" + text_color = "#e0e0e0" if is_dark else "#475569" + heading_color = "#ffffff" if is_dark else "#1e293b" + h2_color = "#e0e0e0" if is_dark else "#334155" + h3_color = "#c0c0c0" if is_dark else "#475569" + code_bg = "#0f0f23" if is_dark else "#1e293b" + pre_bg = "#0f0f23" if is_dark else "#1e293b" + blockquote_bg = "#252540" if is_dark else "#f1f5f9" + blockquote_border = style['accent_color'] + blockquote_color = "#a0a0a0" if is_dark else "#64748b" + + return f''' + + + + + 小红书卡片 + + + +
+
+
+ {html_content} +
+
+
{page_text}
+
+ +''' + + +async def measure_content_height(page: Page, html_content: str) -> int: + """使用 Playwright 测量实际内容高度""" + await page.set_content(html_content, wait_until='networkidle') + await page.wait_for_timeout(300) # 等待字体渲染 + + height = await page.evaluate('''() => { + const inner = document.querySelector('.card-inner'); + if (inner) { + return inner.scrollHeight; + } + const container = document.querySelector('.card-container'); + return container ? container.scrollHeight : document.body.scrollHeight; + }''') + + return height + + +async def render_html_to_image(html_content: str, output_path: str, + width: int = CARD_WIDTH, height: int = CARD_HEIGHT): + """使用 Playwright 将 HTML 渲染为图片""" + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={'width': width, 'height': height}) + + try: + await page.set_content(html_content, wait_until='networkidle') + await page.wait_for_timeout(300) + + # 截图固定尺寸 + await page.screenshot( + path=output_path, + clip={'x': 0, 'y': 0, 'width': width, 'height': height}, + type='png' + ) + + print(f" ✅ 已生成: {output_path}") + + finally: + await browser.close() + + +async def process_and_render_cards(card_contents: List[str], output_dir: str, + style_key: str) -> List[str]: + """ + 处理卡片内容,检测高度并自动分页,然后渲染 + 返回最终生成的所有卡片文件路径 + """ + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={'width': CARD_WIDTH, 'height': CARD_HEIGHT}) + + all_cards = [] + + try: + for content in card_contents: + # 预估内容高度 + estimated_height = estimate_content_height(content) + + # 如果预估高度超过安全高度,尝试拆分 + if estimated_height > SAFE_HEIGHT: + split_contents = smart_split_content(content, SAFE_HEIGHT) + else: + split_contents = [content] + + # 验证每个拆分后的内容 + for split_content in split_contents: + # 生成临时 HTML 测量 + temp_html = generate_card_html(split_content, 1, 1, style_key) + actual_height = await measure_content_height(page, temp_html) + + # 如果仍然超出,进一步按行拆分 + if actual_height > CARD_HEIGHT - 100: + lines = split_content.split('\n') + sub_contents = [] + sub_lines = [] + sub_height = 0 + + for line in lines: + test_lines = sub_lines + [line] + test_html = generate_card_html('\n'.join(test_lines), 1, 1, style_key) + test_height = await measure_content_height(page, test_html) + + if test_height > CARD_HEIGHT - 100 and sub_lines: + sub_contents.append('\n'.join(sub_lines)) + sub_lines = [line] + else: + sub_lines = test_lines + + if sub_lines: + sub_contents.append('\n'.join(sub_lines)) + + all_cards.extend(sub_contents) + else: + all_cards.append(split_content) + + finally: + await browser.close() + + return all_cards + + +async def render_markdown_to_cards(md_file: str, output_dir: str, style_key: str = "purple"): + """主渲染函数:将 Markdown 文件渲染为多张卡片图片""" + print(f"\n🎨 开始渲染: {md_file}") + print(f"🎨 使用样式: {STYLES[style_key]['name']}") + + # 确保输出目录存在 + os.makedirs(output_dir, exist_ok=True) + + # 解析 Markdown 文件 + data = parse_markdown_file(md_file) + metadata = data['metadata'] + body = data['body'] + + # 分割正文内容(基于用户手动分隔符) + card_contents = split_content_by_separator(body) + print(f" 📄 检测到 {len(card_contents)} 个内容块") + + # 处理内容,智能分页 + print(" 🔍 分析内容高度并智能分页...") + processed_cards = await process_and_render_cards(card_contents, output_dir, style_key) + total_cards = len(processed_cards) + print(f" 📄 将生成 {total_cards} 张卡片") + + # 生成封面 + if metadata.get('emoji') or metadata.get('title'): + print(" 📷 生成封面...") + cover_html = generate_cover_html(metadata, style_key) + cover_path = os.path.join(output_dir, 'cover.png') + await render_html_to_image(cover_html, cover_path) + + # 生成正文卡片 + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={'width': CARD_WIDTH, 'height': CARD_HEIGHT}) + + try: + for i, content in enumerate(processed_cards, 1): + print(f" 📷 生成卡片 {i}/{total_cards}...") + card_html = generate_card_html(content, i, total_cards, style_key) + card_path = os.path.join(output_dir, f'card_{i}.png') + + await page.set_content(card_html, wait_until='networkidle') + await page.wait_for_timeout(300) + + await page.screenshot( + path=card_path, + clip={'x': 0, 'y': 0, 'width': CARD_WIDTH, 'height': CARD_HEIGHT}, + type='png' + ) + print(f" ✅ 已生成: {card_path}") + + finally: + await browser.close() + + print(f"\n✨ 渲染完成!共生成 {total_cards} 张卡片,保存到: {output_dir}") + return total_cards + + +def list_styles(): + """列出所有可用样式""" + print("\n📋 可用样式列表:") + print("-" * 40) + for key, style in STYLES.items(): + print(f" {key:12} - {style['name']}") + print("-" * 40) + + +def main(): + parser = argparse.ArgumentParser( + description='将 Markdown 文件渲染为小红书风格的图片卡片(智能分页版)', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +示例: + python render_xhs_v2.py note.md + python render_xhs_v2.py note.md -o ./output --style xiaohongshu + python render_xhs_v2.py --list-styles + ''' + ) + parser.add_argument( + 'markdown_file', + nargs='?', + help='Markdown 文件路径' + ) + parser.add_argument( + '--output-dir', '-o', + default=os.getcwd(), + help='输出目录(默认为当前工作目录)' + ) + parser.add_argument( + '--style', '-s', + default='purple', + choices=list(STYLES.keys()), + help='样式主题(默认: purple)' + ) + parser.add_argument( + '--list-styles', + action='store_true', + help='列出所有可用样式' + ) + + args = parser.parse_args() + + if args.list_styles: + list_styles() + return + + if not args.markdown_file: + parser.print_help() + sys.exit(1) + + if not os.path.exists(args.markdown_file): + print(f"❌ 错误: 文件不存在 - {args.markdown_file}") + sys.exit(1) + + asyncio.run(render_markdown_to_cards(args.markdown_file, args.output_dir, args.style)) + + +if __name__ == '__main__': + main()