Gatsby製のブログにリンクカードプラグイン自力で作って追加した

  • タグ:#Gatsby#雑記

  • ※2024/09/19さらに追記

    プラグイン処理開始前にランダムな時間スリープさせて疑似的に429エラーへ対応。

    SSL署名の問題か一部サイトで情報が取得できなかったのでrejectUnauthorized: falseを追加。 セキュリティ上非推奨の操作のようなので自己責任でお願いします。

    本当はopen-graph-scraperを使う方法に変えたかったが、ogImageの取得がうまくいかず断念。

    ※2024/09/19追記

    このプラグインを用いてビルドすると前述の429エラーが必ず出ます。 インターバル追加で対策したつもりでしたが、並列で全ページのビルド処理が走ってるらしく意味がないようです。 smartcropエラー対策でgatsby-plugin-sharpを更新してようやく気が付きました。

    数ページずつ追加更新していく分には問題ありませんが、Gatsby CleanやGatsby本体の更新などで環境一から作り直す際に、恐らくこの問題が起きます。

    エラーが出なくなるまでビルドし続ければすべてのページを正常に生成できるようですが、やってることはほぼDD○S。これは困った…

    ※2023/09/11追記

    同一サイトへのアクセス時に429エラーとなるためインターバル追加と、リンクカードに背景色指定。

    ※2023/07/11追記

    カードのタイトルが2行以上になる場合に3点リーダーとなるようにCSSを変更。


    というわけで、プラグインを作ってみました。

    目的は外部サービスを一切介さず、 gatsby develop コマンドで確認できる形でマークダウンからリンクカードを配置することでしたが、割と望んだ形になりました。

    そういう方向性の拡張もあるみたいですが、弄り方がわからんので自力で対応。


    注意点

    コードのみの提供です。プラグインとしての配布はしません。また、 gatsby-transformer-remark に依存する形で作成しており、gatsby-plugin-mdx などでの使用は未テストであり想定していません。

    cheerioによるスクレイピングでデータ取得しているので、使用の際は自己責任でお願いします。

    リンク先URLは先頭のh抜きで記載する必要があります。ただしh抜きで記述した場合でも、gatsbyでURLとして認識される記述はこのプラグインでは扱えません。少なくともサブドメインがwwwから始まる場合が該当するのを確認しています。

    Gatsbyだけでなんとかしている都合上、リンク先の内容に変更があった場合に反映は手動で行わないといけません。 静的サイトジェネレーターだから仕方ない。


    御託はいいから中身書け

    gatsby v5 ベースの Gatsby-starter-blog を使用し、プラグインのフォルダはsrc\pluginsの中に置く前提で書きます。 通常通り導入していれば必要なプラグインはすべて揃っているはずです。

    この条件以外での説明はしませんし、何か聞かれても答えません。

    markdown-link-card フォルダを作成し、その中に以下の2つを配置します。

    markdown-link-card.js
    const visit = require('unist-util-visit');
    const cheerio = require('cheerio');
    const axios = require('axios');
    const https = require('https');
    // ランダムな遅延を生成する関数(1〜10秒の範囲)
    function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
    }
    module.exports = async ({ markdownAST }, pluginOptions) => {
    const urlRegex = /\%card\(([^)]+)\)/;
    async function fetchMetadata(url) {
    //ランダム遅延追加(間接的なDDOS対策)
    const randomDelay = Math.floor(Math.random() * 10000) + 1000;
    await sleep(randomDelay);
    const completeURL = `h${url}`;
    const agent = new https.Agent({
    rejectUnauthorized: false // SSL証明書の検証を無効化(非推奨だが、trueだと動かないサイトがある)
    });
    try {
    const response = await axios.get(completeURL, { httpsAgent: agent });
    const $ = cheerio.load(response.data);
    const ogTitle = $('meta[property="og:title"]').attr('content');
    const title = $('title').text();
    const ogDescription = $('meta[property="og:description"]').attr('content');
    const Description = $('meta[property="description"]').attr('content');
    const ogImage = $('meta[property="og:image"]').attr('content');
    const Image = $('meta[property="image"]').attr('content');
    return {
    ogTitle: ogTitle || title,
    ogDescription: ogDescription || Description,
    ogImage: ogImage || Image
    };
    } catch (error) {
    console.error(`Error fetching metadata for ${url}:`, error);
    return {};
    }
    }
    function createCardNode(pluginOptions, metadata, url) {
    const { externalArticle } = pluginOptions;
    const completeURL = `h${url}`;
    const widgetDiv = {
    type: 'html',
    value: `
    <div class="link-card">
    <a href="${completeURL}" rel="noopener noreferrer nofollow" target="_blank">
    <div class="link-card-title">
    ${metadata.ogTitle || externalArticle.title}
    </div>
    <div class="link-card-description">
    ${metadata.ogDescription || externalArticle.description}
    </div>
    <div class="link-card-url">
    ${getDomainFromURL(completeURL)}
    </div>
    </a>
    <a class="link-card-image" href="${completeURL}" rel="noopener noreferrer nofollow" style="background-image: url(${metadata.ogImage || externalArticle.imageUrl})" target="_blank"></a>
    </div>
    `,
    };
    return widgetDiv;
    }
    function getDomainFromURL(url) {
    const domain = url.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/i)[1];
    return domain;
    }
    const promises = [];
    visit(markdownAST, 'text', (node, index, parent) => {
    if (urlRegex.test(node.value) && parent) {
    const url = node.value.match(urlRegex)[1];
    const promise = fetchMetadata(url)
    .then((metadata) => {
    const cardNode = createCardNode(pluginOptions, metadata, url);
    parent.children.splice(index, 1, cardNode);
    })
    .catch((error) => {
    console.error(`Error fetching metadata for ${url}:`, error);
    });
    promises.push(promise);
    }
    });
    await Promise.all(promises);
    return markdownAST;
    };
    package.json
    {
    "main": "markdown-link-card.js"
    }

    gatsby-config.js に今作成したプラグインを記述します。

    gatsby-config.js
    plugins: [
    {
    resolve: `gatsby-transformer-remark`,
    options: {
    plugins: [
    // highlight-start
    {
    resolve: './src/plugins/markdown-link-card',
    options: {
    externalArticle: { //ページ生成時に値が存在しなかった場合に使用されます。
    title: 'No title',
    description: 'No description',
    imageUrl: ' ', //リンク先にサムネイルがない場合に表示する画像を指定
    },
    },
    },
    // highlight-end
    ],
    },
    },
    ]

    imageUrlはページ作成時にしか参照されないことに注意してください。 ページ作成後にリンク先のサムネイルが削除されたりURLが変更になった場合、このプラグインではcssによって定義された背景色以外表示できません。

    cssでレイアウト作らないとカードにならないのでとりあえず以下を追加してください。

    .link-card{
    display: table;
    width: 100%;
    border: 2px solid rgba(200,200,200);
    border-radius: 5px;
    background-color: rgba(255,255,255);
    transition: border .2s;
    }
    .link-card:hover{
    border: 2px solid rgba(150,150,150);
    transition: border .5s;
    }
    .link-card>a{
    display: table-cell;
    padding: 16px;
    width: 100%;
    max-width:0;
    text-decoration: none;
    background-color: initial;
    }
    .link-card-title{
    margin-bottom: 8px;
    font-size: 1.2rem;
    font-weight: 700;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    }
    .link-card-description{
    height: 56px;
    margin-bottom: 4px;
    color: rgba(150,150,150);
    font-size: .75rem;
    word-break: break-all;
    }
    .link-card-url{
    color: rgba(80,80,80);
    font-size: .75rem;
    word-break: break-all;
    }
    .link-card-image{
    display: table-cell;
    min-width: 220px;
    height: 150px;
    text-decoration: none;
    background-position: 50%;
    background-size: cover;
    border-left: 1px solid rgba(200,200,200);
    border-radius: 0 3px 3px 0;
    }

    とにかくリンクカードを表示することを目的としているので、この例ではマウスホバーの枠線アニメーションくらいしか設定していません。 追加の装飾はお好みで行ってください。

    マークダウン本文内でURL先頭のhを抜いて以下のように記述すると、コードブロックの下にあるような見た目でリンクカードが表示されるはずです。

    %card(ttps://doranarasi.com)

    リンクカードクリックし、正常に該当ページに飛べていれば導入は完了です。お疲れ様でした。


    あとがき

    h抜きで記述しないと実装できなかったのが悔やまれる。既存のプラグインだとgatsby-transformer-remarkに依存しながらも、普通にURL記載で処理できるらしい。

    リンク先からサムネイルが消えた場合に404画像に切り替わるようにできていないのも心残り。自力ではここまでが限界でした。

    この投稿が参考になれば幸いです。