open-graph-scraperを使いながらNext.jsのブログにブログカードを出せるようにした

ふと、ブログカードってどうやって実現するのかが気になったので調べてやってみた。

ブログカードというのは、SlackやX、noteの記事なんかで表示されている、他ページの情報を簡易的に表示するコンポーネント。

実際にこれを実現するにあたって以下を検討することになる。

  1. 本文からのリンクの抽出・置換
  2. 取得の仕方・タイミング

本文からのリンクの抽出・置換

記事内では複数のURLが複数回出てくると思うのでそれを抽出する。
何かしら文字列に対してリンクを貼っている部分は対象にしてほしくないので、 URLに対してURLが貼ってあるもの を対象とした。

export function extractBlogCardUrls(html: string): BlogCardUrl[] {
  // aタグでhref属性とテキストが同じURLパターンをマッチ
  const linkRegex = /<a\s+href=["']?(https?:\/\/[^"'\s>]+)["']?[^>]*>\s*\1\s*<\/a>/gi;
  
  return Array.from(html.matchAll(linkRegex), ([fullMatch, url]) => ({
    url,
    fullMatch,
  }));
}

後々のOGP取得用のURLと、置換対象の文字列を返すようにした。

置換に関しては以下のような関数を作っておいた。

export function replaceBlogCardUrls(
  html: string,
  blogCardUrls: BlogCardUrl[],
  replacer: (url: string) => string,
): string {
  return blogCardUrls.reduce((result, { url, fullMatch }) => 
    result.replace(fullMatch, replacer(url)), html
  );
}

replacerの中で、ブログカードのコンポーネント文字列に置換してあげることになる。

OG情報の取得

サイトのOGP情報を使って表示しているので、その辺りの情報が取得できれば情報が揃うことになる。
open-graph-scraperというパッケージがあり、OGPやJSON-LDを取得できるようで今回はこれを使った。

import ogs from "open-graph-scraper";

const ogsPromise = ogs({
  url: "xxx",
  onlyGetOpenGraphInfo: true,
  timeoust: 3000
})

こんな感じで利用する。

実際にOGPを取得するタイミングを検討することになる

  1. SSR/SSGのバックエンドでの処理タイミング
  2. CSRでレンダリングされたタイミング

最初は2かなと思っていたんだけど、CLSが気になるのとパッケージがフロントエンドでの利用を想定していないという話を見かけたので、1でやることにした。
2でやった場合、相手サーバーへのリクエストが増えるというのも気になる。1の場合、サーバー側でキャッシュすることもできるのでそういったことも減らすことができそうに思った。

これらを踏まえて、SSRのバックエンドでの処理時に以下のような形で組み合わせた。

// ブログカード処理: URLを抽出してOGP情報を取得し、ブログカードに置換
  const blogCardUrls = extractBlogCardUrls(post.body);
  if (blogCardUrls.length > 0) {
    try {
      const urls = blogCardUrls.map((item) => item.url);
      const ogpDataList = await fetchMultipleOGP(urls);

      post.body = replaceBlogCardUrls(post.body, blogCardUrls, (url) => {
        const ogpData = ogpDataList.find((data) => data.url === url);
        return ogpData
          ? generateBlogCardHTML(ogpData)
          : `<a href="${url}" target="_blank" rel="noopener">${url}</a>`;
      });
    } catch (error) {
      console.warn("ブログカード処理でエラーが発生しました:", error);
      // エラー時は元のHTMLをそのまま使用
    }
  }

気になっている部分

一応実装自体はできたが、ブログカード部分が文字列への変数埋め込みになっているので、JSXを扱えるようにできるとメンテナンス性もいいのでそのうち対応したい。

参考にしたもの