Ruby 是一种流行的通用编程语言,在网络世界中占有重要地位,但它可以用于网络抓取吗? 在这篇介绍教程文章中,我们将深入探讨 Ruby 作为网络抓取平台。用于网络抓取的最佳 gem 是什么?常见的挑战、习语和提示与技巧是什么?如何使用并行请求扩展网络抓取工具? 最后,我们将用一个现实生活中的示例抓取工具来总结所有内容,该抓取工具从indeed.com收集工作列表信息!
建立联系
要从公共资源中收集数据,我们需要先与其建立连接。大多数 Web 都是通过 HTTP 协议提供服务的,这很简单:我们(客户端)向网站(服务器)发送对特定文档的请求,一旦服务器处理了我们的请求,它就会回复请求的文档 – 一个非常直接交换! 即,我们发送一个请求对象,它由方法(又名类型)、位置和标头组成,然后我们接收一个响应对象,它由状态代码、标头和文档内容本身组成。
Ruby 中的 HTTP 客户端
Ruby 有许多实现此逻辑的 HTTP 客户端包:Faraday、Httparty和Typhoeus。 在本文中,我们将重点关注Typhoeus,因为它是功能最丰富的客户端,具有两个杀手级功能:并行连接和HTTP2 协议支持。但是,大多数 http 客户端几乎可以互换使用。
接下来,让我们试一试 Typhoeus 并探索 HTTP 协议基础知识!
了解请求和响应
当谈到网络抓取时,我们并不完全需要了解有关 http 请求和响应的每一个细节,但是最好有一个总体概述并了解该协议的哪些部分在网络抓取中特别有用。让我们来看看究竟是什么!
请求方法
Http 请求可以方便地分为几种执行不同功能的类型:
GET
requests 旨在请求文件。POST
requests 旨在通过发送文档来请求文档。HEAD
requests 旨在请求文档元信息。PATCH
请求旨在更新文档。PUT
requests 旨在创建新文档或更新它。DELETE
请求旨在删除文档。
当谈到网络抓取时,我们最感兴趣的是收集文档,所以我们将主要处理GET
和POST
输入请求。补充一点,HEAD
请求在网络抓取中很有用,可以优化带宽——有时在下载文档之前,我们可能想检查它的元数据是否值得付出努力。
请求位置
要了解什么是资源位置,首先我们应该快速了解一下 URL 的结构本身:
在这里,我们可以可视化 URL 的每个部分:我们有protocol
,当涉及到 HTTP 时是 或http
,https
然后我们有host
,它本质上是服务器的地址,最后我们有资源的位置和一些自定义参数。 如果您不确定 URL 的结构,您可以随时启动 Ruby 的交互式 shell(irb
在终端中)并让它为您解决:
$ irb irb(main):1:0> uri = URI.parse('http://www.domain.com/path/to/resource?arg1=true&arg2=false') => #<URI::HTTP http://www.domain.com/path/to/resource?arg1=true&arg2=false> irb(main):2:0> uri.scheme => "http" irb(main):3:0> uri.host => "www.domain.com" irb(main):4:0> uri.path => "/path/to/resource" irb(main):5:0> uri.query => "arg1=true&arg2=false"
请求标头
虽然看起来请求标头只是网络抓取中的次要元数据细节,但它们非常重要。标头包含有关请求的基本详细信息,例如:谁在请求数据?他们期望什么类型的数据?弄错这些可能会导致网络抓取工具被拒绝访问。 让我们来看看一些最重要的标头及其含义: 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 的重要功能。 这些是一些最重要的观察结果,有关更多信息,请参阅广泛的完整文档页面:MDN HTTP 标头
响应状态码
方便的是,所有 HTTP 响应都带有一个状态代码,指示此请求是成功、失败还是请求了某些替代操作(如请求进行身份验证)。让我们快速浏览一下与网络抓取最相关的状态代码:
- 200个范围码一般代表成功!
- 300 范围代码往往意味着重定向 – 换句话说,如果我们请求
/product1.html
它的内容可能会被移动到一个新的位置/products/1.html
,服务器会通知我们。 - 400 范围代码表示请求格式错误或被拒绝。我们的网络抓取工具可能会丢失一些标头、cookie 或身份验证详细信息。
- 500 个范围代码通常意味着服务器问题。该网站可能现在不可用或有意禁止访问我们的网络抓取工具。
有关 http 状态代码的更多信息,请参阅文档:MDN 的 HTTP 状态定义
响应头
在网络抓取方面,响应标头提供了一些关于连接功能和效率的重要信息。例如,Set-Cookie
header 请求我们的客户端为将来的请求保存一些 cookie,这可能对网站功能至关重要。其他标头(例如Etag
)Last-Modified
旨在帮助客户端进行缓存以优化资源使用。
有关所有 HTTP 标头的完整列表,请参阅MDN HTTP 标头
最后,就像请求标头一样,以 为前缀的标头X-
是自定义 Web 功能标头。
我们简要地忽略了核心 HTTP 组件,现在是时候尝试一下,看看 HTTP 在实际的 Ruby 中是如何工作的了!
发出 GET 请求
现在我们已经熟悉了 HTTP 协议及其在网络抓取中的使用方式,让我们来看看我们如何在 Ruby 的Typhoeus包中访问它。 让我们从一个基本的 GET 请求开始:
require 'typhoeus' response = Typhoeus.get( "https://httpbin.org/headers", followlocation: true, ) puts "request/url:\n #{response.request.url}" # request that resulted in this response puts "body:\n #{response.body}" puts "code:\n #{response.code}" # status code of response, e.g. 200 puts "headers:\n #{response.headers}" # response headers indicating metadata about response puts "elapsed:\n #{response.total_time}" # total time elapsed
这里我们使用http://httpbin.org/ HTTP 测试服务,在这种情况下我们使用/headers
端点显示服务器从我们那里收到的请求标头。这里要特别注意的一件事是followlocation: true
参数,它确保如果服务器将内容移动到其他地方,我们的抓取工具将遵循 url 重定向。 运行时,此脚本应打印有关我们提出的请求的基本详细信息:
request/url: https://httpbin.org/headers body: { "headers": { "Accept": "*/*", "Host": "httpbin.org", "User-Agent": "Typhoeus - https://github.com/typhoeus/typhoeus", } } code: 200 headers: {"date"=>"Thu, 17 Feb 2022 12:41:37 GMT", "content-type"=>"application/json", "content-length"=>"209", "server"=>"gunicorn/19.9.0", "access-control-allow-origin"=>"*", "access-control-allow-credentials"=>"true"} elapsed: 1.464545
发出 POST 请求
有时我们的网络抓取器可能需要提交某种形式来检索 HTML 结果。例如,搜索查询通常使用POST
带有查询详细信息的请求作为 JSON 或 Formdata 值:
require 'typhoeus' # POST formdata response = Typhoeus.post( "https://httpbin.org/post", followlocation: true, body: {"query" => "cats", "page" => 1}, ) # POST json require 'json' response = Typhoeus.post( "https://httpbin.org/post", followlocation: true, headers: {"Content-Type": "application/json"}, body: {"query": "cats", "page": 1}.to_json, )
确保标头
正如我们之前所述,我们的请求必须提供关于它们自身的元数据,这有助于服务器确定要返回的内容。通常,此元数据可用于识别网络抓取工具并阻止它们。现代网络浏览器会自动在每个请求中包含特定的元数据详细信息,因此如果我们不希望作为网络抓取工具脱颖而出,我们应该复制这种行为。 首先,User-Agent
标Accept
头通常是无用的赠品,因此我们应该为它们设置一些共同的值:
require 'typhoeus' response = Typhoeus.get( "https://httpbin.org/headers", followlocation: true, headers: { # Chrome browser User-Agent and Accept 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', }, ) puts "server got headers:\n #{response.body}"
跟踪 cookie
有时在网络抓取时我们关心持久连接状态。对于我们需要登录或配置网站(如更改货币)的网站,cookie 是网络抓取过程的重要组成部分。 由于 Typhoeus在幕后使用curl,它通过基于文件的跟踪实现了完整的 cookie 支持:
require 'typhoeus' resp = Typhoeus.get( "http://httpbin.org/cookies/set/foo/bar", followlocation: true, # cookies filename where cookies will be READ from cookiefile: "cookies.jar", # cookiejar filename where cookies will be SAVED to cookiejar: "cookies.jar", ) print resp.body
在上面的示例中,我们使用 httpbin.org 的/cookies
端点为会话设置一些 cookie。设置 cookie 后,我们将被重定向到显示已发送 cookie 的页面:
{ "cookies": { "foo": "bar" } }
现在我们了解了在 Ruby 和 Typhoeus 中绕过 HTTP 的方式,让我们来看看连接速度!我们如何才能使这些连接更快、更有效?
并行请求
由于 HTTP 协议是双方之间的数据交换协议,因此需要等待很多时间。换句话说,当我们的客户端发送一个请求时,它需要等待它一直传输到服务器并返回,这会拖延我们的程序。为什么我们的程序要袖手旁观,等待环球旅行的请求?这称为 IO(输入/输出)块。 在 Ruby 的 Typhoeus 中,我们可以通过使用名为Hydra
. 我们可以将回调函数应用于请求并安排它们:
require 'typhoeus' $hydra = Typhoeus::Hydra.new(max_concurrency: 10) urls = [ "http://httpbin.org/links/4/0", "http://httpbin.org/links/4/1", "http://httpbin.org/links/4/2", "http://httpbin.org/links/4/3", ] for url in urls request = Typhoeus::Request.new(url) request.on_complete do |response| puts "#{response.request.url} got #{response.body}" end $hydra.queue(request) end $hydra.run
或者,我们可以收集请求并一起执行它们:
require 'typhoeus' $hydra = Typhoeus::Hydra.new(max_concurrency: 10) def pararell_requests(requests) for request in requests $hydra.queue(request) end $hydra.run return requests.map { |request| request.response } end urls = [ "http://httpbin.org/links/4/0", "http://httpbin.org/links/4/1", "http://httpbin.org/links/4/2", "http://httpbin.org/links/4/3", ] responses = pararell_requests( urls.map{|url| Typhoeus::Request.new(url)} ) for response in responses puts "#{response.request.url} got #{response.body}" end
这两种方法都有它们的好处:回调可以更容易地优化速度,而收集通常更容易使用。无论哪种方式,使用并行请求都可以将我们的网络抓取速度提高数十倍到数千倍,因此对于任何更大的抓取工具来说都是必须的!
现在我们已经熟悉并熟悉 Ruby 的 Typhoeus 中的 HTTP 协议,让我们来看看我们如何从我们正在检索的 HTML 数据中理解。在下一节中,我们将了解使用 CSS 和 XPATH 选择器的 HTML 解析。
解析 HTML:Nokogiri
检索 HTML 文档只是网络抓取过程的一部分——我们还必须解析它们以获取我们正在寻找的数据。幸运的是,HTML 格式被设计为可机器解析,因此我们可以利用这一点并使用特殊的 CSS 或 XPATH 选择器语言来找到要提取的页面的确切部分。 我们在之前的文章中非常详细地介绍了 CSS 和 XPATH 选择器:
在 Ruby 中,这些选择器最流行的包是Nokogiri。让我们快速看一下我们如何使用它来查找我们的数据。 Nokogiri是一个功能齐全的 XML 和 HTML 解析器。让我们看一些使用 CSS 和 XPATH 选择器的实际例子:
require 'nokogiri' html_doc = Nokogiri::HTML( '<div class="links"> <a href="https://twitter.com/@jingzhengli_dev">Twitter</a> <a href="https://www.linkedin.com/company/jingzhengli/">LinkedIn</a> </div>' ) # we can extra nodes text: puts html_doc.css('div').text # or nodes attributes: puts html_doc.css('div').attribute('class') # or xpath: puts html_doc.xpath('//div/a[contains(@href,"twitter")]/@href').text # we can also iterate through multiple nodes for link in html_doc.css('a') puts link.text end
在上面的示例中,我们看到 Nokogiri 具有网络爬虫可能需要的所有解析功能:选择节点的文本和属性值以及支持多个节点选择!
让我们将此解析知识用于示例项目。在下一节中,我们将结合 Typhoeus 和 Nokogiri 来抓取 indeed.com 的职位!
把它们放在一起:indeed.com 抓取工具
为了巩固我们在 Ruby 中进行网络抓取的知识,让我们为https://www.indeed.com编写一个简短的网络抓取程序。 首先,我们应该看看职位列表页面并设计我们的抓取策略:

使用此标记,我们可以设计我们的抓取和 html 解析算法。我们的通用算法看起来像这样:
- 转到https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10 这里请注意,我们可以使用 url 参数复制搜索页面:
q
用于查询、l
用于位置和start
用于页面偏移。 - 从每个工作信息容器中收集工作信息:标题、位置、公司和资源 URL
- 查找总页数
- 从以下页面收集工作信息
为此,我们将使用简单的函数式编程脚本和我们在本文中介绍的工具:用于 http 连接的Typhoeus和用于解析的Nokogiri。 让我们从定义我们将要使用的所有实用程序和最终运行函数的脚本框架开始:
require 'typhoeus' require 'nokogiri' require 'json' $hydra = Typhoeus::Hydra.new(max_concurrency: 20) # 1. def parallel_requests(requests) # helper function that executes multiple requests in parallel for request in requests $hydra.queue(request) end $hydra.run return requests.map { |request| request.response } end # 2. def run(query, location) # main function which runs our scraper # since our scraper doesn't know how many results are there in total we must # scrape the first page of the query: results, total_pages = scrape_jobs_page(query, location) # once we have total pages we can then scrape the remaining pages in parallel: results.concat( scrape_jobs_page(query, location, 10, total_pages)[0] ) puts "scraped #{results.length} results" # output results of all pages as nicely formatted json: return JSON.pretty_generate(results) end # call our scrape loop: run('ruby', 'Scotland')
在这里,我们首先要做的是定义我们的全局Hydra
引擎对象,稍后我们将使用它来并行安排请求。然后我们定义run()
实现核心抓取循环逻辑的函数。 这里的关键成分是用于抓取分页的常见网络抓取习惯用法:抓取第一页并计算出有多少页,然后并行抓取其余页面!这种技术提供了显着的网页抓取速度提升。 最后,我们有两个实现实际抓取逻辑的函数:
parse_jobs_page
– 使用 Nokogiri 以及 CSS 和 XPATH 选择器的混合提取工作列表数据。scrape_jobs_page
– 为给定的搜索查询、位置和页码抓取所有工作。
让我们实现它们来完成我们的抓取工具:
require 'typhoeus' require 'nokogiri' require 'json' $hydra = Typhoeus::Hydra.new(max_concurrency: 20) def parallel_requests(requests) # helper function that executes multiple requests in parallel requests.each do |request| $hydra.queue(request) end $hydra.run requests.map do |request| request.response end end # ------------------------------------------------------------------- # NEW CODE def parse_jobs_page(response) ## # finds job listing data in job listing page HTML # e.g. https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10 puts "#{response.request.url} got #{response.code}" tree = Nokogiri::HTML(response.body) jobs = [] base_url = response.request.url # to accurately extract jobs first we need to find job boxes tree.css('a.result').each do |job| jobs.push({ "title": job.css('h2').text, "company": job.css('.companyOverviewLink').text, "location": job.css('.companyLocation').text, "date": job.xpath(".//span[contains(@class,'date')]/text()").text, # turn urls to absolute urls (include domain name) "url": base_url + job.xpath('@href').text, "company_url": base_url + job.xpath('.//a[contains(@class,"companyOverviewLink")]/@href').text }) end # we can extract job count from "Page N of N jobs" string using regular expressions: total_jobs = Integer(/(\d+) jobs/.match(tree.css('#searchCountPages').text)[1]) [jobs, total_jobs] end def scrape_jobs_page(query, location, offset = 0, limit = 10) ## # scrapes jobs of a given query, location and pagination range # e.g. offset=0 and limit=100 will scrape first 100 results requests = [] # first we create all of the request objects for the request page range (offset + 10..limit).step(10).each do |i| puts "scheduling: https://uk.indeed.com/jobs?q=#{query}&l=#{location}&start=#{i}" requests.push( Typhoeus::Request.new( url = "https://uk.indeed.com/jobs?q=#{query}&l=#{location}&start=#{i}", followlocation: true, # NOTE: here we are using Chrome browser User-Agent and Accept headers to prevent being blocked 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' } ) ) end # then we execute these request in parallel: responses = parallel_requests(requests) results = [] # and finally parse job data from all of the results: responses.each do |response| found, _ = parse_jobs_page(response) results.concat(found) end [results, total_results] end # --------------------------------------------------------------------------- def run(query, location) ## # main function which runs our scraper # since our scraper doesn't know how many results are there in total we must # scrape the first page of the query: results, total_pages = scrape_jobs_page(query, location) # once we have total pages we can then scrape the remaining pages in parallel: results.concat( scrape_jobs_page(query, location, 10, total_pages)[0] ) puts "scraped #{results.length} results" # output results of all pages as nicely formatted json: JSON.pretty_generate(results) end # call our scrape loop: run('ruby', 'Scotland')
在上面的功能中,我们使用了我们在本文中学到的所有知识:
- 我们使用 Nokogiri 使用 CSS 和 XPATH 选择器来添加 HTML 的额外部分
- 我们使用
Hydra
scheduler ofTyphoeus
并行执行多个请求。 - 我们通过向我们的请求提供类似标头的浏览器来确保我们的抓取工具不会被阻止。
如果我们运行这个脚本,我们应该看到类似的结果:
[ { "title": "Software Development Engineer - PXF Team", "url": "https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10/rc/clk?jk=c46386f2a2b68d2e&fccid=fe2d21eef233e94a&vjs=3", "company": "Amazon Dev Centre(Scotland)Ltd", "company_url": "https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10/cmp/Amazon.com", "location": "Edinburgh", "date": "30+ days ago" }, { "title": "Senior Software Architect", "url": "https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10/rc/clk?jk=68588a02b773cfec&fccid=643a553ccd7fba02&vjs=3", "company": "GE Digital", "company_url": "https://uk.indeed.com/jobs?q=ruby&l=Scotland&start=10/cmp/GE-Digital", "location": "Edinburgh EH3 5DA", "date": "8 days ago" }, ... ]
使用 Ruby 生态系统中的这些现有工具,我们用几行代码编写了一个完整、快速的爬虫,但跳过了网络爬虫的主要痛点:错误处理和重试。由于我们使用的是互联网资源,因此我们永远无法确定其稳定性和可访问性。有时网站可能已关闭,或者我们的爬虫可以被识别并被阻止访问该网站。
概括
在这个广泛的教程中,我们介绍了 Ruby 中的网络抓取基础知识。我们探索了一个功能强大的 HTTP 客户端库Typhoeus,它用于libcurl
Ruby 中功能丰富的并行请求。此外,我们研究了Nokogiri—一个强大的 HTML 解析库,它允许我们使用 CSS 和 XPATH 选择器从 HTML 文档中提取我们需要的数据。最后,我们通过现实生活中的网络抓取示例巩固了我们的知识,并抓取了https://www.indeed.com的职位列表!