跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
Rust

Rust 实战:异步爬取 GitHub Trending 热门仓库

本项目利用 Rust 的 reqwest、scraper 和 tokio 库构建异步爬虫,用于抓取 GitHub Trending 页面的热门 Rust 仓库信息。代码涵盖 HTTP 请求、HTML 解析、数据序列化及文件保存流程,采用语义化 CSS 选择器增强稳定性,并配合 anyhow 进行错误处理。最终将作者、名称、描述、星标等关键数据存储为 JSON 文件,适合学习 Rust 网络编程与数据解析实战。

清酒独酌发布于 2026/3/27更新于 2026/4/263 浏览
Rust 实战:异步爬取 GitHub Trending 热门仓库

Rust 实现 GitHub Trending 爬虫

本次实战将使用 Rust 构建一个异步爬虫,目标是抓取 GitHub Trending 页面中热门 Rust 仓库的详细信息(包括仓库名、描述、星标数、作者等),并将结果输出为 JSON 文件。代码重点优化了错误处理容错性和 CSS 选择器的稳定性。

技术栈

  • HTTP 请求:reqwest(Rust 最流行的 HTTP 客户端,支持异步)
  • HTML 解析:scraper(基于 selectors 库,支持 CSS 选择器,轻量高效)
  • JSON 序列化:serde + serde_json(Rust 标准的序列化 / 反序列化库)
  • 异步运行时:tokio(Rust 异步编程的事实标准)
  • 日志:env_logger + log(简单的日志输出,方便调试)
  • 错误处理:anyhow(简化错误传递,无需手动定义复杂错误类型)

项目结构

github-trending-crawler/
├── Cargo.toml      # 依赖配置
├── src/
│   └── main.rs     # 核心逻辑
└── trending_repos.json # 输出结果文件(运行后生成)

文章配图

初始化项目环境

创建项目
cargo new github-trending-crawler
cd github-trending-crawler
配置依赖

在 Cargo.toml 中添加必要的依赖项。版本选择上建议参考 crates.io 查询最新稳定版:

[package]
name = "github-trending-crawler"
version = "0.1.0"
edition = "2021"
description = "A crawler to fetch GitHub Trending Rust repositories"
license = "MIT"

[dependencies]
# HTTP 客户端(异步)
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
# HTML 解析(CSS 选择器)
scraper = "0.18"
# JSON 序列化/反序列化
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 异步运行时
tokio = { version = "1.0", features = ["full"] }
# 日志
log = "0.4"
env_logger = "0.10"
# 错误处理(可选,简化错误传递)
anyhow = "1.0"

文章配图

编写核心爬取逻辑

导入依赖和初始化日志

首先引入必要的模块并设置日志级别,这样在运行时能打印出关键的调试信息:

use anyhow::{Context, Result};
use log::info;
use reqwest::Client;
use scraper::{Html, Selector};
use serde::Serialize;
use std::fs::File;
use std::path::Path;

// 初始化日志(运行时打印调试信息)
fn init_logger() {
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
}
定义数据结构

我们需要定义一个结构体来存储仓库信息,并实现 Serialize trait 以便后续转为 JSON。字段设计需与 GitHub Trending 页面的数据一一对应:

#[derive(Debug, Serialize)]
struct GithubRepo {
    // 作者/组织名
    author: String,
    // 仓库名
    name: String,
    // 仓库描述
    description: Option<String>,
    // 星标数
    stars: String,
    // 分支数
    forks: String,
    // 今日新增星标
    today_stars: String,
    // 仓库链接
    url: String,
}
核心爬虫逻辑
构建 HTTP 客户端并请求页面

这里需要注意 User-Agent 的伪装,避免被 GitHub 拦截。同时保留详细的错误上下文,方便排查网络问题:

async fn fetch_trending_page(client: &Client) -> Result<String> {
    // GitHub Trending Rust 页面 URL(按今日热门排序)
    let url = "https://github.com/trending/rust?since=daily";
    info!("Fetching page: {}", url);

    // 发送 GET 请求(设置 User-Agent 模拟浏览器,避免被 GitHub 拦截)
    let response = client
        .get(url)
        .header(
            "User-Agent",
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        )
        .send()
        .await
        .context(format!("Failed to request URL: {}", url))?;

    // 检查响应状态码(200-299 为成功状态)
    if !response.status().is_success() {
        return Err(anyhow::anyhow!("Request failed with status: {}", response.status()));
    }

    // 读取响应体(HTML 字符串),并记录页面大小
    let html = response.text().await.context("Failed to read response body")?;
    info!("Successfully fetched page (size: {} bytes)", html.len());
    Ok(html)
}
解析 HTML 提取仓库信息

这部分是爬虫的核心。我们做了几个关键优化:

  1. CSS 选择器容错:使用 map_err 捕获选择器解析错误,直接显示详情。
  2. 语义化选择器:不再依赖易变的类名,而是基于 href 后缀(如 /stargazers)或特定属性(如 data-menu-button-text)定位元素。
  3. 缺失值处理:使用 unwrap_or_else 给缺失的星标或分支设置默认值 "0",防止程序崩溃。
fn parse_repos(html: &str) -> Result<Vec<GithubRepo>> {
    info!("Starting to parse repositories...");
    let document = Html::parse_document(html);

    // 定义 CSS 选择器(优化后:基于语义化属性,降低页面样式变更影响)
    // 1. 每个仓库的根节点选择器(GitHub 仓库列表统一用 article.Box-row 包裹)
    let repo_selector = Selector::parse("article.Box-row")
        .map_err(|e| anyhow::anyhow!("Failed to parse repo selector: {}", e))?;

    // 2. 作者 + 仓库名选择器(h2 下的 a 标签,包含仓库路径)
    let author_name_selector = Selector::parse("h2 a")
        .map_err(|e| anyhow::anyhow!("Failed to parse author-name selector: {}", e))?;

    // 3. 仓库描述选择器(通用 p 标签,避免依赖特定类名)
    let desc_selector = Selector::parse("p")
        .map_err(|e| anyhow::anyhow!("Failed to parse description selector: {}", e))?;

    // 4. 星标数选择器(基于 href 后缀 /stargazers,语义化更强)
    let stars_selector = Selector::parse("a[href$='/stargazers']")
        .map_err(|e| anyhow::anyhow!("Failed to parse stars selector: {}", e))?;

    // 5. 分支数选择器(基于 href 后缀 /forks,语义化更强)
    let forks_selector = Selector::parse("a[href$='/forks']")
        .map_err(|e| anyhow::anyhow!("Failed to parse forks selector: {}", e))?;

    // 6. 今日新增星标选择器(基于 data-menu-button-text 属性,稳定性更高)
    let today_stars_selector = Selector::parse("span[data-menu-button-text]")
        .map_err(|e| anyhow::anyhow!("Failed to parse today-stars selector: {}", e))?;

    let mut repos = Vec::new();

    // 遍历每个仓库节点,提取信息
    for repo_node in document.select(&repo_selector) {
        // 1. 提取作者和仓库名(格式:"author/name",从 a 标签文本中解析)
        let author_name_element = repo_node
            .select(&author_name_selector)
            .next()
            .context("Missing author/name element (GitHub page structure may have changed)")?;

        // 清理文本(去除空格和换行符)
        let author_name_text = author_name_element
            .text()
            .collect::<String>()
            .trim()
            .to_string();

        // 分割作者和仓库名(格式必须为 "author/name",否则报错)
        let (author, name) = author_name_text
            .split_once('/')
            .context(format!("Invalid author/name format: '{}' (expected 'author/name')", author_name_text))?;
        let author = author.trim().to_string();
        let name = name.trim().to_string();

        // 2. 提取仓库完整链接(补全 GitHub 域名,a 标签的 href 属性为相对路径)
        let url = author_name_element
            .value()
            .attr("href")
            .context("Missing href attribute for repo link")?
            .to_string();
        let url = format!("https://github.com{}", url); // 拼接完整 URL

        // 3. 提取仓库描述(可选,无描述时为 None,避免强制 unwrap 导致 panic)
        let description = repo_node
            .select(&desc_selector)
            .next()
            .map(|elem| elem.text().collect::<String>().trim().to_string());

        // 4. 提取星标数(缺失时默认值为 "0",容错性优化)
        let stars = repo_node
            .select(&stars_selector)
            .next()
            .map(|elem| elem.text().collect::<String>().trim().to_string())
            .unwrap_or_else(|| "0".to_string());

        // 5. 提取分支数(缺失时默认值为 "0",容错性优化)
        let forks = repo_node
            .select(&forks_selector)
            .next()
            .map(|elem| elem.text().collect::<String>().trim().to_string())
            .unwrap_or_else(|| "0".to_string());

        // 6. 提取今日新增星标(缺失时默认值为 "0",容错性优化)
        let today_stars = repo_node
            .select(&today_stars_selector)
            .next()
            .map(|elem| elem.text().collect::<String>().trim().to_string())
            .unwrap_or_else(|| "0".to_string());

        // 构建仓库对象并添加到列表
        repos.push(GithubRepo {
            author,
            name,
            description,
            stars,
            forks,
            today_stars,
            url,
        });
    }

    info!("Successfully parsed {} repositories", repos.len());
    Ok(repos)
}
保存结果到 JSON 文件

将解析后的仓库列表序列化为格式化的 JSON(pretty 模式),便于阅读和后续处理:

fn save_repos_to_json(repos: &[GithubRepo], path: &str) -> Result<()> {
    info!("Saving repositories to JSON file: {}", path);
    // 创建文件(若已存在会覆盖)
    let file = File::create(Path::new(path))
        .context(format!("Failed to create file: {} (check directory permissions)", path))?;
    // 序列化并写入文件(pretty 模式:缩进格式化,可读性强)
    serde_json::to_writer_pretty(file, repos)
        .context("Failed to serialize repos to JSON (invalid data format)")?;
    info!("Successfully saved {} repos to {}", repos.len(), path);
    Ok(())
}
主函数入口

使用 tokio 异步运行时编排流程:请求页面 → 解析信息 → 保存结果。同时设置合理的超时时间,防止网络异常导致程序卡死:

#[tokio::main]
async fn main() -> Result<()> {
    // 初始化日志(程序启动时执行)
    init_logger();
    info!("Starting GitHub Trending Rust Crawler...");

    // 创建 HTTP 客户端(设置超时,避免网络问题导致程序卡死)
    let client = Client::builder()
        .connect_timeout(std::time::Duration::from_secs(10)) // 连接超时:10 秒
        .timeout(std::time::Duration::from_secs(15)) // 响应超时:15 秒
        .build()
        .context("Failed to create HTTP client (check network or dependencies)")?;

    // 1. 爬取 GitHub Trending 页面 HTML
    let html = fetch_trending_page(&client).await?;

    // 2. 解析 HTML,提取仓库信息
    let repos = parse_repos(&html)?;

    // 3. 将结果保存到 JSON 文件(项目根目录下的 trending_repos.json)
    save_repos_to_json(&repos, "trending_repos.json")?;

    info!("Crawler finished successfully! Check 'trending_repos.json' for results.");
    Ok(())
}

文章配图

运行与验证

运行爬虫

直接在终端执行以下命令即可启动:

# 直接运行(默认输出 info 级别日志)
cargo run

# (可选)输出 debug 级别日志(查看更详细的执行过程,便于调试)
RUST_LOG=debug cargo run

文章配图

验证结果

运行成功后,项目根目录下会生成 trending_repos.json 文件。优化后的结果示例展示了容错性提升后的数据完整性:

文章配图

[
  {
    "author": "YaLTeR",
    "name": "niri",
    "description": "A scrollable-tiling Wayland compositor.",
    "stars": "14,823",
    "forks": "523",
    "today_stars": "0",
    "url": "https://github.com/YaLTeR/niri"
  },
  {
    "author": "librespot-org",
    "name": "librespot",
    "description": "Open Source Spotify client library",
    "stars": "6,131",
    "forks": "773",
    "today_stars": "0",
    "url": "https://github.com/librespot-org/librespot"
  }
]

总结

本项目利用 Rust 的 reqwest、scraper 和 tokio 库构建了高效的异步爬虫流程。相较于初始版本,优化后的代码在 CSS 选择器上采用了语义化属性(如基于 href 后缀、数据属性),降低了 GitHub 页面样式变更带来的维护成本;在错误处理上,通过 map_err 明确选择器解析错误、unwrap_or_else 处理信息缺失场景,大幅提升了程序容错性。最终实现了稳定抓取每日热门 Rust 仓库的关键信息并以 JSON 格式存储。

目录

  1. Rust 实现 GitHub Trending 爬虫
  2. 技术栈
  3. 项目结构
  4. 初始化项目环境
  5. 创建项目
  6. 配置依赖
  7. HTTP 客户端(异步)
  8. HTML 解析(CSS 选择器)
  9. JSON 序列化/反序列化
  10. 异步运行时
  11. 日志
  12. 错误处理(可选,简化错误传递)
  13. 编写核心爬取逻辑
  14. 导入依赖和初始化日志
  15. 定义数据结构
  16. 核心爬虫逻辑
  17. 构建 HTTP 客户端并请求页面
  18. 解析 HTML 提取仓库信息
  19. 保存结果到 JSON 文件
  20. 主函数入口
  21. 运行与验证
  22. 运行爬虫
  23. 直接运行(默认输出 info 级别日志)
  24. (可选)输出 debug 级别日志(查看更详细的执行过程,便于调试)
  25. 验证结果
  26. 总结
  • 💰 8折买阿里云服务器限时8折了解详情
  • 💰 8折买阿里云服务器限时8折购买
  • 🦞 5分钟部署阿里云小龙虾了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • GitHub Copilot 学生认证指南:免费获取 Pro 版权限
  • Notepad++ 直连 SAP SE80 提升 BSP/WebClient UI 视图维护效率
  • OpenClaw.ai:Agentic AI 时代的 Spring Framework 时刻
  • PyApp:将 Python 工程打包为可执行文件的简易方法
  • Ollama 本地部署 DeepSeek-R1-Distill-Llama-8B 实战
  • 实证分析系列:稳健性检验的常用方法与 Python 实现
  • 分布式光纤声波传感(DAS)无人机入侵探测技术与应用
  • Linux 下 OpenClaw 安装、初始化及 Web UI 配置指南
  • FastAPI 实现 Python 前后端交互:用户登录注册与信息查看
  • TwinRL-VLA:数字孪生驱动的机器人强化学习与现实应用
  • 哈希表经典算法题整理
  • AI 无人机智慧巡检平台:20+ 场景识别与全流程调度方案
  • C++ 动态规划:调手表问题求解
  • Metric3D v2: 零样本单目度量深度与表面法线估计几何基础模型
  • 法律领域自然语言处理(NLP)应用与实战
  • Webots 2025a 与 ROS 2 Jazzy e-puck 机器人集成教程
  • llama.cpp 大模型部署指南:CPU/GPU 兼容与 Docker 快速启动
  • 前端核心知识点与面试指南
  • QClaw 上手指南:OpenClaw 桌面端封装与微信直联体验
  • 基于原生 JavaScript 实现记忆翻转卡牌游戏

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online