🎫

自分のウェブサイトにブログカードを実装してみた

2021/03/22に公開

ブログなどを書いてて以下のような感じでカード形式でリンクを表示しているところがあります。ただのリンクよりリンク先の情報が事前に見れるのでとても良いですよね。zennなどでもこのようなリンクが実装されていて良いユーザー体験を提供していると思います。

画像1

自分のサイトを他のサイトやSNSで紹介してもらう際にどのような文言や画像をどのように設定するかという記事はよく見かけます。例えばogp画像の自動生成の記事はzennでもいくつか見かけますね。

https://zenn.dev/tdkn/articles/c52a0cc7bea561

https://zenn.dev/ryo_kawamata/articles/introduction-socialify

https://zenn.dev/makiart/articles/78d53694e70105

https://zenn.dev/mkmk4423/articles/13e913c0a5543781639f

ですが、自分のサイトに他のサイトの情報を取得して表示するかを書いた記事はあまり見かけません。zennでもいくつかはあり、参考にさせてもらいまいた。

https://zenn.dev/littleforest/articles/scrape-og-tags

https://zenn.dev/junki555/articles/a4902e7f66547c91d812

wordpressなどではプラグインが提供されているようで、その導入方法などの記事はすぐ見つかるのですが、例えば私のようにnext.jsを使用した場合などの記事はまったくと言っていいほどです。なので今回はnext.jsを使用した私なりの実装方法を紹介します。良い方法なのかどうかはわかりませんが、参考程度に見ていただければと思います。また、アドバイス等ありましたらお願いします。

仕組み

まずそもそもブログカードは「タイトル」「説明」「URL」「画像」で構成されていることがほとんどです。

ではこれらの情報はどこから取得しているかと言うとhtmlファイルのhead内にあるmetaタグに記述されているものを取得しています。

ですからざっくり説明するとリンクのURLのうち、カード表示にしたいURLのサイトにアクセスし、metaタグの情報を取得する仕組みを作ればいいわけです。

実装

とはいえ簡単に実装できない問題もあります。CORS(Cross-Origin Resource Sharing: オリジン間リソース共有)の問題です。

オリジンとは簡単に言えば、ホスト名のことであり、CORSとは、異なるホスト間でリソースを共有するための仕組みです。(詳しくは以下)

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

https://developer.mozilla.org/en-US/docs/Glossary/Origin

大抵のサイトはこれが設定されているため、ブラウザを使用したリソースの取得は可能ですが、クライアントサイドでjavaScriptなどを使用したリソースの取得はできなくなっています。そのためサーバサイドで情報を取得しブログカードを作成する必要があります。

  1. クライアントの操作によってブログカードを動的に作成したい場合、「ajaxで作成したいURLをサーバサイドに送り、サーバサイドで情報の取得・レンダリングを行いクライアントサイドに返す」という方法が考えられます。

  2. next.jsのSSGのようにあらかじめレンダリングしておくことができるものならば、「SSGの際にURLから情報の取得・レンダリング」を行うといいでしょう。

SSGの処理

今回は、マークダウンファイルから記事をSSGで生成するので2番の方法を紹介します。
流れとしては、

  1. 記事のデータを文字列として取得
  2. カードに変換したいリンクのURLを配列として取得
  3. URLからtext/htmlデータを取得
  4. 取得したtext/htmlデータから該当するmetaタグを取得
  5. title, description, imageにあたる情報を取り出し配列として格納
  6. 配列をコンポーネントに返す。

です。サーバサイドJSではDOM操作のAPIが無いので、JSDOMというライブラリを使用してDOM操作を行っています。
また、コンポーネントにデータを渡す際は、JSONで渡すようなのでundefinedのようなオブジェクトは除外する必要があります。

const jsdom = require("jsdom");
const { JSDOM } = jsdom;

export const getStaticProps: GetStaticProps = async({ params }) => {
    // 1. 記事のデータを文字列として取得 ========================================
    const article = getArticle(); // 記事のデータを文字列として取得

    // 2. カードに変換したいリンクのURLを配列として取得 ===========================
    // 行ごとに配列にする
    const lines = article.split("\n");
    const links = [];
    // URLの取得
    lines.map(line => {
        if (line.indexOf("http://") === 0) links.push(line);
        if (line.indexOf("https://") === 0) links.push(line);
    });

    let cardDatas = [];
    const temps = await Promise.all(links.map(async(link) => {
        const metas = await fetch(link)
        // 3. URLからtext/htmlデータを取得 ====================================
        .then(res => res.text())
        .then(text => {
            const metaData = {
                url: link,
                title: "",
                description: "",
                image: "",
            };
            // 4. 取得したtext/htmlデータから該当するmetaタグを取得 ==============
            const doms = new JSDOM(text);
            const metas = doms.window.document.getElementsByTagName('meta');

            // 5. title, description, imageにあたる情報を取り出し配列として格納 ==
            for (let i = 0; i < metas.length; i++) {
                let pro = metas[i].getAttribute("property");
                if (typeof pro == "string") {
                    if (pro.match("title"))       metaData.title = metas[i].getAttribute("content");
                    if (pro.match("description")) metaData.description = metas[i].getAttribute("content");
                    if (pro.match("image"))       metaData.image = metas[i].getAttribute("content");
                }
                pro = metas[i].getAttribute("name");
                if (typeof pro == "string") {
                    if (pro.match("title"))       metaData.title = metas[i].getAttribute("content");
                    if (pro.match("description")) metaData.description = metas[i].getAttribute("content");
                    if (pro.match("image"))       metaData.image = metas[i].getAttribute("content");
                }
            }
            return metaData;
        })
        .catch(e => {console.log(e);});
        return metas;
    }));
    // 配列の整形 ※コンポーネントに渡す際はjson情報に変換するようなので
    // undefinedのようなオブジェクトは除外する。
    cardDatas = temps.filter(temp => temp !== undefined);

    // 6. 配列をコンポーネントに返す。 ==========================================
    return {
        props: {
            data,
            cardDatas,
        }
    }
}

まずそもそもブログカードは「タイトル」「説明」「URL」「画像」で構成されていることがほとんどです。 と書きましたが、ここはその通りにする必要はなく5. title, description, imageにあたる情報を取り出し配列として格納の処理で任意のタグから情報を取得すれば自分好みのブログカードにすることができます。

そもそも必ずしもtitle, description, imageといったmetaタグを用意しているとも限らず、しっかりしたブログカードを作るのであれば、descriptionが見つからなければ本文から取得するといった処理も必要でしょう。

コンポーネントでの処理

コンポーネントでの処理はお好みなわけですが一応私の実装を載せておきます。ここでは、ReactMarkdownを使った場合の処理です。renderersでlinkと判断されたところをブログカードであるLinkCardコンポーネントに渡しています。

<ReactMarkdown 
    source={ article }
    // LinkCardコンポーネントがブログカード
    renderers={{ code: CodeBlock, link: LinkCard }}
/>

プロップスとしてブログカード情報を渡す方法がわからなかったので、グローバルステートに配列として取得した全てのブログカード情報を保持し、LinkCardコンポーネントに渡ってきたURL
と一致するブログカード情報があればブログカードとして、そうでなければただのaタグとしてレンダリングするようにしています。

import React ,{ useContext } from "react";
import { SiteContext } from "../pages/_app";

interface P {
    href: string,
    children: any,
}

const LinkCard: React.FC<P> = ({ href, children }) => {
    // @ts-ignore
    const { state } = useContext(SiteContext);
    const target = state.metas.find(meta => meta.url == href);

    if (target) {
        return (
            <a href={ href } target="_brank" className="grid grid-cols-5 bg-white rounded-md p-3">
                <div className="col-span-1 flex justify-start items-center">
                    <img src={ target.image ? target.image : "/noImage.svg" } alt={target.title} width="100px" className="object-contain"/>
                </div>
                <div className="col-span-4 flex flex-col justify-start">
                    <div className="text-xl font-bold text-black">{ target.title }</div>
                    <div className="text-gray-400 text-xs">{ target.description }</div>
                </div>
            </a>
        );
    }
    return (
        <a href={ href } target="_brank">{ children }</a>
    )
}

export { LinkCard }

もっとしっかりした実装はzennのこのあたりをみると良さそうです。

Discussion