做数据分析的人,迟早会遇到一个问题:需要的数据不在 Excel 里,不在数据库里,只存在于某个网站上。我刚入行那会儿还在用 RCurl 把整页源码拉下来,再用正则表达式去匹配标题和价格,页面一改版,代码就跟着报废。后来换成 rvest,才真正体会到什么叫“用解析网页的方式写爬虫”,在数据爬虫这件事上省下了一大半时间。
rvest 是 R 语言生态里最成熟的网页爬虫包,由 Hadley Wickham 团队维护,底层依赖 xml2 解析器。它的核心逻辑非常清晰:读取 HTML 文档、定位目标节点、提取内容、再进入数据清洗流程。对于数据分析师、研究生、以及所有不想额外学一门 Python 但又有网页数据采集需求的人来说,rvest 几乎是 R 生态下的最优解。这篇内容我不打算讲虚的,直接从工具选型、环境搭建、核心函数、翻页实战、电商数据场景、以及高频报错这几个维度,把我实际用过的东西完完整整写出来。你可以照着敲一遍,也可以当成 rvest 速查手册收藏。
1. 为什么 R 语言用户做网页采集会首选 rvest
1.1 从 RCurl 到 rvest:我换工具的真实原因
在 rvest 出现之前,R 用户抓网页通常只有两条路:一条是 RCurl 加正则表达式,另一条是 XML 包里的 XPath。RCurl 能做的只是把网页源码拉下来,真正困难的部分——从几十 KB 的 HTML 里准确找到书名、价格、链接——全靠自己写正则。写过正则的人都知道那是什么体验:标签嵌套、属性顺序变化、空白字符干扰,任何一个意外都能让匹配结果变成空。
更痛苦的是页面结构一改版,你的正则就得推倒重来。有一次我抓一个书评网站,对方只是把<div class="book-info">换成了<article class="book">,我整整一个下午都在调试那串像天书一样的匹配规则。后来接触到 rvest,发现它解决的就是这个核心痛点:不让你在字符串层面“肉搏”,而是把网页当成一棵结构化的 DOM 树,你用 CSS 选择器直接定位节点,再用函数把文本、属性、表格批量提取出来。页面结构调整了,多数情况下你只需要改一行选择器,而不是重写一段正则。
1.2 rvest 在 R 生态里的准确位置
说句实话,rvest 不是万能的。它擅长的是“静态 HTML 页面”的数据抽取——也就是数据在服务端渲染好、直接包含在返回的 HTML 里。你可以通过它抓取文章列表、新闻标题、政府公开数据、图书信息、天气数据等大多数常规网页内容。
但如果你面对的是需要登录后才能查看的后台数据、点击“加载更多”才出现的动态内容,或者像拼多多这类完全靠 JavaScript 动态渲染的电商页面,rvest 单靠自己就力不从心了。很多人一开始没有搞清楚这个边界,拿着 rvest 去抓动态网页,折腾半天得到一堆空对象,以为是包有问题,其实是选错了工具。搞清楚“这东西能做什么、不能做什么”,是入门 rvest 之前必须先过的一道坎。
1.3 和其他技术栈的横向对比
我经常被问到一个问题:“我都用 Python 写爬虫了,为什么还要学 rvest?”这个问题的前提就不对。如果你已经在 Python 生态里如鱼得水,确实没必要换。但对已经用 R 做数据分析的人来说,为了抓一个小数据集就切换到 Python,再学一遍 requests、BeautifulSoup、Scrapy,这个学习成本并不划算。
各方案的比较可以从下面这张表看清楚:
| 方案 | 语言 | 学习成本 | 动态页面支持 | 适合场景 |
|---|---|---|---|---|
| rvest | R | 低 | 弱,需配合浏览器自动化解决 | R 环境内的快速数据获取、数据清洗流程 |
| requests + BeautifulSoup | Python | 中 | 弱,同样需要配合 Selenium | Python 技术栈的通用入门爬虫 |
| Scrapy | Python | 高 | 中,配 Selenium 后较强 | 大规模、分布式爬虫项目 |
| RSelenium / chromote | R | 中 | 强,模拟真实浏览器渲染 | 需要登录、点击、滚动加载的动态场景 |
我的建议是:如果你本职工作是数据分析、统计建模,日常用的是 R,那 rvest 完全够用;如果你要做的是千万级页面的采集工程,那应该直接学 Scrapy,而不是在 R 里凑合。工具服务于场景,别为了用某个工具而折腾自己。
2. 搭建抓取环境:会话、编码与请求头的第一道坎
2.1 安装与依赖
rvest 的安装非常简单,一条命令搞定:
install.packages("rvest")它会自动带上几个关键依赖:xml2 负责把 HTML 解析成结构化的 XML 文档,selectr 负责把 CSS 选择器转换成 XPath 表达式,httr 提供底层 HTTP 请求能力。理解这几个依赖很重要,因为它们决定了 rvest 的行为方式。比如 selectr 存在,你才能用h3 a这种 CSS 写法去定位节点,而不是自己手写繁琐的 XPath。
加载的时候养成好习惯,把后面会用到的包一起加载上:
library(rvest) library(dplyr) library(readr) library(data.table)readr用来解析数字和文本格式,data.table的rbindlist在合并多页抓取结果时极其好用。
2.2 两种读页面方式:read_html 与 html_session
rvest 读页面有两种主要方式,很多人一开始没分清楚,结果在处理登录场景时浪费了很多时间。
第一种是read_html(),适合访问无状态的公开页面。它做的事情本质上是一次性 GET 请求,把返回的 HTML 解析成文档对象,然后你就可以在这个对象上做节点定位了:
page <- read_html("https://books.toscrape.com/")第二种是html_session(),它创建一个带 Cookie 的持久会话。如果你要模拟登录、保持登录状态、或者进行表单操作,必须用这个方式。我当时抓一个需要登录的论坛数据,用read_html()每次请求都拿不到完整内容,换成html_session()之后,登录状态一直维持着,后面翻页就顺利多了。
session <- html_session("https://example.com/login") # 配合表单操作 form <- session %>% html_form() form <- form %>% html_form_set(username = "my_name", password = "my_password") session <- session %>% html_submit(form)这里有一个细节值得注意:html_session()返回的本身就是一个会话对象,你可以像管道操作一样把它传给下一步。它的 cookie store 由 httr 在底层帮你维护,你不需要手动处理 Set-Cookie 头。这是 rvest 比“手动构造 httr 请求”更方便的地方。
2.3 User-Agent、超时与重试
很多网站的第一道反爬就是检查请求头里的 User-Agent。如果你直接用 R 的默认 UA 去请求,服务器一看就知道是脚本在访问,可能直接返回 403 或者验证页面。我的习惯是每创建一个新会话,立刻把 UA 设置成常见浏览器的版本:
session <- html_session("https://books.toscrape.com/") session <- session %>% session_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")超时设置同样重要。默认情况下,如果服务器响应慢,R 可能会一直挂在那里,既浪费时间又影响后续请求。用httr的时间配置可以控制请求时长:
page <- read_html(url, timeout = 15)对于稍微正式一点的采集任务,重试机制是必须的。网络抖动、服务器临时过载,都会导致单次请求失败。简单的重试逻辑可以直接用purrr包的insistently(),也可以自己写个循环:
fetch_with_retry <- function(url, max_attempts = 3) { for (i in 1:max_attempts) { res <- tryCatch(read_html(url, timeout = 30), error = function(e) e) if (!inherits(res, "error")) return(res) Sys.sleep(2 * i) } stop("请求失败:", url) }这个函数我在实际项目里一直在用,遇到瞬时错误会自动让步重试,基本能做到“挂掉一次不耽误大局”。
2.4 中文站点的编码乱码问题
rvest 解析 HTML 时,会自动读取页面头部<meta>标签里声明的字符集,所以绝大多数 UTF-8 编码的页面都不会有问题。真正容易翻车的是那些老旧的 GB2312、GBK 编码站点,页面可能没写 charset,或者声明了错误的编码,抓回来全是乱码。
遇到这种情况,先用guess_encoding()猜测一下实际编码:
bin <- readBin(url, "raw", n = 10000) guess_encoding(bin)拿到猜测结果后,再用iconv()或者 xml2 的编码参数做转换。我的经验是:对中文站点,先打印前 500 个字符检查,别埋头往下写,等抓完大量数据才发现全乱码,返工成本很高。这也是我给所有做中文数据采集的人的第一条建议——开工前先验证编码。
3. 核心三件套的配合:定位节点、提取属性、读取文本
3.1 CSS 选择器语法和调试方法
rvest 的节点定位基于 CSS 选择器。如果你有过前端基础,这部分直接能上手;如果没有,记住这几个最常用的写法就够了:
| 目标 | CSS 写法 | 示例 |
|---|---|---|
| 标签选择器 | h1、p、a | 所有 h1 标签 |
| 类选择器 | .price_color | class 里含 price_color 的元素 |
| ID 选择器 | #main | id 为 main 的元素 |
| 属性选择器 | [href] | 带 href 属性的元素 |
| 后代选择器 | h3 a | h3 标签内部的 a 标签 |
| 直接子元素 | ul > li | ul 的直接子 li |
新手最容易栽在“选择器写对了但返回空”这个坑。我的调试方法是:把页面保存到本地,用浏览器打开,按 F12 在开发者工具里用 Ctrl+F 搜索 CSS 选择器,能搜到元素再回来写代码。另一个辅助技巧是使用 SelectorGadget 这类浏览器插件,点几下就能生成目标元素的选择器,虽然它生成的选择器有时比较冗余,但作为初筛非常好用。
3.2 html_elements、html_attr 与 html_text 的配合逻辑
新版 rvest 中,html_nodes()已更名为html_elements(),旧名字仍然可用,但建议直接适应新写法。这个函数的作用是:给定一个节点集合,返回匹配选择器的所有节点。对应的html_element()只返回第一个匹配项,适合只取一个元素的场景。
拿到节点之后,下一步就是“提取”。常用的三个函数分工明确:
html_text():提取节点内部的文本内容,比如书名、价格文字html_attr():提取节点的属性值,比如href、src、classhtml_table():直接把<table>标签转成 data.frame,抓表格型数据时效率极高
举一个最经典的例子,抓取书本网站的单页数据:
page <- read_html("https://books.toscrape.com/") titles <- page %>% html_elements("h3 a") %>% html_text() prices <- page %>% html_elements(".price_color") %>% html_text() links <- page %>% html_elements("h3 a") %>% html_attr("href")这段代码背后是三层结构:html_elements()负责定位,html_text()或html_attr()负责提取,管道运算符%>%把前一步的结果传给后一步。这就是 rvest 和 tidyverse 风格完美融合的体现——抓取数据本身就可以看作一次数据清洗流程的前置环节。
3.3 管道思维下的“一条龙抓取”
rvest 的优势之一是你可以把所有步骤串成一条链,读起来像自然语言。比如抓取书名、价格、评分,然后直接整理成 data.frame:
library(rvest) library(dplyr) page <- read_html("https://books.toscrape.com/") books <- tibble( title = page %>% html_elements("h3 a") %>% html_text(), price = page %>% html_elements(".price_color") %>% html_text(), rating_class = page %>% html_elements("p.star-rating") %>% html_attr("class") )这种写法最大的好处是:每一行代码都对应一个清晰的逻辑单元,后续有任何一步出错,你能立刻定位是选择器的问题、还是提取函数用错了。我在刚开始学习时,喜欢把中间结果打印出来逐个看,而不是一股脑跑完再从头排查。建议你也保留这个习惯。
4. 完整实战:把整个图书榜单抓下来
4.1 先分析 URL 规律,别急着写代码
我见过太多人拿到任务第一反应就是打开 RStudio 写代码,结果写到一半才发现 URL 结构没搞清楚。正确的顺序是:先打开浏览器,手动翻几页,观察 URL 的变化规律。
这次我用的示例站点是 Books to Scrape,它是一个专门为爬虫练习设计的网站。它的目录页 URL 规律非常标准:
https://books.toscrape.com/catalogue/page-1.html https://books.toscrape.com/catalogue/page-2.html ... https://books.toscrape.com/catalogue/page-50.html总共 50 页,每页 20 本书,正好 1000 本。分析完 URL 规律之后,再确认列表页里书名、价格、评分对应的 DOM 结构。这个站点结构稳定,很适合做 rvest 的完整练习。
4.2 单页抓取的完整代码
先写单页逻辑,确认没问题了再扩展成循环。这是我一直坚持的开发节奏——小步快跑,别一次到位。
单页抓取代码:
library(rvest) library(dplyr) library(readr) base_url <- "https://books.toscrape.com/catalogue/page-1.html" page <- read_html(base_url) books_page <- tibble( title = page %>% html_elements("h3 a") %>% html_text(), price_text = page %>% html_elements(".price_color") %>% html_text(), link = page %>% html_elements("h3 a") %>% html_attr("href"), rating_class = page %>% html_elements("p.star-rating") %>% html_attr("class") ) price_num <- parse_number(books_page$price_text) books_page$price <- price_num这里特别提一下parse_number(),它来自 readr 包,专门用来处理“£51.77”这类带货币符号的文本,它会自动把数字部分提取成数值型。如果你想自己用正则处理也行,但parse_number()更省事,而且对“1,234.56”这种带千分位的文本也能正确处理。
评分字段star-rating Three里包含了评分信息,提取出来之后需要映射成数值。我写一个简单的映射字典:
rating_map <- c("One" = 1, "Two" = 2, "Three" = 3, "Four" = 4, "Five" = 5) rating_key <- stringr::str_extract(books_page$rating_class, "One|Two|Three|Four|Five") books_page$rating <- rating_map[rating_key]4.3 翻页循环与随机延时
单页逻辑跑通后,扩展成循环就很简单了。核心思路是:先把每一页的结果存进一个列表,最后用rbindlist()一次性合并。注意循环里一定要加延时——这不是做做样子,而是对目标服务器的基本尊重,也是减少被封概率的有效手段。
library(data.table) library(rvest) library(readr) all_books <- list() for (i in 1:50) { url <- sprintf("https://books.toscrape.com/catalogue/page-%d.html", i) page <- read_html(url, timeout = 15) df <- data.frame( title = page %>% html_elements("h3 a") %>% html_text(), price = page %>% html_elements(".price_color") %>% html_text() %>% parse_number(), link = page %>% html_elements("h3 a") %>% html_attr("href"), rating_class = page %>% html_elements("p.star-rating") %>% html_attr("class"), stringsAsFactors = FALSE ) all_books[[i]] <- df Sys.sleep(runif(1, 0.5, 1.5)) } books <- rbindlist(all_books, fill = TRUE)有几个细节值得解释。sprintf()负责生成带页码的 URL,比手写字符串拼接更安全。Sys.sleep(runif(1, 0.5, 1.5))的含义是每次请求后随机休眠 0.5 到 1.5 秒,固定间隔很容易被服务器识别为机器行为,随机化之后更像人工访问。fill = TRUE保证即使某页字段缺失也不会中断合并。
4.4 数据清洗、去重与导出
抓下来的数据还不能直接用,需要经过一轮清洗。首先是处理重复值——翻页抓取时,偶尔会出现页面内容重复或者你的循环重复执行,所以去重是必须的:
library(dplyr) books_clean <- books %>% distinct(title, .keep_all = TRUE)然后是链接补齐。抓下来的link字段是相对路径,类似../../../../catalogue/a-light-in-the-attic_1000.html,直接使用会失效。用xml2::url_absolute()可以基于基础 URL 拼出完整地址:
library(xml2) full_links <- url_absolute(books_clean$link, base_url) books_clean$full_link <- full_links最后导出成 CSV,方便后续分析:
write.csv(books_clean, "books_toscrape.csv", row.names = FALSE)如果想保留更多格式信息,可以用writexl::write_xlsx()导出 Excel。到这里,一个完整的“列表页翻页抓取 + 数据合并 + 清洗导出”流程就跑通了。整个流程的核心技巧就是“先单页、再循环、合并清洗”,这套模式可以迁移到绝大多数静态网站。
5. 商品数据的现实问题:动态渲染、签名接口与合规边界
5.1 电商商品数据抓取的典型形态
最近半年,问“怎么抓拼多多商品数据”的朋友特别多。先别急,我们来拆解一下典型的电商商品数据结构。大多数电商平台的数据分两层:列表页和详情页。列表页展示商品标题、价格、销量、店铺名;详情页包含更深入的 SKU 信息、评价、库存等。如果目标网站是服务端渲染的,rvest 抓取效率会非常高,列表页一个循环就能把核心字段全拿下来。
以老牌的静态电商页面为例,列表页结构通常是<ul>包裹的<li>集合,每个<li>内部有几段固定的 DOM 结构。你要做的就是像第 4 章那样:定位商品节点、提取文本、翻页循环、合并清洗。这套流程对服务端渲染的电商网站完全适用。
5.2 为什么拼多多这类平台 rvest 很难直接抓
但拼多多这类平台完全是另一回事。你通过普通请求拿到的 HTML 只是一个空壳,商品数据全部靠 JavaScript 动态渲染填充。如果直接read_html(),得到的页面节点里根本没有价格和销量,选择器自然什么都选不中。更麻烦的是,它的数据接口带有签名参数,请求签名算法复杂且经常变化,想通过模拟接口拿到数据,需要做大量的逆向工作,这已经不是 rvest 的能力边界,也超出了常规爬虫的技术范畴。
每次有人满怀期待地问我“用 rvest 怎么抓拼多多”,我都得先把这个现实讲清楚:不是 rvest 不行,而是这类平台压根不适合用静态解析工具去碰。与其在反爬体系上硬刚,不如换个思路。
5.3 面对动态页面的合规路径与替代方案
遇到动态渲染页面,R 生态里不是没有出路,但要分清主次。
第一种思路,查找页面背后是否暴露了相对规整的 JSON 接口。很多网站的数据其实是通过接口返回的,拿到 JSON 之后用jsonlite::fromJSON()解析,比解析 HTML 还干净。不过要注意,接口是否开放、能否绕过签名限制,这取决于站点的技术策略,不要投入太多时间在逆向这种灰色操作上。
第二种思路,用浏览器自动化方案。R 里可以用chromote包启动一个无头 Chrome,让页面在真实浏览器里加载完成,等 JavaScript 执行完毕之后,再把最终的 HTML 取回交给 rvest 解析。思路大致是:启动浏览器、访问页面、等待数秒、获取外层 HTML、回到 rvest 处理。这种方案的缺点是慢、吃内存,且比较容易被检测,适合小规模、低频的数据采集。
第三种思路,也是最建议的路径:优先寻找官方开放数据渠道。电商平台大多有开放平台或数据分析工具,哪怕只能拿到聚合数据,也比冒着合规风险硬爬划算。我个人做商品价格研究时,第一选择永远是公开数据集、官方接口和第三方正规数据服务,只有这些渠道确实覆盖不了需求时,才会考虑亲自采集,并且严格控制频率、控制用量。
这里必须把话说得重一点。robots.txt协议要遵守,平台服务条款要尊重,数据用于个人学习研究和批量下载用于商业竞争,性质完全不同。涉及个人信息的数据,还受到个人信息保护法、数据安全法等法律法规的约束。做数据采集的人一定要有底线思维:目的正当、手段合规、频率克制、规模适度。别为了一次分析任务把自己搭进去,这不值得。
6. 抓取路上的高频报错与反爬实战经验
6.1 HTTP 状态码暗藏的语义
爬虫过程中,服务器不会陪你演戏,它只会用状态码告诉你结果。我整理了一个快速对照表,你可以直接收藏:
| 状态码 | 含义 | 常见原因 | 应对策略 |
|---|---|---|---|
| 200 | 正常 | - | 继续抓取 |
| 301 / 302 | 重定向 | 页面更换地址 | 检查最终 URL,或让会话自动跟随 |
| 403 | 禁止访问 | UA 被识别、IP 受限 | 换 UA、降频、暂停一段时间 |
| 404 | 页面不存在 | URL 规律分析有误 | 重新检查 URL 结构 |
| 429 | 请求太多 | 触发了限流 | 立即停止,加长延时 |
| 503 | 服务暂不可用 | 服务器过载或主动反爬 | 等待后重试,降低频率 |
403 是新手最常碰到的墙。很多时候不是因为你的 IP 被封,而是 UA 太“裸”了,一眼就被识别。我见过一个案例,全部代码只差一行session_user_agent(),加上之后立刻恢复正常抓取。所以当遇到 403 时,先检查请求头,再考虑是不是频率问题。
6.2 选择器返回空:最让人抓狂的“假成功”
html_elements()返回空对象比直接报错更让人头疼——代码没报错,但数据就是空的。我总结下来主要有四种原因:页面结构变了、选择器写错了、目标内容是 JS 动态加载的、以及页面给了你一个反爬的假页面。
排查路径很固定。先把 HTML 保存下来:
page <- read_html(url) writeLines(as.character(page), "page_backup.html")然后用浏览器打开这个备份文件,按 F12 在 Elements 面板里检查结构,重新确认选择器。如果本地备份文件里根本没有你要找的内容,那就是动态加载问题,需要换浏览器自动化方案;如果本地文件有内容,说明是选择器或者 URL 参数的问题。这套排查流程我用了很多年,效率很高。
6.3 超时、连接失败与 SSL 问题
抓取过程中另外一类常见报错来自网络层面。Error in open.connection表示服务器没有响应,Operation timed out说明请求超时,SSL 证书错误则可能因为目标站点的证书链不完整。这些问题的共性解法是:
- 调大
timeout参数,默认时间往往不够用 - 加入重试机制,避免一次网络的偶发故障就中断整个采集任务
- 优先使用 https 协议访问目标站点,减少被中间环节干扰的可能
有一个细节容易被忽略:如果你的采集任务是自动运行的,比如放在服务器上定时执行,一定要加上完整的日志记录。每次抓取成功后写一行日志,记录时间、URL、状态和数据行数。等某一天数据异常时,这份日志能帮你快速定位问题发生在哪个环节。
6.4 多页数据合并中的几个小坑
翻页抓取最让人头疼的问题往往不在抓取阶段,而在合并阶段。最常见的报错是rbindlist遇到NULL元素。当某一页因为网络原因返回空数据时,列表里就会混入空元素,合并时直接报错。解决办法是合并前过滤掉空的页结果:
all_books <- Filter(function(x) !is.null(x) && nrow(x) > 0, all_books) books <- rbindlist(all_books, fill = TRUE)另一个坑是字段类型不一致。比如第一页价格是数值型,后面的页面由于数据缺失变成了字符型,合并时自动转换失败。解决方法是循环里统一字段类型,或者在合并后用type.convert()做一次整体校正:
books <- rbindlist(all_books, fill = TRUE) books <- type.convert(books, as.is = TRUE)最后一个常见问题是字符编码混用。我抓过一些站点,前 30 页是 UTF-8,后 20 页突然变成 GBK,合并后部分中文标题变乱码。这种情况下,循环里最好显式统一编码,解析阶段就做转换,别等到合并之后再补救。
最后分享一个我保持了很多年的习惯:任何抓下来的原始网页,我都会先存一份本地备份再解析。别嫌这一步占磁盘,当你需要回头排查选择器问题、或者想验证某个字段是本来就缺还是没抓到的时候,这份备份能救你的命。爬虫写到后面,你会发现真正的核心竞争力不是爬得有多快,而是出了问题能多快定位、多稳恢复。