メインコンテンツへスキップ
kt-tech.blog

【Next.js】リンクカードの実装方法について

技術9分で読めます

この記事でわかること

  • 記事の HTML からリンクを拾って、リンク先の OGP を取ってカードに差し替える手順
  • cheerio での meta タグの拾い方と、og: を優先するセレクタの書き方
  • 外部の値を HTML に埋め込むときに必要なエスケープと、取得失敗・タイムアウトの扱い
  • markdown 変換でリンクが p タグに包まれてカードが崩れる問題の回避策

Next.js(App Router)と Server Actions を使ったことがあること。HTML のパースに cheerio を使います。

概要

Next.jsで作成したブログにリンクカードを実装する方法についてメモを残しておきます。今回は、server actionsを利用してサーバサイドでリンクカードを生成しておく方法を用いました。あまり参考になるリファレンスがなく苦労しました...

環境

  • Next.js 13.4.5
  • App Routerを採用

実装手順

OGP を取得する関数

まずは、OGP データを取得するメソッドを定義しておきます。

TypeScript
async function fetchOGPData(url: string) {
  // 応答の遅いサイトが1つあるだけでページ全体の生成が止まるので、必ず打ち切る
  const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
  const html = await response.text();
  const $ = cheerio.load(html);

  const getMetaTag = (name: string) => {
    return (
      $(`meta[property="og:${name}"]`).attr('content') ||
      $(`meta[name="twitter:${name}"]`).attr('content') ||
      $(`meta[name="${name}"]`).attr('content')
    );
  };

  return {
    title: getMetaTag('title'),
    description: getMetaTag('description'),
    image: getMetaTag('image'),
  };
}
Tips
属性セレクタの値は meta[name="title"] のようにクォートで囲むのが安全です。囲わないとハイフンやコロンを含む値でセレクタが壊れます。また、OGP を見るなら og: を先に試す順番にしておくと意図した値が取れます。

server actions 側の処理

server actions で実行するコードを記述します。

TypeScript
const html = "ブログの内容をHTML形式に変換したものを代入";

const parse_body = cheerio.load(html);

// 外部から取ってきた文字列をそのまま HTML に入れると、
// リンク先の meta タグに仕込んだタグがそのまま展開されてしまう
const escapeHtml = (value: string) =>
  value
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');

// 全てのリンク要素を取得(同じ URL は 1 回だけ取りにいく)
const links = new Set<string>();
parse_body('a').each((_, link) => {
  const href = parse_body(link).attr('href');
  if (href && !href.startsWith('#')) {
    links.add(href);
  }
});
const uniqueLinks = Array.from(links);

// 各リンクのOGPデータを非同期で取得(効率化のため並列実行)
// Promise.all だと 1 件の失敗で全部落ちるので allSettled を使う
const ogpDataResults = await Promise.allSettled(
  uniqueLinks.map((href) => fetchOGPData(href))
);

// hrefとOGPデータをマッピング(取れなかったものは入れない)
const hrefToOgpData = new Map<string, { title?: string; image?: string }>();
uniqueLinks.forEach((href, index) => {
  const result = ogpDataResults[index];
  if (result.status === 'fulfilled') {
    hrefToOgpData.set(href, result.value);
  }
});

// リンクカードの生成とHTMLの更新
parse_body('a').each((_, link) => {
  const href = parse_body(link).attr('href');
  if (!href || href.startsWith('#')) {
    return;
  }

  const meta = hrefToOgpData.get(href);
  // OGP が取れなかったリンクは普通の a タグのまま残す
  if (!meta?.title) {
    return;
  }

  const thumbnail = meta.image
    ? `<img src="${escapeHtml(meta.image)}" class="link-card-thumbnail" alt="" />`
    : '';

  const linkCardHTML = `
    <div class="link-card mt-3 mb-3">
      <a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
        <div class="link-card-body">
          <div class="link-card-info">
            <div class="link-card-title">${escapeHtml(meta.title)}</div>
            <div class="link-card-url">${escapeHtml(href)}</div>
          </div>
          ${thumbnail}
        </div>
      </a>
    </div>
  `;

  parse_body(link.parent).replaceWith(linkCardHTML);
});
重要
外部サイトから取ってきた値をそのまま埋め込まないこと。meta.title はリンク先が自由に書ける文字列なので、エスケープせずに HTML へ入れると、そこに書かれたタグがそのまま自分のページに展開されます。上のように escapeHtml を通すか、文字列連結ではなく React コンポーネントとして組み立ててください。

CSS

デザイントークンの場合は適宜変更をお願いします。

CSS
.link-card {
  -webkit-box-align: center;
  border: 0.3px solid rgb(var(--foreground-rgb));
  border-radius: 8px;
  display: flex;
  -webkit-box-pack: justify;
  justify-content: space-between;
  overflow: hidden;
  text-decoration: none;
  word-break: break-all;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}

.link-card > a{
  color: rgb(var(--foreground-rgb));
  width: 100%;
}

.link-card:hover {
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}

.link-card-body {
  display: flex;
  justify-content: space-between;
}

.link-card-info {
  padding: 1rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  font-size: 1.0rem;
  flex: 1; 
}

.link-card-title {
  color: var(--color-text-high-emphasis);
  display: -webkit-box;
  font-weight: 600;
  font-size: 1.0rem;
  line-height: 1.5;
  overflow: hidden;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
}

.link-card-url {
  color: var(--color-text-high-emphasis);
  display: -webkit-box;
  font-size: 12px;
  line-height: 1.8;
  overflow: hidden;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
}


.link-card-thumbnail {
  border-left: 1px solid var(--color-divider);
  flex-shrink: 0;
  object-fit: cover;
  width: 270px;
  margin: 0;
}

.link-card-body > img {
  border-radius: 0px;
  border-top-right-radius: 5px;
  border-bottom-right-radius: 5px;
  height: 110px;
}

@media (max-width: 768px){
  .link-card-thumbnail {
    width: 220px;
  }
}

@media (max-width: 479px){
  .link-card-thumbnail {
    width: 110px;
  }
}

表示結果

リンク先のタイトルとサムネイルが入ったカードに差し替わっていれば問題ないです。

ハマったところ

markdown を HTML に変換するとき、リンクが p タグに包まれてしまう問題の対処に 2、3 時間かかりました。

解決方法は、a タグの親要素ごと入れ替えるlink.parentreplaceWith する)ことでした。divp の中に入れられないのが原因なので、同じことをする人は気をつけてください。

おわりに

この実装は、記事を表示するたびにリンク先を fetch しにいくことになるので、実際に運用するなら取得結果をキャッシュするのが前提になります。リンク先の OGP はそう頻繁に変わらないので、ビルド時に一度取って保存してしまうのが楽です。

更新履歴

  1. コード例の不具合を修正。定義されていない uniqueLinks を参照していてそのままでは動かなかったのを、重複を除いたリンク集合を作る形に修正。取得した OGP をエスケープせずに HTML へ埋め込んでいたのを escapeHtml 経由に変更(リンク先の meta タグがそのまま自サイトで展開されるため)。fetch にタイムアウトを追加し、1件の失敗で全体が落ちないよう Promise.allSettled に変更。OGP が取れなかったリンクはカード化せずに a タグのまま残すよう修正。属性セレクタをクォートで囲い、og: を先に見る順番に変更。見出しを実装手順の下に入れ子にし、ハマった点の説明を具体化。表示結果に貼られていた自サイトトップへの裸 URL を削除。
  2. OGP Description の修正