跳至内容
免费开始
返回博客

R 语言网页抓取:rvest 与 httr2 实用指南

Raluca Penciuc最后更新于 6 min read
R 语言网页抓取:rvest 与 httr2 实用指南
要点速览: 在 R 中做网页抓取,用 rvest 通过 CSS 选择器或 XPath 解析 HTML,用 httr2 获取页面,并处理重试和速率限制。只抓一个页面时,单用 rvest 就够了;一旦要批量爬取,就用 httr2 获取、用 rvest 解析;页面由 JavaScript 生成时,改用 read_html_live()。本指南涵盖表格、分页、script 标签中的数据、抓取 API 以及离线测试。

你需要的数据就在某个网页上,而你希望不离开 R 就把它放进一个 tibble。在 R 中做网页抓取,只需两个包,再加上你早已熟悉的 tidyverse。刚接触这个概念?可以先了解什么是网页抓取。

本指南以 Goodreads 作为贯穿全文的示例:读者评选榜单 "Best Books Ever",以及乔治·奥威尔《1984》的图书页面。你将用 rvest 解析一个页面和一张表格,用 httr2 有礼貌地爬取分页列表,提取由 JavaScript 生成的数据,在不改动解析器的前提下把被封锁的页面交给抓取 API,离线测试代码,并汇总结果。

快速上手

  • 安装:install.packages(c("rvest", "httr2"))
  • 用 read_html() 加载,用 html_elements() 选择,用 html_text2() 或 html_attr() 提取
  • 对由 JavaScript 生成的页面,使用 read_html_live()
  • 使用 R 4.1 或更高版本,以便使用原生管道符 |>

在 R 中做网页抓取需要准备什么?

你需要 R 4.1 或更高版本,以及两个包:用 rvest 解析 HTML,用 httr2 获取页面并处理重试和速率限制。页面由 JavaScript 生成时,再加上 chromote;网站封锁你时,再用一个抓取 API。其余的一切,从 dplyr 到 readr,都是你已经在用的 tidyverse。

R 很适合做抓取,因为抓到的行会直接落进 tibble,清洗和分析都能在同一个会话里完成。

rvest 负责解析:用 CSS 选择器或 XPath 选中元素,提取文本、属性和表格。httr2 负责获取:设置请求头,在请求之间等待,重试临时失败,并行发送请求。rvest 本身仍然可以直接获取一个 URL,抓单个页面时这样做没问题。一旦开始批量爬取,就用 httr2 获取、用 rvest 解析。

包

用途

何时使用

CRAN 状态(2026 年 10 月)

rvest

解析 HTML,用 CSS 或 XPath 选择,表格,read_html_live()

始终

1.0.5

xml2

rvest 底层的 HTML/XML 引擎;url_absolute()

解析链接

1.6.0

httr2

带请求头、重试、限速和并行请求的获取

超过少量页面,或调用 API

1.3.0

jsonlite

解析来自接口或 script 标签的 JSON

数据以 JSON 形式提供

2.0.0

chromote

无头 Chrome(read_html_live() 使用它)

由 JavaScript 生成的页面

0.5.1

RSelenium

在 R 中使用 Selenium WebDriver

已有 Selenium 环境或需要 Firefox

1.7.10(需要 Java 和驱动程序)

testthat

离线测试解析器

任何需要重复运行的抓取程序

3.3.2

polite, Rcrawler

较早的礼貌访问和爬取辅助工具

不要用它们开始新代码

截至 2026 年 10 月已在 CRAN 归档

Rcrawler 和 polite 都已在 CRAN 归档(Rcrawler 于 2025 年 4 月,polite 于 2026 年 9 月),因此依赖它们的旧教程无法顺利安装。你也不需要它们:httr2 的 req_throttle() 负责控制请求节奏,本指南后面的 robots.txt 检查则涵盖了 polite 的其余功能。

如何为抓取配置 R?

如果还没有安装 R,请从 CRAN 下载 R。当前版本是 R 4.6.2,本文的每段代码在 R 4.1 或更高版本上都能运行。任何编辑器都可以:RStudio、Positron 或 VS Code。然后用一行命令安装本指南用到的包:

install.packages(c("rvest", "httr2", "jsonlite", "dplyr", "readr", "chromote", "testthat"))

read_html_live() 还需要你的电脑上装有 Google Chrome。chromote 会自动找到它。

新建一个项目文件夹,并在其中创建 fixtures/ 子文件夹,用来存放保存下来的页面。把本指南要离线解析的三个 Goodreads 页面保存下来:

download.file("https://www.goodreads.com/list/show/1.Best_Books_Ever", "fixtures/list-page1.html")
download.file("https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2", "fixtures/list-page2.html")
download.file("https://www.goodreads.com/book/show/5470.1984", "fixtures/book-1984.html")

如何用 rvest 抓取网页?

用 read_html() 加载页面,用 html_elements() 和 CSS 选择器选中重复出现的元素,再用 html_element() 加上 html_text2() 或 html_attr() 从每个元素中提取字段。用这些向量构建一个 tibble。由于 rvest 函数是向量化的,你很少需要写循环,缺失的字段会返回 NA,而不会让程序出错。

Goodreads 的 "Best Books Ever" 榜单每页显示 100 本书。每本书是 class 为 tableList 的表格中的一行,每行包含排名、书名链接、作者链接,以及一行类似 "4.36 avg rating — 10,338,532 ratings" 的文字。下面这个函数把一页榜单转换成 tibble:

# parse_books.R: turn one Goodreads list page into a tibble
library(rvest)
library(tibble)

parse_books <- function(page, page_url) {
  rows  <- html_elements(page, "table.tableList tr")
  links <- html_element(rows, "a.bookTitle")
  stats <- html_text2(html_element(rows, "span.minirating"))   # "4.36 avg rating — 10,338,532 ratings"
  tibble(
    rank       = as.integer(html_text2(html_element(rows, "td.number"))),
    title      = html_text2(links),
    author     = html_text2(html_element(rows, "a.authorName")),
    avg_rating = readr::parse_number(stats),
    ratings    = readr::parse_number(sub(".*avg rating", "", stats)),
    url        = xml2::url_absolute(html_attr(links, "href"), page_url)
  )
}

逐行说明:

  • html_elements(page, "table.tableList tr") 返回所有行:每本书一个节点。
  • html_element(rows, "a.bookTitle")(单数形式)每行恰好返回一个结果,这正是各列保持对齐的关键。如果某一行缺少作者,你会在该位置得到 NA,而不是得到一个更短的向量,导致后面所有值错位。
  • html_text2() 按浏览器显示的样子返回文本,会合并换行和连续空格。较早的 html_text() 返回原始文本,连缩进也包括在内。
  • readr::parse_number() 读取字符串中的第一个数字,因此会把评分那一行变成 4.36。先删掉 "avg rating" 及之前的所有内容,剩下 "— 10,338,532 ratings",解析结果就是 10338532。这两列都是可以排序、求平均的数字,而不是字符串。
  • xml2::url_absolute() 把每个相对 href(例如 /book/show/2767052-the-hunger-games)转换成完整 URL。要以链接所在的页面为基准来解析,而不是以你手动输入的网站根地址为基准。链接可能是相对于页面所在目录的,基准一旦用错,生成的 URL 就会返回 404。url_absolute() 参考文档详细说明了相关规则。

在保存的页面上运行它:

# 01_static.R: parse a saved page (offline); the same code works on a live URL
source("parse_books.R")
page  <- read_html("fixtures/list-page1.html")
books <- parse_books(page, "https://www.goodreads.com/list/show/1.Best_Books_Ever")
print(books, n = 5, width = 100)
cat("rows:", nrow(books), "\n")

输出:

# A tibble: 100 × 6
   rank title                                                       
  <int> <chr>                                                       
1     1 The Hunger Games (The Hunger Games, #1)                     
2     2 Pride and Prejudice                                         
3     3 To Kill a Mockingbird                                       
4     4 Harry Potter and the Order of the Phoenix (Harry Potter, #5)
5     5 The Book Thief                                              
  author          avg_rating  ratings url                                       
  <chr>                <dbl>    <dbl> <chr>                                     
1 Suzanne Collins       4.36 10338532 https://www.goodreads.com/book/show/27670…
2 Jane Austen           4.3   5042082 https://www.goodreads.com/book/show/1885.…
3 Harper Lee            4.26  7157438 https://www.goodreads.com/book/show/2657.…
4 J.K. Rowling          4.5   3954056 https://www.goodreads.com/book/show/2.Har…
5 Markus Zusak          4.39  2994493 https://www.goodreads.com/book/show/19063…
# ℹ 95 more rows
rows: 100

有了保存的副本,你就可以反复调试,而不必每次运行都发送请求。read_html() 也接受 URL,所以 read_html("https://www.goodreads.com/list/show/1.Best_Books_Ever") 会解析在线的榜单。

如何找到合适的 CSS 选择器?

在 Chrome 或 Firefox 中打开页面,右键点击你需要的数据,选择“检查”。找一个每条记录重复出现一次的 class 或标签,在开发者工具(DevTools)控制台中用 document.querySelectorAll() 测试,然后把同样的选择器粘贴到 html_elements() 中。优先使用稳定的 class 名称,而不是从 DevTools 复制来的冗长自动生成路径。

在 Goodreads 榜单上检查一个书名,可以看到 <a class="bookTitle"> 位于 table.tableList 的某个 <tr> 之内。在控制台输入 document.querySelectorAll("table.tableList tr").length,会返回 100,正好每本书一个,说明这正是要找的重复元素。

本指南中出现了六种选择器模式,它们能覆盖大部分抓取工作:

  • 标签.类名: a.bookTitle 匹配 class 为 bookTitle 的 <a> 元素。
  • 后代: table.tableList tr 匹配该表格内任意位置的行。
  • 仅类名: .minirating 匹配带有该 class 的任何元素(为表达清楚,我们写成 span.minirating)。
  • ID: script#__NEXT_DATA__ 匹配带有该 id 的唯一元素。
  • 属性值: script[type="application/ld+json"] 匹配某一类型的 script。
  • 控件属性: button[aria-label="Book details and editions"] 通过按钮的无障碍名称找到它,这个名称通常比样式更持久。

避免使用 DevTools 中 "Copy selector" 生成的长路径,例如 #all_votes > table > tbody > tr:nth-child(1) > td:nth-child(3)。只要布局一变,它们就会失效。带随机后缀的自动生成 class 名称也同样脆弱。

如果你更喜欢点选操作,可以用浏览器扩展 SelectorGadget:点击你想要的元素,它会给出建议的选择器。使用前先在控制台里验证一下。

rvest 也接受 XPath:html_elements(page, xpath = "//a[@class='bookTitle']") 与 CSS 版本的作用相同。关于 XPath 在哪些场景下更合适,请参阅 XPath 何时胜过 CSS。工作时也可以把完整的选择器参考放在手边。

提示: 如果某个选择器在 DevTools 中有效,在 R 中却返回零个元素,那么数据可能并不在服务器发送的 HTML 里。DevTools 显示的是 JavaScript 运行之后的页面,而 read_html() 看到的是运行之前的页面。

如何在 R 中抓取 HTML 表格?

用 html_element() 选中表格,再传给 html_table(),它会返回一个 tibble,表格中的每一列都对应 tibble 中的一列。如果表格没有表头行,设置 header = FALSE,然后重命名各列。之后用 readr::parse_number() 清理数字,因为 html_table() 会原样保留货币符号和文本。

Goodreads 榜单是一张真正的 HTML 表格,所以 html_table() 一次调用就能读取:

# 03_table.R: read the same list with html_table()
library(rvest)
page <- read_html("fixtures/list-page1.html")
raw  <- page |> html_element("table.tableList") |> html_table(header = FALSE)
dim(raw)
raw$X3 <- trimws(gsub("\\s+", " ", raw$X3))   # collapse the cell's line breaks
print(raw[, c("X1", "X3")], n = 3, width = 100)

输出:

[1] 100   4
# A tibble: 100 × 2
     X1
  <int>
1     1
2     2
3     3
  X3                                                                            
  <chr>                                                                         
1 The Hunger Games (The Hunger Games, #1) by Suzanne Collins 4.36 avg rating — …
2 Pride and Prejudice by Jane Austen 4.30 avg rating — 5,042,082 ratings score:…
3 To Kill a Mockingbird by Harper Lee 4.26 avg rating — 7,157,438 ratings score…

你得到 100 行、四列。第一列是排名,已经是整数类型。第三列则暴露了局限:Goodreads 把书名、作者、评分和投票得分都塞进了同一个单元格,因此 html_table() 只能把它们作为一个字符串整体返回。

当每个单元格只包含一个值时(比如价格表或统计表),html_table() 是最快的选择。如果一个单元格里塞了多个字段,就应像 parse_books() 那样在每一行内部分别选择,这样才能得到干净、类型正确的列。

对整个页面调用 html_table() 会返回所有表格组成的列表,所以要先选中你需要的那一张。

如何在 R 中抓取多个页面?

抓取第一页,读取“下一页”链接,用 xml2::url_absolute() 解析它,然后重复,直到链接消失或达到页数上限。通过 httr2 获取页面:用 req_throttle() 限制请求速率,用 req_retry() 重试 429 和 503 响应,同时遵守 Retry-After 响应头。

read_html(url) 适合抓单个页面。批量爬取时,请改用 httr2 获取,因为这样你可以在一条管道里同时控制 user agent、超时、请求速率和重试:

# 02_paginate.R: polite live crawl with httr2 retries, throttling, and next-page links
library(httr2)
source("parse_books.R")

fetch_html <- function(url) {
  request(url) |>
    req_user_agent("r-scraping-tutorial/1.0 (you@example.com)") |>
    req_throttle(capacity = 1, fill_time_s = 2) |>   # at most ~1 request every 2 seconds
    req_retry(max_tries = 3) |>                      # retries 429 and 503, honors Retry-After
    req_timeout(30) |>
    req_perform() |>
    resp_body_html()
}

scrape_list <- function(start_url, max_pages = 3) {
  url <- start_url
  out <- list()
  for (i in seq_len(max_pages)) {
    page     <- fetch_html(url)
    out[[i]] <- parse_books(page, url)
    next_href <- html_attr(html_element(page, "a.next_page"), "href")
    if (is.na(next_href)) break
    url <- xml2::url_absolute(next_href, url)
  }
  dplyr::bind_rows(out, .id = "page")
}

books <- scrape_list("https://www.goodreads.com/list/show/1.Best_Books_Ever", max_pages = 3)
print(dplyr::count(books, page))
print(range(books$rank))

输出:

# A tibble: 3 × 2
  page      n
  <chr> <int>
1 1       100
2 2       100
3 3       100
[1]   1 300

fetch_html() 各部分的作用:

  • req_user_agent() 用来标识你的抓取程序,并给网站留一个联系你的方式。请在这里填写真实的地址。
  • req_throttle(capacity = 1, fill_time_s = 2) 是一个令牌桶:容量为一个请求,每两秒补充一次。因此无论循环跑得多快,爬虫对同一主机最多大约每两秒发送一个请求。
  • req_retry() 会重试临时性失败。默认情况下,这指的是 HTTP 429 Too Many Requests 和 503 Service Unavailable;如果服务器发送了 Retry-After 响应头,httr2 会等待相应时长后再重试。
  • req_timeout(30) 防止某个卡住的请求拖垮整个爬取过程。

scrape_list() 跟随 "Next" 链接(a.next_page),而不是去猜页码。它以当前 URL 为基准解析链接,并在链接消失或达到 max_pages 时停止。bind_rows(.id = "page") 记录了每一行的来源页,而 rank 的范围是 1 到 300,证明没有跳过或重复任何页面。

开发阶段请把 max_pages 设得小一些。三页和一百页能让你学到的东西一样多。

如何并行抓取页面?

httr2 的 req_perform_parallel() 接收一个请求列表并同时执行多个请求,并用 max_active 限制同时进行的请求数(默认为 10)。用 lapply() 遍历你的 URL 来构建这个列表,并给每个请求加上 req_throttle(),让发往同一主机的总体速率保持在合理范围。不要按 CPU 核心数对同一个网站开启多个 worker。那会成倍放大你的流量,很快就会被封。面对成千上万个 URL 时,瓶颈在网络而不在 R:解析一个页面只需几毫秒。并行任务应分散到不同主机上,而不是集中在一个主机上。

如何判断页面是否需要 JavaScript?

用 read_html() 获取原始 HTML,检查你的选择器能否找到内容。如果它返回零个元素,而浏览器里却能看到数据,说明这部分内容是由 JavaScript 生成的。在动用浏览器之前,先看看 script 标签里面:很多网站会以 JSON 形式下发记录,jsonlite 可以直接解析。

Goodreads 上《1984》的页面有一个 "Book details and editions" 按钮。在浏览器中点击它,会展开一个面板,显示该版本的装帧形式、出版社和语言。在原始 HTML 中,这个面板还不存在。但它背后的数据已经存在,就在两个 script 标签里:

# 04_js_check.R: is the data in the HTML, or built by JavaScript?
library(rvest)
page <- read_html("fixtures/book-1984.html")
cat("edition details in raw HTML:", length(html_elements(page, "div.EditionDetails")), "\n")

# 1. JSON-LD: the structured summary many sites publish for search engines
ld <- page |> html_element('script[type="application/ld+json"]') |> html_text2() |> jsonlite::fromJSON()
book <- tibble::tibble(
  title      = ld$name,
  author     = ld$author$name[1],
  pages      = ld$numberOfPages,
  avg_rating = ld$aggregateRating$ratingValue,
  ratings    = ld$aggregateRating$ratingCount
)
print(book)

# 2. __NEXT_DATA__: the app state the page's JavaScript renders from
next_data <- page |> html_element("script#__NEXT_DATA__") |> html_text2() |>
  jsonlite::fromJSON(simplifyVector = FALSE)
state     <- next_data$props$pageProps$apolloState
this_book <- Filter(\(x) identical(x$`__typename`, "Book") && identical(x$legacyId, 5470L), state)[[1]]
cat("publisher:", this_book$details$publisher, "| language:", this_book$details$language$name, "\n")

输出:

edition details in raw HTML: 0 
# A tibble: 1 × 5
  title author        pages avg_rating ratings
  <chr> <chr>         <int>      <dbl>   <int>
1 1984  George Orwell   328       4.21 5812589
publisher: New American Library | language: English 

选择器什么也没找到,说明面板是 JavaScript 生成的,但页面里依然带着这些数据。

JSON-LD(script[type="application/ld+json"])是许多网站为搜索引擎发布的结构化摘要。它体积小、有 schema.org 的文档说明,而且比页面布局变化得少,所以在商品、图书、菜谱和活动页面上,应首先查找它。

__NEXT_DATA__ 是页面的 JavaScript 框架进行渲染所依据的状态数据。Goodreads 把记录存放在一个按类型作为键的扁平列表中,因此代码只保留 ID 与这本书匹配的 Book 条目。它包含可见 HTML 中没有的字段,比如出版社。但它的结构属于网站内部实现,可能随时变化且不会提前通知,所以要用测试把它覆盖起来。

第三个来源是网站自己的 JSON 接口:在 DevTools 中打开 Network 面板,按 Fetch/XHR 过滤,然后刷新页面。httr2 可以调用你在那里找到的接口,resp_body_json() 会解析返回结果,前提是 robots.txt 允许访问该路径。

这三种方式通常都比在浏览器中渲染页面更快、更稳定。

如何在 R 中抓取由 JavaScript 渲染的页面?

使用 rvest 的 read_html_live()。它通过 chromote 在无头 Chrome 中加载页面,让你在渲染后的 DOM 上使用常用的 rvest 函数,还能点击、输入和滚动。请先安装 Google Chrome。如果任务量更大,或者网站会封锁你,就用抓取 API 远程渲染,而不是自己运行 Chrome。

read_html_live() 打开《1984》页面,点击详情按钮,并读取随之出现的面板:

# 05_live.R: render the page in headless Chrome and click a button (rvest >= 1.0.4, chromote + Chrome)
library(rvest)
options(chromote.timeout = 30)   # heavy pages can take longer than the 10-second default
page <- read_html_live("https://www.goodreads.com/book/show/5470.1984")
Sys.sleep(2)   # simple wait; see the note on waiting for a selector
page$click('button[aria-label="Book details and editions"]')
Sys.sleep(1)
items <- page$html_elements("div.EditionDetails div.DescListItem")
details <- tibble::tibble(
  field = items |> html_element("dt") |> html_text2(),
  value = items |> html_element("dd") |> html_text2()
)
print(details)
invisible(page$session$close())

输出:

# A tibble: 3 × 2
  field     value                               
  <chr>     <chr>                               
1 Format    328 pages, Mass Market Paperback    
2 Published July 1, 1950 by New American Library
3 Language  English                             

read_html_live() 自 rvest 1.0.4 起提供,返回一个实时页面对象。它的 $html_elements() 方法与你熟悉的同名函数用法相同,结果可以直接传给 html_element() 和 html_text2()。同一个对象还可以用 $click() 点击选择器、用 $type() 在字段中输入、用 $scroll_to() 滚动到某个位置,足以应对“加载更多”按钮和无限滚动。read_html_live() 参考文档列出了所有方法。

Sys.sleep() 是最简单的等待方式。对于加载慢的页面,可以写一个简短的循环,每半秒检查一次 length(page$html_elements("div.EditionDetails")),直到它大于零或超出时间上限。options(chromote.timeout = 30) 为较重的页面留出更长的加载时间。用完后请关闭会话,因为每个会话都会让一个 Chrome 进程持续运行。

第一次接触没有窗口的浏览器?请阅读无头浏览器的工作原理。

RSelenium 是较早的方案。它仍在 CRAN 上,如果你的团队已经在运行 Selenium Grid 或需要 Firefox,它依然有用。代价在于配置:需要 Java、Selenium 服务器或驱动程序,而且浏览器和驱动程序的版本必须匹配。

本地 Chrome 处理少量页面没有问题。当你需要在云端运行、带有等待和操作的托管浏览器会话时,请改用托管的自动化浏览器。

如何在 R 中通过抓取 API 抓取被封锁的页面?

在 httr2 中把目标 URL 发给抓取 API,然后照常用 rvest 解析响应。使用 WebScrapingAPI 的 Scraper API 时,只需带上你的 api_key 和 URL 调用一个接口。对于 JavaScript 页面,加上 render_js=1。把密钥放在 .Renviron 中,绝不要写进代码。你的解析函数不需要任何改动。

在友好的网站上,重试和限速能解决大多数问题。出现以下症状,说明本地获取已经成为瓶颈:

  • 礼貌重试之后,仍不断返回 403 或 429 响应
  • 本该显示内容的地方出现了验证页面或同意页面
  • 内容因国家而异,而你需要某个特定地区的版本
  • 需要渲染的 JavaScript 页面多到本地 Chrome 无法应付

在更换获取层之前,先试试基础方法。还有其他减少封锁的方法,其中最重要的有四点:

  • 放慢速度:调大 req_throttle() 中的 fill_time_s。
  • 发送带有联系方式的真实 user agent。
  • 缓存已经获取过的页面,而不是重复请求。
  • 不抓取不需要的内容,比如图片或与你的问题无关的页面。

如果症状依旧,就更换获取层。WebScrapingAPI 的 Scraper API 接收一个 URL 并返回页面,代理、重试以及可选的 JavaScript 渲染都由该服务负责处理。它没有 R SDK,你也不需要:一个 httr2 函数就是全部的集成代码。

# 06_scraper_api.R: call WebScrapingAPI's Scraper API from R with httr2
library(httr2)
library(rvest)

wsa_get <- function(url, render_js = FALSE, country = NULL) {
  key <- Sys.getenv("WSA_API_KEY")
  if (key == "") stop("Set WSA_API_KEY in your environment (for example in ~/.Renviron).")
  request("https://api.webscrapingapi.com/v2") |>
    req_url_query(
      api_key   = key,
      url       = url,                       # httr2 URL-encodes query values
      render_js = if (render_js) 1 else NULL,
      country   = country
    ) |>
    req_retry(max_tries = 3) |>
    req_timeout(120) |>
    req_perform()
}

# See exactly what you'll send, without sending it
req <- request("https://api.webscrapingapi.com/v2") |>
  req_url_query(api_key = "YOUR_KEY",
                url = "https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2",
                country = "us")
req$url

输出:

[1] "https://api.webscrapingapi.com/v2?api_key=YOUR_KEY&url=https%3A%2F%2Fwww.goodreads.com%2Flist%2Fshow%2F1.Best_Books_Ever%3Fpage%3D2&country=us"

打印出来的 URL 说明了 req_url_query() 为什么重要。目标 URL 本身带有 ?page=2。如果把它原样拼进另一个 URL,其中的 ? 和任何 & 都会被当成 API 的参数,API 就会抓错页面。httr2 会把它们编码为 %3F 和 %26,目标 URL 因此能完整送达。值为 NULL 的参数会被丢弃,所以 render_js 和 country 只会在你设置时才出现。

不要把密钥写进代码。把下面这一行添加到你主目录下的 .Renviron 文件中,然后重启 R:

WSA_API_KEY=your_api_key_here

Sys.getenv("WSA_API_KEY") 会读取它。R 手册解释了 R 在启动时如何读取 .Renviron。如果没有设置该变量,wsa_get() 会停止运行并报错 Error: Set WSA_API_KEY in your environment (for example in ~/.Renviron).。无效的密钥会返回 HTTP 401,响应体为 {"error":"Invalid API key"},httr2 会把它转换成 R 错误,因此错误的密钥绝不会悄无声息地变成一个空页面。

render_js = TRUE 会在返回 HTML 之前先运行页面的 JavaScript。country = "us" 会以该国家访客看到的样子获取页面。JavaScript 渲染和地理定位不额外消耗积分。

响应会传给你一开始写好的那个 parse_books():

source("parse_books.R")
source("wsa_get.R")   # the wsa_get() function from the previous snippet

target <- "https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2"
page   <- wsa_get(target, country = "us") |> httr2::resp_body_html()
books  <- parse_books(page, target)
print(books, n = 5, width = 100)
cat("rows:", nrow(books), "\n")

# Ask the API to run the page's JavaScript before it returns the HTML
book_page <- wsa_get("https://www.goodreads.com/book/show/5470.1984", render_js = TRUE) |>
  httr2::resp_body_html()
html_text2(html_element(book_page, 'h1[data-testid="bookTitle"]'))

输出:

# A tibble: 100 × 6
   rank title                                                       
  <int> <chr>                                                       
1   101 The Alchemist                                               
2   102 Divergent (Divergent, #1)                                   
3   103 Fahrenheit 451                                              
4   104 The Catcher in the Rye                                      
5   105 The Handmaid's Tale                                         
  author           avg_rating ratings url                                       
  <chr>                 <dbl>   <dbl> <chr>                                     
1 Paulo Coelho           3.91 3382145 https://www.goodreads.com/book/show/18144…
2 Veronica Roth          4.13 3905122 https://www.goodreads.com/book/show/13335…
3 Ray Bradbury           3.97 2488011 https://www.goodreads.com/book/show/13079…
4 J.D. Salinger          3.8  3600144 https://www.goodreads.com/book/show/5107.…
5 Margaret Atwood        4.13 2145887 https://www.goodreads.com/book/show/38447…
# ℹ 95 more rows
rows: 100
[1] "1984"

解析器没有改变,变的只是获取那一行。因此,你为 parse_books() 写的每个测试同样覆盖这条路径。

应该选择哪种 R 抓取方法?

静态页面和练习网站,单用 rvest 即可。需要爬取大量页面、需要限速和重试时,加上 httr2。数据由 JavaScript 加载时,解析 script 标签中的 JSON 或网站自己的 JSON 接口。少量交互式页面用 read_html_live();当封锁或数据量让本地获取变得不可靠时,使用抓取 API。

场景

使用

原因

静态 HTML,少量页面

read_html() + rvest

环节最少

大量页面或批量爬取

httr2 获取(req_throttle()、req_retry())+ rvest

速率限制、重试、超时

数据在 <script> 的 JSON 中或 JSON 接口中

httr2 + jsonlite

比渲染更快、更稳定

由 JavaScript 生成的页面,数量少

read_html_live()

真实的 Chrome,同样的 rvest 函数

你获准自动化的点击、登录和多步骤流程

read_html_live() 的方法,或 RSelenium

支持交互

重试无法解决的 403/429、特定国家的内容,或大规模渲染

在 httr2 中调用 Scraper API

托管的代理、重试和渲染;解析器不变

拿不准时,按顺序逐条检查,遇到第一个“是”就停下:

  1. 数据在原始 HTML 中吗?用 rvest。
  2. 需要的页面不止几个吗?用 httr2 获取,用 rvest 解析。
  3. 数据在 script 标签或 JSON 接口中吗?用 jsonlite 解析。
  4. 页面需要浏览器,而且只有少量页面吗?用 read_html_live()。
  5. 被封锁了、需要特定国家,或者要大规模渲染吗?用抓取 API。

如何测试抓取程序,并把结果变成数据集?

把每种页面类型各保存一份 HTML 文件,编写离线解析这些文件的 testthat 测试。这样网站改版时,会有测试失败,而不是悄悄返回 NA。然后像对待其他数据一样处理抓到的 tibble:用 dplyr 汇总,用 readr::write_csv() 写出,并记录采集时间。

网站改版时,抓取程序很少会崩溃。它们只会返回空列。基于保存页面的测试能发现这一点。你已经用 download.file() 保存了这些测试页面。如果你用 httr2 获取,writeLines(resp_body_string(resp), "fixtures/page.html") 也能达到同样效果。把它们提交到项目仓库中,让测试在每台机器上的运行结果都一致。

下面这个 testthat 文件检查榜单解析器的关键行为:

# tests/test-parse.R: offline tests against saved HTML, run with testthat::test_file()
library(testthat)
source("../parse_books.R")

test_that("list page parses into 100 clean rows", {
  books <- parse_books(read_html("../fixtures/list-page1.html"),
                       "https://www.goodreads.com/list/show/1.Best_Books_Ever")
  expect_equal(nrow(books), 100)
  expect_false(anyNA(books$avg_rating))
  expect_true(all(books$avg_rating >= 1 & books$avg_rating <= 5))
  expect_true(all(books$ratings > 0))
  expect_match(books$url, "^https://www\\.goodreads\\.com/book/show/")
})

在 tests/ 文件夹中用 testthat::test_file("test-parse.R") 运行它:

[ FAIL 0 | WARN 0 | SKIP 0 | PASS 5 ]

网站改版后,重新保存一份页面并再次运行测试。测试失败会告诉你哪个字段出了问题,避免错误数据流入你的分析。在生产环境中也要遵循同样的“失败要显眼”原则:如果某个选择器返回零行,就用错误中止运行,而不是写出一个空文件。

评分人数越多的书,评分也越高吗?两个保存的页面给了你 200 本书可供分析:

# 07_analysis.R: from scraped rows to an answer
library(dplyr, warn.conflicts = FALSE)
source("parse_books.R")
list_url <- "https://www.goodreads.com/list/show/1.Best_Books_Ever"
books <- bind_rows(
  parse_books(read_html("fixtures/list-page1.html"), list_url),
  parse_books(read_html("fixtures/list-page2.html"), paste0(list_url, "?page=2"))
)
books |>
  mutate(popularity = cut(ratings, c(0, 1e6, 3e6, Inf),
                          labels = c("under 1M", "1M to 3M", "over 3M"))) |>
  group_by(popularity) |>
  summarize(books = n(), median_rating = median(avg_rating), .groups = "drop") |>
  print()
books |> count(author, sort = TRUE) |> head(3) |> print()
readr::write_csv(books, "books.csv")
cat("wrote books.csv with", nrow(books), "rows\n")

输出:

# A tibble: 3 × 3
  popularity books median_rating
  <fct>      <int>         <dbl>
1 under 1M      84          4.1 
2 1M to 3M      79          4.14
3 over 3M       37          4.3 
# A tibble: 3 × 2
  author           n
  <chr>        <int>
1 J.K. Rowling    10
2 Rick Riordan     5
3 Riley Sager      4
wrote books.csv with 200 rows

在这个样本中,评分人数超过三百万的 37 本书,评分中位数为 4.3,而评分人数不足一百万的书为 4.1。这只是一份读者投票榜单中呈现的规律,并非出版业的定律。从这里开始,它就是普通的 R 数据了:用 ggplot2 以对数刻度绘制 ratings 对 avg_rating 的散点图,是顺理成章的下一张图。

由于榜单每天都在变化,保存前请添加一列 scraped_at = Sys.time()。下个月有人重新运行分析、得到不同的数字时,时间戳就能解释原因。

网页抓取用 R 还是 Python 更好?

Python 拥有更多的抓取库和浏览器工具,适合大型爬虫和生产服务。如果抓取的数据要用于 R 中的分析、报告或模型,R 更合适,因为 rvest 直接返回 tibble,httr2 也能有节制地获取页面。对大多数分析师来说,坚持使用一种语言,比为了稍快一点的抓取而切换语言更划算。如果数据最终要进入 R 分析、Quarto 报告或模型,用 R 抓取就省掉了一道交接:无需导出,也无需维护第二套需要同步的环境。

获取层与语言无关,因此混合使用很容易。HTTP 客户端或抓取 API 返回给 R 和 Python 的 HTML 完全相同,团队可以用一种语言爬取,用另一种语言分析。Python 方面,请参阅我们的 Python 网页抓取指南。

如何在 R 中负责任地抓取?

爬取前先阅读网站的使用条款和 robots.txt,保持较低的请求速率,用 user agent 标识你的抓取程序,只采集你需要的字段。除非有合法依据,否则避免采集个人数据;未经许可,绝不要抓取需要登录的内容。如有疑问,请询问网站所有者,或使用他们的 API。

robots.txt 是网站告诉爬虫哪些路径不希望被访问的方式。robots.txt 标准(RFC 9309)定义了规则:找到与你的 user agent 对应的规则组,最具体的匹配规则优先。下面这个函数把这些规则应用到一组路径上:

# 08_robots.R: check paths against a site's robots.txt before you crawl (RFC 9309)
library(httr2)

robots_allows <- function(site, paths, agent = "*") {
  resp <- request(site) |> req_url_path("/robots.txt") |>
    req_error(is_error = \(resp) FALSE) |> req_perform()
  if (resp_status(resp) == 404) return(rep(TRUE, length(paths)))   # no robots.txt, no rules
  lines <- trimws(sub("#.*", "", strsplit(resp_body_string(resp), "\n")[[1]]))
  lines <- lines[lines != ""]
  field <- tolower(trimws(sub(":.*", "", lines)))
  value <- trimws(sub("^[^:]*:", "", lines))

  # Keep the Allow/Disallow rules from groups whose User-agent line matches `agent`
  keep <- logical(length(lines)); in_group <- FALSE
  for (i in seq_along(lines)) {
    if (field[i] == "user-agent") {
      if (i == 1 || field[i - 1] != "user-agent") in_group <- FALSE
      in_group <- in_group || value[i] == agent
    } else {
      keep[i] <- in_group && field[i] %in% c("allow", "disallow") && value[i] != ""
    }
  }
  type <- field[keep]; rule <- value[keep]
  pattern <- paste0("^", gsub("*", ".*", gsub("([.?+^$()|{}\\[\\]\\\\])", "\\\\\\1", rule, perl = TRUE), fixed = TRUE))

  vapply(paths, function(path) {
    hits <- which(vapply(pattern, grepl, logical(1), x = path))
    if (length(hits) == 0) return(TRUE)
    best <- hits[nchar(rule[hits]) == max(nchar(rule[hits]))]   # longest match wins
    any(type[best] == "allow")                                  # a tie goes to Allow
  }, logical(1))
}

paths <- c("/list/show/1.Best_Books_Ever", "/book/show/5470.1984", "/book/reviews/5470",
           "/review/list/1", "/search?q=orwell", "/work/quotes/153313")
data.frame(path = paths, allowed = unname(robots_allows("https://www.goodreads.com", paths)))

输出:

                          path allowed
1 /list/show/1.Best_Books_Ever    TRUE
2         /book/show/5470.1984    TRUE
3           /book/reviews/5470   FALSE
4               /review/list/1   FALSE
5             /search?q=orwell   FALSE
6          /work/quotes/153313    TRUE

本指南使用的榜单页和图书页都是允许访问的。书评列表和搜索则不允许,这也是本指南只使用图书层面的评分、不采集单条书评的原因。最后一行展示了最长匹配规则的作用:Goodreads 禁止 /work,但允许更具体的 /work/quotes。

robots.txt 是爬虫约定,而不是法律许可,也不是唯一需要遵守的文件。即使 robots.txt 允许访问某些路径,网站的使用条款仍可能限制自动化采集。例如,Goodreads 的条款将数据挖掘和自动化提取工具排除在其授予的许可之外。因此,运行这些示例之前请先阅读条款;如果要将其数据用于学习以外的任何用途,请先征得 Goodreads 的许可。

书评也说明了为什么个人数据需要谨慎对待:每条书评都带有读者的姓名和观点。想了解更多好习惯,请参阅这些实用的抓取最佳实践以及我们的负责任网络数据原则。

FAQ

rvest 还在维护吗?

是的。rvest 是 tidyverse 的一部分,截至 2026 年 10 月,CRAN 上的版本为 1.0.5。1.0.4 版本新增了用于 JavaScript 页面的 read_html_live()。在获取方面,同一团队现在维护着 httr2,它负责处理重试、速率限制和并行请求,因此大多数现代 R 抓取程序都会把这两个包搭配使用。

rvest 和 httr2 有什么区别?

rvest 负责解析 HTML:用 CSS 或 XPath 选择元素,提取文本、属性和表格。httr2 负责发送 HTTP 请求:设置请求头、重试失败的请求、限制请求速率,并处理 JSON API。简单页面单用 rvest 就能抓取,但批量爬取时,应该用 httr2 获取、用 rvest 解析。

rvest 能抓取 JavaScript 网站吗?

单用 read_html() 不行,因为它只能看到服务器发送的 HTML。read_html_live() 可以:它通过 chromote 在无头 Chrome 中运行页面,并把渲染后的 DOM 交给你,用普通的 rvest 函数处理。不过请先检查 script 标签中是否有 JSON,或者是否有 JSON 接口,这通常更快。

RSelenium 还值得用吗?

RSelenium 仍在 CRAN 上,但它需要 Java、Selenium 服务器或驱动程序,以及版本匹配的浏览器,配置起来很容易出问题。如果你的团队已经在用 Selenium 或需要 Firefox,可以使用它。对大多数 R 用户来说,rvest 的 read_html_live() 就能完成渲染、点击和输入,配置工作少得多。

如何在 R 中抓取网站上的表格?

用 read_html() 加载页面,用 html_element() 和 CSS 选择器(例如 "table.data")选中表格,再调用 html_table()。你会得到一个 tibble,表格的每一列对应一列。对没有表头的表格使用 header = FALSE,之后再用 readr::parse_number() 清理数字。

用 R 抓取时如何避免被封锁?

用 httr2 的 req_throttle() 放慢速度,用 req_retry() 重试 429 和 503 响应,设置真实的 user agent,并缓存页面以免重复获取。如果网站仍然返回 403 错误或验证页面,就把请求交给一个负责管理代理和渲染的抓取 API,你的 rvest 解析器保持不变。

在 R 中应该如何保存 API 密钥?

把密钥写进用户级 .Renviron 文件,格式为一行 WSA_API_KEY=your_key,重启 R,然后用 Sys.getenv("WSA_API_KEY") 读取。绝不要把密钥粘贴到你会分享的脚本、笔记本或 URL 中,并把项目级的 .Renviron 文件加入 .gitignore,确保密钥永远不会进入版本控制。

可以用 R 抓取需要登录的页面吗?

技术上可以:rvest 的 session() 可以提交表单并保留 cookie,httr2 也可以发送经过身份验证的请求。但只有在获得网站所有者许可、并遵守其条款的前提下才能这样做,因为登录后的区域通常包含个人数据或受合同约束的数据。如果网站提供官方 API,请使用 API,而不是抓取登录后的页面。

如何在 R 中并行抓取大量页面?

构建一个 httr2 请求列表,用 req_perform_parallel() 发送,并用 max_active 限制同时运行的请求数。加上 req_throttle(),确保任何一个主机收到的流量都不会超出其承受能力。对单个网站,并发数要保持适度,因为速度很少比不被封锁更重要。

用 R 做网页抓取合法吗?

抓取公开可用的数据通常是合法的,但这取决于网站条款、数据类型和当地法律,尤其涉及个人数据和受版权保护的内容时。使用哪种编程语言并不会改变这一点。请遵守 robots.txt,在没有合法依据时避免采集个人数据,商业项目请寻求法律意见。

保留你的 R 代码,把难抓的页面交出去

当网站开始封锁你或需要 JavaScript 时,你的 rvest 解析器无需改动。把获取请求指向 WebScrapingAPI 的 Scraper API,继续在 R 中解析。JavaScript 渲染和地理定位不额外消耗积分,失败的请求不计费。比较 Scraper API 套餐,可从包含 5,000 次请求的免费试用开始。

关于作者

Raluca Penciuc, 全栈开发工程师 @ WebScrapingAPI

Raluca Penciuc

全栈开发工程师

Raluca Penciuc 是 WebScrapingAPI 的全栈开发工程师,主要负责开发爬虫、优化规避机制,并探索可靠的方法以降低在目标网站上的被检测概率。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。