# WeChat-to-xhs-cards

> 把一篇推文/公众号长文/文章转成小红书 3:4 竖版图片笔记（1080×1440 PNG，多图轮播），自动分页排版后直接出图；同时产出一份可直接复制发布的小红书文字稿（标题/正文/话题标签/参考文献）。支持把产品图透明叠放到插画上做场景卡。当用户说「把这篇推文做成小红书图片」「转成图片笔记」「推文转小红书」「文章拆成图片」「出小红书封面图/配图」「长文做成九图」「这篇公众号文章发小红书」时使用。产出 = 一组 PNG + 一个可本地打开预览的 HTML + 一份文字稿。不用于写文案，也不用于单张海报设计。

- Skill: `ss-yanhao/wechat-to-xhs-cards` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add ss-yanhao/wechat-to-xhs-cards`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ss-yanhao/wechat-to-xhs-cards/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: ss-yanhao (https://skillmd.com/u/ss-yanhao)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ss-yanhao/wechat-to-xhs-cards

---


# 推文 → 小红书图片笔记

把已成稿的长文，拆成小红书那种 3:4 竖版多图笔记：1 张封面 + 若干正文页 + 1 张结尾页，
每页 1080×1440，自动排版、自动导出 PNG。

分工是死的：**AI 负责读懂文章并提炼分页，脚本负责排版出图。** 脚本不理解内容，
所以每一页放什么、标题怎么写，是你要干的活。

## 工作流

### 1. 拿到原文
用户可能给：本地文件路径（.md/.txt/.html）、粘贴的正文、或一个 URL。

**如果是微信公众号链接**，走这两步（WebFetch 只能拿文字、拿不到图）：

```bash
# ① 用 wechat-article-scraper 技能抓全文 + 全部图片
printf '%s\n' "<url>" > urls.txt
python3 ~/.workbuddy/skills/wechat-article-scraper/scripts/download_wechat_articles.py \
  urls.txt --output-dir ./raw --min-image-size 500
```
`--min-image-size` 一定要调到 **500 左右而不是默认的 6000** —— 我们要把所有图都拿下来，
自己按尺寸判定装饰，而不是让脚本用一个粗糙的字节数阈值替我们决定。

```bash
# ② 量尺寸 + 算哈希，用数据筛装饰，别靠肉眼猜
#    宽高比、重复的 md5、白底大留白，是三个决定性的信号
```

**微信推文的装饰图识别规则**（编辑器模板给装饰图用的也是 `<img>`，标签层面区分不了）：

| 信号 | 判定 | 说明 |
|---|---|---|
| 同一张图（md5 相同）在文中出现 ≥2 次 | **装饰** | 分割条、章节隔断 —— 这是最强的信号，一票否决 |
| 高度 < 200px 或 宽高比 > 4 | **装饰** | 分割线、装饰横幅 |
| 宽高比 < 0.4 且白底面积大、字节数小 | **装饰** | 竖版收尾点缀（如羽毛笔、印章） |
| 出现在正文结束后、参考文献/落款之前 | **装饰** | 版式收尾，不是内容 |
| 单边 500px 宽但有完整画面叙事 | **配图** | 编辑器的半宽插图，是内容 |
| `data-w=1280` 且字节数 > 500KB | **配图** | 全宽实拍/大插画 |

**装饰性文本也要剔掉**：小节序号（`#1`/`#2`/裸数字 `1`、`5`）、模板自带的
"点击上方蓝字关注"、空 `<section>`。这些在 `get_text()` 里会混进正文，分页时会变成
莫名其妙的独立短行 —— 正文里出现孤立的数字或 `#N`，基本就是这个。

> ⚠️ **引用标号（`[1]` / `[2][3]` / `[4]` 这类方括号数字）绝不能删。**
> 用户 2026-09-11 明确定下规则：引用标号要**作为正文文本原样保留**，留在它原本所引用的
> 那半句末尾（例：「…让营养吸收更顺畅；[1]兼顾强健骨骼：…」）。
> 它们与文末「参考文献」一一对应，删掉参考文献就成了没有锚点的孤儿。
>
> **区分口诀**：`#N` / 独立成行的裸数字 = 编辑器**序号**装饰 → 可删；
> `[N]` = **引用**标号 → 必留。两者长得像，性质完全相反，别混。
> （如果删了标号后，卖点要点读起来更通顺，那是假象 —— 通顺的代价是失去出处。）

判定完**把结论连依据一起告诉用户**（哪张判为装饰、为什么），让他能推翻你的判断。

### 2. 提炼成 spec（核心步骤，别偷懒）
通读全文，然后决定分页。**分页原则**：

- **封面**：一句钩子标题，**15 字以内**，要有反差或数字。不是文章原标题直接搬 ——
  原标题通常太"文学"，小红书要的是"点进去的冲动"。
- **正文页**：一页只讲一件事。3～4 个要点最佳，超过 5 个就该拆页。
  要点是短句（≤25 字），不是完整段落。
- **金句页**：文章里最扎人的一句话，单独一页放。没有就不放。
- **数据页**：有数字/成果时用，2～4 组最佳。
- **结尾页**：一句总结。互动引导与话题标签**不上图**，走单独的文字稿（见第 5 步）。

页数控制在 **5～9 页**。少于 5 页内容撑不住，多于 9 页没人划到底。

### 3. 写 spec JSON
```json
{
  "style": "ink",
  "accent": "#C1440E",
  "kicker": "顶部小标签 · 品牌或栏目",
  "source": "页脚来源",
  // footnote: 页脚右可选文本，缺省不渲染。建议留空，或放日期/栏目名
  "cover": {
    "title": "封面钩子标题",
    "subtitle": "一句补充说明",
    "img": "cover.jpg"
  },
  "cards": [
    { "kind": "stat", "title": "先摆结果",
      "items": [{ "num": "69", "label": "年品牌历史" }] },
    { "kind": "points", "index": "01", "title": "这一页的小标题",
      "points": ["要点一", "要点二", "要点三"],
      "note": "底部补充一句（可选）" },
    { "kind": "quote", "text": "金句", "by": "出处（可选）" }
  ],
  "ending": {
    "title": "收尾一句",
    "img": "art01_img04.png"
  }
}
```

> **话题标签与引导文案（如"评论区聊聊""更多内容见公众号"）不要写进 spec** ——
> 它们不再渲染到图上。这些统一放进另外单独交付的**文字稿**里（见第 5 步）。

**页眉页脚的 4 段文本来自四个不同地方，交付时要能说清哪个是提取、哪个是撰写的：**

| 位置 | 字段 / 代码 | 来源 |
|---|---|---|
| 页眉左 `品牌 · 由头` | `kicker` | **撰写**。惯例「品牌 · 栏目/由头」，由头从标题与发布日期推 |
| 页眉右 `09 / 09` | `head_foot()` | **脚本自动生成**，不用填 |
| 页脚左 `品牌名` | `source` | **从原文提取**（公众号 `var nickname` / `nick_name`） |
| 页脚右（默认空） | `footnote` | **可选，自己填**。缺省**不渲染** |

> **页脚右没有默认文案** —— 这个位置早先硬编码过一个占位符，结果每张图都挂着一句
> 与文章无关的废话，发出去就是废信息。现在改成 spec 的 `footnote` 字段：填了就渲染，
> 不填就整格不出现。**别再把任何写死的默认值加回脚本。**
>
> 内容给什么，取决于你想让读者看到什么。常见的几种：日期（`2026.09.10`）、
> 栏目/系列名（`养生日常`）、一句品牌主张。**不建议放账号 ID** —— 页眉、页脚左
> 通常已经有品牌名了，再叠一次是重复。

五种卡片类型：`points`（要点列表，最常用）、`quote`（金句）、`stat`（数据）、
`image`（图片）、`product`（场景+产品叠图）。
**字段缺省即不渲染**，不用凑。图上**不要放话题标签和引导文案** —— 那些单独出一份文字稿。

### 图片卡（有配图时用）

```json
{ "kind": "image",
  "img": "art01_img01.png",          // 只写文件名，配合 --img-base 用
  "title": "图片下方的小标题",
  "points": ["要点一", "要点二"],      // 可选，字号会自动比 points 页小
  "caption": "图片说明 / 产品名",      // 可选
  "layout": "top" }                   // top=图上文下（默认）；imgonly=整页只有图
```

### 场景卡（把产品图叠到插画上）

产品图缩小后叠在插画的角上，省一页版面、又不遮主体。**前提**：产品图必须是
**RGBA 透明底**（"漂浮罐体"）；用 `PIL` 检查四角 `alpha==0` 即可确认，
不要只看 RGB —— 透明区的 RGB 通常是黑的，很容易误判成"黑底图"。

```json
{ "kind": "product",
  "base": "art01_img07.png",     // 底图（插画），必须是不透明图
  "product": "art01_img05.png",  // 产品图（透明底）
  "title": "场景标题",
  "caption": "产品名",
  "base_fit": "cover",           // cover(默认) | contain —— 竖版底图用 contain
  "prod_anchor": "left",         // right(默认) | left
  "prod_w": 34,                  // 产品图宽度，占画布宽 %（建议 30~38）
  "prod_x": 3 }                  // 距锚点边的内缩 %
```

两条硬规则：

- **`prod_w` 不要小于 30%**。画布 1080px，30% ≈ 324px；再小罐体上的产品名和
  卖点数字就看不清了，叠了等于没叠。"完全不遮挡"和"看得清"是冲突的，取后者。
- **产品图的底边要与底图下缘齐平**（`bottom:0`，CSS 已内置）。产品图自身的罐底
  在原图里往往就是被裁掉的，底边对齐正好把切口藏进画面边界，看起来是"从画面底部
  立出来"而不是"被切断"。

**别把"边缘密度最低"当成唯一依据选叠图侧**。那种纯量化分析不认识语义 ——
实测有一次它推荐了右侧，但右侧恰好是插画里唯一给"关节"做论证的膝关节特写圆圈。
**必须亲眼看一遍候选底图**，确认要叠的角上没有关键画面元素。

### 封面图与结尾图

`cover.img` 会以通栏形式放在封面底部（替代原先的话题标签位置）；
`ending.img` 会作为"漂浮插画"贴在结尾页底部。两张都可选，不放也不影响。

配图目录用 `--img-base` 传，脚本会把用到的图复制到 `out/images/`，HTML 用相对路径引用，
所以整个 `out/` 目录可以整个搬走不会掉图。

**图片卡文字被裁掉的坑**：`.textbox` 必须是 `flex: 0 0 auto`。如果给它 `flex-shrink:1`，
它会自己压掉高度把内容藏起来，外层 `.stage` 就检测不到溢出，字号自适应永远不触发 ——
表现就是最后的文字被切掉一半。这个坑踩过一次，别改回去。

### 4. 跑脚本出图
```bash
python3 ~/.workbuddy/skills/WeChat-to-xhs-cards/scripts/render.py \
  --spec /path/to/cards.json --out /path/to/out --style ink --scale 2
```

- `--style`：`ink`（暖白编辑风，默认） / `glass`（液态玻璃） / `bold`（深色撞色）
- `--accent`：覆盖主色，如 `#C1440E`
- `--scale`：2 = 2160×2880（默认，够清晰）；1 = 1080×1440
- `--html-only`：只出 HTML 不出图，调样式时用

产物：`01-cover.png` … `NN-ending.png` + `preview.html`（本地打开就能一屏看完全部）。

### 5. 交付
两样东西一起给：

1. **图片**：用 present_files 把 PNG 丢给用户，附一句你分了几页、为什么这样分
2. **文字稿**：另外写一个 `.md`，四个区块 —— 标题（2 个备选 + 原文标题）/ 简短正文 /
   话题标签 / 参考文献。**正文区块里不要混 markdown 语法**，用户要整段复制到小红书发布框。
   正文从原文重组、不新增事实，控制在 200~300 字。

## 排版是自动的，不用你操心

- 字号自适应：每页的文字块会自己缩到不溢出（脚本内置的 fit 逻辑）
- 内容不满一屏时纵向居中，不会顶在上半页
- 想改视觉（配色/字号/圆角）→ 只改 `assets/card.css`，不用碰脚本

## 字体与商用授权（重要，别改回去）

**出图只用内置的开源字体，不碰任何商业字体。**

- 中文正文 = **思源黑体**（Noto Sans SC）；金句页的装饰引号 = **思源宋体**（Noto Serif SC）
- 两者都是 **SIL OFL 1.1**：免费商用，且协议明确允许"与任何软件捆绑再分发"，
  所以可以随本技能一起进公开仓库
- 字体放在 `assets/fonts/`（子集化 woff2，6 个字重合计约 6 MB），脚本每次渲染
  复制到 `out/fonts/`，**整个 out 目录可以独立搬走**；断网也能跑
- **字体栈里不要用 `-apple-system` / `PingFang SC` / `Microsoft YaHei` 打头** ——
  那会让 macOS 命中苹方（苹果商业字体）用于商用物料，且换到 Windows 会变成别的字形，
  结果不可控。内置字体必须排首位，系统字体只做子集外生僻字的兜底
- 子集覆盖 7556 字（GB2312 全字 + ASCII + 常用全角标点），日常中文 99.9%+；
  生僻字会回退到系统字体
- 体积、重建命令、字符集生成脚本：见 `assets/fonts/README.md`

**为什么没用阿里普惠体 / MiSans / HarmonyOS Sans**：它们同样免费商用、字形也很好，
但都是厂商自有协议且**明确禁止再分发字体文件**（普惠体协议："未经授权，任何人不得
上传、发布、转载阿里巴巴字体文件"）——装在本地自己用没问题，随开源仓库分发不行。
选字体时"免费商用"只是第一层，**"允许再分发"才是决定能不能打包的那一层**。

## 坑（踩过，别重踩）

1. **Chrome 152+ 截图后进程不退出**：macOS 上 headless 截图写完 PNG 就卡住
   （CVDisplayLink 报错循环）。脚本已经处理成"轮询产物文件 + 稳定后杀进程组"，
   不要去改成 `subprocess.run` 等它自己结束，会永久挂住。
2. **每次截图必须用全新的临时 profile**：复用 `user-data-dir` 会被上一次的残留
   进程锁住，第二张开始卡死。
3. **必须带 `--no-sandbox`**：外层沙箱和 Chrome 自带 seatbelt 会互相打架，
   不加就一张图都出不来（脚本已内置）。
4. **`mkdir(exist_ok=True)` 在部分环境下仍会抛 EEXIST**：脚本里所有建目录都改成了
   `if not d.exists(): d.mkdir(...)`。不这么写的话**第二次渲染必挂**，而且报错信息
   很有迷惑性（明明写了 exist_ok）。
5. **`.textbox` / `.cover-img` / `.end-float` 必须 `flex: 0 0 auto`**：给它们
   `flex-shrink:1` 会让它们自己压掉高度把内容藏起来，外层 `.stage` 就检测不到溢出，
   字号自适应永远不触发 —— 表现是文字被切掉一半，图消失。这是本技能最容易误伤的地方。
6. **叠图卡的拼贴区必须设 `min-height`**：底图和产品图都是绝对定位、不计入
   `scrollHeight`，如果拼贴区允许被压到 0，长标题会把整块图挤没，而溢出检测抓不到。
7. **`base_fit:"contain"` 时拼贴区要 `width:fit-content`**：否则产品图的百分比锚点
   是相对"含左右留白的拼贴区"算的，罐体会溢出插画边界浮在页面空白上。
8. **找不到 Chrome**：脚本按顺序找 Chrome / Edge / Brave / Chromium，都没有就报错。
   此时用 `--html-only` 先出 HTML，让用户自己在浏览器里截图。
9. **别把 spec 写太大**：单页要点超过 5 条、标题超过 18 字，出图会挤。宁可多分一页。
10. **从 spec 里删掉某张图后，记得手动删 `out/images/` 里的旧副本**：脚本只做
    "复制本次用到的图"，不会清理历史留下的文件。如果那张图恰好是不能用的
    （版权/授权问题），残留副本会跟着交付目录一起被发出去。
11. **字体加载失败是静默的**：`card.css` 里 `@font-face` 用的是 `../fonts/xxx.woff2`
    —— 因为脚本把 CSS 内联进 `out/html/*.html`、字体复制到 `out/fonts/`，路径是相对
    输出目录的 `html/` 算的。改字体文件名时 CSS 那侧要跟着改；写错了**不会有任何报错**，
    页面会安静地退回系统字体（macOS 上就是苹方），出图看着"正常"但字形已经变了。
    验证办法：同一段文字分别用 `"Noto Sans SC Local"` 和 `sans-serif` 渲染两版，
    比对图片哈希 —— 相同就说明内置字体没生效。

