Ruby网络爬虫:实用指南
Advanced Data Extraction Specialist
TL;DR:
- Ruby 的获取与解析故事只有两行设置。
Net::HTTP来自标准库,加上 Nokogiri 完全覆盖服务器渲染页面。 - Nokogiri 使用 CSS 选择器,因此大多数示例可以直接移植。
doc.css("div.quote")的行为与相同选择器在浏览器控制台中的行为一致。 - Scrapeless Universal Scraping API 的响应是一个 JSON 信封。 标记内容包含在
data中,因此您首先解析JSON,然后解析 HTML。 - Ferrum 默认情况下无法连接到 TLS 浏览器端点,原因是缺失的那一行。 它在未设置
hostname的情况下构建其 CDP 套接字,因此未发送 SNI,依赖 SNI 的主机拒绝握手。在隔离中确认:相同的套接字,没有 SNI 失败,有 SNI 成功与scrapeless.com的证书对接。 - 一个十五行的补丁使其工作。 提供 SNI 后,同样的脚本返回
title: Quotes to Scrape和quotes on page: 10。 - 免费开始: Scrapeless 仪表板 提供一个适用于以下每个示例的密钥。
您需要的
以下所有内容在 Ruby 3.2.3 上运行,访问 quotes.toscrape.com,这是一个用于抓取实践的网站。
bash
gem install nokogiri ferrum --no-document
# nokogiri 1.19.4 · ferrum 0.17.2
Net::HTTP、 URI 和 JSON 都是标准库,因此唯一的真实依赖是解析器,后来是浏览器驱动程序。
在您编写版本横幅之前,值得知道一个命名怪癖:Nokogiri 暴露 Nokogiri::VERSION,但 Ferrum 未定义 Ferrum::VERSION 常量。引用它会引发 NameError: uninitialized constant Ferrum::VERSION。改为从 Gem.loaded_specs["ferrum"].version 读取版本。
获取和解析
ruby
require "net/http"
require "uri"
require "nokogiri"
uri = URI("https://quotes.toscrape.com/")
res = Net::HTTP.get_response(uri)
puts "http=#{res.code} bytes=#{res.body.bytesize}"
doc = Nokogiri::HTML(res.body)
quotes = doc.css("div.quote")
puts "quotes=#{quotes.size}"
puts "first_author=#{quotes.first.at_css('small.author').text}"
puts "first_text=#{quotes.first.at_css('span.text').text[0, 40]}"
text
http=200 bytes=11064
quotes=10
first_author=Albert Einstein
first_text=“The world as we have created it is a pr
两个习惯立即产生回报。 css 返回一个节点集,而 at_css 返回第一个匹配项或 nil,因此每当您想要一个元素时,请使用 at_css — 这决定了 nil 和缺少字段时的空集之间的区别,而 nil 会在错误发生时大声失败。
将子查询的范围限制在行内。 quotes.first.at_css('span.text') 在该节点内搜索,而在循环中调用 doc.at_css 会在每次迭代中返回第一个引用的文本。这会产生一个看似合理的数据集,其中每一行都携带相同的值,这远比崩溃要糟糕得多。
Nokogiri 的文档涵盖了 XPath 对应物,如果您更喜欢它们; doc.xpath("//div[@class='quote']") 是另一种方言中的相同查询。
普通 Ruby 停止的地方
该脚本之所以可以工作,是因为目标在服务器端渲染,并且不屏蔽调用者。两种情况会结束这种情况:仅在 JavaScript 运行后存在的内容,以及将裸 HTTP 客户端视为机器人的网站。这两者都不是 Ruby 的限制 — 任何语言中的 HTTP 客户端都无法解决这两种情况。
在这里,升级使用不同的工具,而不是彼此更好的版本,而普通路径在有效的地方仍然是最快和最便宜的。如果您需要有关浏览器添加的背景,浏览器自动化说明 提供了整体形状的概述。
升级一:Universal Scraping API
这使您的代码成为普通的 HTTP 客户端,并将困难转移到服务器端。
ruby
require "net/http"
require "uri"
require "json"
require "nokogiri"
key = ENV.fetch("SCRAPELESS_API_KEY")
api = URI("https://api.scrapeless.com/api/v2/unlocker/request")
req = Net::HTTP::Post.new(api, "Content-Type" => "application/json", "x-api-token" => key)
req.body = JSON.dump(
actor: "unlocker.webunlocker",
input: { url: "https://quotes.toscrape.com/", js_render: false }
)
resp = Net::HTTP.start(api.hostname, api.port, use_ssl: true, read_timeout: 90) { |h| h.request(req) }
body = JSON.parse(resp.body)
puts "http=#{resp.code} envelope_keys=#{body.keys.join(',')}"
puts "quotes=#{Nokogiri::HTML(body['data']).css('div.quote').size}"
text
http=200 envelope_keys=code,data
quotes=10
信封是需要内化的部分。 resp.body 是 JSON;标记位于 body["data"]。将 resp.body 直接传递给 Nokogiri::HTML 会生成一个没有匹配节点的文档,并且没有抛出任何异常 — 选择器会安静地返回零行。解析 JSON,获取 data,然后解析 HTML,并将那个 unwrap 保持在一个方法中,而不是在代码库中分散 JSON.parse(...)["data"]。
注意 Net::HTTP.start(..., use_ssl: true) 而非较短的 Net::HTTP.post。在 https URI 上省略 use_ssl 是一个常见的 Ruby 绊脚石,其表现为混乱的连接重置,而不是明确的错误。
升级二:一个真实的浏览器和一个 Gem 错误
Ferrum 是 Ruby 的 Chrome DevTools 协议驱动程序,它接受一个 ws_url: 用于连接您未启动的浏览器。指向 TLS 端点时,它失败:
text
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 peeraddr=172.67.74.62:443
state=error: sslv3 alert handshake failure
该消息命名了错误的层。它不是证书问题、密码问题或代理问题。
实际原因
Ferrum 0.17.2 在 lib/ferrum/client/web_socket.rb 中构建其 CDP 套接字,如下所示:
ruby
tcp = TCPSocket.new(uri.host, port)
ssl_context = OpenSSL::SSL::SSLContext.new
@sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
@sock.sync_close = true
@sock.connect
OpenSSL::SSL::SSLSocket#hostname 从未被赋值,因此 Ruby 不发送 服务器名称指示 扩展。一个从一个地址服务多个证书的主机无法选择其一,从而导致握手失败。
二十行普通的 Ruby 将其隔离——相同的套接字,仅一行不同:
ruby
require "socket"
require "openssl"
HOST = "browser.scrapeless.com"
def attempt(sni)
tcp = TCPSocket.new(HOST, 443)
sock = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
sock.hostname = HOST if sni # the line ferrum omits
sock.sync_close = true
sock.connect
cn = sock.peer_cert.subject.to_a.find { |a| a[0] == "CN" }
result = "OK cert_cn=#{cn && cn[1]}"
sock.close
result
rescue => e
"FAIL #{e.class}: #{e.message.to_s[0, 70]}"
end
puts "without SNI: #{attempt(false)}"
puts "with SNI: #{attempt(true)}"
text
without SNI: FAIL OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 peeraddr=172.67.74.62:443
with SNI: OK cert_cn=scrapeless.com
这就是整个 bug。任何省略 hostname= 的 Ruby 客户端在任何依赖 SNI 的端点上都会碰到它,而今天大多数端点都是依赖 SNI 的。
一个值得命名的红鲱鱼
Ferrum 中还有第二种可疑行为,但这不是原因。Ferrum::Browser::Process.parse_json_version 在你的 ws_url 上运行 URI.join(url, "/json/version"),这 替换了路径并丢弃了查询字符串。一个路径为 /api/v2/browser 且查询中携带你的令牌的 wss:// URL 被重写为主机根加上 /json/version,完全丢失凭证。该重写地址在这里回答 404 page not found。
看起来致命,但并非如此:该方法捕捉 JSON::ParserError,404 响应体不是有效的 JSON,因此失败被吞没。单独修复 SNI 就足够了——下面的补丁运行在该探针上仍然会返回 404,但仍然可以正常工作。值得了解,以免你花一个下午去解决它,就像这篇文章的第一个诊断所做的那样。
使其工作
CDP 套接字通常是这种形式的 scraper 打开的唯一 TLS 套接字,因此在构造时提供名称就足够了:
ruby
require "ferrum"
require "openssl"
module SNIPatch
def connect
self.hostname = @ferrum_sni if @ferrum_sni && respond_to?(:hostname=)
super
end
end
class OpenSSL::SSL::SSLSocket
prepend SNIPatch
def ferrum_sni=(host)
@ferrum_sni = host
end
end
module SSLSocketFactoryPatch
def new(io, ctx = nil)
sock = super
sock.ferrum_sni = Thread.current[:ferrum_sni_host]
sock
end
end
class << OpenSSL::SSL::SSLSocket
prepend SSLSocketFactoryPatch
end
key = ENV.fetch("SCRAPELESS_API_KEY")
Thread.current[:ferrum_sni_host] = "browser.scrapeless.com"
browser = Ferrum::Browser.new(
ws_url: "wss://browser.scrapeless.com/api/v2/browser?token=#{key}",
timeout: 60,
process_timeout: 60
)
browser.go_to("https://quotes.toscrape.com/")
puts "title: #{browser.evaluate('document.title')}"
puts "quotes on page: #{browser.evaluate('document.querySelectorAll(".quote").length')}"
browser.quit
text
title: Quotes to Scrape
quotes on page: 10
关于范围的两点说明。该补丁对 OpenSSL::SSL::SSLSocket 全局有效,这在单功能 scraper 中是可接受的,而不适合加载到打开其他 TLS 套接字的 Rails 应用程序中——在那里,将其限制在驱动浏览器的进程中。并且上游修复是 gem 中的一行,因此在自己携带补丁之前,检查一下是否有超过 0.17.2 的版本更新。
在三者之间进行选择
| 方法 | 使用时 | 成本 |
|---|---|---|
Net::HTTP + Nokogiri |
服务器呈现的 HTML,可接受的目标 | 最低;一个 gem |
| 通用抓取 API | 被阻止或挑战,不需要 JS | 一个 HTTP 调用,封装需要解包 |
| Ferrum + 远程浏览器 | 内容需要 JavaScript 执行 | 最高;每个任务一个会话,加上 SNI 补丁 |
按列表向下工作,而不是向上。一个看似需要浏览器的页面通常会将其数据嵌入 <script> 标签中,而 doc.at_css("script#__NEXT_DATA__") 与 JSON.parse 在每个方面都优于浏览器会话。
结论
Ruby 对抓取的处理很好,标准库中包含的功能比人们预期的要多。Net::HTTP 和 Nokogiri 覆盖服务器呈现的页面,at_css 与 css 及行作用域子查询是将正确提取与看似正常但实际上不是的数据集区分开的两个细节。
浏览器路径是 Ruby 当前花费比其邻居更多的地方,原因是狭窄而非根本:一个 gem 中一个未设置的属性,产生一个指向 TLS 而非 SNI 的错误消息。上面的隔离脚本在二十行中回答了它,而补丁足够小,可以携带,直到上游发布这一行修复。
准备好用 Ruby 抓取了吗?
在 Scrapeless 控制面板 创建一个密钥,并对你已经收集的页面运行 Net::HTTP 示例。如果它返回你所期待的内容,你就完成了——一个 gem,无需浏览器。通用抓取 API 涵盖了它未覆盖的页面,当前费用请查看 定价页面。
常见问题解答
问:选择 Nokogiri 还是其他解析器?
Nokogiri 仍然是 HTML 的默认选择。它支持 CSS 选择器和 XPath,文档齐全,能够处理格式错误的标记。更轻量的纯 Ruby 解析器也存在,仅在你的部署中原生扩展有问题时值得考虑。
问:为什么我的 Ferrum 连接因 SSL 握手错误而失败?
因为 Ferrum 0.17.2 未在其 OpenSSL::SSL::SSLSocket 上设置 hostname,因此未发送 SNI,而依赖 SNI 的端点无法选择证书。通过上面的二十行脚本在孤立环境中重现此问题,然后应用补丁或等待上游修复。
问:为什么我的解析在 API 调用明显成功时返回空?
你正在解析信封。响应是含有标记的 JSON,data 中的内容,因此 Nokogiri 接收到的是 JSON 字符串,不匹配任何东西,也不抛出任何错误。先解析 JSON,并将 body["data"] 传递给 Nokogiri。
问:Ferrum::VERSION 存在吗?
不存在。它抛出 NameError: uninitialized constant Ferrum::VERSION。当你想记录版本时,使用 Gem.loaded_specs["ferrum"].version。
问:Ruby 是大型抓取工作的合理选择吗?
对于提取和解析工作,是的——Nokogiri 是一个快速的本地解析器,线程能够很好地处理 IO 绑定的并发。较弱的领域是浏览器自动化,在这一方面,周围的工具比 Python 或 Node 的要薄弱,如上面的 SNI 问题所示。一个常见的分配是使用 Ruby 进行 HTTP 路径,而需要浏览器的页面则使用托管浏览器。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



