feat: v2.0 智能分页渲染 + 7种可选样式

新增功能:
- 智能分页渲染脚本(Python/Node.js),自动解决内容溢出问题
- 7种可选样式主题:purple, xiaohongshu, mint, sunset, ocean, elegant, dark
- 新增 STYLES.md 样式选择指南
- 更新 README,添加 v2.0 更新说明
- 保留 V1 旧版脚本保证兼容性

使用方式:
  python scripts/render_xhs_v2.py note.md --style sunset
This commit is contained in:
ZhangJia
2026-01-26 15:48:30 +08:00
parent b20a86afc6
commit 04a17ed78a
6 changed files with 1783 additions and 42 deletions

109
README.md
View File

@@ -6,6 +6,56 @@
[![Node.js](https://img.shields.io/badge/Node.js-16+-green.svg)](https://nodejs.org/) [![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) [![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`里) - 📝 **撰写笔记** - 根据既定主题,撰写小红书笔记(提示词自己调整,在 `SKILL.md`里)
@@ -22,7 +72,7 @@ Clone 项目到本地
```bash ```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 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 ### 1. 配置 Cookie
@@ -93,7 +178,7 @@ python scripts/publish_xhs.py \
## 🎨 自定义样式 ## 🎨 自定义样式
### 修改背景渐变 ### 修改背景渐变V1
编辑 `assets/card.html` 中的 `.card-container` 编辑 `assets/card.html` 中的 `.card-container`
@@ -113,9 +198,16 @@ python scripts/publish_xhs.py \
| 绿色 | `#43e97b → #38f9d7` | | 绿色 | `#43e97b → #38f9d7` |
| 橙黄 | `#fa709a → #fee140` | | 橙黄 | `#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/ md2Redbook/
├── SKILL.md # 技能描述AI Agent 使用) ├── SKILL.md # 技能描述AI Agent 使用)
├── README.md # 项目文档 ├── README.md # 项目文档
├── STYLES.md # 样式选择指南
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── package.json # Node.js 依赖 ├── package.json # Node.js 依赖
├── env.example.txt # Cookie 配置示例 ├── env.example.txt # Cookie 配置示例
@@ -132,8 +225,10 @@ md2Redbook/
│ ├── styles.css # 共用样式表 │ ├── styles.css # 共用样式表
│ └── example.md # 示例 Markdown │ └── example.md # 示例 Markdown
└── scripts/ └── scripts/
├── render_xhs.py # Python 渲染脚本 ├── render_xhs_v2.py # Python 渲染脚本 V2推荐
├── render_xhs.js # Node.js 渲染脚本 ├── render_xhs_v2.js # Node.js 渲染脚本 V2推荐
├── render_xhs.py # Python 渲染脚本 V1
├── render_xhs.js # Node.js 渲染脚本 V1
└── publish_xhs.py # 小红书发布脚本 └── publish_xhs.py # 小红书发布脚本
``` ```
@@ -149,7 +244,7 @@ md2Redbook/
- [Playwright](https://playwright.dev/) - 浏览器自动化渲染 - [Playwright](https://playwright.dev/) - 浏览器自动化渲染
- [Marked](https://marked.js.org/) - Markdown 解析 - [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 客户端 - [xhs](https://github.com/ReaJason/xhs) - 小红书 API 客户端
## 📄 License ## 📄 License

113
SKILL.md
View File

@@ -1,17 +1,18 @@
--- ---
name: xhs-note-creator name: Auto-Redbook
description: 小红书笔记素材创作技能。当用户需要创建小红书笔记素材时使用这个技能。技能包含:根据用户的需求和提供的资料,撰写小红书笔记内容(标题+正文),生成图片卡片(封面+正文卡片),以及发布小红书笔记 description: 小红书笔记素材创作技能。当用户需要创建小红书笔记素材时使用这个技能。技能包含:根据用户的需求和提供的资料,撰写小红书笔记内容(标题+正文),生成图片卡片(封面+正文卡片,支持多种样式主题)
--- ---
# 小红书笔记创作技能 # 小红书笔记创作技能
这个技能用于创建专业的小红书笔记素材,包括内容撰写、图片卡片生成和笔记发布 这个技能用于创建专业的小红书笔记素材,包括内容撰写、图片卡片生成支持7种样式主题和智能分页渲染
## 使用场景 ## 使用场景
- 用户需要创建小红书笔记时 - 用户需要创建小红书笔记时
- 用户提供资料需要转化为小红书风格内容时 - 用户提供资料需要转化为小红书风格内容时
- 用户需要生成精美的图片卡片用于发布时 - 用户需要生成精美的图片卡片用于发布时
- 用户需要多种风格样式选择时
## 工作流程 ## 工作流程
@@ -49,7 +50,7 @@ subtitle: "副标题文案" # 封面副标题不超过15字
2. 用于渲染卡片的 Markdown 文本内容: 2. 用于渲染卡片的 Markdown 文本内容:
- 使用 `---` 分割线将正文分隔为多个卡片段落 - 使用 `---` 分割线将正文分隔为多个卡片段落
- 每个分段的文字控制在 200 字左右 - 每个分段的文字控制在 200 字左右
- 后续会将每个卡片段落渲染为一张图片 - 脚本会自动检测内容高度并智能分页
完整示例: 完整示例:
```markdown ```markdown
@@ -72,9 +73,7 @@ subtitle: "对着抄作业就好了,一起变高效"
# 神器二Raycast ⚡ # 神器二Raycast ⚡
\`\`\`
可使用代码块来增加渲染后图片的视觉丰富度 可使用代码块来增加渲染后图片的视觉丰富度
\`\`\`
## 推荐原因 ## 推荐原因
@@ -90,28 +89,74 @@ subtitle: "对着抄作业就好了,一起变高效"
... ...
#效率工具 #生产力 #Mac软件
``` ```
### 第三步:渲染图片卡片 ### 第三步:渲染图片卡片
将 Markdown 文档渲染为图片卡片。提供两种渲染脚本: 将 Markdown 文档渲染为图片卡片。**推荐使用 V2 版本脚本**,支持智能分页和多种样式。
#### Python 渲染脚本 #### V2 渲染脚本(推荐)
V2 版本新增特性:
-**智能分页**:自动检测内容高度,超出时自动拆分到多张卡片
-**多种样式**:支持 7 种预设样式主题
-**字数预估**:基于字数预分配内容,减少渲染次数
**Python 版本:**
```bash ```bash
python scripts/render_xhs.py <markdown_file> [--output-dir <output_directory>] # 基本用法
python scripts/render_xhs_v2.py <markdown_file>
# 指定输出目录
python scripts/render_xhs_v2.py <markdown_file> -o <output_directory>
# 指定样式主题
python scripts/render_xhs_v2.py <markdown_file> --style xiaohongshu
# 查看所有可用样式
python scripts/render_xhs_v2.py --list-styles
``` ```
- 默认输出目录为当前工作目录 **Node.js 版本:**
- 生成的图片包括封面cover.png和正文卡片card_1.png, card_2.png, ...
#### Node.js 渲染脚本
```bash ```bash
# 基本用法
node scripts/render_xhs_v2.js <markdown_file>
# 指定输出目录和样式
node scripts/render_xhs_v2.js <markdown_file> -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 <markdown_file> [--output-dir <output_directory>]
# Node.js 版本
node scripts/render_xhs.js <markdown_file> [--output-dir <output_directory>] node scripts/render_xhs.js <markdown_file> [--output-dir <output_directory>]
``` ```
功能与 Python 版本相同 **旧版已知问题**:单张卡片内容过多时可能出现文字溢出,需手动用 `---` 分隔
### 第四步:发布小红书笔记(可选) ### 第四步:发布小红书笔记(可选)
@@ -139,30 +184,46 @@ XHS_COOKIE=your_cookie_string_here
- 尺寸比例3:4小红书推荐比例 - 尺寸比例3:4小红书推荐比例
- 基准尺寸1080×1440px - 基准尺寸1080×1440px
- 包含Emoji 装饰、大标题、副标题 - 包含Emoji 装饰、大标题、副标题
- 样式:渐变背景 + 圆角内容区 - 样式:渐变背景 + 圆角内容区(根据所选主题变化)
### 正文卡片 ### 正文卡片
- 尺寸比例3:4 - 尺寸比例3:4
- 基准尺寸1080×1440px - 基准尺寸1080×1440px
- 支持:标题、段落、列表、引用、代码块、图片 - 支持:标题、段落、列表、引用、代码块、图片
- 样式:白色卡片 + 渐变背景边框 - 样式:白色卡片 + 渐变背景边框(根据所选主题变化)
- V2 版本:自动分页,单张卡片内容不会溢出
## 技能资源 ## 技能资源
### 脚本文件 ### 脚本文件
- `scripts/render_xhs.py` - Python 渲染脚本 - `scripts/render_xhs.py` - Python V1 渲染脚本(旧版)
- `scripts/render_xhs.js` - Node.js 渲染脚本 - `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` - 小红书发布脚本 - `scripts/publish_xhs.py` - 小红书发布脚本
### 资源文件 ### 资源文件
- `assets/cover.html` - 封面 HTML 模板 - `assets/cover.html` - 封面 HTML 模板(旧版)
- `assets/card.html` - 正文卡片 HTML 模板 - `assets/card.html` - 正文卡片 HTML 模板(旧版)
- `assets/styles.css` - 共用样式表 - `assets/styles.css` - 共用样式表(旧版)
- `assets/example.md` - 示例 Markdown 文件
## 注意事项 ## 注意事项
1. Markdown 文件应保存在工作目录,渲染后的图片也保存在工作目录 1. **V2 版本推荐**V2 版本支持智能分页,可自动处理内容溢出问题
2. 技能目录 (`md2Redbook/`) 仅存放脚本和模板,不存放用户数据 2. **样式选择**:根据内容风格选择合适的样式主题
3. 图片尺寸会根据内容自动调整,但保持 3:4 比例 3. **Markdown 位置**Markdown 文件应保存在工作目录,渲染后的图片也保存在工作目录
4. Cookie 有有效期限制,过期后需要重新获取 4. **内容长度**:建议每个 `---` 分隔的内容块控制在 200 字以内
5. 发布功能依赖 xhs 库,需要安装:`pip install xhs` 5. **Cookie 有效期**:发布功能的 Cookie 有过期限制,过期后需要重新获取
6. **发布依赖**:发布功能依赖 xhs 库,需要安装:`pip install xhs`
## 智能分页说明
V2 版本的智能分页机制:
1. **预估阶段**:基于字数、元素类型预估内容高度
2. **预渲染阶段**:使用 Playwright 预渲染并测量实际高度
3. **拆分阶段**:如果内容超出,按段落/行智能拆分内容
4. **固定输出**:每张卡片固定为 1080×1440px确保一致性
这种机制确保无论内容多长,都不会出现文字溢出问题。

98
STYLES.md Normal file
View File

@@ -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
```

View File

@@ -4,7 +4,7 @@ title: "5个效率神器"
subtitle: "让工作效率翻倍" subtitle: "让工作效率翻倍"
--- ---
## 神器一Notion 📝 # 神器一Notion 📝
全能型笔记工具,支持数据库、看板、日历等多种视图。 全能型笔记工具,支持数据库、看板、日历等多种视图。
@@ -18,33 +18,60 @@ subtitle: "让工作效率翻倍"
--- ---
## 神器二Raycast ⚡ # 神器二Raycast ⚡
Mac 上的效率启动器,比 Spotlight 强大 100 倍! Mac 上的效率启动器,比 Spotlight 强大 100 倍!
```bash
# 快捷命令示例
raycast://extensions/raycast/clipboard/clipboard-history
```
**必装插件推荐:** **必装插件推荐:**
- 剪贴板历史 - 剪贴板历史
- 窗口管理 - 窗口管理
- 快捷短语 - 快捷短语
- API 调试工具 - API 调试工具
一键搜索、快速启动,让你的 Mac 飞起来 ✈️
--- ---
## 神器三Arc 浏览器 🌈 # 神器三Arc 浏览器 🌈
重新定义浏览器体验: 全新理念的浏览器体验:
- 侧边栏标签管理 - 侧边栏标签管理
- 空间分组功能 - 空间分组功能
- 内置笔记和画板 - 内置笔记和画板
- 极简无干扰模式 - 极简无干扰模式
告别标签栏焦虑,专注当下任务! ---
# 神器四Warp 终端 🖥️
基于 Rust 的现代化终端:
```python
# 支持 AI 智能补全
def example():
print("Hello Warp!")
```
- ⚡ 极速性能
- 🤖 AI 智能提示
- 📋 自动补全
- 🎯 分组工作区
--- ---
## 总结 🎯 # 神器五Fig 自动补全 🔮
终端命令自动补全神器:
- 数百种 CLI 工具支持
- 可视化参数提示
- 团队协作分享
---
# 总结 🎯
效率提升不在于工具多少,而在于是否**真正用起来**。 效率提升不在于工具多少,而在于是否**真正用起来**。
@@ -54,4 +81,4 @@ Mac 上的效率启动器,比 Spotlight 强大 100 倍!
✅ 减少 80% 的焦虑 ✅ 减少 80% 的焦虑
✅ 提升 100% 的专注力 ✅ 提升 100% 的专注力
#效率工具 #生产力 #Mac软件 #工作技巧 #神器推荐 #Notion #Raycast #Arc浏览器 #效率工具 #生产力 #Mac软件 #工作技巧 #神器推荐 #Notion #Raycast #Arc浏览器 #Warp #Fig

723
scripts/render_xhs_v2.js Normal file
View File

@@ -0,0 +1,723 @@
#!/usr/bin/env node
/**
* 小红书卡片渲染脚本 V2 - Node.js 智能分页版
* 将 Markdown 文件渲染为小红书风格的图片卡片
*
* 新特性:
* 1. 智能分页:自动检测内容高度,超出时自动拆分到多张卡片
* 2. 多种样式:支持多种预设样式主题
*
* 使用方法:
* node render_xhs_v2.js <markdown_file> [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 = '<div class="tags-container">';
for (const tag of tags) {
tagsHtml += `<span class="tag" style="background: ${accent};">${tag}</span>`;
}
tagsHtml += '</div>';
}
}
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 `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080, height=1440">
<title>小红书封面</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif;
width: 1080px; height: 1440px; overflow: hidden;
}
.cover-container {
width: 1080px; height: 1440px;
background: ${style.cover_bg};
position: relative; overflow: hidden;
}
.cover-inner {
position: absolute; width: 950px; height: 1310px;
left: 65px; top: 65px;
background: ${innerBg};
border-radius: 25px;
display: flex; flex-direction: column;
padding: 80px 85px;
}
.cover-emoji { font-size: 180px; line-height: 1.2; margin-bottom: 50px; }
.cover-title {
font-weight: 900; font-size: 130px; line-height: 1.4;
background: ${titleGradient};
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
flex: 1;
display: flex; align-items: flex-start;
word-break: break-all;
}
.cover-subtitle {
font-weight: 350; font-size: 72px; line-height: 1.4;
color: ${textColor};
margin-top: auto;
}
</style>
</head>
<body>
<div class="cover-container">
<div class="cover-inner">
<div class="cover-emoji">${emoji}</div>
<div class="cover-title">${title}</div>
<div class="cover-subtitle">${subtitle}</div>
</div>
</div>
</body>
</html>`;
}
/**
* 生成正文卡片 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 `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080">
<title>小红书卡片</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif;
width: 1080px; min-height: 1440px; overflow: hidden; background: transparent;
}
.card-container {
width: 1080px; min-height: 1440px;
background: ${style.card_bg};
position: relative; padding: 50px; overflow: hidden;
}
.card-inner {
background: ${cardBg};
border-radius: 20px;
padding: 60px;
min-height: calc(1440px - 100px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.card-content {
color: ${textColor};
font-size: 42px;
line-height: 1.7;
}
.card-content h1 {
font-size: 72px; font-weight: 700; color: ${headingColor};
margin-bottom: 40px; line-height: 1.3;
}
.card-content h2 {
font-size: 56px; font-weight: 600; color: ${h2Color};
margin: 50px 0 25px 0; line-height: 1.4;
}
.card-content h3 {
font-size: 48px; font-weight: 600; color: ${h3Color};
margin: 40px 0 20px 0;
}
.card-content p { margin-bottom: 35px; }
.card-content strong { font-weight: 700; color: ${headingColor}; }
.card-content em { font-style: italic; color: ${style.accent_color}; }
.card-content a {
color: ${style.accent_color}; text-decoration: none;
border-bottom: 2px solid ${style.accent_color};
}
.card-content ul, .card-content ol {
margin: 30px 0; padding-left: 60px;
}
.card-content li { margin-bottom: 20px; line-height: 1.6; }
.card-content blockquote {
border-left: 8px solid ${style.accent_color};
padding-left: 40px;
background: ${blockquoteBg};
padding-top: 25px; padding-bottom: 25px; padding-right: 30px;
margin: 35px 0;
color: ${blockquoteColor};
font-style: italic;
border-radius: 0 12px 12px 0;
}
.card-content blockquote p { margin: 0; }
.card-content code {
background: ${codeBg};
padding: 6px 16px; border-radius: 8px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 38px;
color: ${style.accent_color};
}
.card-content pre {
background: ${preBg};
color: ${isDark ? '#e0e0e0' : '#e2e8f0'};
padding: 40px; border-radius: 16px;
margin: 35px 0;
overflow-x: visible;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
font-size: 36px; line-height: 1.5;
}
.card-content pre code {
background: transparent; color: inherit; padding: 0; font-size: inherit;
}
.card-content img {
max-width: 100%; height: auto; border-radius: 16px;
margin: 35px auto; display: block;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.card-content hr {
border: none; height: 2px;
background: ${hrBg};
margin: 50px 0;
}
.tags-container {
margin-top: 50px; padding-top: 30px;
border-top: 2px solid ${hrBg};
}
.tag {
display: inline-block;
background: ${style.accent_color};
color: white;
padding: 12px 28px; border-radius: 30px;
font-size: 34px;
margin: 10px 15px 10px 0;
font-weight: 500;
}
.page-number {
position: absolute;
bottom: 80px; right: 80px;
font-size: 36px;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
</style>
</head>
<body>
<div class="card-container">
<div class="card-inner">
<div class="card-content">
${htmlContent}
</div>
</div>
<div class="page-number">${pageText}</div>
</div>
</body>
</html>`;
}
/**
* 测量内容高度
*/
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 <markdown_file> [options]
选项:
-o, --output-dir <dir> 输出目录(默认为当前工作目录)
-s, --style <style> 样式主题(默认: purple
--list-styles 列出所有可用样式
--help 显示帮助信息
可用样式:
purple, xiaohongshu, mint, sunset, ocean, elegant, dark
示例:
node render_xhs_v2.js note.md
node render_xhs_v2.js note.md -o ./output --style xiaohongshu
`);
process.exit(0);
}
if (args.includes('--list-styles')) {
listStyles();
process.exit(0);
}
let markdownFile = null;
let outputDir = process.cwd();
let style = 'purple';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output-dir' || args[i] === '-o') {
outputDir = args[i + 1];
i++;
} else if (args[i] === '--style' || args[i] === '-s') {
if (STYLES[args[i + 1]]) {
style = args[i + 1];
} else {
console.error(`❌ 无效样式: ${args[i + 1]}`);
console.log('可用样式:', Object.keys(STYLES).join(', '));
process.exit(1);
}
i++;
} else if (!args[i].startsWith('-')) {
markdownFile = args[i];
}
}
if (!markdownFile) {
console.error('❌ 错误: 请指定 Markdown 文件');
process.exit(1);
}
if (!fs.existsSync(markdownFile)) {
console.error(`❌ 错误: 文件不存在 - ${markdownFile}`);
process.exit(1);
}
return { markdownFile, outputDir, style };
}
// 主函数
async function main() {
const { markdownFile, outputDir, style } = parseArgs();
await renderMarkdownToCards(markdownFile, outputDir, style);
}
main().catch(error => {
console.error('❌ 渲染失败:', error.message);
process.exit(1);
});

737
scripts/render_xhs_v2.py Normal file
View File

@@ -0,0 +1,737 @@
#!/usr/bin/env python3
"""
小红书卡片渲染脚本 V2 - 智能分页版
将 Markdown 文件渲染为小红书风格的图片卡片
新特性:
1. 智能分页:自动检测内容高度,超出时自动拆分到多张卡片
2. 多种样式:支持多种预设样式主题
3. 字数预估:基于字数预分配内容,减少渲染次数
使用方法:
python render_xhs_v2.py <markdown_file> [options]
依赖安装:
pip install markdown pyyaml playwright
playwright install chromium
"""
import argparse
import asyncio
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import List, Dict, Tuple
try:
import markdown
import yaml
from playwright.async_api import async_playwright, Page
except ImportError as e:
print(f"缺少依赖: {e}")
print("请运行: pip install markdown pyyaml playwright && playwright install chromium")
sys.exit(1)
# 获取脚本所在目录
SCRIPT_DIR = Path(__file__).parent.parent
ASSETS_DIR = SCRIPT_DIR / "assets"
# 卡片尺寸配置 (3:4 比例)
CARD_WIDTH = 1080
CARD_HEIGHT = 1440
# 内容区域安全高度(考虑 padding 和 margin
# card-inner padding: 60px * 2 = 120px
# card-container padding: 50px * 2 = 100px
# 页码区域: ~80px
# 安全边距: ~40px
SAFE_HEIGHT = CARD_HEIGHT - 120 - 100 - 80 - 40 # ~1100px
# 样式配置
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",
"text_light": "#555555",
},
"dark": {
"name": "暗黑模式",
"cover_bg": "linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)",
"card_bg": "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
"accent_color": "#e94560",
},
}
def parse_markdown_file(file_path: str) -> dict:
"""解析 Markdown 文件,提取 YAML 头部和正文内容"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 解析 YAML 头部
yaml_pattern = r'^---\s*\n(.*?)\n---\s*\n'
yaml_match = re.match(yaml_pattern, content, re.DOTALL)
metadata = {}
body = content
if yaml_match:
try:
metadata = yaml.safe_load(yaml_match.group(1)) or {}
except yaml.YAMLError:
metadata = {}
body = content[yaml_match.end():]
return {
'metadata': metadata,
'body': body.strip()
}
def split_content_by_separator(body: str) -> list:
"""按照 --- 分隔符拆分正文为多张卡片内容"""
parts = re.split(r'\n---+\n', body)
return [part.strip() for part in parts if part.strip()]
def estimate_content_height(content: str) -> int:
"""预估内容高度(基于字数和元素类型)"""
lines = content.split('\n')
total_height = 0
for line in lines:
line = line.strip()
if not line:
total_height += 20 # 空行
continue
# 标题
if line.startswith('# '):
total_height += 130 # h1: font-size 72 + margin
elif line.startswith('## '):
total_height += 110 # h2
elif line.startswith('### '):
total_height += 90 # h3
# 代码块
elif line.startswith('```'):
total_height += 80 # 代码块起始/结束
# 列表
elif line.startswith(('- ', '* ', '+ ')):
total_height += 85 # li: line-height ~1.6, font-size 42
# 引用
elif line.startswith('>'):
total_height += 100 # blockquote padding
# 图片
elif line.startswith('!['):
total_height += 300 # 图片高度估计
# 普通段落
else:
# 估算字数
char_count = len(line)
# 一行约25-30个中文字行高1.7字体42px
lines_needed = max(1, char_count / 28)
total_height += int(lines_needed * 42 * 1.7) + 35 # + margin-bottom
return total_height
def smart_split_content(content: str, max_height: int = SAFE_HEIGHT) -> List[str]:
"""
智能拆分内容到多张卡片
基于预估高度进行拆分,尽量保持段落完整
"""
# 首先尝试识别内容块(以标题或空行分隔)
blocks = []
current_block = []
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
# 新标题开始新块(除非是第一个)
if line.strip().startswith('#') and current_block:
blocks.append('\n'.join(current_block))
current_block = [line]
# 分隔线
elif line.strip() == '---':
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
else:
current_block.append(line)
i += 1
if current_block:
blocks.append('\n'.join(current_block))
# 如果没有明显的块边界,按段落拆分
if len(blocks) <= 1:
blocks = [b for b in content.split('\n\n') if b.strip()]
# 合并块到卡片,确保每张卡片高度不超过限制
cards = []
current_card = []
current_height = 0
for block in blocks:
block_height = estimate_content_height(block)
# 如果单个块就超过限制,需要进一步拆分
if block_height > max_height:
# 如果当前卡片有内容,先保存
if current_card:
cards.append('\n\n'.join(current_card))
current_card = []
current_height = 0
# 将大块按行拆分
lines = block.split('\n')
sub_block = []
sub_height = 0
for line in lines:
line_height = estimate_content_height(line)
if sub_height + line_height > max_height and sub_block:
cards.append('\n'.join(sub_block))
sub_block = [line]
sub_height = line_height
else:
sub_block.append(line)
sub_height += line_height
if sub_block:
cards.append('\n'.join(sub_block))
# 如果当前卡片加上这个块会超,先保存当前卡片
elif current_height + block_height > max_height and current_card:
cards.append('\n\n'.join(current_card))
current_card = [block]
current_height = block_height
# 否则加入当前卡片
else:
current_card.append(block)
current_height += block_height
# 保存最后一个卡片
if current_card:
cards.append('\n\n'.join(current_card))
return cards if cards else [content]
def convert_markdown_to_html(md_content: str, style: dict = None) -> str:
"""将 Markdown 转换为 HTML"""
style = style or STYLES["purple"]
# 处理 tags以 # 开头的标签)
tags_pattern = r'((?:#[\w\u4e00-\u9fa5]+\s*)+)$'
tags_match = re.search(tags_pattern, md_content, re.MULTILINE)
tags_html = ""
if tags_match:
tags_str = tags_match.group(1)
md_content = md_content[:tags_match.start()].strip()
tags = re.findall(r'#([\w\u4e00-\u9fa5]+)', tags_str)
if tags:
accent = style.get('accent_color', '#6366f1')
tags_html = f'<div class="tags-container">'
for tag in tags:
tags_html += f'<span class="tag" style="background: {accent};">#{tag}</span>'
tags_html += '</div>'
# 转换 Markdown 为 HTML
html = markdown.markdown(
md_content,
extensions=['extra', 'codehilite', 'tables', 'nl2br']
)
return html + tags_html
def generate_cover_html(metadata: dict, style_key: str = "purple") -> str:
"""生成封面 HTML"""
style = STYLES.get(style_key, STYLES["purple"])
emoji = metadata.get('emoji', '📝')
title = metadata.get('title', '标题')
subtitle = metadata.get('subtitle', '')
# 限制标题和副标题长度
if len(title) > 15:
title = title[:15]
if len(subtitle) > 15:
subtitle = subtitle[:15]
# 暗黑模式特殊处理
is_dark = style_key == "dark"
text_color = "#ffffff" if is_dark else "#000000"
title_gradient = "linear-gradient(180deg, #ffffff 0%, #cccccc 100%)" if is_dark else "linear-gradient(180deg, #2E67B1 0%, #4C4C4C 100%)"
inner_bg = "#1a1a2e" if is_dark else "#F3F3F3"
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080, height=1440">
<title>小红书封面</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Noto Sans SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif;
width: 1080px; height: 1440px; overflow: hidden;
}}
.cover-container {{
width: 1080px; height: 1440px;
background: {style['cover_bg']};
position: relative; overflow: hidden;
}}
.cover-inner {{
position: absolute; width: 950px; height: 1310px;
left: 65px; top: 65px;
background: {inner_bg};
border-radius: 25px;
display: flex; flex-direction: column;
padding: 80px 85px;
}}
.cover-emoji {{ font-size: 180px; line-height: 1.2; margin-bottom: 50px; }}
.cover-title {{
font-weight: 900; font-size: 130px; line-height: 1.4;
background: {title_gradient};
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
flex: 1;
display: flex; align-items: flex-start;
word-break: break-all;
}}
.cover-subtitle {{
font-weight: 350; font-size: 72px; line-height: 1.4;
color: {text_color};
margin-top: auto;
}}
</style>
</head>
<body>
<div class="cover-container">
<div class="cover-inner">
<div class="cover-emoji">{emoji}</div>
<div class="cover-title">{title}</div>
<div class="cover-subtitle">{subtitle}</div>
</div>
</div>
</body>
</html>'''
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'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080">
<title>小红书卡片</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Noto Sans SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif;
width: 1080px; min-height: 1440px; overflow: hidden; background: transparent;
}}
.card-container {{
width: 1080px; min-height: 1440px;
background: {style['card_bg']};
position: relative; padding: 50px; overflow: hidden;
}}
.card-inner {{
background: {card_bg};
border-radius: 20px;
padding: 60px;
min-height: calc(1440px - 100px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}}
.card-content {{
color: {text_color};
font-size: 42px;
line-height: 1.7;
}}
.card-content h1 {{
font-size: 72px; font-weight: 700; color: {heading_color};
margin-bottom: 40px; line-height: 1.3;
}}
.card-content h2 {{
font-size: 56px; font-weight: 600; color: {h2_color};
margin: 50px 0 25px 0; line-height: 1.4;
}}
.card-content h3 {{
font-size: 48px; font-weight: 600; color: {h3_color};
margin: 40px 0 20px 0;
}}
.card-content p {{ margin-bottom: 35px; }}
.card-content strong {{ font-weight: 700; color: {heading_color}; }}
.card-content em {{ font-style: italic; color: {style['accent_color']}; }}
.card-content a {{
color: {style['accent_color']}; text-decoration: none;
border-bottom: 2px solid {style['accent_color']};
}}
.card-content ul, .card-content ol {{
margin: 30px 0; padding-left: 60px;
}}
.card-content li {{ margin-bottom: 20px; line-height: 1.6; }}
.card-content blockquote {{
border-left: 8px solid {blockquote_border};
padding-left: 40px;
background: {blockquote_bg};
padding-top: 25px; padding-bottom: 25px; padding-right: 30px;
margin: 35px 0;
color: {blockquote_color};
font-style: italic;
border-radius: 0 12px 12px 0;
}}
.card-content blockquote p {{ margin: 0; }}
.card-content code {{
background: {'#252540' if is_dark else '#f1f5f9'};
padding: 6px 16px; border-radius: 8px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 38px;
color: {style['accent_color']};
}}
.card-content pre {{
background: {pre_bg};
color: {'#e0e0e0' if is_dark else '#e2e8f0'};
padding: 40px; border-radius: 16px;
margin: 35px 0;
overflow-x: visible;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
font-size: 36px; line-height: 1.5;
}}
.card-content pre code {{
background: transparent; color: inherit; padding: 0; font-size: inherit;
}}
.card-content img {{
max-width: 100%; height: auto; border-radius: 16px;
margin: 35px auto; display: block;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}}
.card-content hr {{
border: none; height: 2px;
background: {'#333355' if is_dark else '#e2e8f0'};
margin: 50px 0;
}}
.tags-container {{
margin-top: 50px; padding-top: 30px;
border-top: 2px solid {'#333355' if is_dark else '#e2e8f0'};
}}
.tag {{
display: inline-block;
background: {style['accent_color']};
color: white;
padding: 12px 28px; border-radius: 30px;
font-size: 34px;
margin: 10px 15px 10px 0;
font-weight: 500;
}}
.page-number {{
position: absolute;
bottom: 80px; right: 80px;
font-size: 36px;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}}
</style>
</head>
<body>
<div class="card-container">
<div class="card-inner">
<div class="card-content">
{html_content}
</div>
</div>
<div class="page-number">{page_text}</div>
</div>
</body>
</html>'''
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()