から2ヶ月と少し経って、やっぱりpuppeteerはRubyから使えないと仕事で困るので、開発を再開した。
concurrent-ruby は意外とシンプルに使える
JSのasync/await をどうやって攻略するか?というところがポイントなのだけど、concurrent-rubyの Concurrent::Promises
っていうクラスを使うと解決ができた。
concurrent-ruby自体は非同期処理をするためのごちゃまぜライブラリな感が否めなくて「結局なにを覚えたらええの?」ってなるんだけど、覚えるべくは先に書いた Concurrent::Promises
.
だいたい以下の5つくらいのイディオムを覚えておけば、async/await, PromiseっぽいものをRubyで素直に書ける。
非同期ジョブとしてのPromiseの生成
Concurrent::Promises.future { # 時間のかかる処理 }
コールバックのPromise化
future = Concurrent::Promises.resolvable_future コールバック do |result| future.fulfill(result) end
Promiseが終わるまで待つ(JSのawait)
begin result = future.value! # fulfillされるまでブロッキング rescue => err # rejectされたとき end
同時並行で走らせ、全部終わるまで待つ(JSのPromise.all)
Concurrent::Promises.zip( future1, future2, ... )
同時並行で走らせ、どれか1つが終わるまで待つ(JSのPromise.race)
Concurrent::Promises.any( future1, future2, ... )
Rubyで非同期処理をスムーズに書くための工夫
goto
メソッドは同期だけど、 click
は非同期で、 type_text
は同期で・・・・
のようなライブラリは自分のような凡人だと「どのメソッドが非同期かわかりづらいな〜〜!!」となってしまい使う気がなくなってしまう。
最初は本当にメソッドごとに同期とか非同期とかばらばらだったんだけど、開発している自分でもだんだんわからなくなってきたので、
- 非同期なメソッドは必ず
async_
から始める - 非同期メソッドには必ず同期版のメソッドも用意する
のようにしてみた。
実装的には
# @param timeout [number|nil] # @param wait_until [string|nil] 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2' private def wait_for_navigation(timeout: nil, wait_until: nil) main_frame.wait_for_navigation(timeout: timeout, wait_until: wait_until) end # @param timeout [number|nil] # @param wait_until [string|nil] 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2' # @return [Future] async def async_wait_for_navigation(timeout: nil, wait_until: nil) wait_for_navigation(timeout: timeout, wait_until: wait_until) end
こんな感じで、非同期で呼ぶ必要があるものは同期版をFutureでラップして返すだけ。(もしくは逆に、同期版は非同期のメソッドをawaitするだけ。)
ダサいけども、こうすることで、
await hoge
のようになっていたら「あ、これは(同期メソッドだから)awaitいらない」って気づけるし、
await_all( send_message ..., # バグ async_wait_for_navigation, )
のようになっていたら「あ、これは非同期メッセージにしないといけないところを同期メソッドを使ってしまってる」と気付ける。
ドキュメンテーションでFutureが返るかどうかを示すという方法ももちろんあるにはあるけど、命名で解決できるならそのほうが実装負荷が少なくて済むと思った。
(未解決)なぜかJSとはイベントコールバック順序が変わってたりする...
おそらく非同期処理のタイミングの問題なんだろうけど
- JSだと必ずattachToTargetの返り値が返ってくるよりも前に
Target.attachedToTarget
がコールバックされるのに、puppeteer-rubyだとTarget.attachedToTargetのほうが後に呼ばれることが多い - JSだと必ずexecutionContextCleared -> executionContextCreated の順でコールバックされるのに、 puppeteer-rubyだとexecutionContextCreated -> executionContextClearedの順で呼ばれることが頻繁にある
などなど。
2つ目は特に意味がわからなくて、ブラウザのJS実行コンテキストが「作られたあとで破棄された」のように解釈されるので、いつまで経っても document を取得できなくて詰む。
とりあえずは
@context_id_to_context.values.each do |context| if context.world context.world.context = nil end end @context_id_to_context.clear
のロジックを少し改造して、「1秒以内に作られたExecutionContextはclearで破棄しない」というひどいロジックを入れて
# create時に記録しておいた時刻から1秒たっていないIDは削除対象外にする now = Time.now context_ids_to_skip = @context_id_created.select { |k, v| now - v < 1 }.keys @context_id_to_context.reject{ |k, v| context_ids_to_skip.include?(k) }.values.each do |context| if context.world context.world.context = nil end end @context_id_to_context.select!{ |k, v| context_ids_to_skip.include?(k) }
こうやって回避。ただ、これでも時々 document を取得できない問題は起きる。謎・・・
(追記: v0.0.11で解決しました!! puppeteer-ruby 0.0.11で完走率が劇的に改善。ブラウザの自動操作が快適に。 - YusukeIwakiのブログ)
(2021.01.29 追記: 問題の原因はおそらくこういうことです→ concurrent-rubyのConcurrent::PromisesはJavaScriptのPromiseと結構違う - Qiita)
ともあれ、動いた・・・!
require 'puppeteer' Puppeteer.launch(headless: false, slow_mo: 50, args: ['--guest', '--window-size=1280,800']) do |browser| page = browser.pages.first || browser.new_page page.viewport = Puppeteer::Viewport.new(width: 1280, height: 800) page.goto("https://github.com/", wait_until: 'domcontentloaded') form = page.S("form.js-site-search-form") searchInput = form.S("input.header-search-input") searchInput.type_text("puppeteer") await_all( page.async_wait_for_navigation, searchInput.async_press("Enter"), ) list = page.S("ul.repo-list") items = list.SS("div.f4") items.each do |item| title = item.Seval("a", "a => a.innerText") puts("==> #{title}") end end
こんなコードを書けば
こんなかんじでグリグリ動くものはできた。(まだまだ未実装機能が山のようにあるけど・・・w)
とりあえずgem pushしとく
RailsのSystemTestCaseに結合できれば最高だなって思っているので、しばらくは空き時間で開発するとおもうけど、いつモチベーションが切れるかはわからない...。
そんな自分への戒めもこめて、とりあえずgem pushだけしといた。
ちなみに完全に蛇足だが、 puppeteer
って名前のGemは8年くらい前に取られていて、登録できなかった・・・!
puppeteer | RubyGems.org | your community gem host
コントリビューション、issueお待ちしております。(対応できるとは言っていない)