编程

把内容进站工程化:格式转换、自动内链与标签治理的实践

为什么内容进站需要工程化

当博客内容来源变得多样——手写稿、资料库文档、外部平台汇编、自动化草稿——再靠「复制粘贴进编辑器」就会失控:格式丢失、站内链接靠手工挂、标签随手新建导致重复。近期为一次批量内容迁移(把一批资料库文档整理进站)搭了一条可复用的「内容进站」管线,分三层:格式转换自动内链标签治理。本文记录这三层的设计取舍与踩到的真坑。

第一层:格式转换,把异构富文本还原成古腾堡块

资料库里的文档并非 Markdown,而是一套类 XML 的富组件结构:<Heading id="…" level="n"><Paragraph id="…"><BlockQuote><Table> / <TableRow> / <TableCell><NumberedList>/<BulletedList><Code id="…">、行内 <Mark> 加粗、<Divider>。要交付到 WordPress,必须映射为块结构。

转换器是一个逐行扫描 + 状态机:遇到开标签进入对应块、读到闭标签收尾;连续的列表项累积后一次性 flush 合并成单个 wp:list,避免每项生成一个块。映射关系如下:

源组件古腾堡块
Heading level=nwp:heading(h1/h2/h3…)
Paragraphwp:paragraph
BlockQuotewp:quote
NumberedList / BulletedListwp:list(连续项合并,<ol>/<ul>)
Table / TableRow / TableCellwp:table(figure > table)
Codewp:code
Mark / Dividerstrong / 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 校验块计数与内容,是这套管线最后一道自动闸门。

参考文档

  1. WordPress.org. REST API Handbook[EB/OL]. https://developer.wordpress.org/rest-api/ (访问日期: 2026-09-11).
  2. WordPress.org. Block Editor Handbook[EB/OL]. https://developer.wordpress.org/block-editor/ (访问日期: 2026-09-11).

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注