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 提取仓库信息
这部分是爬虫的核心。我们做了几个关键优化:
- CSS 选择器容错:使用
map_err捕获选择器解析错误,直接显示详情。 - 语义化选择器:不再依赖易变的类名,而是基于
href后缀(如/stargazers)或特定属性(如data-menu-button-text)定位元素。 - 缺失值处理:使用
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 格式存储。


