用边缘转换让网站直接向 Agent 提供 Markdown
教程用约 160 行无依赖代码,基于 Cloudflare Worker 的 HTMLRewriter 将站点 HTML 按需转换为 Markdown。转换仅针对明确请求 Markdown 的成功 GET HTML 响应,其余资源保持原有处理。
AI 深度解读
同一个网址,浏览器拿到 HTML,AI agent 请求时拿到 Markdown,而且不用再维护一份页面副本。作者在 Cloudflare Worker 上用约 160 行、零依赖的转换代码,实现了这件事。
值得看的不是 HTML 转 Markdown 有多新鲜,而是这套方案如何划清转换范围、保留元数据、处理缓存,以及承认自身局限。对于已有静态落地页、想给 agent 提供更干净内容的团队,它提供了一个可以参考的实现思路。
先确认客户端要什么,再决定是否转换
Agent 读取网页,通常要下载 HTML,再从导航、脚本、样式中提取正文。有些 agent 已经会发送 Accept: text/markdown,服务器可以据此返回 Markdown。
作者没有另建 Markdown 站点,而是在静态资源返回途中转换。入口只接受同时满足四个条件的响应:GET 请求、客户端明确接受 Markdown、资源请求成功、内容类型为 HTML。
下面这段代码负责把转换范围限制在目标页面内:
export async function serveMarkdownForAgents(request, assets) {
const response = await assets.fetch(request);
if (request.method !== 'GET' || !wantsMarkdown(request) || !response.ok
|| !response.headers.get('content-type')?.toLowerCase().startsWith('text/html')) {
return response;
}
// ...convert
}
关键是最后一行判断:即使请求接受 Markdown,图片、CSS、sitemap 等非 HTML 资源仍然原样返回。
wantsMarkdown 也不能简单写成 includes('text/markdown')。作者按逗号拆开 Accept 中的媒体类型,再按分号读取参数,只接受精确匹配的 text/markdown。没有 q 时视为接受;存在 q 时,数值必须满足 value > 0 && value <= 1。
例如 Accept: text/html, text/markdown;q=0 明确排除了 Markdown,字符串搜索却会误判。作者提到,这个场景以及「请求 Markdown 时非 HTML 资源保持不变」都有测试。
不过,这段逻辑判断的是「是否接受 Markdown」,没有比较 HTML 和 Markdown 的相对优先级。若要做完整的内容协商,还需要补充规则。
160 行能处理正文,靠的是页面足够规整
转换使用 Cloudflare 的 HTMLRewriter。它是流式解析器,按选择器触发元素和文本回调,不会先建立完整 DOM 树。转换器因此要自己保存少量状态:输出字符串数组、列表计数栈,以及表示当前是否处于忽略区域的 skippedDepth。

作者跳过 head、nav、footer、script、style、noscript、svg、button、template,也跳过带 hidden、aria-hidden="true"、role="navigation" 或 skip-link 类名的内容。
进入这些区域时增加深度,遇到结束标签再减少;只有深度为零,正文文本才进入输出。这里有个容易漏掉的边界:img、br 等空元素没有结束标签,不能等待它们的 onEndTag。否则计数无法归零,后面的正文会一起消失。
常规映射比较直接:标题转成 # 前缀,段落和章节之间插入空行,strong、em 对应 **、*,pre 转为围栏代码块。列表栈让 ol 输出 1.、2.,让 ul 输出 -。
链接和图片则需要筛选。相对链接 /privacy 会转成绝对 URL,协议只允许 http、https、mailto、tel,javascript: 链接被丢弃。图片必须同时有 alt 文本和可解析的 src 才会输出,避免把装饰图塞进正文。
文本还会解码 &、 和数字实体,折叠空白,最后清理换行附近的空格,把连续空行限制为一个。
正文之外,独立处理器收集 <title>、meta description、canonical URL,写入开头的 front matter。application/ld+json 的文本则放到末尾的 ## Structured data 下,让 FAQ、产品信息等结构化数据也能被 agent 读到。
但这不等于浏览器渲染结果。Worker 只看到静态 HTML,页面加载后由 JavaScript 补出的内容不会出现。
正文转换完,响应头也必须一起改
同一个 URL 返回两种正文时,缓存策略也是功能的一部分。
作者把内容类型改成 Markdown,同时清除与原 HTML 内容绑定的响应头:
headers.set('content-type', 'text/markdown; charset=utf-8');
// 若尚未存在 Vary: Accept 或 Vary: *,则添加 Vary: Accept
for (const name of ['content-length', 'content-encoding', 'content-md5',
'etag', 'content-range', 'accept-ranges', 'digest']) headers.delete(name);
要特别看 etag 的删除:正文已经改变,继续沿用 HTML 的 ETag,可能让条件请求得到指向错误表示的 304。
Vary: Accept 则告诉共享缓存,响应内容还取决于请求的 Accept。否则缓存只认 URL,就可能把 agent 请求得到的 Markdown 发给随后访问的浏览器。上面的注释描述了需要补充的逻辑,并没有展示具体实现。
转换整体包在 try/catch 中,发生异常就返回原 HTML。作者的取舍很明确:agent 收到 HTML 尚能继续处理,转换失败导致 500 则直接打断访问。
原文提供的验证命令是 curl -s -H "Accept: text/markdown" https://talkaside.com/ | head -20,同一 URL 在普通浏览器中仍返回正常页面。不过,文章没有给出延迟、CPU 开销或 token 节省量的对照数据,不能据此推算性能收益。
我的判断
我认可这套方案把 HTML 保留为唯一内容源,再按请求派生 Markdown。对于结构稳定的营销页,它省去了人工同步两份内容的麻烦,也把缓存和降级处理纳入了实现。
但「约 160 行够用」有明确前提:作者的页面主要由标题、段落、列表和 FAQ 构成,转换器也专门适配了该站点的语义 HTML。这是有边界的工程经验,不能直接当成通用转换方案。
原文自己承认,表格只输出竖线分隔的行,缺少 |---| 表头分隔行,因此不属于严格有效的 Markdown 表格。富组件和复杂表格更适合交给成熟的 HTML-to-Markdown 库处理,代价是增加打包依赖。文章没有给出两者的质量、体积或耗时对比。
还要检查代码块保真度:原文同时提到 pre 围栏和空白折叠,却没有展示如何保护代码里的缩进与换行。迁移到技术文档站之前,这一点需要验证。
能拿来做什么
- 如果你的静态落地页已经通过 Cloudflare Worker 提供资源,可以借鉴这套请求判断,只转换成功的 HTML
GET,并验证q=0和非 HTML 资源的行为。 - 给同一 URL 增加 Markdown 表示时,把
Vary: Accept、旧 ETag 和内容相关响应头一起检查,分别验证浏览器请求与 agent 请求。 - 前端页面如果依赖客户端请求补正文,先确认静态 HTML 实际包含哪些内容。这个转换器无法补齐浏览器执行 JavaScript 后才出现的信息。
- 技术文档站应先用嵌套列表、代码块和表格做样例验证,再决定采用定制转换器还是成熟库,重点检查信息有没有丢失、格式有没有改变。