やっぱりPuppeteerをRubyから使えないと困るので、puppeteer-rubyを作ることにした

yusukeiwaki.hatenablog.com

から2ヶ月と少し経って、やっぱりpuppeteerはRubyから使えないと仕事で困るので、開発を再開した。

concurrent-ruby は意外とシンプルに使える

JSのasync/await をどうやって攻略するか?というところがポイントなのだけど、concurrent-rubyConcurrent::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

こんなコードを書けば

image

こんなかんじでグリグリ動くものはできた。(まだまだ未実装機能が山のようにあるけど・・・w)

とりあえずgem pushしとく

RailsのSystemTestCaseに結合できれば最高だなって思っているので、しばらくは空き時間で開発するとおもうけど、いつモチベーションが切れるかはわからない...。

そんな自分への戒めもこめて、とりあえずgem pushだけしといた。

rubygems.org

ちなみに完全に蛇足だが、 puppeteer って名前のGemは8年くらい前に取られていて、登録できなかった・・・!

puppeteer | RubyGems.org | your community gem host

 

コントリビューション、issueお待ちしております。(対応できるとは言っていない)

github.com