はじめに
The Pragmatic Bookshelf | Node.js the Right Wayを読んでいる時にツール作成の依頼が来たので、勉強がてらNode.jsで作ってみました。
HTMLファイルを読み込んでスクレイピングするところはSlideShareにあった電算部ネットワーク講座03のプレゼンテーション資料を参考にさせていただきました。ありがとうございます。
サンプルソース
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はMITです。
使っているモジュールの解説
recursive-readdir
ディレクトリを再帰的にたどってファイルのリストを返してくれるモジュールです。
使い方は上記のリンク先のUsageを見れば一目瞭然です。
Node.jsの標準APIで実現するには、fs.readdir(path, callback)でディレクトリ1段の情報を取得して、fs.fstat(fd, callback)でディレクトリかどうか判断して再帰することになるんでしょうが、非同期でやるの大変そうだなと思った所で検索したら見つけたので今回はこれで。
ディレクトリ配下のファイル数が膨大になる場合は、Streamのドキュメントを読んでReadableとして実装したほうがよいと思います。
q
Promisesの実装はたくさんあってどれ使えばいいのか悩みます。ComplexityMaze » Blog Archive » JavaScript Promises – a comparison of librariesによい比較記事がありました。
今回は、冒頭の本でも紹介されていたQを使ってみました。
Node.jsのAPIはコールバックの第1引数にエラーerrが来て、第2引数以下に結果のデータが来るものが多いですが、Adapting Modeで紹介されているQ.nfcall()とかQ.nfapply()を使うと簡単にPromise化できます。
また、複数のPromiseを実行して完了を待つQ.all()などもあります。
jschardet
文字列のエンコーディングを自動判定してくれるモジュールです。処理対象のHTMLファイルのエンコーディングがutf-8ではなくeuc-jpやshift_jisでも処理できるように自動判定するようにしました。
iconv
文字列のエンコーディングを変換するモジュールです。C++とJavaScriptで実装されています。
- C++のソースnode-iconv/src/binding.cc at master · bnoordhuis/node-iconv
- JavaScriptのソースnode-iconv/lib/iconv.js at master · bnoordhuis/node-iconv
iconvはStreamとして実装されていますので、readable.pipe(destination, [options])で繋いで簡単に使えます。
上のサンプルでは、読み込み時はjschardetで入力エンコーディングを自動判定しているのでpipe()を使わずに、convert()で変換しています。
もし、入力エンコーディングがeuc-jp固定なら、fs.readFile(filename, [options], callback)ではなくfs.createReadStream(path, [options])を使ってストリームを作成し、pipe()でiconvにつなげればすっきり書けると思います。
iconv = new Iconv('EUC-JP', 'UTF-8//TRANSLIT//IGNORE');
fs.createReadStream(inputFile).pipe(iconv)
cheerio
このモジュールを使うとjQueryっぽくHTMLの要素を取得できます。ただし、$
はjQueryのように固定ではなくHTMLを読み込んだ結果を$に設定して使うようになっていたり、いろいろ違いはあります。また、JavaScriptを解釈してDOM要素を更新したりはしないようで、純粋にHTMLを読み込んだ結果を参照するためのモジュールです。
csv
CSVを入出力するモジュールです。Stream対応されているので、入力や出力にストリームを渡すことが出来ます。
readable.pipe(destination, [options])を使うときの注意
今回一番ハマったポイントは、CSVに出力するときにpipe()を使用してSJISに変換しているところです。
iconv.pipe(fs.createWriteStream(outFile));
csv().from([columns].concat(rows), { columns: true })
.to(iconv, { header: true });
最初これを以下のように書いて、出力されたCSVファイルがSJISではなくUTF-8になってしまいました。
csv().from([columns].concat(rows), { columns: true })
.to(iconv.pipe(fs.createWriteStream(outFile)), { header: true });
readable.pipe(destination, [options])にきちんと説明があるのですが、pipe()の戻り値は入力readableではなく出力destinationとなっています。
これはr.pipe(z).pipe(w)
と複数のパイプを繋げるようにするためです。
上の間違った例のように書いてしまうとcsv()
からの出力はiconv
ではなくfs.createWriteStream(outFile)
に渡ってしまいます。