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のコンパイラが余計な気を利かして配列化の関数__slice
をpage.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.includeJs
、page.injectJs
なんかを利用しても解決できるかと思います。