Astro × microCMS でリッチエディタから自動でリンクカードを作成する方法

Astro × microCMS でリッチエディタから自動でリンクカードを作成する方法

  • 02 Dec, 2024

はじめに

AstroとmicroCMSを使って、リッチエディタ内に含まれるリンクを、自動的にリンクカード(ブログカード?)形式に変換して表示する方法を解説します。

調べた際、remark-link-cardを使って対応しているものが多く見られましたが、あっちはファイル内のマークダウン用っぽい?ので、cheerioを使って実装しました。もっといい方法があれば教えていただけるとありがたいです🙏

今回使っている方法は全て下記の方を参考に作らせていただきました。ありがとうございます。

https://zenn.dev/hirokikameda/articles/2ecf83446eec8f

実現したい機能

リッチエディタ上に上画像のようなリンクがある場合

https://google.com

このようなリンクカード形式に変換する

コードの全体像

// 記事のリンクを抽出
    const $ = cheerio.load(post.content);
    
    // OGP情報を取得する関数
    async function fetchOGP(url) {
      try {
        const response = await fetch(url, {
          headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OGPFetcher/1.0)' }
        });
        const html = await response.text();
        const $ = cheerio.load(html);
    
        return {
          title: $('meta[property="og:title"]').attr('content') || $('title').text(),
          description: $('meta[property="og:description"]').attr('content') || '',
          image: $('meta[property="og:image"]').attr('content') || '',
          url,
        };
      } catch (error) {
        console.error(`Failed to fetch OGP for ${url}:`, error);
        return null;
      }
    }
    
    // リンクをOGP情報に基づくブログカードに置き換える
    const links = [];
    $('a').each((_, elm) => {
      const href = $(elm).attr('href');
      if (href && href.startsWith('http')) {
        links.push(href);
      }
    });
    
    const ogpPromises = links.map(fetchOGP);
    const ogpResults = await Promise.all(ogpPromises);
    const validOGPs = ogpResults.filter((ogp) => ogp !== null);
    
    // リンクをブログカードHTMLに置き換え
    $('a').each((_, elm) => {
      const href = $(elm).attr('href');
    
      if (href && href.startsWith('http')) {
        const parent = $(elm).parent();
    
        if (
          parent.is('p') &&
          parent.contents().length === 1 &&
          $(parent.contents()[0]).is('a')
        ) {
          const ogp = validOGPs.find((item) => item.url === href);
    
          if (ogp) {
            parent.replaceWith(`
              <a class="blogcard flex border overflow-hidden border-gray-200 rounded-lg hover:shadow-sm focus:outline-none bg-white w-full hover:opacity-70 duration-300 mb-4" href="${ogp.url}" data-ogp-processed="true" target="_blank">
                <div class="block p-4 flex-1">
                  <div class="min-h-24 flex flex-col justify-center">
                    <p class="blogcard-title font-semibold text-sm text-gray-800">${ogp.title}</p>
                    <p class="mt-1 text-xs text-gray-500">${ogp.description}</p>
                  </div>
                </div>
                <div class="w-32 sm:w-48">
                  <img class="blogcard-description block w-full h-full object-cover rounded-none" src="${ogp.image || '../default-image.jpg'}" alt="${ogp.title}">
                </div>
              </a>
            `);
          }
        }
      }
    });
    
    // ハイライトとブログカードを適用したHTMLをセット
    post.content = $.html();

全体の流れ

$('a').each を使って、記事本文の中に含まれる全てのリンクをリストに追加します。

詳しく解説

1. HTMLのパースをリンクの抽出


const $ = cheerio.load(post.content);
    
    // リンクを収集するための配列
    const links = [];
    $('a').each((_, elm) => {
      const href = $(elm).attr('href'); // `<a>`タグのhref属性を取得
      if (href && href.startsWith('http')) {
        links.push(href); // HTTPリンクのみを対象に収集
      }
    });
    

href属性を取得し、httpで始まる外部リンクだけをlinks配列に追加。

2. OGP情報の取得

収集したリンクごとにOGP情報(タイトル、説明、画像)を取得します。


async function fetchOGP(url) {
      try {
        const response = await fetch(url, {
          headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OGPFetcher/1.0)' }
        });
        const html = await response.text(); // リンク先のHTMLを取得
        const $ = cheerio.load(html); // HTMLを解析
    
        return {
          title: $('meta[property="og:title"]').attr('content') || $('title').text(),
          description: $('meta[property="og:description"]').attr('content') || '',
          image: $('meta[property="og:image"]').attr('content') || '',
          url,
        };
      } catch (error) {
        console.error(`Failed to fetch OGP for ${url}:`, error);
        return null; // エラー時にはnullを返す
      }
    }
    
    // 全リンクに対してOGP情報を取得
    const ogpPromises = links.map(fetchOGP);
    const ogpResults = await Promise.all(ogpPromises);
    const validOGPs = ogpResults.filter((ogp) => ogp !== null); // nullを除外
    

・Promise.allを使用して、すべてのリクエストが完了するまで待機。

3. ブログカード生成条件の判定

抽出したリンクを元に、どのリンクをブログカードに変換するかを判定します。


$('a').each((_, elm) => {
      const href = $(elm).attr('href');
    
      if (href && href.startsWith('http')) {
        const parent = $(elm).parent();
    
        // 条件: 親が`<p>`タグであり、その子要素が`a`タグ1つのみ
        if (
          parent.is('p') &&                // 親が<p>タグ
          parent.contents().length === 1 && // <p>タグ内のコンテンツが<a>タグ1つのみ
          $(parent.contents()[0]).is('a')  // <p>タグの中身が<a>タグ
        ) {
          const ogp = validOGPs.find((item) => item.url === href);
    
          if (ogp) {
            // この時点でカード変換対象
            parent.replaceWith(/* カードHTMLに置き換え */);
          }
        }
      }
    });
    

4. ブログカードHTMLの生成と置き換え

判定したリンクをブログカード形式のHTMLに変換します。


parent.replaceWith(`
      <a class="blogcard flex border overflow-hidden border-gray-200 rounded-lg hover:shadow-sm focus:outline-none bg-white w-full hover:opacity-70 duration-300 mb-4" href="${ogp.url}" target="_blank">
        <div class="block p-4 flex-1">
          <div class="min-h-24 flex flex-col justify-center">
            <p class="blogcard-title font-semibold text-sm text-gray-800" data-title="${ogp.title}">
              ${ogp.title}
            </p>
            <p class="mt-1 text-xs text-gray-500" data-description="${ogp.description}">
              ${ogp.description}
            </p>
          </div>
        </div>
        <div class="w-32 sm:w-48">
          <img class="blogcard-description block w-full h-full object-cover rounded-none" src="${ogp.image || '../default-image.jpg'}" alt="${ogp.title}">
        </div>
      </a>
    `);
    

5. 結果をHTMLとして適用

生成したHTMLを記事に反映します。


// 変換後のHTMLを記事コンテンツに適用
    post.content = $.html();

・$.html()で変更後のHTML全体を取得し、post.contentにセット。

・これにより、ブログ記事の本文がブログカードを含むHTMLとして再構成されます。

まとめ

各ステップの役割

おわりに

以上でAstro × microCMSを使ってリンクカードを作ることができました。再三ですが、もっといい方法があるかもしれないのでご了承ください🙇

勉強のためにヘッドレスCMSを使いましたが、使えば使うほど非エンジニアの第三者が編集しないならマークダウンで直接書いた方がいいのではと思うようになってしまいました・・・😔

そこらへんもまたまとめられたらと思うので、よろしくお願いします🙏