Spider Rust 实战:构建站点页面与资源清单

使用 Spider 的页面订阅流保存 HTML 和 JSONL 清单,记录状态与页面声明的资源链接,理解抓取范围、消费滞后和任务结束。

亿牛云技术团队2026年10月28日6 分钟阅读

做文档迁移或站点盘点时,你需要的不只是一个 URL 集合,还需要知道每个地址返回什么状态、保存在哪个文件,以及页面引用了哪些资源。Spider 可以把页面结果以订阅方式交给 Rust 程序,边遍历边整理清单。

本文建立一轮最多二十页、深度受限的盘点,保存 HTML 与 JSONL,额外提取页面声明的图片、脚本和样式链接。它不下载这些资源,也不把二十页结果当作整站完整镜像。

准备输入地址

先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。

  • BOOKS_LIST_URL:Books to Scrape 首页地址。

在执行后续命令的同一个 Bash/WSL 终端中设置:

export BOOKS_LIST_URL='<paste-the-actual-books-list-url>'

下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。

创建项目与依赖

准备稳定版 Rust/Cargo,按照 Rust 安装说明 建好环境。执行:

cargo new site-inventory
cd site-inventory
cargo add spider@=2.52.4 --features sync
cargo add serde_json scraper url

保留 Cargo.lock。Spider 在本文中负责遍历与页面订阅,scraper 解析已取得的 HTML,url 负责相对地址解析。不要把不同语言的 Spider SDK 示例拼进 Rust API。接口可查 Spider 文档。

先确定清单范围

示例从 Books to Scrape 开始,限制深度一、页面预算二十。深度不是分类数,页面预算也不是商品数;入口上的分类链接和详情链接都可能被发现。

本轮清单说明“在这组配置下实际取得的页面”,不是证明站点总共只有这些页面。正式盘点应保存入口、范围与预算,并对未遍历范围给出解释。

完整的订阅与保存代码

将 src/main.rs 替换为:

use std::{collections::BTreeSet, error::Error, fs, io::Write};
use scraper::{Html, Selector};
use serde_json::json;
use spider::{tokio, traits::PageData, website::Website};
use url::Url;
 
type AnyError = Box<dyn Error + Send + Sync>;
 
#[tokio::main]
async fn main() -> Result<(), AnyError> {
    fs::create_dir("output")?;
    fs::create_dir("output/pages")?;
    let entry = std::env::var("BOOKS_LIST_URL")?;
    let mut site = Website::new(&entry);
    site.with_limit(20).with_depth(1).with_delay(500);
    site.with_respect_robots_txt(true);
    let mut pages = site.subscribe(64);
 
    let consumer = tokio::spawn(async move {
        let selector = Selector::parse("img[src], script[src], link[href]")
            .map_err(|_| "invalid resource selector")?;
        let mut index = fs::File::create("output/inventory.jsonl.part")?;
        let mut count = 0usize;
        loop {
            let page = match pages.recv().await {
                Ok(page) => page,
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    return Err::<usize, AnyError>(
                        format!("page consumer missed {n} messages").into()
                    );
                }
            };
            let source = page.get_url().to_string();
            let html = page.get_html();
            let path = format!("output/pages/{count:04}.html");
            fs::write(&path, &html)?;
            let base = Url::parse(&source)?;
            let document = Html::parse_document(&html);
            let mut resources = BTreeSet::new();
            for element in document.select(&selector) {
                let value = element.value().attr("src")
                    .or_else(|| element.value().attr("href"));
                if let Some(value) = value {
                    if let Ok(resolved) = base.join(value) {
                        if matches!(resolved.scheme(), "http" | "https") {
                            resources.insert(resolved.to_string());
                        }
                    }
                }
            }
            let record = json!({
                "url": source,
                "status": page.status_code().to_string(),
                "html_file": path,
                "html_bytes": html.len(),
                "declared_resource_urls": resources,
            });
            writeln!(index, "{record}")?;
            count += 1;
        }
        index.flush()?;
        Ok::<usize, AnyError>(count)
    });
 
    site.crawl().await;
    site.unsubscribe();
    let count = consumer.await??;
    fs::rename("output/inventory.jsonl.part", "output/inventory.jsonl")?;
    println!("saved_pages={count}");
    Ok(())
}

运行:

cargo run --release

output 必须是新目录;重复运行时为新批次使用另一个目录或先移动旧结果。代码不自动删除旧盘点,避免一次失败覆盖先前材料。

为什么先订阅,再 crawl,最后 unsubscribe

先建立订阅才能接收本轮页面。若等 crawl 完成后才订阅,不应期待补回全部历史事件。

遍历与消费并行进行。订阅缓冲不是无限容量;消费者落后时会收到 Lagged。示例明确报错并保留 .part 文件,不把“循环结束”误认为完整成功。这比用 while let Ok 静默退出更容易发现丢失。

crawl 返回后解除订阅,让消费端能结束,再等待其写入完成,最后把索引改为正式文件名。遍历完成、消息消费完成和文件写入完成是不同阶段。

queue 用于追加 URL,与订阅页面结果不是同一个通道。本文没有混入额外队列生产者,因此不用把“丢弃一个 sender”当成遍历终止条件。

怎样读页面与资源字段

JSONL 每行一条页面记录,示例结构如下:

{"url":"https://example.16yun.cn/docs/","status":"200 OK","html_file":"output/pages/0000.html","html_bytes":1234,"declared_resource_urls":["https://example.16yun.cn/style.css"]}

数值与状态文本是示意,实际状态的字符串表示以库返回为准。HTML 的字节数不是可读正文字数,也不是网络传输压缩大小。

资源清单来自 img、script、link 元素,不包含 CSS 内部引用、JavaScript 动态发起的全部请求或浏览器实际加载成功状态。重复 URL 在单页内去重;跨页共享资源仍会出现在各自记录中,便于保留关联。

如果需要验证每个资源是否存在,另建受限下载检查并记录响应,而不是把声明链接标成“下载成功”。如果需要完整浏览器网络清单,则应选择相应的浏览器观测路径。

状态、范围与动态页面怎样处理

优先检查非成功响应和空 HTML,回到保存的文件确认是否为预期页面。即使状态 200,仍可能是访问提示页,不能自动当作业务文档。

默认 HTTP 盘点不会凭空执行所有动态页面行为。需要浏览器渲染时,根据官方对应 feature 与方法重新配置,并重新考虑资源、就绪条件和预算。不能只启用某个 feature 就宣布所有 SPA 已完整盘点。

常见问题与修复

编译找不到状态方法。 本文使用 PageData trait 的 status_code,不存在这里需要的 get_status_code 方法;确认已导入 trait,并且依赖版本一致。

程序只生成 .part。 检查错误输出,可能是消费滞后、解析或磁盘写入失败。保留部分材料用于排查,不把它直接改名为完整结果。

没有二十条。 上限不是最低条数。检查范围、robots、可发现链接与响应,不能以不足预算判断库失效。

盘点少了某些资源。 判断它们是否由 CSS 或运行时生成;当前选择器只覆盖 HTML 显式声明的部分。

写入拖慢导致 Lagged。 减小抓取速度或改变消费与持久化设计。单纯无限增加缓冲只是推迟问题,需要明确内存与吞吐代价。

再次运行提示目录存在。 这是避免混批的有意限制。为新一轮准备独立输出位置,再按来源与配置比较结果。

一个可信的站点清单应同时说明已经取得的材料和范围限制。流式保存解决内存与衔接问题,范围与结果核对仍需要应用明确管理。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。