Quay lại blog

Ruby Web Scraping: Hướng Dẫn Thực Tế

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

11-Aug-2026

TL;DR:

  • Câu chuyện lấy và phân tích của Ruby chỉ cần hai dòng thiết lập. Net::HTTP từ thư viện chuẩn cộng với Nokogiri hoàn toàn bao phủ các trang được render từ server.
  • Nokogiri chấp nhận các bộ chọn CSS, vì vậy hầu hết các ví dụ chuyển đổi trực tiếp. doc.css("div.quote") hoạt động giống như cách cùng một bộ chọn hoạt động trong bảng điều khiển trình duyệt.
  • API Thu thập Dữ liệu Toàn cầu Scrapeless trả lời với một phong bì JSON. Đánh dấu đến bên trong data, vì vậy bạn phân tích JSON trước và HTML sau.
  • Ferrum không thể truy cập một điểm cuối trình duyệt TLS ngay từ đầu, và lý do là một dòng thiếu. Nó xây dựng socket CDP của mình mà không đặt hostname, vì vậy không có SNI nào được gửi và một máy chủ phụ thuộc SNI từ chối bắt tay. Đã được xác nhận trong điều kiện cô lập: socket giống nhau, không có SNI thì thất bại, SNI thành công với một chứng chỉ cho scrapeless.com.
  • Một bản vá mười lăm dòng khiến nó hoạt động. Với SNI được cung cấp, cùng một script trả về title: Quotes to Scrapequotes on page: 10.
  • Bắt đầu miễn phí: bảng điều khiển Scrapeless phát hành một khóa hoạt động với mọi ví dụ dưới đây.

Những Gì Bạn Cần

Mọi thứ dưới đây đều chạy trên Ruby 3.2.3 đối với quotes.toscrape.com, một trang web được xuất bản để thực hành thu thập dữ liệu.

bash Copy
gem install nokogiri ferrum --no-document
# nokogiri 1.19.4 · ferrum 0.17.2

Net::HTTP, URIJSON là thư viện chuẩn, vì vậy những phụ thuộc thực sự duy nhất là trình phân tích và sau đó là trình điều khiển trình duyệt.

Một điểm kỳ quặc về tên mà bạn nên biết trước khi viết một tiêu đề phiên bản: Nokogiri tiết lộ Nokogiri::VERSION, nhưng Ferrum không định nghĩa hằng Ferrum::VERSION. Tham chiếu đến nó sẽ gây ra NameError: uninitialized constant Ferrum::VERSION. Đọc phiên bản từ Gem.loaded_specs["ferrum"].version thay vào đó.

Lấy và Phân Tích

ruby Copy
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 Copy
http=200 bytes=11064
quotes=10
first_author=Albert Einstein
first_text=“The world as we have created it is a pr

Hai thói quen sẽ mang lại lợi ích ngay lập tức. css trả về một tập hợp node và at_css trả về kết quả đầu tiên hoặc nil, vì vậy hãy sử dụng at_css bất cứ khi nào bạn muốn một phần tử — đó là sự khác biệt giữa nil và một tập hợp rỗng khi một trường bị thiếu, và nil thất bại to lớn tại thời điểm sai lầm.

Hạn chế các truy vấn con trong hàng. quotes.first.at_css('span.text') tìm kiếm bên trong node đó, trong khi gọi doc.at_css trong một vòng lặp sẽ trả về văn bản của câu trích dẫn đầu tiên ở mỗi lần lặp. Điều này tạo ra một tập dữ liệu trông hợp lý, nơi mỗi hàng mang cùng một giá trị, điều này tệ hơn nhiều so với một sự cố.

Tài liệu của Nokogiri bao phủ các tương đương XPath nếu bạn thích chúng; doc.xpath("//div[@class='quote']") là cùng một truy vấn trong phương ngữ khác.

Nơi Ruby Thông Thường Dừng Lại

Script này hoạt động vì mục tiêu render bên server và không kiểm soát các caller của mình. Hai tình huống kết thúc điều đó: nội dung chỉ tồn tại sau khi JavaScript chạy, và các trang web coi một client HTTP bare như một bot. Cả hai không phải là giới hạn của Ruby — không có client HTTP nào trong bất kỳ ngôn ngữ nào giải quyết được cả hai.

Từ đây, sự leo thang là các công cụ khác nhau thay vì các phiên bản tốt hơn của nhau, và con đường thông thường vẫn là nhanh nhất và rẻ nhất ở những nơi nó hoạt động. Nếu bạn cần nền tảng về những gì một trình duyệt thêm vào, giải thích tự động hóa trình duyệt bao phủ hình dạng tổng quát.

Leo Thang Một: API Thu Thập Dữ Liệu Toàn Cầu

Điều này giữ cho mã của bạn là một client HTTP thông thường và chuyển độ khó về phía server.

ruby Copy
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 Copy
http=200 envelope_keys=code,data
quotes=10

Phong bì là phần cần nội tâm hóa. resp.body là JSON; bản đánh dấu sống tại body["data"]. Giao resp.body trực tiếp cho Nokogiri::HTML sẽ tạo ra một tài liệu không có node nào khớp và sẽ không ném ra lỗi nào — các bộ chọn lặng lẽ trả về không có hàng nào. Phân tích JSON, lấy data, sau đó phân tích HTML, và giữ lại việc giải nén đó trong một phương thức thay vì phân tán JSON.parse(...)["data"] khắp cơ sở mã.

Lưu ý Net::HTTP.start(..., use_ssl: true) thay cho Net::HTTP.post ngắn hơn. Bỏ use_ssl đối với một URI https là một sai lầm phổ biến của Ruby khiến cho kết nối được thiết lập lại một cách khó hiểu thay vì một lỗi rõ ràng.

Leo Thang Hai: Một Trình Duyệt Thực, và Một Lỗi Gem

Ferrum là trình điều khiển Giao thức DevTools của Chrome cho Ruby, và nó chấp nhận một ws_url: để gắn vào một trình duyệt mà bạn chưa khởi động. Nhắm vào một điểm cuối TLS, nó thất bại:

text Copy
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 peeraddr=172.67.74.62:443
state=error: sslv3 alert handshake failure

Thông điệp đó đặt tên sai lớp. Đây không phải là vấn đề chứng chỉ, vấn đề cipher, hay vấn đề proxy.

Nguyên Nhân Thực Tế

Ferrum 0.17.2 xây dựng socket CDP của nó trong lib/ferrum/client/web_socket.rb như sau:

ruby Copy
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 chưa bao giờ được gán, vì vậy Ruby không gửi mở rộng Chỉ báo Tên Máy chủ. Một máy chủ phục vụ nhiều chứng chỉ từ một địa chỉ không thể chọn một chứng chỉ, và phản hồi với lỗi bắt tay.
Hai mươi dòng Ruby đơn giản làm cho nó bị cô lập - các socket giống hệt nhau, chỉ khác một dòng:

ruby Copy
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 Copy
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

Đó là toàn bộ lỗi. Bất kỳ khách hàng Ruby nào bỏ qua hostname= sẽ xảy ra lỗi khi gọi đến bất kỳ điểm cuối nào phụ thuộc vào SNI, mà ngày nay hầu hết đều vậy.

Một Mánh Khóe Đáng Lưu Ý

Có một hành vi đáng nghi thứ hai trong Ferrum, và nó không phải là nguyên nhân. Ferrum::Browser::Process.parse_json_version thực thi URI.join(url, "/json/version") trên ws_url của bạn, điều này thay thế đường dẫn và loại bỏ chuỗi truy vấn. Một URL wss:// có đường dẫn là /api/v2/browser và chuỗi truy vấn mang theo token của bạn sẽ được viết lại thành gốc máy chủ cộng với /json/version, hoàn toàn xóa bỏ thông tin xác thực. Địa chỉ đã viết lại đó trả lời 404 page not found ở đây.

Nó trông có vẻ nghiêm trọng nhưng không phải vậy: phương thức này cứu JSON::ParserError, và một thân 404 không phải là JSON hợp lệ, vì vậy lỗi sẽ bị nuốt. Việc sửa chữa SNI một mình là đủ - bản sửa lỗi dưới đây vẫn báo lỗi 404 trong lần kiểm tra đó nhưng vẫn hoạt động. Đáng để biết để bạn không tốn một buổi chiều vào nó, như chẩn đoán đầu tiên của bài viết này đã làm.

Làm Cho Nó Hoạt Động

Socket CDP thường là socket TLS duy nhất mà một scraper dạng này mở, vì vậy việc cung cấp tên trong quá trình khởi tạo là đủ:

ruby Copy
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 Copy
title: Quotes to Scrape
quotes on page: 10

Hai ghi chú về phạm vi. Bản vá là toàn cục đối với OpenSSL::SSL::SSLSocket, điều này là chấp nhận được đối với một scraper có mục đích duy nhất và không phải là thứ cần tải vào một ứng dụng Rails mở các socket TLS khác - ở đó, hãy giới hạn nó vào quy trình điều khiển trình duyệt. Và bản sửa lỗi upstream chỉ là một dòng trong gem, vì vậy hãy kiểm tra xem có phiên bản phát hành nào sau 0.17.2 đã được cập nhật chưa trước khi mang theo bản sửa của riêng bạn.

Lựa Chọn Giữa Ba Cách

cách tiếp cận sử dụng khi chi phí
Net::HTTP + Nokogiri HTML do máy chủ kết xuất, mục tiêu cho phép thấp nhất; một gem
Universal Scraping API bị chặn hoặc gặp thách thức, không cần JS một cuộc gọi HTTP, bao bì để mở
Ferrum + trình duyệt từ xa nội dung cần thực thi JavaScript cao nhất; một phiên mỗi công việc, cộng với bản vá SNI

Hãy làm việc từ dưới lên thay vì từ trên xuống. Một trang dường như cần một trình duyệt rất thường xuyên nhúng dữ liệu của nó vào một thẻ <script>, và doc.at_css("script#__NEXT_DATA__") với JSON.parse vượt trội hơn phiên làm việc của trình duyệt trên mọi phương diện.

Kết Luận

Ruby xử lý việc thu thập dữ liệu rất tốt, và thư viện tiêu chuẩn mang lại nhiều điều hơn những gì người ta mong đợi. Net::HTTP và Nokogiri bao phủ các trang được máy chủ-rendered, với at_css so với css và các truy vấn con theo hàng làm hai chi tiết tách biệt giữa việc khai thác đúng và một tập dữ liệu trông ổn nhưng không phải vậy.

Con đường trình duyệt là nơi Ruby hiện nay tốn kém hơn so với các đồng nghiệp của nó, và lý do thì hẹp chứ không phải là cơ bản: một thuộc tính chưa được thiết lập trong một gem, tạo ra một thông điệp lỗi chỉ vào TLS thay vì vào SNI. Kịch bản cô lập ở trên giải quyết điều đó trong hai mươi dòng, và bản sửa quá nhỏ để mang theo cho đến khi upstream phát hành bản sửa một dòng.

Sẵn Sàng Để Thu Thập Dữ Liệu Với Ruby?

Tạo một khóa trên bảng điều khiển Scrapeless và chạy Net::HTTP ví dụ trên một trang mà bạn đã thu thập. Nếu nó trả về những gì bạn mong đợi, bạn đã hoàn tất - một gem, không trình duyệt. Universal Scraping API bao phủ các trang mà không có, và tỷ lệ hiện tại trên trang giá.

Câu Hỏi Thường Gặp

H: Nokogiri hay một trình phân tích thay thế?

Nokogiri vẫn là mặc định cho HTML. Nó hỗ trợ cả CSS selectors và XPath, được tài liệu hóa tốt, và xử lý các đánh dấu không chính xác. Các trình phân tích tinh khiết bằng Ruby nhẹ hơn tồn tại và chỉ nên được xem xét nếu các phần mở rộng gốc là một vấn đề trong triển khai của bạn.

H: Tại sao kết nối Ferrum của tôi lại thất bại với lỗi bắt tay SSL?

Bởi vì Ferrum 0.17.2 không thiết lập hostname trên OpenSSL::SSL::SSLSocket của nó, vì vậy không có SNI được gửi và một điểm cuối phụ thuộc vào SNI không thể chọn chứng chỉ. Tái tạo nó trong cô lập với kịch bản hai mươi dòng ở trên, sau đó áp dụng bản sửa hoặc chờ đợi bản sửa từ upstream.

H: Tại sao phân tích của tôi lại không trả về gì khi cuộc gọi API rõ ràng đã thành công?

Bạn đang phân tích bao bì. Phản hồi là JSON với đánh dấu bên trong data, vì vậy Nokogiri nhận được một chuỗi JSON, không khớp với gì, và không báo cáo lỗi. Phân tích JSON trước và đưa body["data"] cho Nokogiri.

H: Ferrum::VERSION có tồn tại không?

Không. Nó ném ra NameError: uninitialized constant Ferrum::VERSION. Sử dụng Gem.loaded_specs["ferrum"].version khi bạn muốn ghi lại phiên bản.

H: Ruby có phải là lựa chọn hợp lý cho các công việc thu thập dữ liệu lớn không?
Đối với công việc fetch-and-parse, đúng — Nokogiri là một trình phân tích nhanh và các luồng xử lý tốt độ đồng thời bị giới hạn bởi IO. Khu vực yếu hơn là tự động hóa trình duyệt, nơi mà các công cụ xung quanh mỏng hơn so với Python hoặc Node, như vấn đề SNI ở trên đã minh họa. Một sự phân chia phổ biến là Ruby cho đường dẫn HTTP và một trình duyệt được lưu trữ cho các trang cần một cái.

Tại Scrapless, chúng tôi chỉ truy cập dữ liệu có sẵn công khai trong khi tuân thủ nghiêm ngặt các luật, quy định và chính sách bảo mật trang web hiện hành. Nội dung trong blog này chỉ nhằm mục đích trình diễn và không liên quan đến bất kỳ hoạt động bất hợp pháp hoặc vi phạm nào. Chúng tôi không đảm bảo và từ chối mọi trách nhiệm đối với việc sử dụng thông tin từ blog này hoặc các liên kết của bên thứ ba. Trước khi tham gia vào bất kỳ hoạt động cạo nào, hãy tham khảo ý kiến ​​cố vấn pháp lý của bạn và xem xét các điều khoản dịch vụ của trang web mục tiêu hoặc có được các quyền cần thiết.

Bài viết phổ biến nhất

Danh mục