今更ですが、node.jsでbasic認証にかかったサイトをjqueryでスクレイピングをした。そして困った。

きっかけ

Conkyというデスクトップ上で色々表示できるプログラムで使うスクリプトをNode JSを使って書いてます。
どういった物かというと、ルータ(年代物のCoregaのBAR FX2)から外部IPを取得するもの。
UPnPが使えるルータなので本来ならUPnPで取得したいのですが、色々やってみてもうまくとれない・・・
そこでbasic認証がかかったルータの設定画面(Web)から取得する事にしました。

環境

Node.js -> 0.4.12 // 2011/10/30でstableのやつです
npm -> 1.0.101 // 最初apt-getでinstallしたら0.2.19なんてものが入った。これだと問題が発生したので入れなおした。
jsdom -> 0.2.8 // スクレイピングとかに使います
jquery -> 1.6.4 // 今回指定したバージョン

取得する為にする事

1.Webからデータを取得する方法
2.Basic認証の回避方法
3.取得したHTMLデータをパースする

やってみた

1.APIを見ていたら解決できました。

http - Node.js v0.4.12 Manual & Documentation
設定をオブジェクトで渡してコールバックを指定すると。
よくありがちな方法ですが、やはり楽。

1のサンプル

var http = require('http'),
    routerConf = {
        host : '192.168.0.1', // routerなので。
//        port : '80',     // 省略可能。※1
        path : '/st_dhcp.htm' // これも省略可能。※2
    };

http.get(routerConf,function (response) {

    var chunks = {length : 0, items : []};

    response.on('data', function (chunk) {
        // ここでブツ切れデータを持ったBufferとして渡ってきます。※3
        // ブツ切れなので全データ取得できるまでとりあえずどこかに放り込む。
        chunks.length += chunk.length;
        chunks.items.push(chunk);
    });
    response.on('end', function () {
        // 普段読める文字への変換。
        var allText = '',
            allBuffer = new Buffer(chunks.length)
            liveLength = 0,
            真面目 = function (chunk) {
                chunk.copy(allBuffer,liveLength);
                liveLength += chunk.length;
            },
            手抜き = function (chunk) {
                allText += chunk.toString('utf8');
            };

        chunks.items.forEach(真面目);
        chunks.items.forEach(手抜き);

        delete chunks,preLength,真面目,手抜き;

        // 日本語ですが動きます。どちらでも良いと思います。
        // allBuffer使ったほうが本来のやり方な気がします。
        // ここでは書きませんが文字コード変換(node-iconv)もBufferのままの方が楽です。
        console.log(allBuffer.toString('utf8'));
        console.log(allText);
    });
}).end(); // request終了をする。error処理をわざとしていないのでこういった書き方ができます。
          // 本来はon('error')などを使ってエラー処理すべきです。

※1 lib/http.jsの880行目あたりからの推測ですが。
※2 lib/http.jsの883行目こちらもこのあたりからの推測です。
※3 Buffer

2.ぐぐりました。サイト見つけました。APIも見ました。解決しました。

node.jsでbasic認証を使ってhttps接続する - @blog.justoneplanet.info
ぐぐると一番上にありました。

参考にしたのに否定的になるかもしれませんが・・・
確かに記事に書かれてる方法だと出来るのですが、APIを探してもcreateClientというAPIがない。
変更点の過去ログ等を調べると0.2系から0.3系に上がる時に廃止される方向になったとか。Node.js0.2から0.3への変更点
で、httpsの方法としてマニュアルhttps.requestソッドを使った方法が紹介されてましたが、単純にはうまく行っていなかった模様。
しかし、このサイトのお陰で、Basic認証の仕様を思い出す。
HeaderにAuthorizationを含めれば良いんだと。

関連API調べました。

http.request() は http.ClientRequest クラスのインスタンスを返します。

とマニュアルにあったので、マニュアルhttp.ClientRequestを眺めていると、

ヘッダは setHeader(name, value), getHeader(name), removeHeader(name) API によってまだ可変のままです。

とあったので試して見ましたがうまくいかず。ようやく見つけたのがheadersオプションでした。
最終的に以下のようなコードに。

2のサンプル

var http = require('http'),
    routerConf = {
        host : '192.168.0.1',
        path : 'st_dhcp.htm',
        headers : { // 追加
            'Authorization': 'Basic ' + new Buffer( 'id:pass').toString('base64')
        }
    };

    // 以下は1のサンプルと同じ。
http.get(routerConf,function (response) {
    var chunks = { length : 0, items : []};
    response.on('data', function (chunk) {
       chunks.length += chunk.length;
       chunks.items.push(chunk);
    });
    response.on('end', function () {
        var allBuffer = new Buffer(chunks.length)
            liveLength = 0;

        chunks.items.forEach(function (chunk) {
            chunk.copy(allBuffer,liveLength);
            liveLength += chunk.length;
        });

        delete chunks,preLength;

        console.log(allBuffer.toString('utf8'));
   });
}).end();
3.jQueryで取得する方法ぐぐりました。だけど古かった。jsdomの公式みました。方法わかりました。いけました。

node.jsとjQueryでスクレイピングするウェブアプリの作り方 | さくらたんどっとびーずを参考にしました。
上記のサイトではjsdomというライブラリとjqueryを使ってます。
しかし、半年以上前の記事なので結構APIが変わってました。

まずはインストール周りから。
環境を書いた時にnpmで問題が発生した為入れなおしましたと書いたのですが、
それは3で使うjsdomライブラリでpath解決の問題が発生した為です。
古いnpmの場合ファイルの置き方が違うらしく、うまくrequireできないという問題があります。
これはnpmのversionが1.0以上であれば解決します。

次にjsdomのAPI。もの凄く単純になってました。
以前はdocument作って、window作って、jquery使うのであれば専用関数読んで・・・とちょっと面倒だったのですが
今は1つのメソッドで良いようになってます。

以下公式サンプルです。

var jsdom = require('jsdom');
jsdom.env({
  html: 'http://news.ycombinator.com/',         // 対象となるurlかHTMLソース
  scripts: [
    'http://code.jquery.com/jquery-1.5.min.js' // 追加で必要となる外部のjavascript。scriptタグで指定されてる分については自動で読み込み等出来る模様。
  ],
  done: function(errors, window) {        // domが構築された後呼ばれる
    var $ = window.$;
    console.log('HN Links');
    $('td.title:not(:last) a').each(function() {
      console.log(' -', $(this).text());
    });
  }
});

これ見た時びっくりしました。1,2でやった事はなんだったのだと・・・。
しかしこのままだと、basic認証のサイトをどうやればいいのか解りません。
公式読んでも

config.html : see html above
config.scripts : see scripts above
config.src : An array of javascript strings that will be evaluated against the resulting document. Similar to scripts, but it accepts javascript instead of paths/urls.
config.done : see callback above
config.document :
referer : the new document will have this referer
cookie : manually set a cookie value i.e. 'key=value; expires=Wed, Sep 21 2011 12:00:00 GMT; path=/'
config.features : see Flexibility section below. Note: the default feature set for jsdom.env does not include fetching remote javascript and executing it. This is something that you will need to carefully enable yourself.

としか書かれていない。そこでjsdomのソース読みました。
lib/jsdom.js内に怪しいところありました。

      request({ uri: url,
                encoding: config.encoding || 'utf8',
                headers: config.headers || {}
              },
              function(err, request, body) {
                processHTML(err, body);
      });

あやしい・・・。どうみても怪しい。このファイルの先頭行でrequestというモジュールをrequireしてたので調べたら”Simplified HTTP request client.”とでてきました。
という事でheadersに追加しました。

3.で出来上がったコード。

var Jsdom = require('jsdom'),
    jquery_js = 'https://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js',

    routerConf = {     // http.getでも使いやすいように別けておく。無駄なオプションあると怒られる。
        'host' : '192.168.0.1',
        'path' : 'st_dhcp.htm',
        'headers' : {
            'Authorization': 'Basic ' + new Buffer('id:pass').toString('base64')
        }
    },
    httpConf = {
        'html' : 'http://'+routerConf.host+'/'+routerConf.path,
        'scripts' : [
            jquery_js
        ],
        'headers' : routerConf.headers,
        'done' : function (err,window) {
            var $ = jQuery = window.jQuery,             // $,jQueryで呼び出しやすくするため
                document = window.document;             // 同じ理由でdocumentも
            (function () {
                console.log($(document.body).text());
            }).apply(window);                           // this == windowにするため
        },
        'features' : {
            // scriptタグで指定されてるjsを読み込んだ後実行するかどうか。未指定だと実行。null等のfalsyな値を入れると自動実行されない。
            ProcessExternalResources : null
            // その他はデフォルト
            // MutationEventをどうするかや外部ファイルを読み込むか等がある。
        }
    };
Jsdom.env(httpConf);

文字コード変換が出来ませんがものすごく短くなりました。

感想

思ってたより楽!

以下余談と困った事

jsdomにはsrcオプションを使った場合、

バージョン0.2.9で修正されました
srcを使った分だけdoneに設定したcallbackが呼ばれるという不具合があります。
lib/jsdom 175行目

    totalDocs  = config.scripts.length,

とあるのですが、このtotalDocsはその192行目付近で

    var scriptComplete = function() {
      docsLoaded++;
      if (docsLoaded >= totalDocs) {
        window.document.implementation._features = features;
        callback(errors, window);
      }
    }

として使われています。このscriptCompleteはsrc,scriptsで指定したもののloadイベントが完了するたびによばれるものです。
jsdom自体にはpullリクエスト付きでバグ報告?として上がってました。
最近なのかと思ったら結構前から・・・?単純にこれは以下のように修正すれば良いです。

    totalDocs  = config.scripts.length + config.src.length,


あと、私個人の環境のせいなのか全く解ってない問題が。
その3で作ったソースの先頭に

process.on('uncaughtException', function (err) {
  console.dir(err)
});

var Jsdom = require('jsdom'),

としてもエラーが取得できないexceptionが・・・。周辺でtry-catchとしてもキャッチできない・・・。こまったw

何をしたらそうなったかというと
読み込み対象のhtmlのbodyタグにonloadのattributeがあった時に
onloadに設定されている関数で不具合やらがあった時そうなる。
詳細は不明。というかまだ未調査。

解決するかも?と思い、onloadで呼ばれる関数と同名の関数をsrcで用意し、
featuresオプションのfetchExternalResourcesをfalsyに設定しても解決しない。
うーん・・・どうしたものか。

文字コードを変換しても問題が発生するので文字コードは関係なさげ。