node.jsでローカルディレクトリ配下のHTMLファイルのリストをSJISのCSVで出力するサンプル

# はじめに

[The Pragmatic Bookshelf | Node.js the Right Way](http://pragprog.com/book/jwnode/node-js-the-right-way)を読んでいる時にツール作成の依頼が来たので、勉強がてらNode.jsで作ってみました。

HTMLファイルを読み込んでスクレイピングするところはSlideShareにあった[電算部ネットワーク講座03](http://www.slideshare.net/ww24jp/03-27441078)のプレゼンテーション資料を参考にさせていただきました。ありがとうございます。

# サンプルソース

```js:https://github.com/hnakamur/nodejs-html-filelist-csv-example/blob/2e1eebadf81b46eb017e3a0c471e04ffa56cb41f/index.js
var baseDir = process.argv[2],
    baseURL = process.argv[3],
    outFile = process.argv[4],
    _ = require('underscore'),
    fs = require('fs'),
    path = require('path'),
    Q = require('q'),
    jschardet = require('jschardet'),
    Iconv = require('iconv').Iconv,
    cheerio = require('cheerio'),
    recursive_readdir = require('recursive-readdir'),
    csv = require('csv'),
    htmlFileExpr = /\.html?$/i;

if (!baseDir) {
  throw new Error('A directory of site contents must be specified!');
}
if (!fs.existsSync(baseDir)) {
  throw new Error('A directory specified does not exist!');
}
if (!baseURL) {
  throw new Error('A base URL must be specified!');
}
if (!outFile) {
  throw new Error('A output file must be specified!');
}

function readHtml(filename) {
  return Q.nfcall(fs.readFile, filename)
  .then(function(text) {
    var encoding = jschardet.detect(text).encoding,
        iconv,
        $,
        getText,
        getMetaContent,
        directory,
        title,
        keywords,
        description;

    if (encoding !== 'ascii' && encoding !== 'utf-8') {
      iconv = new Iconv(encoding, 'UTF-8//TRANSLIT//IGNORE');
      text = iconv.convert(text);
    }

    $ = cheerio.load(text);
    getText = function(selector) {
      var el = $(selector);
      return el ? el.text() : '';
    };
    getMetaContent = function(name) {
      var el = $('meta[name=' + name + ']');
          content = el ? el.attr('content') : '';
      return content ? content : '';
    };

    filename = filename.substr(baseDir.length);
    directory = path.dirname(filename);
    title = getText('title');
    keywords = getMetaContent('keywords');
    description = getMetaContent('description');
    return {
      directory: directory,
      title: title,
      keywords: keywords,
      description: description,
      URL: baseURL + filename
    };
  });
}

function writeCSV(results) {
  return Q.fcall(function() {
    var columns = [
          'directory',
          'title',
          'keywords',
          'description',
          'URL'
        ],
        rows = results.map(function(result) {
          return [
            result.directory,
            result.title,
            result.keywords,
            result.description,
            result.URL
          ];
        }),
        iconv = new Iconv('UTF-8', 'Shift_JIS//TRANSLIT//IGNORE');

    iconv.pipe(fs.createWriteStream(outFile));
    csv().from([columns].concat(rows), { columns: true })
    .to(iconv, { header: true });
  });
}

if (baseDir.substr(-1) === '/' && baseDir !== '/') {
  baseDir = baseDir.substr(0, baseDir.length - 1);
}
Q.nfcall(recursive_readdir, baseDir)
.then(function(files) {
  var htmlFiles = _.filter(files, function(file) {
        return htmlFileExpr.test(file);
      }),
      promises = _.map(htmlFiles, function(file) {
        return readHtml(file);
      });

  return Q.all(promises);
})
.then(writeCSV)
.done();
```

[LICENSE](https://github.com/hnakamur/nodejs-html-filelist-csv-example/blob/master/LICENSE)はMITです。


# 使っているモジュールの解説

## [recursive-readdir](https://www.npmjs.org/package/recursive-readdir)

ディレクトリを再帰的にたどってファイルのリストを返してくれるモジュールです。
使い方は上記のリンク先のUsageを見れば一目瞭然です。

Node.jsの標準APIで実現するには、[fs.readdir(path, callback)](http://nodejs.org/api/fs.html#fs_fs_readdir_path_callback)でディレクトリ1段の情報を取得して、[fs.fstat(fd, callback)](http://nodejs.org/api/fs.html#fs_fs_fstat_fd_callback)でディレクトリかどうか判断して再帰することになるんでしょうが、非同期でやるの大変そうだなと思った所で検索したら見つけたので今回はこれで。

ディレクトリ配下のファイル数が膨大になる場合は、[Streamのドキュメント](http://nodejs.org/api/stream.html#stream_api_for_stream_implementors)を読んでReadableとして実装したほうがよいと思います。

## [q](https://www.npmjs.org/package/q)

[Promises](http://www.promisejs.org/)の実装はたくさんあってどれ使えばいいのか悩みます。[ComplexityMaze » Blog Archive » JavaScript Promises – a comparison of libraries](http://complexitymaze.com/2014/03/03/javascript-promises-a-comparison-of-libraries/)によい比較記事がありました。

今回は、冒頭の本でも紹介されていたQを使ってみました。

Node.jsのAPIはコールバックの第1引数にエラーerrが来て、第2引数以下に結果のデータが来るものが多いですが、[Adapting Mode](https://www.npmjs.org/package/q#adapting-node)で紹介されているQ.nfcall()とかQ.nfapply()を使うと簡単にPromise化できます。

また、複数のPromiseを実行して完了を待つQ.all()などもあります。

## [jschardet](https://www.npmjs.org/package/jschardet)

文字列のエンコーディングを自動判定してくれるモジュールです。処理対象のHTMLファイルのエンコーディングがutf-8ではなくeuc-jpやshift_jisでも処理できるように自動判定するようにしました。

## [iconv](https://www.npmjs.org/package/iconv)

文字列のエンコーディングを変換するモジュールです。C++とJavaScriptで実装されています。

* C++のソース[node-iconv/src/binding.cc at master · bnoordhuis/node-iconv](https://github.com/bnoordhuis/node-iconv/blob/master/src/binding.cc)
* JavaScriptのソース[node-iconv/lib/iconv.js at master · bnoordhuis/node-iconv](https://github.com/bnoordhuis/node-iconv/blob/master/lib/iconv.js)

iconvは[Stream](http://nodejs.org/api/stream.html#stream_class_stream_transform)として実装されていますので、[readable.pipe(destination, [options])](http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)で繋いで簡単に使えます。

上のサンプルでは、読み込み時はjschardetで入力エンコーディングを自動判定しているのでpipe()を使わずに、convert()で変換しています。

もし、入力エンコーディングがeuc-jp固定なら、[fs.readFile(filename, [options], callback)](http://nodejs.org/api/fs.html#fs_fs_readfile_filename_options_callback)ではなく[fs.createReadStream(path, [options])](http://nodejs.org/api/fs.html#fs_fs_createreadstream_path_options)を使ってストリームを作成し、pipe()でiconvにつなげればすっきり書けると思います。

```js
  iconv = new Iconv('EUC-JP', 'UTF-8//TRANSLIT//IGNORE');
  fs.createReadStream(inputFile).pipe(iconv)
```

## [cheerio](https://www.npmjs.org/package/cheerio)

このモジュールを使うとjQueryっぽくHTMLの要素を取得できます。ただし、```$```はjQueryのように固定ではなくHTMLを読み込んだ結果を$に設定して使うようになっていたり、いろいろ違いはあります。また、JavaScriptを解釈してDOM要素を更新したりはしないようで、純粋にHTMLを読み込んだ結果を参照するためのモジュールです。

## [csv](https://www.npmjs.org/package/csv)

CSVを入出力するモジュールです。[Stream](http://nodejs.org/api/stream.html#stream_class_stream_transform)対応されているので、入力や出力にストリームを渡すことが出来ます。

# [readable.pipe(destination, [options])](http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)を使うときの注意

今回一番ハマったポイントは、CSVに出力するときにpipe()を使用してSJISに変換しているところです。

```js:正しい実装
    iconv.pipe(fs.createWriteStream(outFile));
    csv().from([columns].concat(rows), { columns: true })
    .to(iconv, { header: true });
```

最初これを以下のように書いて、出力されたCSVファイルがSJISではなくUTF-8になってしまいました。

```js:間違った実装例
    csv().from([columns].concat(rows), { columns: true })
    .to(iconv.pipe(fs.createWriteStream(outFile)), { header: true });
```

[readable.pipe(destination, [options])](http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)にきちんと説明があるのですが、pipe()の戻り値は入力readableではなく出力destinationとなっています。
これは```r.pipe(z).pipe(w)```と複数のパイプを繋げるようにするためです。

上の間違った例のように書いてしまうと```csv()```からの出力は```iconv```ではなく```fs.createWriteStream(outFile)```に渡ってしまいます。