ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

R语言爬虫实战:从数据抓取到清洗的完整指南

R语言爬虫实战:从数据抓取到清洗的完整指南

1. 为什么我至今还在用 R 写爬虫

很多人一听到“爬虫”,第一反应就是 Python,觉得 R 语言只能做统计分析、画图,干不了抓数据这种“脏活累活”。我一开始也是这么想的,直到有一次接了个活儿,需要从几十个结构类似的网页里批量提取表格数据,然后直接做统计建模。用 Python 写当然没问题,但数据抓下来之后还得导到 R 里做分析,中间来回倒腾,烦得很。后来我索性试了试直接用 R 来抓,结果发现——真香。

R 爬虫这件事,核心其实就两块:怎么把网页内容拿下来,以及怎么从一堆 HTML 里把想要的数据抠出来。前者靠httr、curl这类包发请求,后者靠rvest解析页面。听起来简单,但实际操作中坑不少:编码问题、反爬限制、动态加载、分页逻辑、数据清洗……每一个环节都能让你卡上半天。这篇文章我就把自己这些年用 R 做爬虫的完整思路、实操步骤、踩过的坑和总结的技巧,从头到尾捋一遍。不管你是 R 语言新手还是已经有点基础,只要你想用 R 把网页数据搞到手,这篇内容都能直接拿来参考。

我写这篇东西的出发点很简单:网上关于 R 爬虫的教程要么太浅,就教你抓一个静态页面就完事了;要么太散,东一榔头西一棒子,没有一个完整的项目视角。我打算按照一个真实项目的推进节奏来写——从需求分析、工具选型,到核心代码实现,再到问题排查和效率优化,每一步都讲清楚“为什么这么做”以及“不这么做会怎样”。你看完之后,应该能独立完成一个中等规模的 R 爬虫项目,并且知道遇到问题该往哪个方向去查。

2. 项目整体设计与思路拆解

2.1 先想清楚:你要抓的是什么类型的数据

在动手写任何代码之前,我习惯先花十分钟把目标网站摸一遍。这一步很多人会跳过,直接上来就写read_html,结果写到一半发现数据是 JavaScript 动态渲染的,rvest根本抓不到,白忙活一场。所以第一步永远是:打开目标页面,右键查看源代码,搜索你要的数据在不在里面。

如果数据在源代码里能找到,那就是静态页面,rvest直接搞定。如果找不到,说明数据是通过接口异步加载的,你就得去 Network 面板里找那个返回 JSON 的请求,然后用httr直接请求接口。还有一种情况是数据在源代码里但被混淆了,这种就比较麻烦,可能需要配合正则表达式或者别的工具来处理。

我一般会把目标页面分成三类:

  • 静态 HTML 表格类:数据直接写在<table>标签里,这种最好处理,html_table()一把梭。
  • 列表/卡片类:数据分散在多个<div>或<li>里,需要用 CSS 选择器或 XPath 逐个提取字段。
  • 接口返回 JSON 类:页面本身没有数据,靠 XHR 请求拿 JSON 再渲染,这种直接请求接口最省事。

分类清楚了,后面的工具选型和代码结构就顺理成章了。

2.2 工具选型:为什么是 rvest + httr 这套组合

R 里面做爬虫的包其实不少,rvest、httr、curl、RSelenium、chromote等等。我大部分项目里用的都是rvest+httr这套组合,原因有这么几个。

rvest是 Hadley Wickham 写的,语法非常符合 tidyverse 的风格,用%>%管道操作符串起来,读起来很顺。它的核心函数就几个:read_html()读页面,html_nodes()选节点,html_text()取文本,html_attr()取属性,html_table()直接解析表格。学起来快,用起来也快。

httr负责发 HTTP 请求,比 R 自带的url()函数灵活得多。你可以方便地设置请求头、Cookie、代理、超时时间,还能处理 POST 请求和表单提交。配合curl包,基本上能模拟出浏览器的大部分行为。

那什么时候需要用到RSelenium或者chromote呢?只有当页面数据完全依赖 JavaScript 渲染,而且你找不到对应的接口时,才需要动用浏览器自动化工具。这类工具的好处是能拿到渲染后的完整 DOM,坏处是慢、吃资源、配置麻烦。我的原则是:能用请求解决的,绝不上浏览器。

2.3 项目结构:别把代码写成一大坨

我见过很多人写爬虫,从头到尾一个脚本几百行,中间连个函数都不抽。这种代码跑一次还行,一旦要改或者要复用,简直就是灾难。我一般会把一个爬虫项目拆成这么几个部分:

  • 配置区:URL 列表、请求头、延时参数、输出路径这些全部放在最上面,方便调整。
  • 请求函数:封装一个fetch_page()函数,统一处理请求头发送、超时重试、编码转换。
  • 解析函数:针对每个字段写独立的提取逻辑,比如get_title()、get_price()、get_date()。
  • 主流程:循环调用请求和解析函数,把结果拼成数据框。
  • 输出与日志:每抓完一批就写一次文件,同时记录成功和失败的 URL,方便断点续抓。

这样拆的好处是,调试的时候可以单独测试某一个函数,不用每次都跑全流程。而且如果目标网站改版了,你只需要改对应的解析函数,其他部分不用动。

3. 核心细节解析与实操要点

3.1 请求头:别让服务器一眼看出你是机器人

发请求的时候,最容易被忽略但又最关键的就是请求头(HTTP Headers)。默认情况下,httr::GET()发出的请求 User-Agent 是类似libcurl/7.x.x这样的字符串,服务器一看就知道不是正常浏览器,轻则返回精简版页面,重则直接拒绝。

我一般会设置这几个头:

headers <- c( `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", `Accept` = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", `Accept-Language` = "zh-CN,zh;q=0.9,en;q=0.8" )

User-Agent 建议用主流浏览器的,不要用太老的版本。Accept-Language 设置成中文优先,有些网站会根据这个返回不同语言的内容。如果你的目标网站需要登录才能看到数据,那还得加上 Cookie,这个后面单独说。

注意:不要把所有请求都设置成完全一样的头,尤其是 User-Agent,偶尔换一换更安全。我一般会准备三五个不同的 UA 字符串,随机轮换使用。

3.2 编码问题:中文乱码的根源与解法

中文网站最常见的坑就是编码。R 在读取网页时,如果编码判断错了,返回的文本就是一堆乱码。read_html()默认会尝试自动检测编码,但有时候会检测失败。

我的处理方式是:先用httr::GET()拿到原始响应,然后手动检查Content-Type头里的 charset 信息,再决定用什么编码来解析。

resp <- GET(url, headers = headers) content_type <- headers(resp)[["content-type"]] # 如果 content-type 里有 charset=gbk 之类的信息,就按那个来

如果响应头里没有明确指定编码,那就得靠猜。常见的编码有 UTF-8、GBK、GB2312。我一般会先试 UTF-8,如果出现乱码再换 GBK。read_html()有一个encoding参数可以手动指定:

page <- read_html(resp, encoding = "GBK")

还有一个更稳妥的办法是用iconv()函数做转换:

raw_text <- content(resp, "text", encoding = "UTF-8") fixed_text <- iconv(raw_text, from = "GBK", to = "UTF-8")

这个坑我踩过太多次了,尤其是抓一些地方性的小网站,编码五花八门。建议在请求函数里就把编码处理逻辑封装好,后面就不用每次都操心了。

3.3 CSS 选择器与 XPath:怎么精准定位你要的节点

rvest支持两种定位方式:CSS 选择器和 XPath。CSS 选择器写起来简洁,XPath 功能更强大。我一般优先用 CSS 选择器,实在定位不到再用 XPath。

常用的 CSS 选择器语法:

语法含义示例
#id按 id 选#main-content
.class按 class 选.product-title
tag按标签名选table
tag.class标签+类名div.price
[attr=value]按属性选[data-type="review"]
parent > child直接子元素ul > li
ancestor descendant后代元素div span

实际用的时候,我一般会在浏览器里先右键“检查元素”,找到目标节点的 class 或 id,然后直接拿来用。但要注意,有些网站的 class 名是动态生成的,每次刷新都不一样,这种就不能硬编码,得找更稳定的父级节点或者用位置索引来定位。

XPath 的写法类似//div[@class="content"]//p/text(),功能更强但可读性差一些。我一般在需要按文本内容筛选节点的时候才用 XPath,比如“找到所有包含‘价格’二字的 span”。

3.4 分页处理:三种常见模式与应对策略

分页是爬虫绕不开的问题。常见的分页模式有三种:

第一种是 URL 参数分页,比如page=1、page=2,这种最简单,直接循环构造 URL 就行。

第二种是“下一页”链接分页,页面上有一个“下一页”按钮,你需要先解析出下一页的链接,再请求那个链接。这种需要用html_attr("href")把链接抠出来,然后拼成完整 URL。

第三种是 AJAX 滚动加载,页面滚动到底部自动加载更多,URL 不变。这种要么找到背后的接口直接请求,要么用浏览器自动化工具模拟滚动。

我一般会先判断是哪种模式,然后写对应的循环逻辑。对于第一种和第二种,用一个while循环就够了:

all_data <- list() page_num <- 1 has_next <- TRUE while (has_next) { url <- paste0(base_url, "?page=", page_num) page <- fetch_page(url) items <- parse_items(page) all_data[[page_num]] <- items # 判断是否还有下一页 next_link <- page %>% html_node("a.next-page") has_next <- !is.na(next_link) page_num <- page_num + 1 Sys.sleep(runif(1, 1, 3)) # 随机延时 }

提示:分页循环一定要加延时,而且最好是随机延时,不要固定间隔。固定间隔的请求模式很容易被识别出来。

4. 实操过程与核心环节实现

4.1 环境准备与依赖安装

先把需要的包装好。我一般会用到这几个:

install.packages(c("httr", "rvest", "xml2", "stringr", "dplyr", "purrr", "readr"))

简单说明一下各自的作用:httr负责发请求,rvest负责解析 HTML,xml2是rvest的底层依赖,stringr处理字符串,dplyr和purrr做数据整理和循环,readr负责写文件。

如果你需要处理 JSON 接口返回的数据,再加一个jsonlite:

install.packages("jsonlite")

4.2 封装一个通用的请求函数

我把请求逻辑封装成一个函数,统一处理请求头、超时、重试和编码:

fetch_page <- function(url, headers = default_headers, max_retry = 3, sleep_range = c(1, 3)) { for (i in seq_len(max_retry)) { tryCatch({ resp <- GET(url, headers = headers, timeout(15)) if (status_code(resp) == 200) { # 自动检测编码 ct <- headers(resp)[["content-type"]] if (grepl("gbk|gb2312", ct, ignore.case = TRUE)) { txt <- content(resp, "text", encoding = "GBK") txt <- iconv(txt, from = "GBK", to = "UTF-8") page <- read_html(txt) } else { page <- read_html(resp) } Sys.sleep(runif(1, sleep_range[1], sleep_range[2])) return(page) } else { message(sprintf("请求返回状态码 %d,第 %d 次重试", status_code(resp), i)) Sys.sleep(2^i) } }, error = function(e) { message(sprintf("请求出错:%s,第 %d 次重试", e$message, i)) Sys.sleep(2^i) }) } return(NULL) }

这个函数做了几件事:设置超时时间、检查状态码、自动处理 GBK 编码、失败后指数退避重试、每次请求后随机延时。基本上覆盖了日常抓取的大部分场景。

4.3 解析列表页:提取详情页链接

假设我们要抓的是一个商品列表页,每个商品有标题、价格,点击进入有详情页。第一步是先把列表页里所有详情页的链接提取出来。

parse_list_page <- function(page) { items <- page %>% html_nodes(".product-item") data.frame( title = items %>% html_node(".product-title") %>% html_text(trim = TRUE), price = items %>% html_node(".product-price") %>% html_text(trim = TRUE), link = items %>% html_node("a") %>% html_attr("href"), stringsAsFactors = FALSE ) }

这里有几个细节要注意。html_nodes()返回的是节点集合,如果某个字段在某个商品上不存在,html_node()会返回NA,不会报错,这是好事。但html_text()对NA节点调用会返回NA,所以最终数据框里会有缺失值,后面统一处理就行。

链接如果是相对路径,还需要拼成完整 URL:

base_url <- "https://example.com" items$link <- ifelse( grepl("^http", items$link), items$link, paste0(base_url, items$link) )

4.4 解析详情页:逐字段提取

详情页的解析逻辑和列表页类似,只是字段更多。我一般会为每个字段写一个独立的提取函数,这样调试起来方便:

get_detail_title <- function(page) { page %>% html_node("h1.detail-title") %>% html_text(trim = TRUE) } get_detail_price <- function(page) { raw <- page %>% html_node(".detail-price") %>% html_text(trim = TRUE) # 去掉货币符号和千分位逗号 as.numeric(gsub("[^0-9.]", "", raw)) } get_detail_desc <- function(page) { page %>% html_node(".detail-description") %>% html_text(trim = TRUE) }

价格字段特别需要注意,网页上显示的可能是“¥1,299.00”这种格式,直接转数字会失败。我一般用正则把非数字和小数点的字符全部去掉,再转成数值型。

4.5 主流程:串起来跑通

把上面的函数串起来,主流程大概长这样:

base_url <- "https://example.com" all_items <- list() for (page_num in 1:10) { list_url <- paste0(base_url, "/list?page=", page_num) list_page <- fetch_page(list_url) if (is.null(list_page)) next items <- parse_list_page(list_page) all_items[[page_num]] <- items message(sprintf("第 %d 页抓取完成,共 %d 条", page_num, nrow(items))) } result <- bind_rows(all_items) write_csv(result, "output/list_data.csv")

这是第一步,先把列表页的数据抓下来。然后第二步是遍历每一条的链接,去抓详情页:

detail_results <- list() for (i in seq_len(nrow(result))) { detail_page <- fetch_page(result$link[i]) if (is.null(detail_page)) { detail_results[[i]] <- data.frame( title = NA, price = NA, desc = NA, url = result$link[i] ) next } detail_results[[i]] <- data.frame( title = get_detail_title(detail_page), price = get_detail_price(detail_page), desc = get_detail_desc(detail_page), url = result$link[i], stringsAsFactors = FALSE ) if (i %% 50 == 0) { message(sprintf("已完成 %d / %d", i, nrow(result))) # 每 50 条存一次盘,防止中途崩溃丢数据 bind_rows(detail_results) %>% write_csv("output/detail_progress.csv") } } final <- bind_rows(detail_results) write_csv(final, "output/final_data.csv")

这个流程里我加了两个保护措施:一是每 50 条存一次盘,二是失败的时候记录 URL 而不是直接跳过。这样即使跑到一半程序崩了,你也能知道哪些抓成功了、哪些没抓成功,方便断点续抓。

4.6 数据清洗:抓下来只是第一步

抓下来的原始数据往往很脏,需要做一轮清洗。常见的清洗操作包括:

  • 去掉首尾空白:trimws()
  • 去掉换行符和多余空格:gsub("\\s+", " ", x)
  • 提取数字:gsub("[^0-9.]", "", x)
  • 日期格式化:as.Date(x, format = "%Y-%m-%d")
  • 缺失值处理:ifelse(is.na(x), 0, x)或者直接过滤掉

我一般会写一个clean_data()函数,把所有清洗逻辑集中在一起,方便复用和修改。

5. 常见问题与排查技巧实录

5.1 请求被拒绝或返回空内容

这是最常见的问题。可能的原因和排查方向:

现象可能原因排查方法解决方案
返回 403请求头被识别检查 User-Agent换成浏览器 UA
返回 200 但内容为空需要登录/Cookie检查是否需要登录添加 Cookie
返回 200 但内容是验证页触发反爬查看返回内容降低频率、加延时
连接超时网络问题或 IP 被限换网络测试增加超时时间、重试

我遇到最多的情况是请求头没设置好。有些网站会检查Referer字段,如果缺失就返回空内容。这时候加上Referer头指向目标网站的首页就行。

还有一种情况是请求频率太高被临时限制。我的经验是:单次请求间隔不要低于 1 秒,批量抓取时每抓 50-100 条就休息 10-30 秒。虽然慢一点,但稳定,不会跑一半被封。

5.2 选择器失效:网站改版了怎么办

网站改版是爬虫的宿命。今天能用的选择器,明天可能就失效了。我的应对策略是:

  • 尽量用稳定的属性定位:id 通常比 class 稳定,>safe_extract <- function(node, selector, attr = NULL) { target <- html_node(node, selector) if (is.na(target)) return(NA_character_) if (is.null(attr)) { html_text(target, trim = TRUE) } else { html_attr(target, attr) } }

    这样即使某个字段的节点不存在,也不会导致整个流程崩溃。

    5.3 中文乱码的几种变体

    中文乱码不止一种表现。有时候是“锟斤拷”这种经典乱码,有时候是问号,有时候是方框。不同的乱码对应不同的原因:

    • “锟斤拷”:通常是 UTF-8 和 GBK 之间反复转换导致的,说明编码判断错了。
    • 问号“???”:通常是目标编码不支持某些字符,或者转换时丢失了信息。
    • 方框“□□□”:通常是字体问题,但如果是抓取的数据,说明编码解析有问题。

    我的排查步骤是:先用content(resp, "raw")拿到原始字节,然后用rawToChar()看看原始内容,再手动指定编码转换。这个方法虽然笨,但能定位到具体是哪一步出的问题。

    5.4 动态加载数据的处理思路

    如果数据是 JavaScript 动态加载的,rvest直接抓是抓不到的。这时候有两条路:

    第一条路是找接口。打开浏览器的开发者工具,切到 Network 面板,刷新页面,找到返回数据的那个 XHR 请求。然后直接用httr请求那个接口,拿到 JSON 后用jsonlite::fromJSON()解析。这条路速度快、资源消耗小,是我的首选。

    第二条路是用浏览器自动化。如果接口找不到或者接口有复杂的加密参数,那就只能用chromote或RSelenium来模拟浏览器行为了。这类工具能执行 JavaScript,等页面渲染完成后再提取 DOM。缺点是慢,而且需要额外配置浏览器驱动。

    我一般会先花时间找接口,实在找不到才上浏览器自动化。因为浏览器自动化不仅慢,而且稳定性差,浏览器版本更新、驱动不匹配这些问题都很烦人。

    5.5 断点续抓:别让程序崩溃白跑一场

    抓取大批量数据的时候,程序跑几个小时是常事。如果跑到一半崩溃了,没有断点续抓机制的话,就得从头再来。我的做法是:

    • 每抓完一批就写一次文件,文件名带时间戳或批次号。
    • 同时维护一个“已完成 URL”的列表,每次启动时先读取这个列表,跳过已经抓过的。
    • 失败的 URL 单独记录到一个文件里,方便后续重试。
    # 读取已完成的 URL done_urls <- if (file.exists("output/done_urls.txt")) { readLines("output/done_urls.txt") } else { character(0) } # 过滤掉已完成的 remaining <- result$link[!result$link %in% done_urls] # 每抓完一条就追加记录 write(result$link[i], file = "output/done_urls.txt", append = TRUE)

    这个机制看起来简单,但实际用起来能省下大量时间。尤其是抓几千条数据的时候,中途因为网络波动或者网站临时限制导致中断,有了断点续抓就能接着跑,不用从头来。

    6. 效率优化与进阶技巧

    6.1 并发请求:用 furrr 加速抓取

    R 默认是单线程的,抓取速度受限于每次请求的响应时间。如果目标网站响应慢,一条一条抓会非常耗时。这时候可以用furrr包做并行抓取:

    library(furrr) plan(multisession, workers = 4) results <- future_map(urls, function(u) { fetch_page(u) }, .progress = TRUE)

    workers = 4表示用 4 个并行会话。注意不要设置太多,一般 3-5 个就够了,太多容易触发网站的限制。而且并行抓取的时候,延时策略要调整,因为多个请求同时发出,整体频率会更高。

    提示:并行抓取适合目标网站响应慢但限制不严的情况。如果网站有严格的频率限制,并行反而容易触发封禁,这时候还是老老实实单线程加延时更稳妥。

    6.2 用缓存避免重复请求

    调试解析逻辑的时候,经常需要反复请求同一个页面。每次都发请求不仅慢,还可能触发限制。我的做法是把请求结果缓存到本地:

    fetch_with_cache <- function(url, cache_dir = "cache") { if (!dir.exists(cache_dir)) dir.create(cache_dir) cache_file <- file.path(cache_dir, paste0(digest::digest(url), ".html")) if (file.exists(cache_file)) { return(read_html(cache_file)) } page <- fetch_page(url) if (!is.null(page)) { write_html(page, cache_file) } page }

    这样调试的时候第一次请求会走网络,后面都读本地缓存,速度快很多。等解析逻辑调通了,再清空缓存跑全量。

    6.3 日志记录:出了问题能查

    一个健壮的爬虫项目应该有完整的日志记录。我一般会记录这几类信息:

    • 每次请求的 URL、状态码、耗时
    • 解析成功的记录数
    • 解析失败的 URL 和原因
    • 程序开始和结束的时间
    log_msg <- function(level, msg) { timestamp <- format(Sys.time(), "%Y-%m-%d %H:%M:%S") line <- sprintf("[%s] [%s] %s", timestamp, level, msg) write(line, file = "output/crawler.log", append = TRUE) message(line) }

    日志不用搞得太复杂,能定位问题就行。关键是失败的时候要知道是哪个 URL 失败了、失败原因是什么。

    6.4 数据存储:CSV 还是数据库

    小规模数据(几千条以内)用 CSV 就够了,简单直接,用 Excel 也能打开看。数据量大了之后(几万条以上),CSV 的读写效率会明显下降,这时候可以考虑用 SQLite:

    library(DBI) library(RSQLite) con <- dbConnect(SQLite(), "output/data.db") dbWriteTable(con, "products", result, append = TRUE) dbDisconnect(con)

    SQLite 的好处是单文件、不需要额外服务、支持 SQL 查询。对于个人项目来说,比 MySQL 之类的轻量多了。

    7. 几个我踩过的坑和总结的经验

    第一个坑是忽略 robots.txt。虽然技术上你可以抓任何页面,但从合规角度来说,应该先看看目标网站的 robots.txt 文件,了解哪些路径允许抓取、哪些不允许。这不是技术问题,是基本的网络礼仪。

    第二个坑是没有设置合理的超时时间。默认的超时时间可能很长,遇到响应慢的网站会卡住很久。我一般设置 15-30 秒,超过就重试。

    第三个坑是在循环里直接操作数据框。rbind()在循环里用会导致性能急剧下降,因为每次都要复制整个数据框。正确做法是用列表收集结果,循环结束后一次性bind_rows()。

    第四个坑是忘记处理缺失值。网页上的数据不一定是完整的,有些字段可能为空。如果不处理,后续分析的时候会各种报错。我一般会在解析阶段就把缺失值统一成NA,然后在清洗阶段决定是填充还是删除。

    第五个坑是没有版本控制。爬虫代码经常需要根据网站变化调整,如果没有版本控制,改着改着就不知道哪个版本能用了。我一般会用 git 管理代码,每次网站改版导致解析逻辑变化时,都提交一次记录。

    最后分享一个实用的小技巧:在正式跑全量之前,先用 5-10 条数据做小规模测试。确认请求、解析、存储整个流程都跑通了,再放开跑全量。这样即使有问题,也能快速发现和修复,不会浪费大量时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进