一般テキストからテンプレートマッチングで項目を一覧表形式で抽出するJSコード (パターンの繰り返しから正規表現で連続キャプチャ)

 

もし,文字列がデータ構造を持っていれば,パースは容易だ。たとえば:

  • DOM

これらはいずれもテキストが持つデータ構造であり,項目を抽出するのがきわめて容易。

 

しかし,データ構造を持たないようなテキストもある。

 

そういうテキストから,項目を抽出したい場合がある。

そんな場合に役立つのが以下のコード。

抽出結果をHTMLのTABLE形式で出力するので,表計算ソフトにコピペできる。そうなれば構造化が終わっているので,あとは加工し放題というわけ。
仕様メモ:
・同一テンプレート行内で複数のカッコにマッチした時は,各カッコが1こずつのtdとなる。
・同一テンプレートが複数回マッチした時は,一つのtdの中に改行付きで複数行のテキストとして格納される。
・テンプレートが最後まで到達したら,出力のtrも1行分が終わり,次のtrへ移る。

<h2>テンプレートマッチングを繰り返すことによって項目を抽出して一覧表形式に出力するフォーム</h2>



解析対象のテキスト:<br>

<textarea id="ta_honbun" cols="80" rows="20"></textarea><br>
<br>


テンプレート:<br>

<textarea id="ta_template" cols="80" rows="20"
></textarea><br>
<br>


<input type="button" value="この本文とテンプレートで項目抽出を開始!" onclick="f()"><br>
<br>


動作ログ:<br>

<textarea id="ta_log" cols="80" rows="20"></textarea><br>
<br>


項目の抽出結果:<br>

<pre id="pre_result_table"
style="padding:40px;margin:10px;background-color:lightgray;height:300px;overflow-y:scroll;overflow-x:scroll;"
></pre>


仕様:<br>

<pre style="padding:10px;margin:10px;background-color:lightgray;">

要件:

・テキストとテンプレートを入力すると,HTMLのTABLE形式で出力される。

・解析対象のテキストに対して複数行のテンプレートを渡すと,
テンプレートとマッチングした結果の該当する中身だけを抽出してくれる。
具体的に言うと,正規表現のキャプチャカッコに相当する部分だけを抜き出す。

・解析対象のテキストに対して,テンプレートは何回転もする。
同じような情報の繰り返しから,項目を抜き出すような用途に役立つ。


テンプレートの書き方:

・テンプレート文字列は,行内で正規表現として記述する。
アスタリスクなどの記号はエスケープ漏れに注意。

・テンプレート内で,正規表現として()でくくった内容がキャプチャされる。

・テンプレート文字列は,行内で,行頭を表す^と,行末を表す$を省略して記述する。

・テンプレート文字列には,二行続けて同じ内容の行なきこと。
1行書いておけば,次の行にも流用してマッチしようとするので,ダブらせる必要が無い。

・テンプレートに (.*) を書けば,空行を許可するひとつながりの連続行を表す。
いっぽうテンプレートに (.+) を書けば,空行を許可しないひとつながりの連続行を表す。


コード規約:

・変数名の接頭辞について,hは本文,tはテンプレートのつもり。

</pre>


<style>

textarea{
	overflow-x : scroll;
}

table.capture_results td{
	padding : 3px;
	background-color : whitesmoke;
}

</style>


<script>


// メイン処理
function f(){
	
	clear_log();
	$("pre_result_table").innerHTML = "処理中です。";
	
	var arr_honbun = $("ta_honbun").value.replace(/\r\n/g, "\n").split("\n");
	var arr_tmpl = $("ta_template").value.replace(/\r\n/g, "\n").split("\n");
	
	var match_infos_all;
	
	// マッチングを実行(すごく重い処理)
	var promise = getMatchInfos( arr_honbun, arr_tmpl );
	promise.execAndPassResultTo(function( result_of_promise ){
		// 実行結果を取得して
		match_infos_all = result_of_promise;

		// マッチング情報を結果表示
		showMatchResultsAsTable( match_infos_all );
		
		log("全処理が完了しました。");
	});
}



// 配列同士を比較してマッチングを実行
function getMatchInfos( arr_honbun, arr_tmpl ){

	// いまテンプレートの何行目にいるか
	var tmpl_line_cnt = 0;
	
	// マッチ情報の全集合体
	var match_infos_all = [];
	
	// テンプレートを一回転させた分に対してのマッチ情報
	var match_infos_one_tmpl = [];
	
	// テンプレート中のマッチが発生した行の番号
	var prev_matched_tmpl_line_cnt = -1;
	
	// 本文の全行に対して
	var promise = arr_honbun.each_with_heavy_operation(function( h_line, h_line_cnt ){

		// この中の処理はすごく重い。1400行ぐらいでブラウザの応用が停止の警告が出る。
		// なので,ここから先は同期的に実行せず,うまく非同期化する。

		var t_line;
		var matching_success = false;
		var testing_t_line_cnt;
		var m;
		
		log(
			"本文の" 
				+ h_line_cnt
				+ "行目を検査:"
				+ h_line
		);
		
		// テンプレートはまず一歩先でマッチングしてみる
		if( tmpl_line_cnt + 1 < arr_tmpl.length ){
			testing_t_line_cnt = tmpl_line_cnt + 1;
		}else{
			// テンプレートの最終行まで来たら,最初の行に戻ってみる
			testing_t_line_cnt = 0;
		}
		t_line = arr_tmpl[ testing_t_line_cnt ];
			log("テンプレートは一歩先でマッチングにトライ:" + t_line)
			
		m = match_honbun_and_template( h_line, t_line );
		if( m ){
			log( "マッチ成功:" + m.join(",") );
			
			// テンプレートが一回りした場合
			if( testing_t_line_cnt == 0 ){
				// ここまでを記録してから精算する
				match_infos_all.push( match_infos_one_tmpl );
				match_infos_one_tmpl = [];
			}

			// 今回のマッチ情報を保管(カッコによるキャプチャ情報がある場合のみ)
			if( m.length > 1 ){
				log("キャプチャ情報を格納");
				m.shift();
				match_infos_one_tmpl = match_infos_one_tmpl.concat( m );
			}
			
			// テンプレートの利用行を更新する
			tmpl_line_cnt = testing_t_line_cnt;
			matching_success = true;
			prev_matched_tmpl_line_cnt = tmpl_line_cnt;
			
			log();
			return;
		}else{
			log("マッチせず");
		}
		
		// この時点でまだマッチングが成功していなければ
		if( ! matching_success ){
			t_line = arr_tmpl[ tmpl_line_cnt ];
				log("テンプレートは進まずにマッチングにトライ:" + t_line)
		
			m = match_honbun_and_template( h_line, t_line );
			if( m ){
				log( "マッチ成功:" + m.join(",") );
				
				// 今回のマッチ情報を保管(カッコによるキャプチャ情報がある場合のみ)
				if( m.length > 1 ){

					log("キャプチャ情報を格納");
					
					// SPEC: 前回と同じ行をテンプレートとして利用した場合は,キャプチャ結果をマージする。
					if( prev_matched_tmpl_line_cnt == tmpl_line_cnt ){
						m.shift();
						
						// 末尾の要素に
						match_infos_one_tmpl[ match_infos_one_tmpl.length - 1 ] 
							+= (
								"\n" 
								+ m.join("") 
							)// マージする
						;
					}else{
						// ふつうにキャプチャ結果を記録
						m.shift();
						match_infos_one_tmpl = match_infos_one_tmpl.concat( m );
					}
					
				}
				matching_success = true;
				
				prev_matched_tmpl_line_cnt = tmpl_line_cnt;
				
				log();
				return;
			}else{
				// マッチしなかったらテンプレートに誤りがある
				log("テンプレートが不正です。動作停止");
				throw("wrong template");
			}
		}
		
	}).afterThat(function(){ // 重い処理が終わったら・・・
	
		// さいご余った分を記録
		match_infos_all.push( match_infos_one_tmpl );
		
		log("マッチングによるキャプチャ部分の項目抽出が完了");
		
		return match_infos_all;
	});
	
	// 実行予定だけを返す
	return promise;

}


// 本文とテンプレートを1行だけマッチ
function match_honbun_and_template( line_honbun, line_template ){
	return line_honbun.match( new RegExp( "^" + line_template + "$", "" ) );
		// NOTE: マッチングオプションにgを含めないことがポイント。
		// gがあると部分文字列が返ってこない。
		// http://d.hatena.ne.jp/chalcedony_htn/20090315/1237121111
}


// -------- Promise動作


// 配列の各行に対してすごく重い処理をしたい
Array.prototype.each_with_heavy_operation = function( func ){
	// promiseを数珠つなぎにする
	var promises = this.map(function( item, index ){
		return new Promise( func, [ item, index ] );
	});
	for( var i = 1; i < this.length; i ++ ){
		promises[ i - 1 ].setNextStep( promises[ i ] );
	}
	
	// 末尾のpromise要素を返す
	return promises[ promises.length - 1 ];
};


// 動作の約束オブジェクト
var Promise = function( func, args, params ){
	log( "promise生成" );
	
	this._func = func;
	this._args = args;
	
	if( params ){
		if( params.get_param_from_prev_step ){
			log("このpromiseは実行時に直前のpromiseから値を受け取ります。");
			this.get_param_from_prev_step = true;
		}
	}
}
Promise.prototype = {
	_func : null,
	_args : null,
	
	_prev_step : null,
	_next_step : null,
	
	get_param_from_prev_step : false,
	
	// 次の動作を決めておく
	setNextStep : function( p ){
		this._next_step = p;
		p._prev_step = this;
		
		return this;
	},
	
	// 次の動作をクロージャで指定
	afterThat : function( func ){
		var p = new Promise( func, [] );
		this.setNextStep( p );
		
		return p;
	},
	
	// ここまでのプロミスチェーンを全部実行して,
	// 返り値を次の動作に渡す
	execAndPassResultTo : function( next_func ){
		var p = this;
		var p_arr = [];
		
		// 前の動作へたどってゆく
		while( p != null ){
			p_arr.push( p );
			p = p._prev_step;
		}
		
		// 時系列に並び替える
		p_arr = p_arr.reverse();
		
		// 最後に付与
		var p_last = new Promise( 
			next_func, 
			[], 
			{
				// 直前の返り値を実行引数とする
				get_param_from_prev_step : true 
			} 
		);
		p_arr[ p_arr.length - 1 ].setNextStep( p_last );
		
		// 先頭のプロミスを取得して実行
		p_arr[0].execPromise();
	},
	
	// このプロミスを実行
	execPromise : function( arg ){
		
		// 前回の結果を引き継ぐか?
		if( this.get_param_from_prev_step ){
			// 引数として,前回の返り値を優先する。
			this._args = [ arg ];
			log("前回の結果を引き継ぐために,引数を更新しました。: " + this._args );
			log("更新済みの引数の個数:" + this._args.length );
		}
	
		// 動作を実行
		log("promise実行します・・・");
		var result = this._func.apply( this, this._args );
			//log("promise実行しました。実行結果:" + result );
		
		var next_p = this._next_step;
		if( next_p ){
			// 非同期に次へ移る
			setTimeout( function(){
				next_p.execPromise( result )
			}, 0 );
		}
	}
};


// ------ etc


// マッチ情報をテーブル形式で一覧表示
function showMatchResultsAsTable( match_infos_all ){
	
	log("結果表示:" + match_infos_all.length + "行のテーブル");
	
	// 全行
	var trs = match_infos_all.map(function( match_infos_one_tmpl, index ){
		
		log(
			"結果の連結中:テーブルの" 
				+ index 
				+ "行目は"
				+ match_infos_one_tmpl.length 
				+ "項目"
		);
		
		// 全項目
		var tds = match_infos_one_tmpl.map(function(item, index){
			return (
				"<td><pre>"
					+ ( "" + item )
						.replace(/>/g, "&gt;") // タグはソースとして表示する
						.replace(/</g, "&lt;")
						.replace(/^\n+/g, "") // 最初と最後の空改行は削除する
						.replace(/\n+$/g, "")
					+ "</pre></td>"
			);
		}).join("");
			// Array#mapと#joinの組み合わせサイコー!
		
		return "<tr>" + tds + "</tr>";
		
	}).join("\n");
	
	var table_html = "<table border=1 class='capture_results'><tbody>" + trs + "</tbody></table>";
	
	// 表示
	$( "pre_result_table" ).innerHTML = table_html;
}


// 配列のイテレータ
Array.prototype.each = function( func ){
	for( var i = 0; i < this.length; i ++ ){
		func.call( this, this[i], i ); 
	}
	return this; // チェインを継続
};

// map
Array.prototype.map = function( func ){
	var _arr = [];
	this.each(function( item, ind ){
		_arr.push( func.call( _arr, item, ind ) );
	});
	return _arr;
};


function $( dom_id ){
	return document.getElementById( dom_id );
}


function log( s ){
	if( ! s ) s = "";
	$("ta_log").value = ("" + s) + "\n" + $("ta_log").value;
}

function clear_log(){
	$("ta_log").value = "";
}



</script>


工夫した点:

  • 配列のeachがすごく重くなるので,jQueryのDeffered/Promiseパターンを参考に,自前でコールバックチェインの非同期化を実装した。


非同期化の部分は,同期的な実装の場合,本文テキストが1400行ぐらいになると,ブラウザのスクリプトが応答していませんという警告が表示された。

非同期化したあとは,3000行以上のテキストを余裕でパースできるようになった。数分かかるけどね。

 

今後の課題:

  • Promise部分を洗練されたライブラリに切り分けたい。
  • テキストとテンプレートの,わかりやすい公開可能なサンプルを付与したい。
  • Promise部分は,進捗をゲージで表示できるとなお良い。