Rust 爬虫与数据处理实战:大规模并发抓取 + 流式处理

前言

💡 痛点: Python 爬虫太慢?Go 并发好但类型不够安全?内存泄漏导致爬虫崩溃?解析 HTML 一改就全崩?

🎯 解决方案: 用 Rust 构建高性能爬虫引擎,reqwest 异步 HTTP + scraper 类型安全解析 + tokio 万级并发 + Redis 去重队列 + PostgreSQL 持久化,从单机到分布式全链路。
#mermaid-svg-dFWzXcyoKCHG2sWI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dFWzXcyoKCHG2sWI .error-icon{fill:#552222;}#mermaid-svg-dFWzXcyoKCHG2sWI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dFWzXcyoKCHG2sWI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dFWzXcyoKCHG2sWI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dFWzXcyoKCHG2sWI .marker.cross{stroke:#333333;}#mermaid-svg-dFWzXcyoKCHG2sWI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dFWzXcyoKCHG2sWI p{margin:0;}#mermaid-svg-dFWzXcyoKCHG2sWI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster-label text{fill:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster-label span{color:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster-label span p{background-color:transparent;}#mermaid-svg-dFWzXcyoKCHG2sWI .label text,#mermaid-svg-dFWzXcyoKCHG2sWI span{fill:#333;color:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI .node rect,#mermaid-svg-dFWzXcyoKCHG2sWI .node circle,#mermaid-svg-dFWzXcyoKCHG2sWI .node ellipse,#mermaid-svg-dFWzXcyoKCHG2sWI .node polygon,#mermaid-svg-dFWzXcyoKCHG2sWI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dFWzXcyoKCHG2sWI .rough-node .label text,#mermaid-svg-dFWzXcyoKCHG2sWI .node .label text,#mermaid-svg-dFWzXcyoKCHG2sWI .image-shape .label,#mermaid-svg-dFWzXcyoKCHG2sWI .icon-shape .label{text-anchor:middle;}#mermaid-svg-dFWzXcyoKCHG2sWI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dFWzXcyoKCHG2sWI .rough-node .label,#mermaid-svg-dFWzXcyoKCHG2sWI .node .label,#mermaid-svg-dFWzXcyoKCHG2sWI .image-shape .label,#mermaid-svg-dFWzXcyoKCHG2sWI .icon-shape .label{text-align:center;}#mermaid-svg-dFWzXcyoKCHG2sWI .node.clickable{cursor:pointer;}#mermaid-svg-dFWzXcyoKCHG2sWI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dFWzXcyoKCHG2sWI .arrowheadPath{fill:#333333;}#mermaid-svg-dFWzXcyoKCHG2sWI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dFWzXcyoKCHG2sWI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dFWzXcyoKCHG2sWI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dFWzXcyoKCHG2sWI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dFWzXcyoKCHG2sWI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dFWzXcyoKCHG2sWI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster text{fill:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI .cluster span{color:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dFWzXcyoKCHG2sWI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dFWzXcyoKCHG2sWI rect.text{fill:none;stroke-width:0;}#mermaid-svg-dFWzXcyoKCHG2sWI .icon-shape,#mermaid-svg-dFWzXcyoKCHG2sWI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dFWzXcyoKCHG2sWI .icon-shape p,#mermaid-svg-dFWzXcyoKCHG2sWI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dFWzXcyoKCHG2sWI .icon-shape .label rect,#mermaid-svg-dFWzXcyoKCHG2sWI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dFWzXcyoKCHG2sWI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dFWzXcyoKCHG2sWI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dFWzXcyoKCHG2sWI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
解析器
抓取引擎
调度器
新 URL
Scheduler

URL 队列管理
去重

Bloom Filter
Fetcher

reqwest 异步
代理池

轮换
限速

令牌桶
Parser

scraper
提取规则

Selector
PostgreSQL
Redis
文件

JSONL/Parquet

2026 Rust 爬虫技术栈:

组件 推荐方案 替代
HTTP reqwest hyper (低层)
HTML 解析 scraper + select.rs html5ever / lol_html
并发 tokio + Stream async-std
去重 Bloom Filter + Redis Set roaring bitmap
代理 自建代理池 ProxyMesh / BrightData
限速 governor(令牌桶) tokio::time
存储 PostgreSQL + Redis SQLite / ClickHouse
序列化 serde + serde_json
导出 JSONL / Parquet (arrow-rs) CSV

一、项目脚手架

toml 复制代码
# Cargo.toml

[package]
name = "rust-crawler"
version = "0.1.0"
edition = "2024"

[dependencies]
# HTTP
reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "brotli", "deflate"] }

# HTML 解析
scraper = "0.22"
select = "0.6"

# 异步
tokio = { version = "1", features = ["full"] }
futures = "0.3"
tokio-stream = "0.1"
async-stream = "0.3"

# 数据库
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }

# 去重
bloomfilter = "0.2"

# 限速
governor = "0.8"

# 代理
rand = "0.9"

# 序列化
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# 导出
arrow = { version = "54", optional = true }
parquet = { version = "54", optional = true }

# 工具
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
url = "2"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }

[features]
default = []
parquet-export = ["arrow", "parquet"]

项目结构

复制代码
rust-crawler/
├── src/
│   ├── main.rs
│   ├── config.rs
│   ├── error.rs
│   ├── scheduler/
│   │   ├── mod.rs
│   │   ├── queue.rs           # URL 队列
│   │   └── dedup.rs           # Bloom Filter 去重
│   ├── fetcher/
│   │   ├── mod.rs
│   │   ├── client.rs          # HTTP 客户端
│   │   ├── proxy.rs           # 代理池
│   │   └── rate_limiter.rs    # 限速
│   ├── parser/
│   │   ├── mod.rs
│   │   ├── html.rs            # HTML 解析
│   │   └── selectors.rs       # CSS 选择器
│   ├── pipeline/
│   │   ├── mod.rs
│   │   ├── store.rs           # 存储管道
│   │   └── export.rs          # 导出管道
│   └── spider/
│       ├── mod.rs
│       └── engine.rs          # 爬虫引擎
├── config.toml
└── seeds.txt                  # 种子 URL

二、配置

rust 复制代码
// src/config.rs

use serde::Deserialize;

#[derive(Debug, Deserialize, Clone)]
pub struct CrawlerConfig {
    pub concurrency: usize,
    pub request_timeout_secs: u64,
    pub rate_limit_per_sec: u32,
    pub max_depth: u32,
    pub max_pages: usize,
    pub user_agent: String,
    pub respect_robots_txt: bool,
    pub proxy_list: Vec<String>,
    pub bloom_filter_capacity: usize,
    pub bloom_filter_error_rate: f64,
    pub database_url: String,
    pub redis_url: String,
    pub export_format: ExportFormat,
}

#[derive(Debug, Deserialize, Clone)]
pub enum ExportFormat {
    Jsonl,
    Csv,
    #[cfg(feature = "parquet-export")]
    Parquet,
}

impl CrawlerConfig {
    pub fn load() -> anyhow::Result<Self> {
        let config = config::Config::builder()
            .add_source(config::File::with_name("config"))
            .add_source(config::Environment::with_prefix("CRAWLER").separator("__"))
            .build()?;
        Ok(config.try_deserialize()?)
    }
}
toml 复制代码
# config.toml
concurrency = 100
request_timeout_secs = 30
rate_limit_per_sec = 50
max_depth = 5
max_pages = 10000
user_agent = "RustCrawler/1.0"
respect_robots_txt = true
proxy_list = []
bloom_filter_capacity = 1000000
bloom_filter_error_rate = 0.01
database_url = "postgres://user:pass@localhost:5432/crawler"
redis_url = "redis://127.0.0.1:6379"
export_format = "Jsonl"

三、URL 调度器 + 去重

3.1 Bloom Filter 去重

rust 复制代码
// src/scheduler/dedup.rs

use bloomfilter::Bloom;
use std::sync::Mutex;

pub struct UrlDeduplicator {
    bloom: Mutex<Bloom<String>>,
    redis: Option<redis::aio::ConnectionManager>,
}

impl UrlDeduplicator {
    pub fn new(capacity: usize, error_rate: f64) -> Self {
        let bloom = Bloom::new_for_fp_rate(capacity, error_rate)
            .expect("Failed to create Bloom filter");

        Self {
            bloom: Mutex::new(bloom),
            redis: None,
        }
    }

    pub fn with_redis(mut self, redis: redis::aio::ConnectionManager) -> Self {
        self.redis = Some(redis);
        self
    }

    /// 检查 URL 是否已访问(Bloom Filter + Redis 双重检查)
    pub async fn is_seen(&self, url: &str) -> bool {
        // 第一层:Bloom Filter(快速,可能有假阳性)
        {
            let bloom = self.bloom.lock().unwrap();
            if bloom.check(&url.to_string()) {
                return true;
            }
        }

        // 第二层:Redis(精确检查)
        if let Some(redis) = &self.redis {
            use redis::AsyncCommands;
            let mut conn = redis.clone();
            if let Ok(exists) = conn.sismember::<_, _, i64>("crawled_urls", url).await {
                if exists == 1 {
                    return true;
                }
            }
        }

        false
    }

    /// 标记 URL 为已访问
    pub async fn mark_seen(&self, url: &str) {
        // 写入 Bloom Filter
        {
            let mut bloom = self.bloom.lock().unwrap();
            bloom.set(&url.to_string());
        }

        // 写入 Redis(持久化)
        if let Some(redis) = &self.redis {
            use redis::AsyncCommands;
            let mut conn = redis.clone();
            let _ = conn.sadd::<_, _, i64>("crawled_urls", url).await;
        }
    }
}

3.2 URL 队列

rust 复制代码
// src/scheduler/queue.rs

use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::scheduler::dedup::UrlDeduplicator;

#[derive(Debug, Clone)]
pub struct UrlTask {
    pub url: String,
    pub depth: u32,
    pub parent: Option<String>,
}

pub struct UrlQueue {
    queue: Mutex<VecDeque<UrlTask>>,
    dedup: Arc<UrlDeduplicator>,
    max_depth: u32,
}

impl UrlQueue {
    pub fn new(dedup: Arc<UrlDeduplicator>, max_depth: u32) -> Self {
        Self {
            queue: Mutex::new(VecDeque::new()),
            dedup,
            max_depth,
        }
    }

    /// 添加种子 URL
    pub async fn add_seeds(&self, urls: Vec<String>) {
        let mut queue = self.queue.lock().await;
        for url in urls {
            if !self.dedup.is_seen(&url).await {
                self.dedup.mark_seen(&url).await;
                queue.push_back(UrlTask {
                    url,
                    depth: 0,
                    parent: None,
                });
            }
        }
    }

    /// 推入新发现的 URL
    pub async fn push(&self, url: String, depth: u32, parent: Option<String>) -> bool {
        if depth > self.max_depth {
            return false;
        }

        if self.dedup.is_seen(&url).await {
            return false;
        }

        self.dedup.mark_seen(&url).await;
        self.queue.lock().await.push_back(UrlTask { url, depth, parent });
        true
    }

    /// 弹出下一个 URL
    pub async fn pop(&self) -> Option<UrlTask> {
        self.queue.lock().await.pop_front()
    }

    /// 当前队列长度
    pub async fn len(&self) -> usize {
        self.queue.lock().await.len()
    }
}

四、HTTP 抓取器

4.1 限速器

rust 复制代码
// src/fetcher/rate_limiter.rs

use governor::{Quota, RateLimiter};
use governor::clock::DefaultClock;
use governor::state::InMemoryState;
use std::num::NonZeroU32;
use std::sync::Arc;

pub type SharedRateLimiter = Arc<RateLimiter<InMemoryState, DefaultClock>>;

pub fn create_rate_limiter(per_second: u32) -> SharedRateLimiter {
    let quota = Quota::per_second(NonZeroU32::new(per_second).unwrap());
    Arc::new(RateLimiter::direct(quota))
}

4.2 代理池

rust 复制代码
// src/fetcher/proxy.rs

use rand::Rng;
use std::sync::Arc;

#[derive(Debug, Clone)]
pub struct ProxyPool {
    proxies: Vec<String>,
    current: Arc<std::sync::atomic::AtomicUsize>,
}

impl ProxyPool {
    pub fn new(proxies: Vec<String>) -> Self {
        Self {
            proxies,
            current: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
        }
    }

    /// 轮询获取下一个代理
    pub fn next(&self) -> Option<String> {
        if self.proxies.is_empty() {
            return None;
        }
        let idx = self.current.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        Some(self.proxies[idx % self.proxies.len()].clone())
    }

    /// 随机获取代理
    pub fn random(&self) -> Option<String> {
        if self.proxies.is_empty() {
            return None;
        }
        let idx = rand::rng().random_range(0..self.proxies.len());
        Some(self.proxies[idx].clone())
    }

    /// 移除失败的代理
    pub fn remove(&mut self, proxy: &str) {
        self.proxies.retain(|p| p != proxy);
    }
}

4.3 HTTP 客户端

rust 复制代码
// src/fetcher/client.rs

use reqwest::{Client, ClientBuilder, StatusCode};
use crate::config::CrawlerConfig;
use crate::fetcher::proxy::ProxyPool;
use crate::fetcher::rate_limiter::SharedRateLimiter;
use crate::error::AppError;
use governor::RateLimiter;
use std::sync::Arc;
use std::time::Duration;

#[derive(Clone)]
pub struct Fetcher {
    client: Client,
    proxy_pool: Arc<std::sync::Mutex<ProxyPool>>,
    rate_limiter: SharedRateLimiter,
    timeout: Duration,
    user_agent: String,
}

#[derive(Debug)]
pub struct FetchedPage {
    pub url: String,
    pub status: u16,
    pub content_type: String,
    pub body: String,
    pub elapsed: Duration,
}

impl Fetcher {
    pub fn new(config: &CrawlerConfig, rate_limiter: SharedRateLimiter) -> Self {
        let client = ClientBuilder::new()
            .timeout(Duration::from_secs(config.request_timeout_secs))
            .user_agent(&config.user_agent)
            .cookie_store(true)
            .gzip(true)
            .brotli(true)
            .deflate(true)
            .pool_max_idle_per_host(20)
            .pool_idle_timeout(Duration::from_secs(60))
            .build()
            .expect("Failed to build HTTP client");

        let proxy_pool = Arc::new(std::sync::Mutex::new(
            ProxyPool::new(config.proxy_list.clone())
        ));

        Self {
            client,
            proxy_pool,
            rate_limiter,
            timeout: Duration::from_secs(config.request_timeout_secs),
            user_agent: config.user_agent.clone(),
        }
    }

    /// 抓取页面(自动限速 + 代理 + 重试)
    pub async fn fetch(&self, url: &str) -> Result<FetchedPage, AppError> {
        // 限速
        self.rate_limiter.until_ready().await;

        let start = std::time::Instant::now();
        let mut retries = 3u32;

        loop {
            let mut request = self.client.get(url);

            // 使用代理
            if let Some(proxy) = self.proxy_pool.lock().unwrap().next() {
                // reqwest 需要在 Client 构建时配置代理
                // 简化:使用系统代理
            }

            // 随机 User-Agent
            let ua = self.random_ua();
            request = request.header("User-Agent", ua);

            match request.send().await {
                Ok(response) => {
                    let status = response.status();
                    let content_type = response
                        .headers()
                        .get("content-type")
                        .and_then(|v| v.to_str().ok())
                        .unwrap_or("text/html")
                        .to_string();

                    let body = response.text().await
                        .map_err(|e| AppError::InternalError)?;

                    return Ok(FetchedPage {
                        url: url.to_string(),
                        status: status.as_u16(),
                        content_type,
                        body,
                        elapsed: start.elapsed(),
                    });
                }
                Err(e) if retries > 0 => {
                    retries -= 1;
                    tracing::warn!(url, error = %e, retries, "请求失败,重试中");
                    tokio::time::sleep(Duration::from_millis(500 * (4 - retries) as u64)).await;
                }
                Err(e) => {
                    return Err(AppError::InternalError);
                }
            }
        }
    }

    fn random_ua(&self) -> String {
        let uas = [
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36",
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15",
            "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/132.0",
        ];
        let idx = rand::rng().random_range(0..uas.len());
        uas[idx].to_string()
    }
}

五、HTML 解析器

rust 复制代码
// src/parser/html.rs

use scraper::{Html, Selector, ElementRef};
use url::Url;
use serde::{Serialize, Deserialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedPage {
    pub url: String,
    pub title: String,
    pub description: Option<String>,
    pub keywords: Option<String>,
    pub content: String,
    pub links: Vec<LinkInfo>,
    pub images: Vec<ImageInfo>,
    pub structured_data: Vec<serde_json::Value>,
    pub h1: Vec<String>,
    pub h2: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkInfo {
    pub href: String,
    pub text: String,
    pub nofollow: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInfo {
    pub src: String,
    pub alt: Option<String>,
}

pub struct HtmlParser;

impl HtmlParser {
    /// 解析 HTML 页面
    pub fn parse(html: &str, base_url: &str) -> anyhow::Result<ParsedPage> {
        let document = Html::parse_document(html);
        let base = Url::parse(base_url)?;

        // 标题
        let title = Self::select_text(&document, "title").unwrap_or_default();

        // Meta
        let description = Self::select_attr(&document, "meta[name='description']", "content");
        let keywords = Self::select_attr(&document, "meta[name='keywords']", "content");

        // 正文提取(去除标签)
        let content = Self::extract_content(&document);

        // 链接
        let links = Self::extract_links(&document, &base);

        // 图片
        let images = Self::extract_images(&document, &base);

        // 结构化数据
        let structured_data = Self::extract_structured_data(&document);

        // 标题层级
        let h1 = Self::select_all_text(&document, "h1");
        let h2 = Self::select_all_text(&document, "h2");

        Ok(ParsedPage {
            url: base_url.to_string(),
            title,
            description,
            keywords,
            content,
            links,
            images,
            structured_data,
            h1,
            h2,
        })
    }

    /// 提取正文(智能去噪)
    fn extract_content(document: &Html) -> String {
        // 移除 script / style / nav / footer / header
        let unwanted = ["script", "style", "nav", "footer", "header", "aside"];

        // 获取 body 文本
        if let Ok(selector) = Selector::parse("body") {
            let body = document.select(&selector).next();
            if let Some(body) = body {
                let mut text = String::new();
                Self::collect_text(body, &unwanted, &mut text);
                // 清理多余空白
                text = text.split_whitespace().collect::<Vec<_>>().join(" ");
                // 截断过长文本
                if text.len() > 50000 {
                    text.truncate(50000);
                }
                return text;
            }
        }

        String::new()
    }

    fn collect_text<'a>(
        element: ElementRef<'a>,
        unwanted: &[&str],
        output: &mut String,
    ) {
        for child in element.children() {
            if let Some(el) = child.value().as_element() {
                if unwanted.contains(&el.name()) {
                    continue;
                }
                if let Some(child_ref) = ElementRef::wrap(child) {
                    Self::collect_text(child_ref, unwanted, output);
                }
            } else if let Some(text) = child.value().as_text() {
                let t = text.text.trim();
                if !t.is_empty() {
                    if !output.is_empty() {
                        output.push(' ');
                    }
                    output.push_str(t);
                }
            }
        }
    }

    /// 提取所有链接
    fn extract_links(document: &Html, base: &Url) -> Vec<LinkInfo> {
        let mut links = Vec::new();

        if let Ok(selector) = Selector::parse("a[href]") {
            for el in document.select(&selector) {
                if let Some(href) = el.value().attr("href") {
                    let resolved = base.join(href).ok();
                    if let Some(url) = resolved {
                        let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
                        let nofollow = el.value().attr("rel")
                            .map(|r| r.contains("nofollow"))
                            .unwrap_or(false);

                        links.push(LinkInfo {
                            href: url.to_string(),
                            text,
                            nofollow,
                        });
                    }
                }
            }
        }

        links
    }

    /// 提取图片
    fn extract_images(document: &Html, base: &Url) -> Vec<ImageInfo> {
        let mut images = Vec::new();

        if let Ok(selector) = Selector::parse("img[src]") {
            for el in document.select(&selector) {
                if let Some(src) = el.value().attr("src") {
                    if let Ok(url) = base.join(src) {
                        images.push(ImageInfo {
                            src: url.to_string(),
                            alt: el.value().attr("alt").map(|s| s.to_string()),
                        });
                    }
                }
            }
        }

        images
    }

    /// 提取 JSON-LD 结构化数据
    fn extract_structured_data(document: &Html) -> Vec<serde_json::Value> {
        let mut data = Vec::new();

        if let Ok(selector) = Selector::parse("script[type='application/ld+json']") {
            for el in document.select(&selector) {
                let text = el.text().collect::<String>();
                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
                    data.push(json);
                }
            }
        }

        data
    }

    // 辅助方法
    fn select_text(document: &Html, selector_str: &str) -> Option<String> {
        Selector::parse(selector_str).ok().and_then(|s| {
            document.select(&s).next().map(|el| el.text().collect())
        })
    }

    fn select_attr(document: &Html, selector_str: &str, attr: &str) -> Option<String> {
        Selector::parse(selector_str).ok().and_then(|s| {
            document.select(&s).next().and_then(|el| el.value().attr(attr).map(|v| v.to_string()))
        })
    }

    fn select_all_text(document: &Html, selector_str: &str) -> Vec<String> {
        Selector::parse(selector_str).ok().map(|s| {
            document.select(&s).map(|el| el.text().collect::<String>()).collect()
        }).unwrap_or_default()
    }
}

六、存储与导出管道

6.1 PostgreSQL 存储

rust 复制代码
// src/pipeline/store.rs

use sqlx::PgPool;
use crate::parser::html::ParsedPage;
use crate::error::AppResult;

pub struct PageStore {
    pool: PgPool,
}

impl PageStore {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    /// 保存解析结果
    pub async fn save(&self, page: &ParsedPage) -> AppResult<()> {
        sqlx::query!(
            r#"INSERT INTO crawled_pages 
               (url, title, description, keywords, content, links, images, structured_data, h1, h2)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
               ON CONFLICT (url) DO UPDATE SET
                 title = EXCLUDED.title,
                 content = EXCLUDED.content,
                 updated_at = NOW()"#,
            page.url,
            page.title,
            page.description,
            page.keywords,
            page.content,
            serde_json::to_value(&page.links)?,
            serde_json::to_value(&page.images)?,
            serde_json::to_value(&page.structured_data)?,
            &page.h1,
            &page.h2,
        )
        .execute(&self.pool)
        .await?;

        Ok(())
    }

    /// 批量保存(高性能)
    pub async fn save_batch(&self, pages: &[ParsedPage]) -> AppResult<()> {
        // 使用事务批量插入
        let mut tx = self.pool.begin().await?;

        for page in pages {
            sqlx::query!(
                r#"INSERT INTO crawled_pages 
                   (url, title, description, keywords, content, links, images, structured_data, h1, h2)
                   VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
                   ON CONFLICT (url) DO NOTHING"#,
                page.url, page.title, page.description, page.keywords,
                page.content,
                serde_json::to_value(&page.links)?,
                serde_json::to_value(&page.images)?,
                serde_json::to_value(&page.structured_data)?,
                &page.h1, &page.h2,
            )
            .execute(&mut *tx)
            .await?;
        }

        tx.commit().await?;
        Ok(())
    }
}

6.2 JSONL 导出

rust 复制代码
// src/pipeline/export.rs

use std::path::Path;
use tokio::io::AsyncWriteExt;
use crate::parser::html::ParsedPage;

pub struct JsonlExporter {
    file: tokio::fs::File,
    count: usize,
}

impl JsonlExporter {
    pub async fn new(path: &str) -> anyhow::Result<Self> {
        let file = tokio::fs::File::create(path).await?;
        Ok(Self { file, count: 0 })
    }

    /// 追加一条记录
    pub async fn export(&mut self, page: &ParsedPage) -> anyhow::Result<()> {
        let line = serde_json::to_string(page)? + "\n";
        self.file.write_all(line.as_bytes()).await?;
        self.count += 1;

        // 每 1000 条 flush 一次
        if self.count % 1000 == 0 {
            self.file.flush().await?;
            tracing::info!(count = self.count, "💾 JSONL flush");
        }

        Ok(())
    }

    pub async fn close(&mut self) -> anyhow::Result<()> {
        self.file.flush().await?;
        tracing::info!(total = self.count, "✅ JSONL 导出完成");
        Ok(())
    }
}

七、爬虫引擎

rust 复制代码
// src/spider/engine.rs

use std::sync::Arc;
use tokio::sync::Semaphore;
use futures::stream::{self, StreamExt};
use crate::config::CrawlerConfig;
use crate::scheduler::queue::UrlQueue;
use crate::scheduler::dedup::UrlDeduplicator;
use crate::fetcher::client::{Fetcher, FetchedPage};
use crate::parser::html::HtmlParser;
use crate::pipeline::store::PageStore;
use crate::pipeline::export::JsonlExporter;

pub struct CrawlEngine {
    config: CrawlerConfig,
    queue: Arc<UrlQueue>,
    fetcher: Fetcher,
    store: PageStore,
    exporter: Arc<tokio::sync::Mutex<JsonlExporter>>,
    semaphore: Arc<Semaphore>,
    pages_crawled: Arc<std::sync::atomic::AtomicUsize>,
}

impl CrawlEngine {
    pub async fn new(config: CrawlerConfig) -> anyhow::Result<Self> {
        let pool = sqlx::PgPool::connect(&config.database_url).await?;
        sqlx::migrate!().run(&pool).await?;

        let redis = redis::Client::open(config.redis_url.as_str())?;
        let redis_manager = redis::aio::ConnectionManager::new(redis).await?;

        let dedup = Arc::new(
            UrlDeduplicator::new(config.bloom_filter_capacity, config.bloom_filter_error_rate)
                .with_redis(redis_manager)
        );

        let rate_limiter = crate::fetcher::rate_limiter::create_rate_limiter(config.rate_limit_per_sec);
        let fetcher = Fetcher::new(&config, rate_limiter);
        let queue = Arc::new(UrlQueue::new(dedup, config.max_depth));
        let store = PageStore::new(pool);
        let exporter = Arc::new(tokio::sync::Mutex::new(
            JsonlExporter::new("output.jsonl").await?
        ));

        Ok(Self {
            config,
            queue,
            fetcher,
            store,
            exporter,
            semaphore: Arc::new(Semaphore::new(config.concurrency)),
            pages_crawled: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
        })
    }

    /// 启动爬虫
    pub async fn run(&self, seeds: Vec<String>) -> anyhow::Result<()> {
        tracing::info!(seeds = seeds.len(), concurrency = self.config.concurrency, "🚀 爬虫启动");

        // 添加种子 URL
        self.queue.add_seeds(seeds).await;

        // 主循环
        loop {
            // 检查退出条件
            let crawled = self.pages_crawled.load(std::sync::atomic::Ordering::Relaxed);
            if crawled >= self.config.max_pages {
                tracing::info!(crawled, "✅ 达到最大页面数,停止");
                break;
            }

            let queue_len = self.queue.len().await;
            if queue_len == 0 {
                tracing::info!("📭 队列为空,停止");
                break;
            }

            // 批量取出 URL
            let batch_size = self.config.concurrency.min(queue_len);
            let mut tasks = Vec::new();

            for _ in 0..batch_size {
                if let Some(task) = self.queue.pop().await {
                    let permit = self.semaphore.clone().acquire_owned().await?;
                    tasks.push(tokio::spawn(self.crawl_page(task, permit)));
                }
            }

            // 等待当前批次完成
            for result in futures::future::join_all(tasks).await {
                if let Err(e) = result {
                    tracing::error!(error = %e, "任务异常");
                }
            }

            // 输出进度
            tracing::info!(
                crawled,
                queue = self.queue.len().await,
                "📊 进度"
            );
        }

        // 关闭导出器
        self.exporter.lock().await.close().await?;

        tracing::info!(total = crawled, "🏁 爬虫完成");
        Ok(())
    }

    /// 抓取单个页面
    async fn crawl_page(
        &self,
        task: crate::scheduler::queue::UrlTask,
        _permit: tokio::sync::OwnedSemaphorePermit,
    ) -> anyhow::Result<()> {
        tracing::debug!(url = %task.url, depth = task.depth, "🕷️ 抓取");

        // 抓取
        let page = self.fetcher.fetch(&task.url).await?;

        if page.status != 200 {
            tracing::warn!(url = %task.url, status = page.status, "⚠️ 非 200 状态码");
            return Ok(());
        }

        // 只处理 HTML
        if !page.content_type.contains("text/html") {
            return Ok(());
        }

        // 解析
        let parsed = HtmlParser::parse(&page.body, &task.url)?;

        // 发现新链接 → 加入队列
        for link in &parsed.links {
            if !link.nofollow && link.href.starts_with("http") {
                self.queue.push(
                    link.href.clone(),
                    task.depth + 1,
                    Some(task.url.clone()),
                ).await;
            }
        }

        // 存储
        self.store.save(&parsed).await?;

        // 导出
        self.exporter.lock().await.export(&parsed).await?;

        // 计数
        self.pages_crawled.fetch_add(1, std::sync::atomic::Ordering::Relaxed);

        Ok(())
    }
}

八、启动入口

rust 复制代码
// src/main.rs

mod config;
mod error;
mod scheduler;
mod fetcher;
mod parser;
mod pipeline;
mod spider;

use clap::Parser;

#[derive(Parser)]
#[command(name = "rust-crawler", about = "高性能 Rust 爬虫引擎")]
struct Cli {
    /// 种子 URL 文件
    #[arg(short, long, default_value = "seeds.txt")]
    seeds: String,

    /// 并发数
    #[arg(short, long)]
    concurrency: Option<usize>,

    /// 最大页面数
    #[arg(long)]
    max_pages: Option<usize>,

    /// 最大深度
    #[arg(short, long)]
    max_depth: Option<u32>,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 初始化日志
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .json()
        .init();

    let cli = Cli::parse();

    // 加载配置
    let mut config = config::CrawlerConfig::load()?;
    if let Some(c) = cli.concurrency { config.concurrency = c; }
    if let Some(m) = cli.max_pages { config.max_pages = m; }
    if let Some(d) = cli.max_depth { config.max_depth = d; }

    // 读取种子 URL
    let seeds = tokio::fs::read_to_string(&cli.seeds).await?
        .lines()
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(|l| l.trim().to_string())
        .collect::<Vec<_>>();

    tracing::info!(seeds = seeds.len(), config = ?config, "📋 配置加载完成");

    // 启动引擎
    let engine = spider::engine::CrawlEngine::new(config).await?;
    engine.run(seeds).await?;

    Ok(())
}

总结

Rust 爬虫技术选型 Checklist

复制代码
□ 调度
  □ Bloom Filter 去重(双层:Bloom + Redis)
  □ VecDeque FIFO 队列
  □ 深度控制 + 最大页面数

□ 抓取
  □ reqwest 异步 HTTP(gzip/brotli/deflate)
  □ governor 令牌桶限速
  □ 代理池(轮询/随机/故障剔除)
  □ 自动重试(指数退避)
  □ 随机 User-Agent

□ 解析
  □ scraper CSS 选择器
  □ 正文提取(智能去噪:script/style/nav/footer)
  □ JSON-LD 结构化数据
  □ 链接/图片提取(URL 解析)

□ 存储
  □ PostgreSQL(UPSERT + 批量事务)
  □ JSONL 文件导出(缓冲 flush)

□ 并发
  □ Semaphore 信号量控制
  □ tokio 异步运行时
  □ Arc<Mutex> 共享状态
  □ AtomicUsize 无锁计数

性能对比

指标 Rust (tokio) Python (aiohttp) Go (net/http)
并发连接 10K+ ~1K ~5K
内存/1000 并发 ~30MB ~200MB ~80MB
解析速度 ~50MB/s ~10MB/s ~30MB/s
CPU 占用 ~5% ~25% ~12%
零拷贝解析

本文覆盖 Rust 爬虫与数据处理完整链路:Bloom Filter + Redis 双层去重 + FIFO URL 队列 + reqwest 异步 HTTP + governor 令牌桶限速 + 代理池 + scraper HTML 解析 + 正文智能提取 + JSON-LD 结构化数据 + PostgreSQL 批量存储 + JSONL 导出 + Semaphore 并发控制 + 完整爬虫引擎。

相关推荐
程序喵大人1 小时前
【C++并发系列】第二章:锁解决了什么问题?
开发语言·c++·并发编程·
guygg881 小时前
二维弹塑性有限元分析(von Mises 等向硬化)— MATLAB 实现
开发语言·人工智能·matlab
在放️2 小时前
Python 练习题讲解 2 · 循环计算
开发语言·python
江华森2 小时前
高级 Bash 脚本编程指南 — 实战教程
开发语言·bash
我不是懒洋洋2 小时前
【C++】string(string的成员变量、auto和范围for、string常用接口的说明、OJ题目、string的模拟实现)
c语言·开发语言·c++·visual studio
承渊政道2 小时前
飞算JavaAI 智能引导背后的多 Agent 协作机制解析:从老旧 Java 后台升级到可运行工程
java·开发语言·spring boot·安全·intellij-idea·软件工程·ai编程
Brilliantwxx2 小时前
【C++】 C++11 知识点梳理(中)
开发语言·c++
j7~2 小时前
【C++】STL--Vector容器--拆析解剖Vector的实现以及Vector的底层详解(2)
开发语言·c++·动态二维数组·vector深度剖析·vector的实现·杨辉三角形
三品吉他手会点灯9 小时前
C语言学习笔记 - 50.流程控制4 - 流程控制为什么非常非常重要
c语言·开发语言·笔记·学习