昨天在 Cloudflare 控制台和阿里云 ESA 边缘加速都看到了「将网页转换为 Markdown 文本」的功能,但二者均需升级到付费套餐才能开启。这里引用 Cloudflare 博客的几段话,说明 Markdown 的重要性:
为何 Markdown 很重要
将原始 HTML 提供给 AI,就像是按字数付费阅读包装,而不是里面的字母。在 Markdown 页面中添加一个简单的
## About Us 大约消耗 3 个令牌;而它对应的 HTML 代码 <h2 class="section-title" id="about">About Us</h2> 会占用 12-15 个令牌,这还不包括填充每个真实网页但毫无语义价值的 <div> 包装器、导航栏和脚本标签。您正在阅读的这篇博客文章在 HTML 中占用 16,180 个令牌,而转换为 Markdown 后仅使用 3,150 个令牌。这相当于令牌使用量减少了 80% 。
Markdown 已迅速成为智能体和整个 AI 系统的通用语言。格式清晰的结构使其非常适合 AI 处理,最终带来更好的结果,同时最大限度地减少令牌浪费。
问题在于,Web 是由 HTML 而不是 Markdown 构成,而且页面大小多年来一直在稳步增长,导致页面难以解析。智能体的目标是过滤掉所有非必要元素,并扫描相关内容。
如今,将 HTML 转换为 Markdown 是任何 AI 管道的常见步骤。不过,这个流程不尽如人意:它会浪费计算资源,增加成本和处理复杂度,最重要的是,这可能并不是内容创作者最初预期的使用方式。
一、本站内容本来就是 Markdown
由于本站内容在编写阶段就是 Markdown,所以并不需要做「HTML → Markdown」的转换。站点适配只需要在收到请求后,把微调过的 Markdown 文本(加上精简后的元数据等)直接返回即可。
于是问题变成了:如何在 Leptos 中接管文章路由,并返回符合标准的自定义响应。
这里有一个架构细节:我的文章组件是放在 HomeOutlet 里的。这意味着如果直接在 Post 组件里把内容改成 Markdown 文本,返回时会连带着站点的 Header 和 Footer 一起输出——这不是我们想要的结果。
所以最初的思路是:在 Leptos 服务端,当访问路径是文章并在Acceot请求头中存在text/markdown时,就跳过组件构建渲染,直接覆盖抢答整个响应。
二、在 Leptos 中做的的尝试
翻了一遍官方文档,也读了一部分 Leptos 与 Axum 的耦合库,发现 Leptos 只公开了 ResponseParts 和 ResponseOptions 两个结构体:
pub struct ResponseOptions(pub Arc<RwLock<ResponseParts>>);
// 这个结构体允许你在组件或 Server Function 中设置响应头、覆盖响应状态码。
// 通常放在 ResponseOptions 里,常用于设置 Cookie 或自定义响应。
pub struct ResponseParts {
pub status: Option<StatusCode>,
pub headers: HeaderMap,
}
也就是说,Leptos 只允许修改状态码和响应头,无法接管、覆盖整个响应体即Response<Body>(当然,这肯定不是 Leptos 的缺陷,而是框架的普遍设计。)。即使通过 #[cfg(feature = "ssr")] 强制把返回内容改成纯 Markdown,到了客户端渲染阶段也一定会因为 hydration 不匹配而 panic。
三、用 Axum 中间件按需接管 Markdown 响应
既然在 Leptos 框架层面无法实现,那能不能在更上层的 Axum 里做?
判断标准其实很清晰:请求头 Accept 中包含 text/markdown,就说明这个请求希望得到 Markdown 纯文本。再确认这个 URI 是合法的文章路径,就可以在 Axum 中间件层抢先于 Leptos 把数据库里的 Markdown 文本作为响应返回。
Axum 的 middleware::from_fn 正好能做这件事:它在 Leptos 路由之前执行,并且可以自由选择是「抢答」还是「放行给下一层」。
axum::middleware::from_fn pub fn from_fn<F, T>(f: F) -> FromFnLayer<F, (), T>
F = fn guard(Request<Body>, …) -> …, T = (Request<Body>,)Create a middleware from an async function.
from_fnrequires the function given to
- Be an
async fn.- Take zero or more
FromRequestPartsextractors.- Take exactly one
FromRequest extractor as the second to last argument.- Take
Next as the last argument.- Return something that implements
IntoResponse.Note that this function doesn’t support extracting
State. For that, use from_fn_with_state.Example
use axum::{ Router, http, routing::get, response::Response, middleware::{self, Next}, extract::Request, }; async fn my_middleware( request: Request, next: Next, ) -> Response { // do something with `request`... let response = next.run(request).await; // do something with `response`... response } let app = Router::new() .route("/", get(|| async { /* ... */ })) .layer(middleware::from_fn(my_middleware));Running extractors
use axum::{ Router, extract::Request, http::{StatusCode, HeaderMap}, middleware::{self, Next}, response::Response, routing::get, }; async fn auth( // run the `HeaderMap` extractor headers: HeaderMap, // you can also add more extractors here but the last // extractor must implement `FromRequest` which // `Request` does request: Request, next: Next, ) -> Result<Response, StatusCode> { match get_token(&headers) { Some(token) if token_is_valid(token) => { let response = next.run(request).await; Ok(response) } _ => { Err(StatusCode::UNAUTHORIZED) } } } fn get_token(headers: &HeaderMap) -> Option<&str> { // ... } fn token_is_valid(token: &str) -> bool { // ... } let app = Router::new() .route("/", get(|| async { /* ... */ })) .route_layer(middleware::from_fn(auth));
四、实现:中间件与路由集成
4.1 中间件代码
pub async fn accept_markdown_example(mut req: Request<Body>, next: Next) -> Response {
if req
.headers()
.get(HeaderName::from_static("accept"))
.and_then(|h| h.to_str().ok())
.is_some_and(|v| v.contains("text/markdown"))
{
todo!("在这里先校验 URI 是否为合法文章路径,再从数据库读取对应的 Markdown 文本;如果为非法请求则交给Leptos处理。");
let markdown = "### I am markdown content.";
let length = markdown.len().to_string();
let mut rep = Response::new(markdown.into());
let rep_header = rep.headers_mut();
rep_header.append(
ContentType::name(),
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
// Cloudflare 推进的 content-signal字段。
rep_header.append(
HeaderName::from_static("content-signal"),
HeaderValue::from_static("ai-train=yes, search=yes, ai-input=yes"),
);
if let Ok(length) = HeaderValue::from_str(&length) {
rep_header.append(HeaderName::from_static("content-length"), length);
}
return rep;
} else {
next.run(req).await
}
}
4.2 main 函数集成
#[tokio::main]
async fn main() {
...
let app = Router::new()
...
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback_service(leptos_axum::site_pkg_dir_service(&leptos_options).fallback(
leptos_axum::ErrorHandler::new(shell, leptos_options.clone()),
))
.with_state(leptos_options)
.layer(from_fn(changjiu::markdown_response::accept_markdown));
let app: tower_http::normalize_path::NormalizePath<Router> =
tower_http::normalize_path::NormalizePathLayer::trim_trailing_slash().layer(app);
info!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(
listener,
ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(app),
)
.await
.unwrap();
}
五、验证:用 curl 请求 Markdown 版本
在终端里对本篇文章发一个请求,就能看到它同时具备两种表示:
# 只看响应头(-I 即 HEAD 请求)
curl -sI "https://www.changjiu365.cn/tutorial/rust/leptos-axum-accept-header-markdown" \
-H "Accept: text/markdown"
# 连同响应体一起取回
curl -s "https://www.changjiu365.cn/tutorial/rust/leptos-axum-accept-header-markdown" \
-H "Accept: text/markdown"
响应头:
HTTP/2 200
content-type: text/markdown; charset=utf-8
content-length: xxx
content-signal: ai-train=yes, search=yes, ai-input=yes
响应体则是文章本身的 Markdown,外加一段精简的 Front Matter:
+++
title = "在 Leptos + Axum 中基于 Accept 头返回 Markdown"
...
+++
# 在 Leptos + Axum 中基于 Accept 头返回 Markdown
昨天在 Cloudflare 控制台和阿里云 ESA 边缘加速都看到了「将网页转换为 Markdown 文本」的功能,但二者均需升级到付费套餐才能开启。这里引用 Cloudflare 博客的几段话,说明 Markdown 的重要性:
... ...
作为对照,把 -H "Accept: text/markdown" 去掉,同一个 URL 返回的就是完整的 HTML 源码。如果 Accept 里没有 text/markdown、或者 URI 不是合法文章路径,中间件会直接 next.run(req).await 放行给 Leptos,行为与改动前完全一致。