a-blog cmsで関連エントリーを自動的に表示する

この記事は、[a-blog cms Advent Calendar 2017](https://adventar.org/calendars/2570)の12日目の記事です。

Webサイトやブログで必要不可欠なのが回遊性の強化です。a-blog cmsでは「[関連エントリー機能](https://developer.a-blogcms.jp/tips/entries/related-entries.html)」が備わっていますが、手動で関連記事を設定する必要があります。

自動で関連記事を表示するには、別途レコメンドシステムを導入したり、簡易的に「同じカテゴリー内の最新記事5件」や「全記事からランダムで5件」を表示したりといったアプローチがありますが、今回はJavaScriptを使って「閲覧している記事と近い内容の記事を表示する」方法をご紹介したいと思います。

下記は完成形のイメージです。

![related.png](https://qiita-image-store.s3.amazonaws.com/0/100753/10a1716c-8615-b735-6916-31eb1fdc6977.png)



# 実装の流れ

閲覧している記事と近い内容の記事を表示するロジックは、単語の登場回数やタグの使用状況で比較するなど様々な方法があるようですが、今回は本文を取得して、最も多く登場しているキーワードでサイト内検索をかけます。

1. 記事本文を取得
2. 記事本文をキーワードごとに分解
3. キーワードから助詞を削除し、2語以上の漢字またはカタカナのみ抽出
4. 登場頻度の最も高いキーワードでサイト内検索
5. 何もヒットしなければ全記事からランダム表示


# 具体的な実装の方法

## 関連記事を表示するエリアを作成

記事詳細 `entry.html` に関連記事を表示するエリアを追加します。

```entry.html
<!-- BEGIN_IF [%{VIEW}/eq/entry/] -->
<div class="related-article">
  <h4>関連記事</h4>
  <ul></ul>
</div>
<!-- END_IF -->
```

## 関連記事を検索するためのテンプレートを作成

Ajaxを使って呼び出す関連記事を検索するためのテンプレートファイル `related.html` を追加します。検索結果を表示するモジュールID `blog_related` を作成しておきます。条件設定の引数は「キーワード」にチェックを入れておきます。

```related.html
<!-- BEGIN_MODULE Entry_Summary id="blog_related" -->
<!-- BEGIN unit:loop -->
<!-- BEGIN entry:loop -->
<li><a href="{url}"><strong>{title}</strong> <span class="date">{date#Y}.{date#m}.{date#d} ({date#l}[weekEN2JP])</span></a></li>
<!-- END entry:loop -->
<!-- END unit:loop -->
<!-- END_MODULE Entry_Summary -->
```

![blog_related.png](https://qiita-image-store.s3.amazonaws.com/0/100753/fdba39e9-2b27-8010-ece5-f6c090def894.png)

![blog_related_order.png](https://qiita-image-store.s3.amazonaws.com/0/100753/8567b8b7-bfa6-b050-19da-aaa0405ea242.png)


## 本文を取得し関連記事を検索するJavaScriptを読み込み

関連記事エリアがあれば、本文 `.acms-entry` から単語を抽出し、助詞を削除し2文字以上の漢字またはカタカナでフィルタリングして「1位のキーワード 2位のキーワード」で検索します。ヒットしなければ「1位のキーワード」で検索します。合計3件になるまでヒットしなければ全記事一覧からランダムで表示します。単語の抽出は正規表現で行っているので、独自の単語表で抽出やフィルタリングを行うことができます。

```related_articles.js
$related_articles = $('.related-article');
$related_articles.each(function(){

	// 単語抽出用の正規表現
	var pattern_arr = [
		'[独自のキーワード]+',
		'[一-龠々〆ヵヶ]+',
		'[ぁ-ん]+',
		'[ァ-ヴー]+',
		'[a-zA-Z0-9]+',
		'[ａ-ｚＡ-Ｚ０-９]+',
		'[、。！!？?()（）「」『』]+',
	];
	var pattern_reg = new RegExp(pattern_arr.join('|'), 'g');

	// 助詞抽出用の正規表現
	var particle_str = "(でなければ|について|ならば|までを|までの|くらい|なのか|として|とは|なら|から|まで|して|だけ|より|ほど|など|って|では|は|で|を|の|が|に|へ|と|て)";
	var particle_reg_first = new RegExp('^'+particle_str+'', 'g');
	var particle_reg_last = new RegExp(''+particle_str+'$', 'g');

	// 本文を取得し単語ごとに分解
	var text = $('.acms-entry').text();
	var token = text.match(pattern_reg);
	
	// キーワードの出現数を取得
	var counts = {};
	for(var i=0;i<token.length;i++){
		// キーワードから助詞を削除
		var key = token[i].replace(particle_reg_first, "").replace(particle_reg_last, "");
		// 2文字以上の漢字またはカタカナのみ抽出
		if( key.length > 1 && key.match(/[ァ-ヴー]+|[一-龠々〆ヵヶ]+/) ){
			counts[key] = (counts[key]) ? counts[key] + 1 : 1 ;
		}
	}

	// キーワードの出現数順に並び替え
	var ary = new Array();
	var keywords = new Array();
	var keywordslog = "";
	for (var key in counts) { ary.push(counts[key]); }
	ary.sort(function(a, b) {return b - a;})
	for (var i = 0; i < ary.length; i++) {
		for (var k in counts) {
			if (ary[i] == counts[k]) {
				keywordslog += k + ' : ' + counts[k]+"\n";
				keywords.push(k);
				counts[k] = "";
			}
		}
	}
	console.info(keywordslog);

	// 関連記事の埋め込み
	// 1位と2位のキーワードで検索
	console.info('関連記事：「'+keywords[0]+'」「'+keywords[1]+'」で検索...');
	$.get(location.pathname+'/tpl/related.html?keyword='+keywords[0]+'%20'+keywords[1], function(data){
		// 何もなければ1位のキーワードのみで検索
		if(data!=''){
			$related_articles.find('ul').append(data);
			related_article_check();
			console.info('表示完了');
		}
		if(data==''){
			console.info('該当なし');
			console.info('関連記事：「'+keywords[0]+'」で検索...');	
			$.get(location.pathname+'/tpl/related.html?keyword='+keywords[0], function(data){
				// 何もなければ全件からランダム表示
				if(data!=''){
					$related_articles.find('ul').append(data);
					related_article_check();
					console.info('表示完了');
				}
				if(data==''){
					console.info('該当なし');
					console.info('関連記事：全件からランダム表示');	
					$.get(location.pathname+'/tpl/related.html', function(data){
						$related_articles.find('ul').append(data);
						related_article_check();
						console.info('表示完了');
					});
				}
			});

		}
	});

	// 関連記事を3つに調整
	function related_article_check(){
		// 関連記事が3つ以下なら全記事からランダム表示
		if($related_articles.find('li').length < 3){
			$.get(location.pathname+'/tpl/related.html', function(data){
				$related_articles.find('ul').append(data);
				related_article_check();
			});
		}
		// 関連記事が3つ以上なら3件に減らす
		if($related_articles.find('li').length > 3){
			$related_articles.find('li:gt(2)').remove();
		}
	}

});
```

## 関連エントリー機能も使えるようにする（おまけ）

手動でも関連エントリーを設定できるよう、関連エントリー表示用のモジュールID `blog_related_manually` を設定しておきます。関連エントリーが2件設定されていれば2件はそのまま表示して、残りの1件を自動的に関連するものを表示します。

```entry.html
<!-- BEGIN_IF [%{VIEW}/eq/entry/] -->
<div class="related-article" data-tag="<!-- BEGIN tag:veil --><!-- BEGIN tag:loop -->{name},<!-- END tag:loop --><!-- END tag:veil -->">
  <h4>関連記事</h4>
  <ul>
    <!-- BEGIN_MODULE Entry_Summary id="blog_related_manually" -->
    <!-- BEGIN unit:loop -->
    <!-- BEGIN entry:loop -->
    <li><a href="{url}"><strong>{title}</strong> <span class="date">{date#Y}.{date#m}.{date#d} ({date#l}[weekEN2JP])</span></a></li>
    <!-- END entry:loop -->
    <!-- END unit:loop -->
    <!-- END_MODULE Entry_Summary -->
  </ul>
</div>
<!-- END_IF -->
```
![blog_related_manually1.png](https://qiita-image-store.s3.amazonaws.com/0/100753/4506327a-c3ea-473d-729d-753ae9ba6ff9.png)

![blog_related_manually2.png](https://qiita-image-store.s3.amazonaws.com/0/100753/ca98cd20-37f0-a72a-e7e3-ef8c9bb21f15.png)


# 実装結果

開発者コンソールに検索の履歴を記録するようにしています。WordPressからのa-blog cmsへの移設案件があり、600件ほどの記事を対象に上記方法で実装してみましたが、なかなかそれっぽく動作しているのではないかと思います。 a-blog cmsはこのように複雑なことも、ちょっとした工夫で実現できるのがとてもおもしろいです。

![](https://qiita-image-store.s3.amazonaws.com/0/100753/04459aa0-9f38-ff1a-3e43-9eb3511f08ae.png)

![](https://qiita-image-store.s3.amazonaws.com/0/100753/beed0159-4a92-c0e9-aa2c-c98d9fe80402.png)



# 参考資料

* [PHPでブログのキーワードを抽出するクラス ← Neo Inspiration](https://neoinspire.net/archives/61)
* [JavaScript で形態素解析もどき - エブログ](http://ablog.seesaa.net/article/20969848.html)
* [形態素解析もどきを改良してみた - エブログ](http://ablog.seesaa.net/article/24578324.html)
* [形態素解析もどきで単語の頻出度を調べる JavaScript - エブログ](http://ablog.seesaa.net/article/22934677.html)