把内容进站工程化:格式转换、自动内链与标签治理的实践
为什么内容进站需要工程化
当博客内容来源变得多样——手写稿、资料库文档、外部平台汇编、自动化草稿——再靠「复制粘贴进编辑器」就会失控:格式丢失、站内链接靠手工挂、标签随手新建导致重复。近期为一次批量内容迁移(把一批资料库文档整理进站)搭了一条可复用的「内容进站」管线,分三层:格式转换、自动内链、标签治理。本文记录这三层的设计取舍与踩到的真坑。
第一层:格式转换,把异构富文本还原成古腾堡块
资料库里的文档并非 Markdown,而是一套类 XML 的富组件结构:<Heading id="…" level="n">、<Paragraph id="…">、<BlockQuote>、<Table> / <TableRow> / <TableCell>、<NumberedList>/<BulletedList>、<Code id="…">、行内 <Mark> 加粗、<Divider>。要交付到 WordPress,必须映射为块结构。
转换器是一个逐行扫描 + 状态机:遇到开标签进入对应块、读到闭标签收尾;连续的列表项累积后一次性 flush 合并成单个 wp:list,避免每项生成一个块。映射关系如下:
| 源组件 | 古腾堡块 |
|---|---|
| Heading level=n | wp:heading(h1/h2/h3…) |
| Paragraph | wp:paragraph |
| BlockQuote | wp:quote |
| NumberedList / BulletedList | wp:list(连续项合并,<ol>/<ul>) |
| Table / TableRow / TableCell | wp:table(figure > table) |
| Code | wp:code |
| Mark / Divider | strong / wp:separator |
看起来直白,但两个坑足以让转换「静默丢内容」。
坑一:自闭合单元格让扫描跑飞,吞掉整篇后半
现象:某篇 42KB 的文档转换后只剩 8.5KB,前半正常、后半凭空消失,且没有任何报错。
根因:源表格里存在自闭合的空单元格 <TableCell … />。原扫描器读到 <TableCell> 后,一路向后找 </TableCell>;但空格子没有闭标签,于是它「吃掉」了直到文末的所有内容。
修复:给自闭合标签单独分支(以 /> 结尾的即为空单元格/空行);同时给单元格内层循环加越界守卫——遇到 </TableRow> 或 </Table> 也立即终止,杜绝 runaway。
# 空单元格(自闭合):单独分支,不进内层循环
elif t.startswith('<TableCell') and t.rstrip().endswith('/>'):
cur_row.append('')
# 普通单元格:内层循环必须带越界守卫
elif t.startswith('<TableCell'):
cell = []; i += 1
while i < n:
ct = src[i].strip()
if ct.startswith('</TableCell>') or ct.startswith('</TableRow>') or ct.startswith('</Table>'):
break # 守卫:不再无限向后吞
# ...解析单元格内容...
i += 1
坑二:段落外的独立 Mark 行被丢弃
现象:原文有 26 处强调句在转换后消失。
根因:这些 <Mark> 不以 <Paragraph> 包裹,是顶层独立行;扫描器只处理了「段落内的 Mark」,顶层行落入未识别分支被静默跳过。
修复:把顶层 <Mark> 行按普通段落处理(内部再解析行内强调)。通用教训:转换器对「未识别结构」绝不能静默跳过——要么显式处理,要么抛错;静默跳过等价于数据丢失。
第二层:内链自动化,让站内链接自己长出来
手工给每篇文章挂站内链接不可持续。做法是双层关键词映射:
- 人工精选层
internal-links.json:维护「关键词 → 目标 URL」的精选映射,覆盖书名、核心概念、分类归档等真正值得导流的目标,并携带maxLinks上限。 - 自动生成层
taxonomy-links.json:由全站分类与标签实时生成(数百条),让「任意文章提到某个已有标签词」都能落到对应归档页。
注入器在正文上按以下规则落链接:
- 每篇不超过 5 条,且按段落分散——不能全挤在第一段。
- 排除自指向:不链接到文章自身的 URL,也不链接到它自己所属的分类/标签归档(否则出现「点进去还是这篇/同类」的死循环)。
- 同 URL 去重;跳过代码块、标题、已有链接。
- 中英文差异匹配:中文用子串匹配,英文用词边界,避免
Go命中Google之类的误伤。 - 幂等:若正文已存在指向该 URL 的
<a>,直接跳过——重跑不重复、不挪动。
排除集由当前文章的分类/标签 ID 动态生成:
# 生成自指向排除集:排除集 = 文章自身 URL + 自身分类/标签归档
node build-exclude.js /tmp/exclude.json 465 4,775,554
# 注入:curated 映射 + taxonomy 映射 + 排除集
node inject-internal-links.js draft.html final.html \
internal-links.json taxonomy-links.json exclude.json
存量文章同样可以批量补齐:先 dry-run 预演,再 --apply 写回。一次回填命中全站 136 篇,其中 95 篇新增内链、41 篇无需改动、0 失败。
第三层:标签治理,堵住重复标签的源头
标签是内链与归档的骨架,一旦出现重复,整套映射都会失准。本项目两次踩到同一个坑。
现象:站内反复出现两个同名「价值投资」标签。
根因:创建标签的 ensureTag 只按 slug 查重。而既有标签的 slug 是中文(价值投资)——用英文 slug value-investing 去查永远查不到,于是每次都新建一个重复标签。
修复:查重改为 slug + name 双查,任一命中即复用;对已产生的重复,用合并脚本归一——把所有文章改挂到保留标签 → 删除旧标签 → 重生 taxonomy 映射。
// 双查:先按 slug,再按 name,命中即复用,绝不盲目新建
const bySlug = await GET('/wp-json/wp/v2/tags?slug=' + encodeURIComponent(slug));
if (hit(bySlug, name)) return bySlug.id;
const byName = await GET('/wp-json/wp/v2/tags?search=' + encodeURIComponent(name));
if (hit(byName, name)) return byName.id;
// 都未命中才创建
教训:任何「按标识查重再创建」的逻辑,都要考虑标识可能被本地化(中文 slug)——单字段查重迟早漏检并制造脏数据。
收尾:三条不可省的发布规范
- 正文一律标准块(
wp:heading/wp:paragraph/wp:list/wp:table/wp:code),禁止把整篇塞进单个wp:html裸块——否则块编辑器里不可按块维护。 - SEO 摘要单独写,不要依赖正文首段自动生成,避免被 shortcode / 代码片段污染。
- 参考文献用论文格式:
[编号] 作者. 题名[EB/OL]. 链接 (访问日期: YYYY-MM-DD).
发布后拉一次 context=edit 校验块计数与内容,是这套管线最后一道自动闸门。
参考文档
- WordPress.org. REST API Handbook[EB/OL]. https://developer.wordpress.org/rest-api/ (访问日期: 2026-09-11).
- WordPress.org. Block Editor Handbook[EB/OL]. https://developer.wordpress.org/block-editor/ (访问日期: 2026-09-11).