AstroJS で関連記事を出力する【TF-IDF】
1. はじめに
読者に他のブログ記事を読んでもらう確率を上げることを考えると、関連記事を表示させることは有効な手段だと思います。WordPress ではプラグインをインストールすることで関連記事を出力することが出来ます。また、Hugo では関連記事を出力する仕組み 1 が標準機能として実装されています。
残念ながら AstroJS には、関連記事を出力する機能が執筆時点では組み込まれていません。そこで、関連記事を出力する仕組みを実装したいと思います。具体的には、ブログ記事のタイトル同士の関連度を算出します。本記事では、関連度として Term Frequency–Inverse Document Frequency (TF-IDF) を使います。また、TF-IDF を計算するために外部パッケージの natural
を使います。natural の詳しい情報については、公式ドキュメント 2 を参照ください。
2. テンプレート
まず初めに、yarn create astro
で blog テンプレートを生成します。次に、生成した blog テンプレートのディレクトリに移動して natural
パッケージをインストールします。これで下準備完了です。
1$ yarn create astro2yarn create v1.22.213[1/4] Resolving packages...4[2/4] Fetching packages...5[3/4] Linking dependencies...6[4/4] Building fresh packages...7
8success Installed "create-astro@4.7.3" with binaries:9 - create-astro10[########################################] 40/4011 astro Launch sequence initiated.12
13 dir Where should we create your new project?14 ./dreary-dwarf15
16 tmpl How would you like to start your new project?17 Use blog template18
19 ts Do you plan to write TypeScript?20 Yes21
22 use How strict should TypeScript be?23 Strict24
25 deps Install dependencies?26 Yes27
28 git Initialize a new git repository?29 No30 ◼ Sounds good! You can always run git init manually.31
32 ✔ Project initialized!33 ■ Template copied34 ■ TypeScript customized35 ■ Dependencies installed36
37 next Liftoff confirmed. Explore your project!38
39 Enter your project directory using cd ./dreary-dwarf40 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/chat44
45╭─────╮ Houston:46│ ◠ ◡ ◠ Good luck out there, astronaut! 🚀47╰─────╯48$ cd dreary-dwarf49$ yarn add natural50yarn add v1.22.2151warning package.json: No license field52warning dreary-dwarf@0.0.1: No license field53[1/4] Resolving packages...54[2/4] Fetching packages...55[3/4] Linking dependencies...56warning Workspaces can only be enabled in private projects.57[4/4] Building fresh packages...58
59success Saved lockfile.60warning dreary-dwarf@0.0.1: No license field61warning Workspaces can only be enabled in private projects.62success Saved 9 new dependencies.63info Direct dependencies64└─ natural@6.10.465info All dependencies66├─ afinn-165-financialmarketnews@3.0.067├─ afinn-165@1.0.468├─ apparatus@0.0.1069├─ natural@6.10.470├─ safe-stable-stringify@2.4.371├─ stopwords-iso@1.1.072├─ sylvester@0.0.1273├─ underscore@1.13.674└─ wordnet-db@3.1.14
3. TF-IDF
src/pages/blog/[...slug].astro
にコード (ハイライト部分) を追加します。処理としては、任意のブログ記事のタイトルと全ブログ記事のタイトルの関連度を算出して、関連度が高い順にソートしています。
1---2import { type CollectionEntry, getCollection } from 'astro:content'3import BlogPost from '../../layouts/BlogPost.astro'4
5export async function getStaticPaths() {6 const posts = await getCollection('blog')7 return posts.map((post) => ({8 params: { slug: post.slug },9 props: post,10 }))11}12type Props = CollectionEntry<'blog'>13
14const post = Astro.props15const { Content } = await post.render()16
17import natural from 'natural'18const tfidf = new natural.TfIdf()19const posts = await getCollection('blog')20posts.map((post) => tfidf.addDocument(post.data.title))21tfidf22 .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 post と Third post が出力されています。これは、First post の post
と Second post、Third post の post
がマッチしているからです。Markdown Style Guide と Using MDX はマッチする単語がないので関連なしになっています。
1{ index: 0, measure: 3.139434283188365 } First post2{ index: 2, measure: 1.2231435513142097 } Second post3{ index: 3, measure: 1.2231435513142097 } Third post4{ index: 1, measure: 0 } Markdown Style Guide5{ index: 4, measure: 0 } Using MDX
本記事では、ブログ記事のタイトルで計算していますが、ブログ記事の本文やタグなどで計算することも可能です。
4. おわりに
第 3 章で示したコードでは、ブログ記事のタイトルだけで関連度を計算しています。そのため、そこまで精度は高くありません。もう少し精度を上げるとしたら、本文やタグなどを追加する必要があると思います。また、同じようなブログ記事が出力されるといった挙動をする場合があります。これは、変数 posts
の順序が固定されているために発生します。そのため、本記事では sort
でランダム性を追加しています。日時でソートして、最新の記事を表示するなどの応用も考えられます。
1const tfidf = new natural.TfIdf()2const posts = await getCollection('blog')3const randomPosts = posts.sort(() => Math.random() - 0.5)4randomPosts.map((post) => tfidf.addDocument(post.data.title))5const relatedPosts = tfidf6 .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']])
-
Hugo, Related content:https://gohugo.io/content-management/related/ ↩
-
Natural, TF-IDF:https://naturalnode.github.io/natural/tfidf.html ↩