markdown-import-and-export-pipeline

Markdown 导入导出管道

本节讲 youteacher_content 里两条相关的内容管道:一条面向 agent 的 markdown 导入器,把 markdown 加媒体变成一篇和编辑器手工创建完全一样的草稿文章;另一条是基于 zip 的文章与分类批量导出/导入。两条都围着同一个事实转:文章以 BlockNote JSON 存储,媒体放在对象存储里,所以每条路径都得转换成这个形状,并保持媒体引用的一致。

面向 agent 的 markdown 导入

只有一个端点 POST /import-markdown,仅对管理员开放。它接收 multipart/form-data:文本字段(markdown、可选 title、可选 categorySlug),加上可重复的文件分块——一个可选的 featuredImage,以及任意多个 media 文件。成功时返回 201 和一份文章 DTO,与在编辑器里创建出来的文章无法区分——同样的 BlockNote 内容、同样的草稿状态。未知的文件字段会被记日志后忽略。

大小在两层被限制。markdown 正文上限 1 MB。每个媒体文件按 10 MB 的应用级上限重新校验;当某个分块触到底层 multipart 限制时,错误被重映射成唯一的规范化 400 / file_too_large,这样无论客户端从哪条路撞到上限,看到的都是同一个错误码。

端点背后的 use case 按"先校验"的有序步骤执行:

  1. 文本检查。 markdown 不能为空。标题取自显式字段,取不到就取 markdown 里的第一个 H1;两者都没有则拒绝导入。
  2. 分类。 若给了 categorySlug,它必须能解析到一个已存在的分类。
  3. 媒体校验,在任何上传之前。 每个文件的 MIME 必须是 image/ 开头,且不超过大小上限。
  4. 引用校验。 markdown 可以用 media:文件名 记号引用媒体;每个这样的引用都必须对应一个已上传的文件,否则以 unknown_media_ref 失败。
  5. 以 saga 方式上传。 到这一步才把文件上传到对象存储,并记下每个存储 key。文件按文件名去重,重复引用只上传一次。
  6. 重写并转换。media:文件名 记号重写成存储 URL,再把 markdown 转成 BlockNote JSON。若没提供 featured image,就把内容里的第一张内联图片作为 featured image。
  7. 以草稿持久化。 创建文章,contentFormatblocknote;slug 冲突时给标题加一段短 id 后缀,与普通创建路径一致。

saga 就是那条安全性质:上传之后任何一步抛错,都会先把已上传的每个对象删掉再让错误浮上来,所以一次失败的导入不会留下孤立的媒体。

Markdown → BlockNote 转换

转换用 marked 词法分析器(GitHub 风格),把它的 token 树映射到 BlockNote 的规范块类型:标题(层级最高到 3)、段落、无序与有序列表项、代码块、图片。段落会在每个图片 token 处被切开——文本段变成段落块,图片变成独立的图片块,对应 BlockNote 编辑器里"这是一张照片"后面跟图片这一行的排布方式。内联样式(粗体、斜体、行内代码、链接)会被保留;文本段落里的内联图片会降级为它的 alt 文本,因为 BlockNote 没有内联图片类型。无法识别的东西一律落为段落,空输入产出单个空段落。

在标准 markdown 之上,转换器还认识一套通用的 slash-command 指令语法,用于编辑器的自定义块。叶子指令是单独一行的 ::name{key="value"};容器指令以 :::name{...} 开始,跨越随后的若干行,以 ::: 收尾。指令名原样成为 BlockNote 块的 type,容器体则原封不动放进 props.content。因为名字是直通映射的,编辑器注册的任何自定义块都无需新增转换代码即可导入。

基于 zip 的导出与导入

批量搬运走一个自包含的 zip。导出按显式 id、按分类、或全部(上限 500 篇)选取文章,然后组装成一个包:一个 manifest.json(版本、时间戳、计数)、一个 categories/categories.json(含每个祖先分类,按深度排序)、以及每篇文章一个 posts/<slug>.json。媒体是有意思的部分——导出器收集每个内部媒体 URL(featured 图、遍历 BlockNote 内容找到的内联图、以及附件),把每一个下载进 zip 的 media/ 前缀路径下,并把每篇文章内容和 featured-image 字段里的 URL 重写成这些相对路径。只有落在实例对象存储前缀下的 URL 才被当作内部 URL,外部 URL 原样保留。下载失败的媒体会被跳过并记警告,而不是中止整个导出。

导入是它的逆过程,而且刻意做得宽容。它校验 manifest 版本,父先子后地导入分类(同 slug 已存在就复用,否则创建),把每个媒体文件上传回存储并建立 路径→URL 映射,再逐篇导入文章。内容通过把相对媒体路径替换回新的存储 URL 来还原。每篇文章都以草稿导入;slug 冲突加 id 后缀,唯一约束冲突触发同样的后缀兜底,别名只在尚未被占用时才恢复。单篇文章、单个分类或单个媒体上的错误会被收进一份结果摘要,而不是让整次导入失败,所以一个残缺的包也能把能落的部分落下来。

BlockNote 媒体遍历器被两个方向共用:它解析内容 JSON,递归穿过块的 contentchildren,读取图片的 props.url,要么收集内部 URL(导出),要么在深拷贝的树上按映射替换(导入)。zip 本身用 archiver 以压缩级别 6 构建,在内存里缓冲分块,只 finalize 一次。

为什么是这个形状

反复出现的主题是单一的内容表示 + 一致的媒体引用。markdown 导入的存在,是为了让 agent 不必懂 BlockNote 的 JSON 就能产出编辑器级的文章;指令语法让这扇门对自定义块保持敞开,无需逐块写代码。saga 与"上传前先校验"的次序,是为了失败时绝不留下上传了一半的媒体。导出和导入在实例之间搬运同一份表示,来去两程都重写媒体引用,让一个包可移植、可重新托管,而不是绑死在某一个存储源上。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →