浏览 AI 使用教程
操作教程

Codex批量修改Markdown:解析标题、预演清单与副本验证

提供完整Node脚本,用markdown-it解析目标标题并保留frontmatter、段落和代码块,先生成带哈希的预演清单,再写入副本检查差异。

Codex项目开发与自动化工作流主视觉

先看重点:快速结论

  1. 完整脚本只改目标普通二级标题,保留代码示例和元数据。
  2. 输入不覆盖;预演后变更、格式歧义和已有输出目录都会停止。
本文目录 · 跳到当前步骤

批量修改不能只靠“全局替换后看一眼”。本例只把普通顶层二级标题“旧标题”改为“新标题”,同样文字出现在段落、YAML、代码块或链接中都不改。脚本先用解析器确认结构,再按原始行位置改动,最终生成副本。

环境、范围和输入

在新的 markdown-demo 目录使用 Node.js 20+,安装本例固定版本依赖:

powershell
npm init -y
npm install --save-exact --ignore-scripts [email protected] [email protected]

版本用于固定教学环境,不表示以后永远使用这些版本。保存锁文件。这里使用 markdown-it 的公开解析入口,不导入内部子路径。

建立 input/a.md:

markdown
---
title: "旧标题"
updated: "2026-10-01"
---
## 旧标题

正文里提到旧标题,不应该被替换。

```markdown
## 旧标题
```

[旧标题](https://example.com)

再建立 input/b.md,正文为“没有目标标题。”。目录可以有多篇 .md 文件,本例只处理直接子文件,不递归、不跟随目录链接。练习使用普通 UTF-8 文件;自定义 MDX 或不完整 frontmatter 先修复,不套本脚本。

完整脚本:解析、预演、核对输入再写副本

保存为 rename-heading.mjs:

js
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import MarkdownIt from 'markdown-it';
import matter from 'gray-matter';

const mode = process.argv[2] || 'plan';
if (!['plan', 'apply'].includes(mode)) throw new Error('用法: node rename-heading.mjs plan|apply');
const inputDir = path.resolve('input');
const outputDir = path.resolve('converted');
const planFile = path.resolve('markdown-plan.json');
const md = new MarkdownIt();
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const names = fs.readdirSync(inputDir).filter(n => n.endsWith('.md')).sort();
const rows = [];
for (const name of names) {
  const file = path.join(inputDir, name);
  if (!fs.lstatSync(file).isFile()) throw new Error('仅处理普通文件: ' + name);
  const buffer = fs.readFileSync(file);
  const raw = buffer.toString('utf8');
  if (!Buffer.from(raw).equals(buffer)) throw new Error('不是有效 UTF-8: ' + name);
  const parsed = matter(raw);
  const body = parsed.content;
  if (!raw.endsWith(body)) throw new Error('无法保留原始头部: ' + name);
  const prefix = raw.slice(0, raw.length - body.length);
  const lines = body.match(/[^\n]*\n|[^\n]+$/g) || [];
  const tokens = md.parse(body, {});
  const changes = [];
  for (let i = 0; i < tokens.length; i++) {
    const t = tokens[i];
    if (t.type !== 'heading_open' || t.tag !== 'h2' || t.level !== 0) continue;
    const inline = tokens[i + 1];
    if (inline.content !== '旧标题') continue;
    const [start, end] = t.map;
    const line = lines[start];
    if (end !== start + 1 || !/^## 旧标题(?:\r?\n)?$/.test(line) ||
        inline.children.length !== 1 || inline.children[0].type !== 'text') {
      throw new Error('标题格式需人工处理: ' + name + ':' + (start + 1));
    }
    lines[start] = line.replace('旧标题', '新标题');
    changes.push({ bodyLine: start + 1, before: '## 旧标题', after: '## 新标题' });
  }
  const after = prefix + lines.join('');
  if (JSON.stringify(matter(after).data) !== JSON.stringify(parsed.data)) {
    throw new Error('元数据变化: ' + name);
  }
  rows.push({ name, sourceHash: hash(buffer), changes, after });
}
const manifest = {
  inputDir, rule: 'top-level plain ATX h2: 旧标题 -> 新标题',
  files: rows.map(({ name, sourceHash, changes }) => ({ name, sourceHash, changes }))
};
if (mode === 'plan') {
  fs.writeFileSync(planFile, JSON.stringify(manifest, null, 2) + '\n');
  console.log(JSON.stringify(manifest, null, 2));
} else {
  const saved = JSON.parse(fs.readFileSync(planFile, 'utf8'));
  if (JSON.stringify(saved) !== JSON.stringify(manifest)) {
    throw new Error('输入或规则已变化,请重新预演并检查');
  }
  // Existing output is deliberately refused; the input is never overwritten.
  fs.mkdirSync(outputDir);
  for (const row of rows) fs.writeFileSync(path.join(outputDir, row.name), row.after, { flag: 'wx' });
  console.log('副本写入 converted;原 input 未改动');
}

它没有重新序列化全部 Markdown,也没有重新输出 YAML;原头部字节和非目标行保持原样。解析器负责识别 heading_open 的类型和行范围,只有已经确认是普通顶层二级标题的那一行才做替换。因此最后一处 replace 是结构定位后的局部操作,不是对整篇文章硬替换。

Setext 标题、带强调/链接的标题、块引用和列表中的标题都不在自动处理范围。恰好具有目标内容但格式不符合时,脚本会停止;没有命中的其他结构保持不动。它是明确规则的批处理示例,不是通用 Markdown 迁移器。

先看清单,再执行

powershell
node rename-heading.mjs plan
Get-Content -LiteralPath markdown-plan.json
node rename-heading.mjs apply
git diff --no-index -- input converted

预演应列出 a.md 的一个标题变更,b.md 的 changes 为空。清单保存源文件哈希;预演后输入增加、减少或内容变化,apply 都会停止,要求重新预演。不要只看变更数量,还要查看前后文字与文件名。

应用后 converted/a.md 的实际标题变成“新标题”,YAML title、正文句子、代码块和链接文字仍是“旧标题”;converted/b.md 与原文一致。git diff --no-index 的退出码 1 表示发现差异,并不自动代表脚本错误。

input 原文件从未被覆盖。converted 已存在时脚本拒绝执行,避免覆盖上次结果;保存旧结果后使用新的练习目录再执行。如果磁盘写入中途失败,副本目录可能不完整,原 input 仍在;不要把半份输出当成完成品。

还要验证哪些反例

复制练习目录,分别测试:a.md 没有目标标题、相同文字只在代码块、标题含格式、预演后改输入、重复执行 apply。检查未匹配内容是否原样保留,错误是否停止写入,而不是只确认正常样本能变出文件。

允许自动替换的是这次明确选择的结构。若需求改为更新 frontmatter 的 updated,应通过 YAML 字段修改并说明序列化格式变化;不能把本例标题脚本稍改字符串就宣称已经支持所有字段迁移。

放回真实项目之前

让 Codex 比较源与副本,解释每处差异并列出未覆盖的语法,再按真实项目的构建命令验证副本。构建可能有自定义插件和内部链接规则,解析成功不能证明业务页面正确。

只有确认本次变更符合需求后,再通过可审查的逐文件差异合入内容源;不要覆盖整个目录。这个练习不会部署网站,也不会替真实项目决定哪些标题应该改。

资料来源

下一步

继续阅读与你当前问题相关的教程: