Rubyウェブスクレイピング:実践ガイド
Advanced Data Extraction Specialist
TL;DR:
- Rubyのフェッチ&パースのストーリーは、セットアップが2行だけです。
Net::HTTPは標準ライブラリから、加えてNokogiriがサーバーレンダリングされたページを完全にカバーします。 - NokogiriはCSSセレクタを使用しますので、ほとんどの例が直接移植可能です。
doc.css("div.quote")は、ブラウザのコンソールで同じセレクタが動作するのと同様に動作します。 - Scrapeless Universal Scraping APIはJSONエンベロープで応答します。 マークアップは
dataの内側に到着するので、まずJSONをパースし、次にHTMLをパースします。 - FerrumはTLSブラウザエンドポイントに箱から出してすぐには到達できません。その理由は1行の欠落です。
hostnameを設定せずにCDPソケットを構築するため、SNIが送信されず、SNIに依存するホストがハンドシェイクを拒否します。孤立した状態で確認済み:同一ソケットで、SNIなしは失敗、SNIありはscrapeless.com用の証明書に対して成功します。 - 15行のパッチで動作します。 SNIを提供すると、同じスクリプトは
title: Quotes to Scrapeとquotes on page: 10を返しました。 - 無料で始めましょう: Scrapelessダッシュボードは、以下の例のすべてで機能するキーを発行します。
必要なもの
以下のすべては、スクレイピングの練習用に公開されたサイトquotes.toscrape.comに対してRuby 3.2.3で実行されました。
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
2つの習慣がすぐに役立ちます。cssはノードセットを返し、at_cssは最初の一致またはnilを返しますので、1つの要素が必要なときはat_cssを使いましょう — あるフィールドが欠落しているときのnilと空のセットとの違いであり、nilは間違いが起きた時点で大きなエラーを引き起こします。
子クエリの範囲を行に限定します。quotes.first.at_css('span.text')はそのノードの内側を検索しますが、ループ内でdoc.at_cssを呼び出すと、毎回最初の引用のテキストを返します。それは、すべての行が同じ値を持つ、信頼できるように見えるデータセットを生成しますが、これはクラッシュよりもはるかに悪い結果です。
Nokogiriのドキュメントは、XPathの同等物をカバーしており、そちらを好む場合はdoc.xpath("//div[@class='quote']")が他の方言での同じクエリです。
プレインRubyが止まるところ
スクリプトは、ターゲットがサーバーサイドでレンダリングし、呼び出し元をフィルタリングしないために動作します。これを終わらせる2つの状況があります:JavaScriptが実行された後にのみ存在するコンテンツ、および素のHTTPクライアントをボットとして扱うサイトです。いずれもRubyの制限ではありません — どの言語のHTTPクライアントも解決していません。
ここからのエスカレーションは、他のツールを利用することではなく、より良いバージョン同士の違いです。プレインパスは、機能するところで最速かつ最も安価なままです。ブラウザが追加するものについての背景が必要な場合は、ブラウザ自動化の説明が全体の形をカバーしています。
エスカレーション1:ユニバーサルスクレイピング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をパースし、その展開をメソッド内に保持してJSON.parse(...)["data"]をコードベース全体に散らさないようにします。
Net::HTTP.start(..., use_ssl: true)を短いNet::HTTP.postの代わりに注意してください。use_sslをhttps URIに対して省略するのは、混乱した接続リセットとして現れる一般的なRubyのつまづきです。
エスカレーション2:リアルブラウザと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はサーバーネームインディケーション拡張を送信しません。1つのアドレスから多くの証明書を提供するホストは、1つを選択できず、ハンドシェイクの失敗で応答します。
平凡なRubyの二十行でそれを隔離します — 同一のソケット、1行が異なる:
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
これが全てのバグです。hostname=を省略するRubyクライアントは、現在ほとんどがそうである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ソケットは、この形のスクレイパーが開く唯一の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
スコープに関する2つの注意。パッチはOpenSSL::SSL::SSLSocketに対してグローバルであり、単一目的のスクレイパーでは受け入れられますが、他のTLSソケットを開くRailsアプリケーションに読み込むものではありません — そこで、ブラウザを駆動するプロセスに限定してください。そして、上流の修正はgem内の1行だけであるため、独自のパッチを持つ前に、0.17.2以降のリリースが行われているか確認してください。
三つの間での選択
| approach | use when | cost |
|---|---|---|
Net::HTTP + Nokogiri |
サーバー生成のHTML、許可的なターゲット | 最低; 1つのgem |
| Universal Scraping API | ブロックされているまたは挑戦されている、JSは不要 | 1つのHTTPコール、展開すべき封筒 |
| Ferrum + リモートブラウザ | コンテンツはJavaScript実行を必要とする | 最高; ジョブごとのセッション、さらにSNIパッチ |
リストを上からではなく下に向かって進めてください。ブラウザが必要なように見えるページは、非常に多くの場合、<script>タグにデータが埋め込まれており、doc.at_css("script#__NEXT_DATA__")とJSON.parseでブラウザセッションに勝ちます。
結論
Rubyはスクレイピングをうまく処理し、標準ライブラリには人々が予想する以上にそれが含まれています。Net::HTTPとNokogiriはサーバー生成のページをカバーしており、at_css対cssと行スコープの子クエリが、正しい抽出と、見た目は良いが実際にはそうでないデータセットとの2つの違いです。
ブラウザパスは、Rubyが現在その隣接者よりもコストが高い場所であり、その理由は基本的なものではなく狭いものです:1つのgem内の未設定の属性がTLSを指すエラーメッセージを生成します。上記の隔離スクリプトは二十行でそれに応じ、パッチは上流が1行の修正を出荷するまで持ち運ぶのに十分小さいです。
Rubyでスクレイピングの準備はできましたか?
Scrapelessダッシュボードでキーを作成し、すでに収集しているページに対してNet::HTTPの例を実行してください。それが期待通りの結果を返す場合、完了です — ひとつのgem、ブラウザなし。なお、Universal Scraping APIはそれが行われないページをカバーし、現在の料金は料金ページにあります。
FAQ
Q: Nokogiriまたは代替パーサー?
NokogiriはHTMLのデフォルトのままです。CSSセレクタとXPathの両方を扱い、文書化が充実していて、誤ったマークアップを処理します。軽量な純Rubyのパーサーも存在しますが、ネイティブ拡張がデプロイメントで問題である場合にのみ考慮する価値があります。
Q: なぜ私のFerrum接続がSSLハンドシェイクエラーで失敗するのですか?
Ferrum 0.17.2がhostnameをOpenSSL::SSL::SSLSocketに設定しないため、SNIが送信されず、SNI依存のエンドポイントが証明書を選択できなくなるからです。それを上記の二十行のスクリプトで隔離して再現し、パッチを適用するか、上流の修正を待ってください。
Q: APIコールが明らかに成功したとき、なぜ私のパースが何も返さないのですか?
あなたは封筒を解析しています。レスポンスはJSONで、その中にdataのマークアップが含まれているため、NokogiriはJSON文字列を受け取り、何も一致せず、何も発生させません。まずJSONを解析し、body["data"]をNokogiriに渡してください。
Q: Ferrum::VERSIONは存在しますか?
いいえ。これはNameError: uninitialized constant Ferrum::VERSIONを発生させます。バージョンをログに記録したいときはGem.loaded_specs["ferrum"].versionを使用してください。
Q: Rubyは大規模なスクレイピングジョブに対して合理的な選択ですか?
フェッチとパースの作業については、はい — Nokogiriは高速なネイティブパーサーであり、スレッドはIOバウンドの同時処理をうまく処理します。弱点はブラウザの自動化であり、周囲のツールはPythonやNodeと比べて薄いです。上記のSNIの問題がそれを示しています。一般的な分割は、HTTP経路にRubyを使い、必要なページにホストされたブラウザを使用することです。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



