Nhost 集成指南:深入解析 html-to-markdown v2 的 Go 转换器架构、插件机制与 CLI 实战
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
本文以 Nhost 仓库中 vendored 的github.com/JohannesKaufmann/html-to-markdown/v2(版本 v2.5.0,见 go.mod)为主体,系统讲解这套 Go 语言 HTML 转 Markdown 方案的核心 API、可定制选项、插件机制、CLI 用法与底层实现原理。读完本文,你将掌握如何用ConvertString一行完成转换、如何用converter.NewConverter组装自定义渲染流水线、如何通过TagType与RendererFor精确控制每个 HTML 标签的输出行为,以及如何编写和注册自己的转换逻辑,并了解它在 Nhost 的 AI WebFetch 工具中把网页正文转成 LLM 友好 Markdown 的真实落地方式。
一、核心能力一览:开箱即用的转换覆盖度
该库的目标是把 HTML(乃至完整网页)转换为干净、可读的 Markdown。官方文档列出的能力覆盖了日常写作中最常见的格式化场景:
- 粗体与斜体:支持
bold/italic,甚至能在单个英文单词内部正确插入定界符(例如unbelievable中间的粗体),不会破坏词内结构; - 有序/无序列表:完整支持嵌套层级;
- 引用块:块引用内部可包含其他元素,且支持引用嵌套;
- 行内代码与代码块:正确处理反引号与多行代码块,保留代码结构;
- 链接与图片:正确处理多行链接文本,并在需要时为空白行添加转义;
- 智能转义(Smart Escaping):只在必要时转义特殊字符,避免意外触发 Markdown 渲染(详见 ESCAPING.md);
- 移除/保留 HTML:可选择剥离或保留特定 HTML 标签,完全掌控输出;
- 插件体系:易于用插件扩展,或自行编写自定义插件;
- 表格插件:支持对齐(alignment)、
rowspan与colspan的表格转换。
这些能力分别由base与commonmark两个内置插件承载。其中base负责与 Markdown 语法无关的通用行为(移除节点、空白折叠等),commonmark负责按 CommonMark Spec 输出语法,这一分工也是理解后续所有自定义机制的基础。
二、Golang 库快速上手
2.1 安装
go get -u github.com/JohannesKaufmann/html-to-markdown/v2如需锁定特定提交,可在模块路径后追加/v2@commithash。在 Nhost 仓库中,该依赖以v2.5.0版本固化在 go.mod 中,属于正式发布的稳定版本。
2.2 最小示例:ConvertString
package main import ( "fmt" "log" htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" ) func main() { input := `<strong>Bold Text</strong>` markdown, err := htmltomarkdown.ConvertString(input) if err != nil { log.Fatal(err) } fmt.Println(markdown) // Output: **Bold Text** }2.3 更多入口:ConvertReader 与 ConvertNode
除字符串入口外,包级 API 还提供了另外两个入口,实现在 convert.go:
ConvertReader(r io.Reader, opts ...):从任意io.Reader读取 HTML 并返回[]byte;ConvertNode(doc *html.Node, opts ...):如果你已经用golang.org/x/net/html的html.Parse()解析过页面,可以直接把解析出的*html.Node传给转换器,避免重复解析。
从源码看,这三个函数都是同一套逻辑的薄封装:它们内部都先构造一个挂载了base.NewBasePlugin()与commonmark.NewCommonmarkPlugin()的Converter,再调用conv.ConvertString / ConvertReader / ConvertNode。因此它们的默认行为完全一致,你可以按输入形态自由选择。
2.4 相对链接转绝对链接:WithDomain
网页中的图片、链接常常是相对路径(如/assets/image.png)。通过converter.WithDomain可以把它们统一转为绝对链接:
package main import ( "fmt" "log" htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" "github.com/JohannesKaufmann/html-to-markdown/v2/converter" ) func main() { input := `<img src="/assets/image.png" />` markdown, err := htmltomarkdown.ConvertString( input, converter.WithDomain("https://example.com"), ) if err != nil { log.Fatal(err) } fmt.Println(markdown) // Output:  }WithDomain的实现位于 converter/convert.go,它把 domain 注入转换上下文,底层由AssembleAbsoluteURL负责拼接。拼接逻辑(converter/url.go)有几处值得注意的细节:
- 对
#单独处理,避免空 fragment 被url.Parse误解; - 把链接中的换行、制表符分别替换为
%0A、%09,提高解析成功率; - 保留查询参数的原始顺序,仅做编码,并把
+替换为%20,避免mailto:等场景中空格被错误编码; - 遇到
data:URI(如内联 base64 图片)时不拼接域名。
这些细节保证了"整站抓取转 Markdown"场景下的链接可用性。
三、从默认包装器到完全可定制:converter 与插件组装
官方文档明确说明:ConvertString()只是converter.NewConverter()+base插件 +commonmark插件的小型包装。当需要更多控制权时,可以直接组装:
package main import ( "fmt" "log" "github.com/JohannesKaufmann/html-to-markdown/v2/converter" "github.com/JohannesKaufmann/html-to-markdown/v2/plugin/base" "github.com/JohannesKaufmann/html-to-markdown/v2/plugin/commonmark" ) func main() { input := `<strong>Bold Text</strong>` conv := converter.NewConverter( converter.WithPlugins( base.NewBasePlugin(), commonmark.NewCommonmarkPlugin( commonmark.WithStrongDelimiter("__"), // ...additional configurations for the plugin ), // ...additional plugins (e.g. table) ), ) markdown, err := conv.ConvertString(input) if err != nil { log.Fatal(err) } fmt.Println(markdown) // Output: __Bold Text__ }注意:直接使用
NewConverter时,务必同时注册base与commonmark两个插件。这一点不只是文档建议——converter/convert.go 中定义了两个显式错误:未注册任何 render handler 时报errNoRenderHandlers(提示"你是否忘了注册 commonmark 和 base 插件"),注册了commonmark但缺少base时报errBasePluginMissing。转换前会主动校验并返回错误,而不是静默产出错误结果。
3.1 commonmark 插件配置项详解
commonmark插件通过WithXxx选项函数配置输出风格,其配置结构体与默认值定义在 options.go,默认值填充逻辑在fillInDefaultConfig中,可配置项如下:
| 选项函数 | 控制内容 | 允许取值 | 默认值 |
|---|---|---|---|
WithEmDelimiter | 斜体定界符 | _或* | *(在词内表现更佳,v2 新默认) |
WithStrongDelimiter | 粗体定界符 | **或__ | ** |
WithHorizontalRule | 水平分割线 | 任意 Thematic break | * * * |
WithBulletListMarker | 无序列表符号 | -、+或* | - |
WithCodeBlockFence | 围栏代码块定界符 | ```或~~~ | ``` |
WithHeadingStyle | 标题风格 | atx或setext | atx |
WithListEndComment | 列表结束注释 | 布尔值 | 开启 |
WithLinkEmptyHrefBehavior | 空href链接的处理 | render(渲染为[text]())或skip(降级为纯文本) | render |
WithLinkEmptyContentBehavior | 无内容链接的处理 | render(渲染为[](/page))或skip(输出空串) | render |
源码注释还预留了CodeBlockStyle(indented/fenced)、LineBreakStyle、WithLinkStyle(inlined/referenced_index/referenced_short)等未完成项,说明库仍在持续演进中。
从实现看,NewCommonmarkPlugin会先应用所有选项,再调用fillInDefaultConfig补齐默认值,最后在Init阶段(commonmark.go)执行配置校验,并注册EscapedChar、一系列UnEscaper、渲染器与文本转换器。
3.2 转义模式:WithEscapeMode
converter.WithEscapeMode(mode)控制转义的严格程度,定义在 converter/converter.go:
"smart"(默认):只在可能产生歧义的位置加反斜杠转义;"disabled":完全不转义。
详见下文"转义原理"一节中的对比示例。
四、Collapse 与 Tag Type:精确控制每个标签的行为
转换过程中,空白折叠(collapse)依赖每个节点的标签类型(块级/行内)判断。官方文档指出:如果你在使用 Web Components / 自定义元素,务必通过TagType或RendererFor注册其类型,否则折叠逻辑无法正确处理它们。
4.1 三种 TagType 与优先级
register.TagType(name, type, priority)为标签注册类型,类型定义在 converter/register.go:
TagTypeBlock:块级元素,折叠时按块处理(前后留空行);TagTypeInline:行内元素;TagTypeRemove:在 Pre-Render 阶段直接删除该节点。
未注册的标签会回退到dom.NameIsBlockNode / NameIsInlineNode的内置判断(register.go)。
base插件在初始化时已注册了一批默认移除的标签(plugin/base/base.go):#comment、head、script、style、link、meta、iframe、noscript、input、textarea。这也是为什么转换整站 HTML 时这些噪音元素会自动消失。
4.2 预置渲染器
base插件提供三个预置渲染器(plugin/base/renderers.go):
RenderAsHTML:把节点(含子节点)整体按 HTML 原样输出,块级标签前后补空行;RenderAsHTMLWrapper:节点本身输出为 HTML 外壳,子节点按 Markdown 渲染;RenderAsPlaintextWrapper:保留子节点的 Markdown 渲染,仅处理外层换行。
4.3 组合示例
官方文档给出的组合示例正好对应其tag_type_renderer截图中的三种场景:
conv.Register.TagType("nav", converter.TagTypeRemove, converter.PriorityStandard) conv.Register.RendererFor("b", converter.TagTypeInline, base.RenderAsHTML, converter.PriorityEarly) conv.Register.RendererFor("article", converter.TagTypeBlock, base.RenderAsHTMLWrapper, converter.PriorityStandard)- 把
<nav>整体从输出中移除; - 把
<b>声明为行内元素并按 HTML 原样保留(PriorityEarly让它抢先于 commonmark 的粗体渲染器执行); - 把
<article>声明为块级元素,输出<article>外壳、内部转 Markdown。
RendererFor是TagType+Renderer的便捷包装(register.go):先注册标签类型,再注册一个"仅当节点名匹配时才调用目标渲染函数,否则返回RenderTryNext交给下一个处理器"的渲染器。
4.4 优先级系统
所有注册项都带优先级整数,常量定义在 converter/prioritized.go:
| 常量 | 值 | 含义 |
|---|---|---|
PriorityEarly | 100 | 尽早执行;想更早可继续减 |
PriorityStandard | 500 | 无特殊顺序要求 |
PriorityLate | 1000 | 尽量靠后执行;想更晚可继续加 |
注册的处理函数会按优先级升序排序后依次调用(prioritizedSlice.Sort())。例如base插件把节点移除注册为PriorityEarly、把空白折叠注册为PriorityLate(确保折叠在其他函数之后执行);commonmark 插件把列表结束注释处理注册为PriorityLate+100,保证在折叠与移除之后运行。这也是覆盖默认行为的入口——例如想保留<style>标签,只需用更高的优先级(如PriorityEarly)重新注册它。
五、插件机制:扩展转换能力
5.1 Plugin 接口与生命周期
插件只需要实现一个极简接口(converter/plugin.go):
type Plugin interface { Name() string // 插件公开名称,如 "strikethrough" Init(conv *Converter) error // 校验参数并注册规则 }WithPlugins(plugins ...Plugin)逐个调用Register.Plugin(plugin):先记录插件名,再执行Init;若Init返回错误(如配置校验失败),错误会被暂存,并在首次执行ConvertNode时返回(converter/convert.go)。base与commonmark两个插件的Init实现(base.go、commonmark.go)就是按这个模式注册各自规则的范本。
5.2 已发布插件一览
仓库plugin目录(plugin)下目前已实现的插件:
| 名称 | 状态 | 说明 |
|---|---|---|
| Base | 已实现 | 通用基础功能(节点移除、空白折叠、文本转义等) |
| Commonmark | 已实现 | 按 CommonMark Spec 输出 Markdown |
| Strikethrough | 已实现 | 把<strike>、<s>、<del>转为~~语法 |
| Table | 已实现 | 按 GitHub Flavored Markdown 规范输出表格,支持对齐、rowspan、colspan |
| GitHubFlavored | 规划中 | — |
| TaskListItems | 规划中 | — |
| VimeoEmbed / YoutubeEmbed | 规划中 | — |
| ConfluenceCodeBlock / ConfluenceAttachments | 规划中 | — |
官方文档同时说明:v1 中尚有一部分插件未移植到 v2,规划项会陆续补齐。
5.3 编写自定义逻辑的两条路径
官方推荐的自定义流程分两步:
- 编写逻辑并注册:先写自己的转换逻辑,通过
RegisterAPI 挂到转换器上; - (可选)打包成插件发布:如果逻辑对他人也有价值,可打包成插件并发布,参考 WRITING_PLUGINS.md(注意:该文档当前仍是 TODO 占位状态,具体规范以
Plugin接口与Init注册模式为准)。
5.4 Register 注册 API 全景
Converter.Register暴露的注册入口全部实现在 converter/register.go,按转换流水线阶段划分:
| 注册方法 | 签名 | 作用 |
|---|---|---|
PreRenderer | func(ctx Context, doc *html.Node) | 渲染前对整棵 DOM 树做预处理(移除、折叠、打标记等) |
Renderer | func(ctx Context, w Writer, n *html.Node) RenderStatus | 渲染单个节点;返回RenderTryNext交给下一个处理器,RenderSuccess表示完成 |
PostRenderer | func(ctx Context, content []byte) []byte | 渲染完成后对整段输出做后处理(修剪空白、还原转义等) |
TextTransformer | func(ctx Context, content string) string | 转换纯文本节点内容(HTML 实体替换、转义等) |
EscapedChar | func(chars ...rune) | 声明需要转义的字符集合 |
UnEscaper | func(chars []byte, index int) int | 在安全场景下撤销多余的转义 |
TagType | func(tagName string, tagType tagType, priority int) | 注册标签的块/行内/移除类型 |
所有注册内部都通过sync.RWMutex保护,读取端(getXxxHandlers)会先拷贝再排序,保证并发安全(详见下文 FAQ)。
六、CLI:命令行上的 HTML 转 Markdown
使用 Golang 库可获得最大的定制能力,而 CLI 是最快的上手方式——它也构建在同一个转换器之上。
6.1 安装方式
官方支持四种途径:
- Homebrew Tap:
brew install JohannesKaufmann/tap/html2markdown; - Debian:官方发布
deb安装包,按云仓库的 Setup Instructions 配置软件源后安装; - 预编译二进制:从 releases 页面下载 Linux/macOS/Windows 预编译产物,解压后把可执行文件放到系统 PATH(如
/usr/local/bin); - Go 安装:
go install github.com/JohannesKaufmann/html-to-markdown/v2/cli/html2markdown@latest,会下载源码并编译到 Go 二进制目录(通常为$GOPATH/bin)。
此外,发布版本的二进制由 GoReleaser 自动构建并挂载到每个 release,本地也可用go build ./cli/html2markdown自行编译。
6.2 版本检查
html2markdown --version注意:务必确认
--version输出2.X.X,因为 v1 与 v2 各自对应不同的 CLI 程序。
6.3 基本用法
管道输入是最常用的形态:
$ echo "<strong>important</strong>" | html2markdown **important**配合curl抓取整页:
$ curl --no-progress-meter http://example.com | html2markdown # Example Domain This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission. [More information...](https://www.iana.org/domains/example)文件批量转换(支持通配符):
$ html2markdown --input file.html --output file.md $ html2markdown --input "src/*.html" --output "dist/"6.4 常用选项
--help可查看全部配置,官方文档示例的常用选项包括:
--domain="https://example.com":把相对链接转为绝对链接;--exclude-selector=".ad":排除class="ad"的元素;--include-selector="article":只保留<article>元素参与转换;--plugin-strikethrough、--plugin-table:启用对应插件。
官方同时说明:CLI 尚未支持全部库选项,定制能力会随时间逐步补全。
七、转义(Escaping)原理
7.1 为什么需要转义
Markdown 中某些字符有特殊含义,例如-可表示列表、强调与分割线。反斜杠\用来"转义"这些字符,使其按字面渲染。转义并非多余:比如下面的 HTML 转出的 Markdown 中,Paragraph 1下方只有一个-,会被 Markdown 解析器误认为setext 二级标题:
<h2>Paragraph 1</h2> <p>Paragraph 2</p>看似普通的输出(-单独一行)其实有歧义。一个恰到好处的反斜杠可以消除它:
Paragraph 1 \- Paragraph 27.2 两种转义模式对比
WithEscapeMode("smart")(默认)与WithEscapeMode("disabled")对同一输入产生不同结果(完整示例见 ESCAPING.md):
| 内容 | |
|---|---|
| 输入 | <p>fake **bold** and real <strong>bold</strong></p> |
| smart 输出 | fake \*\*bold\*\* and real **bold** |
| smart 渲染 | fake **bold** 与 realbold正确区分 |
| disabled 输出 | fake **bold** and real **bold** |
| disabled 渲染 | 真假粗体无法区分,两处都渲染为粗体 |
smart模式会为歧义字符添加反斜杠(输出稍显杂乱但渲染正确);disabled模式完全不做转义,可能导致意外渲染。因此生产环境建议保持默认的 smart 模式;若遇到转义异常的内容,可临时禁用并反馈问题。
7.3 转义字符集合
从 commonmark.go 可以看到,commonmark插件注册的转义字符覆盖了 Markdown 的全部语法符号:
\ * _ - + . > | $ # = [ ] ( ) ! ~ ` " '同时它注册了一整套UnEscaper(IsItalicOrBold、IsBlockQuote、IsAtxHeader、IsSetextHeader、IsDivider、IsOrderedList、IsUnorderedList、IsImageOrLink、IsFencedCode、IsInlineCode、IsBackslash),在确认上下文安全时撤销多余转义,这正是"smart"的实现机制。
八、转换流水线的源码级剖析
8.1 三阶段模型
ConvertNode的核心流程(converter/convert.go)是一个清晰的"先处理、再渲染、后修饰"三段式流水线:
- Pre-Render(预处理):按优先级依次执行所有
PreRenderer,对整棵 DOM 树做修改——base插件在此阶段移除废弃节点(preRenderRemove)、合并相邻文本节点、折叠空白(preRenderCollapse);commonmark插件在此阶段为列表添加结束注释标记; - Render(渲染):从根节点开始递归渲染,结果写入
bytes.Buffer; - Post-Render(后处理):按优先级依次执行所有
PostRenderer,对完整输出做收尾——base插件的postRenderTrimContent修剪首尾空白与多余换行,postRenderUnescapeContent撤销安全场景下的多余转义;commonmark插件的handlePostRenderCodeBlockNewline用真实换行替换代码块内部的占位标记。
8.2 节点渲染决策链
handleRenderNode(converter/render.go)对每个节点按三步决策:
#text文本节点:直接走文本转换管线(依次经过所有TextTransformer,如 HTML 实体替换、转义);- 遍历所有
Renderer,按优先级调用;返回RenderSuccess即停止,返回RenderTryNext则交给下一个处理器; - 兜底逻辑:块级节点前后补空行,然后递归渲染子节点(render.go)。
RenderStatus只有RenderTryNext与RenderSuccess两个取值(status.go),这让自定义渲染器可以"尝试-回退"地链式协作。commonmark 的各类渲染器(标题、粗体斜体、链接图片、列表、引用、代码等)都以这个模式实现,例如 render_heading.go 会根据HeadingStyle生成 ATX(#前缀)或 setext(=/-下划线)标题,并处理行内换行、行尾#转义等边界;render_bold_italic.go 则负责在词内正确插入粗体/斜体定界符。
8.3 空白折叠的实现来源
空白折叠算法(collapse/collapse.go)是从 JavaScript 生态的 turndown 库移植而来,turndown 又改编自 collapse-white-space;移植到 Go 时用自定义代码替代了正则以提升性能。其核心逻辑是:把任意空白序列统一为单个空格,根据块级/行内/void/preformatted 节点决定前后空格去留,最终把 DOM 中的冗余文本节点清理干净。文件头部的版权注释如实记录了这条移植链,这对评估该算法的成熟度是有价值的背景信息。
8.4 内部标记机制
渲染过程中,库会用一些不可见字符作为内部占位标记(marker/marker.go):MarkerEscaping使用贝尔字符\a(7)标记需要转义的位置,MarkerCodeBlockNewline使用私有区字符\uF002暂时代替代码块内的换行,防止换行在中间处理阶段被破坏,最后在 Post-Render 阶段统一还原。
九、在 Nhost 仓库中的实际应用
这套库在 Nhost 中并非孤立依赖,而是真实服务于 AI 能力链路。Nhost 的 AI Agent 提供web_fetch工具,用于抓取网页并把内容以 Markdown 形式返回给 LLM,其实现位于 services/ai/agents/tool/webfetch.go:
- 依赖声明:
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0(go.mod),导入别名md; - 抓取环节:使用带 SSRF 防护的 transport、30 秒超时、最多 5 次重定向、1 MB 响应体上限(webfetch.go);
- 转换环节:仅当响应
Content-Type为text/html或application/xhtml时调用md.ConvertString(string(body))(webfetch.go),一次调用即完成整页 HTML 到 Markdown 的转换; - 输出防护:转换结果会被截断到 256 KB(
truncateOutput),避免病态页面(深层嵌套 HTML、密集转义序列)产出远超源体积的 Markdown 撑爆 LLM 上下文窗口,并在截断处追加...[truncated]标记。
这是ConvertString+ 默认插件组合在真实产品中的典型用法:只需一行 API 调用,即可把任意网页正文变成干净的、可供 LLM 直接阅读的 Markdown。
十、FAQ 与最佳实践
10.1 如何扩展自定义逻辑
- 编写自己的逻辑后用
Register注册即可; - 不满意库的默认行为?用
PriorityEarly(数值更小)让自定义逻辑先于默认规则执行; - 若逻辑对他人有价值,可打包成插件发布。
10.2 遇到 Bug 如何反馈
官方强烈建议:提交 issue 时务必附上触发问题的 HTML 片段。没有可复现的 HTML 片段,很难定位和修复转换问题。
10.3 安全注意事项
该库产出的是"人类可读、可人工修改"的 Markdown,不做任何内容净化。当你把 Markdown 转回 HTML 渲染(例如用 goldmark、blackfriday)时,必须警惕恶意内容注入;在浏览器中展示之前,请先用 bluemonday 之类的 HTML 净化器过滤。安全漏洞报告途径见 SECURITY.md。
10.4 goroutine 并发安全
Converter可以安全地被多个 goroutine 共享使用。从 converter/converter.go 可以看到,Converter内部持有sync.RWMutex,所有注册与读取路径(Register、getXxxHandlers、getTagType、checkIsEscapedChar、错误状态读写)均受其保护;读取端还通过拷贝 + 排序避免数据竞争。官方文档确认存在对应的并发行为测试。
10.5 转义与反斜杠
Markdown 中某些字符(如*)具有特殊含义,反斜杠\用来转义它们。转义后的字符在最终渲染中不会显示反斜杠,这是完全安全的。相关原理可继续阅读 ESCAPING.md。
10.6 贡献与测试
仓库内置大量Golden File 测试,贡献者可以放心实验:把你遇到问题的 HTML 片段加入testdata目录下的.in.html文件,运行go test -update观察哪些.out.md文件发生变化;修改内部逻辑后再次运行go test -update对比影响。提交 PR 前务必运行这些测试并把结果文件一并纳入版本控制。官方同时强调:出于向后兼容考虑,外部 API 不应随意变更。
10.7 许可证
除特别说明外,项目采用 MIT 许可证授权,全文见 LICENSE。
结语
从ConvertString的一行调用,到converter.NewConverter的插件组装,再到TagType/RendererFor/优先级体系对每个标签的精细控制,html-to-markdown v2 提供了一条从"开箱即用"到"完全自定义"的平滑路径;CLI 则让非 Go 场景也能直接复用同一套转换引擎。理解其"Pre-Render → Render → Post-Render"的流水线模型与基于优先级的插件协作机制,是掌握这套库的关键。Nhost 的 AI WebFetch 工具展示了它在真实产品中的价值——把网页抓取与 LLM 阅读之间的格式鸿沟,用一次 API 调用轻松填平。
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考