CoffeeScriptを利用したPhantom.jsのevaluateで「ReferenceError: Can't find variable: __slice」となる問題

原因

Phantom.jsのpage.evaluateに渡す関数は外側のスコープの変数を利用できないのに、CoffeeScriptのコンパイラが外側のスコープの変数を利用する関数を生成してしまうため。

対策

次のような関数を利用することでpage.evaluateに渡す関数が変数を自身のスコープ内のみで解決できるようにする。

toBeExecutableInBrowser = (func) ->
  str = func.toString().replace /\n/, '\n    var __slice = [].slice;\n'
  eval "(#{str})"

あるいは次のようにしてpage.evaluateの動作を変更し、ブラウザ内に__sliceという変数を事前に定義するようにしておく。

do () ->
  evaluate = page.evaluate
  page.evaluate = (func) ->
    evaluate.call @, `function() {
      window.__slice = [ ].slice;
    }`
    evaluate.call @, func

経緯みたいなもの(蛇足)

例えば、はてなブックマークホッテントリのタイトル一覧を取得しようとした場合、次のようなコードを書けば上手くいきます。

page = require('webpage').create()
page.open 'http://b.hatena.ne.jp/hotentry', (status) ->
  result = page.evaluate () ->
    links = document.getElementsByClassName 'entry-link'
    links = [ ].slice.call links
    JSON.stringify(links.map (link) -> link.innerText)
  titles = JSON.parse result
  console.log titles.join('\n')
  phantom.exit()

ブラウザ内でJavaScriptを実行するところの

links = document.getElementsByClassName 'entry-link'
links = [ ].slice.call links

という部分でdocument.getElementsByClassNameで取得したリンク一覧(配列のようなオブジェクト)をきちんとした配列に変換しています。

この部分をCoffeeScriptの文法を利用して次のように書き直しました。

[links...] = document.getElementsByClassName 'entry-link'

...これがGood PracticeかBad Practiceかどうかはどうかはさておくとして、一応これでも動くと思ったんですが、実行してみると次のようなエラーになってしまいました。

ReferenceError: Can't find variable: __slice

  phantomjs://webpage.evaluate():3
  phantomjs://webpage.evaluate():7
  phantomjs://webpage.evaluate():7
TypeError: 'null' is not an object (evaluating 'titles.join')

  example.coffee:17

CoffeeScriptのコンパイラが余計な気を利かして配列化の関数__slicepage.evaluateに渡す関数の外側で定義していたことが原因でした。page.evaluateは渡された関数の外側にある変数を利用できない(というかPhantom側からブラウザ側に関数を渡す過程でスコープチェーンを保持しておくことができない)ので、ブラウザ側で__sliceという変数の解決に失敗しているということです。

それで、なるべく関数のコードを変えないような形でこの問題を解決できないかといろいろ試したんですが、最終的に次のようにするのがベターかなと思いました。

toBeExecutableInBrowser = (func) ->
  str = func.toString().replace /\n/, '\n    var __slice = [ ].slice;\n'
  eval "(#{str})"

getTitles = () ->
  [links...] = document.getElementsByClassName 'entry-link'
  JSON.stringify(links.map (link) -> link.innerText)

browserGetTitles = toBeExecutableInBrowser getTitles

page = require('webpage').create()
page.open 'http://b.hatena.ne.jp/hotentry', (status) ->
  result = page.evaluate browserGetTitles
  titles = JSON.parse result
  console.log titles.join('\n')
  phantom.exit()

toBeExecutableInBrowserという関数を利用して、page.evaluateに渡したい関数の中身に無理やり__sliceの宣言を追加してしまうという感じです。

一応これで動くようになりました。まあ今回の場合はべつに[links...]という記法を使わずに[ ].slice.callを使ったり、そもそも[ ].map.callを使えばよかったわけですが、他にも色々と同じような問題が出てくるだろうということで書き残しておきました。CoffeeScriptの予約語リストを見るに'__hasProp', '__extends', '__slice', '__bind' '__indexOf'のあたりはまた同じような問題を起こしそうです。


もう一つのやり方として次のようなのも良い感じです。

browserGetTitles = () ->
  [links...] = document.getElementsByClassName 'entry-link'
  JSON.stringify(links.map (link) -> link.innerText)

page = require('webpage').create()

do () ->
  evaluate = page.evaluate
  page.evaluate = (func) ->
    evaluate.call @, `function() {
      window.__slice = [ ].slice;
    }`
    evaluate.call @, func

page.open 'http://b.hatena.ne.jp/hotentry', (status) ->
  result = page.evaluate browserGetTitles
  titles = JSON.parse result
  console.log titles.join('\n')
  phantom.exit()

page.evaluateの動作を変更して、先にグローバルに__sliceを定義するようにしています。CoffeeScriptでは__slice予約語になっているのでJavaScript埋め込み機能を利用しています。page.evaluateJavaScriptを使えばもう少し簡潔になるんですが、なぜか利用できないのでこういう形になりました。

あとはpage.includeJspage.injectJsなんかを利用しても解決できるかと思います。