R 编程语言是现代数据科学中最流行的语言之一,网络抓取通常可用于在 R 语言本身中有效地生成数据集!
R 语言生态系统配备了我们进行网络抓取所需的所有功能:HTTP 客户端、HTML 解析器和各种数据处理实用程序。
在本文中,我们将深入探讨 R 中的网页抓取——正确的方法。
我们将介绍快速异步 HTTP 连接、如何避免基本阻塞以及如何解析 HTML 文件以获取数据。
最后,我们将通过https://www.indeed.com/的示例职位列表信息抓取工具巩固这些知识!
建立联系
Web 抓取一般包括两个步骤:获取数据和解析数据。在本节中,我们将专注于获取数据,这是通过 HTTP 连接完成的。
为了检索公共资源,我们(客户端)必须连接到服务器并希望服务器向我们提供文档的数据。此 HTTP 交互称为请求和响应,该协议涉及许多不同的部分,如请求方法、位置、标头等。但在我们开始探索这些位之前,我们应该在 R 中选择一个 HTTP 客户端!
HTTP 客户端:crul
为了处理我们的 HTTP 连接,我们需要一个 HTTP 客户端库,而 R 语言主要有两个相互竞争的库:
在本文中,我们将坚持使用crul,因为它为网络抓取提供了重要的可选功能——异步(并行)请求,这对于快速抓取至关重要。
HTTP 涉及大量等待 – 每次客户端发出请求时,它必须同时等待服务器响应阻塞代码。所以,如果我们可以建立多个并发连接——我们就可以跳过阻塞等待!
例如,1 个同步请求将花费我们 0.1 秒的实际处理时间和 2 秒的等待时间,而 10 个异步请求将花费我们 10x.1 秒的处理时间和 1×2 秒的等待时间——这是一个巨大的差异!
让我们用crul包准备我们的 R 环境,我们将使用它来熟悉 HTTP 协议:
> install.packages("crul", repos = "https://dev.ropensci.org")
了解请求和响应
当谈到网络抓取时,我们并不完全需要了解有关 HTTP 请求和响应的每一个细节,但是,最好有一个总体概述并了解该协议的哪些部分在网络抓取中特别有用。让我们来看看究竟是什么!
请求方法
HTTP 请求可以方便地分为几种执行不同功能的类型。最常见的是在网络抓取中我们会遇到这些类型的请求:
GET
requests 旨在请求文件。POST
requests 旨在通过发送文档来请求文档。HEAD
requests 旨在请求文档元信息。
当谈到网络抓取时,我们最感兴趣的是收集文档,所以我们将主要处理GET
和POST
输入请求。补充一点,HEAD
请求在网络抓取中很有用,可以优化带宽——有时在下载文档之前,我们可能想检查它的元数据是否值得付出努力。
其他在网络抓取中不常见但仍然值得关注的方法是:
PATCH
请求旨在更新文档。PUT
requests 旨在创建新文档或更新它。DELETE
请求旨在删除文档。
请求位置
每个 HTTP 请求都需要告诉正在请求什么资源,这是通过具有详细结构的 URL(通用资源位置)完成的:
在这里,我们可以可视化 URL 的每个部分:
- 协议 – 当谈到 HTTP 时,要么是
http
要么https
。 - host – 服务器的地址,例如域名或IP地址。
- location – 主机资源的相对位置。
如果您不确定 URL 的结构,您可以随时启动 R 交互式 shell(在终端中)并使用crul库的函数R
让它为您弄清楚:url_parse
$ R > crul::url_parse("http://www.domain.com/path/to/resource?arg1=true&arg2=false") $scheme [1] "http" $domain [1] "www.domain.com" $port [1] NA $path [1] "path/to/resource" $parameter $parameter$arg1 [1] "true" $parameter$arg2 [1] "false" $fragment [1] NA
请求标头
虽然看起来请求标头只是网络抓取中的次要元数据细节,但它们非常重要。标头包含有关请求的基本详细信息,例如:谁在请求数据?他们期望什么类型的数据?弄错这些可能会导致网络抓取工具被拒绝访问。
让我们来看看一些最重要的标头及其含义:
User-Agent是一个身份标头,它告诉服务器谁在请求文档。
# example user agent for Chrome browser on Windows operating system: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36
每当您在网络浏览器中访问网页时,都会使用类似于“浏览器名称、操作系统、某些版本号”的用户代理字符串来标识自己。这有助于服务器确定是为客户端提供服务还是拒绝客户端。在网络抓取中,我们不想被拒绝内容,所以我们必须通过伪造我们的用户代理看起来像浏览器之一来融入其中。
有很多在线数据库包含各种平台的最新用户代理字符串,就像这样
Cookie用于存储持久性数据。这是网站跟踪用户状态的一项重要功能:用户登录、配置首选项等。Cookie 有点超出本文的范围,但我们将在未来介绍它们。
Accept标头(还有 Accept-Encoding、Accept-Language 等)包含有关我们期望的内容类型的信息。通常,在网络抓取时,我们想模仿一种流行的网络浏览器,例如 Chrome 浏览器:
text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
X-前缀的标头是特殊的自定义标头。这些在网络抓取时非常重要,因为它们可能配置抓取的网站/webapp 的重要功能。
响应状态码
方便的是,所有 HTTP 响应都带有一个状态代码,指示此请求是成功、失败还是请求了某些替代操作(如请求进行身份验证)。让我们快速浏览一下与网络抓取最相关的状态代码:
- 200个范围码一般代表成功!
- 300 范围代码往往意味着重定向 – 换句话说,如果我们请求
/product1.html
它的内容可能会被移动到一个新的位置/products/1.html
,服务器会通知我们。 - 400 范围代码表示请求格式错误或被拒绝。我们的网络抓取工具可能会丢失一些标头、cookie 或身份验证详细信息。
- 500 个范围代码通常意味着服务器问题。该网站可能现在不可用或有意禁止访问我们的网络抓取工具。
响应头
在网络抓取方面,响应标头提供了一些关于连接功能和效率的重要信息。例如,Set-Cookie
header 请求我们的客户端为将来的请求保存一些 cookie,这可能对网站功能至关重要。其他标头(例如Etag
)Last-Modified
旨在帮助客户端进行缓存以优化资源使用。
最后,就像请求标头一样,以 为前缀的标头X-
是自定义 Web 功能标头。
我们简要地忽略了核心 HTTP 组件,现在是时候尝试一下,看看 HTTP 在实际 R 中是如何工作的了!
发出 GET 请求
现在我们已经熟悉了 HTTP 协议及其在网络抓取中的使用方式,让我们使用 R 的 crul 库来练习它。
让我们从一个基本的 GET 请求开始:
library("crul") response <- HttpClient$new('https://httpbin.org/headers')$get() # response url - it can be different from above if redirect happened print(response$url) # status code: print(response$status_code) # check whether response succeeded, i.e. status code <= 201 print(response$success()) # response headers: print(response$response_headers) # response binary content print(response$content) # response content as text () print(response$parse()) # can also load text json response to R's named list: jsonlite::fromJSON(response$parse())
这里我们使用http://httpbin.org/ HTTP 测试服务,在这种情况下我们使用/headers
端点显示服务器从我们那里收到的请求标头。
运行时,此脚本应打印有关我们提出的请求的基本详细信息:
> library("crul") > response <- HttpClient$new('https://httpbin.org/headers')$get() > > # response url - it can be different from above if redirect happened > print(response$url) [1] "https://httpbin.org/headers" > # status code: > print(response$status_code) [1] 200 > # check whether response succeeded, i.e. status code <= 201 > print(response$success()) [1] TRUE > # response headers: > print(response$response_headers) $status [1] "HTTP/2 200" $date [1] "Wed, 02 Mar 2022 08:28:04 GMT" $`content-type` [1] "application/json" $`content-length` [1] "286" $server [1] "gunicorn/19.9.0" $`access-control-allow-origin` [1] "*" $`access-control-allow-credentials` [1] "true" > # response binary content > print(response$content) [1] 7b 0a 20 20 22 68 65 61 64 65 72 73 22 3a 20 7b 0a 20 20 20 20 22 41 63 63 [26] 65 70 74 22 3a 20 22 61 70 70 6c 69 63 61 74 69 6f 6e 2f 6a 73 6f 6e 2c 20 [51] 74 65 78 74 2f 78 6d 6c 2c 20 61 70 70 6c 69 63 61 74 69 6f 6e 2f 78 6d 6c [76] 2c 20 2a 2f 2a 22 2c 20 0a 20 20 20 20 22 41 63 63 65 70 74 2d 45 6e 63 6f [101] 64 69 6e 67 22 3a 20 22 67 7a 69 70 2c 20 64 65 66 6c 61 74 65 22 2c 20 0a [126] 20 20 20 20 22 48 6f 73 74 22 3a 20 22 68 74 74 70 62 69 6e 2e 6f 72 67 22 [151] 2c 20 0a 20 20 20 20 22 55 73 65 72 2d 41 67 65 6e 74 22 3a 20 22 6c 69 62 [176] 63 75 72 6c 2f 37 2e 38 31 2e 30 20 72 2d 63 75 72 6c 2f 34 2e 33 2e 32 20 [201] 63 72 75 6c 2f 31 2e 32 2e 30 22 2c 20 0a 20 20 20 20 22 58 2d 41 6d 7a 6e [226] 2d 54 72 61 63 65 2d 49 64 22 3a 20 22 52 6f 6f 74 3d 31 2d 36 32 31 66 32 [251] 61 39 34 2d 33 31 32 61 66 38 33 62 33 33 63 37 32 35 34 65 33 33 34 36 39 [276] 64 30 39 22 0a 20 20 7d 0a 7d 0a > # response content as text () > print(response$parse()) No encoding supplied: defaulting to UTF-8. [1] "{\n \"headers\": {\n \"Accept\": \"application/json, text/xml, application/xml, */*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"libcurl/7.81.0 r-curl/4.3.2 crul/1.2.0\", \n \"X-Amzn-Trace-Id\": \"Root=1-621f2a94-312af83b33c7254e33469d09\"\n }\n}\n" > # can also load text json response to R's named list: > jsonlite::fromJSON(response$parse()) No encoding supplied: defaulting to UTF-8. $headers $headers$Accept [1] "application/json, text/xml, application/xml, */*" $headers$`Accept-Encoding` [1] "gzip, deflate" $headers$Host [1] "httpbin.org" $headers$`User-Agent` [1] "libcurl/7.81.0 r-curl/4.3.2 crul/1.2.0"
发出 POST 请求
有时我们的网络抓取器可能需要提交某种形式来检索 HTML 结果。例如,搜索查询通常使用POST
带有查询详细信息的请求作为 JSON 或 Formdata 值:
library("crul") # send form type post request: response <- HttpClient$new('https://httpbin.org/post')$post( body = list("query" = "cats", "page" = 1), encode = "form", ) print(jsonlite::fromJSON(response$parse())) # or json type post request: response <- HttpClient$new('https://httpbin.org/post')$post( body = list("query" = "cats", "page" = 1), encode = "json", ) print(jsonlite::fromJSON(response$parse()))
确保标头
正如我们之前所述,我们的请求必须提供关于它们自身的元数据,这有助于服务器确定要返回的内容。通常,此元数据可用于识别网络抓取工具并阻止它们。现代网络浏览器会自动在每个请求中包含特定的元数据详细信息,因此如果我们不希望作为网络抓取工具脱颖而出,我们应该复制这种行为。
首先,User-Agent
标Accept
头通常是无用的赠品,因此我们应该为它们设置一些共同的值。这可以在全局或每个请求的基础上完成:
library("crul") # we can set headers for every request response <- HttpClient$new("https://httpbin.org/headers", headers = list( "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36", "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" ) )$get() print(jsonlite::fromJSON(response$parse())$headers) # or set headers for the whole script (recommended) set_headers( "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36", "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" ) response <- HttpClient$new("https://httpbin.org/headers")$get() print(jsonlite::fromJSON(response$parse())$headers)
在上面的示例中,我们将标头设置为模仿 Windows 平台上的 Chrome 网络浏览器。这个简单的改变可以防止很多网页抓取阻塞,推荐给每个网页抓取工具。
跟踪 cookie
有时在网络抓取时我们关心持久连接状态。对于我们需要登录或配置网站(如更改货币)的网站,cookie 是网络抓取过程的重要组成部分。
Crul 支持基于基础的 cookie 跟踪,HttpClient
这意味着附加到一个客户端对象的所有请求共享 cookie:
library("crul") session <- HttpClient$new('http://httpbin.org/') # set some cookies: resp_set_cookies <- session$get('/cookies/set/foo/bar') # see current cookies: resp_retrieve_cookies <- session$get('/cookies') print(resp_retrieve_cookies$parse())
在上面的示例中,我们使用 httpbin.org 的/cookies
端点为会话设置一些 cookie。设置 cookie 后,我们将被重定向到显示已发送 cookie 的页面:
{ "cookies": { "foo": "bar" } }
现在我们知道了在 R 和 Crul 中绕过 HTTP 的方式,让我们来看看连接速度!我们如何才能使这些连接更快、更有效?
异步(并行)请求
由于 HTTP 协议是双方之间的数据交换协议,因此需要等待很多时间。换句话说,当我们的客户端发送请求时,它需要等待它一直传输到服务器并返回,这会导致我们的程序停止。为什么我们的程序要袖手旁观,等待环球旅行的请求?这称为 IO(输入/输出)块。
我们为这个特殊功能选择了 R 的crul
包httr
——它使异步请求非常容易访问:
library("crul") start = Sys.time() responses <- Async$new( urls = c( "http://httpbin.org/links/4/0", "http://httpbin.org/links/4/1", "http://httpbin.org/links/4/2", "http://httpbin.org/links/4/3" ), )$get() print(responses) print(Sys.time() - start)
在上面的示例中,我们正在批处理多个 url 以一起执行它们。或者,我们可以走得更远,执行各种不同的请求:
library("crul") start = Sys.time() responses <- AsyncVaried$new( HttpRequest$new("http://httpbin.org/links/4/0")$get(), HttpRequest$new("http://httpbin.org/links/4/1")$get(), HttpRequest$new("http://httpbin.org/links/4/2")$get(), HttpRequest$new("http://httpbin.org/links/4/3", headers=list("User-Agent"="different"))$get(), HttpRequest$new("http://httpbin.org/post")$post(body=list(query="cats", page = 1)) )$request() print(responses) print(Sys.time() - start)
上述方法允许我们混合不同的类型和参数请求。
现在我们已经熟悉并熟悉 R 中的 HTTP 协议,crul
让我们来看看我们如何从我们检索的 HTML 数据中理解。在下一节中,我们将看看在 R 中使用 CSS 和 XPATH 选择器进行 HTML 解析!
解析 HTML:Rvest
检索 HTML 文档只是网络抓取过程的一部分——我们还必须解析它们以获取我们正在寻找的数据。幸运的是,HTML 格式被设计为可机器解析,因此我们可以利用这一点并使用特殊的 CSS 或 XPATH 选择器语言来找到要提取的页面的确切部分。
我们在之前的文章中非常详细地介绍了 CSS 和 XPATH 选择器:
在 R 中,有一个库同时支持 CSS 和 XPATH 选择器:rvest
让我们来看看一些常见的rvest用例,并以Apify为例
library("rvest") tree <- read_html(' <div class="links"> <a href="https://twitter.com/apify">Twitter</a> <a href="https://www.linkedin.com/company/apify/">LinkedIn</a> </div> ') # we can execute basic css selectors and pull all text values: print(tree %>% html_element("div.links") %>% html_text()) # "[1] "\n Twitter\n LinkedIn\n"" # we can also execute xpath selectors: print(tree %>% html_element(xpath="//div[@class='links']") %>% html_text()) # "[1] "\n Twitter\n LinkedIn\n"" # html_text2 - outputs are cleaned fo trailing/leading space characters: print(tree %>% html_element("div") %>% html_text2()) # "[1] "Twitter LinkedIn"" # we can select attribute of a single element: print(tree %>% html_element("div") %>% html_attr('class')) # "links" # or attributes of multiple elements: print(tree %>% html_elements("div.links a") %>% html_attr('href')) # [1] "https://twitter.com/apify" # [2] "https://www.linkedin.com/company/apify/"
这里的主要功能是 R 的管道符号%>%
,它允许我们通过多个处理器(如 XPATH 或 CSS 选择器以及文本或属性提取器)处理我们的 HTML 树。
Rvest 还带有一些受数据科学用例启发的特殊解析功能。例如,它允许我们将 HTML 表格转换为 R 的数据框:
library("rvest") tree <- read_html(' <div class="table-wrapper"> <table> <th> <td>model</td> <td>year</td> </th> <tr> <td>Mazda</td> <td>2011</td> </tr> <tr> <td>Toyota</td> <td>1992</td> </tr> </table> </div> ') tree %>% html_element('.table-wrapper') %>% html_table() ## A tibble: 2 × 2 # X1 X2 # <chr> <int> # 1 Mazda 2011 # 2 Toyota 1992
在上面的示例中,html_table()
管道函数会自动从给定的选择器中提取整个表(如果它包含<table>
节点)。它从节点中获取表头<th>
,甚至将值转换为适当的类型(在此示例中,年份值被转换为整数)。
真正探索 harvest 的最好方法是使用示例项目,所以让我们这样做吧!
示例项目
为了巩固我们的知识,我们将为https://uk.indeed.com/编写一个简短的网络抓取工具。
我们将从给定的 R 工作搜索查询中抓取工作列表数据:
- 我们将抓取 indeed.com 搜索页面以查找位置,例如
https://uk.indeed.com/jobs?q=r&l=Scotland
- 通过使用在页面上查找 10 个工作列表的第一页
rvest
- 查找作业/页面的总数。
- 抓取其他工作页面。
我们将通过将我们的逻辑分离成单一用途的函数来编写一个快速且易于理解的爬虫。
让我们通过定义常量和公司解析函数从下往上启动我们的爬虫:
library("crul") library("rvest") HEADERS <- list( "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36", "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", "Accept-Encoding" = "gzip, deflate, br", "Accept-Language" = "en-US,en;q=0.9" )
在这里,我们定义HEADERS
常量。为了避免我们的抓取工具被阻塞,我们需要确保它看起来像一个网络浏览器。为此,我们只需复制 Chrome 浏览器将在 Windows 计算机上使用的标头。
接下来,我们可以定义我们公司的解析函数。我们将使用 CSS 选择器来提取作业详细信息,为此,我们可以使用 Chrome 开发人员工具
在 R 中,我们的解析器逻辑将如下所示:
parse_search <- function(response){ # build rvest tree tree <- read_html(response$parse()) # find total jobs available total_jobs <- tree %>% html_element("#searchCountPages") %>% html_text2() total_jobs <- strtoi(stringr::str_match(total_jobs, "(\\d+) jobs")[,2]) # find displayed job container boxes: jobs <- tree %>% html_elements('#mosaic-zone-jobcards .result') # extract job listing from each job container box parsed <- list() for (job in jobs){ parsed <- append(parsed, list( title = job %>% html_element('h2') %>% html_text2(), company = job %>% html_element('.companyOverviewLink') %>% html_text2(), location = job %>% html_element('.companyLocation') %>% html_text2(), date = job %>% html_element(xpath=".//span[contains(@class,'date')]/text()") %>% html_text2(), url = url_build(response$url, job %>% html_attr('href')), company_url = url_build(response$url, job %>% html_element(xpath='.//a[contains(@class,"companyOverviewLink")]') %>% html_attr('href')) )) } # return parsed jobs and total job count in the query print(glue("found total {length(jobs)} jobs from total of {total_jobs}")) list(jobs=parsed, total=total_jobs) }
这里我们有一个函数,它接受一个响应对象,构建一个rvest HTML 树,并使用 CSS 和 XPath 选择的组合,我们正在提取职位列表的详细信息。
为此,我们首先选择作业列表容器框(li
元素)。然后,我们遍历每一个并提取作业详细信息。
我们可以通过明确抓取 1 家公司来测试此代码:
url <- "https://uk.indeed.com/jobs?q=r&l=scotland") response <- HttpClient$new(url, header=HEADERS)$get() print(parse_search(response))
这将返回第一个查询页面的结果和列表总数:
found total 15 jobs from total of 330 $jobs $jobs$title [1] "Parts Supervisor" $jobs$company [1] "BMW Group Retail" $jobs$location [1] "Edinburgh" $jobs$date [1] "4 days ago" $jobs$url [1] "https://uk.indeed.com/rc/clk?jk=ad7698381a9870de&fccid=bf564b9e3f3db3fc&vjs=3" $jobs$company_url [1] "https://uk.indeed.com/cmp/BMW-Group" <...> $total [1] 330
为了完成我们的抓取器,让我们添加一个抓取循环,该循环将抓取第一页,解析结果,然后并行抓取其余页面:
scrape_search_page <- function(query, location, offset=0, limit=10){ # first we need to create all urls we'll be scraping based on offset and limit arguments # 0:10 will create first page scrape url, and 10:80 will create 1-8 pages since there are 10 results per page print(glue("scraping {query} at {location} in range {offset}:{limit}")) urls <- list() for (i in seq(offset+10, limit, by=10)){ urls <- append(urls, glue("https://uk.indeed.com/jobs?q={query}&l={location}&start={i}")) } # then we want to retrieve these urls in parallel: print(glue("scraping search page urls: {urls}")) responses <- Async$new( urls = urls, headers = HEADERS, )$get() # finally we want to unpack results of each individual page into final dataset found_jobs <- list() total_jobs <- NULL for (response in responses){ page_results <- parse_search(response) found_jobs <- append(found_jobs, page_results$jobs) total_jobs <- page_results$total } # we return jobs we parsed and total jobs presented in the search page: list(jobs=found_jobs, total=total_jobs) } scrape_search <- function(query, location){ # this is our main function that scrapes all pages of the query explicitly # first, we scrape the first page first_page_results <- scrape_search_page(query, location) found_jobs <- first_page_results$jobs total_jobs <- first_page_results$total # then we scrape remaining pages: print(glue("scraped first page, found {length(found_jobs)} jobs; continuing with remaining: {total_jobs}")) remaining_page_results <- scrape_search_page(query, location, offset = 10, limit = total_jobs) # finally, we return combined dataset append(found_jobs, remaining_page_results$jobs) }
在这里,我们定义了我们的抓取循环,它利用了crul的并行连接特性。让我们试试我们的爬虫并计时:
start = Sys.time() print(scrape_search("r", "Scotland")) print(Sys.time() - start)
我们可以看到我们在 10 秒内抓取了 300 多个职位列表!我们可以轻松地将这个微型抓取器嵌入到我们的 R 工作流程中,而无需担心缓存或数据存储,因为每个数据集抓取只需几秒钟即可刷新。
概括
在这篇关于 R 中网络抓取的广泛介绍文章中,我们了解了使用crul库的 HTTP 连接策略:如何提交获取和发布请求、更改标头、跟踪 cookie,最后,如何使用异步高效地完成所有这些工作(并行)请求。
了解了 HTTP 位后,我们了解了使用rvest进行的 HTML 解析,它支持基于 CSS 和 XPath 选择器的元素提取。