AstroJS で関連記事を出力する【TF-IDF】

heroImage

1. はじめに

読者に他のブログ記事を読んでもらう確率を上げることを考えると、関連記事を表示させることは有効な手段だと思います。WordPress ではプラグインをインストールすることで関連記事を出力することが出来ます。また、Hugo では関連記事を出力する仕組み 1 が標準機能として実装されています。

残念ながら AstroJS には、関連記事を出力する機能が執筆時点では組み込まれていません。そこで、関連記事を出力する仕組みを実装したいと思います。具体的には、ブログ記事のタイトル同士の関連度を算出します。本記事では、関連度として Term Frequency–Inverse Document Frequency (TF-IDF) を使います。また、TF-IDF を計算するために外部パッケージの natural を使います。natural の詳しい情報については、公式ドキュメント 2 を参照ください。

2. テンプレート

まず初めに、yarn create astro で blog テンプレートを生成します。次に、生成した blog テンプレートのディレクトリに移動して natural パッケージをインストールします。これで下準備完了です。

Terminal window
1
$ yarn create astro
2
yarn create v1.22.21
3
[1/4] Resolving packages...
4
[2/4] Fetching packages...
5
[3/4] Linking dependencies...
6
[4/4] Building fresh packages...
7
8
success Installed "create-astro@4.7.3" with binaries:
9
- create-astro
10
[########################################] 40/40
11
astro Launch sequence initiated.
12
13
dir Where should we create your new project?
14
./dreary-dwarf
15
16
tmpl How would you like to start your new project?
17
Use blog template
18
19
ts Do you plan to write TypeScript?
20
Yes
21
22
use How strict should TypeScript be?
23
Strict
24
25
deps Install dependencies?
26
Yes
27
28
git Initialize a new git repository?
29
No
30
Sounds good! You can always run git init manually.
31
32
Project initialized!
33
Template copied
34
TypeScript customized
35
Dependencies installed
36
37
next Liftoff confirmed. Explore your project!
38
39
Enter your project directory using cd ./dreary-dwarf
40
Run yarn dev to start the dev server. CTRL+C to stop.
41
Add frameworks like react or tailwind using astro add.
42
43
Stuck? Join us at https://astro.build/chat
44
45
╭─────╮ Houston:
46
Good luck out there, astronaut! 🚀
47
╰─────╯
48
$ cd dreary-dwarf
49
$ yarn add natural
50
yarn add v1.22.21
51
warning package.json: No license field
52
warning dreary-dwarf@0.0.1: No license field
53
[1/4] Resolving packages...
54
[2/4] Fetching packages...
55
[3/4] Linking dependencies...
56
warning Workspaces can only be enabled in private projects.
57
[4/4] Building fresh packages...
58
59
success Saved lockfile.
60
warning dreary-dwarf@0.0.1: No license field
61
warning Workspaces can only be enabled in private projects.
62
success Saved 9 new dependencies.
63
info Direct dependencies
64
└─ natural@6.10.4
65
info All dependencies
66
├─ afinn-165-financialmarketnews@3.0.0
67
├─ afinn-165@1.0.4
68
├─ apparatus@0.0.10
69
├─ natural@6.10.4
70
├─ safe-stable-stringify@2.4.3
71
├─ stopwords-iso@1.1.0
72
├─ sylvester@0.0.12
73
├─ underscore@1.13.6
74
└─ wordnet-db@3.1.14

3. TF-IDF

src/pages/blog/[...slug].astro にコード (ハイライト部分) を追加します。処理としては、任意のブログ記事のタイトルと全ブログ記事のタイトルの関連度を算出して、関連度が高い順にソートしています。

src/pages/blog/[...slug].astro
1
---
2
import { type CollectionEntry, getCollection } from 'astro:content'
3
import BlogPost from '../../layouts/BlogPost.astro'
4
5
export async function getStaticPaths() {
6
const posts = await getCollection('blog')
7
return posts.map((post) => ({
8
params: { slug: post.slug },
9
props: post,
10
}))
11
}
12
type Props = CollectionEntry<'blog'>
13
14
const post = Astro.props
15
const { Content } = await post.render()
16
17
import natural from 'natural'
18
const tfidf = new natural.TfIdf()
19
const posts = await getCollection('blog')
20
posts.map((post) => tfidf.addDocument(post.data.title))
21
tfidf
22
.tfidfs(post.data.title)
23
.map((measure, index) => {
24
return { index: index, measure: measure }
25
})
26
.sort((a, b) => b['measure'] - a['measure'])
27
.forEach((x) => console.log(x, posts[x['index']].data.title))
28
---
29
30
<BlogPost {...post.data}>
31
<Content />
32
</BlogPost>

yarn run dev で起動して、http://localhost:4322/blog/first-post/ にアクセスした際の実行結果が、以下の通りです。最も関連度が高いものとして First post が出力されているのは、重複しているからなので無視します。次に関連度が高いものとして Second postThird post が出力されています。これは、First post の post と Second post、Third post の post がマッチしているからです。Markdown Style Guide と Using MDX はマッチする単語がないので関連なしになっています。

Terminal window
1
{ index: 0, measure: 3.139434283188365 } First post
2
{ index: 2, measure: 1.2231435513142097 } Second post
3
{ index: 3, measure: 1.2231435513142097 } Third post
4
{ index: 1, measure: 0 } Markdown Style Guide
5
{ index: 4, measure: 0 } Using MDX

本記事では、ブログ記事のタイトルで計算していますが、ブログ記事の本文やタグなどで計算することも可能です。

4. おわりに

第 3 章で示したコードでは、ブログ記事のタイトルだけで関連度を計算しています。そのため、そこまで精度は高くありません。もう少し精度を上げるとしたら、本文やタグなどを追加する必要があると思います。また、同じようなブログ記事が出力されるといった挙動をする場合があります。これは、変数 posts の順序が固定されているために発生します。そのため、本記事では sort でランダム性を追加しています。日時でソートして、最新の記事を表示するなどの応用も考えられます。

src/pages/blog/[...slug].astro
1
const tfidf = new natural.TfIdf()
2
const posts = await getCollection('blog')
3
const randomPosts = posts.sort(() => Math.random() - 0.5)
4
randomPosts.map((post) => tfidf.addDocument(post.data.title))
5
const relatedPosts = tfidf
6
.tfidfs(post.data.title)
7
.map((measure, index) => {
8
return { index: index, measure: measure }
9
})
10
.sort((a, b) => b['measure'] - a['measure'])
11
.slice(1, 6)
12
.map((x) => randomPosts[x['index']])

  1. Hugo, Related content:https://gohugo.io/content-management/related/

  2. Natural, TF-IDF:https://naturalnode.github.io/natural/tfidf.html