[Astro] 関連記事をタグ・カテゴリのスコアリングで出す
Tech記事下に「関連記事」を出したくて、外部サービスを使わずにタグとカテゴリのスコアリングで簡易レコメンドを実装しました。静的サイトなので、ビルド時に全記事を見て計算するだけです。
考え方
- 同じタグを共有していたら関連度が高い(重み大)
- 同じカテゴリなら少し関連度を足す(重み小)
- 自分自身と下書きは除外
- スコア順に並べて上位N件を出す
実装
getCollection で取った全記事を Props で受け取り、スコアを計算して上位4件を返します。
---
// src/components/RelatedPosts.astro
import type { CollectionEntry } from 'astro:content';
import type { CategoryKey } from '@/data/site';
interface Props {
currentSlug: string;
tags: string[];
category: CategoryKey;
allPosts: CollectionEntry<'blog'>[];
}
const { currentSlug, tags, category, allPosts } = Astro.props;
const relatedPosts = allPosts
.filter((p) => p.id !== currentSlug && !p.data.draft)
.map((post) => {
const sharedTags = post.data.tags.filter((t) => tags.includes(t)).length;
const sameCategory = post.data.category === category ? 1 : 0;
return { post, score: sharedTags * 2 + sameCategory };
})
.filter((p) => p.score > 0) // 関連が無いものは出さない
.sort((a, b) => b.score - a.score)
.slice(0, 4)
.map((p) => p.post);
---
{relatedPosts.length > 0 && (
<div class="grid grid-cols-2 gap-4">
{relatedPosts.map((post) => (
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
))}
</div>
)}
スコアは 共有タグ数 × 2 + 同カテゴリ(0 or 1)。タグ一致を重く見て、カテゴリは”おまけ”程度にしています。score > 0(=何かしら関連がある)で絞るので、無関係な記事は出ません。
呼び出し側
記事レイアウトから、現在の記事のタグ・カテゴリと、全記事を渡します。
---
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog');
const { post } = Astro.props;
---
<RelatedPosts
currentSlug={post.id}
tags={post.data.tags}
category={post.data.category}
allPosts={allPosts}
/>
ハマりどころ:カード内にリンクを入れ子にしない
関連記事カードに「カテゴリバッジ(=カテゴリページへのリンク)」も出したかったのですが、カード全体を <a> にした中にバッジの <a> を入れると、リンクの入れ子になって不正なHTMLになります。ブラウザが外側の <a> を勝手に閉じてしまい、バッジが別要素として飛び出す=レイアウトが崩れました。
対処は、リンクを兄弟要素に分けること。
<div class="group">
<a href={`/blog/${post.id}/`}>
<img ... />
<h5>{post.data.title}</h5>
</a>
<CategoryBadge category={post.data.category} /> {/* ← aの外に出す */}
</div>
メモ
- 関連記事はタグ一致を重めにスコアリングするだけで、それっぽい精度になる
- 静的サイトなのでビルド時に全記事を走査。記事数が多くなければ十分速い
- カード内にリンクの入れ子(aの中にa)を作らないこと
Astro, TypeScript