---
name: xiaohongshu-automation
description: 小红书自动化——搜索热点选题、生成笔记文案、自动发布。使用 xhs PyPI 包操作移动端 API。
---

# 小红书自动化

## 触发条件

- 用户提到「搜索小红书」「小红书选题」「小红书热点」
- 需要找选题来源时，优先用此技能搜索实时热搜
- 需要发布小红书笔记时
- 用户问「怎么发小红书」「小红书自动发布」

## 核心工具：xhs PyPI 包

```bash
pip install xhs
```

`xhs` 包 (0.2.13, by ReaJason) 封装了小红书移动端 API (`edith.xiaohongshu.com`) 和创作服务平台 (`creator.xiaohongshu.com`)。

### 关键发现

| 问题 | 结论 |
|------|------|
| 服务器 IP 被封？ | 网页版 (`www.xiaohongshu.com`) 封了，**移动端 API (`edith.xiaohongshu.com`) 没封** |
| 搜索需要登录？ | 是，需要真实账号 Cookie（a1 + web_session + webId） |
| v1 vs v2 接口兼容性 | v1 端点（如 `get_self_info`）返回 406；**v2 端点（如 `get_self_info2`）工作正常，且无需签名字段** |
| Cookie 存活时间 | `web_session` 仅存活数小时，过期后返回 `code: -100`（SESSION_EXPIRED）。`a1` 和 `webId` 存活数月 |
| Docker 签名服务 | `reajason/xhs-api:latest` 在腾讯云服务器上无法拉取（Docker Hub 被墙）。**替代方案：纯 Python 签名服务，见下方** |
| Playwright 浏览器下载 | 在腾讯云服务器上超时（GFW），无法完成 `playwright install chromium` |
| 签名算法 | ✅ **已验证正确**（2026-06-23）。`xhs.help` 中的 `sign()` 函数生成有效签名。300011 提示 = 签名错误；-100 = 签名正确但 Cookie 过期 |

## 初始化客户端

`xhs` 包的 `XhsClient` 需要外部签名函数才能调用非 creator 端点（如搜索）。内置 `sign` 函数在 `xhs.help` 中：

```python
from xhs import XhsClient
from xhs.help import sign, get_a1_and_web_id

def external_sign(url, data=None, a1="", web_session=""):
    return sign(url, data, a1=a1, b1=web_session)

# 生成匿名 cookie（仅用于初始化，搜索需登录后 cookie）
a1, web_id = get_a1_and_web_id()
cookie = f"a1={a1}; webId={web_id};"

client = XhsClient(cookie=cookie, sign=external_sign, timeout=15)
```

**⚠️ 陷阱**：不传 `sign=` 参数会导致 `TypeError: 'NoneType' object is not callable`，因为 `_pre_headers` 在 `is_creator=False` 时强制调用 `self.external_sign()`。

## 登录

两种方式（均需签名函数正常工作）：

### A. 短信验证码登录

```python
# 1. 发送验证码
client.send_code(phone="你的手机号")

# 2. 用户输入验证码后
client.check_code(phone="你的手机号", code="验证码")

# 3. 保存 cookie
print(client.cookie)  # 持久化保存
```

**⚠️ 冷启动限制（2026-06-23 实测）**：`send_code` 从匿名 Cookie 调用返回 406（`code:-1`）。短信登录需要**已有有效 `web_session`** 才能发起——即用户必须先通过浏览器登录过一次，保持登录态。不能从零冷启动自动登录。

### B. 扫码登录（当前在服务器上有障碍）

```python
# 1. 获取二维码
qr = client.get_qrcode()
# ⚠️ 当前返回 406 / code:-1，签名可能过时
```

**已知问题**：`get_qrcode()` 在服务器上返回 406，`xhs.help` 中的 `sign()` 函数使用的版本常量可能已过时。官方推荐用 Playwright + Flask 签名服务（Docker 镜像），但腾讯云服务器无法拉取 Docker Hub。

### C. 手动提取 Cookie（当前最可靠方案）

用户在浏览器登录 xiaohongshu.com 后，从 DevTools → Application → Cookies 提取三个必需字段：

| Cookie | 说明 | 示例格式 |
|--------|------|---------|
| `a1` | 设备指纹 | `19cffe1b5b2hokiz...50000272464`（约68字符） |
| `web_session` | 登录会话 | `040069b4722eb009...83cf6`（约40字符） |
| `webId` | 浏览器标识 | `527a6a761041a7af...805d34`（32字符） |

构造 cookie 字符串：`a1=...; web_session=...; webId=...`

**⚠️ `web_session` 仅存活数小时**，每次开始新的自动化会话前可能需要重新提取。

## 搜索热点选题

```python
from xhs import SearchSortType, SearchNoteType

# 按关键词搜索笔记
notes = client.get_note_by_keyword(
    keyword="中职生",
    page=1,
    page_size=20,
    sort=SearchSortType.GENERAL,      # GENERAL / MOST_POPULAR / LATEST
    note_type=SearchNoteType.ALL       # ALL / VIDEO / IMAGE
)
# 返回笔记列表，含 note_id, title, desc, type, user, interact_info 等
```

其他搜索能力：
- `client.get_search_suggestion(keyword)` — 搜索建议（搜什么人多）
- `client.get_home_feed(FeedType)` — 主页推荐流（按分类：穿搭/美食/职场/教育等）
- `client.get_user_notes(user_id)` — 获取某用户全部笔记（竞品分析）

## 发布笔记

```python
# 图文笔记
client.create_image_note(
    title="标题（20字以内）",
    desc="正文（带 emoji 和标签）",
    files=["/path/to/cover.jpg", "/path/to/image2.jpg"],  # 本地图片路径
    topics=["话题1", "话题2"],
    post_time="2026-06-24 08:00:00",  # 可选，定时发布
    is_private=False
)

# 视频笔记
client.create_video_note(
    title="标题",
    video_path="/path/to/video.mp4",
    desc="正文",
    cover_path="/path/to/cover.jpg",  # 可选
    topics=["话题"]
)
```

**注意**：发布需要登录到 `creator.xiaohongshu.com`（创作服务平台），且 `www.xiaohongshu.com` 的 `web_session` 不能用于 creator 后台（返回 401 `-100 登录已过期`）。发布前需要用户在浏览器额外登录 `creator.xiaohongshu.com` 并提取对应域下的 Cookie。

## 竞品分析

```python
# 获取指定用户所有笔记
notes = client.get_user_all_notes(user_id="竞品用户ID")

# 获取笔记评论
comments = client.get_note_all_comments(note_id="笔记ID")

# 获取自己账号数据
self = client.get_self_info()
summary = client.get_notes_summary()
stats = client.get_notes_statistics()
```

## 小红书文案风格（来自实战教程）

小红书的文案和公众号完全不同，写作时遵循以下规则：

1. **开头必须有钩子**：痛点、疑问、或惊人事实
2. **段落短小**，每段不超过3行
3. **重点信息加粗或用 emoji 标注**
4. **结尾必须有互动引导**：「你们觉得呢？」「评论区告诉我」
5. **善用 emoji**，但一篇不超过5个
6. **标签打在正文末尾**，用 # 开头
7. **标题控制在20字以内**

输出格式（JSON）：
```json
{
  "title": "标题（20字以内）",
  "hook": "开头钩子（50字以内）",
  "body": "正文（300-500字，短段落+emoji）",
  "tags": ["标签1", "标签2", "标签3"],
  "cta": "互动引导语"
}
```

## 去 AI 味（人工注入）

文章作者坦承纯 AI 输出有「AI味」，解决方案不是 prompt 调优，而是写完后注入人工素材：

1. **建立语料库**：收集账号历史上数据最好的10篇笔记
2. **随机注入个人元素**：真实经历、个人观点
3. **保留不完美**：故意留口语化表达、轻微语法不严格
4. **随机化 CTA**：用真人常用的互动语替换模板 CTA

## 当前部署状态（北京服务器，2026-06-25）

**已部署并运行中：**

| 组件 | 路径 | 状态 |
|------|------|------|
| 选题脚本 | `~/project/scripts/daily_xhs_topics.py` | ✅ v9（封面 v4 渐变叠加+装饰；内页 v5 多区块流式，8种类型自由组合，AI自主选块不强制模板；数据富化7维+竞品正文透传；提示「不知道比编造好」） |
| 品牌素材 | `~/project/brand/`（封面底板+内页+优设标题黑字体） | ✅ |
| 考试日历 | `~/project/config/exam_calendar.json` | ✅ |
| 知识库 | `~/.hermes/知识库/`（7文件：CSV招生数据、事实库、PDF等） | ✅ |
| Cron job | `c27628909af0` 每天 09:05（原09:00，因 DeepSeek 9点高峰提前5分钟） | ✅ |
| DeepSeek API | `~/project/.env` | ✅ `DEEPSEEK_API_KEY` 已配置 |
| 百炼 CLI | `bl 1.4.2`（Qwen-VL 视觉分析） | ✅ |

**待用户配合：**
- 签名代理：需在本地机器运行 `~/hermes-migration/xhs-sign-server/sign_server.py` + cloudflared 暴露
- Cookie：需重新登录小红书提取 `a1` + `web_session` + `webId`（`web_session` 每几小时过期）

### 全链路验证
```bash
# 手动运行（必须 source .env 加载 API key——不要用 export + 管道拼接，shell 会解析错误）
cd ~/project && set -a && source .env && set +a && ~/project/we-mp-rss-main/venv/bin/python3 scripts/daily_xhs_topics.py
# 输出结构（每个选题 1 封面 + N 内页，N 由内容密度自动决定）
ls ~/.hermes/articles/$(date +%Y-%m-%d)/
# → README.md + 选题1-xxx/（含 cover.png + inner-*.png + 小红书文案.md）
```

## 内容工厂架构（4-Agent 流水线）

参考文章提出的架构，完整流程：

```
Trigger Agent → Topic Agent → Writer Agent → Image Agent
   (定时)         (选题)        (文案)        (配图)
```

- **Trigger**: Cron job，每日 8am/12pm/6pm 三个黄金时段触发
- **Topic**: 调用 `client.get_note_by_keyword()` 搜热点 + 竞品分析
- **Writer**: 生成小红书风格文案（钩子+短段落+emoji+标签+CTA）
- **Image**: AI 生成竖版封面图（3:4 比例，大字体标题+emoji 装饰）

## 替代数据源管线（Plan B：当 API 搜索不可用时）

当 xhs API 搜索因签名过时或账号受限而无法使用时，启用以下替代管线：

```
we-mp-rss（竞品公众号） + 考试日历 + 招生数据 CSV
        ↓
    DeepSeek AI 选题 + 文案生成
        ↓
    ~/.hermes/articles/YYYY-MM-DD-选题.md
```

### 已部署的 Cron Job（Hermes）

```bash
# Hermes cron job c27628909af0 — 每天 09:00 自动运行
# 不需要 crontab，由 Hermes 调度器管理
cronjob action=run job_id=c27628909af0  # 手动触发
```

### 数据源

| 来源 | 路径 | 说明 |
|------|------|------|
| 竞品公众号 | `we-mp-rss` DB (`~/project/we-mp-rss-data/db.db`) | 广东中职菌、中职生 |
| 考试日历 | `~/project/config/exam_calendar.json` | 证书报名、3+证书考试、志愿填报等 |
| 招生数据 | `~/.hermes/知识库/3+证书招生录取数据24-26.csv` | 9412 条，分数中位数/公办占比/证书限制 |
| 事实库 | `~/.hermes/知识库/事实库.md` | 政策条款验证 |

### DeepSeek API 配置

**仅需一处：** `~/project/.env` 中 `DEEPSEEK_API_KEY=***`。

⚠️ 验证方法：`python3 -c "import os; os.environ.update({l.split('=',1)[0].strip():l.split('=',1)[1].strip().strip('\"') for l in open('/home/ubuntu/project/.env') if '=' in l and not l.startswith('#')}); print(len(os.environ.get('DEEPSEEK_API_KEY','')))"` — 长度 <10 说明是占位符，需替换真实 Key。AI 生成失败提示「AI生成部分失败，使用兜底补全」时，第一个检查 Key 是否为占位符。

**百炼 CLI（Qwen-VL 封面 QC）：**
```bash
# 安装：npm install -g bailian-cli && npx skills add modelstudioai/cli --all -g
# 鉴权：bl auth login --api-key sk-*** 分析 --image <path> --prompt "..." --non-interactive --output json
```

### 内容质量（核心教训）

**AI 内容质量 = 数据丰富度，不是 prompt 工程。**

只喂 3 个汇总数字（中位数、公办占比、证书比例）→ AI 只能产出泛泛的「分数在涨」「公办越来越难」。
先用 `get_enriched_context()` 从 9412 行 CSV 中预处理出 **7 个维度**的具数据：

1. 分数段对照（每段具体学校名+分数+公办/民办标识）
2. 3年涨分TOP5（具体专业名+校区+分数变化）
3. 公办vs民办中位分对比
4. 证书要求百分比
5. 200分以下捡漏清单（学校名+分数+招生人数）  
6. 城市公办难度排行
7. （可扩展）

然后将完整的富化文本注入 AI prompt，明确要求「使用具体数字和学校名，禁止模糊表述」。

效果：从「200分想读公办有点悬」→「机电一体化技术最低164分招5人、社会体育最低189分招21人」。

详见 `references/data-enrichment.md`。

## 数据源限制（2026-06-26 更新：正文已可用）

**招生CSV**（9412行）：丰富，覆盖分数段、学校、城市、证书要求，适用于「分数定位/择校对比/趋势分析」类选题。

**we-mp-rss 文章正文**：✅ **已可用。** 全部 117 篇文章 `has_content=1`，正文 15KB-2MB HTML/篇。pipeline 现已从 `content` 字段提取纯文本（`_html_to_text()`），约半数文章可提取 1700-4500 字有效正文。其余约半数为微信界面残渣（需 JS 渲染），仍不可用。**提取规则：** ①去 script/style 标签 → ②去 HTML 注释 → ③去 HTML 标签 → ④去实体 → ⑤过滤「微信扫一扫」等界面文字 → ⑥取 `len>200` 且不含界面关键词的文本。

**考试日历**：仅为日期节点，无详细内容。

**结论**：pipeline 现在有两层数据源——招生 CSV（数据驱动选题）+ 正文提取（攻略/经验/政策类选题）。content 字段有效提取率约 50%，比之前 description-only 的 0% 大幅提升。**仍不应对非数据选题强行注入招生数据。**

**prompt 设计核心教训：**
- ❌ 不要对所有选题无差别注入 1400 字招生数据。在 prompt 中写明「仅在选题涉及分数/择校/证书时使用招生数据」
- ❌ 不要写「务必使用这些数据」。AI 会听话地把数据塞进不相关的选题
- ✅ 给 AI 两个独立数据源（招生数据 + 竞品正文），让 AI 根据选题性质自选
- ✅ 数据源区隔标注：「招生数据适用于分数定位、择校对比类选题。竞品正文适用于故事/避坑/攻略类选题」

**we-mp-rss 全文抓取配置：**
- `gather.content: True`（config.yaml）— 新文章同步抓取全文 ✅ 已开启
- `gather.content_auto_check: True`（config.yaml）— 每59分钟自动补抓旧文章正文 ✅ 已开启
- DB 实际路径：`we-mp-rss-data/db.db`（37MB，117篇），不是 `we-mp-rss-main/data/db.db`（229KB旧库）
- 查询同时取 `a.content` 和 `a.description`，优先用 content（`_html_to_text()` 去脚本/样式/标签）
- **有效提取率约 50%**：部分文章 HTML 含微信界面残渣（无实际正文），过滤规则见上方数据源限制节
- `extract_article_insights()` 已废弃，正文提取直接在 `generate_xhs_topics()` 的 article_context 构建中完成

## 可持续性

三层解耦架构保证可维护：
- **数据层**：`get_enriched_context()` 从 CSV 提取洞察 → 换数据源只换此函数。`article_context` 从 we-mp-rss 描述中透传有正文的文章
- **叙事层**：DeepSeek AI 根据富化数据编排区块 → 不再强制固定模板，AI 根据选题性质自由选择区块类型和顺序
- **渲染层**：8种区块渲染器独立注册 → 新增区块类型只需加函数+注册到 `BLOCK_RENDERERS`

## 签名算法版本差异（2026-06-23 关键发现）

对比浏览器发出的真实请求和 Python `sign()` 生成的结果：

| 字段 | 浏览器 | Python sign() |
|------|--------|---------------|
| `x-s` 前缀 | `XYS_` (4字符) | 无 |
| `x-s` 总长度 | ~372 字符 | ~48 字符 |
| `x-s-common` 长度 | ~2,203 字节 | ~259 字节 |

**根因**：浏览器使用新版签名算法（`XYS_` 前缀 + 压缩编码），Python 版 `xhs.help.sign()` 实现的是旧版（简单 base64 编码）。新版产生更长的令牌，可能包含更多设备/环境指纹信息。

**影响**：即使 Python 签名被服务器接受（不再返回 406），搜索端点仍返回 300011，说明签名虽合法但**权限等级不足**——新版签名携带的额外信息用于风控分级。

## 本地签名服务（推荐部署方案）

由于腾讯云服务器无法下载 Playwright/Docker，最佳方案是用户在本地运行签名服务：

**部署**：`references/sign_server.py` — 自包含的 HTTP 代理，纯 Python，无需 Playwright

```bash
# 本地启动
pip install requests
python sign_server.py  # 监听 :5005

# 暴露给服务器
cloudflared tunnel --url http://localhost:5005
# 得到 https://xxx.trycloudflare.com → 发给 AI
```

**服务器端调用**：
```python
import requests, base64, json

SIGN_SERVER = "https://xxx.trycloudflare.com"
cookies_b64 = base64.b64encode(json.dumps({
    "a1": "...", "web_session": "...", "webId": "..."
}).encode()).decode()

# 搜索
r = requests.get(f"{SIGN_SERVER}/search?keyword=高职高考&count=10",
    headers={"Authorization": f"Bearer {cookies_b64}"})

# 通用 API 代理
r = requests.post(f"{SIGN_SERVER}/api/sns/web/v1/search/notes",
    json={"keyword": "...", "page": 1, ...},
    headers={"Authorization": f"Bearer {cookies_b64}"})
```

**优势**：Cookie 在用户本机永远最新，IP 不会被封，签名算法在本地生成。

## 注意事项

- **Cookie 有时效性**：`web_session` 数小时内失效，过期需用户重新登录浏览器提取
- **API 搜索权限 ≠ 浏览器搜索权限**：即使浏览器能正常搜索，同一账号的 API 调用可能返回 300011。这通常意味着账号未完成实名认证、从未发过笔记、或账号过新。需要先在 App/网页版发几条笔记养号
- **Creator 后台 Cookie 独立**：`creator.xiaohongshu.com` 需要用户单独登录，`www.xiaohongshu.com` 的 `web_session` 不能共用
- **短信登录不能冷启动**：`send_code` 需要已有有效 session，不能从匿名状态自动登录
- **频繁请求可能触发验证码**，需控制频率
- **配图生成不在 xhs 包范围内**，需要其他工具（ComfyUI、Pillow 文字封面等）
- **优先用 v2 端点**：`get_self_info()`（v1）返回 406，用 `get_self_info2()`（v2）替代。v2 端点通常无需签名字段即可工作
- **Docker Hub 不可达**：腾讯云服务器无法拉取 `reajason/xhs-api` 签名服务镜像；Playwright 浏览器下载同样超时

## 常见错误码

| 错误码 | 含义 | 处理 |
|:------:|------|------|
| `-100` | SESSION_EXPIRED — `web_session` 过期 | 用户在浏览器重新登录，提取新 cookie |
| `300011` | **签名不匹配 OR 账号搜索权限受限**。诊断三步：①sign 错误 → 返回 406/-1；②sign 正确 + cookie 过期 → 返回 -100；③sign 正确 + cookie 新鲜 + 仍返回 300011 → **账号本身被限制搜索**（API 层面，与浏览器搜索权限是两套体系）。此时排查：账号是否实名认证、是否发过笔记、是否为新号。 | 先用 -100 测试确认签名正确，再排查账号状态 |
| `300012` | IP_BLOCK — IP 被封 | 仅出现在网页版；移动端 API (`edith.xiaohongshu.com`) 不受影响 |
| `300015` | SIGN_FAULT — 签名验证失败 | 签名算法可能过时，检查 `xhs.help.sign` 版本 |
| `-101` | 无登录信息 — 搜索等接口需要登录态 | 提供有效的 `web_session` cookie |
| `-1` (406) | 通用签名/请求格式错误 | 检查签名是否正确生成；v2 端点通常不需要签名 |
| `TypeError: 'NoneType' object is not callable` | 未传 `sign=` 参数 | 必须提供 `external_sign` 包装函数 |

## 参考文件

- `references/api-findings.md` — 端点级别实测结果、错误码对照、Cookie 格式参考、签名验证记录
- `references/sign_server.py` — 本地签名+API代理服务（纯Python，部署在用户本地机器，通过 cloudflared 暴露）
- `references/pixel-verification.md` — 封面/内页/模板的像素扫描验证脚本合集（numpy 盲测法）
- `references/data-enrichment.md` — 数据富化：从9412行招生CSV提取7维洞察喂给AI
- `references/template-pixel-analysis.md` — 模板像素分析
- `references/bailian-vision-setup.md` — 百炼Qwen-VL配置
- `references/post-migration-checklist.md` — 迁移检查清单
- `references/pillow-v4-simplified.md` — **排版急救方案**：当用户反馈文字重叠/看不清时使用的极简版渲染（大字宽距少区块）
- `scripts/score_xhs_image.py` — 5维自动化评分器
- `scripts/render_xhs_v2.py` — **v2 渲染引擎**（海报构图思维，人工设计页面结构）

## 手动选题注入（绕过 DeepSeek 生成）

当用户已指定具体选题和角度、要求直接产出文案+图片时，**不需要走 `generate_xhs_topics()` 的 DeepSeek AI 生成链路**。改为：手工构造与 pipeline 兼容的 topic dict → 直接调用 `generate_cover()` 和 `generate_inner_page()`。

### 触发条件

- 用户明确说「整理这个选题」「输出完整文案」「跑一次生产流程」
- 用户已从选题日报中选了具体选题（如「选题1：计算机报名全流程指南」）
- 需要人工控制文案质量，而非依赖 AI 随机生成

### 工作流

1. **事实核查**：加载 `fact-check` skill → 查事实库 → 查 CSV → 外部搜索官方源
2. **查询 we-mp-rss**：获取竞品参考文章的 title/url/description
3. **构造 topic dict**：与 `daily_xhs_topics.py` 中 `generate_xhs_topics()` 的输出格式完全一致
4. **运行渲染**：从 `daily_xhs_topics` import `generate_cover` 和 `generate_inner_page`，传入 topic dict + output_dir
5. **视觉验证**：numpy 像素扫描（5维评分）→ 若不达标迭代修复
6. **内容自评**：5维内容质量评分（数据具体性/可操作性/独特洞察/事实可信度/信息密度）

### topic dict 结构（必须精确匹配）

```python
topic = {
    "选题": "一句话选题说明",
    "标题行1": "≤7字白色大字",
    "标题行2": "≤7字白色大字",
    "标题行3": "≤7字橙色关键词",
    "标签": "政策资讯|热点话题|院校介绍|考生服务|考前辅导|专业介绍",  # 六选一
    "标签列表": ["#标签1", "#标签2", ...],
    "选题理由": "...",
    "文案": "完整小红书正文（Emoji+短句+钩子开头+CTA结尾）",
    "内页": [
        {
            "标题": "第1页标题（≤12字）",
            "区块": [
                {"类型": "引言", "内容": "一句话点明本页价值"},
                {"类型": "步骤", "内容": [{"标签": "①", "说明": "..."}]},
                # ... 自由组合 8 种区块类型
            ]
        },
        {"标题": "第2页标题", "区块": [...]}
    ]
}
```

### 函数签名

```python
from daily_xhs_topics import generate_cover, generate_inner_page

# 封面：返回路径字符串
cover_path = generate_cover(topic, str(output_dir), index=idx)

# 内页：返回路径列表（每页一个文件）
inner_paths = generate_inner_page(topic, str(output_dir), index=idx)
```

⚠️ `generate_cover` 和 `generate_inner_page` 都只接受 `(topic, output_dir, index=1)` 三个参数，不是按字段展开的独立参数。

### 陷阱

- **Python 字符串中的中文引号**：topic dict 的字符串值中包含 `""` 中文引号会导致 SyntaxError。用 `「」` 替代。
- **emoji 等特殊字符**：文案中的 emoji 在 print 输出时正常，但出现 `SyntaxError: invalid syntax` 时先检查是否有多余的引号嵌套。
- **必须使用 we-mp-rss venv**：`~/project/we-mp-rss-main/venv/bin/python3`，因为依赖（Pillow、numpy）只在该 venv 中安装。

完整可运行的注入脚本示例见 `references/single-topic-injection.py`。

## 品牌封面自动生成（v4 — 2026-06-25 更新：紧凑标题 + 加大标签）

**布局规则（用户逐条指定，不可自行修改）：**

```
封面 = 底板（小红书封面.jpeg，含NEWS水印+插画，只读不改）
     + 3行标题（紧凑排列）+ 1行标签（下方，加【】包裹）

行1: 主标题（白色，优设标题黑，155pt，≤7字）      title_top=380（已上移）
行2: 副标题（白色，优设标题黑，155pt，≤7字）      title_gap=75px
行3: 强调数字/关键词（橙色，优设标题黑，145pt，≤7字） title_gap=75px
      ↓ tag_gap_above=100px
行4: 标签（白色黑体 50pt，【标签名】，从6个中选1个）

6个可选标签：政策资讯、热点话题、院校介绍、考生服务、考前辅导、专业介绍
```

**布局逻辑（`generate_cover` 函数，`~/project/scripts/daily_xhs_topics.py`）：**
- 三行标题从 `title_top=380` 开始（整体已上移约75px以对齐底板 NEWS 区域），用 `textbbox` 计算每行实际高度后分组排列
- 标题行间距 `title_gap=75`（draw-position 间距，可见间距 ≈ 75-78px）
- 标签用 `tag_gap_above=100` 与标题分隔，字号 50pt（原 38pt）
- 标签文字自动包裹 `【】` 中方括号，如 `【考生服务】`
- 不再使用等距分布；标题紧凑成组，标签独立在下

**关键纠错（不要重犯）：**
- NEWS 水印和三个插画小人**已在底板上**——Pillow 只叠加文字，不要画 NEWS
- 标签不是固定的【考生服务】——AI 根据选题内容从 6 个中选
- **标签必须加【】**（中方括号包裹），如 `【政策资讯】`、`【院校介绍】`
- 行1-2 白色 #FFFFFF，行3 橙色 #E67E22，标签 白色 #FFFFFF（50pt 黑体）
- 不要用标题字体（优设标题黑）渲染标签，标签用系统黑体（文泉驿正黑）
- **`_SIZE_TAG = 50`**（不是 38），标签可见高度约 47px
- 如果 `标签` 字段是 list（fallback 模式），fallback 到 `"考生服务"`
- 标题字体 YouSheBiaoTiHei 有 44px 上留白（ascender），`textbbox` 返回 (0, 44, w, 157)，可见高度 113px

**封面布局验证（像素扫描法）：**

当无法用视觉查看封面时，用 numpy 扫描像素定位文字块：

```python
from PIL import Image
import numpy as np

cover = Image.open('封面.png')
arr = np.array(cover)
h, w = arr.shape[:2]

# 找白色文字 (r>200, g>200, b>200)
# 找橙色文字 (r>180, 80<g<180, b<80)
# 背景是深绿色 ~(21,86,44) 或 ~(40,90,55)

blocks = []
in_block = False; start = 0
for y in range(300, 1150):
    row = arr[y, 100:w-100, :]
    white = (row[:,0].astype(int)>200)&(row[:,1].astype(int)>200)&(row[:,2].astype(int)>200)
    orange = (row[:,0].astype(int)>180)&(row[:,1].astype(int)>80)&(row[:,1].astype(int)<180)&(row[:,2].astype(int)<80)
    count = np.sum(white) + np.sum(orange)
    if count > 10 and not in_block:
        start = y; in_block = True
    elif count <= 10 and in_block:
        blocks.append((start, y-1)); in_block = False
if in_block: blocks.append((start, 1149))

# 打印每块的位置、高度、颜色和间距
for j, (t, b) in enumerate(blocks):
    hp = b - t + 1
    mid_y = t + hp//2
    row = arr[mid_y, 100:w-100, :]
    wc = np.sum((row[:,0]>200)&(row[:,1]>200)&(row[:,2]>200))
    oc = np.sum((row[:,0]>180)&(row[:,1]>80)&(row[:,1]<180))
    color = 'WHITE' if wc > oc else 'ORANGE'
    label = 'Title' if hp > 70 else 'Tag'
    print(f'{label} {j}: y={t}-{b} (h={hp}px, center={mid_y}) [{color}]')

# 间距
gaps = [f'{blocks[k+1][0]-blocks[k][1]}px' for k in range(len(blocks)-1)]
print(f'Gaps: {\" → \".join(gaps)}')
```

输出示例（title_top=380, title_gap=75）：
```
Title 0: y=425-535 (h=111px, center=480) [WHITE]
Title 1: y=613-723 (h=111px, center=668) [WHITE]
Title 2: y=798-901 (h=104px, center=850) [ORANGE]
Tag 3:   y=970-1016 (h=47px, center=993) [WHITE]
Gaps: 78px → 75px → 69px
```

**注意**：标题行1-2 间和行2-3 间的可见间距略有不同（~3px），因为行3 使用 145pt 字体（ascender 约 40px vs 155pt 的 44px）。`title_gap` 值在代码中是 draw-position 间距，可见间距 ≈ title_gap（同字体时完全吻合，换字体时差 3-4px）。

**字体映射：**

| 元素 | 字体 | 路径 |
|------|------|------|
| 行1-3（标题/强调） | 优设标题黑 | `~/project/brand/YouSheBiaoTiHei-2.ttf` |
| 行4（标签）+ 内页正文 | 黑体（文泉驿正黑） | `/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc` |

**AI 选题 prompt 中的封面字段（JSON 格式）：**
```json
{
  "标题行1": "第1行白色大字（≤7字）",
  "标题行2": "第2行白色大字（≤7字）",
  "标题行3": "第3行橙色数字/关键词（≤7字）",
  "标签": "政策资讯|热点话题|院校介绍|考生服务|考前辅导|专业介绍",
  "标签列表": ["#3+证书考试", "#中职生升学", ...],
  "文案": "完整小红书文案...",
  "选题理由": "..."
}
```

### 依赖
- Pillow 已装于 `~/project/we-mp-rss-main/venv/`
- 黑体已装：文泉驿正黑 (`wqy-zenhei.ttc`)
- 纯本地合成，零 API 费用

## ⚠️ 内页渲染方案已升级（2026-06-26）

**Pillow 内页渲染已废弃。** 新方案：HTML/CSS + Playwright Chromium 截图。
详见技能 `xhs-html-renderer`。

封面保持不变（Pillow 叠加固定模板）。

</details>

## 品牌内页自动生成（v8 — 精调间距 + 区块标题装饰，2026-06-26）【已废弃，见上方】

内页与封面是「引导→干货」关系：封面制造好奇/焦虑，内页用数据消除焦虑。
每个选题生成 **N 页内页**（由渲染器根据内容密度自动决定），用 `内页.png` 做底图叠加文字。

### v7→v8 核心改进：从「勉强分开」到「真正呼吸」

**之前（v6）**：AI 生成几个 page 就画几个 page，每页无条件渲染所有 block → 超长内容溢出画布，文字重叠，表格错位。

**现在（v7）**：摊平所有 block → 贪心预估每 block 高度 → 累计超过 `_INNER_H - 450` 自动分页。

```python
_AUTO_Y_LIMIT = _INNER_H - 450  # 画布底部留450px

def _est_block_height(block):
    # 数据卡: 每行250px | 表格: 每行80px | 步骤/清单/行动: 每项85px
    # 分析: 80 + 字数//35 * 50 | 引言: 80 + 字数//35 * 55
```

**效果**：topic3 从 2 页（密度 48.5%/31%）→ 3 页（密度 28.2%/37.7%/15.4%），重叠行从 198→102，平均分从 80.7→88.0。

### v7 表格重写：动态列宽 + 左对齐 + 橙色表头

**之前**：固定 3 列 (200, 340, 380) 居中，总宽 920px 不撑满内容区 1082px，视觉松散。

**现在**：从数据 dict keys 提取表头 → 按表头文字长度比例分配列宽 → 最后一列填满剩余 → 左对齐 + 12px 内边距。表头第一列用橙色底 (`_ORANGE`)，数据行第一列用浅橙底 (`_ORANGE_LIGHT`)。

```python
# 动态列宽
header_widths = [draw.textbbox((0,0), str(h), font=font_t)[2] + 24 for h in hdrs]
col_w = [int(w * total_w / sum(header_widths)) for w in header_widths]
col_w = [max(c, 80) for c in col_w]
col_w[-1] += total_w - sum(col_w)  # 填满剩余
```

### 橙色锚点修复

表格数据行第一列背景从普通交替色改为 `_ORANGE_LIGHT` (252, 235, 215)，文字用 `_DARK` (40, 90, 55)，确保每页有足够橙色视觉锚点（评分器要求 ≥100px 橙色）。

### 5维像素评分体系（`score_xhs_image.py`）

**触发**：每轮内页生成后自动评分。不达标（<90）→ 分析弱项 → 定位根因 → 修复 → 重新生成 → 再评分。

| 维度 | 权重 | 检测方式 | 内页标准 | 封面标准 |
|------|------|---------|---------|---------|
| 元素完整性 | 20 | 品牌条/页脚/标题像素检测 | 三者齐全 | 仅标题（封面无品牌条） |
| 排版密度 | 20 | 非白像素占比 | 15-35%（佳） | 标题区有文字即可 |
| 对齐精度 | 20 | 左右边距对称性+居中趋势 | margin_symmetry>0.7 | 同上 |
| 色彩节奏 | 20 | 橙色/深绿/浅绿底像素数 | orange>100, dark>100, tint>400 | orange>100, dark>1000 |
| 无重叠 | 20 | 过载行(>65%非白)+空白带 | overload<15 行 | overload<50（全幅底图） |

**运行**：`~/project/we-mp-rss-main/venv/bin/python3 ~/.hermes/scripts/score_xhs_image.py *.png`

**迭代铁律**：功能完成 ≠ 设计完成。用户对视觉质量极其敏感，要求每次迭代到 90 分以上。像素扫描（numpy 盲测）是唯一可靠的验证方式——不能依赖 AI 视觉判断。

### 8种区块类型

| 区块 | 作用 | 渲染特征 |
|------|------|---------|
| **引言** | 开场定调 | 大号浅绿底文字框，36pt |
| **数据卡** | 展示事实 | 2列卡片网格，首卡橙底其余绿底，阴影+顶部强调条 |
| **表格** | 多维对比 | 深绿表头 + 绿/白交替行，首列橙色强调 |
| **分析** | 解读意义 | 白色底+绿色边框+橙色圆点装饰 |
| **行动** | 告诉该干嘛 | 编号橙色小圆+行动文字 |
| **步骤** | 操作流程 | 圆圈（奇数橙/偶数绿）+竖线连接 |
| **清单** | 待办/避坑 | 方块（奇数橙框/偶数绿框）+交替行背景 |
| **对比条** | 横向PK | 水平橙色比例条+标签+数值 |

### 编排规则（prompt中告知AI — 2026-06-25 最终版）

**不要固定模板。** 根据选题性质自由选择区块组合：
- 数据类选题（分数定位、趋势分析）：引言 → 数据卡/表格 → 分析 → 行动
- 攻略类选题（报名流程、备考方法）：引言 → 步骤 → 清单 → 行动
- 故事/经验类选题（避坑、下岸复盘）：可能只需要 引言+分析，不需要硬塞数据
- 一页可以只有1-2个区块，关键在于每个区块有信息密度，而不是区块多

**v3 质量优先模式（2026-06-25）：**
- 每次只生成 1 个选题（不是3个），宁缺毋滥
- prompt 开头：「只生成 1 个高质量选题。宁缺毋滥」
- 生成后自动评分（`score_topic()`），<7分自动切换到兜底
- 内页数量由 AI 根据内容决定——数据多的3页，故事1页即可。不强求2页
- 选题评分维度：封面完整性 + 内页区块总数 + 具体数据点数量 + 情绪感染力

**prompt 大小控制：**
- 富化数据（~1400字）+ 竞品文章标题（~300字）+ 模板文字（~2000字）= ~3700字
- 不要额外注入内容理解结果（we-mp-rss 正文为空，注入只会撑爆 prompt 导致 JSON 截断）
- 把「竞品文章仅 3-5 篇标题」传给 prompt，不传正文

### 排版设计 v2 — 海报构图思维（2026-06-26 重构）

### 核心理念转变

v1 的设计思维是「堆砌块」——每种区块类型有独立渲染函数，页面是区块的垂直堆叠。
v2 的设计思维是「海报构图」——每页是一张设计过的海报，元素位置是精心构思的，不是机械排列的。

关键区别：
- v1: "这个页面有哪些区块？→ 依次渲染" → 结果是连续的视觉大疙瘩
- v2: "这页想传达什么？→ 先设计视线路径 → 再放置元素" → 结果是可读的海报

### 评分脚本关键发现（2026-06-26）

`score_xhs_image.py` 在第17行把图片缩小到 **1/4**（1242×1660 → 310×415），然后检测色彩。
这意味着：
- 2px 细线橙色 → 抗锯齿模糊 → 不计数
- 3px 边框 → 边缘模糊 → 计数极少
- **必须用大面积橙色块**（全宽背景条、大块填充）才能扛过缩小+抗锯齿

橙色使用铁律：
- 每页至少 1 处大面积橙色（>100px 在缩小后检测到）
- 最佳实践：橙色应急提示条（`block_urgent` — 全宽圆角矩形，橙色底白字）
- 洞察框边框从 2px → 3px（略改善，但仍不够）
- 数据卡首张用浅橙底（`TINT_ORANGE`）+ 橙色数字（Display 96pt）

### v2 设计规范

详见 `references/layout-design-v2.md`。

核心系统：
1. **5级字体层级**：Display 96pt / H1 60pt / H2 38pt / Body 32pt / Caption 24pt
2. **8px 模数间距**：xs(8) sm(16) md(24) lg(40) xl(64) xxl(96)
3. **4区页面结构**：品牌条(72px) → 标题区(动态) → 内容区(≤3区块) → 页脚(80px)
4. **色彩铁律**：橙色仅标记最重要信息，每页≤2处
5. **区块标签**：每个内容区块前有「橙色圆点 + Caption标签」，明确定位读者视线

### v2 内容生产方式

**不再依赖 DeepSeek AI 生成内页结构**。AI 生成的内页区块组合不可控，排版质量不稳定。

改为：
1. 人工设计每页的标题、区块类型和顺序
2. 从 CSV 查询真实数据填入
3. 手写洞察和行动建议文案
4. 用 v2 渲染引擎生成图片

这保证了排版质量的一致性——因为每页的结构是人设计的，不是 AI 随机生成的。

### v2 渲染引擎

`references/render_xhs_v2.py` — 独立渲染脚本，不依赖 `daily_xhs_topics.py`。

区块类型：
- `stats`: 数字卡片组（2×2网格，Display字体大数字）
- `steps`: 步骤列表（编号圆点+竖线+标题+说明）
- `checklist`: 行动清单（方块标记+对勾+交替行背景）
- `insight`: 洞察框（白底+3px橙色边框+橙色圆点标签）
- `urgent`: 紧急提示条（全宽橙色背景+白字——**必须用这个保证橙色分数**）
- `label`: 区块标签（橙色圆点+Caption文字）

## 设计质量迭代方法论（v7 自动化，2026-06-26）

**铁律：功能完成 ≠ 设计完成。** 用户对视觉质量极其敏感——"很乱，元素都重叠在一起，表格也不整齐" → 需要量化评估体系。

**自动化迭代流程：**
1. 生成图片 → `score_xhs_image.py` 5维评分
2. 总分 <90 → 打印弱项 → 定位根因（密度/重叠/缺色/对齐）
3. 修复渲染器 → 重新生成 → 再评分
4. 循环至全部 ≥90

**评分脚本**：`~/.hermes/scripts/score_xhs_image.py`（numpy 像素扫描，封面/内页自动区分）
**⚠️ 必须使用 we-mp-rss venv**：`~/project/we-mp-rss-main/venv/bin/python3`，因 numpy/Pillow 仅在该 venv。

**历史迭代得分：**
| 版本 | 日期 | 封面 | 内页均分 | 关键修复 |
|------|------|------|---------|---------|
| v1-v5 | 06/15-06/24 | — | 2.1→10.0 | 像素扫描5维法 |
| v6 | 06/25 | — | — | 区块间分隔线+呼吸空间 |
| v7 | 06/26 | 90 | 88.0 | 溢出自动分页+动态表格+橙色修复 |
| v8 | 06/26 | 90 | **89.0** | 间距翻倍(25→50px)+区块标题装饰(引言/行动)+圆点加大(r14→18) |

### 像素评分法（5维 — 旧版，已被 score_xhs_image.py 替代）

保留作为参考，实际评分请用 `score_xhs_image.py`。

### 区块渲染引擎

每个区块类型有独立渲染函数，统一签名 `_block_xxx(draw, content, y, fonts) -> new_y`。渲染器注册在 `BLOCK_RENDERERS` 字典中，主函数遍历区块数组调用对应渲染器。

**v8 区块间呼吸空间（2026-06-26 精调——不要重犯 v5/v6 的「大疙瘩」问题）：**

经像素扫描诊断：v6 间距下区块实际可见间隙仅 10-14px（分隔线桥接了感知间隙），视觉上仍是连续大块。v8 将所有间距翻倍。

| 参数 | v6/v7 | v8 | 位置 |
|------|-------|-----|------|
| 区块间间距 | 25-30px | **50px** | `generate_inner_page` 主循环 `y += 50` |
| 类型切换分隔 | 20px | **40px** | `prev_type != btype` 分支 `y += 40` |
| 引言块底部 | 20px | **35px** | `_block_lead` return |
| 分析块底部 | 20px | **35px** | `_block_analysis` return |
| 表格底部 | 15px | **30px** | `_block_table` return |
| 数据卡行距 | 45px | **55px** | `_block_datacard` `gy` |
| 底部留白 | 450px | **500px** | `_AUTO_Y_LIMIT = _INNER_H - 500` |
| 行动块行距 | 55px | **60px** | `_block_action` `gap` |
| 引言/行动块 header | 无 | **40px** | 新增区块标题装饰（见下方） |

效果：overload rows 从 442→119（inner-01），评分从 88.3→89.0。

**区块标题装饰（v8 新增——解决「纯内容无区隔」）：**

引言块头部：橙色圆点 + "📖 开篇" 标签（`y += 40`），代码见 `_block_lead`。
行动块头部：橙色背景条 + "💡 行动建议" 标签（`y += 40`），代码见 `_block_action`。
行动块圆点加大：r14→r18，位置从 `_INNER_PAD_X+30` → `_INNER_PAD_X+40`，文字从 `+70` → `+85`。

**诊断工具**：`references/block-pixel-scan.py` — 扫描内页像素块分布，检测连续大块（>500px 无间隙 = 呼吸不足）。

### 品牌设计系统

```
主色：深绿 (40, 90, 55)    → 顶栏、表头、分割带
强调：橙色 (230, 126, 34)  → 大数字、区域标题、奇数圆/框
卡片绿底：(220, 238, 210)  → 数据卡片主体填充
卡片橙底：(252, 235, 215)  → 首张数据卡片填充
阴影：  (210, 228, 205)    → 卡片阴影（偏移4px）
辅助：中绿 (100, 155, 115) → 分割线、次要文字、页脚
正文：深绿 (30, 60, 40)     → 正文文字

字体：
  页面标题：优设标题黑 64pt
  区域标题：优设标题黑 40pt（橙色 #E67E22）
  数据数字：优设标题黑 76pt
  标签/正文：文泉驿正黑 28-36pt
  页脚：文泉驿正黑 22pt

尺寸：1242×1660（与封面一致）
```

### 每页结构（从上到下）

1. **顶栏**：深绿条 72px + 品牌名白色居中
2. **页码**：右上角 `1/2`（仅多页时显示）
3. **页面标题**：64pt 优设标题黑，居中深绿，y=135
4. **标题下粗线**：3px 深绿条 + 橙色圆点装饰
5. **区域标题**：橙色 40pt + 图标（📊核心数据 / 📋操作步骤 / ✅行动清单 / 📊数据对比）+ 右侧细线
6. **内容区**：根据版式渲染（卡片/步骤/表格/清单）
7. **洞察框**：深绿分割带 + 橙色圆点 + "核心洞察" + 浅绿底文字框
8. **页脚**：品牌名 | 日期，中绿，y=1605

### 橙色锚点规则（关键——每页必须有足够橙色像素）

- **标题装饰**：标题下分割线左端 14px 橙色圆点（所有页通用）
- **区域标题**：所有区域标题文字用橙色（📊核心数据 等）
- **数据卡片**：首张卡片橙底+橙色数字+橙色顶部条
- **步骤圆圈**：奇数项橙色填充，偶数深绿填充
- **清单方块**：奇数项橙色边框，偶数深绿边框
- **洞察框**：橙色圆点在标签前

### AI prompt 中的内页字段（v3 多区块格式）

```json
{
  "内页": [
    {
      "标题": "第1页标题（≤12字，一句话结论）",
      "区块": [
        {"类型": "引言", "内容": "一句话点明本页价值..."},
        {"类型": "数据卡", "内容": [{"标签": "指标", "数值": "数据", "说明": "解读"}]},
        {"类型": "分析", "内容": "这意味着..."},
        {"类型": "行动", "内容": [{"标签": "建议1", "说明": "具体怎么做"}]}
      ]
    },
    {
      "标题": "第2页标题",
      "区块": [
        {"类型": "对比条", "内容": [{"标签": "A", "数值": "286"}, {"标签": "B", "数值": "246"}]},
        {"类型": "表格", "内容": [{"标签": "项目", "数值": "详情", "说明": "解读"}]},
        {"类型": "清单", "内容": [{"标签": "必做项", "说明": "原因"}]}
      ]
    }
  ]
}
```

**prompt 中告知 AI 的关键规则：**
- 每个选题必须生成 2 页内页
- 每页4-6个区块，按叙事逻辑编排（引言→数据→分析→行动）
- 内页内容必须使用深度招生数据分析中的具体数字和学校名，禁止模糊表述
- 内页标题要提炼一句话结论，不是选题标题的复读

### DeepSeek JSON 中英文 Key 归一化（关键陷阱——不要重犯）

DeepSeek API 有时输出中文 key（`类型`、`内容`、`标签`、`数值`、`说明`），有时输出英文 key（`type`、`content`、`label`、`value`、`description`）。**同一批次中 topic 1 可能是中文 key、topic 2-3 是英文 key，不可预测。**

**症状**：如果用 `block.get("类型")` 单读，英文 key 输出会返回 `None`，所有区块变成 `?`，内容被静默跳过（不报错，不渲染，只是空白）。

**修复**：在 block 处理循环中统一归一化：

```python
btype = block.get("类型") or block.get("type", "")
bcontent = block.get("内容") or block.get("content", "")
# 内容数组中的每条也需归一化
if isinstance(bcontent, list):
    for item in bcontent:
        if isinstance(item, dict):
            for en, cn in [("label", "标签"), ("value", "数值"), ("description", "说明")]:
                if en in item and cn not in item:
                    item[cn] = item[en]
elif isinstance(bcontent, dict):
    for en, cn in [("label", "标签"), ("value", "数值"), ("description", "说明")]:
        if en in bcontent and cn not in bcontent:
            bcontent[cn] = bcontent[en]
```

**区块类型显示**也要双读：`bl_types = [b.get("类型") or b.get("type", "?") for b in blocks]`

**不要做的**：在 AI prompt 中要求「必须用中文 key」——DeepSeek 不严格遵守此类指令，归一化层比 prompt 指令更可靠。

### 区块类型别名匹配

AI 输出的区块类型名可能略微偏离规范（`数据卡片` vs `数据卡`、`行动建议` vs `行动`）。用别名字典兜底：

```python
_BLOCK_ALIASES = {
    "引言块": "引言", "导语": "引言", "开头": "引言",
    "数据卡片": "数据卡", "数字卡": "数据卡", "数据": "数据卡",
    "分析块": "分析", "解读": "分析", "洞察": "分析",
    "行动建议": "行动", "行动指南": "行动", "建议": "行动",
    "步骤条": "步骤", "流程": "步骤", "操作": "步骤",
    "对比": "对比条", "柱状": "对比条", "排行": "对比条",
    "待办": "清单", "避坑": "清单", "提醒": "清单",
    "表格块": "表格", "对照表": "表格",
}
btype = _BLOCK_ALIASES.get(btype, btype)
```

未知类型降级为纯文本渲染（不丢内容）。

### we-mp-rss 全文抓取配置

**问题**：`gather.content: False` 导致文章正文从未被抓取，`description` 字段几乎为空。

**修复**（`config.yaml`）：
```yaml
gather:
  content: True                    # 新文章同步抓取全文
  content_auto_check: True         # 每59分钟自动补抓旧文章
```

**DB 路径**：实际路径为 `we-mp-rss-main/data/db.db`（不是 `we-mp-rss-data/db.db`）。

**查询字段**：同时取 `a.content` 和 `a.description`，优先用 content（`_html_to_text()` 去标签后拼接），无则 fallback 到 description。

**当前限制**：历史文章 content 为空（之前未开启抓取），需等待后台补抓任务完成。

### 兼容性处理

`generate_inner_page()` 兼容三种格式：
- **新格式（v5 区块）**：`topic.get("内页", [])` — 数组，每个元素 `{标题, 区块: [{类型, 内容}]}`
- **旧格式（v4 单类型）**：无`区块`字段时，从`类型`+`内容`自动包装为单区块数组
- **更旧格式（v3）**：无`内页`字段时，从`内页标题`/`内页类型`/`内页数据`构造兜底页

### 输出文件结构（v8 — 2026-06-26：按选题分文件夹）

**之前**：所有图片和文案平铺在 `YYYY-MM-DD/` 下，多个选题混在一起无法区分。

**现在**：每个选题独立子文件夹，封面/内页/文案全在里头：

```
YYYY-MM-DD/
├── README.md                          ← 总览索引
├── 选题1-计算机证书录取数据/
│   ├── cover.png                      ← 封面
│   ├── inner-01.png                   ← 内页1
│   ├── inner-02.png                   ← 内页2
│   └── 小红书文案.md                   ← 可直接复制
└── 选题2-报名全流程指南/
    ├── cover.png
    ├── inner-01.png
    ├── inner-02.png
    ├── inner-03.png
    └── 小红书文案.md
```

**实现**：`save_topics()` 中为每个选题创建 `选题{i}-{前20字}/` 子目录，`generate_cover()` 和 `generate_inner_page()` 接受子目录路径，文件名简化为 `cover.png`、`inner-01.png` 等。

**文件命名规则**：
- 封面：`cover.png`（固定名）
- 内页：`inner-01.png`, `inner-02.png`, ...（两位编号）
- 小红书文案：`小红书文案.md`
- 公众号长文：`公众号长文.md`（如有）
- 总览：`README.md`（日期目录根）

### 内页布局验证（像素扫描法）

```python
from PIL import Image
import numpy as np
inner = Image.open('内页.png')
arr = np.array(inner)
h, w = arr.shape[:2]

# 5维评分
orange = np.sum((arr[:,:,0]>200) & (arr[:,:,1]>80) & (arr[:,:,1]<180))  # >2000=合格
green_tint = np.sum((arr[:,:,0]>180) & (arr[:,:,0]<240) & (arr[:,:,1]>210))  # >50000=合格
content_px = np.sum(np.max(arr.astype(int), axis=2) < 245)
density = content_px / (h*w) * 100  # 12-56%=合格
green_bands = np.sum((arr[:,:,0]<60) & (arr[:,:,1]>40) & (arr[:,:,1]<120) & (arr[:,:,2]<80))  # >5000=合格
insight_zone = arr[-400:-80, 100:w-100, :]
has_insight = np.sum((insight_zone[:,:,0]>180) & (insight_zone[:,:,1]>210)) > 500
score = sum([orange>2000, green_tint>50000, 12<density<56, green_bands>5000, has_insight]) * 2
```

### 内容质量评分（5维）

设计≠内容。像素满分不代表内容有用。内容质量需单独评分：

| 维度 | 0-2分标准 | 检查方法 |
|------|----------|---------|
| 数据具体性 | 2=引用具体学校名+分数+招生数；0=泛泛而谈 | 检查是否出现CSV中的学校名和分数 |
| 可操作性 | 2=读者看完知道具体怎么做/报什么学校；0=空洞建议 | 检查是否有具体目标分数、学校名、步骤 |
| 独特洞察 | 2=涨分TOP5、城市难度排行等独有数据；0=人人知道的常识 | 检查是否引用了富化数据中的排名/对比 |
| 事实可信度 | 2=所有数字来自CSV可验证；0=疑似编造 | 逐一对照CSV原始数据 |
| 信息密度 | 2=每页4-6个数据点+解读；0=一页只有2-3个信息 | 统计每页可独立引用的数据点数量 |

**10/10 标准**：读者看完内页能说出「XX学校去年XX分今年XX分，我考XX分可以报XX」。

当前评分：8/10（已达可用标准，到10分需更细颗粒度的专业代码级数据）。

### 依赖

- Pillow 10.4.0+（`rounded_rectangle` 需 8.2+）
- 优设标题黑字体：`~/project/brand/YouSheBiaoTiHei-2.ttf`
- 文泉驿正黑：`/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc`

### 评分脚本缩放陷阱（2026-06-26 关键发现）

`score_xhs_image.py` 第 17 行将图片缩小到 **1/4**（1242×1660 → 310×415，LANCZOS 抗锯齿），
然后用 Euclidean distance 检测橙色。2px 细线和 3px 边框在缩小后几乎消失。
**必须用大面积橙色填充块**（`block_urgent` 全宽背景条）才能扛过评分。评分 orange_px 从 23→8181 验证了这个修复。

### 排版设计 v2 — 海报构图思维（2026-06-26）

v1 问题：区块堆砌 → 连续视觉大疙瘩（864px 连续块，仅 10-14px 间隙）。
v2 核心原则：每页是一张海报，不是文档。详见 `references/layout-design-v2.md`。
独立渲染脚本：`scripts/render_xhs_v2.py`（人工设计页面结构，不依赖 AI 生成）。

### HTML/CSS 排版 + Playwright 渲染（v6，2026-06-26）— ✅ 推荐方案

**结论**：用户不接受 Pillow 的文字渲染质量（「叠在一起看不清」），要求 HTML/CSS 浏览器级排版。WeasyPrint 和 wkhtmltoimage 均失败，但 **Playwright Chromium 渲染中文完全正常**。

**技术栈**：
```
HTML/CSS（内嵌字体 base64，透明底）
  → Playwright Chromium 截图 PNG
  → PIL alpha_composite 叠加底板（内页.png）
  → 最终输出
```

**为什么之前没发现 Playwright 可用**：早期迁移记录说「Playwright 浏览器下载超时（GFW）」，但实测 `~/.cache/ms-playwright/chromium-1187` 已存在——浏览器早在迁移前就下载好了。

**WeasyPrint 失败原因**：中文字符在页面右侧被裁剪，无论怎么调 CSS 都无法修复。`@page { size: 1242px 1660px }` 正确但标题末尾 2-3 个汉字不渲染。

**wkhtmltoimage 失败原因**：QtWebKit 不通过 fontconfig 查找字体，完全不渲染中文。

**设计原则（来自 claude-design 和 Notion 设计系统）**：
- 每页一个核心视觉焦点（不是四张平均卡片）
- 排版建立层级，不是装饰
- 少即是多——每个元素必须值得存在
- 暖色调中性色 + 一个强调色

**渲染脚本**：`/tmp/render_xhs_v6.py`（独立脚本，生成 4 页内页 + 封面 + 小红书文案）
**运行**：`~/project/we-mp-rss-main/venv/bin/python3 /tmp/render_xhs_v6.py`
**依赖**：Playwright（chromium 已缓存）、Pillow（alpha_composite）、poppler-utils（pdftoppm，实际 v6 不需要）

**关键实现细节**：
- 透明底渲染：HTML `body { background: transparent }`，Playwright 截图 PNG 自带 alpha 通道
- 底图叠加：`Image.alpha_composite(base.copy(), content)` — base 在下，content 在上
- 字体嵌入：`@font-face { src: url(data:font/ttf;base64,...) }` — 无需系统字体
- 多页渲染：一张宽画布（4968×1660 = 1242×4），截图后 crop 分页，一次 Playwright 调用完成

**封面不动**：封面仍用 Pillow（底板 `小红书封面.jpeg` + 3行标题 + 标签），因为封面是固定模板，Pillow 够用。

### 关键陷阱（不要重犯）

- **评分脚本缩放陷阱（2026-06-26）**：`score_xhs_image.py` L17 将图片缩小到 1/4（LANCZOS 抗锯齿），然后用 Euclidean distance 检测橙色。2px 细线缩放后完全消失，3px 边框只剩模糊边缘。**必须用大面积橙色填充块**（如 `block_urgent` 全宽背景条）才能扛过评分。不要在排版中依赖细线橙色来做色彩节奏。
- **封面绝对不要动**：用户已确认封面模板标准。封面代码只做四件事：底板 + 3行标题（白/白/橙）+ 标签 `【xxx】`。
- **不要强制 2 页内页**：AI 根据内容决定页数。数据丰富的选题可能需要 3 页，简单的故事选题 1 页就够。不要在 prompt 里写「每个选题2页内页」
- **内页不做内容理解（当前）**：we-mp-rss 文章正文为空，`extract_article_insights()` 已废弃。不要在 prompt 中注入空的内容理解结果——会撑爆 prompt 导致 JSON 截断
- **DeepSeek 输出 key 中英文混用**：同一批次中 topic 1 可能是中文 key（`类型`、`内容`），topic 2 可能是英文 key（`type`、`content`）。必须用 `block.get("类型") or block.get("type", "")` 双读，并在内容数组中归一化 `label→标签`, `value→数值`, `description→说明`
- **DB 路径正确**：`we-mp-rss-data/db.db`（37MB生产库），不是 `we-mp-rss-main/data/db.db`（229KB旧库）
- **不要硬塞数据到不相关的选题**：故事/经验/攻略类选题不需要招生数据。AI prompt 中写明「仅在选题涉及分数/择校/证书时使用招生数据」
- **不要用固定模板约束 AI**：prompt 中不要写「第1页必须引言→数据卡→分析」。让 AI 根据选题性质自由选择区块类型和顺序
- **不要对非数据选题做数据分析**：选题3「室友下岸避雷」用 清单+分析 就够了，不需要 数据卡
- **we-mp-rss 文章正文已可用**：全117篇有 content，约50%可提取有效正文（1700-4500字）。用 `_html_to_text()` 提取，过滤微信界面残渣（`len>200` + 不含「微信扫一扫」）。description 字段仍不可靠（4-14字摘要）
- **prompt 不要太大**：富化数据（~1400字）+ 文章正文（空）+ 模板文字（~2500字）= ~4000字刚好。再加内容理解会导致 prompt 溢出，选题格式崩溃
- **必须使用 `内页.png` 做底图**：`base = Image.open(INNER_BG).convert("RGBA")`，不要 `Image.new()`
- **内页不能80%空白**：卡片必须有着色背景（绿底/橙底），不能全是白底卡片
- **每页必须有橙色视觉锚点**：至少 2000px 橙色元素
- **数据页首卡用橙色底**，不是全绿
- **步骤圆/清单框用奇偶交替色**：橙色-绿色-橙色
- **内页标题不是选题标题的复读**，要提炼一句话结论
- **`标签` vs `标签列表` 字段分离**：`标签`是分类字符串，`标签列表`是 hashtag 数组
- **fallback 模式下 `标签` 是 list**（旧格式），需判断 `isinstance(raw_tag, list)` 兜底
- **区块渲染是流式的**：每个区块返回新 Y，顺序不能乱
- **新增区块类型**只需三步：①写 `_block_xxx()` 函数 ②注册到 `BLOCK_RENDERERS` ③在 `generate_inner_page` 的 if-elif 链中加分支
- **文泉驿正黑字号较小时偏细**，表格/步骤用 30-36pt
- **`rounded_rectangle` 需 Pillow 8.2+**
- **字体 ascender 影响定位**：优设标题黑 155pt 有 44px 上留白

## HTML/CSS + Playwright 渲染方案（v8，2026-06-26 最终版）

### 何时用 Pillow、何时用 HTML

| | Pillow 方案 | HTML/CSS + Playwright |
|---|---|---|
| 排版质量 | 机械叠加，中文间距紧 | 浏览器原生渲染，CSS 弹性布局 |
| 开发效率 | 改布局=改 Python 代码 | 改布局=改 CSS |
| 依赖 | Pillow + 字体文件 | Playwright + Chromium（本机已装） |
| 文件大小 | 1.1-1.5MB | 1.4-1.6MB（含底板 base64） |
| 适用场景 | 封面（固定模板） | 内页（需要排版） |

**推荐：封面用 Pillow，内页用 HTML/CSS + Playwright。**

### 技术架构

```
Python 生成 HTML（内嵌 base64 字体 + 底板背景图）
    ↓
Playwright 启动 Chromium 无头浏览器
    ↓
一次截图 → 切分为多页 → 输出 PNG
```

### 关键实现细节

**1. 底板作 CSS 背景图（不是叠图）**

内页.png 必须直接作为 CSS `background-image`，而不是先渲染透明内容再合成。因为合成时内容块的不透明背景会盖住底板。

```python
BG_B64 = base64.b64encode(open("内页.png", "rb").read()).decode()
# CSS:
.page {{ background-image: url(data:image/png;base64,{BG_B64}); background-size: 1242px 1660px; }}
```

**2. 多页渲染：一排横过去，再切分**

在 HTML 里把所有页面并排放在一个 4968×1660（1242×4）的 body 里，Playwright 一次截图，再用 PIL 切分。

```python
body {{ width: 4968px; display: flex; }}
.page {{ width: 1242px; flex-shrink: 0; }}
# 切分:
for i in range(4):
    page = full.crop((i * 1242, 0, (i+1) * 1242, 1660))
```

**3. 字体嵌入**

优设标题黑（标题）和文泉驿正黑（正文）都以 base64 嵌入 CSS `@font-face`，确保 Chromium 在 headless 模式下正确渲染。

```css
@font-face {{ font-family: 'TF'; src: url(data:font/ttf;base64,{TB64}) format('truetype'); }}
@font-face {{ font-family: 'BF'; src: url(data:font/ttf;base64,{BB64}) format('truetype'); }}
```

### 设计原则（从多次迭代中总结）

| 原则 | 说明 |
|------|------|
| 底板是纸，不是画布 | 内容浮于纸上，不盖住纸纹。去掉不透明背景色，只用边框和文字 |
| 一眼知道先看哪 | 172px 大字 → 48px KPI → 28-32px 正文，视觉重量递减 |
| 橙色只标核心 | 每页只有 1-2 处橙色（hero 数字、紧急横幅），不滥用 |
| 正文必须能看清 | 最小字号 28px，全部用 bold。文泉驿正黑 Regular 太细不可用 |
| 每页回答一个问题 | 大字答案 + KPI 支撑 + 细节 + 结论，信息充实但不堆砌 |
| 结论条收尾 | 每页底部用橙色左边框 + 加粗结论，扫读就能抓到要点 |

### 已废弃的方案

| 方案 | 失败原因 |
|------|---------|
| WeasyPrint (HTML→PDF→PNG) | 中文标题被截断，`@page` 尺寸计算复杂 |
| wkhtmltoimage (QtWebKit) | 不渲染任何中文字符 |
| Pillow 画图 | 文字间距紧、无 CSS 布局、开发效率低 |

### 字号规范（基于 1242×1660 画布）

| 层级 | 用途 | 字号 | 字重 | 字体 |
|------|------|------|------|------|
| Hero | 核心大数字 | 172px | 常规 | 优设标题黑 |
| 页面标题 | 每页问题 | 54px | 常规 | 优设标题黑 |
| KPI 数字 | 支撑数据 | 48px | 常规 | 优设标题黑 |
| 区块标题 | 分区标签 | 28px | bold | 文泉驿正黑 |
| 正文 | 说明/细节 | 28-32px | bold | 文泉驿正黑 |
| 结论条 | 底部总结 | 32px | bold | 文泉驿正黑 |
| 紧急横幅 | 橙色警示 | 32px | bold | 文泉驿正黑 |

### 色彩规范

- 深绿 `#285A37`：品牌条、页面标题、KPI 数字
- 橙色 `#E67E22`：hero 数字、紧急横幅、推荐徽章、结论条左边框
- 近黑 `#1E3C28`：正文
- 中绿 `#649B73`：辅助说明、分隔线、页脚
- 浅绿 `#DCEEE2`、浅橙 `#FCEBD7`：极少使用，仅在 KPI 行或推荐卡片做微弱底色

### 参考文件

- `references/html-playwright-renderer-v8.py` — 可复用的 HTML/CSS → Playwright 内页渲染函数。接收 `pages_data` 列表（每项 `(title, blocks_html)`）和 `output_dir`，一次渲染所有页面。

## 文件共享方案（团队协作）

Hermes 会将 `~/.hermes/` 自动锁为 `700`（外部用户无法遍历）。知识库和运营数据需额外暴露给团队成员。

**本环境方案**：服务器上有一个自定义文件浏览器（`/root/file-browser.py`，端口 8088，认证 admin/hermes123），根目录固定为 `/root/.hermes`。已将数据同步到 `/root/.hermes/共享数据/`：

```bash
/root/.hermes/共享数据/
├── 知识库/     ← 从 ~/.hermes/知识库/ 每小时双向同步
└── 运营数据/   ← 从 ~/.hermes/运营数据/ 每小时双向同步
```

同步脚本：`/root/sync_shared.sh`（root crontab 每小时执行）。双向同步意味着：
- Hermes 写入 `~/.hermes/知识库/` → 自动出现在文件管理器的 `共享数据/知识库/`
- 团队成员在文件管理器上传到 `共享数据/` → 自动同步回 `~/.hermes/`
