乐于分享
好东西不私藏

当官方SDK摆烂,我用Rust爬了整站文档

当官方SDK摆烂,我用Rust爬了整站文档

事情的起因

最近在做一个项目,需要对接一个第三方服务。按理说,官方SDK一装,API一调,齐活儿。但现实往往不按剧本走——SDK跑不通

不是编译错误那种明确的失败,而是运行时各种诡异行为:参数格式和文档对不上、返回值结构随时变、某些接口压根就是404。翻了一圈GitHub Issues,发现我不是一个人,好几个人提了同样的问题,官方的回复是”我们在评估”——翻译过来就是:别等了

项目有deadline,不能干等着。于是我用最原始的方式硬上:

在Rust项目里直接用reqwest手动拼HTTP请求,拿着浏览器DevTools一个个抓接口,把请求参数、headers、response结构全部硬编码

丑是丑了点,但能跑,先上线再说。

那段时间每天对着Network面板,感觉自己像个手工匠人,一个请求一个请求地敲。功能是实现了,但代码里到处是魔法数字和硬编码的JSON字段名,维护性为零。

终于闲下来,该收拾烂摊子了

前几天终于没那么忙了,我决定把这件事彻底解决一下。

我的想法很简单:既然SDK靠不住,那就

写个Rust爬虫把官方API文档全部下载下来,自己维护一份本地文档

有了完整的API文档,后面不管是生成类型定义、写调用封装,还是做成工具链的一部分,都有了可靠的数据源。

用Rust写爬虫这个决定没什么好纠结的——主力语言就是它,tokio的异步运行时用着顺手,serde处理JSON是家常便饭,而且爬虫这种I/O密集型任务,Rust的零开销抽象正好合适。

但这个事做起来才发现,没那么简单。

SPA:爬虫的噩梦

打开官方文档网站,第一眼我就知道麻烦来了——这是个单页应用(SPA)

传统的服务端渲染网站,每个URL对应一个完整的HTML页面,爬虫拿到URL列表,一个个请求过去,解析HTML提取内容,完事。流程清晰,技术成熟。

但SPA不一样。页面的HTML骨架几乎为空,所有内容都是JavaScript动态渲染的。URL变了但页面没刷新,路由在客户端处理。你用普通的HTTP请求拿到的,就是一个空壳子加上几大坨JS bundle。

// 满怀期待地请求文档页面let html = client    .get("https://docs.example.com/api/users")    .send()    .await?    .text()    .await?;// 结果拿到的HTML:// <html>//   <body>//     <div id="root"></div>//     <script src="/static/js/main.abc123.js"></script>//   </body>// </html>//// 内容呢?在JS里。JS执行完了才有内容。

这就意味着,

传统的reqwest + scraper路线对这种页面彻底失效

你得让浏览器真正执行那些JavaScript,等页面渲染完成,再提取内容。

爬取SPA的几条路

面对SPA爬取,通常有这么几条路可以走:

第一条:无头浏览器(Headless Browser)

Rust生态里有headless_chromechromiumoxide这类crate,底层驱动Chrome DevTools Protocol。启动一个真实的浏览器实例,加载页面、执行JS、等渲染完成,然后提取DOM内容。最稳妥,但也最慢——每个页面都要走一遍完整的浏览器渲染流程。

第二条:逆向API

打开DevTools的Network面板,观察页面加载时到底发了哪些XHR请求。很多时候,SPA的内容其实是从后端API动态获取的。如果你能找到这些内部API,直接用reqwest请求API拿JSON数据,比渲染整个页面快十倍。

第三条:SSR检测

有些号称SPA的网站,其实做了服务端渲染(SSR)或者预渲染。用reqwest直接请求试试,说不定HTML里就有内容。运气好的话这条路成本最低。

三条路我都试了。第三条,没有,纯客户端渲染。第一条,能跑,但速度太慢,而且这个文档站有几百个页面。

最终我选了第二条:逆向内部API

用Rust搭爬虫骨架

先搭基础结构。tokio做异步运行时,reqwest发请求,serde解析JSON,这个组合写爬虫最顺手:

[dependencies]tokio = { version = "1", features = ["full"] }reqwest = { version = "0.12", features = ["json"] }serde = { version = "1", features = ["derive"] }serde_json = "1"anyhow = "1"tracing = "0.1"tracing-subscriber = "0.3"tokio-stream = "0.1"futures = "0.3"

先把导航API的返回结构用serde定义出来:

#[derive(Debug, Deserialize)]struct NavResponse {    sections: Vec<NavSection>,}#[derive(Debug, Deserialize)]struct NavSection {    id: String,    title: String,    slug: String,    #[serde(default)]    children: Vec<NavSection>,}

然后是递归遍历,收集所有叶子节点——也就是每一个具体的文档页面路径:

fn collect_slugs(sections: &[NavSection], out: &mut Vec<String>) {    for section in sections {        if section.children.is_empty() {            out.push(section.slug.clone());        } else {            collect_slugs(&section.children, out);        }    }}

这部分AI写得很快,serde的derive宏配合递归遍历,没什么心智负担。

逆向的过程

说实话,这个环节是整个事情里最有技术含量的部分,也是AI单独搞不定的部分。

打开Network面板,过滤XHR请求,开始翻。文档页面加载时会请求一个内部API,返回的数据结构长这样:

{  "sections": [    {      "id": "api-users",      "title": "Users API",      "slug": "/api/users",      "children": [        { "id": "get-users", "title": "List Users", "slug": "/api/users/list" },        { "id": "create-user", "title": "Create User", "slug": "/api/users/create" }      ]    }  ]}

好家伙,这是个嵌套的树形结构,说明文档的导航是动态生成的。找到了这个API,就等于拿到了整个文档的目录树。

但光有目录不够。每个文档页面的具体内容(参数说明、返回值示例、代码片段)藏在另一个API里:

GET /api/v2/docs/api/users/list

这个API返回的是结构化的JSON,包含该页面完整的文档内容。

这就是整个爬取的关键突破口:找到内部API的结构和规律。

找到规律之后,核心爬取逻辑就很清晰了:

async fn crawl_all_docs(client: &Client, base_url: &str) -> Result<Vec<DocPage>> {    // 第一步:请求导航API,拿到目录树    let nav: NavResponse = client        .get(&format!("{}/api/nav", base_url))        .send()        .await?        .json()        .await?;    // 第二步:递归收集所有文档路径    let mut slugs = Vec::new();    collect_slugs(&nav.sections, &mut slugs);    // 第三步:并发请求每个文档页面的内容    let mut pages = Vec::new();    for slug in slugs {        let page = fetch_doc_content(client, base_url, &slug).await?;        pages.push(page);        // 500ms间隔,避免触发频率限制        tokio::time::sleep(Duration::from_millis(500)).await;    }    Ok(pages)}

AI在这里的角色

这个过程里,AI做了什么?大量的执行工作

但关键的设计决策——走哪条技术路线、从哪里逆向、API结构怎么解析——这些是需要人来判断的。原因很简单:

  • • AI不知道这个网站的技术栈。它看不到浏览器DevTools,无法自己做逆向分析。你得告诉它”这个站用了什么框架,内部API长什么样”。
  • • 反爬策略需要经验来应对。请求频率多高会触发限制?哪些header是必须的?Cookie怎么维护?这些是”一次性”的领域知识,AI没有上下文。
  • • Rust的编译器会帮你挡住一部分问题,但数据结构的设计还是得人来定。比如NavSection的children是嵌套还是扁平?API返回的时间格式是字符串还是数字?这些得看了真实数据才知道。

我的工作模式是这样的:

我:我发现这个文档站的导航数据在这个API里,    返回的是树形结构,每个节点有id/title/slug/children。    用serde derive定义结构体,帮我写个递归遍历,    把所有叶子节点的slug收集到Vec<String>里。AI:(输出struct定义 + 递归函数,一把过)我:好,现在用reqwest对每个slug请求内容API,    返回的JSON里body字段是HTML格式的文档内容。    用scraper crate解析HTML,提取文本内容,    保留代码块(<pre><code>)的结构。AI:(输出HTML解析和文本提取代码)我:这个文档站有频率限制,200ms间隔太短会503,    加上tokio::time::sleep间隔和重试机制,    用anyhow统一错误处理,最多重试3次。AI:(加入sleep和重试逻辑)

我提供方向和约束,AI负责把想法变成Rust代码。

说实话,这种模式下Rust的优势很明显——编译器是第二道关卡,AI生成的代码如果类型不对或生命周期有问题,直接编译不通过,不用等到运行时才发现。

一次典型的踩坑

爬取过程中有个小插曲。大部分页面都顺利拿到了,但有一批接口文档的页面始终返回空内容。

我重新打开DevTools对比正常页面和异常页面的网络请求,发现了一个细节:

这些页面的内容不是一次性加载的,而是做了懒加载

—— 页面先加载骨架,用户滚动到某个区域时才触发内容请求,而且这个请求带了特殊的参数,是从页面JS上下文中动态生成的。

这意味着不能简单地请求内容API,得先加载页面,从JS上下文中提取那个动态参数,再用这个参数请求真正的内容。

这种场景,纯靠AI是想不出来的。你得自己打开DevTools,对比请求差异,发现隐藏的参数。然后把发现告诉AI,让它来处理”提取动态参数”这个执行层面的活。

对于这类懒加载页面,最终我用了chromiumoxide——Rust里通过CDP协议控制Chrome的crate:

// 这类页面的内容API需要动态token// token藏在前端JS的全局变量里// 得先用headless browser加载页面,提取token,再请求内容async fn scrape_lazy_page(    browser: &Browser,    page_url: &str,    client: &Client,    content_base: &str,) -> Result<String> {    // 第一步:用headless browser加载页面    let page = browser.new_page("about:blank").await?;    page.goto(page_url).await?;    page.wait_for_navigation_response().await?;    // 第二步:从前端上下文提取动态token    let token: String = page        .evaluate("window.__docs_config__.token")        .await?        .into_value()?;    // 第三步:用token请求真正的文档内容    let content = client        .get(&format!("{}{}", content_base, page_url))        .header("X-Docs-Token", token)        .send()        .await?        .text()        .await?;    page.close().await?;    Ok(content)}

这大概占了整体文档20%的页面,但也是耗时最多的部分。最后我采用了混合策略:普通页面走reqwest API直连,懒加载页面走chromiumoxide headless browser。速度和覆盖率的平衡点。

用Rust做这种混合爬虫有个好处——tokio的异步运行时让HTTP请求和浏览器操作可以自然地混在同一个async函数里,不需要像Python那样用asyncio去协调多套异步体系。

结果

最终跑完了全部API文档,几百个页面,包括参数说明、返回值结构、示例代码,一个不落。整个过程从分析到完成,大概花了大半天。

编译后的二进制文件跑起来飞快,几百个页面几分钟就全部爬完了。这就是Rust写爬虫的体感:

开发阶段跟编译器搏斗的时间不少,但跑起来之后就是”真香”

没有GIL的限制,没有解释器的开销,I/O密集型任务并发跑满。

拿到原始数据之后,我做了几件事:

  1. 1. 统一格式。不同页面的文档格式有细微差异(有的用Markdown,有的混着HTML),全部转成统一的Markdown格式。HTML转Markdown这一步也用Rust处理,直接操作DOM树做文本提取。
  2. 2. 建立索引。按API分组,建立目录索引,序列化成JSON方便后续检索。
  3. 3. 去重和校验。有些页面在不同入口重复出现,用HashSet做去重;部分页面内容不完整,标记出来后续补充。

下一步:把文档变成Skills

有了这些结构化的文档数据,接下来的计划是把它们转化为Skills——可被AI工具直接调用的知识模块。

这是什么意思呢?简单说,就是当我后续在项目里需要调用某个API时,不再需要打开浏览器翻文档,也不需要靠记忆猜测参数格式。AI工具可以直接从Skills里获取最准确的API定义、参数说明和使用示例。

没有Skills之前:  我:帮我调用这个接口创建用户  AI:我需要看一下文档...(打开网站翻半天)...参数应该是这样  我:不对,那个字段是必填的  AI:好的我改一下...有了Skills之后:  我:帮我调用这个接口创建用户  AI:(直接从Skill里拿到API定义)      根据文档,这个接口需要name(必填)、email(必填)、      role(可选,默认"user"),返回201和user对象  我:对着文档说的,没问题

Skills本质上就是把文档从”给人看的”变成”给AI用的”。

人的阅读理解和AI的知识检索是两种不同的信息消费方式。文档写得再好,AI还是得靠自然语言理解去猜;Skills把API的参数、类型、约束直接结构化地喂给AI,准确度和效率都上了一个台阶。

这也是我现在越来越认同的一个思路:

与其等官方把SDK修好,不如自己把基础设施搞定

官方SDK是他们的节奏,文档爬取和Skills转化是我的节奏。后者虽然前期多花点时间,但长期可控性更强。

最后分享几点体会

Rust写爬虫,编译器是最好的队友

serde的反序列化、reqwest的类型安全response、anyhow的错误传播——每一步都有编译器帮你把关。AI生成的代码能不能用,cargo check一下就知道,这种确定性在写爬虫这种”一次性”工具时格外有价值。

爬虫经验是会积累的

这次处理SPA的各种套路——逆向API、混合策略、懒加载处理——都是之前踩坑积累下来的。技术热点年年换,但有些基本功永远不会过时。

AI是放大器,不是替代品

它放大的是你的经验和判断力。你知道该往哪个方向走,AI帮你走得更远。但如果你自己也不知道方向,AI只会带你更快地迷路。Rust的强类型系统在这里反而成了优势——AI生成的代码合不合理,类型签名先帮你筛掉一大半。

把一次性工作变成资产

爬文档这件事本身是一次性的,但转成Skills之后就变成了可复用的资产。这种”一次投入、长期受益”的事情,值得花时间做。而Rust编译后的二进制文件随时可以拿出来重跑,不用担心环境依赖的问题。